Overview

Squeeze to Scale (Object or World) in VR

The following code enables you to use both controllers in VR (tested on Quest) and scale the player's perspective (XRRig) by squeezing the grab triggers and moving the controllers closer (pinch out) or further apart (pinch in). The boolean allowWorldScaling has to be ticked in unity for that to work.

Upon selecting a draggable object (Drag controls script), the player can scale up or down that object, while keeping the finger on the trigger and squeezing both grab buttons and moving the hands closer or apart.

The current script enables you to visually see the scale. Create a world canvas with a text component as a child. Assign the world canvas to scaleTextObject and the text to scaleText. scaleTextObject will then spawn in front of the player and follow the head movement whenever scaling.

At the moment the position of the hands (controllers) is done by finding the avatar's hands. I couldn't make it work otherwise. If you find a better way please share.

import { Behaviour, WebXR,serializeable, WebXREvent,WebXRAvatar,GameObject, AvatarMarker,Text} from "@needle-tools/engine";
import { Object3D, Vector3,Quaternion,PerspectiveCamera} from "three";

export class SqueezeScale extends Behaviour {

   
    private webXR?: WebXR;

    private selectedObj: Object3D| null = null;

    @serializeable(Object3D)
    scaleTextObject: Object3D| null = null;
    
    @serializeable(Text)
    scaleText?: Text;

    public allowWorldScaling: boolean =false;

    private leftSqueeze:boolean=false;
    private rightSqueeze:boolean=false;

    private bothSqueezeStarted=false;

    private rigScaleUpdated=false;

    private initialDistance:number=1;
    private initialScale:number=1;
    private newScale:number | null=null;

    private leftHand?:Object3D;
    private rightHand?:Object3D;
    private head?:Object3D;

    start(): void {

        let _webxr=GameObject.findObjectOfType(WebXR);
        if(_webxr)
        {
            this.webXR=_webxr;
            console.log("webxr found");
        }

        //Wait for XR Session
        WebXR.addEventListener(WebXREvent.XRStarted, () => {
            //listen to squeeze events
            this.context.xrSession?.addEventListener("squeezestart", (event) => { this.onSqueezeEvent(event,true); });
            this.context.xrSession?.addEventListener("squeezeend", (event) => { this.onSqueezeEvent(event,false); });
        });
    }


    onSqueezeEvent(event: XRInputSourceEvent, status:boolean) {
        
        if(event.inputSource.handedness==="right")
        {
            this.rightSqueeze=status;
        }
        
        if(event.inputSource.handedness==="left")
        {
            this.leftSqueeze=status;
        }
    }

    update(){
        
        if(this.context.isInVR)
        {
            //cache object selected if any
            this.objectGrab();
            
            //if both grips are squeezed 
            if(this.leftSqueeze && this.rightSqueeze)
            {
                //if object is selected either in the left or right controller (only one)
                if(this.selectedObj!=null)
                {
                    //after initial distance value has been set
                    if(this.bothSqueezeStarted)
                    {
                        //get current distance between controllers
                        const scaleValue=this.calculateDistance();

                        //get distance change since beginning of squeeze to get a "pinch in/out" effect
                        this.newScale=this.initialScale+scaleValue-this.initialDistance;

                        //avoid 0 and negative scales
                        if(this.newScale<0.001){this.newScale=0.001;}

                        // scale object according to new distance since initial distance
                        this.selectedObj.scale.x=this.newScale;
                        this.selectedObj.scale.y=this.newScale;
                        this.selectedObj.scale.z=this.newScale;

                        this.showVisual(this.newScale,"Object :");
                    }
                    else
                    {
                        //get initial distance value (only once at a new squeeze both hands event)
                        this.bothSqueezeStarted=true;

                        this.initialDistance=this.calculateDistance();

                        //cache object's initial scale
                        this.initialScale=this.selectedObj.scale.x;
                    }
                }
                else
                {
                    //scale world ?
                    if(this.webXR?.Rig && this.allowWorldScaling)
                    {
                        //after initial distance value has been set
                        if(this.bothSqueezeStarted)
                        {
                            //get current distance between controllers
                            const scaleValue=this.calculateDistance();

                            //get distance change since beginning of squeeze to get a "pinch in/out" effect
                            this.newScale=this.initialScale+scaleValue-this.initialDistance;

                            //avoid 0 and negative scales
                            if(this.newScale<0.001){this.newScale=0.001;}

                            this.showVisual(this.newScale, "World :");

                            this.rigScaleUpdated=true;
                        }
                        else
                        {
                            //get initial distance value (only once at a new squeeze both hands event)
                            this.bothSqueezeStarted=true;

                            this.initialDistance=this.calculateDistance();

                            //cache object's initial scale
                            this.initialScale=this.webXR.Rig.scale.x;
                        }
                    }
                }
                
            }
            else
            {
                //reset values
                this.bothSqueezeStarted=false;

                //if world has been scaled, scale rig accordingly at the end of squeezing and once only
                if(this.webXR?.Rig && this.rigScaleUpdated && this.newScale)
                {
                    //change rig scale
                    this.webXR.Rig.scale.set(this.newScale, this.newScale, this.newScale);
                    this.webXR.Rig.updateMatrixWorld();
                    
                    const cam = this.context.mainCamera as PerspectiveCamera;
                    cam.near=this.newScale>2?0.0001:0.2;
                    cam.updateProjectionMatrix();

                    //reset
                    this.rigScaleUpdated=false;
                }

                if(this.scaleTextObject)
                {
                    this.scaleTextObject.visible=false;
                }

                this.newScale=null;    
            }
        }
    }

    private calculateDistance():number
    {
        let distance=1;

        if(this.leftHand && this.rightHand)
        {
            
            const left=this.leftHand.position;
            const right=this.rightHand.position;

            // Calculate the difference between the positions
            const dx = left.x - right.x;
            const dy = left.y - right.y;
            const dz = left.z - right.z;

            // Calculate the distance using the Euclidean distance formula
            distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
        }
        else
        {
            //set positions of controllers from your avatar (only once)
            let allAvatars = AvatarMarker.instances;
            if(allAvatars.length>0)
            {
                for (let i=0;i<allAvatars.length;i++)
                {
                    if(allAvatars[i].isLocalAvatar())
                    {
                        const av=allAvatars[i].avatar as WebXRAvatar;
                        if(av!=null)
                        {
                            this.leftHand=av.handLeft as Object3D;
                            this.rightHand=av.handRight as Object3D;
                            this.head = av.head as Object3D;
                        }
                    }
                }
            }
        }
        
        return distance;
    }

    showVisual(scale:number, mesg:string):void
    {
        if(this.scaleTextObject && this.head && this.scaleText)
        {
            this.scaleTextObject.visible=true;

            const offset = new Vector3(0,0,7);
            offset.applyQuaternion(this.head.quaternion);

            this.scaleTextObject.position.copy(this.head.position.add(offset));

            const roundedNum= +scale.toFixed(3);
            this.scaleText.text=mesg+" "+roundedNum;
        }
        
    }
    

    objectGrab():void
    {
        if(this.webXR?.RightController?.grabbed?.selected) 
        {
            this.selectedObj = this.webXR.RightController.grabbed.selected;
        }
        else if(this.webXR?.LeftController?.grabbed?.selected)
        {
            this.selectedObj = this.webXR.LeftController.grabbed.selected;
        }
        else
        {
            this.selectedObj=null;
        }
    }
}