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);
}
}
}
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;
}
}
}
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.