Add Unity Pipeline package support

This commit is contained in:
ud18010
2026-07-31 17:34:04 +08:00
parent d1a526094e
commit 786b7ffff9
590 changed files with 51995 additions and 2 deletions
@@ -0,0 +1,208 @@
using System;
using System.Collections.Generic;
using Unity.Profiling;
using UnityEngine;
namespace Unity.Pipeline.Runtime.Telemetry
{
/// <summary>
/// Samples per-frame timing on the main thread so the runtime telemetry command can report fps and
/// frame-time statistics over a rolling window.
///
/// A Player build has no Editor profiler window to read from and no <c>EditorApplication.update</c>
/// to drive sampling, so <see cref="RuntimePipelineManager"/> owns one shared instance and feeds it
/// every frame from its <c>Update</c>. The sampler is deliberately allocation-free on the hot path:
/// each frame time is written into a fixed ring buffer and only reduced to a <see cref="FrameStatsSnapshot"/>
/// on demand when a telemetry request arrives (far rarer than the frame rate).
///
/// Sampling uses <c>Time.unscaledDeltaTime</c> so reported fps reflects real wall-clock frame pacing
/// independent of <c>Time.timeScale</c> — a paused or slow-motion game still reports its true fps.
/// </summary>
public sealed class FrameStatsSampler : IDisposable
{
/// <summary>
/// The process-wide sampler fed by the active <see cref="RuntimePipelineManager"/>. Null when no
/// manager is present (e.g. EditMode tests, or a scene without the component). Telemetry consumers
/// must treat a null sampler as "frame stats unavailable" rather than failing — memory and counter
/// data that do not depend on per-frame sampling can still be reported without it.
/// </summary>
public static FrameStatsSampler Shared { get; set; }
private readonly float[] m_FrameTimesMs;
private int m_Head;
private int m_Count;
private float m_LastFrameTimeMs;
// ProfilerRecorders pull counters straight from the profiler stream. They are started lazily on
// the first snapshot (creation must happen on the main thread) and disposed with the sampler.
// Recorders that do not resolve on this platform/Unity version report Valid == false and are
// simply skipped when building a snapshot.
private readonly List<CounterRecorder> m_Counters = new List<CounterRecorder>();
private bool m_CountersStarted;
private struct CounterRecorder
{
public string Name;
public ProfilerRecorder Recorder;
}
/// <summary>Number of frames the rolling window can hold.</summary>
public int Capacity => m_FrameTimesMs.Length;
/// <summary>Number of frames currently recorded (ramps up to <see cref="Capacity"/>).</summary>
public int SampleCount => m_Count;
public FrameStatsSampler(int windowFrames = 120)
{
if (windowFrames < 1)
windowFrames = 1;
m_FrameTimesMs = new float[windowFrames];
}
/// <summary>
/// Record one completed frame. Call once per frame from the main thread.
/// <paramref name="deltaSeconds"/> is the unscaled delta time of the frame just completed
/// (typically <c>Time.unscaledDeltaTime</c>).
/// </summary>
public void Sample(float deltaSeconds)
{
var ms = deltaSeconds * 1000f;
m_LastFrameTimeMs = ms;
m_FrameTimesMs[m_Head] = ms;
m_Head = (m_Head + 1) % m_FrameTimesMs.Length;
if (m_Count < m_FrameTimesMs.Length)
m_Count++;
// FrameTimingManager only yields data for frames in which timings were captured, so the
// capture call has to happen on the per-frame path, not when a snapshot is requested.
FrameTimingManager.CaptureFrameTimings();
}
/// <summary>
/// Reduce the current window to an immutable snapshot. Must be called on the main thread (it reads
/// profiler recorders and the frame timing manager).
/// </summary>
public FrameStatsSnapshot GetSnapshot()
{
var snap = new FrameStatsSnapshot();
if (m_Count > 0)
{
float sum = 0f, min = float.MaxValue, max = 0f;
for (int i = 0; i < m_Count; i++)
{
var v = m_FrameTimesMs[i];
sum += v;
if (v < min) min = v;
if (v > max) max = v;
}
var avg = sum / m_Count;
snap.Available = true;
snap.SampleWindow = m_Count;
snap.AverageFrameTimeMs = avg;
snap.MinFrameTimeMs = min;
snap.MaxFrameTimeMs = max;
snap.LastFrameTimeMs = m_LastFrameTimeMs;
snap.Fps = avg > 0f ? 1000f / avg : 0f;
}
ReadFrameTimingManager(snap);
ReadCounters(snap);
return snap;
}
// CPU/GPU frame time from FrameTimingManager. Only populated when the platform reports timings
// (requires "Frame Timing Stats" in Player settings / -enable-frame-timing-stats); otherwise the
// snapshot's GpuTimingAvailable stays false rather than reporting misleading zeros.
private static void ReadFrameTimingManager(FrameStatsSnapshot snap)
{
var timings = new FrameTiming[1];
var captured = FrameTimingManager.GetLatestTimings(1, timings);
if (captured > 0)
{
snap.GpuTimingAvailable = true;
snap.CpuFrameTimeMs = timings[0].cpuFrameTime;
snap.GpuFrameTimeMs = timings[0].gpuFrameTime;
}
}
private void ReadCounters(FrameStatsSnapshot snap)
{
EnsureCountersStarted();
var counters = new Dictionary<string, double>();
foreach (var c in m_Counters)
{
if (c.Recorder.Valid)
counters[c.Name] = c.Recorder.LastValue;
}
snap.Counters = counters;
}
private void EnsureCountersStarted()
{
if (m_CountersStarted)
return;
m_CountersStarted = true;
// Common render/memory counters. Stat names that do not exist on the running platform resolve
// to invalid recorders and are filtered out at snapshot time.
TryAddCounter("DrawCalls", ProfilerCategory.Render, "Draw Calls Count");
TryAddCounter("SetPassCalls", ProfilerCategory.Render, "SetPass Calls Count");
TryAddCounter("Triangles", ProfilerCategory.Render, "Triangles Count");
TryAddCounter("Vertices", ProfilerCategory.Render, "Vertices Count");
TryAddCounter("GcAllocInFrame", ProfilerCategory.Memory, "GC Allocated In Frame");
}
private void TryAddCounter(string reportedName, ProfilerCategory category, string statName)
{
m_Counters.Add(new CounterRecorder
{
Name = reportedName,
Recorder = ProfilerRecorder.StartNew(category, statName)
});
}
public void Dispose()
{
foreach (var c in m_Counters)
{
if (c.Recorder.Valid)
c.Recorder.Dispose();
}
m_Counters.Clear();
m_CountersStarted = false;
}
}
/// <summary>
/// Immutable point-in-time reduction of <see cref="FrameStatsSampler"/>'s rolling window plus the
/// counters/frame-timing read at snapshot time. Serialized as the frame-stats portion of the runtime
/// telemetry response.
/// </summary>
[Serializable]
public class FrameStatsSnapshot
{
/// <summary>True when at least one frame has been sampled (i.e. a manager is feeding the sampler).</summary>
public bool Available { get; set; }
/// <summary>Number of frames included in the averages.</summary>
public int SampleWindow { get; set; }
/// <summary>Frames per second derived from the average frame time over the window.</summary>
public float Fps { get; set; }
public float AverageFrameTimeMs { get; set; }
public float MinFrameTimeMs { get; set; }
public float MaxFrameTimeMs { get; set; }
public float LastFrameTimeMs { get; set; }
/// <summary>True when FrameTimingManager returned CPU/GPU timings for a recent frame.</summary>
public bool GpuTimingAvailable { get; set; }
public double CpuFrameTimeMs { get; set; }
public double GpuFrameTimeMs { get; set; }
/// <summary>Valid profiler counters by reported name (e.g. DrawCalls, Triangles).</summary>
public Dictionary<string, double> Counters { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0e2b7696d6f845cba4f7c776006707d1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,68 @@
using System;
using UnityEngine;
namespace Unity.Pipeline.Config
{
/// <summary>
/// Configuration for Pipeline server functionality in Unity Player builds.
/// Create this asset and place in Resources folder to enable runtime Pipeline support.
/// </summary>
[CreateAssetMenu(fileName = "RuntimePipelineConfig", menuName = "Pipeline/Runtime Configuration", order = 1)]
public class RuntimePipelineConfig : ScriptableObject
{
[Header("Server Configuration")]
[Tooltip("Enable Pipeline HTTP server in Player builds. SECURITY WARNING: Only enable in development/QA builds, never production without proper security measures.")]
public bool enableInBuilds = false;
[Tooltip("HTTP port for Pipeline server. Use 0 for auto-assignment from range 7900-7999.")]
[Range(0, 65535)]
public int port = 0;
[Header("Advanced")]
[Tooltip("Request timeout in milliseconds. Higher values allow longer-running commands.")]
[Range(1000, 60000)]
public int requestTimeoutMs = 30000;
[Tooltip("Enable detailed logging of all remote requests for security auditing.")]
public bool enableAuditLogging = true;
/// <summary>
/// Validate the configuration for correctness.
/// Called by build processor to ensure safe deployment.
/// </summary>
public ValidationResult Validate()
{
if (!enableInBuilds)
return ValidationResult.Success("Runtime Pipeline disabled");
// Port validation
if (port != 0 && (port < 7900 || port > 7999))
{
return ValidationResult.Warning(
$"Port {port} is outside recommended runtime range 7900-7999. May conflict with Editor instances.");
}
return ValidationResult.Success("Configuration is valid");
}
}
/// <summary>
/// Result from configuration validation.
/// </summary>
[Serializable]
public class ValidationResult
{
public bool IsValid { get; set; }
public string Level { get; set; } // "success", "warning", "error"
public string Message { get; set; }
public static ValidationResult Success(string message = "Valid") =>
new ValidationResult { IsValid = true, Level = "success", Message = message };
public static ValidationResult Warning(string message) =>
new ValidationResult { IsValid = true, Level = "warning", Message = message };
public static ValidationResult Error(string message) =>
new ValidationResult { IsValid = false, Level = "error", Message = message };
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0fd19cf31d54e3848a1075f001aed64f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,368 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Unity.Pipeline.Config;
using Unity.Pipeline.Commands;
using Unity.Pipeline.HotReload;
using Unity.Pipeline.Runtime.Telemetry;
using UnityEngine;
namespace Unity.Pipeline
{
/// <summary>
/// Unified Pipeline runtime manager that handles both server creation and event processing.
/// Replaces the need for separate RuntimePipelineConfig and RuntimePipelineProcessor.
///
/// Simply add this component to a GameObject in your scene to enable Pipeline runtime support.
/// Only one instance should exist in your project.
/// </summary>
[AddComponentMenu("Pipeline/Runtime Pipeline Manager")]
[DisallowMultipleComponent]
public class RuntimePipelineManager : MonoBehaviour
{
[Header("Server Configuration")]
[Tooltip("Enable Pipeline HTTP server in Player builds. SECURITY WARNING: Only enable in development/QA builds, never production without proper security measures.")]
public bool enableInBuilds = false;
[Tooltip("HTTP port for Pipeline server. Use 0 for auto-assignment from range 7900-7999.")]
[Range(0, 65535)]
public int port = 0;
[Header("Advanced")]
[Tooltip("Request timeout in milliseconds. Higher values allow longer-running commands.")]
[Range(1000, 60000)]
public int requestTimeoutMs = 30000;
[Tooltip("Enable detailed logging of all remote requests for security auditing.")]
public bool enableAuditLogging = true;
[Header("Runtime")]
[Tooltip("Enable automatic server startup when the component starts (for runtime builds only).")]
public bool autoStart = true;
[Tooltip("Maximum work items to process per frame to maintain performance.")]
[Range(1, 50)]
public int maxWorkItemsPerFrame = 10;
// Absolute roots this build is allowed to hot reload source files from (Assets + loaded
// package locations). Baked at build time by the build processor (a running Player cannot
// resolve the project layout) and published to HotReloadFileScope when the server starts.
// Hidden from the inspector: it is build-injected, not user-edited.
[SerializeField, HideInInspector] private List<string> m_AllowedReloadRoots = new List<string>();
// Internal state
private RuntimePipelineServer m_Server;
private RuntimePipelineConfig m_RuntimeConfig;
// True only for the instance that installed FrameStatsSampler.Shared, so a rejected duplicate's
// OnDestroy never tears down the real sampler.
private bool m_OwnsSampler;
/// <summary>
/// Get the runtime server instance if it's running.
/// </summary>
public RuntimePipelineServer Server => m_Server;
/// <summary>
/// Whether the server is currently running.
/// </summary>
public bool IsServerRunning => m_Server != null && m_Server.IsRunning;
/// <summary>
/// Get the actual port the server is running on.
/// </summary>
public int ActualPort => m_Server?.Port ?? 0;
public RuntimePipelineConfig Config => m_RuntimeConfig;
/// <summary>
/// Absolute roots this build may hot reload source files from. Baked at build time.
/// </summary>
public IReadOnlyList<string> AllowedReloadRoots => m_AllowedReloadRoots;
/// <summary>
/// Set the allowed hot reload roots. Called by the build processor to bake the project's
/// Assets and package locations into the build.
/// </summary>
public void SetAllowedReloadRoots(IEnumerable<string> roots)
{
m_AllowedReloadRoots = roots != null ? new List<string>(roots) : new List<string>();
}
void Awake()
{
// Ensure only one instance exists
var existing = PipelineUtils.FindObjectsByType<RuntimePipelineManager>();
if (existing.Length > 1)
{
Debug.LogWarning($"Pipeline: Multiple RuntimePipelineManager instances found. Destroying duplicate on '{name}'.");
Destroy(this);
return;
}
// Don't destroy on load for persistent operation across scenes
DontDestroyOnLoad(gameObject);
// Set up command discovery (the server owns its own dispatcher, initialized on Start).
CommandRegistry.SetDiscovery(null); // Triggers reflection-based discovery
// Auto-discover and register hot-reloadable methods. Hot reload requires this manager to
// be present (it owns the communication workflow), so discovery lives here rather than in
// each gameplay script's Awake.
RegisterDiscoveredHotReloadMethods();
// Own the process-wide frame-stats sampler. A Player has no profiler window or
// EditorApplication.update to drive sampling, so the manager feeds it from Update (below) and
// the runtime_status telemetry command reads FrameStatsSampler.Shared. Created here (rather than
// on server start) so fps history is already warm by the time an agent connects.
FrameStatsSampler.Shared = new FrameStatsSampler();
m_OwnsSampler = true;
}
void Start()
{
if (autoStart && enableInBuilds)
{
StartServer();
}
}
/// <summary>
/// Scan loaded user assemblies for methods tagged [HotReload] (in-place workflow) and
/// [HotReloadWithOverrides] (helper workflow) and register them as reload targets. This replaces any
/// per-script RegisterReloadableType call in Awake.
/// </summary>
private static void RegisterDiscoveredHotReloadMethods()
{
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic
| BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
int registered = 0;
foreach (var assembly in PipelineUtils.GetLoadedAssemblies())
{
if (!ShouldScanForHotReload(assembly))
continue;
Type[] types;
try
{
types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types; // best effort: keep the types that did load
}
foreach (var type in types)
{
if (type == null)
continue;
foreach (var method in type.GetMethods(flags))
{
var inPlace = method.GetCustomAttribute<HotReloadAttribute>();
if (inPlace != null)
{
// The weaver keys dispatch on TypeName.MethodName, so register with the
// default id (no custom Id) to match.
HotReloadRegistry.RegisterReloadableMethod(
method, new HotReloadWithOverridesAttribute { RequireMainThread = inPlace.RequireMainThread });
registered++;
continue;
}
var reloadable = method.GetCustomAttribute<HotReloadWithOverridesAttribute>();
if (reloadable != null)
{
HotReloadRegistry.RegisterReloadableMethod(method, reloadable);
registered++;
}
}
}
}
if (registered > 0)
Debug.Log($"Pipeline: Auto-discovered and registered {registered} hot-reloadable method(s).");
}
/// <summary>
/// Skip engine/framework assemblies that cannot contain user hot-reload methods.
/// </summary>
private static bool ShouldScanForHotReload(Assembly assembly)
{
if (assembly.IsDynamic)
return false;
var name = assembly.GetName().Name;
if (string.IsNullOrEmpty(name))
return false;
foreach (var prefix in s_HotReloadSkipPrefixes)
{
if (name.StartsWith(prefix, StringComparison.Ordinal))
return false;
}
return true;
}
private static readonly string[] s_HotReloadSkipPrefixes =
{
"System", "mscorlib", "netstandard", "Microsoft", "Mono.", "nunit",
"UnityEngine", "UnityEditor", "Unity.Collections", "Unity.Burst",
"Unity.Mathematics", "Newtonsoft", "log4net", "ICSharpCode",
};
void Update()
{
// Feed the frame-stats sampler once per frame on the main thread. Uses unscaled delta so
// reported fps reflects real frame pacing regardless of Time.timeScale.
FrameStatsSampler.Shared?.Sample(Time.unscaledDeltaTime);
// Pump this server's own dispatcher for main-thread operations (player builds have no
// EditorApplication.update to auto-pump it).
m_Server?.Dispatcher.ProcessWorkQueue(maxWorkItemsPerFrame);
// Drive the watchdog (player builds have no EditorApplication.update). No-op unless the
// server's watchdog is enabled and armed.
m_Server?.WatchdogTick();
}
void OnApplicationQuit()
{
StopServer();
}
void OnDestroy()
{
// Dispose the sampler's profiler recorders and drop the shared reference, but only if this
// manager is the one that installed it (a rejected duplicate must not tear down the real one).
if (m_OwnsSampler)
{
FrameStatsSampler.Shared?.Dispose();
FrameStatsSampler.Shared = null;
m_OwnsSampler = false;
}
}
/// <summary>
/// Manually start the Pipeline server.
/// </summary>
public void StartServer()
{
if (m_Server != null && m_Server.IsRunning)
{
Debug.LogWarning("Pipeline: Server already started or initialization attempted.");
return;
}
try
{
System.Console.WriteLine("Pipeline: Starting runtime server from RuntimePipelineManager...");
if (!enableInBuilds)
{
Debug.LogWarning("Pipeline: Runtime server disabled in configuration (enableInBuilds = false)");
return;
}
// Create runtime config from component settings
m_RuntimeConfig = CreateRuntimeConfig();
// Validate configuration
var validation = m_RuntimeConfig.Validate();
if (!validation.IsValid)
{
Debug.LogError($"Pipeline: Invalid runtime configuration: {validation.Message}");
return;
}
if (validation.Level == "warning")
{
Debug.LogWarning($"Pipeline: Runtime configuration warning: {validation.Message}");
}
// Create runtime server
m_Server = new RuntimePipelineServer(m_RuntimeConfig);
// Start server on configured port
var serverPort = port == 0 ? 0 : port;
m_Server.Start(serverPort);
if (m_Server.IsRunning)
{
// The hot-reload registry marshals main-thread overrides through a dispatcher
// when hot-reloaded code is invoked from a background thread at runtime. Inject
// this server's dispatcher here (a player has exactly one manager), so editor and
// per-test servers never clobber the global registry's dispatcher.
Unity.Pipeline.HotReload.HotReloadRegistry.Dispatcher = m_Server.Dispatcher;
// Publish the build-baked allowed roots so reload commands can validate that
// incoming files are inside the project before compiling and injecting them.
Unity.Pipeline.HotReload.HotReloadRegistry.AllowedReloadRoots = m_AllowedReloadRoots;
System.Console.WriteLine($"Pipeline: Runtime server started successfully on port {m_Server.Port}");
}
else
{
Debug.LogError("Pipeline: Failed to start runtime server");
}
}
catch (Exception ex)
{
Debug.LogError($"Pipeline: Runtime initialization failed: {ex.Message}");
Debug.LogError($"Pipeline: Stack trace: {ex.StackTrace}");
}
}
/// <summary>
/// Manually stop the Pipeline server.
/// </summary>
public void StopServer()
{
try
{
if (m_Server != null && m_Server.IsRunning)
{
System.Console.WriteLine("Pipeline: Stopping runtime server...");
// Drop the registry's reference to this server's (about to be shut down) dispatcher.
if (Unity.Pipeline.HotReload.HotReloadRegistry.Dispatcher == m_Server.Dispatcher)
Unity.Pipeline.HotReload.HotReloadRegistry.Dispatcher = null;
m_Server.Stop(); // Stop() shuts down the server's own dispatcher.
}
}
catch (Exception ex)
{
Debug.LogError($"Pipeline: Runtime shutdown error: {ex.Message}");
}
}
/// <summary>
/// Create a RuntimePipelineConfig from the component settings.
/// </summary>
private RuntimePipelineConfig CreateRuntimeConfig()
{
var config = ScriptableObject.CreateInstance<RuntimePipelineConfig>();
// Copy settings from component to config
config.enableInBuilds = this.enableInBuilds;
config.port = this.port;
config.requestTimeoutMs = this.requestTimeoutMs;
config.enableAuditLogging = this.enableAuditLogging;
return config;
}
/// <summary>
/// Validate the current configuration.
/// </summary>
public ValidationResult ValidateConfiguration()
{
if (m_RuntimeConfig == null)
m_RuntimeConfig = CreateRuntimeConfig();
return m_RuntimeConfig.Validate();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dd4ab1c9091e0194196b5f5d51cb932f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,74 @@
using System;
using Unity.Pipeline.Config;
using Unity.Pipeline.Models;
using UnityEngine;
namespace Unity.Pipeline
{
public class RuntimePipelineServer : BasePipelineServer
{
private RuntimePipelineConfig m_Config;
private RuntimeInstanceDescriptor m_InstanceDescriptor;
public override DateTime StartedAt => m_InstanceDescriptor == null ? new DateTime() : m_InstanceDescriptor.StartedAt;
public RuntimePipelineServer(RuntimePipelineConfig config)
{
m_Config = config;
}
protected override (int basePort, int maxPort) GetPortRange()
{
return (7900, 7949); // Runtime production (test runtime servers use 7950-7999)
}
protected override void CreateInstanceDescriptor()
{
// Create and write instance descriptor for CLI discovery
m_InstanceDescriptor = RuntimeInstanceDescriptor.CreateCurrent(Port, m_Config);
RuntimeInstanceDescriptor.WriteToWorkingDirectory(m_InstanceDescriptor);
}
protected override void DeleteInstanceDescriptor()
{
// Clean up instance descriptor file
if (m_InstanceDescriptor != null)
{
RuntimeInstanceDescriptor.RemoveFromWorkingDirectory();
}
m_InstanceDescriptor = null;
}
protected override void UpdateHeartBeat()
{
// Update heartbeat in instance descriptor
if (m_InstanceDescriptor != null)
{
m_InstanceDescriptor.LastHeartbeat = DateTime.UtcNow;
try
{
RuntimeInstanceDescriptor.WriteToWorkingDirectory(m_InstanceDescriptor);
}
catch (Exception ex)
{
Debug.LogWarning($"Failed to update instance descriptor: {ex.Message}");
}
}
}
protected override object GetServerStatus()
{
UpdateHeartBeat();
return new
{
status = m_InstanceDescriptor == null ? "error" : "ready",
lastHeartbeat = m_InstanceDescriptor?.LastHeartbeat
};
}
protected override string GetToken()
{
return m_InstanceDescriptor.EvalToken;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4599450d651dea74cb4b90289360aa3e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: