â—€ Overview
Vertical Move in VR using the right joystick (Quest)

The following code will enable Quest users (haven't tested with other devices) to move up and down with the right-joystick`s y axis. (the x axis being used for snap-turns).

This code will interfere with the teleport script when accidentally pointing towards an object and trying to move up. It is recommended to remove the teleport script for that matter.

You can place this script anywhere.


import { Behaviour, WebXR, GameObject} from "@needle-tools/engine";
import { Vector3,Quaternion} from "three";
import { Mathf } from "@needle-tools/engine";

export class VerticalMove extends Behaviour {

    private webXR?: WebXR;
    private joystickY?:number;
    private worldRot: Quaternion = new Quaternion();

    start(): void {

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


    update()
    {
        if(this.context.isInVR)
        {
            //get y value from right joystick
            this.verticalMove();
        }
    }

    verticalMove():void
    {
        if(this.webXR?.RightController?.input?.gamepad?.axes[3]) 
        {
            this.joystickY=this.webXR.RightController.input.gamepad.axes[3];

            const speedFactor = 3;
            const powFactor = 2;
            const speed = Mathf.clamp01(2 * 2);
            
            const verticalDir = this.joystickY < 0 ? 1 : -1;
            let vertical = Math.pow(this.joystickY, powFactor);
            vertical *= verticalDir;
            vertical *= speed;

            this.webXR.Rig.getWorldQuaternion(this.worldRot);
            
            let movementVector=new Vector3();
            movementVector.set(0, vertical, 0);
            movementVector.applyQuaternion(this.webXR.TransformOrientation);
            movementVector.x = 0;
            movementVector.applyQuaternion(this.worldRot);
            movementVector.multiplyScalar(speedFactor * this.context.time.deltaTime);

            this.webXR.Rig.position.add(movementVector);
        }
    }
}


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;
        }
    }
}
Network instantiation of multiple objects

In a multiuser session, typically objects are instantiated using instantiateSynced as such:

import { Behaviour,GameObject,serializable,InstantiateOptions} from "@needle-tools/engine";
import { Vector3, Object3D, } from "three";

export class InstantiateObjectForAll extends Behaviour
{
    @serializable(Object3D)
    myPrefab?: GameObject;

    public makeObject():void{
         const options = new InstantiateOptions();
         options.context = this.context;
         options.position = new Vector3(0,0,0);
         GameObject.instantiateSynced(this.myPrefab, options) as GameObject;
    }
}

My particular use-case was for generating programmatically a random scene made of cubes, and that scene had to be the same for all users of the same room. I had used the example above but for some unknown reasons sometimes the scenes were partially rendered when instantiating simultaneously >400 objects. @Marcel of Needle suggested to generate a seed (position of all objects in the scene) and send that seed instead using :

this.context.connection.send()

All users using :

this.context.connection.beginListen()

would receive any seed previously sent, upon joining the same room, allowing them to instantiate cubes according to that seed (array of Vector3).

Here is a script illustrating the use of the send method and the beginListen counterpart:


//This is an example of sending the seed of a randomly generated scene made of cubes, for all other instances logging into the same room to create the same scene.

//This script requires a prefab (e.g. a 1x1x1 Cube)
//This script will generate and build randomly positioned cubes (random walk) as a child of the object it is attached to. 
//The generateSeed() method is in this script called via a button. The button is deactivated once the seed has been transmitted.
//Any users joining the same room will receive the seed and build the exact same scene


import { Behaviour,GameObject,serializable,InstantiateOptions} from "@needle-tools/engine";
import { Vector3, Object3D } from "three";


export class NetworkedSeed extends Behaviour
{
    @serializable(Object3D)
    prefab?: GameObject;

    @serializable(Object3D)
    generateButton?: Object3D;

    public seedSize: number = 30; 
   
    seed: Vector3[] = [];

    onEnable(): void {
        this.context.connection.beginListen("mySeed", this.onDataReceived);
        if(this.generateButton)
        {
            this.generateButton.visible=true;
        }
    }
    onDisable(): void {
        this.context.connection.stopListen("mySeed", this.onDataReceived);
    }

    onDataReceived = (data: any) => {

        console.log("Received data:", data.mySeed);
        if(this.seed.length===0)
        {
            //prevent other generations of the seed
            if(this.generateButton)
            {
                this.generateButton.visible=false;
            }

            this.seed=data.mySeed;
            //build scene
            this.buildScene();
        }
    };


    //generate and send seed to all from the button generateButton
    public generateSeed():void{

        if(this.seed.length==0) //no seed found => generate one
        {
            this.seed = [];
            const uniquePositions = new Set<string>();
            
            //start at origin
            const startPosition = new Vector3(0, 0, 0);
            this.seed.push(startPosition.clone());
            uniquePositions.add(startPosition.toArray().toString());
            
            //go for a random walk of length : seedSize
            while (this.seed.length < this.seedSize) {
                const lastPosition = this.seed[this.seed.length - 1];
                let newPosition: Vector3;
                
                //walk and add position, making sure they are unique
                do {
                const direction = this.getRandomDirection();
                newPosition = lastPosition.clone().add(direction);
                } while (uniquePositions.has(newPosition.toArray().toString()));
                
                this.seed.push(newPosition.clone());
                uniquePositions.add(newPosition.toArray().toString());
            }
            
            //send the seed to all on the server
            this.sendSeed();

            //prevent other generations of the seed
            if(this.generateButton)
            {
                this.generateButton.visible=false;
            }
        }
        
        //build scene locally
        this.buildScene();
    }

    private sendSeed():void{
        if(this.seed.length!=0)
        {
            this.context.connection.send("mySeed",{guid:this.guid, mySeed: this.seed});
            console.log("------ SEED SENT -------");
        }
    }

    public buildScene():void{

        //check if the seed is not empty
        if(this.seed.length==0)
        {
            console.log("array was empty");
            return;
        }
        
        //check if the scene has already been built
        if(this.gameObject.children.length>0) 
        {
            console.log("Scene already present");
            return;
        }
    
        // Create cubes at each position of the random walk 
        for(let i=0; i<this.seed.length; i++)
        {
            const option = new InstantiateOptions();
            option.context = this.context;
            option.parent=this.gameObject;
            option.position = this.seed[i];
            
            if(this.prefab!=null)
            {
                const cube = GameObject.instantiate(this.prefab, option) as GameObject;
            }
        }

        console.log("----------- Scene Built ---------");
        
    }

    private getRandomDirection(): Vector3 {
        const x = Math.random() < 0.5 ? -1 : 1;
        const y = Math.random() < 0.5 ? -1 : 1;
        const z = Math.random() < 0.5 ? -1 : 1;
        return new Vector3(x, y, z);
    }

}

The above script is placed on an object (any Transform) and will generate an array of unique Vector3 positions for a specified length (seedSize) after generateSeed() is called (In this case it is called from a button: generateButton).

Once generated it will send the array to the server and build the scene. The building process consist of instantiating the prefab at each Vector3 position of the seed (this.seed) array.

Any user joining the same room after a seed has been generated and sent, will receive the seed from the server and trigger the callback onDataReceived() which will cache the seed array, disable the button, and build the scene with the prefab, according to the seed.

This gives a way to generate a scene and communicate the seed of that scene, for each user to build locally.

This was the solution I chose which worked better than instantiating a complex scene (>400 objects) with instantiateSynced which would occasionally cause bugs.