24 Commits
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
max 5eaf3547dc Random uint util 2024-11-16 14:54:34 +01:00
max d80e1177b9 vcs 2024-11-16 13:22:38 +01:00
max cf6cd080c6 Add component button 2024-11-13 21:11:50 +01:00
max 87ee6df46f Inspector tests 2024-11-02 21:59:35 +01:00
max 57b42d8daa Really basic component inspection 2024-10-22 23:54:36 +02:00
max 2a351f7b9d re-added moontools.ecs submodule 2024-10-22 21:14:21 +02:00
max 7225d13880 Optimized filter for transform entities with missing LocalToWorld components 2024-10-21 22:48:57 +02:00
max 567714a52d Profiler threading support, flame graph
Changed the profiler into a node based system for better data access, more overhead than the simple struct+depth info but can hold more detail and less post processing of data
Profiler now also profiles threads
Added some test profile tags
The profiler window now also has a FlameGraph
2024-10-20 23:17:41 +02:00
max 2c84e650d6 Combined transform systems
Profiler window calls count
Parallel transform system is now in the normal transform system
Removed unused parent system
2024-10-20 03:51:59 +02:00
max 82fe47f627 Profiler and LocalToWorldThreadedSystem
Added a simple profiler
Testing LocalToWorldSystem with Parallel execution for root nodes
2024-10-19 23:41:05 +02:00
max 6be63195f0 comment 2024-10-15 23:19:46 +02:00
max 9387bfa59c switched back to imgui dragdrop 2024-10-15 23:17:58 +02:00
max 86b54e1521 test with ecs source and target drag drops 2024-10-15 23:15:35 +02:00
max ba88432e77 unsafe drag drop test
- unsafe because of entity reference
- unsafe because of unsafe code lol
- fixes in parent/child system
2024-10-15 22:29:19 +02:00
max 5cc876fce9 tree hierachy and selection test 2024-10-15 21:47:23 +02:00
max 91b4f5fafb hierachy and parent child system updates
- started working on an editor hierarchy window
- testing filters
- testing parent child relations without system
- testing root component
- thinking about how to get all entities that are not a child, but also don't have an other identifying component
2024-10-15 00:41:45 +02:00
max 0d14a32726 added idenentity transform 2024-10-13 01:20:23 +02:00
max b3adef3a40 Naming, comments and local to world update 2024-10-13 01:05:46 +02:00
max 30deeca452 ecs and tranforms
added the moonworks ecs library.
testing it by setting up a transform and parent system.
2024-10-13 00:08:01 +02:00
24 changed files with 2587 additions and 20 deletions
+3
View File
@@ -16,3 +16,6 @@
[submodule "Nerfed.Runtime/Libraries/ImGui.NET"] [submodule "Nerfed.Runtime/Libraries/ImGui.NET"]
path = Nerfed.Runtime/Libraries/ImGui.NET path = Nerfed.Runtime/Libraries/ImGui.NET
url = https://github.com/ImGuiNET/ImGui.NET.git url = https://github.com/ImGuiNET/ImGui.NET.git
[submodule "Nerfed.Runtime/Libraries/MoonTools.ECS"]
path = Nerfed.Runtime/Libraries/MoonTools.ECS
url = https://github.com/MoonsideGames/MoonTools.ECS.git
+1
View File
@@ -4,6 +4,7 @@
<mapping directory="" vcs="Git" /> <mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/Nerfed.Runtime/Libraries/FAudio" vcs="Git" /> <mapping directory="$PROJECT_DIR$/Nerfed.Runtime/Libraries/FAudio" vcs="Git" />
<mapping directory="$PROJECT_DIR$/Nerfed.Runtime/Libraries/ImGui.NET" vcs="Git" /> <mapping directory="$PROJECT_DIR$/Nerfed.Runtime/Libraries/ImGui.NET" vcs="Git" />
<mapping directory="$PROJECT_DIR$/Nerfed.Runtime/Libraries/MoonTools.ECS" vcs="Git" />
<mapping directory="$PROJECT_DIR$/Nerfed.Runtime/Libraries/RefreshCS" vcs="Git" /> <mapping directory="$PROJECT_DIR$/Nerfed.Runtime/Libraries/RefreshCS" vcs="Git" />
<mapping directory="$PROJECT_DIR$/Nerfed.Runtime/Libraries/SDL2CS" vcs="Git" /> <mapping directory="$PROJECT_DIR$/Nerfed.Runtime/Libraries/SDL2CS" vcs="Git" />
<mapping directory="$PROJECT_DIR$/Nerfed.Runtime/Libraries/WellspringCS" vcs="Git" /> <mapping directory="$PROJECT_DIR$/Nerfed.Runtime/Libraries/WellspringCS" vcs="Git" />
@@ -0,0 +1,4 @@
namespace Nerfed.Editor.Components;
public readonly record struct SelectedInHierachy;
public readonly record struct ClickedInHierachy;
+6
View File
@@ -66,6 +66,12 @@ namespace Nerfed.Editor
UpdateDock(); UpdateDock();
ImGui.ShowDemoWindow(); ImGui.ShowDemoWindow();
foreach (MoonTools.ECS.System system in Program.editorSystems)
{
using ProfilerScope scope = new(system.GetType().Name);
system.Update(Engine.Timestep);
}
} }
} }
} }
+1
View File
@@ -9,6 +9,7 @@
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<Configurations>Debug;Test;Release</Configurations> <Configurations>Debug;Test;Release</Configurations>
<Platforms>x64</Platforms> <Platforms>x64</Platforms>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x64' "> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x64' ">
+69 -4
View File
@@ -1,9 +1,19 @@
using Nerfed.Runtime; using MoonTools.ECS;
using Nerfed.Editor.Systems;
using Nerfed.Runtime;
using Nerfed.Runtime.Components;
using Nerfed.Runtime.Systems;
using Nerfed.Runtime.Util;
using System.Numerics;
namespace Nerfed.Editor; namespace Nerfed.Editor;
internal class Program internal class Program
{ {
private static readonly World world = new World();
private static List<MoonTools.ECS.System> systems = new List<MoonTools.ECS.System>();
public static List<MoonTools.ECS.System> editorSystems = new List<MoonTools.ECS.System>();
private static void Main(string[] args) private static void Main(string[] args)
{ {
Engine.OnInitialize += HandleOnInitialize; Engine.OnInitialize += HandleOnInitialize;
@@ -16,6 +26,45 @@ internal class Program
private static void HandleOnInitialize() private static void HandleOnInitialize()
{ {
//systems.Add(new ParentSystem(world));
systems.Add(new LocalToWorldSystem(world));
editorSystems.Add(new EditorProfilerWindow(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");
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;
}
}
// Open project. // Open project.
// Setip EditorGui. // Setip EditorGui.
EditorGui.Initialize(); EditorGui.Initialize();
@@ -23,16 +72,32 @@ internal class Program
private static void HandleOnUpdate() private static void HandleOnUpdate()
{ {
// Editor Update. foreach (MoonTools.ECS.System system in systems)
EditorGui.Update(); {
using ProfilerScope scope = new(system.GetType().Name);
system.Update(Engine.Timestep);
}
using (new ProfilerScope("EditorGui.Update"))
{
// Editor Update.
EditorGui.Update();
}
// Try Catch UserCode Update. // Try Catch UserCode Update.
using (new ProfilerScope("world.FinishUpdate"))
{
world.FinishUpdate();
}
} }
private static void HandleOnRender() private static void HandleOnRender()
{ {
EditorGui.Render(); using (new ProfilerScope("EditorGui.Render"))
{
EditorGui.Render();
}
} }
private static void HandleOnQuit() private static void HandleOnQuit()
@@ -0,0 +1,190 @@
using ImGuiNET;
using MoonTools.ECS;
using Nerfed.Editor.Components;
using Nerfed.Runtime;
using Nerfed.Runtime.Components;
using Nerfed.Runtime.Util;
namespace Nerfed.Editor.Systems
{
// Window that draws entities.
internal class EditorHierarchyWindow : MoonTools.ECS.System
{
private const ImGuiTreeNodeFlags baseFlags = ImGuiTreeNodeFlags.OpenOnArrow | ImGuiTreeNodeFlags.OpenOnDoubleClick | ImGuiTreeNodeFlags.SpanAvailWidth;
//private readonly Filter rootEntitiesWithTransformFilter;
//private readonly Filter rootEntitiesFilterBroken;
private readonly Filter rootEntitiesFilter;
private readonly EditorHierachySelectionSystem hierachySelectionSystem;
public EditorHierarchyWindow(World world) : base(world)
{
//rootEntitiesWithTransformFilter = FilterBuilder.Include<LocalTransform>().Exclude<Child>().Build();
// TODO: this doesn't work.
//rootEntitiesFilterBroken = FilterBuilder.Exclude<Child>().Build();
// Maybe the parent/child functions should add a root component when not being a child.
rootEntitiesFilter = FilterBuilder.Include<Root>().Build();
// Maybe instead of a root, if we need a component that is always on an entity and has some use we could create something like a VersionComponent which only hold an int.
// The version would update each time something changes on the entity.
// Or a EditorComponent, just a component that always gets added when in editor mode.
hierachySelectionSystem = new EditorHierachySelectionSystem(world);
}
public override void Update(TimeSpan delta)
{
ImGui.Begin("Hierarchy");
ImGuiTreeNodeFlags flags = baseFlags;
flags |= ImGuiTreeNodeFlags.DefaultOpen;
if (ImGui.TreeNodeEx("World", flags))
{
if (ImGui.BeginDragDropTarget())
{
unsafe
{
ImGuiPayloadPtr payload = ImGui.AcceptDragDropPayload($"{nameof(EditorHierarchyWindow)}");
if (payload.NativePtr != null)
{
Entity* data = (Entity*)payload.Data;
Entity child = data[0];
Log.Info($"Dropped {child.ID}");
Transform.RemoveParent(World, child);
}
}
ImGui.EndDragDropTarget();
}
//foreach (Entity entity in rootEntitiesWithTransformFilter.Entities)
//{
// DrawEntityAndChildren(entity);
//}
foreach (Entity entity in rootEntitiesFilter.Entities)
{
DrawEntityAndChildren(entity);
}
ImGui.TreePop();
}
ImGui.End();
hierachySelectionSystem.Update(delta);
}
private void DrawEntityAndChildren(in Entity entity)
{
ImGuiTreeNodeFlags flags = baseFlags;
if (!World.HasInRelation<ChildParentRelation>(entity))
{
flags |= ImGuiTreeNodeFlags.Leaf;
}
if (World.Has<SelectedInHierachy>(entity))
{
flags |= ImGuiTreeNodeFlags.Selected;
}
if (ImGui.TreeNodeEx($"{entity.ID} | {GetTag(entity)}", flags))
{
// TODO: fix selection, look at ImGui 1.91, https://github.com/ocornut/imgui/wiki/Multi-Select
// Selection.
if (ImGui.IsItemClicked() && !ImGui.IsItemToggledOpen())
{
World.Set(entity, new ClickedInHierachy());
}
// Drag and drop.
if (ImGui.BeginDragDropSource())
{
unsafe
{
fixed (Entity* payload = &entity)
{
ImGui.SetDragDropPayload($"{nameof(EditorHierarchyWindow)}", (IntPtr)payload, (uint)sizeof(Entity));
}
}
ImGui.EndDragDropSource();
}
if (ImGui.BeginDragDropTarget())
{
unsafe
{
ImGuiPayloadPtr payload = ImGui.AcceptDragDropPayload($"{nameof(EditorHierarchyWindow)}");
if (payload.NativePtr != null)
{
Entity ent = *(Entity*)payload.Data;
Log.Info($"Dropped {ent.ID}");
Transform.SetParent(World, ent, entity);
}
}
ImGui.EndDragDropTarget();
}
// Draw children.
ReverseSpanEnumerator<Entity> childEntities = World.InRelations<ChildParentRelation>(entity);
foreach (Entity childEntity in childEntities)
{
DrawEntityAndChildren(childEntity);
}
ImGui.TreePop();
}
}
// System for handling the selected entities in the hierachy.
private class EditorHierachySelectionSystem : MoonTools.ECS.System
{
private readonly Filter selectedEntities;
private readonly Filter clickedEntities;
public EditorHierachySelectionSystem(World world) : base(world)
{
selectedEntities = FilterBuilder.Include<SelectedInHierachy>().Build();
clickedEntities = FilterBuilder.Include<ClickedInHierachy>().Build();
}
public override void Update(TimeSpan delta)
{
ImGuiIOPtr io = ImGui.GetIO();
if (!clickedEntities.Empty && !io.KeyCtrl)
{
foreach (Entity entity in selectedEntities.Entities)
{
Remove<SelectedInHierachy>(entity);
}
}
foreach (Entity entity in clickedEntities.Entities)
{
// Unselect.
if (Has<SelectedInHierachy>(entity))
{
Remove<SelectedInHierachy>(entity);
}
// Select.
else
{
Set(entity, new SelectedInHierachy());
}
Remove<ClickedInHierachy>(entity);
}
}
}
}
}
@@ -0,0 +1,100 @@
using System.Numerics;
using ImGuiNET;
using MoonTools.ECS;
using Nerfed.Editor.Components;
using Nerfed.Runtime.Serialization;
#if DEBUG
namespace Nerfed.Editor.Systems
{
// Window that draws entities.
internal class EditorInspectorWindow : MoonTools.ECS.DebugSystem
{
private readonly Filter selectedEntityFilter;
public EditorInspectorWindow(World world) : base(world)
{
selectedEntityFilter = FilterBuilder.Include<SelectedInHierachy>().Build();
}
public override void Update(TimeSpan delta)
{
ImGui.Begin("Inspector");
foreach (Entity entity in selectedEntityFilter.Entities)
{
DrawEntityComponents(entity);
}
ImGui.End();
}
private void DrawEntityComponents(Entity entity)
{
World.ComponentTypeEnumerator componentTypes = World.Debug_GetAllComponentTypes(entity);
// Add button of all types that we can add. Also filter out types we already have.
List<Type> componentTypesToAdd = ComponentHelper.AddComponentByType.Keys.ToList();
foreach (Type componentType in componentTypes)
{
componentTypesToAdd.Remove(componentType);
}
const string popupId = "AddComponentPopup";
if (ImGui.Button("Add Component"))
{
ImGui.OpenPopup(popupId);
}
if (ImGui.BeginPopup(popupId))
{
foreach (Type componentType in componentTypesToAdd)
{
if (ImGui.Selectable(componentType.Name))
{
if (ComponentHelper.AddComponentByType.TryGetValue(componentType, out Action<World, Entity> componentSetter))
{
componentSetter.Invoke(World, entity);
}
}
}
ImGui.EndPopup();
}
ImGui.Dummy(new Vector2(16, 16));
ImGui.Text("ComponentInspectorByType");
foreach (Type componentType in componentTypes)
{
if (ComponentHelper.ComponentInspectorByType.TryGetValue(componentType, out Action<World, Entity> componentInspector))
{
componentInspector(World, entity);
}
else if (ComponentHelper.GetComponentByType.TryGetValue(componentType, out Func<World, Entity, ValueType> componentGetter))
{
ValueType component = componentGetter.Invoke(World, entity);
ImGui.Text(component.ToString());
}
else
{
ImGui.Text(componentType.Name);
}
ImGui.Separator();
}
ImGui.Dummy(new Vector2(16, 16));
// ImGui.Text("Reflection");
// foreach (Type component in componentTypes)
// {
// System.Reflection.MethodInfo getMethodInfo = typeof(World).GetMethod("Get");
// System.Reflection.MethodInfo getComponentMethod = getMethodInfo.MakeGenericMethod(component);
// object result = getComponentMethod.Invoke(World, [entity]);
//
// // process here
// ImGui.Text(result.ToString());
// }
}
}
}
#endif
@@ -0,0 +1,343 @@
using ImGuiNET;
using MoonTools.ECS;
using Nerfed.Runtime;
using System.Numerics;
namespace Nerfed.Editor.Systems
{
internal class EditorProfilerWindow : MoonTools.ECS.System
{
const ImGuiTableFlags tableFlags = ImGuiTableFlags.Resizable | ImGuiTableFlags.BordersOuter | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.ScrollY | ImGuiTableFlags.ScrollX;
const ImGuiTreeNodeFlags treeNodeFlags = ImGuiTreeNodeFlags.SpanAllColumns;
const ImGuiTreeNodeFlags treeNodeLeafFlags = ImGuiTreeNodeFlags.SpanAllColumns | ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.NoTreePushOnOpen;
private int selectedFrame = 0;
private int previousSelectedFrame = -1;
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)
{
}
public override void Update(TimeSpan delta)
{
if (Profiler.Frames.Count <= 0)
{
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 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 = frameSnapshot.Count - 1;
}
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();
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();
}
DrawThreadRolling(orderedThreadRollingData);
DrawHierachy(frame);
ImGui.SameLine();
DrawCombined(orderedCombinedData);
ImGui.End();
}
private static void DrawHierachy(Profiler.Frame frame)
{
if(frame == null)
{
return;
}
ImGui.BeginChild("Hierachy", new Vector2(150, 0), ImGuiChildFlags.ResizeX);
if (ImGui.BeginTable("ProfilerData", 8, tableFlags, new Vector2(0, 0)))
{
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();
foreach (Profiler.ScopeNode node in frame.RootNodes)
{
DrawHierachyNode(node);
}
ImGui.EndTable();
}
ImGui.EndChild();
}
private static void DrawHierachyNode(Profiler.ScopeNode node)
{
ImGui.TableNextRow();
ImGui.TableNextColumn();
bool isOpen = false;
bool isLeaf = node.Children.Count == 0;
if (isLeaf) {
ImGui.TreeNodeEx(node.Label, treeNodeLeafFlags);
}
else
{
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)
{
for (int i = 0; i < node.Children.Count; i++)
{
DrawHierachyNode(node.Children[i]);
}
ImGui.TreePop();
}
}
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 Vector2(0, 0));
if (ImGui.BeginTable("ProfilerCombinedData", 8, tableFlags, new Vector2(0, 0)))
{
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, double selfMs, uint calls, double avgMs, double p95Ms, long allocBytes, long selfAllocBytes)> combinedData in orderedCombinedData)
{
ImGui.TableNextRow();
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Key}");
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();
}
ImGui.EndChild();
}
private static IOrderedEnumerable<KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms, long allocBytes, long selfAllocBytes)>> CalculateCombinedData(Profiler.Frame frame)
{
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)
{
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 IOrderedEnumerable<KeyValuePair<int, Profiler.RollingThreadMetrics>> CalculateThreadRollingData()
{
IReadOnlyDictionary<int, Profiler.RollingThreadMetrics> rollingData = Profiler.GetRollingThreadMetricsSnapshot();
return rollingData.OrderByDescending(x => x.Value.P95Ms);
}
private static void DrawThreadRolling(in IOrderedEnumerable<KeyValuePair<int, Profiler.RollingThreadMetrics>> orderedThreadRollingData)
{
if (orderedThreadRollingData == null)
{
return;
}
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);
}
}
}
@@ -0,0 +1,6 @@
using System.Numerics;
namespace Nerfed.Runtime.Components
{
public readonly record struct LocalToWorld(Matrix4x4 localToWorldMatrix);
}
@@ -0,0 +1,9 @@
using System.Numerics;
namespace Nerfed.Runtime.Components
{
public readonly record struct LocalTransform(Vector3 position, Quaternion rotation, Vector3 scale)
{
public static readonly LocalTransform Identity = new(Vector3.Zero, Quaternion.Identity, Vector3.One);
}
}
+11
View File
@@ -0,0 +1,11 @@
using MoonTools.ECS;
namespace Nerfed.Runtime.Components
{
public readonly record struct Root;
//public readonly record struct Parent;
//public readonly record struct PreviousParent;
public readonly record struct Child;
// Describes a relation from the child to the parent.
public readonly record struct ChildParentRelation;
}
+4
View File
@@ -0,0 +1,4 @@
namespace Nerfed.Runtime.Components
{
public readonly record struct Test();
}
+14
View File
@@ -111,10 +111,14 @@ public static class Engine
private static void Tick() private static void Tick()
{ {
Profiler.BeginFrame();
AdvanceElapsedTime(); AdvanceElapsedTime();
if (framerateCapped) if (framerateCapped)
{ {
Profiler.BeginSample("framerateCapped");
/* We want to wait until the framerate cap, /* We want to wait until the framerate cap,
* but we don't want to oversleep. Requesting repeated 1ms sleeps and * but we don't want to oversleep. Requesting repeated 1ms sleeps and
* seeing how long we actually slept for lets us estimate the worst case * seeing how long we actually slept for lets us estimate the worst case
@@ -137,6 +141,8 @@ public static class Engine
Thread.SpinWait(1); Thread.SpinWait(1);
AdvanceElapsedTime(); AdvanceElapsedTime();
} }
Profiler.EndSample();
} }
// Do not let any step take longer than our maximum. // Do not let any step take longer than our maximum.
@@ -149,6 +155,7 @@ public static class Engine
{ {
while (accumulatedUpdateTime >= Timestep) while (accumulatedUpdateTime >= Timestep)
{ {
Profiler.BeginSample("Update");
Keyboard.Update(); Keyboard.Update();
Mouse.Update(); Mouse.Update();
GamePad.Update(); GamePad.Update();
@@ -156,19 +163,26 @@ public static class Engine
ProcessSDLEvents(); ProcessSDLEvents();
// Tick game here... // Tick game here...
Profiler.BeginSample("OnUpdate");
OnUpdate?.Invoke(); OnUpdate?.Invoke();
Profiler.EndSample();
AudioDevice.WakeThread(); AudioDevice.WakeThread();
accumulatedUpdateTime -= Timestep; accumulatedUpdateTime -= Timestep;
Profiler.EndSample();
} }
double alpha = accumulatedUpdateTime / Timestep; double alpha = accumulatedUpdateTime / Timestep;
// Render here.. // Render here..
Profiler.BeginSample("OnRender");
OnRender?.Invoke(); OnRender?.Invoke();
Profiler.EndSample();
accumulatedDrawTime -= framerateCapTimeSpan; accumulatedDrawTime -= framerateCapTimeSpan;
} }
Profiler.EndFrame();
} }
private static TimeSpan AdvanceElapsedTime() private static TimeSpan AdvanceElapsedTime()
+7 -6
View File
@@ -32,12 +32,13 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Compile Include="Libraries\SDL2CS\src\SDL2.cs"/> <Compile Include="Libraries\FAudio\csharp\FAudio.cs" />
<Compile Include="Libraries\RefreshCS\RefreshCS.cs"/> <Compile Include="Libraries\ImGui.NET\src\ImGui.NET\**\*.cs" />
<Compile Include="Libraries\FAudio\csharp\FAudio.cs"/> <Compile Include="Libraries\MoonTools.ECS\src\**\*.cs" />
<Compile Include="Libraries\WellspringCS\WellspringCS.cs"/> <Compile Include="Libraries\RefreshCS\RefreshCS.cs" />
<Compile Include="Libraries\dav1dfile\csharp\dav1dfile.cs"/> <Compile Include="Libraries\SDL2CS\src\SDL2.cs" />
<Compile Include="Libraries\ImGui.NET\src\ImGui.NET\**\*.cs"/> <Compile Include="Libraries\WellspringCS\WellspringCS.cs" />
<Compile Include="Libraries\dav1dfile\csharp\dav1dfile.cs" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+791 -8
View File
@@ -1,29 +1,812 @@
using System.Diagnostics; using System.Collections.Concurrent;
using System.Reflection; using System.Diagnostics;
using System.Runtime.CompilerServices;
namespace Nerfed.Runtime; namespace Nerfed.Runtime;
public struct ProfilerScope : IDisposable public struct ProfilerScope : IDisposable
{ {
public ProfilerScope(string label) { public ProfilerScope(string label)
{
Profiler.BeginSample(label); Profiler.BeginSample(label);
} }
public void Dispose() { public ProfilerScope(string label, string category, ulong tagMask = 0)
{
Profiler.BeginSample(label, category, tagMask);
}
public void Dispose()
{
Profiler.EndSample(); Profiler.EndSample();
} }
} }
public static class Profiler public static class Profiler
{ {
[Conditional("PROFILING")] public enum CaptureMode
public static void BeginSample(string label) { {
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; }
public IReadOnlyList<ScopeNode> RootNodes => rootNodes;
public IReadOnlyDictionary<string, LabelMetrics> LabelMetrics => labelMetrics;
public IReadOnlyDictionary<string, LabelMetrics> CategoryMetrics => categoryMetrics;
public IReadOnlyDictionary<int, ThreadMetrics> ThreadMetrics => threadMetrics;
// 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()
{
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]);
}
for (int i = 0; i < rootNodes.Count; i++)
{
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; 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()
{
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)(Math.Max(0, EndTime - StartTime))) * 1000 / Stopwatch.Frequency;
}
public double SelfMilliseconds()
{
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;
public static readonly BoundedQueue<Frame> Frames = new(maxFrames);
// 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")] [Conditional("PROFILING")]
public static void EndSample() { public static void BeginFrame()
{
if (!IsRecording)
{
return;
}
if (currentFrame != null)
{
FinalizeCurrentFrame();
}
currentFrame = RentFrame(frameCount);
}
[Conditional("PROFILING")]
public static void EndFrame()
{
if (!IsRecording)
{
return;
}
FinalizeCurrentFrame();
}
[Conditional("PROFILING")]
public static void BeginSample(string label)
{
BeginSample(label, DefaultCategory, 0);
}
[Conditional("PROFILING")]
public static void BeginSample(string label, string category, ulong tagMask = 0)
{
if (!IsRecording || currentFrame == null)
{
return;
}
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)
{
int threadId = state.ThreadId;
ScopeNode rootScopeNode = RentNode(GetThreadRootLabel(threadId), DefaultCategory, 0, threadId, null);
scopes.Push(rootScopeNode);
frame.AddRootNode(rootScopeNode);
}
ScopeNode newScope = scopes.Peek().AddChild(label, category, tagMask);
scopes.Push(newScope);
newScope.NoteSetupOverhead();
}
[Conditional("PROFILING")]
public static void EndSample()
{
if (!IsRecording || currentFrame == null)
{
return;
}
ThreadProfilerState state = threadStates.Value;
if (state.CaptureDecisions.Count == 0)
{
return;
}
bool captured = state.CaptureDecisions.Pop();
if (!captured)
{
return;
}
Stack<ScopeNode> scopes = state.Scopes;
if (scopes.Count > 1)
{
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);
}
}
} }
} }
+556
View File
@@ -0,0 +1,556 @@
using ImGuiNET;
using System.Numerics;
namespace Nerfed.Runtime;
public static class ProfilerVisualizer
{
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;
}
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;
}
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 origin = ImGui.GetCursorScreenPos();
Vector2 viewSize = ImGui.GetContentRegionAvail();
float canvasWidth = Math.Max(1f, viewSize.X);
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);
float clipMinX = origin.X;
float clipMaxX = origin.X + canvasWidth;
float clipMinY = origin.Y;
float clipMaxY = origin.Y + Math.Max(1f, ImGui.GetWindowHeight());
DrawTimelineHeader(drawList, origin, canvasWidth, timelineStartTicks, visibleStartTicks, visibleDurationTicks, textColor, borderColor);
HoverEntry? hovered = null;
for (int frameIndex = visibleStartIndex; frameIndex <= visibleEndIndex; frameIndex++)
{
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);
if (frameEndX < clipMinX || frameStartX > clipMaxX)
{
continue;
}
bool isSelectedFrame = frameIndex == state.SelectedFrameIndex;
uint frameShadeColor = (frameIndex & 1) == 0 ? frameBgColor : frameBgHoveredColor;
if (isSelectedFrame)
{
frameShadeColor = LerpColor(headerColor, headerHoveredColor, 0.45f);
}
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 HandleZoomAndPan(TimelineState state, double timelineStartTicks, double timelineDurationTicks, double visibleDurationTicks, float originX, float canvasWidth, ref bool userNavigated)
{
ImGuiIOPtr io = ImGui.GetIO();
if (Math.Abs(io.MouseWheel) < float.Epsilon)
{
return;
}
if (!io.KeyCtrl && !io.KeyShift)
{
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
{
// Shift + scroll: horizontal pan
state.PanTicks -= wheel * (visibleDurationTicks * 0.10d);
state.FollowLatest = false;
userNavigated = true;
}
}
private static int FindFrameIndexByMouseX(IReadOnlyList<Profiler.Frame> frames, int visibleStartIndex, int visibleEndIndex, double visibleStartTicks, double visibleDurationTicks, float originX, float width, float mouseX)
{
double t = Math.Clamp((mouseX - originX) / Math.Max(1f, width), 0f, 1f);
double timelineTicks = visibleStartTicks + (visibleDurationTicks * t);
for (int i = visibleStartIndex; i <= visibleEndIndex; i++)
{
Profiler.Frame frame = frames[i];
if (timelineTicks >= frame.StartTime && timelineTicks <= frame.EndTime)
{
return i;
}
}
return -1;
}
private static void DrawTimelineHeader(ImDrawListPtr drawList, Vector2 origin, float width, double timelineStartTicks, double visibleStartTicks, double visibleDurationTicks, uint textColor, uint borderColor)
{
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)));
}
}
@@ -0,0 +1,68 @@
using System.Numerics;
using ImGuiNET;
using MoonTools.ECS;
using Nerfed.Runtime.Components;
namespace Nerfed.Runtime.Serialization;
public static class ComponentHelper
{
// Auto generate this.
public static readonly Dictionary<Type, Func<World, Entity, ValueType>> GetComponentByType = new()
{
{ typeof(LocalTransform), (world, entity) => world.Get<LocalTransform>(entity) },
{ typeof(Root), (world, entity) => world.Get<Root>(entity) },
};
// Auto generate this.
public static readonly Dictionary<Type, Action<World, Entity, ValueType>> SetComponentByType = new()
{
{ typeof(LocalTransform), (world, entity, component) => world.Set(entity, (LocalTransform)component) },
{ typeof(Root), (world, entity, component) => world.Set(entity, (Root)component) },
};
// Auto generate this, but it should only contain user assignable components (so something like 'root' should be excluded).
// Maybe use an attribute for this.
public static readonly Dictionary<Type, Action<World, Entity>> AddComponentByType = new()
{
{ typeof(LocalTransform), (world, entity) => world.Set(entity, LocalTransform.Identity) },
};
// Auto generate this, but also keep the option for 'custom inspectors'.
// Maybe via attribute?
public static readonly Dictionary<Type, Action<World, Entity>> ComponentInspectorByType = new()
{
{
typeof(LocalTransform), (world, entity) =>
{
(Vector3 position, Quaternion rotation, Vector3 scale) = world.Get<LocalTransform>(entity);
Vector3 eulerAngles = MathEx.ToEulerAngles(rotation);
eulerAngles = new Vector3(float.RadiansToDegrees(eulerAngles.X), float.RadiansToDegrees(eulerAngles.Y), float.RadiansToDegrees(eulerAngles.Z));
bool isDirty = false;
ImGui.BeginGroup();
ImGui.Text($"{nameof(LocalTransform)}");
isDirty |= ImGui.DragFloat3("Position", ref position, 0.2f, float.MinValue, float.MaxValue /*, "%f0 m" */); // TODO: right format.
isDirty |= ImGui.DragFloat3("Rotation", ref eulerAngles);
isDirty |= ImGui.DragFloat3("Scale", ref scale);
ImGui.EndGroup();
if (!isDirty)
{
return;
}
eulerAngles = new Vector3(float.DegreesToRadians(eulerAngles.X), float.DegreesToRadians(eulerAngles.Y), float.DegreesToRadians(eulerAngles.Z));
world.Set(entity, new LocalTransform(position, MathEx.ToQuaternion(eulerAngles), scale));
}
},
{
typeof(Root), (world, entity) =>
{
ImGui.BeginGroup();
ImGui.Text($"{nameof(Root)}");
ImGui.EndGroup();
}
},
};
}
@@ -0,0 +1,95 @@
using MoonTools.ECS;
using Nerfed.Runtime.Components;
using Nerfed.Runtime.Util;
using System.Numerics;
// TODO:
// Explore if having a WorldTransform and LocalTransfom component each holding position, rotation, scale values and the matricies is useful.
// Often you need to either get or set these values.
// If so, we probably need a utility funciton to do so. Since changing these values means that we need to update all the related data + children as well.
// TODO:
// When modifying transform all the children need to be updated as well.
namespace Nerfed.Runtime.Systems
{
public class LocalToWorldSystem : MoonTools.ECS.System
{
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;
public LocalToWorldSystem(World world) : base(world)
{
rootEntitiesFilter = FilterBuilder.Include<LocalTransform>().Exclude<Child>().Build();
if (useParallelFor)
{
entitiesWithoutLocalToWorldFilter = FilterBuilder.Include<LocalTransform>().Exclude<LocalToWorld>().Build();
updateWorldTransform = UpdateWorldTransformByIndex;
}
}
public override void Update(TimeSpan delta)
{
if (rootEntitiesFilter.Empty)
{
return;
}
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.
foreach (Entity entity in entitiesWithoutLocalToWorldFilter.Entities) {
Set(entity, new LocalToWorld(Matrix4x4.Identity));
}
Profiler.EndSample();
Profiler.BeginSample("ParallelFor.LocalToWorldUpdate");
// 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");
UpdateWorldTransform(entity, Matrix4x4.Identity);
Profiler.EndSample();
}
}
}
private void UpdateWorldTransformByIndex(int entityFilterIndex)
{
Profiler.BeginSample("UpdateWorldTransformByIndex");
Entity entity = rootEntitiesFilter.NthEntity(entityFilterIndex);
UpdateWorldTransform(entity, Matrix4x4.Identity);
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);
Set(entity, localToWorld);
}
ReverseSpanEnumerator<Entity> childEntities = World.InRelations<ChildParentRelation>(entity);
foreach (Entity childEntity in childEntities)
{
UpdateWorldTransform(childEntity, localToWorldMatrix);
}
}
}
}
+145
View File
@@ -0,0 +1,145 @@
using System.Collections;
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;
public BoundedQueue(int maxSize)
{
this.maxSize = maxSize;
queue = new Queue<T>(maxSize);
}
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)
{
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()
{
T[] snapshot;
lock (syncLock)
{
snapshot = queue.ToArray();
}
return ((IEnumerable<T>)snapshot).GetEnumerator();
}
IEnumerator IEnumerable.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 Capacity => maxSize;
public bool IsSynchronized => true;
public object SyncRoot => syncLock;
int IReadOnlyCollection<T>.Count
{
get
{
lock (syncLock)
{
return queue.Count;
}
}
}
}
+49
View File
@@ -1,3 +1,5 @@
using System.Numerics;
namespace Nerfed.Runtime; namespace Nerfed.Runtime;
public static class MathEx public static class MathEx
@@ -17,4 +19,51 @@ public static class MathEx
public static float Remap(float value, float oldMin, float oldMax, float newMin, float newMax) { public static float Remap(float value, float oldMin, float oldMax, float newMin, float newMax) {
return (value - oldMin) / (oldMax - oldMin) * (newMax - newMin) + newMin; return (value - oldMin) / (oldMax - oldMin) * (newMax - newMin) + newMin;
} }
// https://stackoverflow.com/questions/70462758/c-sharp-how-to-convert-quaternions-to-euler-angles-xyz
public static Quaternion ToQuaternion(Vector3 v)
{
float cy = (float)Math.Cos(v.Z * 0.5);
float sy = (float)Math.Sin(v.Z * 0.5);
float cp = (float)Math.Cos(v.Y * 0.5);
float sp = (float)Math.Sin(v.Y * 0.5);
float cr = (float)Math.Cos(v.X * 0.5);
float sr = (float)Math.Sin(v.X * 0.5);
return new Quaternion
{
W = (cr * cp * cy + sr * sp * sy),
X = (sr * cp * cy - cr * sp * sy),
Y = (cr * sp * cy + sr * cp * sy),
Z = (cr * cp * sy - sr * sp * cy),
};
}
public static Vector3 ToEulerAngles(Quaternion q)
{
Vector3 angles = new();
// roll / x
double sinrCosp = 2 * (q.W * q.X + q.Y * q.Z);
double cosrCosp = 1 - 2 * (q.X * q.X + q.Y * q.Y);
angles.X = (float)Math.Atan2(sinrCosp, cosrCosp);
// pitch / y
double sinp = 2 * (q.W * q.Y - q.Z * q.X);
if (Math.Abs(sinp) >= 1)
{
angles.Y = (float)Math.CopySign(Math.PI / 2, sinp);
}
else
{
angles.Y = (float)Math.Asin(sinp);
}
// yaw / z
double sinyCosp = 2 * (q.W * q.Z + q.X * q.Y);
double cosyCosp = 1 - 2 * (q.Y * q.Y + q.Z * q.Z);
angles.Z = (float)Math.Atan2(sinyCosp, cosyCosp);
return angles;
}
} }
+13
View File
@@ -0,0 +1,13 @@
using System.Security.Cryptography;
namespace Nerfed.Runtime.Util;
public static class RandomId
{
public static uint GenerateSecureRandomUInt()
{
byte[] buffer = new byte[4];
RandomNumberGenerator.Fill(buffer);
return BitConverter.ToUInt32(buffer, 0);
}
}
+99
View File
@@ -0,0 +1,99 @@
using MoonTools.ECS;
using Nerfed.Runtime.Components;
using System.Collections.Generic;
using System.Numerics;
namespace Nerfed.Runtime.Util
{
// https://github.com/needle-mirror/com.unity.entities/blob/master/Unity.Transforms/TransformHelpers.cs
public static class Transform
{
public static Vector3 Forward(in this Matrix4x4 matrix) => new Vector3(matrix.M31, matrix.M32, matrix.M33);
public static Vector3 Back(in this Matrix4x4 matrix) => -matrix.Forward();
public static Vector3 Up(in this Matrix4x4 matrix) => new Vector3(matrix.M21, matrix.M22, matrix.M23);
public static Vector3 Down(in this Matrix4x4 matrix) => -matrix.Up();
public static Vector3 Right(in this Matrix4x4 matrix) => new Vector3(matrix.M11, matrix.M12, matrix.M13);
public static Vector3 Left(in this Matrix4x4 matrix) => -matrix.Right();
//public static Vector3 Translation(in this Matrix4x4 matrix) => new Vector3();
//public static Quaternion Rotation(in this Matrix4x4 matrix) => new Quaternion();
public static Matrix4x4 TRS(in this LocalTransform localTransform)
{
return Matrix4x4.CreateScale(localTransform.scale) *
Matrix4x4.CreateFromQuaternion(localTransform.rotation) *
Matrix4x4.CreateTranslation(localTransform.position);
}
// Sets the parent child relation and adds a child component.
// Relation goes from child to parent.
public static void SetParent(in World world, in Entity child, in Entity parent)
{
RemoveParent(world, child);
world.Relate(child, parent, new ChildParentRelation());
world.Set(child, new Child());
world.Remove<Root>(child);
return;
}
// Removes any parent child relation ship, thus making it a 'root' object.
public static void RemoveParent(in World world, in Entity child)
{
if (!world.HasOutRelation<ChildParentRelation>(child))
{
return;
}
Entity parent = world.OutRelationSingleton<ChildParentRelation>(child);
// TODO: Check if Unrelate all also unrelates incomming relations..?
world.Unrelate<ChildParentRelation>(child, parent);
world.Remove<Child>(child);
world.Set(child, new Root());
}
public static Entity CreateBaseEntity(this World world, string tag = "")
{
Entity entity = world.CreateEntity(tag);
world.Set(entity, new Root());
return entity;
}
// Force update the transform data of an entity (and children).
// Useful for when you need precise up to date transform data.
public static void ForceUpdateLocalToWorld(in World world, in Entity entity)
{
Matrix4x4 parentLocalToWorldMatrix = Matrix4x4.Identity;
if (world.HasOutRelation<ChildParentRelation>(entity)) {
Entity parent = world.OutRelationSingleton<ChildParentRelation>(entity);
if (world.Has<LocalToWorld>(parent))
{
parentLocalToWorldMatrix = world.Get<LocalToWorld>(parent).localToWorldMatrix;
}
}
ForceUpdateLocalToWorld(world, entity, parentLocalToWorldMatrix);
}
private static void ForceUpdateLocalToWorld(in World world, in Entity entity, Matrix4x4 localToWorldMatrix)
{
if (world.Has<LocalTransform>(entity))
{
LocalTransform localTransform = world.Get<LocalTransform>(entity);
localToWorldMatrix = Matrix4x4.Multiply(localToWorldMatrix, localTransform.TRS());
LocalToWorld localToWorld = new(localToWorldMatrix);
world.Set(entity, localToWorld);
Log.Info($"Entity {entity} | local position {localTransform.position} | world position {localToWorldMatrix.Translation}");
}
ReverseSpanEnumerator<Entity> childEntities = world.InRelations<ChildParentRelation>(entity);
foreach (Entity childEntity in childEntities)
{
ForceUpdateLocalToWorld(world, childEntity, localToWorldMatrix);
}
}
}
}