diff --git a/Assets/Mirror/Profiler.meta b/Assets/Mirror/Profiler.meta new file mode 100644 index 000000000..7c68fd1b5 --- /dev/null +++ b/Assets/Mirror/Profiler.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 82fe9954a7d514fb098fd578f6c89acc +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/Chart.meta b/Assets/Mirror/Profiler/Chart.meta new file mode 100644 index 000000000..bb896ae8c --- /dev/null +++ b/Assets/Mirror/Profiler/Chart.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fda3d426dcfd94e53ba1cc2e77522cd0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/Chart/ChartView.cs b/Assets/Mirror/Profiler/Chart/ChartView.cs new file mode 100644 index 000000000..abe99e41f --- /dev/null +++ b/Assets/Mirror/Profiler/Chart/ChartView.cs @@ -0,0 +1,366 @@ +using System; +using System.Linq; +using UnityEngine; +using UnityEditor; +using System.Collections.Generic; + +namespace Mirror.Profiler.Chart +{ + + public class ChartView + { + Material mat; + + public int MaxFrames = 1000; + + private int selectedFrame = -1; + + public int SelectedFrame + { + get => selectedFrame; + set + { + this.selectedFrame = value; + OnSelectFrame?.Invoke(value); + } + } + + public event Action OnSelectFrame; + + private static readonly int[] Scales = { 4, 6, 8, 12, 16, 20, 28 }; + + private static readonly Color[] SeriesPalette = { + ToColor(0xCC7000), + ToColor(0x5AB2BC), + ToColor(0xfdc086), + ToColor(0xffff99), + ToColor(0x386cb0), + ToColor(0xf0027f), + ToColor(0xbf5b17), + ToColor(0x666666)}; + + private static Color ToColor(int hex) + { + float r = ((hex & 0xff0000) >> 0x10) / 255f; + float g = ((hex & 0xff00) >> 8) / 255f; + float b = (hex & 0xff) / 255f; + + return new Color(r, g, b); + } + + public List Series = new List(); + + public ChartView(params ISeries [] series) + { + Series.AddRange(series); + } + + public void OnGUI(Rect rect) + { + if (mat == null) + { + Shader shader = Shader.Find("Hidden/Internal-Colored"); + mat = new Material(shader); + } + + if (Event.current.type == EventType.MouseDown) + { + OnMouseDown(rect, Event.current); + } + + if (Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.LeftArrow) + { + SelectedFrame -= 1; + + Event.current.Use(); + } + + if (Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.RightArrow) + { + SelectedFrame += 1; + + Event.current.Use(); + } + + if (Event.current.type == EventType.Repaint) + { + RectInt axisBounds = AxisBounds(); + GUI.BeginClip(rect); + GL.PushMatrix(); + try + { + GL.Clear(true, false, Color.black); + mat.SetPass(0); + + ClearBackground(rect); + + + acccumulatedValues.Clear(); + for (int i = 0; i < Series.Count; i++) + { + DrawSeries(rect, axisBounds, Series[i], SeriesPalette[i % SeriesPalette.Count()]); + } + DrawGrid(rect, axisBounds); + + DrawSelectedFrame(rect, axisBounds); + + DrawLegend(rect); + } + finally + { + GL.PopMatrix(); + GUI.EndClip(); + } + } + } + + private void OnMouseDown(Rect rect, Event current) + { + Vector2 position = current.mousePosition; + + // was the click for us? + if (!rect.Contains(position)) + return; + + // need to map to axis + RectInt axisBounds = AxisBounds(); + + var (x, _) = ScreenToDataSpace(rect, axisBounds, position); + + SelectedFrame = Mathf.RoundToInt(x); + } + + private (float, float) ScreenToDataSpace(Rect rect, RectInt axisBounds, Vector2 position) + { + Vector2 normalized = (position - rect.min) / rect.size ; + + float x = axisBounds.width * normalized.x + axisBounds.xMin; + float y = axisBounds.yMax - axisBounds.height * normalized.y ; + return (x, y); + } + + private void DrawSelectedFrame( Rect rect, RectInt axisBounds) + { + int selected = SelectedFrame; + + if (selected >= 0) + { + GL.Begin(GL.LINES); + + Vector2 selectedPosition = Project(selected, 0, axisBounds, rect); + GL.Color(Color.yellow); + + GL.Vertex3(selectedPosition.x, 0, 0); + GL.Vertex3(selectedPosition.x, rect.height, 0); + GL.End(); + } + + } + + private RectInt AxisBounds() + { + Rect dataBounds = DataBounds(); + int ymin = 0; + int ymax = PrettyScale(dataBounds.yMax); + int xmax = Mathf.RoundToInt(dataBounds.xMax); + int xmin = xmax - MaxFrames; + + return new RectInt(xmin, ymin, xmax - xmin, ymax - ymin); + + } + + private int PrettyScale(float max) + { + int baseScale = 1; + + while (true) + { + foreach (int scale in Scales) + { + if (scale * baseScale > max) + return scale * baseScale; + } + + baseScale *= 10; + } + } + + + List stackedValues = new List(); + + private Rect DataBounds() + { + Vector2 min = new Vector2Int(int.MaxValue, 0); + Vector2 max = new Vector2Int(int.MinValue, int.MinValue); + + stackedValues.Clear(); + + foreach (ISeries serie in Series) + { + int index = 0; + + foreach (var (x,y) in serie.Data) + { + if (stackedValues.Count <= index) + { + stackedValues.Add(0); + } + + float newvalue = stackedValues[index] + y; + + max.y = Math.Max(max.y, newvalue); + max.x = Math.Max(max.x, x); + min.x = Math.Min(min.x, x); + + stackedValues[index] = newvalue; + + index++; + } + } + + if (min.x > max.x) + min = max = new Vector2Int(0, 0); + + return new Rect(min.x, min.y, max.x - min.x, max.y - min.y); + } + + List acccumulatedValues = new List(); + + private void DrawSeries(Rect rect, RectInt axisBounds, ISeries series, Color color) + { + GL.Begin(GL.TRIANGLE_STRIP); + GL.Color(color); + + int pframe = -1; + + float pLow = 0; + float pHigh = 0; + + float low = 0; + float high = 0; + + int index = 0; + + foreach ((int frame, float value) in series.Data) + { + if (pframe < frame - 1) + { + // there were empty frames, need to draw zeros + DrawDataPoint(rect, axisBounds, pframe, pLow, pHigh, pframe+1, 0, 0); + DrawDataPoint(rect, axisBounds, pframe+1, 0, 0, frame - 1, 0, 0); + pLow = 0; + pHigh = 0; + pframe = frame - 1; + } + + if (acccumulatedValues.Count <= index) + { + acccumulatedValues.Add(0); + } + + low = acccumulatedValues[index]; + high = low + value; + + DrawDataPoint(rect, axisBounds, pframe, pLow, pHigh, frame, low, high); + + acccumulatedValues[index] = high; + + pLow = low; + pHigh = high; + + pframe = frame; + index++; + } + GL.End(); + } + + private void DrawDataPoint(Rect rect, RectInt axisBounds, int pframe, float pLow, float pHigh, int frame, float low, float high) + { + // assume we ended the strip in ph + if (frame >= axisBounds.xMin && frame <= axisBounds.xMax) + { + Vector2 pl = Project(pframe, pLow, axisBounds, rect); + Vector2 ph = Project(pframe, pHigh, axisBounds, rect); + + Vector2 l = Project(frame, low, axisBounds, rect); + Vector2 h = Project(frame, high, axisBounds, rect); + + GL.Vertex3(h.x, h.y, 0); + GL.Vertex3(pl.x, pl.y, 0); + GL.Vertex3(l.x, l.y, 0); + GL.Vertex3(h.x, h.y, 0); + } + } + + private Vector2 Project(int x, float y, RectInt axisBounds, Rect rect) + { + float px = rect.width * (x - axisBounds.xMin) / axisBounds.width; + float py = rect.height * (y - axisBounds.yMin) / axisBounds.height; + + return new Vector2(px, rect.height - py); + } + + private void DrawGrid(Rect rect, RectInt axisBounds) + { + Vector2 labelSize = Vector2.zero; + + for (int i = 1; i < 4; i++) + { + float f = 0.4f; + int lineValue = i * axisBounds.height / 4; + + string labelTxt = lineValue.ToString(); + + GUIStyle labelStyle = new GUIStyle(); + labelStyle.normal.textColor = new Color(f, f, f, 1); + labelStyle.alignment = TextAnchor.MiddleLeft; + + Rect labelPosition = new Rect(2, rect.height * (4 - i) / 4 - 20, rect.width, 40); + GUI.Label(labelPosition, labelTxt, labelStyle); + + Vector2 size = labelStyle.CalcSize(new GUIContent(labelTxt)); + labelSize = Vector2.Max(labelSize, size); + } + + mat.SetPass(0); + GL.Begin(GL.LINES); + + // 4 lines + for (int i = 1; i < 4; i++) + { + float f = 0.2f; + GL.Color(new Color(f, f, f, 1)); + + GL.Vertex3(2 + 2 + labelSize.x, i * rect.height / 4, 0); + GL.Vertex3(rect.width, i * rect.height / 4, 0); + } + GL.End(); + + } + + private static void ClearBackground(Rect rect) + { + GL.Begin(GL.QUADS); + GL.Color(Color.black); + GL.Vertex3(0, 0, 0); + GL.Vertex3(rect.width, 0, 0); + GL.Vertex3(rect.width, rect.height, 0); + GL.Vertex3(0, rect.height, 0); + GL.End(); + } + + public void DrawLegend(Rect rect) + { + Rect areaRect = new Rect(4, 4, rect.width, rect.height); + + for (int i = 0; i< Series.Count; i++) + { + GUIStyle style = new GUIStyle(); + style.normal.textColor = SeriesPalette[i % SeriesPalette.Count()]; + style.contentOffset = new Vector2(0, (Series.Count - i - 1) * 15); + style.alignment = TextAnchor.UpperLeft; + GUI.Label(areaRect, Series[i].Name, style); + } + } + } +} \ No newline at end of file diff --git a/Assets/Mirror/Profiler/Chart/ChartView.cs.meta b/Assets/Mirror/Profiler/Chart/ChartView.cs.meta new file mode 100644 index 000000000..0279e2ab2 --- /dev/null +++ b/Assets/Mirror/Profiler/Chart/ChartView.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cce5c930d77ae429d90b86cbfb2d0a01 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/Chart/ISeries.cs b/Assets/Mirror/Profiler/Chart/ISeries.cs new file mode 100644 index 000000000..7fc4850c5 --- /dev/null +++ b/Assets/Mirror/Profiler/Chart/ISeries.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace Mirror.Profiler.Chart +{ + + public interface ISeries + { + IEnumerable<(int, float)> Data { get; } + + string Name { get; } + } +} diff --git a/Assets/Mirror/Profiler/Chart/ISeries.cs.meta b/Assets/Mirror/Profiler/Chart/ISeries.cs.meta new file mode 100644 index 000000000..258abfc1a --- /dev/null +++ b/Assets/Mirror/Profiler/Chart/ISeries.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d7a6aa3d0128c4140a5dee1ad948f993 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/Chart/Series.cs b/Assets/Mirror/Profiler/Chart/Series.cs new file mode 100644 index 000000000..61e287821 --- /dev/null +++ b/Assets/Mirror/Profiler/Chart/Series.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using UnityEngine; +using System.Linq; + +namespace Mirror.Profiler.Chart +{ + + public class Series : ISeries + { + + public string Name { get; } + public IEnumerable<(int, float)> Data { get; } + + public Series(string name, IEnumerable<(int, float)> data) + { + Name = name; + Data = data; + } + + public Rect Bounds + { + get + { + int minx = int.MaxValue; + float miny = int.MaxValue; + int maxx = int.MinValue; + float maxy = int.MinValue; + + foreach ((int x, float y) in Data) + { + minx = Mathf.Min(minx, x); + miny = Mathf.Min(miny, y); + maxx = Mathf.Max(maxx, x); + maxy = Mathf.Max(maxy, y); + } + + if (minx > maxx) + { + minx = maxx = 0; + miny = maxy = 0; + } + + return new Rect(minx, miny, maxx - minx, maxy - miny); + } + } + + } + +} \ No newline at end of file diff --git a/Assets/Mirror/Profiler/Chart/Series.cs.meta b/Assets/Mirror/Profiler/Chart/Series.cs.meta new file mode 100644 index 000000000..0b57036b0 --- /dev/null +++ b/Assets/Mirror/Profiler/Chart/Series.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 22e1ccfd8e2634bc4b60f1a482ec4c54 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/Mirror.Profiler.asmdef b/Assets/Mirror/Profiler/Mirror.Profiler.asmdef new file mode 100644 index 000000000..b94415480 --- /dev/null +++ b/Assets/Mirror/Profiler/Mirror.Profiler.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Mirror.Profiler", + "references": [ + "Mirror" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [] +} \ No newline at end of file diff --git a/Assets/Mirror/Profiler/Mirror.Profiler.asmdef.meta b/Assets/Mirror/Profiler/Mirror.Profiler.asmdef.meta new file mode 100644 index 000000000..d80e0d17f --- /dev/null +++ b/Assets/Mirror/Profiler/Mirror.Profiler.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4f6360625eae34006b0fbd08a678325c +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/NetworkProfiler.cs b/Assets/Mirror/Profiler/NetworkProfiler.cs new file mode 100644 index 000000000..dd79a2e5d --- /dev/null +++ b/Assets/Mirror/Profiler/NetworkProfiler.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEngine; + +namespace Mirror.Profiler +{ + + public class NetworkProfiler + { + internal List ticks; + + public int MaxFrames { get; set; } + public int MaxTicks { get; set; } + + /// + /// + /// + /// How many frames should the profiler save + public NetworkProfiler(int maxFrames = 600) + { + ticks = new List(maxFrames); + MaxFrames = maxFrames; + } + + bool isRecording; + + public IList Ticks + { + get => ticks; + } + + public bool IsRecording + { + get => isRecording; + set + { + if (value && !isRecording) + { + NetworkDiagnostics.InMessageEvent += OnInMessage; + NetworkDiagnostics.OutMessageEvent += OnOutMessage; + } + else if (!value && isRecording) + { + NetworkDiagnostics.InMessageEvent -= OnInMessage; + NetworkDiagnostics.OutMessageEvent -= OnOutMessage; + } + isRecording = value; + } + } + + + private void OnInMessage(NetworkDiagnostics.MessageInfo messageInfo) + { + AddMessage(NetworkDirection.Incoming, messageInfo); + + } + + private void OnOutMessage(NetworkDiagnostics.MessageInfo messageInfo) + { + AddMessage(NetworkDirection.Outgoing, messageInfo); + } + + + private void AddMessage(NetworkDirection direction, NetworkDiagnostics.MessageInfo messageInfo) + { + NetworkProfileMessage profilerMessage = new NetworkProfileMessage + { + Direction = direction, + Type = messageInfo.message.GetType().Name, + Name = GetMethodName(messageInfo.message), + Channel = messageInfo.channel, + Size = messageInfo.bytes, + Count = messageInfo.count, + GameObject = GetGameObject(messageInfo.message) + }; + + // add to the tick + NetworkProfileTick tick = AddCurrentTick(); + tick.RecordMessage(profilerMessage); + ticks[ticks.Count - 1] = tick; + DropOldTicks(); + } + + private NetworkProfileTick AddCurrentTick() + { + NetworkProfileTick lastTick = ticks.Count > 0 ? ticks[ticks.Count - 1] : new NetworkProfileTick(); + + if (ticks.Count == 0 || lastTick.frameCount != Time.frameCount) + { + NetworkProfileTick newTick = new NetworkProfileTick + { + frameCount = Time.frameCount, + time = Time.time, + }; + + ticks.Add(newTick); + return newTick; + } + + return lastTick; + } + + public NetworkProfileTick CurrentTick() + { + NetworkProfileTick lastTick = ticks.Count > 0 ? ticks[ticks.Count - 1] : new NetworkProfileTick(); + + if (ticks.Count == 0 || lastTick.frameCount < Time.frameCount) + { + NetworkProfileTick newTick = new NetworkProfileTick + { + frameCount = Time.frameCount + }; + + return newTick; + } + + return lastTick; + } + + private void DropOldTicks() + { + while (ticks.Count > 0) + { + if (ticks[0].frameCount < Time.frameCount - MaxFrames) + ticks.RemoveAt(0); + else + break; + } + while (ticks.Count > MaxTicks) + { + ticks.RemoveAt(0); + } + } + + private string GetMethodName(IMessageBase message) + { + switch (message) + { + case CommandMessage msg: + return GetMethodName(msg.functionHash, "InvokeCmd"); + case RpcMessage msg: + return GetMethodName(msg.functionHash, "InvokeRpc"); + case SyncEventMessage msg: + return GetMethodName(msg.functionHash, "InvokeSyncEvent"); + } + return null; + } + + private string GetMethodName(int functionHash, string prefix) + { + string fullMethodName = NetworkBehaviour.GetRpcHandler(functionHash).Method.Name; + if (fullMethodName.StartsWith(prefix, StringComparison.Ordinal)) + return fullMethodName.Substring(prefix.Length); + + return fullMethodName; + } + + private GameObject GetGameObject(IMessageBase message) + { + uint netId = 0; + + switch (message) + { + case CommandMessage msg: + netId = msg.netId; + break; + case UpdateVarsMessage msg: + netId = msg.netId; + break; + case RpcMessage msg: + netId = msg.netId; + break; + case SyncEventMessage msg: + netId = msg.netId; + break; + case ObjectDestroyMessage msg: + netId = msg.netId; + break; + case SpawnMessage msg: + return msg.sceneId != 0 ? GetSceneObject(msg.sceneId) : GetPrefab(msg.assetId); + default: + return null; + } + + if (NetworkIdentity.spawned.TryGetValue(netId, out NetworkIdentity id)) + { + return id.gameObject; + } + return null; + } + + private GameObject GetSceneObject(ulong sceneId) + { + try + { + NetworkIdentity[] ids = Resources.FindObjectsOfTypeAll(); + + foreach (var id in ids) + { + if (id.sceneId == sceneId) + return id.gameObject; + } + return null; + } + catch (Exception) + { + return null; + } + } + + private GameObject GetPrefab(Guid assetId) + { + var networkManager = NetworkManager.singleton; + + if (networkManager == null) + return null; + + GameObject playerPrefab = networkManager.playerPrefab; + if (playerPrefab != null) + { + NetworkIdentity id = playerPrefab.GetComponent(); + if (id != null && id.assetId == assetId) + { + return playerPrefab; + } + } + + foreach (var prefab in networkManager.spawnPrefabs) + { + NetworkIdentity id = prefab.GetComponent(); + if (id != null && id.assetId == assetId) + { + return prefab; + } + } + return null; + } + + /// + /// Saves the current tick array to the specified file relative to the executing assembly + /// + /// The filename to save to + public void Save(string filename) + { + using (MemoryStream stream = new MemoryStream()) + { + var formatter = new System.Runtime.Serialization.Formatters.Binary.BinaryFormatter(); + formatter.Serialize(stream, this.Ticks); + File.WriteAllBytes(filename, stream.GetBuffer()); + } + } + + /// + /// Loads the ticks from the specified filename + /// + /// The filename of the capture to load + public void Load(string filename) + { + FileStream stream = File.OpenRead(filename); + var formatter = new System.Runtime.Serialization.Formatters.Binary.BinaryFormatter(); + this.ticks = (List)formatter.Deserialize(stream); + } + + /// + /// Clears out all the ticks + /// + public void Clear() + { + this.ticks.Clear(); + } + + internal NetworkProfileTick GetTick(int frame) + { + var tick = Ticks.FirstOrDefault(t => t.frameCount == frame); + + if (tick.frameCount != frame) + tick.frameCount = frame; + + return tick; + } + + public NetworkProfileTick GetNextMessageTick(int frame) + { + var tick = Ticks.FirstOrDefault(t => t.frameCount > frame); + if (tick.frameCount == 0) + tick.frameCount = frame + 1; + return tick; + } + + public NetworkProfileTick GetPrevMessageTick(int frame) + { + var tick = Ticks.LastOrDefault(t => t.frameCount < frame); + if (tick.frameCount == 0) + tick.frameCount = frame - 1; + return tick; + } + } +} diff --git a/Assets/Mirror/Profiler/NetworkProfiler.cs.meta b/Assets/Mirror/Profiler/NetworkProfiler.cs.meta new file mode 100644 index 000000000..6310b48d5 --- /dev/null +++ b/Assets/Mirror/Profiler/NetworkProfiler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eb842af19bf94bc4e83b08a766963df9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/NetworkProfilerTick.cs b/Assets/Mirror/Profiler/NetworkProfilerTick.cs new file mode 100644 index 000000000..5b7fc4103 --- /dev/null +++ b/Assets/Mirror/Profiler/NetworkProfilerTick.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace Mirror.Profiler +{ + /// + /// The direction of network traffic + /// + [Serializable] + public enum NetworkDirection + { + /// + /// Data/Message is coming from a remote host + /// + Incoming = 0, + + /// + /// Data/Message going to a remote host + /// + Outgoing = 1 + } + + /// + /// Stores a network tick + /// + [Serializable] + public struct NetworkProfileTick + { + /// + /// The Time.time at the moment tick collection ended + /// + public int frameCount; + + private List messages; + internal float time; + + /// + /// The summary of messages captured during the tick + /// + public List Messages + { + get + { + messages = messages ?? new List(); + return messages; + } + } + + /// + /// Records the message to the current tick + /// + public void RecordMessage(NetworkProfileMessage networkProfileMessage) + { + Messages.Add(networkProfileMessage); + } + + public int Count(NetworkDirection direction) + { + int total = 0; + foreach (var message in Messages) + if (message.Direction == direction) + total += message.Count; + return total; + } + + public int Bytes(NetworkDirection direction) + { + int total = 0; + foreach (var message in Messages) + if (message.Direction == direction) + total += message.Count * message.Size; + return total; + } + + public int TotalMessages() + { + int total = 0; + foreach (var message in Messages) + total +=message.Count; + return total; + } + } + + /// + /// Stores a network profile message + /// + [Serializable] + public struct NetworkProfileMessage + { + public NetworkDirection Direction; + public string Type; + public string Name; + public int Channel; + public int Size; + public int Count; + + [NonSerialized] + private GameObject gameObject; + + public GameObject GameObject + { + get => gameObject; + set + { + gameObject = value; + Object = value == null ? "" : value.name; + } + } + + public string Object { get; private set; } + } + +} diff --git a/Assets/Mirror/Profiler/NetworkProfilerTick.cs.meta b/Assets/Mirror/Profiler/NetworkProfilerTick.cs.meta new file mode 100644 index 000000000..560911939 --- /dev/null +++ b/Assets/Mirror/Profiler/NetworkProfilerTick.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a08bddf54468b4432912e474dabc9457 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/NetworkProfilerWindow.cs b/Assets/Mirror/Profiler/NetworkProfilerWindow.cs new file mode 100644 index 000000000..376b4b83f --- /dev/null +++ b/Assets/Mirror/Profiler/NetworkProfilerWindow.cs @@ -0,0 +1,436 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEditor.IMGUI.Controls; +using UnityEngine; +using Mirror.Profiler.Chart; +using Mirror.Profiler.Table; +using System.Linq; + +namespace Mirror.Profiler +{ + public class NetworkProfilerWindow : EditorWindow + { + public enum ChartSeries { + MessageCount, + TotalBytes, + Bandwidth + } + + public const int MaxFrames = 1000; + public const int MaxTicks = 300; + + private const string SaveEditorPrefKey = "Mirror.NetworkProfilerWindow.Record"; + private const string CaptureFileExtension = "netdata"; + private const float EstimatedOverheadPerMessage = 70f; + + public int activeConfigIndex; + private GUIStyle headerStyle; + private Vector2 leftScrollPosition; + private readonly NetworkProfiler networkProfiler = new NetworkProfiler(MaxFrames); + internal NetworkProfileTick selectedTick; + TreeModel treeModel; + + private readonly ChartView chart; + + public GUIStyle columnHeaderStyle { get; private set; } + + + public NetworkProfilerWindow() + { + var inCount = GetCountSeriesByFrame("Inbound (count)", NetworkDirection.Incoming); + var outCount = GetCountSeriesByFrame("Outbound (count)", NetworkDirection.Outgoing); + + chart = new ChartView(inCount,outCount) + { + MaxFrames = MaxTicks, + }; + + chart.OnSelectFrame += Chart_OnSelectFrame; + } + + private void Chart_OnSelectFrame(int frame) + { + if (ShowAllFrames) + { + NetworkProfileTick t = networkProfiler.GetTick(frame); + Show(t); + } + else if (frame >= 0 && frame < networkProfiler.Ticks.Count) + { + NetworkProfileTick t = networkProfiler.Ticks[frame]; + Show(t); + } + else + { + Show(new NetworkProfileTick()); + } + } + +#region Render window + + [NonSerialized] bool m_Initialized; + [SerializeField] TreeViewState m_TreeViewState; // Serialized in the window layout file so it survives assembly reloading + [SerializeField] MultiColumnHeaderState m_MultiColumnHeaderState; + MultiColumnTreeView m_TreeView; + + void InitIfNeeded() + { + if (!m_Initialized) + { + // Check if it already exists (deserialized from window layout file or scriptable object) + if (m_TreeViewState == null) + m_TreeViewState = new TreeViewState(); + + bool firstInit = m_MultiColumnHeaderState == null; + MultiColumnHeaderState headerState = MultiColumnTreeView.CreateDefaultMultiColumnHeaderState(); + if (MultiColumnHeaderState.CanOverwriteSerializedFields(m_MultiColumnHeaderState, headerState)) + MultiColumnHeaderState.OverwriteSerializedFields(m_MultiColumnHeaderState, headerState); + m_MultiColumnHeaderState = headerState; + + MultiColumnHeader multiColumnHeader = new MultiColumnHeader(headerState); + if (firstInit) + multiColumnHeader.ResizeToFit(); + + treeModel = new TreeModel(GetData(selectedTick)); + + m_TreeView = new MultiColumnTreeView(m_TreeViewState, multiColumnHeader, treeModel); + + m_Initialized = true; + } + } + + IList GetData(NetworkProfileTick tick) + { + // generate some test data + return MyTreeElementGenerator.GenerateTable(tick); + } + + void DoTreeView(Rect rect) + { + InitIfNeeded(); + m_TreeView.OnGUI(rect); + } + + private void OnEnable() + { + this.networkProfiler.IsRecording = EditorPrefs.GetBool(SaveEditorPrefKey, false); + ConfigureProfiler(); + } + + public void OnGUI() + { + if (headerStyle == null) + { + headerStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleLeft, fontStyle = FontStyle.Bold, fontSize = 14 }; + } + + EditorGUILayout.BeginVertical(GUILayout.ExpandHeight(true), GUILayout.ExpandWidth(true)); + + this.DrawCommandBar(); + + Rect rect = GUILayoutUtility.GetRect(10, 1000, 200, 200); + + this.chart.OnGUI(rect); + + Rect treeRect = GUILayoutUtility.GetRect(new GUIContent(), new GUIStyle(), GUILayout.ExpandHeight(true), GUILayout.ExpandWidth(true)); + this.DoTreeView(treeRect); + + EditorGUILayout.EndVertical(); + + this.Repaint(); + } + + private void DrawCommandBar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar, GUILayout.ExpandWidth(true)); + + bool newValue = GUILayout.Toggle(networkProfiler.IsRecording, "Record", EditorStyles.toolbarButton); + + if (newValue != networkProfiler.IsRecording) + { + EditorPrefs.SetBool(SaveEditorPrefKey, newValue); + } + + networkProfiler.IsRecording = newValue; + + if (GUILayout.Button("Clear", EditorStyles.toolbarButton)) + { + this.networkProfiler.Clear(); + } + + if (GUILayout.Button("Save", EditorStyles.toolbarButton)) + { + string path = EditorUtility.SaveFilePanel("Save Network Profile", null, "Capture", CaptureFileExtension); + if (!string.IsNullOrEmpty(path)) + { + this.networkProfiler.Save(path); + } + } + + if (GUILayout.Button("Load", EditorStyles.toolbarButton)) + { + string path = EditorUtility.OpenFilePanel("Open Network Profile", null, CaptureFileExtension); + if (!string.IsNullOrEmpty(path)) + { + this.networkProfiler.Load(path); + + if (this.networkProfiler.Ticks.Count > 0) + { + Show(networkProfiler.Ticks[0]); + this.ConfigureProfiler(); + } + } + } + GUILayout.FlexibleSpace(); + + var leftIcon = EditorGUIUtility.TrIconContent("Animation.PrevKey", "Previous Messages"); + if (GUILayout.Button(leftIcon, EditorStyles.toolbarButton)) + { + if (ShowAllFrames) + { + NetworkProfileTick tick = networkProfiler.GetPrevMessageTick(chart.SelectedFrame); + + chart.SelectedFrame = tick.frameCount; + } + else + { + chart.SelectedFrame -= 1; + } + } + + var rightIcon = EditorGUIUtility.TrIconContent("Animation.NextKey", "Next Messages"); + if (GUILayout.Button(rightIcon, EditorStyles.toolbarButton)) + { + if (ShowAllFrames) + { + NetworkProfileTick tick = networkProfiler.GetNextMessageTick(chart.SelectedFrame); + + chart.SelectedFrame = tick.frameCount; + } + else + { + chart.SelectedFrame += 1; + } + } + + var optionsIcon = EditorGUIUtility.TrIconContent("_Popup", "Options"); + if (EditorGUILayout.DropdownButton(optionsIcon , FocusType.Passive, EditorStyles.toolbarButton)) + { + GenericMenu menu = new GenericMenu(); + + menu.AddItem(new GUIContent("Message count"), Series == ChartSeries.MessageCount, OnSeriesSelected, ChartSeries.MessageCount); + menu.AddItem(new GUIContent("Total bytes"), Series == ChartSeries.TotalBytes, OnSeriesSelected, ChartSeries.TotalBytes); + menu.AddItem(new GUIContent("Estimated Bandwidth"), Series == ChartSeries.Bandwidth, OnSeriesSelected, ChartSeries.Bandwidth); + menu.AddSeparator(""); + menu.AddItem(new GUIContent("Show all frames"), ShowAllFrames, () => + { + ShowAllFrames = !ShowAllFrames; + }); + + menu.ShowAsContext(); + } + EditorGUILayout.EndHorizontal(); + } + + + #region Chart options + + private void OnSeriesSelected(object series) + { + EditorPrefs.SetString("Mirror.Profiler.Series", series.ToString()); + ConfigureProfiler(); + } + + public ChartSeries Series => + (ChartSeries)Enum.Parse(typeof(ChartSeries), EditorPrefs.GetString("Mirror.Profiler.Series", ChartSeries.MessageCount.ToString())); + + public bool ShowAllFrames + { + get => EditorPrefs.GetBool("Mirror.Profiler.AllFrames", false); + set + { + EditorPrefs.SetBool("Mirror.Profiler.AllFrames", value); + ConfigureProfiler(); + } + } + + private void ConfigureProfiler() + { + ISeries inSeries; + ISeries outSeries; + + if (ShowAllFrames) + { + networkProfiler.MaxFrames = MaxFrames; + networkProfiler.MaxTicks = int.MaxValue; + chart.MaxFrames = MaxFrames; + + (inSeries, outSeries) = AllFrameSeries(); + } + else + { + networkProfiler.MaxFrames = int.MaxValue; + networkProfiler.MaxTicks = MaxTicks; + chart.MaxFrames = MaxTicks; + + (inSeries, outSeries) = AllTicksSeries(); + } + + chart.Series[0] = inSeries; + chart.Series[1] = outSeries; + } + + private (ISeries, ISeries) AllFrameSeries() + { + switch (Series) + { + case ChartSeries.TotalBytes: + return ( + GetByteSeriesByFrame("Inbound (bytes)", NetworkDirection.Incoming), + GetByteSeriesByFrame("Outbound (bytes)", NetworkDirection.Outgoing)); + + case ChartSeries.MessageCount: + return ( + GetCountSeriesByFrame("Inbound (bytes)", NetworkDirection.Incoming), + GetCountSeriesByFrame("Outbound (bytes)", NetworkDirection.Outgoing)); + + case ChartSeries.Bandwidth: + return ( + GetBandwidthSeriesByFrame("Inbound (bytes/s)", NetworkDirection.Incoming), + GetBandwidthSeriesByFrame("Outbound (bytes/s)", NetworkDirection.Outgoing)); + + default: + return ( + GetByteSeriesByFrame("Inbound (bytes)", NetworkDirection.Incoming), + GetByteSeriesByFrame("Outbound (bytes)", NetworkDirection.Outgoing)); + } + } + + private (ISeries, ISeries) AllTicksSeries() + { + switch (Series) + { + case ChartSeries.TotalBytes: + return ( + GetByteSeriesByIndex("Inbound (bytes)", NetworkDirection.Incoming), + GetByteSeriesByIndex("Outbound (bytes)", NetworkDirection.Outgoing)); + case ChartSeries.MessageCount: + return ( + GetCountSeriesByIndex("Inbound (bytes)", NetworkDirection.Incoming), + GetCountSeriesByIndex("Outbound (bytes)", NetworkDirection.Outgoing)); + case ChartSeries.Bandwidth: + return ( + GetBandwidthSeriesByIndex("Inbound (bytes/s)", NetworkDirection.Incoming), + GetBandwidthSeriesByIndex("Outbound (bytes/s)", NetworkDirection.Outgoing)); + default: + throw new Exception("Invalid chart type"); + } + } + + + private ISeries GetCountSeriesByFrame(string legend, NetworkDirection direction) + { + var data = networkProfiler.Ticks.Select(tick => (tick.frameCount, (float)tick.Count(direction))); + + // append the current frame + var lastTick = Enumerable.Range(0, 1).Select(_ => + { + var tick = networkProfiler.CurrentTick(); + return (tick.frameCount, (float)tick.Count(direction)); + }); + + return new Series(legend, data.Concat(lastTick)); + } + + private ISeries GetByteSeriesByFrame(string legend, NetworkDirection direction) + { + var data = networkProfiler.Ticks.Select(tick => (tick.frameCount, (float)tick.Bytes(direction))); + + // append the current frame + var lastTick = Enumerable.Range(0, 1).Select(_ => + { + var tick = networkProfiler.CurrentTick(); + return (tick.frameCount, (float)tick.Bytes(direction)); + }); + + return new Series(legend, data.Concat(lastTick)); + } + + private ISeries GetCountSeriesByIndex(string legend, NetworkDirection direction) + { + var data = networkProfiler.Ticks.Select((tick, i) => (i, (float)tick.Count(direction))); + + return new Series(legend, data); + } + + private ISeries GetByteSeriesByIndex(string legend, NetworkDirection direction) + { + var data = networkProfiler.Ticks.Select((tick, i) => (i, (float)tick.Bytes(direction))); + + return new Series(legend, data); + } + + private ISeries GetBandwidthSeriesByIndex(string legend, NetworkDirection direction) + { + var tickData = networkProfiler.Ticks; + var tick1 = new[] + { + new NetworkProfileTick + { + frameCount = 0, + time =0 + } + }; + var allTicks = tick1.Concat(tickData); + + var data = allTicks + .Zip(tickData, (prevTick, newTick) => EstimateBandwidth(newTick, prevTick, direction)) + .Select((value, i) => (i - 1, value)); + + return new Series(legend, data); + } + + private float EstimateBandwidth(NetworkProfileTick tick, NetworkProfileTick prevTick, NetworkDirection direction) + { + float estimatedBytes = tick.Count(direction) * EstimatedOverheadPerMessage + tick.Bytes(direction); + + if (prevTick.frameCount == 0 || tick.time <= prevTick.time) + return estimatedBytes; + + return estimatedBytes / (tick.time - prevTick.time); + } + + private ISeries GetBandwidthSeriesByFrame(string legend, NetworkDirection direction) + { + var data = networkProfiler.Ticks.Select(tick => (tick.frameCount, tick.Bytes(direction) + tick.Count(direction) * EstimatedOverheadPerMessage)); + + // append the current frame + var lastTick = Enumerable.Range(0, 1).Select(_ => + { + var tick = networkProfiler.CurrentTick(); + return (tick.frameCount, tick.Bytes(direction) + tick.Count(direction) * EstimatedOverheadPerMessage); + }); + + return new Series(legend, data.Concat(lastTick)); + + } + + #endregion + + + [MenuItem("Window/Analysis/Mirror Network Profiler", false, 0)] + public static void ShowWindow() + { + GetWindow("Mirror Network Profiler"); + } + + public void Show(NetworkProfileTick tick) + { + selectedTick = tick; + treeModel.SetData(GetData(selectedTick)); + } + +#endregion + } +} diff --git a/Assets/Mirror/Profiler/NetworkProfilerWindow.cs.meta b/Assets/Mirror/Profiler/NetworkProfilerWindow.cs.meta new file mode 100644 index 000000000..60ec9912a --- /dev/null +++ b/Assets/Mirror/Profiler/NetworkProfilerWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a090c2f7eb0f55941be54dad139bc759 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/README.md b/Assets/Mirror/Profiler/README.md new file mode 100644 index 000000000..19a2c37d4 --- /dev/null +++ b/Assets/Mirror/Profiler/README.md @@ -0,0 +1,14 @@ +# Mirror profiler + +The mirror profiler is a graphical tool for diagnosing network performance with mirror. + +## Install + +1) Clone mirror repository in https://github.com/vis2k/Mirror +2) Clone this repository inside Assets/Mirror/Profiler +3) Open whole project in Unity +4) Open the profiler in Window -> Analysis -> Mirror Network Profiler +5) Open the basic example scene +6) Hit play +7) click "Record" in the mirror network profiler +8) click on "Start Host" diff --git a/Assets/Mirror/Profiler/README.md.meta b/Assets/Mirror/Profiler/README.md.meta new file mode 100644 index 000000000..ad29ba53a --- /dev/null +++ b/Assets/Mirror/Profiler/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 41e0c9adc640542668992e114c4ed136 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/Table.meta b/Assets/Mirror/Profiler/Table.meta new file mode 100644 index 000000000..54dc0e337 --- /dev/null +++ b/Assets/Mirror/Profiler/Table.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f6aecdbb5da6c4891b64f929e4a11ec7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/Table/MultiColumnTreeView.cs b/Assets/Mirror/Profiler/Table/MultiColumnTreeView.cs new file mode 100644 index 000000000..e467bd0ef --- /dev/null +++ b/Assets/Mirror/Profiler/Table/MultiColumnTreeView.cs @@ -0,0 +1,384 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEditor.IMGUI.Controls; +using UnityEngine; +using UnityEngine.Assertions; + +namespace Mirror.Profiler.Table +{ + internal class MultiColumnTreeView : TreeViewWithTreeModel + { + const float KRowHeights = 20f; + const float KToggleWidth = 18f; + public bool showControls = true; + + // All columns + enum MyColumns + { + Direction, + Name, + Object, + Count, + Bytes, + TotalBytes, + Channel, + } + + public enum SortOption + { + Direction, + Name, + Object, + Count, + Bytes, + TotalBytes, + Channel, + } + + // Sort options per column + readonly SortOption[] m_SortOptions = + { + SortOption.Direction, + SortOption.Name, + SortOption.Object, + SortOption.Count, + SortOption.Bytes, + SortOption.TotalBytes, + SortOption.Channel + }; + + public static void TreeToList(TreeViewItem root, IList result) + { + if (root == null) + throw new NullReferenceException("root"); + if (result == null) + throw new NullReferenceException("result"); + + result.Clear(); + + if (root.children == null) + return; + + Stack stack = new Stack(); + for (int i = root.children.Count - 1; i >= 0; i--) + stack.Push(root.children[i]); + + while (stack.Count > 0) + { + TreeViewItem current = stack.Pop(); + result.Add(current); + + if (current.hasChildren && current.children[0] != null) + { + for (int i = current.children.Count - 1; i >= 0; i--) + { + stack.Push(current.children[i]); + } + } + } + } + + public MultiColumnTreeView(TreeViewState state, MultiColumnHeader multicolumnHeader, TreeModel model) : base(state, multicolumnHeader, model) + { + Assert.AreEqual(m_SortOptions.Length, Enum.GetValues(typeof(MyColumns)).Length, "Ensure number of sort options are in sync with number of MyColumns enum values"); + + // Custom setup + rowHeight = KRowHeights; + columnIndexForTreeFoldouts = 1; + showAlternatingRowBackgrounds = true; + showBorder = true; + customFoldoutYOffset = (KRowHeights - EditorGUIUtility.singleLineHeight) * 0.5f; // center foldout in the row since we also center content. See RowGUI + extraSpaceBeforeIconAndLabel = KToggleWidth; + multicolumnHeader.sortingChanged += OnSortingChanged; + + Reload(); + } + + + // Note we We only build the visible rows, only the backend has the full tree information. + // The treeview only creates info for the row list. + protected override IList BuildRows(TreeViewItem root) + { + IList rows = base.BuildRows(root); + SortIfNeeded(root, rows); + return rows; + } + + void OnSortingChanged(MultiColumnHeader _) + { + SortIfNeeded(rootItem, GetRows()); + } + + void SortIfNeeded(TreeViewItem root, IList rows) + { + if (rows.Count <= 1) + return; + + if (multiColumnHeader.sortedColumnIndex == -1) + { + return; // No column to sort for (just use the order the data are in) + } + + // Sort the roots of the existing tree items + SortByMultipleColumns(); + TreeToList(root, rows); + Repaint(); + } + + void SortByMultipleColumns() + { + int[] sortedColumns = multiColumnHeader.state.sortedColumns; + + if (sortedColumns.Length == 0) + return; + + IEnumerable> myTypes = rootItem.children.Cast>(); + IOrderedEnumerable> orderedQuery = InitialOrder(myTypes, sortedColumns); + for (int i = 1; i < sortedColumns.Length; i++) + { + SortOption sortOption = m_SortOptions[sortedColumns[i]]; + bool ascending = multiColumnHeader.IsSortedAscending(sortedColumns[i]); + + switch (sortOption) + { + case SortOption.Direction: + orderedQuery = orderedQuery.ThenBy(l => l.data.message.Direction, ascending); + break; + case SortOption.Name: + orderedQuery = orderedQuery.ThenBy(l => l.data.name, ascending); + break; + case SortOption.Object: + orderedQuery = orderedQuery.ThenBy(l => l.data.message.Object, ascending); + break; + case SortOption.Count: + orderedQuery = orderedQuery.ThenBy(l => l.data.message.Count, ascending); + break; + case SortOption.Bytes: + orderedQuery = orderedQuery.ThenBy(l => l.data.message.Size, ascending); + break; + case SortOption.TotalBytes: + orderedQuery = orderedQuery.ThenBy(l => l.data.message.Size * l.data.message.Count, ascending); + break; + case SortOption.Channel: + orderedQuery = orderedQuery.ThenBy(l => l.data.message.Channel, ascending); + break; + } + } + + rootItem.children = orderedQuery.Cast().ToList(); + } + + IOrderedEnumerable> InitialOrder(IEnumerable> myTypes, int[] history) + { + SortOption sortOption = m_SortOptions[history[0]]; + bool ascending = multiColumnHeader.IsSortedAscending(history[0]); + switch (sortOption) + { + case SortOption.Direction: + return myTypes.Order(l => l.data.message.Direction, ascending); + case SortOption.Name: + return myTypes.Order(l => l.data.name, ascending); + case SortOption.Object: + return myTypes.Order(l => l.data.message.Object, ascending); + case SortOption.Count: + return myTypes.Order(l => l.data.message.Count, ascending); + case SortOption.Bytes: + return myTypes.Order(l => l.data.message.Size, ascending); + case SortOption.TotalBytes: + return myTypes.Order(l => l.data.message.Size * l.data.message.Count, ascending); + case SortOption.Channel: + return myTypes.Order(l => l.data.message.Channel, ascending); + default: + Assert.IsTrue(false, "Unhandled enum"); + break; + } + + // default + return myTypes.Order(l => l.data.name, ascending); + } + + protected override void RowGUI(RowGUIArgs args) + { + TreeViewItem item = (TreeViewItem)args.item; + + for (int i = 0; i < args.GetNumVisibleColumns(); ++i) + { + CellGUI(args.GetCellRect(i), item, (MyColumns)args.GetColumn(i), ref args); + } + } + + void CellGUI(Rect cellRect, TreeViewItem item, MyColumns column, ref RowGUIArgs args) + { + // Center cell rect vertically (makes it easier to place controls, icons etc in the cells) + CenterRectUsingSingleLineHeight(ref cellRect); + + string value = ""; + + switch (column) + { + case MyColumns.Direction: + value = item.data.message.Direction == NetworkDirection.Incoming ? "In" : "Out"; + break; + + case MyColumns.Name: + value = item.data.name; + break; + + case MyColumns.Object: + value = item.data.message.Object; + break; + + case MyColumns.Count: + value = item.data.message.Count.ToString(); + break; + + case MyColumns.Bytes: + value = item.data.message.Size.ToString(); + break; + + case MyColumns.TotalBytes: + value = (item.data.message.Size * item.data.message.Count).ToString(); + break; + + case MyColumns.Channel: + value = item.data.message.Channel < 0 ? "" : item.data.message.Channel.ToString(); + break; + + } + + DefaultGUI.Label(cellRect, value, args.selected, args.focused); + } + + protected override void SingleClickedItem(int id) + { + base.SingleClickedItem(id); + + IList rows = GetRows(); + + TreeViewItem item = (TreeViewItem)rows[id - 1]; + + GameObject gameobject = item.data.message.GameObject; + if (gameobject != null) + { + Selection.activeGameObject = gameobject; + } + } + // Misc + //-------- + + protected override bool CanMultiSelect(TreeViewItem item) + { + return true; + } + + public static MultiColumnHeaderState CreateDefaultMultiColumnHeaderState() + { + MultiColumnHeaderState.Column[] columns = { + new MultiColumnHeaderState.Column + { + headerContent = new GUIContent("In/Out"), + contextMenuText = "In/Out", + headerTextAlignment = TextAlignment.Left, + sortedAscending = true, + sortingArrowAlignment = TextAlignment.Right, + width = 50, + minWidth = 30, + maxWidth = 60, + autoResize = false, + allowToggleVisibility = true + }, + new MultiColumnHeaderState.Column + { + headerContent = new GUIContent("Name"), + headerTextAlignment = TextAlignment.Left, + sortedAscending = true, + sortingArrowAlignment = TextAlignment.Right, + width = 110, + minWidth = 60, + autoResize = true, + allowToggleVisibility = false + }, + new MultiColumnHeaderState.Column + { + headerContent = new GUIContent("Object"), + headerTextAlignment = TextAlignment.Left, + sortedAscending = true, + sortingArrowAlignment = TextAlignment.Right, + width = 110, + minWidth = 60, + autoResize = true, + allowToggleVisibility = false + }, + new MultiColumnHeaderState.Column + { + headerContent = new GUIContent("Count", "How many observers received the message" ), + headerTextAlignment = TextAlignment.Left, + sortedAscending = true, + sortingArrowAlignment = TextAlignment.Right, + width = 70, + minWidth = 60, + autoResize = false + }, + new MultiColumnHeaderState.Column + { + headerContent = new GUIContent("Bytes", "How big was the message. Not including transport headers"), + headerTextAlignment = TextAlignment.Left, + sortedAscending = true, + sortingArrowAlignment = TextAlignment.Right, + width = 70, + minWidth = 60, + autoResize = false, + allowToggleVisibility = true + }, + new MultiColumnHeaderState.Column + { + headerContent = new GUIContent("Total Bytes", "Total amount of bytes sent (Count * Bytes) "), + headerTextAlignment = TextAlignment.Left, + sortedAscending = true, + sortingArrowAlignment = TextAlignment.Right, + width = 70, + minWidth = 60, + autoResize = false + }, + new MultiColumnHeaderState.Column + { + headerContent = new GUIContent("Channel", "Channel through which the message was sent"), + headerTextAlignment = TextAlignment.Left, + sortedAscending = true, + sortingArrowAlignment = TextAlignment.Right, + width = 70, + minWidth = 60, + autoResize = false + } + }; + + Assert.AreEqual(columns.Length, Enum.GetValues(typeof(MyColumns)).Length, "Number of columns should match number of enum values: You probably forgot to update one of them."); + + MultiColumnHeaderState state = new MultiColumnHeaderState(columns); + return state; + } + } + + static class MyExtensionMethods + { + public static IOrderedEnumerable Order(this IEnumerable source, Func selector, bool ascending) + { + if (ascending) + { + return source.OrderBy(selector); + } + return source.OrderByDescending(selector); + } + + public static IOrderedEnumerable ThenBy(this IOrderedEnumerable source, Func selector, bool ascending) + { + if (ascending) + { + return source.ThenBy(selector); + } + return source.ThenByDescending(selector); + } + } +} diff --git a/Assets/Mirror/Profiler/Table/MultiColumnTreeView.cs.meta b/Assets/Mirror/Profiler/Table/MultiColumnTreeView.cs.meta new file mode 100644 index 000000000..414b4b65f --- /dev/null +++ b/Assets/Mirror/Profiler/Table/MultiColumnTreeView.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1b0852662036646439b19f80d65f25c4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/Table/MyTreeElement.cs b/Assets/Mirror/Profiler/Table/MyTreeElement.cs new file mode 100644 index 000000000..d49a766f0 --- /dev/null +++ b/Assets/Mirror/Profiler/Table/MyTreeElement.cs @@ -0,0 +1,21 @@ +using System; +using UnityEngine; +using Random = UnityEngine.Random; + + +namespace Mirror.Profiler.Table +{ + + [Serializable] + internal class MyTreeElement : TreeElement + { + public NetworkProfileMessage message; + + public MyTreeElement(NetworkProfileMessage message, int depth, int id) : base(depth, id) + { + this.message = message; + } + + public override string name => message.Name ?? message.Type ?? ""; + } +} diff --git a/Assets/Mirror/Profiler/Table/MyTreeElement.cs.meta b/Assets/Mirror/Profiler/Table/MyTreeElement.cs.meta new file mode 100644 index 000000000..dc1342e82 --- /dev/null +++ b/Assets/Mirror/Profiler/Table/MyTreeElement.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 040ad8037c1ae49ab95def4bf2b9cea3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/Table/MyTreeElementGenerator.cs b/Assets/Mirror/Profiler/Table/MyTreeElementGenerator.cs new file mode 100644 index 000000000..b0941be64 --- /dev/null +++ b/Assets/Mirror/Profiler/Table/MyTreeElementGenerator.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using Random = UnityEngine.Random; + + +namespace Mirror.Profiler.Table +{ + + static class MyTreeElementGenerator + { + internal static IList GenerateTable(NetworkProfileTick tick) + { + int IDCounter = 0; + + List treeElements = new List(tick.Messages.Count); + MyTreeElement root = new MyTreeElement(new NetworkProfileMessage(), -1, IDCounter++); + + treeElements.Add(root); + + foreach (var message in tick.Messages) + { + MyTreeElement child = new MyTreeElement(message, 0, IDCounter++); + treeElements.Add(child); + } + + return treeElements; + } + } +} diff --git a/Assets/Mirror/Profiler/Table/MyTreeElementGenerator.cs.meta b/Assets/Mirror/Profiler/Table/MyTreeElementGenerator.cs.meta new file mode 100644 index 000000000..3eb72af9a --- /dev/null +++ b/Assets/Mirror/Profiler/Table/MyTreeElementGenerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b7c37deb0866a48539dc2183b612b598 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/Table/TreeElement.cs b/Assets/Mirror/Profiler/Table/TreeElement.cs new file mode 100644 index 000000000..baae8bf51 --- /dev/null +++ b/Assets/Mirror/Profiler/Table/TreeElement.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + + +namespace Mirror.Profiler.Table +{ + + [Serializable] + public abstract class TreeElement + { + [SerializeField] int m_ID; + [SerializeField] int m_Depth; + [NonSerialized] TreeElement m_Parent; + [NonSerialized] List m_Children; + + public int depth + { + get { return m_Depth; } + set { m_Depth = value; } + } + + public TreeElement parent + { + get { return m_Parent; } + set { m_Parent = value; } + } + + public abstract string name + { + get; + } + + public List children + { + get { return m_Children; } + set { m_Children = value; } + } + + public bool hasChildren + { + get { return children != null && children.Count > 0; } + } + + public int id + { + get { return m_ID; } + set { m_ID = value; } + } + + public TreeElement() + { + } + + public TreeElement( int depth, int id) + { + m_ID = id; + m_Depth = depth; + } + } + +} + + diff --git a/Assets/Mirror/Profiler/Table/TreeElement.cs.meta b/Assets/Mirror/Profiler/Table/TreeElement.cs.meta new file mode 100644 index 000000000..9b1cd7da5 --- /dev/null +++ b/Assets/Mirror/Profiler/Table/TreeElement.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6018fe3de1538447190001b95d052dad +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/Table/TreeElementUtility.cs b/Assets/Mirror/Profiler/Table/TreeElementUtility.cs new file mode 100644 index 000000000..a58f6118d --- /dev/null +++ b/Assets/Mirror/Profiler/Table/TreeElementUtility.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; + +namespace Mirror.Profiler.Table +{ + + // TreeElementUtility and TreeElement are useful helper classes for backend tree data structures. + // See tests at the bottom for examples of how to use. + + public static class TreeElementUtility + { + // Returns the root of the tree parsed from the list (always the first element). + // Important: the first item and is required to have a depth value of -1. + // The rest of the items should have depth >= 0. + public static T ListToTree(IList list) where T : TreeElement + { + // Validate input + ValidateDepthValues(list); + + // Clear old states + foreach (var element in list) + { + element.parent = null; + element.children = null; + } + + // Set child and parent references using depth info + for (int parentIndex = 0; parentIndex < list.Count; parentIndex++) + { + var parent = list[parentIndex]; + bool alreadyHasValidChildren = parent.children != null; + if (alreadyHasValidChildren) + continue; + + int parentDepth = parent.depth; + int childCount = 0; + + // Count children based depth value, we are looking at children until it's the same depth as this object + for (int i = parentIndex + 1; i < list.Count; i++) + { + if (list[i].depth == parentDepth + 1) + childCount++; + if (list[i].depth <= parentDepth) + break; + } + + // Fill child array + List childList = null; + if (childCount != 0) + { + childList = new List(childCount); // Allocate once + childCount = 0; + for (int i = parentIndex + 1; i < list.Count; i++) + { + if (list[i].depth == parentDepth + 1) + { + list[i].parent = parent; + childList.Add(list[i]); + childCount++; + } + + if (list[i].depth <= parentDepth) + break; + } + } + + parent.children = childList; + } + + return list[0]; + } + + // Check state of input list + public static void ValidateDepthValues(IList list) where T : TreeElement + { + if (list.Count == 0) + throw new ArgumentException("list should have items, count is 0, check before calling ValidateDepthValues", "list"); + + if (list[0].depth != -1) + throw new ArgumentException("list item at index 0 should have a depth of -1 (since this should be the hidden root of the tree). Depth is: " + list[0].depth, "list"); + + for (int i = 0; i < list.Count - 1; i++) + { + int depth = list[i].depth; + int nextDepth = list[i + 1].depth; + if (nextDepth > depth && nextDepth - depth > 1) + throw new ArgumentException(string.Format("Invalid depth info in input list. Depth cannot increase more than 1 per row. Index {0} has depth {1} while index {2} has depth {3}", i, depth, i + 1, nextDepth)); + } + + for (int i = 1; i < list.Count; ++i) + if (list[i].depth < 0) + throw new ArgumentException("Invalid depth value for item at index " + i + ". Only the first item (the root) should have depth below 0."); + + if (list.Count > 1 && list[1].depth != 0) + throw new ArgumentException("Input list item at index 1 is assumed to have a depth of 0", "list"); + } + + } + +} diff --git a/Assets/Mirror/Profiler/Table/TreeElementUtility.cs.meta b/Assets/Mirror/Profiler/Table/TreeElementUtility.cs.meta new file mode 100644 index 000000000..6403d7da5 --- /dev/null +++ b/Assets/Mirror/Profiler/Table/TreeElementUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ac69cc547e5d04cd999f18d3002e9208 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/Table/TreeModel.cs b/Assets/Mirror/Profiler/Table/TreeModel.cs new file mode 100644 index 000000000..c8d1f768c --- /dev/null +++ b/Assets/Mirror/Profiler/Table/TreeModel.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Mirror.Profiler.Table +{ + // The TreeModel is a utility class working on a list of serializable TreeElements where the order and the depth of each TreeElement define + // the tree structure. Note that the TreeModel itself is not serializable (in Unity we are currently limited to serializing lists/arrays) but the + // input list is. + // The tree representation (parent and children references) are then build internally using TreeElementUtility.ListToTree (using depth + // values of the elements). + // The first element of the input list is required to have depth == -1 (the hiddenroot) and the rest to have + // depth >= 0 (otherwise an exception will be thrown) + + public class TreeModel where T : TreeElement + { + IList m_Data; + T m_Root; + int m_MaxID; + + public T root { get { return m_Root; } set { m_Root = value; } } + public event Action modelChanged; + public int numberOfDataElements + { + get { return m_Data.Count; } + } + + public TreeModel(IList data) + { + SetData(data); + } + + public T Find(int id) + { + return m_Data.FirstOrDefault(element => element.id == id); + } + + public void SetData(IList data) + { + Init(data); + } + + void Init(IList data) + { + m_Data = data ?? throw new ArgumentNullException(nameof(data), "Input data is null. Ensure input is a non-null list."); + + if (m_Data.Count > 0) + m_Root = TreeElementUtility.ListToTree(data); + + m_MaxID = m_Data.Max(e => e.id); + Changed(); + } + + public int GenerateUniqueID() + { + return ++m_MaxID; + } + + public IList GetAncestors(int id) + { + List parents = new List(); + TreeElement T = Find(id); + if (T != null) + { + while (T.parent != null) + { + parents.Add(T.parent.id); + T = T.parent; + } + } + return parents; + } + + public IList GetDescendantsThatHaveChildren(int id) + { + T searchFromThis = Find(id); + if (searchFromThis != null) + { + return GetParentsBelowStackBased(searchFromThis); + } + return new List(); + } + + IList GetParentsBelowStackBased(TreeElement searchFromThis) + { + Stack stack = new Stack(); + stack.Push(searchFromThis); + + List parentsBelow = new List(); + while (stack.Count > 0) + { + TreeElement current = stack.Pop(); + if (current.hasChildren) + { + parentsBelow.Add(current.id); + foreach (var T in current.children) + { + stack.Push(T); + } + } + } + + return parentsBelow; + } + + + void Changed() + { + if (modelChanged != null) + modelChanged(); + } + } + +} diff --git a/Assets/Mirror/Profiler/Table/TreeModel.cs.meta b/Assets/Mirror/Profiler/Table/TreeModel.cs.meta new file mode 100644 index 000000000..a739c3a5b --- /dev/null +++ b/Assets/Mirror/Profiler/Table/TreeModel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d5dcc828380a0402b9d0e40a47005d6b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Profiler/Table/TreeViewWithTreeModel.cs b/Assets/Mirror/Profiler/Table/TreeViewWithTreeModel.cs new file mode 100644 index 000000000..ee1c006dd --- /dev/null +++ b/Assets/Mirror/Profiler/Table/TreeViewWithTreeModel.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEditor.IMGUI.Controls; +using UnityEngine; + + +namespace Mirror.Profiler.Table +{ + + internal class TreeViewItem : TreeViewItem where T : TreeElement + { + public T data { get; set; } + + public TreeViewItem(int id, int depth, string displayName, T data) : base(id, depth, displayName) + { + this.data = data; + } + } + + internal class TreeViewWithTreeModel : TreeView where T : TreeElement + { + readonly List m_Rows = new List(100); + public event Action treeChanged; + + public TreeModel treeModel { get; private set; } + + public TreeViewWithTreeModel(TreeViewState state, TreeModel model) : base(state) + { + Init(model); + } + + public TreeViewWithTreeModel(TreeViewState state, MultiColumnHeader multiColumnHeader, TreeModel model) + : base(state, multiColumnHeader) + { + Init(model); + } + + void Init(TreeModel model) + { + treeModel = model; + treeModel.modelChanged += ModelChanged; + } + + void ModelChanged() + { + treeChanged?.Invoke(); + + Reload(); + } + + protected override TreeViewItem BuildRoot() + { + int depthForHiddenRoot = -1; + return new TreeViewItem(treeModel.root.id, depthForHiddenRoot, treeModel.root.name, treeModel.root); + } + + protected override IList BuildRows(TreeViewItem root) + { + if (treeModel.root == null) + { + Debug.LogError("tree model root is null. did you call SetData()?"); + } + + m_Rows.Clear(); + if (!string.IsNullOrEmpty(searchString)) + { + Search(treeModel.root, searchString, m_Rows); + } + else + { + if (treeModel.root.hasChildren) + AddChildrenRecursive(treeModel.root, 0, m_Rows); + } + + // We still need to setup the child parent information for the rows since this + // information is used by the TreeView internal logic (navigation, dragging etc) + SetupParentsAndChildrenFromDepths(root, m_Rows); + + return m_Rows; + } + + void AddChildrenRecursive(T parent, int depth, IList newRows) + { + foreach (T child in parent.children) + { + var item = new TreeViewItem(child.id, depth, child.name, child); + newRows.Add(item); + + if (child.hasChildren) + { + if (IsExpanded(child.id)) + { + AddChildrenRecursive(child, depth + 1, newRows); + } + else + { + item.children = CreateChildListForCollapsedParent(); + } + } + } + } + + void Search(T searchFromThis, string search, List result) + { + if (string.IsNullOrEmpty(search)) + throw new ArgumentException("Invalid search: cannot be null or empty", "search"); + + const int kItemDepth = 0; // tree is flattened when searching + + Stack stack = new Stack(); + foreach (var element in searchFromThis.children) + stack.Push((T)element); + while (stack.Count > 0) + { + T current = stack.Pop(); + // Matches search? + if (current.name.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0) + { + result.Add(new TreeViewItem(current.id, kItemDepth, current.name, current)); + } + + if (current.children != null && current.children.Count > 0) + { + foreach (var element in current.children) + { + stack.Push((T)element); + } + } + } + SortSearchResult(result); + } + + protected virtual void SortSearchResult(List rows) + { + rows.Sort((x, y) => EditorUtility.NaturalCompare(x.displayName, y.displayName)); // sort by displayName by default, can be overriden for multicolumn solutions + } + + protected override IList GetAncestors(int id) + { + return treeModel.GetAncestors(id); + } + + protected override IList GetDescendantsThatHaveChildren(int id) + { + return treeModel.GetDescendantsThatHaveChildren(id); + } + + } + +} diff --git a/Assets/Mirror/Profiler/Table/TreeViewWithTreeModel.cs.meta b/Assets/Mirror/Profiler/Table/TreeViewWithTreeModel.cs.meta new file mode 100644 index 000000000..0137242db --- /dev/null +++ b/Assets/Mirror/Profiler/Table/TreeViewWithTreeModel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4ddf5bd6d1c6d4724b7368e8b4814479 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: