// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) VRMADA, All rights reserved. // // -------------------------------------------------------------------------------------------------------------------- using System.Collections.Generic; using UltimateXR.Avatar; using UltimateXR.Core; using UltimateXR.Core.Components; using UltimateXR.Devices; using UltimateXR.Extensions.Unity.Math; using UltimateXR.Extensions.Unity.Render; using UltimateXR.Haptics.Helpers; using UltimateXR.Manipulation; using UnityEngine; using UnityEngine.Rendering; namespace UltimateXR.Examples.FullScene.Lab { /// /// Component that handles the laser in the lab room. /// public partial class Laser : UxrComponent { #region Inspector Properties/Serialized Fields [SerializeField] private Transform _laserSource; [SerializeField] private LayerMask _collisionMask = -1; [SerializeField] private float _laserRayWidth = 0.003f; [SerializeField] private float _laserRayLength = 5.0f; [SerializeField] private Color _laserColor = Color.red; [SerializeField] private float _laserBurnDelaySeconds = 0.2f; // Time the laser needs to be travelling at a speed less than LaserBurnParticlesMaxSpeed to create burn FX [SerializeField] private float _laserBurnSpeedThreshold = 0.4f; // Maximum speed the laser can travel before stopping particles emission [SerializeField] private Color _laserBurnColor = new Color(0.0f, 0.0f, 0.0f, 0.6f); [SerializeField] private float _laserBurnVertexDistance = 0.03f; [SerializeField] private float _laserBurnHeightOffset = 0.001f; [SerializeField] private float _laserBurnDurationFadeStart = 3.0f; [SerializeField] private float _laserBurnDurationFadeEnd = 6.0f; [SerializeField] private Color _laserBurnIncandescentColor = new Color(0.7f, 0.7f, 0.1f, 1.0f); [SerializeField] private float _laserBurnIncandescentDurationFadeEnd = 1.0f; [SerializeField] private ParticleSystem _laserBurnParticles; [SerializeField] private float _laserBurnParticlesHeightOffset; [SerializeField] private bool _laserBurnReflectParticles; [SerializeField] private GameObject _enableWhenLaserActive; [SerializeField] private UxrGrabbableObject _triggerGrabbable; [SerializeField] private Transform _trigger; [SerializeField] private Vector3 _triggerOffset; [SerializeField] private UxrFixedHapticFeedback _laserHaptics; #endregion #region Unity /// /// Sets up internal data. /// protected override void Awake() { base.Awake(); _triggerInitialOffset = _trigger.localPosition; // Line renderer setup _laserLineRenderer = gameObject.AddComponent(); _laserLineRenderer.useWorldSpace = false; SetLaserLineRendererMesh(_laserRayLength); _laserLineRenderer.material = new Material(ShaderExt.UnlitTransparentColor); _laserLineRenderer.material.renderQueue = (int)RenderQueue.Overlay + 1; _laserLineRenderer.material.color = _laserColor; _laserLineRenderer.loop = true; _laserLineRenderer.enabled = false; _laserBurns = new List(); ParticleSystem.EmissionModule emission = _laserBurnParticles.emission; emission.enabled = false; _laserHaptics.enabled = false; _createBurnTimer = _laserBurnDelaySeconds; } /// /// Subscribe to avatar updated event. /// protected override void OnEnable() { base.OnEnable(); UxrManager.AvatarsUpdated += UxrManager_AvatarsUpdated; } /// /// Unsubscribe from avatar updated event. /// protected override void OnDisable() { base.OnDisable(); UxrManager.AvatarsUpdated -= UxrManager_AvatarsUpdated; } #endregion #region Event Handling Methods /// /// We update the laser after all VR avatars have been updated to make sure it's processed after all manipulation. /// private void UxrManager_AvatarsUpdated() { // Check if there is a hand grabbing the laser if (UxrGrabManager.Instance.GetGrabbingHand(_triggerGrabbable, 0, out UxrGrabber grabber)) { // Check if it's the local avatar, because the local avatar will drive the state changes (IsLaserEnabled is network synchronized). if (grabber.Avatar.AvatarMode == UxrAvatarMode.Local) { // It is! see which hand to check for a trigger squeeze float trigger = UxrAvatar.LocalAvatarInput.GetInput1D(grabber.Side, UxrInput1D.Trigger); _trigger.localPosition = _triggerInitialOffset + _triggerOffset * trigger; _triggerGrabbable.GetGrabPoint(0).GetGripPoseInfo(grabber.Avatar).PoseBlendValue = trigger; if (UxrAvatar.LocalAvatarInput.GetButtonsPress(grabber.Side, UxrInputButtons.Trigger)) { // Trigger is squeezed if (IsLaserEnabled == false) { IsLaserEnabled = true; } } else { IsLaserEnabled = false; } } } else { // If there are no grabs, we don't need to sync using IsLaserEnabled because it can be solved locally. _laserLineRenderer.enabled = false; } // Check laser raycast if (IsLaserEnabled) { float currentRayLength = _laserRayLength; if (Physics.Raycast(_laserSource.position, _laserSource.forward, out RaycastHit hitInfo, currentRayLength, _collisionMask, QueryTriggerInteraction.Ignore)) { if (CurrentLaserBurn == null) { // This is a new burn -> initialize _laserBurns[_laserBurns.Count - 1] = CreateNewLaserBurn(hitInfo.collider); _createBurnTimer = _laserBurnDelaySeconds; } else if (CurrentLaserBurn.Collider != hitInfo.collider) { // If we hit another object we create a new laser burn entry _laserBurns.Add(CreateNewLaserBurn(hitInfo.collider)); _createBurnTimer = _laserBurnDelaySeconds; } // Check if laser travel speed is below threshold to create a burn. If so decrement timer which will start a burn if it reaches 0. if (Vector3.Distance(_lastLaserHitPosition, hitInfo.point) / Time.deltaTime < _laserBurnSpeedThreshold) { _createBurnTimer -= Time.deltaTime; } else { // Not enough speed -> new burn _laserBurns.Add(CreateNewLaserBurn(hitInfo.collider)); _createBurnTimer = _laserBurnDelaySeconds; } _lastLaserHitPosition = hitInfo.point; // Needs to start burn FX? ParticleSystem.EmissionModule emission = _laserBurnParticles.emission; emission.enabled = _createBurnTimer < 0.0f; _laserBurnParticles.transform.position = hitInfo.point + hitInfo.normal * _laserBurnParticlesHeightOffset; _laserBurnParticles.transform.rotation = _laserBurnReflectParticles ? Quaternion.LookRotation(Vector3.Reflect(_laserSource.forward, hitInfo.normal)) : Quaternion.Slerp(Quaternion.LookRotation(hitInfo.normal, Vector3.right), Quaternion.LookRotation(Vector3.up, Vector3.right), 0.9f); if (_createBurnTimer < 0.0f) { // Burn trail float segmentDistance = CurrentLaserBurn.PathPositions.Count == 0 ? 0.0f : Vector3.Distance(hitInfo.point, CurrentLaserBurn.LastWorldPathPosition); if (CurrentLaserBurn.PathPositions.Count == 0 || segmentDistance > _laserBurnVertexDistance) { // Here we should create a new segment since the burn has travelled enough distance to create a new one, but first we are going to check if we somehow // went over a hole, bump or depression in the geometry from the last point to this one. We do not want a burn strip to appear over gaps on the geometry so in that case // what we will do is just create a new laser burn and skip this segment if (CurrentLaserBurn.PathPositions.Count > 0 && segmentDistance > BurnGapCheckMinDistance) { bool startNewBurn = false; Vector3 vectorAB = hitInfo.point - CurrentLaserBurn.LastWorldPathPosition; for (int checkStep = 0; checkStep < BurnGapCheckSteps; ++checkStep) { // Perform a series of steps casting rays from the laser source to intermediate points between the last burn segment and the current one looking for changes in depth float t = (checkStep + 1.0f) / (BurnGapCheckSteps + 1.0f); Vector3 pointCheck = hitInfo.point + vectorAB * t; Vector3 perpendicular = Vector3.Cross(Vector3.Cross(vectorAB.normalized, ((CurrentLaserBurn.LastWorldNormal + hitInfo.normal) * 0.5f).normalized), vectorAB.normalized); Vector3 raySource = CurrentLaserBurn.LastWorldPathPosition + vectorAB * 0.5f + perpendicular * BurnGapCheckRaySourceDistance; if (Physics.Raycast(raySource, (pointCheck - raySource).normalized, out RaycastHit hitInfoBurnGapCheck, _laserRayLength, _collisionMask, QueryTriggerInteraction.Ignore)) { float distanceToLine = hitInfoBurnGapCheck.point.DistanceToLine(CurrentLaserBurn.LastWorldPathPosition, hitInfo.point); if (distanceToLine > BurnGapCheckLineDistanceThreshold) { // Depth change too big -> create new laser burn startNewBurn = true; break; } } else { // Raycast did not find intersection -> create new laser burn startNewBurn = true; break; } } if (startNewBurn) { _laserBurns.Add(CreateNewLaserBurn(hitInfo.collider)); } } // Add last point and offset a little bit from the geometry to draw correctly CurrentLaserBurn.PathPositions.Add(CurrentLaserBurn.Transform.InverseTransformPoint(hitInfo.point + hitInfo.normal * _laserBurnHeightOffset)); CurrentLaserBurn.PathCreationTimes.Add(Time.time); CurrentLaserBurn.LastNormal = CurrentLaserBurn.Transform.InverseTransformVector(hitInfo.normal); UpdateLaserBurnLineRenderer(CurrentLaserBurn); UpdateLaserBurns(0, _laserBurns.Count - 1); } else { UpdateLaserBurns(0, _laserBurns.Count); } } else { UpdateLaserBurns(0, _laserBurns.Count); } currentRayLength = hitInfo.distance; } else { ParticleSystem.EmissionModule emission = _laserBurnParticles.emission; emission.enabled = false; _createBurnTimer = _laserBurnDelaySeconds; UpdateLaserBurns(0, _laserBurns.Count); } SetLaserLineRendererMesh(currentRayLength); } else { ParticleSystem.EmissionModule emission = _laserBurnParticles.emission; emission.enabled = false; _createBurnTimer = _laserBurnDelaySeconds; UpdateLaserBurns(0, _laserBurns.Count); } if (_laserHaptics && grabber != null && grabber.Avatar.AvatarMode == UxrAvatarMode.Local) { _laserHaptics.enabled = IsLaserEnabled; _laserHaptics.HandSide = grabber.Side; } else { _laserHaptics.enabled = false; } if (_enableWhenLaserActive && !_enableWhenLaserActive.activeSelf && IsLaserEnabled) { _enableWhenLaserActive.SetActive(true); } else if (_enableWhenLaserActive && _enableWhenLaserActive.activeSelf && !IsLaserEnabled) { _enableWhenLaserActive.SetActive(false); } } #endregion #region Private Methods /// /// Updates the laser line renderer /// /// Current laser ray length private void SetLaserLineRendererMesh(float rayLength) { _laserLineRenderer.startWidth = _laserRayWidth; _laserLineRenderer.endWidth = _laserRayWidth; Vector3[] positions = { new Vector3(0.0f, 0.0f, 0.0f), new Vector3(0.0f, 0.0f, rayLength > LaserGradientLength ? LaserGradientLength : rayLength * 0.33f), new Vector3(0.0f, 0.0f, rayLength < LaserGradientLength * 2.0f ? rayLength * 0.66f : rayLength - LaserGradientLength), new Vector3(0.0f, 0.0f, rayLength) }; for (int i = 0; i < positions.Length; ++i) { positions[i] = _laserLineRenderer.transform.InverseTransformPoint(_laserSource.TransformPoint(positions[i])); } _laserLineRenderer.positionCount = 4; _laserLineRenderer.SetPositions(positions); Gradient colorGradient = new Gradient(); colorGradient.colorKeys = new[] { new GradientColorKey(Color.white, 0.0f), new GradientColorKey(Color.white, rayLength > LaserGradientLength ? LaserGradientLength / rayLength : 0.33f), new GradientColorKey(Color.white, rayLength < LaserGradientLength * 2.0f ? 0.66f : 1.0f - LaserGradientLength / rayLength), new GradientColorKey(Color.white, 1.0f) }; colorGradient.alphaKeys = new[] { new GradientAlphaKey(0.0f, 0.0f), new GradientAlphaKey(_laserColor.a, rayLength > LaserGradientLength ? LaserGradientLength / rayLength : 0.3f), new GradientAlphaKey(_laserColor.a, rayLength < LaserGradientLength * 2.0f ? 0.66f : 1.0f - LaserGradientLength / rayLength), new GradientAlphaKey(0.0f, 1.0f) }; _laserLineRenderer.colorGradient = colorGradient; } /// /// Creates a new laser burn entry /// /// Collider the laser burn is attached to /// New laser burn object private LaserBurn CreateNewLaserBurn(Collider collider) { LaserBurn newLaserBurn = new LaserBurn(); newLaserBurn.Collider = collider; newLaserBurn.GameObject = new GameObject("LaserBurn"); newLaserBurn.GameObject.transform.parent = collider.transform; newLaserBurn.GameObject.transform.localPosition = Vector3.zero; newLaserBurn.GameObject.transform.localRotation = Quaternion.identity; newLaserBurn.LineRenderer = newLaserBurn.GameObject.AddComponent(); newLaserBurn.LineRenderer.receiveShadows = true; newLaserBurn.LineRenderer.shadowCastingMode = ShadowCastingMode.Off; newLaserBurn.LineRenderer.useWorldSpace = false; newLaserBurn.LineRenderer.material = new Material(ShaderExt.UnlitTransparentColor); newLaserBurn.LineRenderer.material.renderQueue = (int)RenderQueue.Overlay + 1; newLaserBurn.LineRenderer.material.color = _laserBurnColor; newLaserBurn.LineRenderer.loop = false; newLaserBurn.LineRenderer.positionCount = 0; newLaserBurn.GameObjectIncandescent = new GameObject("LaserBurnIncandescent"); newLaserBurn.GameObjectIncandescent.transform.parent = collider.transform; newLaserBurn.GameObjectIncandescent.transform.localPosition = Vector3.zero; newLaserBurn.GameObjectIncandescent.transform.localRotation = Quaternion.identity; newLaserBurn.IncandescentLineRenderer = newLaserBurn.GameObjectIncandescent.AddComponent(); newLaserBurn.IncandescentLineRenderer.receiveShadows = false; newLaserBurn.IncandescentLineRenderer.shadowCastingMode = ShadowCastingMode.Off; newLaserBurn.IncandescentLineRenderer.useWorldSpace = false; newLaserBurn.IncandescentLineRenderer.material = new Material(ShaderExt.UnlitAdditiveColor); newLaserBurn.IncandescentLineRenderer.material.renderQueue = (int)RenderQueue.Overlay + 2; newLaserBurn.IncandescentLineRenderer.material.color = _laserBurnIncandescentColor; newLaserBurn.IncandescentLineRenderer.loop = false; newLaserBurn.IncandescentLineRenderer.positionCount = 0; newLaserBurn.PathPositions = new List(); newLaserBurn.PathCreationTimes = new List(); _createBurnTimer = _laserBurnDelaySeconds; return newLaserBurn; } /// /// Updates the given range of laser burns. Unused laser burns will be deleted /// /// The start index /// The number of items to update private void UpdateLaserBurns(int startIndex, int count) { for (int i = startIndex; i < startIndex + count && i < _laserBurns.Count; ++i) { UpdateLaserBurnLineRenderer(_laserBurns[i]); if (_laserBurns[i].PathPositions.Count <= 1 && _laserBurns[i] != CurrentLaserBurn) { Destroy(_laserBurns[i].GameObject); Destroy(_laserBurns[i].GameObjectIncandescent); _laserBurns.RemoveAt(i); i--; } } } /// /// Updates the laser burn line renderer /// /// LaserBurn object whose LineRenderer to update private void UpdateLaserBurnLineRenderer(LaserBurn laserBurn) { if (laserBurn == null || laserBurn.GameObject == null) { return; } laserBurn.LineRenderer.startWidth = _laserRayWidth * 2.5f; laserBurn.LineRenderer.endWidth = _laserRayWidth * 2.5f; laserBurn.IncandescentLineRenderer.startWidth = _laserRayWidth * 1.5f; laserBurn.IncandescentLineRenderer.endWidth = _laserRayWidth * 1.5f; // Remove segment positions that have already faded out. Keep just 1 if all need to be deleted to have LastPathPosition accessible. int lastIndexToDelete = -1; for (int i = 0; i < laserBurn.PathCreationTimes.Count; ++i) { if (Time.time - laserBurn.PathCreationTimes[i] >= _laserBurnDurationFadeEnd) { lastIndexToDelete = i; } else { break; } } if (lastIndexToDelete >= 0 && laserBurn.PathPositions.Count > 1) { laserBurn.PathCreationTimes.RemoveRange(0, lastIndexToDelete + 1); laserBurn.PathPositions.RemoveRange(0, lastIndexToDelete + 1); } // Inside the burn range, compute the start index for the incandescent part int incandescentIndexStart = 0; for (int i = 0; i < laserBurn.PathCreationTimes.Count; ++i) { if (Time.time - laserBurn.PathCreationTimes[i] < _laserBurnIncandescentDurationFadeEnd) { incandescentIndexStart = i; break; } } // Create color and alpha gradients. Maximum number of entries for Unity's LineRenderer are 8. if (laserBurn.PathCreationTimes.Count > 0) { Gradient colorGradient = new Gradient(); Gradient colorGradientIncandescent = new Gradient(); colorGradient.colorKeys = new[] { new GradientColorKey(Color.white, 0.0f), new GradientColorKey(Color.white, 1.0f) }; colorGradientIncandescent.colorKeys = new[] { new GradientColorKey(Color.white, 0.0f), new GradientColorKey(Color.white, 1.0f) }; GradientAlphaKey[] alphaKeys = new GradientAlphaKey[8]; GradientAlphaKey[] alphaKeysIncandescent = new GradientAlphaKey[8]; for (int i = 0; i < alphaKeys.Length; ++i) { float t = i / (alphaKeys.Length - 1.0f); int timeIndex = Mathf.Clamp(Mathf.RoundToInt(t * (laserBurn.PathCreationTimes.Count - 1.0f)), 0, laserBurn.PathCreationTimes.Count - 1); float life = Time.time - laserBurn.PathCreationTimes[timeIndex]; alphaKeys[i].alpha = life < _laserBurnDurationFadeStart ? 1.0f : 1.0f - Mathf.Clamp01((life - _laserBurnDurationFadeStart) / (_laserBurnDurationFadeEnd - _laserBurnDurationFadeStart)); alphaKeys[i].time = t; } for (int i = 0; i < alphaKeysIncandescent.Length; ++i) { float t = i / (alphaKeysIncandescent.Length - 1.0f); int numIncandescentEntries = laserBurn.PathCreationTimes.Count - incandescentIndexStart; int timeIndex = Mathf.Clamp(Mathf.RoundToInt(t * (numIncandescentEntries - 1.0f)), 0, numIncandescentEntries - 1); float life = Time.time - laserBurn.PathCreationTimes[incandescentIndexStart + timeIndex]; alphaKeysIncandescent[i].alpha = 1.0f - Mathf.Clamp01(life / _laserBurnIncandescentDurationFadeEnd); alphaKeysIncandescent[i].time = t; } colorGradient.alphaKeys = alphaKeys; colorGradientIncandescent.alphaKeys = alphaKeysIncandescent; laserBurn.LineRenderer.colorGradient = colorGradient; laserBurn.IncandescentLineRenderer.colorGradient = colorGradientIncandescent; } // Update positions if (laserBurn.PathPositions.Count > 1) { laserBurn.LineRenderer.positionCount = laserBurn.PathPositions.Count; laserBurn.LineRenderer.SetPositions(laserBurn.PathPositions.ToArray()); } else { laserBurn.LineRenderer.positionCount = 0; } if (incandescentIndexStart < laserBurn.PathPositions.Count - 1) { int count = laserBurn.PathPositions.Count - incandescentIndexStart; laserBurn.IncandescentLineRenderer.positionCount = count; laserBurn.IncandescentLineRenderer.SetPositions(laserBurn.PathPositions.GetRange(incandescentIndexStart, count).ToArray()); } else { laserBurn.IncandescentLineRenderer.positionCount = 0; } } #endregion #region Private Types & Data /// /// Gets or sets whether the laser is enabled. /// private bool IsLaserEnabled { get => _laserLineRenderer.enabled; set { if (_laserLineRenderer.enabled != value) { BeginSync(); if (value) { // Start a new empty laser burn (it will be setup later, we use null to force to set up a new one) _laserBurns.Add(null); } _laserLineRenderer.enabled = value; EndSyncProperty(value); } } } /// /// Gets the current laser burn being generated. /// private LaserBurn CurrentLaserBurn => _laserBurns[_laserBurns.Count - 1]; private const float LaserGradientLength = 0.4f; // Laser is rendered with a gradient of this length to make it a little less dull private const float BurnGapCheckSteps = 4; // Number of steps to check for gaps in the geometry when computing laser burns private const float BurnGapCheckMinDistance = 0.02f; // Only perform gap checks if the separation between two consecutive laser burn segments is greater than this value private const float BurnGapCheckRaySourceDistance = 0.1f; // The raycasts performed on each gap check step will be casted from this distance to the geometry private const float BurnGapCheckLineDistanceThreshold = 0.002f; // Allow this amount of depth variation when checking for gaps or bumps in the geometry private Vector3 _triggerInitialOffset; private LineRenderer _laserLineRenderer; private List _laserBurns; private Vector3 _lastLaserHitPosition; private float _createBurnTimer; #endregion } }