Two-hand Scaling script

(could use some optimization, but I’ve been looking for this for so long)

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.