(could use some optimization, but I've been looking for this for *so long*) Code (CSharp): using System; using System.Collections; using System.Collections.Generic; using UnityEngine; using Valve.VR; using Valve.VR.InteractionSystem; public class Scaleable : MonoBehaviour { private GameObject middleMan; private bool stoppingResize; private SteamVR_Action_Boolean grabBoolean = SteamVR_Input.GetAction<SteamVR_Action_Boolean>("GrabGrip"); public SteamVR_Skeleton_JointIndexEnum fingerJointHover = SteamVR_Skeleton_JointIndexEnum.indexTip; protected MeshRenderer[] highlightRenderers; protected MeshRenderer[] existingRenderers; protected GameObject highlightHolder; protected SkinnedMeshRenderer[] highlightSkinnedRenderers; protected SkinnedMeshRenderer[] existingSkinnedRenderers; protected static Material highlightMat; [Tooltip("An array of child gameObjects to not render a highlight for. Things like transparent parts, vfx, etc.")] public GameObject[] hideHighlight; private bool isResizing; public SteamVR_Action_Boolean rightGrab; public Hand rightHand; private bool rightGrabbing; private Collider[] rightOverlappingColliders; public LayerMask rightHoverLayerMask = -1; public SteamVR_Action_Boolean leftGrab; public Hand leftHand; private bool hovering; private bool wasHovering; private bool leftGrabbing; private Collider[] leftOverlappingColliders; public LayerMask leftHoverLayerMask = -1; public int hoverPriority; private int prevOverlappingColliders = 0; private bool attachedToHand; private float initialDistance; private Vector3 initialScale; private Quaternion initialRot; private Vector3 offsetPos; private Hand currentMain; private List<MeshRenderer> flashingRenderers = new List<MeshRenderer>(); public Color hintColor; public GameObject grabHintPrefab; private GameObject rightTextHint; private GameObject leftTextHint; void OnEnable() { rightGrab.AddOnChangeListener(SetRightGrab, rightHand.handType); leftGrab.AddOnChangeListener(SetLeftGrab, leftHand.handType); } void Start() { highlightMat = (Material)Resources.Load("SteamVR_HoverHighlight", typeof(Material)); if (highlightMat == null) { Debug.LogError("<b>[SteamVR Interaction]</b> Hover Highlight Material is missing. Please create a material named 'SteamVR_HoverHighlight' and place it in a Resources folder", this); } if (rightHand.gameObject.layer == 0) Debug.LogWarning("<b>[SteamVR Interaction]</b> Hand is on default layer. This puts unnecessary strain on hover checks as it is always true for hand colliders (which are then ignored).", this); else rightHoverLayerMask &= ~(1 << rightHand.gameObject.layer); //ignore self for hovering if (leftHand.gameObject.layer == 0) Debug.LogWarning("<b>[SteamVR Interaction]</b> Hand is on default layer. This puts unnecessary strain on hover checks as it is always true for hand colliders (which are then ignored).", this); else leftHoverLayerMask &= ~(1 << leftHand.gameObject.layer); //ignore self for hovering // allocate array for colliders rightOverlappingColliders = new Collider[32]; leftOverlappingColliders = new Collider[32]; foreach (Hand hand in Player.instance.hands) { hand.HideController(); } } void SetRightGrab(SteamVR_Action_Boolean fromAction, SteamVR_Input_Sources fromSource, bool newState) { float scaledHoverRadius = 0.075f * Mathf.Abs(SteamVR_Utils.GetLossyScale(rightHand.transform)); float closestDistance = float.MaxValue; Scaleable closestInteractable = null; if (rightHand.mainRenderModel != null) CheckHoveringForTransform(rightHand, rightOverlappingColliders, rightHand.mainRenderModel.GetBonePosition((int)rightHand.fingerJointHover), ref closestDistance, ref closestInteractable, Color.blue); if (this.Equals(closestInteractable)) { if (newState) { wasHovering = hovering; hovering = false; GrabHintOff(rightHand); } else { wasHovering = hovering; hovering = true; if (isResizing) { stoppingResize = true; isResizing = false; EndScale(rightHand, leftGrabbing); } } rightGrabbing = newState; SetResizing(rightHand); SetPickup(newState, rightHand); } } void SetLeftGrab(SteamVR_Action_Boolean fromAction, SteamVR_Input_Sources fromSource, bool newState) { float scaledHoverRadius = 0.075f * Mathf.Abs(SteamVR_Utils.GetLossyScale(leftHand.transform)); float closestDistance = float.MaxValue; Scaleable closestInteractable = null; if (leftHand.mainRenderModel != null) CheckHoveringForTransform(leftHand, leftOverlappingColliders, leftHand.mainRenderModel.GetBonePosition((int)leftHand.fingerJointHover), ref closestDistance, ref closestInteractable, Color.blue); if (this.Equals(closestInteractable)) { if (newState) { wasHovering = hovering; hovering = false; GrabHintOff(leftHand); } else { wasHovering = hovering; hovering = true; if (isResizing) { stoppingResize = true; isResizing = false; EndScale(leftHand, rightGrabbing); } } leftGrabbing = newState; SetResizing(leftHand); SetPickup(newState, leftHand); } } void EndScale(Hand hand, bool grabbing) { transform.SetParent(null); if (grabbing) { transform.SetParent(hand.otherHand.transform); } } // Update is called once per frame void Update() { float rightClosestDistance = float.MaxValue; Scaleable rightClosestInteractable = null; if (rightHand.mainRenderModel != null) CheckHoveringForTransform(rightHand, rightOverlappingColliders, rightHand.mainRenderModel.GetBonePosition((int)rightHand.fingerJointHover), ref rightClosestDistance, ref rightClosestInteractable, Color.blue); float leftClosestDistance = float.MaxValue; Scaleable leftClosestInteractable = null; if (leftHand.mainRenderModel != null) CheckHoveringForTransform(leftHand, leftOverlappingColliders, leftHand.mainRenderModel.GetBonePosition((int)leftHand.fingerJointHover), ref leftClosestDistance, ref leftClosestInteractable, Color.blue); if (this.Equals(leftClosestInteractable) || this.Equals(rightClosestInteractable)) { if (this.Equals(leftClosestInteractable)) { if (transform.parent == null) { GrabHintOn(leftHand, "Grab"); } else if (transform.parent.Equals(rightHand.transform)) { GrabHintOn(leftHand, "Scale"); } } else { GrabHintOff(leftHand); } if (this.Equals(rightClosestInteractable)) { if (transform.parent == null) { GrabHintOn(rightHand, "Grab"); } else if (transform.parent.Equals(leftHand.transform)) { GrabHintOn(rightHand, "Scale"); } } else { GrabHintOff(rightHand); } wasHovering = hovering; hovering = true; } else { wasHovering = hovering; hovering = false; GrabHintOff(rightHand); GrabHintOff(leftHand); } if (hovering && !wasHovering) { CreateHighlightRenderers(); } else if (!hovering || !wasHovering) { UnHighlight(); } UpdateHighlightRenderers(); if (isResizing) { SetScale(); } foreach (MeshRenderer r in flashingRenderers) { r.material.SetColor("_EmissionColor", Color.Lerp(Color.black, hintColor, Util.RemapNumberClamped(Mathf.Cos((Time.realtimeSinceStartup) * Mathf.PI * 2.0f), -1.0f, 1.0f, 0.0f, 1.0f))); r.material.SetFloat("_EmissionScaleUI", Mathf.Lerp(0.0f, 10.0f, Util.RemapNumberClamped(Mathf.Cos((Time.realtimeSinceStartup) * Mathf.PI * 2.0f), -1.0f, 1.0f, 0.0f, 1.0f))); } if (rightTextHint != null) { rightTextHint.transform.LookAt(Camera.main.transform); } if (leftTextHint != null) { leftTextHint.transform.LookAt(Camera.main.transform); } } private void GrabHintOn(Hand hand, string text) { hand.ShowController(); hand.HideSkeleton(); // hand.GetComponent<HandPhysics>().enabled = false; SteamVR_RenderModel model = hand.GetComponentInChildren<SteamVR_RenderModel>(); if (model != null) { string gripName = grabBoolean.GetRenderModelComponentName(hand.handType); Dictionary<string, Transform> componentTransformMap = new Dictionary<string, Transform>(); for (int childIndex = 0; childIndex < model.transform.childCount; childIndex++) { Transform child = model.transform.GetChild(childIndex); if (!componentTransformMap.ContainsKey(child.name)) { componentTransformMap.Add(child.name, child); } } Transform buttonTransform = componentTransformMap[gripName]; if (hand.Equals(rightHand)) { if (rightTextHint == null) { rightTextHint = GameObject.Instantiate(grabHintPrefab, buttonTransform.position, buttonTransform.rotation); rightTextHint.transform.SetParent(buttonTransform); rightTextHint.transform.localPosition += new Vector3(-0.05349f, 0.01587f, -0.16261f); } rightTextHint.GetComponent<HintText>().text.text = text; } else { if (leftTextHint == null) { leftTextHint = GameObject.Instantiate(grabHintPrefab, buttonTransform.position, buttonTransform.rotation); leftTextHint.transform.SetParent(buttonTransform); leftTextHint.transform.localPosition += new Vector3(0.05349f, -0.01587f, -0.16261f); } leftTextHint.GetComponent<HintText>().text.text = text; } foreach (MeshRenderer r in buttonTransform.GetComponentsInChildren<MeshRenderer>()) { if (!flashingRenderers.Contains(r)) flashingRenderers.Add(r); r.material.EnableKeyword("_EMISSION"); } } } private void GrabHintOff(Hand hand) { if (flashingRenderers.Count > 0) { SteamVR_RenderModel model = hand.GetComponentInChildren<SteamVR_RenderModel>(); if (model != null) { string gripName = grabBoolean.GetRenderModelComponentName(hand.handType); Debug.Log($"gripName: {gripName}"); Dictionary<string, Transform> componentTransformMap = new Dictionary<string, Transform>(); for (int childIndex = 0; childIndex < model.transform.childCount; childIndex++) { Transform child = model.transform.GetChild(childIndex); if (!componentTransformMap.ContainsKey(child.name)) { componentTransformMap.Add(child.name, child); } } Transform buttonTransform = componentTransformMap[gripName]; foreach (MeshRenderer r in buttonTransform.GetComponentsInChildren<MeshRenderer>()) { flashingRenderers.Remove(r); r.material.DisableKeyword("_EMISSION"); } } if (hand.Equals(rightHand) && rightTextHint != null) { Destroy(rightTextHint); } else if (hand.Equals(leftHand) && leftTextHint != null) { Destroy(leftTextHint); } } if (flashingRenderers.Count == 0) { hand.HideController(); hand.ShowSkeleton(); // hand.GetComponent<HandPhysics>().enabled = true; } } private void UnHighlight() { Destroy(highlightHolder); GrabHintOff(rightHand); GrabHintOff(leftHand); } void SetResizing(Hand mainHand) { if (leftGrabbing && rightGrabbing) { isResizing = true; attachedToHand = true; Debug.Log($"attached to {mainHand.handType}"); UnHighlight(); initialDistance = Vector3.Distance(mainHand.transform.position, mainHand.otherHand.transform.position); middleMan = new GameObject(); Transform midpoint = middleMan.transform; midpoint.position = (mainHand.otherHand.transform.position + mainHand.transform.position) / 2; midpoint.rotation = FindRot(mainHand.transform, mainHand.otherHand.transform); currentMain = mainHand; transform.SetParent(midpoint); midpoint.SetParent(null); offsetPos = transform.localPosition; } } void SetScale() { Vector3 mainPos = currentMain.transform.position; Vector3 otherPos = currentMain.otherHand.transform.position; float scale = Vector3.Distance(mainPos, otherPos) / initialDistance; middleMan.transform.localScale = new Vector3(scale, scale, scale); middleMan.transform.rotation = FindRot(currentMain.transform, currentMain.otherHand.transform); middleMan.transform.position = (mainPos + otherPos) / 2; } public void ScaleAround(GameObject target, Vector3 pivot, Vector3 newScale) { Vector3 A = target.transform.localPosition; Vector3 B = pivot; Vector3 C = A - B; // diff from object pivot to desired pivot/origin float RS = newScale.x / target.transform.localScale.x; // relataive scale factor // calc final position post-scale Vector3 FP = B + C * RS; // finally, actually perform the scale/translation target.transform.localScale = newScale; target.transform.localPosition = FP; } //find rotation between two points to add to initialrot private Quaternion FindRot(Transform t1, Transform t2) { Quaternion rot1 = t1.rotation; Quaternion rot2 = t2.rotation; Vector3 pos1 = t1.position; Vector3 pos2 = t2.position; Vector3 axis1to2 = (pos2 - pos1); Vector3 up1 = t1.up; Vector3 up2 = t2.up; Vector3 averageUp = (up1 + up2) / 2; Vector3 forward = Vector3.Cross(averageUp, axis1to2); Vector3 finalUp = Vector3.Cross(forward, axis1to2); Quaternion rot = Quaternion.LookRotation(forward, finalUp); return rot; } void SetPickup(bool newState, Hand hand) { if (isResizing || stoppingResize) { if (stoppingResize) stoppingResize = false; return; } else if (newState) { transform.SetParent(hand.transform); attachedToHand = true; UnHighlight(); return; } else { transform.SetParent(null); attachedToHand = false; UnHighlight(); CreateHighlightRenderers(); return; } } protected virtual bool CheckHoveringForTransform(Hand hand, Collider[] overlappingColliders, Vector3 hoverPosition, ref float closestDistance, ref Scaleable closestInteractable, Color debugColor) { bool foundCloser = false; // null out old vals for (int i = 0; i < overlappingColliders.Length; ++i) { overlappingColliders[i] = null; } int numColliding = Physics.OverlapSphereNonAlloc(hoverPosition, hand.controllerHoverRadius, overlappingColliders, hand.hoverLayerMask.value); if (numColliding >= 32) Debug.LogWarning("<b>[SteamVR Interaction]</b> This hand is overlapping the max number of colliders: " + 32 + ". Some collisions may be missed. Increase 32 on Hand.cs"); // DebugVar int iActualColliderCount = 0; // Pick the closest hovering for (int colliderIndex = 0; colliderIndex < overlappingColliders.Length; colliderIndex++) { Collider collider = overlappingColliders[colliderIndex]; if (collider == null) continue; Scaleable contacting = collider.GetComponentInParent<Scaleable>(); // Yeah, it's null, skip if (contacting == null) continue; // Ignore this collider for hovering IgnoreHovering ignore = collider.GetComponent<IgnoreHovering>(); if (ignore != null) { if (ignore.onlyIgnoreHand == null || ignore.onlyIgnoreHand == hand) { continue; } } // Can't hover over the object if it's attached bool hoveringOverAttached = false; for (int attachedIndex = 0; attachedIndex < hand.AttachedObjects.Count; attachedIndex++) { if (hand.AttachedObjects[attachedIndex].attachedObject == contacting.gameObject) { hoveringOverAttached = true; break; } } if (hoveringOverAttached) continue; // Best candidate so far... float distance = Vector3.Distance(contacting.transform.position, hoverPosition); //float distance = Vector3.Distance(collider.bounds.center, hoverPosition); bool lowerPriority = false; if (closestInteractable != null) { // compare to closest interactable to check priority lowerPriority = contacting.hoverPriority < closestInteractable.hoverPriority; } bool isCloser = (distance < closestDistance); if (isCloser && !lowerPriority) { closestDistance = distance; closestInteractable = contacting; foundCloser = true; } iActualColliderCount++; } if (iActualColliderCount > 0 && iActualColliderCount != prevOverlappingColliders) { prevOverlappingColliders = iActualColliderCount; } return foundCloser; } protected virtual void CreateHighlightRenderers() { existingSkinnedRenderers = this.GetComponentsInChildren<SkinnedMeshRenderer>(true); if (highlightHolder == null) highlightHolder = new GameObject("Highlighter"); highlightSkinnedRenderers = new SkinnedMeshRenderer[existingSkinnedRenderers.Length]; for (int skinnedIndex = 0; skinnedIndex < existingSkinnedRenderers.Length; skinnedIndex++) { SkinnedMeshRenderer existingSkinned = existingSkinnedRenderers[skinnedIndex]; if (ShouldIgnoreHighlight(existingSkinned)) continue; GameObject newSkinnedHolder = new GameObject("SkinnedHolder"); newSkinnedHolder.transform.parent = highlightHolder.transform; SkinnedMeshRenderer newSkinned = newSkinnedHolder.AddComponent<SkinnedMeshRenderer>(); Material[] materials = new Material[existingSkinned.sharedMaterials.Length]; for (int materialIndex = 0; materialIndex < materials.Length; materialIndex++) { materials[materialIndex] = highlightMat; } newSkinned.sharedMaterials = materials; newSkinned.sharedMesh = existingSkinned.sharedMesh; newSkinned.rootBone = existingSkinned.rootBone; newSkinned.updateWhenOffscreen = existingSkinned.updateWhenOffscreen; newSkinned.bones = existingSkinned.bones; highlightSkinnedRenderers[skinnedIndex] = newSkinned; } MeshFilter[] existingFilters = this.GetComponentsInChildren<MeshFilter>(true); existingRenderers = new MeshRenderer[existingFilters.Length]; highlightRenderers = new MeshRenderer[existingFilters.Length]; for (int filterIndex = 0; filterIndex < existingFilters.Length; filterIndex++) { MeshFilter existingFilter = existingFilters[filterIndex]; MeshRenderer existingRenderer = existingFilter.GetComponent<MeshRenderer>(); if (existingFilter == null || existingRenderer == null || ShouldIgnoreHighlight(existingFilter)) continue; GameObject newFilterHolder = new GameObject("FilterHolder"); newFilterHolder.transform.parent = highlightHolder.transform; MeshFilter newFilter = newFilterHolder.AddComponent<MeshFilter>(); newFilter.sharedMesh = existingFilter.sharedMesh; MeshRenderer newRenderer = newFilterHolder.AddComponent<MeshRenderer>(); Material[] materials = new Material[existingRenderer.sharedMaterials.Length]; for (int materialIndex = 0; materialIndex < materials.Length; materialIndex++) { materials[materialIndex] = highlightMat; } newRenderer.sharedMaterials = materials; highlightRenderers[filterIndex] = newRenderer; existingRenderers[filterIndex] = existingRenderer; } } protected virtual void UpdateHighlightRenderers() { if (highlightHolder == null) return; for (int skinnedIndex = 0; skinnedIndex < existingSkinnedRenderers.Length; skinnedIndex++) { SkinnedMeshRenderer existingSkinned = existingSkinnedRenderers[skinnedIndex]; SkinnedMeshRenderer highlightSkinned = highlightSkinnedRenderers[skinnedIndex]; if (existingSkinned != null && highlightSkinned != null && attachedToHand == false) { highlightSkinned.transform.position = existingSkinned.transform.position; highlightSkinned.transform.rotation = existingSkinned.transform.rotation; highlightSkinned.transform.localScale = existingSkinned.transform.lossyScale; highlightSkinned.localBounds = existingSkinned.localBounds; highlightSkinned.enabled = hovering && existingSkinned.enabled && existingSkinned.gameObject.activeInHierarchy; int blendShapeCount = existingSkinned.sharedMesh.blendShapeCount; for (int blendShapeIndex = 0; blendShapeIndex < blendShapeCount; blendShapeIndex++) { highlightSkinned.SetBlendShapeWeight(blendShapeIndex, existingSkinned.GetBlendShapeWeight(blendShapeIndex)); } } else if (highlightSkinned != null) highlightSkinned.enabled = false; } for (int rendererIndex = 0; rendererIndex < highlightRenderers.Length; rendererIndex++) { MeshRenderer existingRenderer = existingRenderers[rendererIndex]; MeshRenderer highlightRenderer = highlightRenderers[rendererIndex]; if (existingRenderer != null && highlightRenderer != null && attachedToHand == false) { highlightRenderer.transform.position = existingRenderer.transform.position; highlightRenderer.transform.rotation = existingRenderer.transform.rotation; highlightRenderer.transform.localScale = existingRenderer.transform.lossyScale; highlightRenderer.enabled = hovering && existingRenderer.enabled && existingRenderer.gameObject.activeInHierarchy; } else if (highlightRenderer != null) { highlightRenderer.enabled = false; GrabHintOff(rightHand); GrabHintOff(leftHand); } } } protected virtual bool ShouldIgnoreHighlight(Component component) { return ShouldIgnore(component.gameObject); } protected virtual bool ShouldIgnore(GameObject check) { for (int ignoreIndex = 0; ignoreIndex < hideHighlight.Length; ignoreIndex++) { if (check == hideHighlight[ignoreIndex]) return true; } return false; } }
The text hint prefab is a gameobject with a canvas child with a TMPro object, where the gameobject has a simple `HintText` component that solely accesses the TMPro object. I can post it if needed but it shouldn't be too hard to make edit: here it is
Hi @miloszecket, I too have been looking for this functionality for a long time. I was excited to see your code but ran into some incompatibilites with the Unity XR environment I'm working in for a standalone Quest 2 app. Any chance you've already been down that rode and have an XR version of you code to share? Thanks.