IndieGame/client/Packages/com.unity.inputsystem@1.7.0/Samples~/Visualizers/VisualizationHelpers.cs

702 lines
27 KiB
C#
Raw Normal View History

2024-10-11 10:12:15 +08:00
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<TValue> : Visualizer
where TValue : struct
{
public RingBuffer<TValue> samples;
public RingBuffer<GUIContent> samplesText;
protected ValueVisualizer(int numSamples = 10)
{
samples = new RingBuffer<TValue>(numSamples);
samplesText = new RingBuffer<GUIContent>(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<TValue> : ValueVisualizer<TValue>
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<Vector2>
{
// 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<FrameSample>(historyDepth);
else
timeline.timeSamples = new RingBuffer<TimeSample>(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<TimeSample> timeSamples;
public RingBuffer<FrameSample> 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<TValue>
{
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];
}
}
}
}
}