VisualizationHelpers.cs (26364B)
1 using System; 2 using UnityEngine.InputSystem.Utilities; 3 using Unity.Collections.LowLevel.Unsafe; 4 using UnityEngine.InputSystem.LowLevel; 5 6 ////REVIEW: for vector2 visualizers of sticks, it could be useful to also visualize deadzones and raw values 7 8 namespace UnityEngine.InputSystem.Samples 9 { 10 internal static class VisualizationHelpers 11 { 12 public enum Axis { X, Y, Z } 13 14 public abstract class Visualizer 15 { 16 public abstract void OnDraw(Rect rect); 17 public abstract void AddSample(object value, double time); 18 } 19 20 public abstract class ValueVisualizer<TValue> : Visualizer 21 where TValue : struct 22 { 23 public RingBuffer<TValue> samples; 24 public RingBuffer<GUIContent> samplesText; 25 26 protected ValueVisualizer(int numSamples = 10) 27 { 28 samples = new RingBuffer<TValue>(numSamples); 29 samplesText = new RingBuffer<GUIContent>(numSamples); 30 } 31 32 public override void AddSample(object value, double time) 33 { 34 var v = default(TValue); 35 36 if (value != null) 37 { 38 if (!(value is TValue val)) 39 throw new ArgumentException( 40 $"Expecting value of type '{typeof(TValue).Name}' but value of type '{value?.GetType().Name}' instead", 41 nameof(value)); 42 v = val; 43 } 44 45 samples.Append(v); 46 samplesText.Append(new GUIContent(v.ToString())); 47 } 48 } 49 50 // Visualizes integer and real type primitives. 51 public class ScalarVisualizer<TValue> : ValueVisualizer<TValue> 52 where TValue : struct 53 { 54 public TValue limitMin; 55 public TValue limitMax; 56 public TValue min; 57 public TValue max; 58 59 public ScalarVisualizer(int numSamples = 10) 60 : base(numSamples) 61 { 62 } 63 64 public override void OnDraw(Rect rect) 65 { 66 // For now, only draw the current value. 67 DrawRectangle(rect, new Color(1, 1, 1, 0.1f)); 68 if (samples.count == 0) 69 return; 70 var sample = samples[samples.count - 1]; 71 if (Compare(sample, default) == 0) 72 return; 73 if (Compare(limitMin, default) != 0) 74 { 75 // Two-way visualization with positive and negative side. 76 throw new NotImplementedException(); 77 } 78 else 79 { 80 // One-way visualization with only positive side. 81 var ratio = Divide(sample, limitMax); 82 var fillRect = rect; 83 fillRect.width = rect.width * ratio; 84 DrawRectangle(fillRect, new Color(0, 1, 0, 0.75f)); 85 86 var valuePos = new Vector2(fillRect.xMax, fillRect.y + fillRect.height / 2); 87 DrawText(samplesText[samples.count - 1], valuePos, ValueTextStyle); 88 } 89 } 90 91 public override void AddSample(object value, double time) 92 { 93 base.AddSample(value, time); 94 95 if (value != null) 96 { 97 var val = (TValue)value; 98 if (Compare(min, val) > 0) 99 min = val; 100 if (Compare(max, val) < 0) 101 max = val; 102 } 103 } 104 105 private static unsafe int Compare(TValue left, TValue right) 106 { 107 var leftPtr = UnsafeUtility.AddressOf(ref left); 108 var rightPtr = UnsafeUtility.AddressOf(ref right); 109 if (typeof(TValue) == typeof(int)) 110 return ((int*)leftPtr)->CompareTo(*(int*)rightPtr); 111 if (typeof(TValue) == typeof(float)) 112 return ((float*)leftPtr)->CompareTo(*(float*)rightPtr); 113 throw new NotImplementedException("Scalar value type: " + typeof(TValue).Name); 114 } 115 116 private static unsafe void Subtract(ref TValue left, TValue right) 117 { 118 var leftPtr = UnsafeUtility.AddressOf(ref left); 119 var rightPtr = UnsafeUtility.AddressOf(ref right); 120 121 if (typeof(TValue) == typeof(int)) 122 *(int*)leftPtr = *(int*)leftPtr - *(int*)rightPtr; 123 if (typeof(TValue) == typeof(float)) 124 *(float*)leftPtr = *(float*)leftPtr - *(float*)rightPtr; 125 throw new NotImplementedException("Scalar value type: " + typeof(TValue).Name); 126 } 127 128 private static unsafe float Divide(TValue left, TValue right) 129 { 130 var leftPtr = UnsafeUtility.AddressOf(ref left); 131 var rightPtr = UnsafeUtility.AddressOf(ref right); 132 133 if (typeof(TValue) == typeof(int)) 134 return (float)*(int*)leftPtr / *(int*)rightPtr; 135 if (typeof(TValue) == typeof(float)) 136 return *(float*)leftPtr / *(float*)rightPtr; 137 throw new NotImplementedException("Scalar value type: " + typeof(TValue).Name); 138 } 139 } 140 141 ////TODO: allow asymmetric center (i.e. center not being a midpoint of rectangle) 142 ////TODO: enforce proper proportion between X and Y; it's confusing that X and Y can have different units yet have the same length 143 public class Vector2Visualizer : ValueVisualizer<Vector2> 144 { 145 // Our value space extends radially from the center, i.e. we have 146 // 360 discrete directions. Sampling at that granularity doesn't work 147 // super well in visualizations so we quantize to 3 degree increments. 148 public Vector2[] maximums = new Vector2[360 / 3]; 149 public Vector2 limits = new Vector2(1, 1); 150 151 private GUIContent limitsXText; 152 private GUIContent limitsYText; 153 154 public Vector2Visualizer(int numSamples = 10) 155 : base(numSamples) 156 { 157 } 158 159 public override void AddSample(object value, double time) 160 { 161 base.AddSample(value, time); 162 163 if (value != null) 164 { 165 // Keep track of radial maximums. 166 var vector = (Vector2)value; 167 var angle = Vector2.SignedAngle(Vector2.right, vector); 168 if (angle < 0) 169 angle = 360 + angle; 170 var angleInt = Mathf.FloorToInt(angle) / 3; 171 if (vector.sqrMagnitude > maximums[angleInt].sqrMagnitude) 172 maximums[angleInt] = vector; 173 174 // Extend limits if value is out of range. 175 var limitX = Mathf.Max(Mathf.Abs(vector.x), limits.x); 176 var limitY = Mathf.Max(Mathf.Abs(vector.y), limits.y); 177 if (!Mathf.Approximately(limitX, limits.x)) 178 { 179 limits.x = limitX; 180 limitsXText = null; 181 } 182 if (!Mathf.Approximately(limitY, limits.y)) 183 { 184 limits.y = limitY; 185 limitsYText = null; 186 } 187 } 188 } 189 190 public override void OnDraw(Rect rect) 191 { 192 DrawRectangle(rect, new Color(1, 1, 1, 0.1f)); 193 DrawAxis(Axis.X, rect, new Color(0, 1, 0, 0.75f)); 194 DrawAxis(Axis.Y, rect, new Color(0, 1, 0, 0.75f)); 195 196 var sampleCount = samples.count; 197 if (sampleCount == 0) 198 return; 199 200 // If limits aren't (1,1), show the actual values. 201 if (limits != new Vector2(1, 1)) 202 { 203 if (limitsXText == null) 204 limitsXText = new GUIContent(limits.x.ToString()); 205 if (limitsYText == null) 206 limitsYText = new GUIContent(limits.y.ToString()); 207 208 var limitsXSize = ValueTextStyle.CalcSize(limitsXText); 209 var limitsXPos = new Vector2(rect.x - limitsXSize.x, rect.y - 5); 210 var limitsYPos = new Vector2(rect.xMax, rect.yMax); 211 212 DrawText(limitsXText, limitsXPos, ValueTextStyle); 213 DrawText(limitsYText, limitsYPos, ValueTextStyle); 214 } 215 216 // Draw maximums. 217 var numMaximums = 0; 218 var firstMaximumPos = default(Vector2); 219 var lastMaximumPos = default(Vector2); 220 for (var i = 0; i < 360 / 3; ++i) 221 { 222 var value = maximums[i]; 223 if (value == default) 224 continue; 225 var valuePos = PixelPosForValue(value, rect); 226 if (numMaximums > 0) 227 DrawLine(lastMaximumPos, valuePos, new Color(1, 1, 1, 0.25f)); 228 else 229 firstMaximumPos = valuePos; 230 lastMaximumPos = valuePos; 231 ++numMaximums; 232 } 233 if (numMaximums > 1) 234 DrawLine(lastMaximumPos, firstMaximumPos, new Color(1, 1, 1, 0.25f)); 235 236 // Draw samples. 237 var alphaStep = 1f / sampleCount; 238 var alpha = 1f; 239 for (var i = sampleCount - 1; i >= 0; --i) // Go newest to oldest. 240 { 241 var value = samples[i]; 242 var valueRect = RectForValue(value, rect); 243 DrawRectangle(valueRect, new Color(1, 0, 0, alpha)); 244 alpha -= alphaStep; 245 } 246 247 // Print value of most recent sample. Draw last so 248 // we draw over the other stuff. 249 var lastSample = samples[sampleCount - 1]; 250 var lastSamplePos = PixelPosForValue(lastSample, rect); 251 lastSamplePos.x += 3; 252 lastSamplePos.y += 3; 253 DrawText(samplesText[sampleCount - 1], lastSamplePos, ValueTextStyle); 254 } 255 256 private Rect RectForValue(Vector2 value, Rect rect) 257 { 258 var pos = PixelPosForValue(value, rect); 259 return new Rect(pos.x - 1, pos.y - 1, 2, 2); 260 } 261 262 private Vector2 PixelPosForValue(Vector2 value, Rect rect) 263 { 264 var center = rect.center; 265 var x = Mathf.Abs(value.x) / limits.x * Mathf.Sign(value.x); 266 var y = Mathf.Abs(value.y) / limits.y * Mathf.Sign(value.y) * -1; // GUI Y is upside down. 267 var xInPixels = x * rect.width / 2; 268 var yInPixels = y * rect.height / 2; 269 return new Vector2(center.x + xInPixels, 270 center.y + yInPixels); 271 } 272 } 273 274 // Y axis is time, X axis can be multiple visualizations. 275 public class TimelineVisualizer : Visualizer 276 { 277 public bool showLegend { get; set; } 278 public bool showLimits { get; set; } 279 public TimeUnit timeUnit { get; set; } = TimeUnit.Seconds; 280 public GUIContent valueUnit { get; set; } 281 ////REVIEW: should this be per timeline? 282 public int timelineCount => m_Timelines != null ? m_Timelines.Length : 0; 283 public int historyDepth { get; set; } = 100; 284 285 public Vector2 limitsY 286 { 287 get => m_LimitsY; 288 set 289 { 290 m_LimitsY = value; 291 m_LimitsYMin = null; 292 m_LimitsYMax = null; 293 } 294 } 295 296 public TimelineVisualizer(float totalTimeUnitsShown = 4) 297 { 298 m_TotalTimeUnitsShown = totalTimeUnitsShown; 299 } 300 301 public override void OnDraw(Rect rect) 302 { 303 var endTime = Time.realtimeSinceStartup; 304 var startTime = endTime - m_TotalTimeUnitsShown; 305 var endFrame = InputState.updateCount; 306 var startFrame = endFrame - (int)m_TotalTimeUnitsShown; 307 308 for (var i = 0; i < timelineCount; ++i) 309 { 310 var timeline = m_Timelines[i]; 311 var sampleCount = timeUnit == TimeUnit.Frames 312 ? timeline.frameSamples.count 313 : timeline.timeSamples.count; 314 315 // Set up clip rect so that we can do stuff like render lines to samples 316 // falling outside the render rectangle and have them get clipped. 317 GUI.BeginGroup(rect); 318 var plotType = timeline.plotType; 319 var lastPos = default(Vector2); 320 var timeUnitsPerPixel = rect.width / m_TotalTimeUnitsShown; 321 var color = m_Timelines[i].color; 322 for (var n = sampleCount - 1; n >= 0; --n) 323 { 324 var sample = timeUnit == TimeUnit.Frames 325 ? timeline.frameSamples[n].value 326 : timeline.timeSamples[n].value; 327 328 ////TODO: respect limitsY 329 330 float y; 331 if (sample.isEmpty) 332 y = 0.5f; 333 else 334 y = sample.ToSingle(); 335 336 y /= limitsY.y; 337 338 var deltaTime = timeUnit == TimeUnit.Frames 339 ? timeline.frameSamples[n].frame - startFrame 340 : timeline.timeSamples[n].time - startTime; 341 var pos = new Vector2(deltaTime * timeUnitsPerPixel, rect.height - y * rect.height); 342 343 if (plotType == PlotType.LineGraph) 344 { 345 if (n != sampleCount - 1) 346 { 347 DrawLine(lastPos, pos, color, 2); 348 if (pos.x < 0) 349 break; 350 } 351 } 352 else if (plotType == PlotType.BarChart) 353 { 354 ////TODO: make rectangles have a progressively stronger hue or saturation 355 var barRect = new Rect(pos.x, pos.y, timeUnitsPerPixel, y * limitsY.y * rect.height); 356 DrawRectangle(barRect, color); 357 } 358 359 lastPos = pos; 360 } 361 GUI.EndGroup(); 362 } 363 364 if (showLegend && timelineCount > 0) 365 { 366 var legendRect = rect; 367 legendRect.x += rect.width + 2; 368 legendRect.width = 400; 369 legendRect.height = ValueTextStyle.CalcHeight(m_Timelines[0].name, 400); 370 for (var i = 0; i < m_Timelines.Length; ++i) 371 { 372 var colorTagRect = legendRect; 373 colorTagRect.width = 5; 374 var labelRect = legendRect; 375 labelRect.x += 8; 376 labelRect.width -= 8; 377 378 DrawRectangle(colorTagRect, m_Timelines[i].color); 379 DrawText(m_Timelines[i].name, labelRect.position, ValueTextStyle); 380 381 legendRect.y += labelRect.height + 2; 382 } 383 } 384 385 if (showLimits) 386 { 387 if (m_LimitsYMax == null) 388 m_LimitsYMax = new GUIContent(m_LimitsY.y.ToString()); 389 if (m_LimitsYMin == null) 390 m_LimitsYMin = new GUIContent(m_LimitsY.x.ToString()); 391 392 DrawText(m_LimitsYMax, new Vector2(rect.x + rect.width, rect.y), ValueTextStyle); 393 DrawText(m_LimitsYMin, new Vector2(rect.x + rect.width, rect.y + rect.height), ValueTextStyle); 394 } 395 } 396 397 public override void AddSample(object value, double time) 398 { 399 if (timelineCount == 0) 400 throw new InvalidOperationException("Must have set up a timeline first"); 401 AddSample(0, PrimitiveValue.FromObject(value), (float)time); 402 } 403 404 public int AddTimeline(string name, Color color, PlotType plotType = default) 405 { 406 var timeline = new Timeline 407 { 408 name = new GUIContent(name), 409 color = color, 410 plotType = plotType, 411 }; 412 if (timeUnit == TimeUnit.Frames) 413 timeline.frameSamples = new RingBuffer<FrameSample>(historyDepth); 414 else 415 timeline.timeSamples = new RingBuffer<TimeSample>(historyDepth); 416 417 var index = timelineCount; 418 Array.Resize(ref m_Timelines, timelineCount + 1); 419 m_Timelines[index] = timeline; 420 421 return index; 422 } 423 424 public int GetTimeline(string name) 425 { 426 for (var i = 0; i < timelineCount; ++i) 427 if (string.Compare(m_Timelines[i].name.text, name, StringComparison.InvariantCultureIgnoreCase) == 0) 428 return i; 429 return -1; 430 } 431 432 // Add a time-based sample. 433 public void AddSample(int timelineIndex, PrimitiveValue value, float time) 434 { 435 m_Timelines[timelineIndex].timeSamples.Append(new TimeSample 436 { 437 value = value, 438 time = time 439 }); 440 } 441 442 // Add a frame-based sample. 443 public ref PrimitiveValue GetOrCreateSample(int timelineIndex, int frame) 444 { 445 ref var timeline = ref m_Timelines[timelineIndex]; 446 ref var samples = ref timeline.frameSamples; 447 var count = samples.count; 448 if (count > 0) 449 { 450 if (samples[count - 1].frame == frame) 451 return ref samples[count - 1].value; 452 453 Debug.Assert(samples[count - 1].frame < frame, "Frame numbers must be ascending"); 454 } 455 456 return ref samples.Append(new FrameSample {frame = frame}).value; 457 } 458 459 private float m_TotalTimeUnitsShown; 460 private Vector2 m_LimitsY = new Vector2(-1, 1); 461 private GUIContent m_LimitsYMin; 462 private GUIContent m_LimitsYMax; 463 private Timeline[] m_Timelines; 464 465 private struct TimeSample 466 { 467 public PrimitiveValue value; 468 public float time; 469 } 470 471 private struct FrameSample 472 { 473 public PrimitiveValue value; 474 public int frame; 475 } 476 477 private struct Timeline 478 { 479 public GUIContent name; 480 public Color color; 481 public RingBuffer<TimeSample> timeSamples; 482 public RingBuffer<FrameSample> frameSamples; 483 public PrimitiveValue minValue; 484 public PrimitiveValue maxValue; 485 public PlotType plotType; 486 } 487 488 public enum PlotType 489 { 490 LineGraph, 491 BarChart, 492 } 493 494 public enum TimeUnit 495 { 496 Seconds, 497 Frames, 498 } 499 } 500 501 public static void DrawAxis(Axis axis, Rect rect, Color color = default, float width = 1) 502 { 503 Vector2 start, end, tickOffset; 504 switch (axis) 505 { 506 case Axis.X: 507 start = new Vector2(rect.x, rect.y + rect.height / 2); 508 end = new Vector2(start.x + rect.width, rect.y + rect.height / 2); 509 tickOffset = new Vector2(0, 3); 510 break; 511 512 case Axis.Y: 513 start = new Vector2(rect.x + rect.width / 2, rect.y); 514 end = new Vector2(start.x, rect.y + rect.height); 515 tickOffset = new Vector2(3, 0); 516 break; 517 518 case Axis.Z: 519 // From bottom left corner to upper right corner. 520 start = new Vector2(rect.x, rect.yMax); 521 end = new Vector2(rect.xMax, rect.y); 522 tickOffset = new Vector2(1.5f, 1.5f); 523 break; 524 525 default: 526 throw new NotImplementedException(); 527 } 528 529 ////TODO: label limits 530 531 DrawLine(start, end, color, width); 532 DrawLine(start - tickOffset, start + tickOffset, color, width); 533 DrawLine(end - tickOffset, end + tickOffset, color, width); 534 } 535 536 public static void DrawRectangle(Rect rect, Color color) 537 { 538 var savedColor = GUI.color; 539 GUI.color = color; 540 GUI.DrawTexture(rect, OnePixTex); 541 GUI.color = savedColor; 542 } 543 544 public static void DrawText(string text, Vector2 pos, GUIStyle style) 545 { 546 var content = new GUIContent(text); 547 DrawText(content, pos, style); 548 } 549 550 public static void DrawText(GUIContent text, Vector2 pos, GUIStyle style) 551 { 552 var content = new GUIContent(text); 553 var size = style.CalcSize(content); 554 var rect = new Rect(pos.x, pos.y, size.x, size.y); 555 style.Draw(rect, content, false, false, false, false); 556 } 557 558 // Adapted from http://wiki.unity3d.com/index.php?title=DrawLine 559 public static void DrawLine(Vector2 pointA, Vector2 pointB, Color color = default, float width = 1) 560 { 561 // Save the current GUI matrix, since we're going to make changes to it. 562 var matrix = GUI.matrix; 563 564 // Store current GUI color, so we can switch it back later, 565 // and set the GUI color to the color parameter 566 var savedColor = GUI.color; 567 GUI.color = color; 568 569 // Determine the angle of the line. 570 var angle = Vector3.Angle(pointB - pointA, Vector2.right); 571 572 // Vector3.Angle always returns a positive number. 573 // If pointB is above pointA, then angle needs to be negative. 574 if (pointA.y > pointB.y) 575 angle = -angle; 576 577 // Use ScaleAroundPivot to adjust the size of the line. 578 // We could do this when we draw the texture, but by scaling it here we can use 579 // non-integer values for the width and length (such as sub 1 pixel widths). 580 // Note that the pivot point is at +.5 from pointA.y, this is so that the width of the line 581 // is centered on the origin at pointA. 582 GUIUtility.ScaleAroundPivot(new Vector2((pointB - pointA).magnitude, width), new Vector2(pointA.x, pointA.y + 0.5f)); 583 584 // Set the rotation for the line. 585 // The angle was calculated with pointA as the origin. 586 GUIUtility.RotateAroundPivot(angle, pointA); 587 588 // Finally, draw the actual line. 589 // We're really only drawing a 1x1 texture from pointA. 590 // The matrix operations done with ScaleAroundPivot and RotateAroundPivot will make this 591 // render with the proper width, length, and angle. 592 GUI.DrawTexture(new Rect(pointA.x, pointA.y, 1, 1), OnePixTex); 593 594 // We're done. Restore the GUI matrix and GUI color to whatever they were before. 595 GUI.matrix = matrix; 596 GUI.color = savedColor; 597 } 598 599 private static Texture2D s_OnePixTex; 600 private static GUIStyle s_ValueTextStyle; 601 602 internal static GUIStyle ValueTextStyle 603 { 604 get 605 { 606 if (s_ValueTextStyle == null) 607 { 608 s_ValueTextStyle = new GUIStyle(); 609 s_ValueTextStyle.fontSize -= 2; 610 s_ValueTextStyle.normal.textColor = Color.white; 611 } 612 return s_ValueTextStyle; 613 } 614 } 615 616 internal static Texture2D OnePixTex 617 { 618 get 619 { 620 if (s_OnePixTex == null) 621 s_OnePixTex = new Texture2D(1, 1); 622 return s_OnePixTex; 623 } 624 } 625 626 public struct RingBuffer<TValue> 627 { 628 public TValue[] array; 629 public int head; 630 public int count; 631 632 public RingBuffer(int size) 633 { 634 array = new TValue[size]; 635 head = 0; 636 count = 0; 637 } 638 639 public ref TValue Append(TValue value) 640 { 641 int index; 642 var bufferSize = array.Length; 643 if (count < bufferSize) 644 { 645 Debug.Assert(head == 0, "Head can't have moved if buffer isn't full yet"); 646 index = count; 647 ++count; 648 } 649 else 650 { 651 // Buffer is full. Bump head. 652 index = (head + count) % bufferSize; 653 ++head; 654 } 655 array[index] = value; 656 return ref array[index]; 657 } 658 659 public ref TValue this[int index] 660 { 661 get 662 { 663 if (index < 0 || index >= count) 664 throw new ArgumentOutOfRangeException(nameof(index)); 665 return ref array[(head + index) % array.Length]; 666 } 667 } 668 } 669 } 670 }