railroader-setons-special-s.../mcp-tools/UnityMarkerProfiler.cs
seton 5e18cb84a1 MCP: localhost agent server with reloadable S3.Mcp.Tools plugin pack
Loopback MCP host with observe/control/develop gates and a hot-reload
tools DLL. Default token is local-dev only. Leave the module off unless
you are driving S3 from an agent.
2026-09-11 15:19:24 -04:00

197 lines
7 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using S3.Modules.Profiler;
using Unity.Profiling;
using Unity.Profiling.LowLevel.Unsafe;
using UnityEngine;
namespace S3.Mcp.Tools;
/// <summary>Capture-scoped Unity PlayerLoop and subsystem marker recorders.</summary>
internal static class UnityMarkerProfiler
{
sealed class Entry
{
public string Id = "";
public ProfilerRecorder Recorder;
}
readonly struct Marker
{
public readonly ProfilerCategory Category;
public readonly string Name;
public Marker(ProfilerCategory category, string name)
{
Category = category;
Name = name;
}
}
static readonly Marker[] CandidateMarkers =
{
new(ProfilerCategory.Scripts, "BehaviourUpdate"),
new(ProfilerCategory.Scripts, "ScriptRunBehaviourUpdate"),
new(ProfilerCategory.Scripts, "ScriptRunBehaviourFixedUpdate"),
new(ProfilerCategory.Scripts, "ScriptRunBehaviourLateUpdate"),
new(ProfilerCategory.Scripts, "ScriptRunDelayedTasks"),
new(ProfilerCategory.Scripts, "CoroutinesDelayedCalls"),
new(ProfilerCategory.Scripts, "GC.Collect"),
new(ProfilerCategory.Scripts, "GC.Incremental.Collect"),
new(ProfilerCategory.Internal, "PlayerLoop"),
new(ProfilerCategory.Internal, "EarlyUpdate"),
new(ProfilerCategory.Internal, "FixedUpdate"),
new(ProfilerCategory.Internal, "PreUpdate"),
new(ProfilerCategory.Internal, "Update"),
new(ProfilerCategory.Internal, "PreLateUpdate"),
new(ProfilerCategory.Internal, "PostLateUpdate"),
new(ProfilerCategory.Internal, "WaitForJobGroupID"),
new(ProfilerCategory.Internal, "JobHandle.Complete"),
new(ProfilerCategory.Internal, "Semaphore.WaitForSignal"),
new(ProfilerCategory.Internal, "WaitForTargetFPS"),
new(ProfilerCategory.Internal, "Gfx.WaitForPresentOnGfxThread"),
new(ProfilerCategory.Internal, "Gfx.PresentFrame"),
new(ProfilerCategory.Loading, "PreloadManager.UpdatePreloading"),
new(ProfilerCategory.Loading, "Application.Integrate Assets in Background"),
new(ProfilerCategory.Loading, "AsyncUploadManager.Update"),
new(ProfilerCategory.Loading, "Resources.UnloadUnusedAssets"),
new(ProfilerCategory.Loading, "SceneManager.Update"),
new(ProfilerCategory.Render, "UpdateAllRenderers"),
new(ProfilerCategory.Render, "UpdateRendererBoundingVolumes"),
new(ProfilerCategory.Render, "UpdateAllSkinnedMeshes"),
new(ProfilerCategory.Render, "Camera.Render"),
new(ProfilerCategory.Render, "CullScriptable"),
new(ProfilerCategory.Render, "RenderLoop.Draw"),
new(ProfilerCategory.Render, "BatchRendererGroup"),
new(ProfilerCategory.Animation, "DirectorUpdateAnimationBegin"),
new(ProfilerCategory.Animation, "DirectorUpdateAnimationEnd"),
new(ProfilerCategory.Animation, "Animator.Update"),
new(ProfilerCategory.Physics, "Physics.Simulate"),
new(ProfilerCategory.Particles, "ParticleSystem.Update"),
new(ProfilerCategory.Particles, "ParticleSystem.ScheduleGeometryJobs"),
};
static readonly List<Entry> Entries = new();
static GameObject? _driverObject;
public static int ActiveMarkers => Entries.Count;
public static string Start()
{
Stop();
var names = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var available = new List<ProfilerRecorderHandle>();
try { ProfilerRecorderHandle.GetAvailable(available); }
catch { }
for (int i = 0; i < available.Count && Entries.Count < 512; i++)
{
try
{
ProfilerRecorderDescription description =
ProfilerRecorderHandle.GetDescription(available[i]);
string name = description.Name;
if (description.UnitType !=
ProfilerMarkerDataUnit.TimeNanoseconds)
continue;
AddRecorder(description.Category, name, names, seen);
}
catch { }
}
// Some release players expose only a small built-in counter list.
// Probe known marker/category pairs to supplement that list.
for (int i = 0;
i < CandidateMarkers.Length && Entries.Count < 512;
i++)
{
Marker marker = CandidateMarkers[i];
AddRecorder(marker.Category, marker.Name, names, seen);
}
if (Entries.Count > 0)
{
_driverObject = new GameObject("S3 Deep Unity Marker Profiler");
_driverObject.hideFlags = HideFlags.HideAndDontSave;
UnityEngine.Object.DontDestroyOnLoad(_driverObject);
_driverObject.AddComponent<UnityMarkerFrameDriver>();
}
return Entries.Count == 0
? "markers=0"
: $"available={available.Count} markers={Entries.Count} " +
$"[{string.Join(", ", names.GetRange(0, Math.Min(20, names.Count)))}" +
(names.Count > 20 ? ", ...]" : "]");
}
static void AddRecorder(
ProfilerCategory category,
string name,
List<string> names,
HashSet<string> seen)
{
string unique = category.Name + ":" + name;
if (!seen.Add(unique)) return;
try
{
ProfilerRecorder recorder = ProfilerRecorder.StartNew(
category,
name,
1,
ProfilerRecorderOptions.StartImmediately |
ProfilerRecorderOptions.SumAllSamplesInFrame);
if (!recorder.Valid)
{
recorder.Dispose();
return;
}
string id = "unity." + category.Name + ":" + name;
Entries.Add(new Entry { Id = id, Recorder = recorder });
names.Add(unique);
}
catch { }
}
public static void Stop()
{
if (_driverObject != null)
{
UnityEngine.Object.Destroy(_driverObject);
_driverObject = null;
}
for (int i = 0; i < Entries.Count; i++)
{
try { Entries[i].Recorder.Dispose(); }
catch { }
}
Entries.Clear();
}
public static void Sample()
{
if (!SparseHitchSampler.Active) return;
for (int i = 0; i < Entries.Count; i++)
{
Entry entry = Entries[i];
long nanoseconds;
try { nanoseconds = entry.Recorder.LastValue; }
catch { continue; }
if (nanoseconds <= 0 || nanoseconds > 10_000_000_000L)
continue;
long ticks = (long)(
nanoseconds * (double)Stopwatch.Frequency / 1_000_000_000.0);
SparseHitchSampler.Record(entry.Id, ticks);
}
}
}
[DefaultExecutionOrder(31900)]
public sealed class UnityMarkerFrameDriver : MonoBehaviour
{
void LateUpdate() => UnityMarkerProfiler.Sample();
}