using System; using UnityEngine.InputSystem.Utilities; using Unity.Collections.LowLevel.Unsafe; using UnityEngine.InputSystem.LowLevel; ////REVIEW: for vector2 visualizers of sticks, it could be useful to also visualize deadzones and raw values namespace UnityEngine.InputSystem.Samples { internal static class VisualizationHelpers { public enum Axis { X, Y, Z } public abstract class Visualizer { public abstract void OnDraw(Rect rect); public abstract void AddSample(object value, double time); } public abstract class ValueVisualizer : Visualizer where TValue : struct { public RingBuffer samples; public RingBuffer samplesText; protected ValueVisualizer(int numSamples = 10) { samples = new RingBuffer(numSamples); samplesText = new RingBuffer(numSamples); } public override void AddSample(object value, double time) { var v = default(TValue); if (value != null) { if (!(value is TValue val)) throw new ArgumentException( $"Expecting value of type '{typeof(TValue).Name}' but value of type '{value?.GetType().Name}' instead", nameof(value)); v = val; } samples.Append(v); samplesText.Append(new GUIContent(v.ToString())); } } // Visualizes integer and real type primitives. public class ScalarVisualizer : ValueVisualizer where TValue : struct { public TValue limitMin; public TValue limitMax; public TValue min; public TValue max; public ScalarVisualizer(int numSamples = 10) : base(numSamples) { } public override void OnDraw(Rect rect) { // For now, only draw the current value. DrawRectangle(rect, new Color(1, 1, 1, 0.1f)); if (samples.count == 0) return; var sample = samples[samples.count - 1]; if (Compare(sample, default) == 0) return; if (Compare(limitMin, default) != 0) { // Two-way visualization with positive and negative side. throw new NotImplementedException(); } else { // One-way visualization with only positive side. var ratio = Divide(sample, limitMax); var fillRect = rect; fillRect.width = rect.width * ratio; DrawRectangle(fillRect, new Color(0, 1, 0, 0.75f)); var valuePos = new Vector2(fillRect.xMax, fillRect.y + fillRect.height / 2); DrawText(samplesText[samples.count - 1], valuePos, ValueTextStyle); } } public override void AddSample(object value, double time) { base.AddSample(value, time); if (value != null) { var val = (TValue)value; if (Compare(min, val) > 0) min = val; if (Compare(max, val) < 0) max = val; } } private static unsafe int Compare(TValue left, TValue right) { var leftPtr = UnsafeUtility.AddressOf(ref left); var rightPtr = UnsafeUtility.AddressOf(ref right); if (typeof(TValue) == typeof(int)) return ((int*)leftPtr)->CompareTo(*(int*)rightPtr); if (typeof(TValue) == typeof(float)) return ((float*)leftPtr)->CompareTo(*(float*)rightPtr); throw new NotImplementedException("Scalar value type: " + typeof(TValue).Name); } private static unsafe void Subtract(ref TValue left, TValue right) { var leftPtr = UnsafeUtility.AddressOf(ref left); var rightPtr = UnsafeUtility.AddressOf(ref right); if (typeof(TValue) == typeof(int)) *(int*)leftPtr = *(int*)leftPtr - *(int*)rightPtr; if (typeof(TValue) == typeof(float)) *(float*)leftPtr = *(float*)leftPtr - *(float*)rightPtr; throw new NotImplementedException("Scalar value type: " + typeof(TValue).Name); } private static unsafe float Divide(TValue left, TValue right) { var leftPtr = UnsafeUtility.AddressOf(ref left); var rightPtr = UnsafeUtility.AddressOf(ref right); if (typeof(TValue) == typeof(int)) return (float)*(int*)leftPtr / *(int*)rightPtr; if (typeof(TValue) == typeof(float)) return *(float*)leftPtr / *(float*)rightPtr; throw new NotImplementedException("Scalar value type: " + typeof(TValue).Name); } } // Visualizes .current device value public class CurrentDeviceVisualizer : Visualizer { private InputDevice m_CurrentDevice = null; public override void OnDraw(Rect rect) { // For now, only draw the current value. DrawRectangle(rect, new Color(1, 1, 1, 0.1f)); var name = m_CurrentDevice != null ? m_CurrentDevice.name : "null"; DrawText(name, new Vector2(rect.xMin + 4, (rect.yMin + rect.yMax) / 2.0f), ValueTextStyle); } public override void AddSample(object value, double time) { var device = (InputDevice)value; if (device is Gamepad) m_CurrentDevice = Gamepad.current; else if (device is Mouse) m_CurrentDevice = Mouse.current; else if (device is Pen) m_CurrentDevice = Pen.current; else if (device is Pointer) // should be last, because it's a base class for Mouse and Pen m_CurrentDevice = Pointer.current; else throw new ArgumentException( $"Expected device type that implements .current, but got '{device.name}' (deviceId: {device.deviceId}) instead "); } } ////TODO: allow asymmetric center (i.e. center not being a midpoint of rectangle) ////TODO: enforce proper proportion between X and Y; it's confusing that X and Y can have different units yet have the same length public class Vector2Visualizer : ValueVisualizer { // Our value space extends radially from the center, i.e. we have // 360 discrete directions. Sampling at that granularity doesn't work // super well in visualizations so we quantize to 3 degree increments. public Vector2[] maximums = new Vector2[360 / 3]; public Vector2 limits = new Vector2(1, 1); private GUIContent limitsXText; private GUIContent limitsYText; public Vector2Visualizer(int numSamples = 10) : base(numSamples) { } public override void AddSample(object value, double time) { base.AddSample(value, time); if (value != null) { // Keep track of radial maximums. var vector = (Vector2)value; var angle = Vector2.SignedAngle(Vector2.right, vector); if (angle < 0) angle = 360 + angle; var angleInt = Mathf.FloorToInt(angle) / 3; if (vector.sqrMagnitude > maximums[angleInt].sqrMagnitude) maximums[angleInt] = vector; // Extend limits if value is out of range. var limitX = Mathf.Max(Mathf.Abs(vector.x), limits.x); var limitY = Mathf.Max(Mathf.Abs(vector.y), limits.y); if (!Mathf.Approximately(limitX, limits.x)) { limits.x = limitX; limitsXText = null; } if (!Mathf.Approximately(limitY, limits.y)) { limits.y = limitY; limitsYText = null; } } } public override void OnDraw(Rect rect) { DrawRectangle(rect, new Color(1, 1, 1, 0.1f)); DrawAxis(Axis.X, rect, new Color(0, 1, 0, 0.75f)); DrawAxis(Axis.Y, rect, new Color(0, 1, 0, 0.75f)); var sampleCount = samples.count; if (sampleCount == 0) return; // If limits aren't (1,1), show the actual values. if (limits != new Vector2(1, 1)) { if (limitsXText == null) limitsXText = new GUIContent(limits.x.ToString()); if (limitsYText == null) limitsYText = new GUIContent(limits.y.ToString()); var limitsXSize = ValueTextStyle.CalcSize(limitsXText); var limitsXPos = new Vector2(rect.x - limitsXSize.x, rect.y - 5); var limitsYPos = new Vector2(rect.xMax, rect.yMax); DrawText(limitsXText, limitsXPos, ValueTextStyle); DrawText(limitsYText, limitsYPos, ValueTextStyle); } // Draw maximums. var numMaximums = 0; var firstMaximumPos = default(Vector2); var lastMaximumPos = default(Vector2); for (var i = 0; i < 360 / 3; ++i) { var value = maximums[i]; if (value == default) continue; var valuePos = PixelPosForValue(value, rect); if (numMaximums > 0) DrawLine(lastMaximumPos, valuePos, new Color(1, 1, 1, 0.25f)); else firstMaximumPos = valuePos; lastMaximumPos = valuePos; ++numMaximums; } if (numMaximums > 1) DrawLine(lastMaximumPos, firstMaximumPos, new Color(1, 1, 1, 0.25f)); // Draw samples. var alphaStep = 1f / sampleCount; var alpha = 1f; for (var i = sampleCount - 1; i >= 0; --i) // Go newest to oldest. { var value = samples[i]; var valueRect = RectForValue(value, rect); DrawRectangle(valueRect, new Color(1, 0, 0, alpha)); alpha -= alphaStep; } // Print value of most recent sample. Draw last so // we draw over the other stuff. var lastSample = samples[sampleCount - 1]; var lastSamplePos = PixelPosForValue(lastSample, rect); lastSamplePos.x += 3; lastSamplePos.y += 3; DrawText(samplesText[sampleCount - 1], lastSamplePos, ValueTextStyle); } private Rect RectForValue(Vector2 value, Rect rect) { var pos = PixelPosForValue(value, rect); return new Rect(pos.x - 1, pos.y - 1, 2, 2); } private Vector2 PixelPosForValue(Vector2 value, Rect rect) { var center = rect.center; var x = Mathf.Abs(value.x) / limits.x * Mathf.Sign(value.x); var y = Mathf.Abs(value.y) / limits.y * Mathf.Sign(value.y) * -1; // GUI Y is upside down. var xInPixels = x * rect.width / 2; var yInPixels = y * rect.height / 2; return new Vector2(center.x + xInPixels, center.y + yInPixels); } } // Y axis is time, X axis can be multiple visualizations. public class TimelineVisualizer : Visualizer { public bool showLegend { get; set; } public bool showLimits { get; set; } public TimeUnit timeUnit { get; set; } = TimeUnit.Seconds; public GUIContent valueUnit { get; set; } ////REVIEW: should this be per timeline? public int timelineCount => m_Timelines != null ? m_Timelines.Length : 0; public int historyDepth { get; set; } = 100; public Vector2 limitsY { get => m_LimitsY; set { m_LimitsY = value; m_LimitsYMin = null; m_LimitsYMax = null; } } public TimelineVisualizer(float totalTimeUnitsShown = 4) { m_TotalTimeUnitsShown = totalTimeUnitsShown; } public override void OnDraw(Rect rect) { var endTime = Time.realtimeSinceStartup; var startTime = endTime - m_TotalTimeUnitsShown; var endFrame = InputState.updateCount; var startFrame = endFrame - (int)m_TotalTimeUnitsShown; for (var i = 0; i < timelineCount; ++i) { var timeline = m_Timelines[i]; var sampleCount = timeUnit == TimeUnit.Frames ? timeline.frameSamples.count : timeline.timeSamples.count; // Set up clip rect so that we can do stuff like render lines to samples // falling outside the render rectangle and have them get clipped. GUI.BeginGroup(rect); var plotType = timeline.plotType; var lastPos = default(Vector2); var timeUnitsPerPixel = rect.width / m_TotalTimeUnitsShown; var color = m_Timelines[i].color; for (var n = sampleCount - 1; n >= 0; --n) { var sample = timeUnit == TimeUnit.Frames ? timeline.frameSamples[n].value : timeline.timeSamples[n].value; ////TODO: respect limitsY float y; if (sample.isEmpty) y = 0.5f; else y = sample.ToSingle(); y /= limitsY.y; var deltaTime = timeUnit == TimeUnit.Frames ? timeline.frameSamples[n].frame - startFrame : timeline.timeSamples[n].time - startTime; var pos = new Vector2(deltaTime * timeUnitsPerPixel, rect.height - y * rect.height); if (plotType == PlotType.LineGraph) { if (n != sampleCount - 1) { DrawLine(lastPos, pos, color, 2); if (pos.x < 0) break; } } else if (plotType == PlotType.BarChart) { ////TODO: make rectangles have a progressively stronger hue or saturation var barRect = new Rect(pos.x, pos.y, timeUnitsPerPixel, y * limitsY.y * rect.height); DrawRectangle(barRect, color); } lastPos = pos; } GUI.EndGroup(); } if (showLegend && timelineCount > 0) { var legendRect = rect; legendRect.x += rect.width + 2; legendRect.width = 400; legendRect.height = ValueTextStyle.CalcHeight(m_Timelines[0].name, 400); for (var i = 0; i < m_Timelines.Length; ++i) { var colorTagRect = legendRect; colorTagRect.width = 5; var labelRect = legendRect; labelRect.x += 8; labelRect.width -= 8; DrawRectangle(colorTagRect, m_Timelines[i].color); DrawText(m_Timelines[i].name, labelRect.position, ValueTextStyle); legendRect.y += labelRect.height + 2; } } if (showLimits) { if (m_LimitsYMax == null) m_LimitsYMax = new GUIContent(m_LimitsY.y.ToString()); if (m_LimitsYMin == null) m_LimitsYMin = new GUIContent(m_LimitsY.x.ToString()); DrawText(m_LimitsYMax, new Vector2(rect.x + rect.width, rect.y), ValueTextStyle); DrawText(m_LimitsYMin, new Vector2(rect.x + rect.width, rect.y + rect.height), ValueTextStyle); } } public override void AddSample(object value, double time) { if (timelineCount == 0) throw new InvalidOperationException("Must have set up a timeline first"); AddSample(0, PrimitiveValue.FromObject(value), (float)time); } public int AddTimeline(string name, Color color, PlotType plotType = default) { var timeline = new Timeline { name = new GUIContent(name), color = color, plotType = plotType, }; if (timeUnit == TimeUnit.Frames) timeline.frameSamples = new RingBuffer(historyDepth); else timeline.timeSamples = new RingBuffer(historyDepth); var index = timelineCount; Array.Resize(ref m_Timelines, timelineCount + 1); m_Timelines[index] = timeline; return index; } public int GetTimeline(string name) { for (var i = 0; i < timelineCount; ++i) if (string.Compare(m_Timelines[i].name.text, name, StringComparison.InvariantCultureIgnoreCase) == 0) return i; return -1; } // Add a time-based sample. public void AddSample(int timelineIndex, PrimitiveValue value, float time) { m_Timelines[timelineIndex].timeSamples.Append(new TimeSample { value = value, time = time }); } // Add a frame-based sample. public ref PrimitiveValue GetOrCreateSample(int timelineIndex, int frame) { ref var timeline = ref m_Timelines[timelineIndex]; ref var samples = ref timeline.frameSamples; var count = samples.count; if (count > 0) { if (samples[count - 1].frame == frame) return ref samples[count - 1].value; Debug.Assert(samples[count - 1].frame < frame, "Frame numbers must be ascending"); } return ref samples.Append(new FrameSample {frame = frame}).value; } private float m_TotalTimeUnitsShown; private Vector2 m_LimitsY = new Vector2(-1, 1); private GUIContent m_LimitsYMin; private GUIContent m_LimitsYMax; private Timeline[] m_Timelines; private struct TimeSample { public PrimitiveValue value; public float time; } private struct FrameSample { public PrimitiveValue value; public int frame; } private struct Timeline { public GUIContent name; public Color color; public RingBuffer timeSamples; public RingBuffer frameSamples; public PrimitiveValue minValue; public PrimitiveValue maxValue; public PlotType plotType; } public enum PlotType { LineGraph, BarChart, } public enum TimeUnit { Seconds, Frames, } } public static void DrawAxis(Axis axis, Rect rect, Color color = default, float width = 1) { Vector2 start, end, tickOffset; switch (axis) { case Axis.X: start = new Vector2(rect.x, rect.y + rect.height / 2); end = new Vector2(start.x + rect.width, rect.y + rect.height / 2); tickOffset = new Vector2(0, 3); break; case Axis.Y: start = new Vector2(rect.x + rect.width / 2, rect.y); end = new Vector2(start.x, rect.y + rect.height); tickOffset = new Vector2(3, 0); break; case Axis.Z: // From bottom left corner to upper right corner. start = new Vector2(rect.x, rect.yMax); end = new Vector2(rect.xMax, rect.y); tickOffset = new Vector2(1.5f, 1.5f); break; default: throw new NotImplementedException(); } ////TODO: label limits DrawLine(start, end, color, width); DrawLine(start - tickOffset, start + tickOffset, color, width); DrawLine(end - tickOffset, end + tickOffset, color, width); } public static void DrawRectangle(Rect rect, Color color) { var savedColor = GUI.color; GUI.color = color; GUI.DrawTexture(rect, OnePixTex); GUI.color = savedColor; } public static void DrawText(string text, Vector2 pos, GUIStyle style) { var content = new GUIContent(text); DrawText(content, pos, style); } public static void DrawText(GUIContent text, Vector2 pos, GUIStyle style) { var content = new GUIContent(text); var size = style.CalcSize(content); var rect = new Rect(pos.x, pos.y, size.x, size.y); style.Draw(rect, content, false, false, false, false); } // Adapted from http://wiki.unity3d.com/index.php?title=DrawLine public static void DrawLine(Vector2 pointA, Vector2 pointB, Color color = default, float width = 1) { // Save the current GUI matrix, since we're going to make changes to it. var matrix = GUI.matrix; // Store current GUI color, so we can switch it back later, // and set the GUI color to the color parameter var savedColor = GUI.color; GUI.color = color; // Determine the angle of the line. var angle = Vector3.Angle(pointB - pointA, Vector2.right); // Vector3.Angle always returns a positive number. // If pointB is above pointA, then angle needs to be negative. if (pointA.y > pointB.y) angle = -angle; // Use ScaleAroundPivot to adjust the size of the line. // We could do this when we draw the texture, but by scaling it here we can use // non-integer values for the width and length (such as sub 1 pixel widths). // Note that the pivot point is at +.5 from pointA.y, this is so that the width of the line // is centered on the origin at pointA. GUIUtility.ScaleAroundPivot(new Vector2((pointB - pointA).magnitude, width), new Vector2(pointA.x, pointA.y + 0.5f)); // Set the rotation for the line. // The angle was calculated with pointA as the origin. GUIUtility.RotateAroundPivot(angle, pointA); // Finally, draw the actual line. // We're really only drawing a 1x1 texture from pointA. // The matrix operations done with ScaleAroundPivot and RotateAroundPivot will make this // render with the proper width, length, and angle. GUI.DrawTexture(new Rect(pointA.x, pointA.y, 1, 1), OnePixTex); // We're done. Restore the GUI matrix and GUI color to whatever they were before. GUI.matrix = matrix; GUI.color = savedColor; } private static Texture2D s_OnePixTex; private static GUIStyle s_ValueTextStyle; internal static GUIStyle ValueTextStyle { get { if (s_ValueTextStyle == null) { s_ValueTextStyle = new GUIStyle(); s_ValueTextStyle.fontSize -= 2; s_ValueTextStyle.normal.textColor = Color.white; } return s_ValueTextStyle; } } internal static Texture2D OnePixTex { get { if (s_OnePixTex == null) s_OnePixTex = new Texture2D(1, 1); return s_OnePixTex; } } public struct RingBuffer { public TValue[] array; public int head; public int count; public RingBuffer(int size) { array = new TValue[size]; head = 0; count = 0; } public ref TValue Append(TValue value) { int index; var bufferSize = array.Length; if (count < bufferSize) { Debug.Assert(head == 0, "Head can't have moved if buffer isn't full yet"); index = count; ++count; } else { // Buffer is full. Bump head. index = (head + count) % bufferSize; ++head; } array[index] = value; return ref array[index]; } public ref TValue this[int index] { get { if (index < 0 || index >= count) throw new ArgumentOutOfRangeException(nameof(index)); return ref array[(head + index) % array.Length]; } } } } }