Skip to content

Instantly share code, notes, and snippets.

@rtlsilva
Last active November 29, 2023 22:36
Show Gist options
  • Save rtlsilva/a1fa4f55f39e7d55a981b534c67aa803 to your computer and use it in GitHub Desktop.
Save rtlsilva/a1fa4f55f39e7d55a981b534c67aa803 to your computer and use it in GitHub Desktop.
Components and extensions that aid in working with AR Foundation using reactive programming
using System;
using UniRx;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.XR.ARFoundation;
using MyNamespace.Utils;
using MyNamespace.Extensions;
namespace MyNamespace.AR
{
/// <summary>
/// AR Foundation controller.
/// </summary>
[RequireComponent(typeof(ARSessionOrigin))]
public class ARFoundationController : MonoBehaviour
{
/// <summary>
/// Holds the Foundation Controller state
/// </summary>
public struct ExperienceState
{
/// <summary>
/// The number of planes detected
/// </summary>
public readonly int PlaneCount;
/// <summary>
/// Is the content placed?
/// </summary>
public readonly bool IsContentPlaced;
/// <summary>
/// Creates a new instance of the <see cref="ExperienceState"/> struct.
/// </summary>
/// <param name="planeCount">The number of planes detected</param>
/// <param name="isContentPlaced">Flag indicating if the content is placed</param>
public ExperienceState(int planeCount, bool isContentPlaced)
{
this.PlaneCount = planeCount;
this.IsContentPlaced = isContentPlaced;
}
}
[Header("AR Components")]
/// <summary>
/// Reference to the AR session component
/// </summary>
[SerializeField]
private ARSession sessionAR;
/// <summary>
/// AR session origin component
/// </summary>
[SerializeField]
private ARSessionOrigin sessionOriginAR;
/// <summary>
/// AR Raycast manager component
/// </summary>
[SerializeField]
private ARRaycastManager raycastManagerAR;
/// <summary>
/// The AR camera that renders virtual content and the camera feed
/// </summary>
public Camera ArCamera => this.sessionOriginAR.camera;
/// <summary>
/// The AR camera's pose
/// </summary>
public Pose ArCameraPose => new Pose(this.sessionOriginAR.camera.transform.position, this.sessionOriginAR.camera.transform.rotation);
/// <summary>
/// AR point cloud component
/// </summary>
[SerializeField]
private ARPointCloudManager pointCloudManagerAR;
/// <summary>
/// AR plane manager component
/// </summary>
[SerializeField]
private ARPlaneManager planeManagerAR;
[Header("AR Content Configuration")]
/// <summary>
/// The minimum distance at which content must be placed.
/// </summary>
[SerializeField]
private float minimumContentDepth;
/// <summary>
/// Whether to enable scaling AR content based on the distance to the touched plane.
/// </summary>
[SerializeField]
private bool enableDistanceBasedScaling;
/// <summary>
/// The minimum scale factor that can be applied to the AR content
/// </summary>
[SerializeField]
private float minimumContentScaleFactor = 0.1f;
/// <summary>
/// The maximum scale factor that can be applied to the AR content
/// </summary>
[SerializeField]
private float maximumContentScaleFactor = 10f;
/// <summary>
/// The distance in meters at which the AR content should appear in its original scale
/// </summary>
[Tooltip("The distance in meters at which the AR content should appear in its original scale")]
[SerializeField]
private float contentReferenceDistance = 2;
/// <summary>
/// The root transform that will hold the AR content
/// </summary>
[SerializeField]
private Transform contentRoot;
/// <summary>
/// The AR content's apparent location.
/// </summary>
private Vector3 contentLocation = Vector3.zero;
/// <summary>
/// The AR content's apparent scale factor.
/// </summary>
private float contentScaleFactor = 1.0f;
[Header("AR Debug")]
/// <summary>
/// The panel that will display debug information from native AR subsystems
/// </summary>
[SerializeField]
private ARDebugPanel debugPanel;
/// <summary>
/// Refenence to the AR world scale slider
/// </summary>
[SerializeField]
private Slider scaleSlider;
/// <summary>
/// Reference to the player intruction text component
/// </summary>
[SerializeField]
private Text playerInstructionsText;
/// <summary>
/// Flag indicating if the model was already placed
/// </summary>
private ReactiveProperty<bool> isContentPlaced = new ReactiveProperty<bool>();
/// <summary>
/// An observable that emits whether when the AR content is placed.
/// </summary>
public IObservable<bool> WhenContentPlaced => this.isContentPlaced.DistinctUntilChanged();
/// <summary>
/// The object instantiated as a result of a successful raycast intersection with a plane.
/// </summary>
private GameObject spawnedObject;
/// <summary>
/// Status of the AR experience
/// </summary>
private bool initialized;
/// <summary>
/// Action triggered when a viable planned is detected
/// </summary>
private Action initExperience;
/// <summary>
/// Holds multiple disposable observable subscriptions
/// </summary>
private CompositeDisposable disposables;
#if UNITY_EDITOR
/// <summary>
/// An <see cref="IInputProvider"/> to handle touch/mouse input.
/// </summary>
private readonly IInputProvider inputProvider = new EditorInputProvider();
#else
private readonly IInputProvider inputProvider = new MobileInputProvider();
#endif
/// <summary>
/// Initialize the AR controller.
/// </summary>
/// <param name="onExperienceInitialized">An action to execute when the AR content is initialized.</param>
public void Initialize(Action onExperienceInitialized)
{
this.initExperience = onExperienceInitialized;
this.disposables = new CompositeDisposable();
this.sessionOriginAR = this.GetComponent<ARSessionOrigin>();
if (this.enableDistanceBasedScaling && this.scaleSlider.isActiveAndEnabled)
{
this.scaleSlider.minValue = this.minimumContentScaleFactor;
this.scaleSlider.maxValue = this.maximumContentScaleFactor;
}
#if DEBUG
// Toggle the debug panel by quadruple-tapping the screen
Utils.ObservableInput.WhenMouseButtonMultiClicked(minimumClicks: 4)
.Subscribe(_ => this.debugPanel.gameObject.SetActive(!this.debugPanel.gameObject.activeSelf))
.AddTo(this.disposables, this);
#endif
// Disable all plane visuals when content placed, then keep disabling planes when changes happen to trackable planes
this.WhenContentPlaced.WhereTrue().First().Do(_ => this.planeManagerAR.SetPlaneVisualsActive(false))
.ContinueWith(_ =>
this.planeManagerAR.WhenPlaneChanged()
.SelectMany(planes => planes)
.Do(plane => this.planeManagerAR.SetPlaneVisualsActive(plane.trackableId, !this.isContentPlaced.Value)))
.Subscribe()
.AddTo(this.disposables, this);
this.initialized = true;
}
/// <summary>
/// Instantiates the AR content.
/// </summary>
/// <param name="data">The AR item data referencing the content prefab.</param>
public GameObject InstantiateExperience(ARItemData data)
{
this.spawnedObject = Instantiate(data.ArFoundationPrefab, this.contentRoot);
this.isContentPlaced.Value = true;
return this.spawnedObject;
}
/// <summary>
/// Applies the given scale factor to the content.
/// <para>If the scale slider is enabled, this changes the slider's value. Otherwise, it scales the AR session.</para>
/// </summary>
/// <param name="scaleFactor">The target scale factor.</param>
/// <param name="relative">Whether the supplied factor is applied to the original scale factor or overwrites it.</param>
public void ApplyScaling(float scaleFactor, bool relative = false)
{
var finalScaleFactor = (relative ? this.contentScaleFactor + scaleFactor : scaleFactor)
.Clamp(this.minimumContentScaleFactor, this.maximumContentScaleFactor);
if (this.scaleSlider.isActiveAndEnabled && this.scaleSlider.value != scaleFactor)
this.scaleSlider.value = finalScaleFactor; // this should trigger ScaleSession
else
this.ScaleSession(finalScaleFactor);
}
/// <summary>
/// Scales the AR session so that the content appears to have the given scale factor.
/// Note that the pivot point for scaling is set by <see cref="MoveSession(Vector3)"/>.
/// </summary>
/// <param name="scaleFactor">The factor by which the content should appear scaled.</param>
private void ScaleSession(float scaleFactor)
{
this.contentScaleFactor = scaleFactor;
this.sessionOriginAR.transform.localScale = Vector3.one * this.contentScaleFactor;
this.debugPanel.DisplayLogMessage($"[ScaleSession] ARSessionOrigin scale: {this.sessionOriginAR.transform.localScale}\n" +
$"ARSessionOrigin offset {this.sessionOriginAR.transform.GetChild(0).position}\n" +
$"ARSessionOrigin rotation {this.sessionOriginAR.transform.rotation.eulerAngles}");
}
/// <summary>
/// Moves the AR session so that the content appears to be at the given location.
/// </summary>
/// <param name="targetLocation">The location of the content in world space.</param>
public void MoveSession(Vector3 targetLocation)
{
// MakeContentAppearAt applies the offset relatively, which accumulates error, so we reset it first
this.sessionOriginAR.MakeContentAppearAt(this.contentRoot, this.contentLocation.Negate());
this.contentLocation = targetLocation;
this.sessionOriginAR.MakeContentAppearAt(this.contentRoot, this.contentLocation);
this.debugPanel.DisplayLogMessage($"[MoveSession] ARSessionOrigin scale: {this.sessionOriginAR.transform.localScale}\n" +
$"ARSessionOrigin offset {this.sessionOriginAR.transform.GetChild(0).position}\n" +
$"ARSessionOrigin rotation {this.sessionOriginAR.transform.rotation.eulerAngles}");
}
/// <summary>
/// Rotates the AR session so that the content appears to be rotated the given angle around the Y axis.
/// </summary>
/// <param name="rotationAngle">The angle around the Y axis that the content should assume.</param>
public void RotateSession(float rotationAngle)
{
this.RotateSession(rotationAngle, Vector3.up);
}
/// <summary>
/// Rotates the AR session so that the content appears to be rotated the given angle around the given axis.
/// </summary>
/// <param name="rotationAngle">The angle around the given axis that the content should assume.</param>
/// <param name="axis">The axis around which to appear to rotate the content.</param>
public void RotateSession(float rotationAngle, Vector3 axis)
{
this.RotateSession(Quaternion.AngleAxis(rotationAngle, axis));
}
/// <summary>
/// Rotates the AR session so that the content appears to be rotated the given rotation.
/// </summary>
/// <param name="rotation">The rotation that the content should assume.</param>
public void RotateSession(Quaternion rotation)
{
this.sessionOriginAR.MakeContentAppearAt(this.contentRoot, rotation);
this.debugPanel.DisplayLogMessage($"[RotateSession] ARSessionOrigin scale: {this.sessionOriginAR.transform.localScale}\n" +
$"ARSessionOrigin offset {this.sessionOriginAR.transform.GetChild(0).position}\n" +
$"ARSessionOrigin rotation {this.sessionOriginAR.transform.rotation.eulerAngles}");
}
private void Update()
{
if (!this.initialized || this.isContentPlaced.Value || !Input.GetMouseButtonDown(0) || InputUtils.IsPointerOverUIObject())
return;
if (this.raycastManagerAR.RaycastToPlane(this.inputProvider.GetTouch(0), this.planeManagerAR, out var hitPose, out var hitPlane, out var contentDistance))
{
this.debugPanel.DisplayLogMessage($"Touched a plane at {hitPose.position}");
this.initExperience?.Invoke();
this.MoveSession(hitPose.position);
var comparisonPos = this.ArCamera.transform.position.WithY(hitPose.position.y);
var lookDirection = comparisonPos - hitPose.position;
this.RotateSession(Quaternion.LookRotation(lookDirection, hitPlane.normal));
if (this.enableDistanceBasedScaling)
{
// Determine initial content scale factor based on distance to the plane
// The content appears to have the original real world scale if it spawns at the reference distance,
// scaling up to the maximum scale factor if it is farther away or to the minimum if it's closer.
// What actually gets scaled is the session origin transform, so we scale it by the inverse of the calculated factor.
var scaleFactor = (1 / contentDistance.Ratio(0, this.contentReferenceDistance))
.Clamp(this.minimumContentScaleFactor, this.maximumContentScaleFactor);
this.debugPanel.DisplayLogMessage($"Distance based initial scaling:\nContent distance: {contentDistance}\nInitial scale factor: {scaleFactor}");
this.ApplyScaling(scaleFactor);
}
foreach (var pointCloud in this.pointCloudManagerAR.trackables)
pointCloud.gameObject.SetActive(false);
}
}
/// <summary>
/// Resets the AR system
/// </summary>
public void ResetModelAR()
{
Destroy(this.spawnedObject);
this.sessionAR.Reset();
this.isContentPlaced.Value = false;
// re enable the managers after the reset
this.planeManagerAR.enabled = true;
this.pointCloudManagerAR.enabled = true;
}
/// <summary>
/// Closes the AR experience
/// </summary>
public void CloseExperience()
{
this.disposables.Dispose();
}
/// <summary>
/// Returns an observable that emits the the state of the experience whenever it changes to a new value
/// </summary>
public IObservable<ExperienceState> WhenExperienceStateChanged()
{
// Count detected planes and content placement
return this.planeManagerAR.WhenPlaneAdded().Select(planes => planes.Count)
.Merge(this.planeManagerAR.WhenPlaneRemoved().Select(planes => -planes.Count))
.StartWith(this.planeManagerAR?.trackables.count ?? 0)
.Scan((accumulator, current) => accumulator + current)
.CombineLatest(this.isContentPlaced, (planeCount, isContentPlaced) => new ExperienceState(planeCount, isContentPlaced))
.DistinctUntilChanged();
}
}
}
using UniRx;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.XR.ARFoundation;
using MyNamespace.Extensions;
namespace MyNamespace.AR
{
/// <summary>
/// Controls the display of debug information from native AR subsystems
/// </summary>
public class ARDebugPanel : MonoBehaviour
{
[Header("Managers")]
[SerializeField]
private ARCameraManager cameraManager;
[SerializeField]
private ARPlaneManager planeManager;
[Header("AR Debug")]
/// <summary>
/// Reference to the text log component
/// </summary>
[SerializeField]
private Text logText;
/// <summary>
/// Reference to the <see cref="ScrollRect"/> that contains the text log
/// </summary>
[SerializeField]
private ScrollRect logScrollRect;
/// <summary>
/// Reference to the light used to replicate the physical world's lighting conditions
/// </summary>
[SerializeField]
private Light environmentalLight;
/// <summary>
/// Reference to the average brightness text component
/// </summary>
[SerializeField]
private Text averageBrightnessText;
/// <summary>
/// Reference to the average temperature text component
/// </summary>
[SerializeField]
private Text averageColorTemperatureText;
/// <summary>
/// Reference to the average color correction text component
/// </summary>
[SerializeField]
private Text averageColorCorrectionText;
private void Start()
{
ObservableARSubsystemManager.WhenSystemStateChanged()
.Subscribe(args => this.DisplayLogMessage($"System State Changed: {args.state}"))
.AddTo(this);
this.planeManager.WhenPlaneAdded()
.SelectMany(planes => planes)
.Subscribe(plane => this.DisplayLogMessage($"Plane ID {plane.trackableId} added at {plane.center}"))
.AddTo(this);
this.planeManager.WhenPlaneRemoved()
.SelectMany(planes => planes)
.Subscribe(plane => this.DisplayLogMessage($"Plane ID {plane.trackableId} removed at {plane.center}"))
.AddTo(this);
this.cameraManager.WhenBrightnessEstimationChanged()
.Subscribe(brightness =>
{
this.environmentalLight.intensity = brightness;
this.averageBrightnessText.text = $"Average brightness: {brightness}";
})
.AddTo(this);
this.cameraManager.WhenColorTemperatureEstimationChanged()
.Subscribe(colorTemperature =>
{
this.environmentalLight.colorTemperature = colorTemperature;
this.averageColorTemperatureText.text = $"Average color temperature: {colorTemperature}";
})
.AddTo(this);
this.cameraManager.WhenColorCorrectionEstimationChanged()
.Subscribe(colorCorrection =>
{
this.environmentalLight.color = colorCorrection;
this.averageColorCorrectionText.text = $"Color correction: {colorCorrection}";
})
.AddTo(this);
}
/// <summary>
/// Displays the given message on the text log UI component.
/// </summary>
/// <param name="message">The message to display.</param>
public void DisplayLogMessage(string message)
{
this.logText.text += message.AddSuffixIfAbsent("\n");
this.logScrollRect.verticalNormalizedPosition = 0;
}
}
}
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;
namespace MyNamespace.AR
{
/// <summary>
/// Extension methods for working with ARFoundation components
/// (e.g. <see cref="ARSessionOrigin"/>, <see cref="ARPlaneManager"/>, <see cref="ARPointCloudManager"/>, <see cref="ARReferencePointManager"/>)
/// </summary>
public static class ARFoundationComponentExtensions
{
/// <summary>
/// Enables or disables the visual representation of all tracked planes.
/// </summary>
/// <param name="manager">This <see cref="ARPlaneManager"/> that holds detected planes.</param>
/// <param name="activeState">The state of the planes' visual representation.</param>
public static void SetPlaneVisualsActive(this ARPlaneManager manager, bool activeState)
{
foreach (var plane in manager.trackables)
plane.gameObject.SetActive(activeState);
}
/// <summary>
/// Enables or disables the visual representation of the given tracked plane.
/// </summary>
/// <param name="manager">This <see cref="ARPlaneManager"/> that holds detected planes.</param>
/// <param name="planeId">The identifier of the plane to affect.</param>
/// <param name="activeState">The state of the planes' visual representation.</param>
public static void SetPlaneVisualsActive(this ARPlaneManager manager, TrackableId planeId, bool activeState)
{
manager.GetPlane(planeId)?.gameObject.SetActive(activeState);
}
/// <summary>
/// Casts a ray from a point in screen space against trackable planes.
/// </summary>
/// <param name="raycastManager">This <see cref="ARRaycastManager"/> that will cast the ray.</param>
/// <param name="screenPoint">The point in screen space from which to cast the ray.</param>
/// <param name="planeManager">The <see cref="ARPlaneManager"/> that holds detected planes.</param>
/// <param name="pose">The pose at which the cast ray hit the plane.</param>
/// <param name="plane">The <see cref="ARPlane"/> hit by the cast ray.</param>
/// <param name="distance">The distance to the hit plane.</param>
public static bool RaycastToPlane(this ARRaycastManager raycastManager, Vector3 screenPoint, ARPlaneManager planeManager, out Pose pose, out ARPlane plane, out float distance)
{
var hits = new List<ARRaycastHit>();
pose = Pose.identity;
plane = null;
distance = 0;
if (raycastManager.Raycast(screenPoint, hits, TrackableType.PlaneWithinPolygon))
{
var hit = hits[0];
pose = hit.pose;
plane = planeManager.GetPlane(hit.trackableId);
distance = hit.distance;
return true;
}
return false;
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using UniRx;
using UnityEngine.XR.ARFoundation;
namespace MyNamespace.AR
{
/// <summary>
/// Observable wrappers for <see cref="ARSubsystemManager"/> events.
/// </summary>
public static class ObservableARSubsystemManager
{
/// <summary>
/// Returns an observable that emits <see cref="ARSystemStateChangedEventArgs"/> whenever the <see cref="ARSubsystemManager.systemState"/> changes.
/// </summary>
public static IObservable<ARSessionStateChangedEventArgs> WhenSystemStateChanged()
{
return Observable.FromEvent<ARSessionStateChangedEventArgs>(
addHandler => ARSession.stateChanged += addHandler,
removeHandler => ARSession.stateChanged -= removeHandler);
}
/// <summary>
/// Returns an observable that emits <see cref="ARCameraFrameEventArgs"/> whenever a new camera frame is provided by the device.
/// </summary>
public static IObservable<ARCameraFrameEventArgs> WhenCameraFrameReceived(this ARCameraManager cameraManager)
{
return Observable.FromEvent<ARCameraFrameEventArgs>(
addHandler => cameraManager.frameReceived += addHandler,
removeHandler => cameraManager.frameReceived -= removeHandler);
}
/// <summary>
/// Returns an observable that emits the average brightness in the scene, as estimated by the current AR device, whenever it changes to a new value.
/// </summary>
public static IObservable<float> WhenBrightnessEstimationChanged(this ARCameraManager cameraManager)
{
return cameraManager.WhenCameraFrameReceived()
.Where(args => args.lightEstimation.averageBrightness.HasValue)
.Select(args => args.lightEstimation.averageBrightness.Value)
.DistinctUntilChanged();
}
/// <summary>
/// Returns an observable that emits the average color temperature in the scene, as estimated by the current AR device, whenever it changes to a new value.
/// <para>Currently, this is only available on ARKit.</para>
/// </summary>
public static IObservable<float> WhenColorTemperatureEstimationChanged(this ARCameraManager cameraManager)
{
return cameraManager.WhenCameraFrameReceived()
.Where(args => args.lightEstimation.averageColorTemperature.HasValue)
.Select(args => args.lightEstimation.averageColorTemperature.Value)
.DistinctUntilChanged();
}
/// <summary>
/// Returns an observable that emits scaling factors used for color correction, as estimated by the current AR device, whenever it changes to a new value.
/// </summary>
public static IObservable<UnityEngine.Color> WhenColorCorrectionEstimationChanged(this ARCameraManager cameraManager)
{
return cameraManager.WhenCameraFrameReceived()
.Where(args => args.lightEstimation.colorCorrection.HasValue)
.Select(args => args.lightEstimation.colorCorrection.Value)
.DistinctUntilChanged();
}
/// <summary>
/// Returns an observable that emits added planes.
/// </summary>
public static IObservable<List<ARPlane>> WhenPlaneAdded(this ARPlaneManager planeManager)
{
return planeManager.WhenAnyPlaneEvent().Select(args => args.added);
}
/// <summary>
/// Returns an observable that emits removed planes.
/// </summary>
public static IObservable<List<ARPlane>> WhenPlaneRemoved(this ARPlaneManager planeManager)
{
return planeManager.WhenAnyPlaneEvent().Select(args => args.removed);
}
/// <summary>
/// Returns an observable that emits updated planes.
/// </summary>
public static IObservable<List<ARPlane>> WhenPlaneUpdated(this ARPlaneManager planeManager)
{
return planeManager.WhenAnyPlaneEvent().Select(args => args.updated);
}
/// <summary>
/// Returns an observable that emits added, removed and updated planes.
/// </summary>
public static IObservable<List<ARPlane>> WhenPlaneChanged(this ARPlaneManager planeManager)
{
return planeManager.WhenAnyPlaneEvent().Select(args =>
args.added
.Concat(args.removed)
.Concat(args.updated)
.ToList());
}
/// <summary>
/// Returns an observable that emits <see cref="ARPlanesChangedEventArgs"/> whenever any plane event occurs.
/// </summary>
private static IObservable<ARPlanesChangedEventArgs> WhenAnyPlaneEvent(this ARPlaneManager planeManager)
{
return Observable.FromEvent<ARPlanesChangedEventArgs>(
addHandler => planeManager.planesChanged += addHandler,
removeHandler => planeManager.planesChanged -= removeHandler);
}
/// <summary>
/// Returns an observable that emits <see cref="ARPointCloudChangedEventArgs"/> whenever any point cloud event occurs.
/// </summary>
public static IObservable<ARPointCloudChangedEventArgs> WhenPointCloudUpdated(this ARPointCloudManager pointCloudManager)
{
return Observable.FromEvent<ARPointCloudChangedEventArgs>(
addHandler => pointCloudManager.pointCloudsChanged += addHandler,
removeHandler => pointCloudManager.pointCloudsChanged-= removeHandler);
}
/// <summary>
/// Returns an observable that emits <see cref="ARReferencePointsChangedEventArgs"/> whenever any reference point event occurs.
/// </summary>
public static IObservable<ARReferencePointsChangedEventArgs> WhenReferencePointUpdated(this ARReferencePointManager referencePointManager)
{
return Observable.FromEvent<ARReferencePointsChangedEventArgs>(
addHandler => referencePointManager.referencePointsChanged += addHandler,
removeHandler => referencePointManager.referencePointsChanged -= removeHandler);
}
}
}
using UnityEngine;
namespace MyNamespace.Utils
{
/// <summary>
/// Abstract input provider.
/// </summary>
public interface IInputProvider
{
/// <summary>
/// Returns the number of current touches.
/// </summary>
/// <returns>The number of current touches</returns>
int GetTouchCount();
/// <summary>
/// Returns the position of the touch at the given index in pixel coordinates
/// </summary>
/// <param name="index">The index of the touch</param>
/// <returns>The position of the touch at the given index in pixel coordinates</returns>
Vector2 GetTouch(int index);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment