Intro
This is the third blog post detailing how to make real-time multiplayer WebXR experiences.
Having covered the conceptual sides of how it works and the practical sides of how to implement user position data, this blog post will clarify how to implement interactions with 3D models so users can interact with their environment in real time.
If you havent already, read the other two posts referenced above and have fun reading the below :D
Code Examples
In the previous blog post, I talk about the XRScene Higher Order Component (HOC) which is declared in the index directory.
Well start from there and expand on:
- how to declare a 3D models
- how to make those 3D models interactive
- how to integrate the Websockets with the 3D models
Lets go :D
How to declare 3D models
In order to emit and retrieve a 3D models position data with Websockets, you need to first instantiate the Websockets (as explained in part 2 of this tutorial series) and then declare a 3D model component that is set up to leverage those Websockets.
Lets take a look at how Ive set them up, by first looking at the index.js file.
The index.js file
See lines 8 and 2024, which show where the 3D model is loaded.
You can see that we are passing a name, position and rotation property into this componentlets take a closer look at the Shiba.js component to understand how those are being used.
import Head from 'next/head'
import dynamic from 'next/dynamic';
import React, { useRef, useState, Suspense, lazy, useEffect } from 'react'
import Header from '../components/Header'
const XRScene = dynamic(() => import("../components/XRScene"), { ssr: false });
const Shiba = lazy(() => import("../components/3dAssets/Shiba.js"), {ssr: false});
const Slide = lazy(() => import("../components/3dAssets/Slide.js"), {ssr: false});
const Dome = lazy(() => import("../components/3dAssets/Dome.js"), {ssr: false});
export default function Home() {
return (
<>
<Head>
<title>Wrapper.js Web XR Example</title>
</Head>
<Header />
<XRScene>
<Shiba
name={'shiba'}
position={[1, -1.1, -3]}
rotation={[0,1,0]}
/>
<Dome
name={'breakdown'}
image={'space.jpg'}
admin={true}
/>
<Slide
name={'smile'}
image={'smile.jpeg'}
position={[-2, 1, 0]}
rotation={[0,-.5,0]}
width={10}
height={10}
/>
<ambientLight intensity={10} />
<spotLight position={[10, 10, 10]} angle={0.15} penumbra={1} />
<pointLight position={[-10, -10, -10]} />
<spotLight position={[10, 10, 10]} angle={15} penumbra={1} />
</XRScene>
</>
)
}
The 3D model component (Shiba.js)
This component is responsible for rendering the GLTF 3D model, allowing it to be interactive with XR controllers and connecting it to Websockets.
To break this down further:
- Rendering the GLTF model : between lines 1632 is the logic that is auto-generated by an opensource repository that converts GLTFs into React-Three-Fiber components
- Enabling the use of XR interactivity for the model : on line 12 and 39 the Higher Order Component (HOC) withXrInteractivity.js is used to provide Shiba.js with XR interactivity.
- Enabling the use of Websockets for the model: on lines 11 and 40 the HOC withCollaboration.js is used to provide Shiba.js with
/*
Auto-generated by: https://github.com/pmndrs/gltfjsx
author: zixisun02 (https://sketchfab.com/zixisun51)
license: CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/)
source: https://sketchfab.com/3d-models/shiba-faef9fe5ace445e7b2989d1c1ece361c
title: Shiba
*/
import React, { useRef, forwardRef, useEffect } from 'react'
import { useGLTF, useAnimations } from '@react-three/drei'
import withCollaboration from './withCollaboration';
import withXrInteractivity from './withXrInteractivity';
const Model = forwardRef((props, group) => {
const {name } = props;
const { nodes, materials } = useGLTF('/shiba/scene.gltf')
return (
<group ref={group} {...props} dispose={null} name={name}>
<group rotation={[-Math.PI / 2, 0, 0]}>
<group rotation={[Math.PI / 2, 0, 0]}>
<group rotation={[-Math.PI / 2, 0, 0]}>
<mesh geometry={nodes.Group18985_default_0.geometry} material={nodes.Group18985_default_0.material} />
</group>
<group rotation={[-Math.PI / 2, 0, 0]}>
<mesh geometry={nodes.Box002_default_0.geometry} material={nodes.Box002_default_0.material} />
</group>
<group rotation={[-Math.PI / 2, 0, 0]}>
<mesh geometry={nodes.Object001_default_0.geometry} material={nodes.Object001_default_0.material} />
</group>
</group>
</group>
</group>
)
});
useGLTF.preload('/shiba/scene.gltf')
const InteractiveModel = withXrInteractivity(Model);
const Shiba = withCollaboration(InteractiveModel);
export default Shiba;
How to make those models interactive
In order to enable the use of your XR controllers (e.g Oculus Quest 2 controllers or HoloLens 2 hand tracked controllers etc), you need to implement a library called react-three/xr.
In this example, Ive implemented this in a HOC called withXrInteractivity.js, lets take a deeper look at it!
Higher Order ComponentwithXrInteractivity.js
This HOC is responsible for:
- Enabling the object to become grabbable by the XR controller: lines 4046 we are using the RayGrab HOC that is provided by react-three/xr, for devices that are capable of XR interactions
- Tracking the position values of the model that has been moved: lines 1826 we are using the useXREvent function from react-three/xr to track the position of where an object has moved to and from
- Updating the global app state with the name and positions of the object that has been moved: finally, between lines 2836 we are figuring out which of the objects with this HOC attached to it have been moved and then update the apps global state with that objects name and position values
import React, { useRef, useEffect, useState } from 'react'
import { useThree } from '@react-three/fiber';
import { RayGrab, useXREvent } from '@react-three/xr';
import deviceStore from '../../stores/device';
import selectedObjectStore from '../../stores/selectedObject';
import { Matrix4, Vector3, } from 'three';
const withXrInteractivity = (BaseComponent) => (props) => {
const { device } = deviceStore();
const { setSelectedObject } = selectedObjectStore();
const group = useRef();
const { scene } = useThree();
const [oldPosition, setOldPosition] = useState();
const [newPosition, setNewPosition] = useState()
if(device != '' && device != 'web') {
useXREvent('selectstart', (e) => {
updatePosition(props.name, scene, setOldPosition);
});
useXREvent('selectend', (e) => {
updatePosition(props.name, scene, setNewPosition);
})
}
useEffect(()=> {
if(oldPosition && newPosition) {
// if the old positions are not equal to the new positions
if(oldPosition.x != newPosition.x || oldPosition.y != newPosition.y || oldPosition.z != newPosition.z) {
// then you know this object has just been updated, execute logic to update websockets and analytics
selectedObject(props.name, scene.getObjectByName(props.name), setSelectedObject, group);
}
}
},[oldPosition, newPosition])
return (
<>
{device != '' && device != 'web' &&
<RayGrab>
<BaseComponent
ref={group}
{...props}
/>
</RayGrab>
}
{device != '' && device == 'web' &&
<BaseComponent
ref={group}
{...props}
/>
}
</>
)
};
const updatePosition = (name, scene, setPosition) => {
let pos = new Vector3();
let tempMatrix = new Matrix4;
tempMatrix = scene.getObjectByName(name).matrixWorld;
// set the oldPosition based on the matrix world positions
pos.setFromMatrixPosition(tempMatrix);
setPosition(pos)
};
const selectedObject = (objectname, object, setSelectedObject, group) => {
let { x, y, z } = object.position
setSelectedObject({
objectname: objectname,
position: {
x:x,
y:y,
z:z
},
group: group
});
}
export default withXrInteractivity;
How to integrate the Websockets with the 3D models
Once weve made the model interactive with the XR controllers, we then need to integrate the use of Websockets to allow other users to see the new position of the model youve moved.
There are two sides of this to understand, the first is the withCollaboration.js HOC that is responsible for the actual Websocket integration on the Front End and the second is what the data actually looks like in DynamoDB.
Higher Order ComponentwithCollaboration.js
Starting with the Front End, withCollaboration.js does the following:
- Submits the positions of the object youve just moved with your XR controllers to the Websocket : lines 1925 check if youre device is capable of XR interactivity, if so then it will update the Websocket with the name of the model being moved, as well as the position of that moved model and the name of the user which moved it
- Listens to the Websocket for any updates and updates the positions of the moved models : lines 2832 listen for updates on the Websocket and then check if the submission was made from a different user from the one that submitted it, if so then the new position of the model is applied to the scene.
Building on that last bullet point, the reason it needs to check if the user that submitted the movement is different from the user receiving the positional data on the web app, is to prevent bouncing of position values for the user that originally submitted the new position of the model.
For example, if you submit position values to the Websocket, the Websocket would immediately detect that you had moved the model and would would try to update the position of the model youve just movedthis can cause confusion at the Three.JS layer and cause the position of the model to move erratically and become buggy.
import React, { useRef, useEffect, useState } from 'react'
import { useThree } from '@react-three/fiber';
import { RayGrab, useXREvent } from '@react-three/xr';
import deviceStore from '../../stores/device';
import socketStore from '../../stores/socket';
import cognitoStore from '../../stores/cognito';
import selectedObjectStore from '../../stores/selectedObject';
import { Matrix4 } from 'three';
const withCollaboration = (BaseComponent) => (props) => {
const { device } = deviceStore();
const { selectedObject } = selectedObjectStore();
const { sendJsonMessage, lastJsonMessage } = socketStore();
const { cognito } = cognitoStore();
const { scene } = useThree();
const [socketMode, setSocketMode] = useState('initialLoad');
useEffect(()=> {
if(device != '' && device != 'web') {
if(selectedObject.objectname) {
submitPositionsToCloud(selectedObject.objectname, cognito.username, scene.getObjectByName(selectedObject.objectname), sendJsonMessage);
}
}
},[selectedObject])
useEffect(()=> {
if(device != '') {
updateModelFromWebSockets(lastJsonMessage, props.name, cognito, scene.getObjectByName(props.name), socketMode, setSocketMode);
}
}, [lastJsonMessage])
return (
<BaseComponent
{...props}
/>
)
};
const submitPositionsToCloud = (objectname, username, object, sendJsonMessage) => {
let newData = {
type: 'objects',
uid: objectname,
data: {
submittedBy: username,
matrixWorld: object.matrixWorld
}
};
sendJsonMessage({
action: 'positions',
data: newData
});
}
const updateModelFromWebSockets = (lastJsonMessage, name, cognito, group, socketMode, setSocketMode) => {
let data;
if(lastJsonMessage != null){
for(let x=0; x<lastJsonMessage.length; x++) {
if(lastJsonMessage[x].uid == name) {
if(lastJsonMessage[x].data.matrixWorld.length != 0) {
data = lastJsonMessage[x].data;
}
}
}
}
if(data) {
if(socketMode == 'stream' && data.submittedBy != cognito.username || socketMode == 'initialLoad') {
let tempMatrix = new Matrix4();
tempMatrix.copy(data.matrixWorld);
tempMatrix.decompose(group.position, group.quaternion, group.scale)
if(socketMode == 'initialLoad') {
setSocketMode('stream');
}
}
}
}
export default withCollaboration;
Database for Websocket positional data
Having submitted the new position values of the model to the Websocket, this is then passed onto DynamoDB through the same process outlined in part 2 of this tutorial series.
Ive attached a screenshot below of the DynamoDB table, which shows two types of entries: objects and users.
Users refers to people who have logged in and have moved around, their position values are stored there (see part 2 of this tutorial series).
Objects refers to the models that users have moved using their XR controllers as described in this blog post, lets take a deeper look at this.
Screenshot of the positional data in the DynamoDB Table
Looking at an example of the model entry that is stored, you can see that the models name is stored (in this case shiba) as well as the position of the model (called matrixWorld) and the name of the user that submitted the new position.
The reason that we stored the position as a matrixWorld instead of x/y/z value, is down to the complexities of how a models position is changed when you move it using an XR controller.
When you grab a model using an XR controller, that model becomes a child of the controller, until the moment that you release that model.
In order to provide other users with the accurate position of where youve moved the model to, we are storing that models matrixWorld position, which is essentially its global positionnot its position in relation to the XR controller (aka not its position as a child of the XR controller).
{
"type": {
"S": "objects"
},
"uid": {
"S": "shiba"
},
"data": {
"M": {
"matrixWorld": {
"M": {
"elements": {
"L": [
{
"N": "-0.8743998152939527"
},
{
"N": "-0.23723998239253116"
},
{
"N": "-0.4231857431325429"
},
{
"N": "0"
},
{
"N": "-0.23408684389239517"
},
{
"N": "0.9703077655463146"
},
{
"N": "-0.0602815775361555"
},
{
"N": "0"
},
{
"N": "0.4249385470147294"
},
{
"N": "0.046353861928324164"
},
{
"N": "-0.9040077143825459"
},
{
"N": "0"
},
{
"N": "0.6380046282561553"
},
{
"N": "1.0890667315312834"
},
{
"N": "-4.233745217744318"
},
{
"N": "1"
}
]
}
}
},
"submittedBy": {
"S": "hi@jamesmiller.blog"
}
}
}
}
Conclusion
Now THAT is a lot of information!!!
I really hope this has been helpful in enabling you to make real-time multiplayer WebXR experiences using React-Three-Fiber and Websockets :D
With any luck, you will be able to adapt this code for your appor even better use Wrapper.js to build your WebXR experiences!
I hope this is helpful and in the meantimehave fun :D
Top comments (0)