Введение
В своей последней статье в блоге я рассказал о концепциях реализации многопользовательских WebXR-проектов в реальном времени.
В этой статье я продемонстрирую практическую сторону этой статьи, чтобы вы могли начать адаптировать примеры кода в своем приложении или, если хотите, использовать созданный мной шаблон Wrapper.JS WebXR (откуда взяты фрагменты кода).
Если вы еще не сделали этого, то, пожалуйста, прочитайте первую часть этой серии уроков, чтобы вы могли понять, как работают приведенные ниже фрагменты кода.
Готовы? Поехали!!! 😀
Примеры кода
В одном из своих недавних постов о том, как сделать WebXR опыт, который работает на любом устройстве, я рассказал о созданном мной компоненте высшего порядка (HOC) под названием XRScene.
В этих примерах кода мы расскажем о том:
- как инстанцировать Websockets
- как передавать данные с помощью Websockets
- получение & визуализация данных Websocket.
Давайте начнем 😀
Как инстанцировать Websockets
Для того чтобы передавать и получать данные с помощью Websockets, вам нужно сначала настроить их в вашем приложении.
Давайте рассмотрим, как я их устанавливаю, сначала посмотрим, как настроен мой файл index.js.
Файл index.js
Вы можете видеть, что в этом файле объявлен фронт-энд, который отображается для индексного маршрута /.
Я выделил строки 7 и 19-42, которые показывают компонент высшего порядка (HOC) XRScene, где написана логика для 3D-приложения (three.js).
Внутри этого компонента мы должны увидеть, где реализованы Websockets.
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>
</>
)
}
Компонент XRScene
Этот компонент отвечает за динамический выбор подходящего WebGL рендерера для браузера, который используется для открытия веб-страницы (как описано в предыдущем посте).
Я не буду повторять то, что уже обсуждалось в том сообщении, но обратите внимание, что в строках 18 и 34 есть компонент Sockets HOC, который содержит логику рендеринга WebGL в качестве своих дочерних компонентов.
Именно этот компонент Sockets нам и нужно рассмотреть.
import React, { useRef, useState, useEffect, Suspense, lazy } from 'react'
import RenderWeb from './RenderWeb';
import RenderAR from './RenderAR';
import RenderVR from './RenderVR';
import deviceStore from '../../stores/device';
import Sockets from './../Sockets';
export default function XRScene(props) {
const { children } = props;
const { device, setDevice } = deviceStore();
useEffect(() => {
const fetchData = async() => setDevice(await checkDevice())
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<Sockets>
{device != undefined && device == 'webAR' &&
<RenderAR>
{children}
</RenderAR>
}
{device != undefined && device == 'webVR' &&
<RenderVR>
{children}
</RenderVR>
}
{device != undefined && device == 'web' &&
<RenderWeb>
{children}
</RenderWeb>
}
</Sockets>
)
}
const checkDevice = async() => {
if(navigator.xr == undefined) return 'web'
let isAR = await navigator.xr.isSessionSupported( 'immersive-ar');
if(isAR) return 'webAR';
let isVR = await navigator.xr.isSessionSupported( 'immersive-vr');
if(isVR) return 'webVR';
return 'web'
}
Компонент Sockets
В этом компоненте мы используем библиотеку react-use-websocket для реализации Websockets.
В строке 11 мы устанавливаем Websocket, который должно использовать приложение, и обеспечиваем его защиту с помощью JWT, полученного Cognito, чтобы убедиться, что Back End виден только аутентифицированным пользователям.
В строках 19-23 мы обновляем глобальное состояние нашего приложения с последним полученным сообщением и функцией для отправки сообщения.
import React, { useState, useEffect } from 'react';
import useSocketIO, {ReadyState} from 'react-use-websocket';
import { wsApiURL } from './../../utils'
import socketStore from './../../stores/socket';
import cognitoStore from './../../stores/cognito';
const Sockets = (props) => {
const { children } = props;
const { cognito } = cognitoStore();
const [socketUrl] = useState(`${wsApiURL}?token=${cognito.jwt}`)
const { setSendJsonMessage, setLastJsonMessage } = socketStore();
const {
sendJsonMessage,
lastJsonMessage,
} = useSocketIO(socketUrl);
useEffect(() => {
setSendJsonMessage(sendJsonMessage);
setLastJsonMessage(lastJsonMessage);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastJsonMessage]);
return (
<>
{ children }
</>
);
}
export default Sockets;
Как передавать данные с помощью Websockets
Теперь мы рассмотрели, как устроены Websockets, давайте посмотрим, как мы можем использовать их для передачи данных о местоположении пользователя в режиме реального времени.
Для начала посмотрим на любой из компонентов, отображаемых в компоненте Sockets, для примера возьмем компонент RenderAR.
Компонент RenderAR
Этот компонент отвечает за возврат компонента ARCanvas (который является WebGL рендерером для устройств, которые могут использовать дополненную реальность в браузере).
Есть два компонента, которые я буду рассматривать в этом руководстве, компонент Camera и компонент Avatars.
Компонент Avatars используется для рендеринга других пользователей, которые перемещаются по сайту, чтобы пользователь мог их видеть. Я объясню это далее в учебнике.
Компонент Camera отвечает за настройку движения и зрения для вошедшего пользователя, именно с него мы начнем рассмотрение того, как передаются данные с помощью Websockets.
import React, { useRef, useState, useEffect, Suspense, lazy } from 'react'
import { VRCanvas, ARCanvas, useXR, DefaultXRControllers, Hands } from '@react-three/xr'
import Camera from './Camera';
import Avatars from '../Avatars';
const RenderAR = (props) => {
const { children } = props;
return (
<ARCanvas style={{
height: '100vh',
width: '100vw'
}}>
<Suspense fallback={null}>
<Avatars/>
<Camera
fov={65}
aspect={window.innerWidth / window.innerHeight}
radius={1000}
/>
<DefaultXRControllers />
{children}
</Suspense>
</ARCanvas>
)
}
export default RenderAR;
Компонент камеры
Компонент камеры отвечает за настройку параметров движения и зрения для пользователя, который вошел в приложение.
Илл. подробно описывает, как работает Front End для этого приложения, а также как он взаимодействует с Back End (собственно Websockets).
Ниже в этом примере есть много кода, который устанавливает детали для камеры, которую пользователь может использовать для передвижения/видения.
Для простоты предположим, что вы понимаете основы работы Three.JS, и перейдем сразу к той части, где реализуются собственно сокеты.
Я выделил строки 5153 и 6181, которые показывают:
- 51-53 : Триггер устанавливается в true каждые 250 миллисекунд.
- 61-81 : Метод жизненного цикла useEffect, который запускается каждый раз, когда триггер активирован. Эта функция отвечает за передачу позиционных данных с помощью функции sendJsonMessage.
Внутри этой функции useEffect происходит следующее:
-
Строка 62 получает имя пользователя, вошедшего в систему.
-
В строках 63-67 определяются данные, которые будут переданы в Websocket.
-
Тип установлен на users, так как мы определяем позиционные данные для пользователей
-
Уникальный идентификатор (uid) устанавливается на имя пользователя, которое мы только что определили в строке 62.
-
Фактические данные о перемещении определяются в локальной переменной состояния пользователя
-
Строки 68-73 проверяют, установлен ли триггер на true, а затем убеждаются, что если есть данные о движении, чтобы сбросить состояние, позволяющее отслеживать данные о движении, и если нет, посылают пустой пакет данных.
-
Строки 74-77 содержат фактическую функцию, которая отправляет данные в Websocket.
-
Строка 79 сбрасывает переменную состояния триггера
import * as THREE from "three";
import { useFrame, useThree, extend } from '@react-three/fiber';
import React, { useRef, useEffect, useState, useMemo } from 'react';
import { useXR, useXRFrame } from '@react-three/xr'
import cognitoStore from './../../stores/cognito';
import socketStore from './../../stores/socket';
const Camera = (props) => {
const ref = useRef();
const set = useThree((state) => state.set);
const { player } = useXR()
const [xPos, setXPos] = useState([]);
const [yPos, setYPos] = useState([]);
const [zPos, setZPos] = useState([]);
const [xRotation, setXRotation] = useState([]);
const [yRotation, setYRotation] = useState([]);
const [zRotation, setZRotation] = useState([]);
const [movement, setMovement] = useState(false);
const [trigger, setTrigger] = useState(false);
const [user, setUser] = useState([]);
const camera = useThree((state) => state.camera)
const { cognito } = cognitoStore();
const { sendJsonMessage } = socketStore();
const posCorrection = (props.posCorrection) ? props.posCorrection : 0;
const positionVariables = {
setXPos, setYPos, setZPos,
setXRotation, setYRotation, setZRotation,
camera
}
useEffect(() => {
const updatedPositions = {xPos, yPos, zPos, xRotation, yRotation, zRotation};
updateGlobalPositions(updatedPositions, setMovement, setUser);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [xPos, yPos, zPos, xRotation, yRotation, zRotation])
useFrame(() => updatePositions(positionVariables));
useXRFrame(() => updatePositions(positionVariables));
useEffect(() => {
set({
camera: ref.current,
})
ref.current.position.set(0, .5, -5);
ref.current.lookAt(new THREE.Vector3(0, .5, 0));
ref.current.updateProjectionMatrix()
setInterval(()=>{
setTrigger(true);
}, 250);
if(player) {
player.position.y -= posCorrection;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const { username } = cognito;
let newData ={
type: 'users',
uid: username,
data: user
};
if(trigger){
if(movement == true) {
setMovement(false);
} else {
newData.data = '';
}
sendJsonMessage({
action: 'positions',
data: newData
});
}
setTrigger(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trigger]);
return (
<perspectiveCamera ref={ref} {...props}/>
)
}
const updatePositions = (positionVariables) => {
const {
setXPos, setYPos, setZPos,
setXRotation, setYRotation, setZRotation,
camera
} = positionVariables;
setXPos(camera.position.x)
setYPos(camera.position.y)
setZPos(camera.position.z)
setXRotation(camera.rotation.x)
setYRotation(camera.rotation.y)
setZRotation(camera.rotation.z)
}
const updateGlobalPositions = (updatedPositions, setMovement, setUser) => {
setMovement(true);
const { xPos, yPos, zPos, xRotation, yRotation, zRotation } = updatedPositions;
let position = {
x: xPos,
y: yPos,
z: zPos
};
let rotation = {
x: xRotation,
y: yRotation,
z: zRotation
}
let newUserData = {
position: position,
rotation: rotation
};
setUser(newUserData);
}
export default Camera;
Отправка позиционных данных в Back End
После отправки данных по Websocket на Back End запускается функция Lamda, содержащая приведенный ниже код.
Она принимает данные, отправленные с фронт-энда, и сохраняет их в таблице DynamoDB (см. строку 47).
Затем содержимое таблицы DynamoDB возвращается на Front End (см. строки 21-25).
'use strict';
// const util = require('util')
const AWS = require('aws-sdk');
const dynamoDb = new AWS.DynamoDB.DocumentClient();
module.exports.handler = async (event, context) => {
const {IS_OFFLINE, positions_table_id, domain_name, stage, api_local_ip_address, local_api_ws_port} = process.env;
const localUrl = `https://${api_local_ip_address}:${local_api_ws_port}`;
const liveUrl = `https://ws.${domain_name}`;
const socketUrl = (IS_OFFLINE) ? localUrl: liveUrl;
console.log(`https://${event.requestContext.domainName}/${event.requestContext.stage}`)
// await sendMessageToClient(callbackUrlForAWS, connectionId, event);
let connectionId = event.requestContext.connectionId;
console.log(`connectionid is the ${connectionId}`)
const client = new AWS.ApiGatewayManagementApi({
apiVersion: '2018-11-29',
endpoint: socketUrl
});
const data = JSON.parse(event.body).data;
await client
.postToConnection({
ConnectionId: event.requestContext.connectionId,
Data: JSON.stringify(await returnPositionData(data, positions_table_id))
})
.promise();
return {
statusCode: 200,
};
};
const returnPositionData = async(posData, positions_table_id) => {
const { type, uid, data} = posData;
if(data != '') {
const putParams = {
Item: {
type: type,
uid: uid,
data: data
},
TableName: positions_table_id
};
dynamoDb.put(putParams).promise();
// return nothing and post to dynamo
await dynamoDb.put(putParams).promise();
}
// return all data
const getParams = {
TableName: positions_table_id
};
const result = await dynamoDb.scan(getParams).promise();
return result.Items;
}
Получение & визуализация данных Websocket
Теперь, когда мы поняли, как передавать данные о положении пользователя, мы можем посмотреть, как визуализировать положение других пользователей, чтобы вы могли видеть их перемещение в реальном времени!
Для этого нам понадобится компонент RenderAR и посмотреть, как работает компонент Avatars.
Компонент Avatars componentindex.js
Этот компонент отвечает за получение данных по http и websocket, затем перебирает всех других пользователей, у которых есть запись, сохраненная в DynamoDB, и передает их реквизиты в компонент Avatar.
В этом разделе мы рассмотрим код фронт-энда и таблицу DynamoDB, которые обеспечивают эту работу.
Это еще один большой файл, в котором много всего происходит, но есть две ключевые области, на которые вы должны обратить внимание и понять их:
- Строка 29: где мы передаем последние полученные данные Websocket, содержащие все позиции других пользователей, текущего вошедшего пользователя и изображения всех других вошедших пользователей.
- Строки 49-56: где мы выводим компонент Avatar для каждого пользователя, переданного в строке 29, обратите внимание, что его позиция / поворот / uid / изображение включены в реквизиты.
import React, { useRef, useState, useEffect, Suspense, lazy } from 'react'
import socketStore from '../../stores/socket';
import Avatar from './Avatar';
import axios from 'axios';
import { httpApiURL } from '../../utils';
import cognitoStore from '../../stores/cognito';
const Avatars = () => {
const { cognito } = cognitoStore();
const { lastJsonMessage } = socketStore();
const [getUserImages, setUserImages] = useState([]);
useEffect(() => {
const fetchData = async() => {
let allData = await getUserData(cognito, 'returnAll');
let userImages ={};
for(let x = 0; x<allData.Items.length; x++) {
userImages[allData.Items[x].username] =allData.Items[x].image
}
setUserImages(userImages)
}
fetchData();
}, [cognito])
return (
<>
{
lastJsonMessage != null &&
<AvatarList list={lastJsonMessage} cognito={cognito} userImages={getUserImages}/>
}
</>
)
}
const AvatarList = (props) => {
const { list, cognito, userImages } = props;
const avatars = [];
for(let x=0; x<list.length; x++) {
if(list[x].uid != cognito.username) {
if(list[x].type == 'users') {
list[x].image = userImages[list[x].uid];
avatars.push(list[x]);
}
}
}
return (
<>
{avatars.map(avatar => (
<Avatar
position={avatar.data.position}
rotation={avatar.data.rotation}
key={avatar.uid}
image={avatar.image}
/>
))}
</>
)
};
const getUserData = (cognito, all) => axios({
method: 'post',
url: `${httpApiURL}/users/data`,
data: {
cognito: all
},
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${cognito.jwt}`
}
}).then((res) => {
const data = JSON.parse(res.data.body);
return data;
}, (error) => {
console.log(error);
})
export default Avatars;
База данных для позиционных данных Websocket
Ниже я приложил то, как эти данные Websocket выглядят в DynamoDB.
В этой таблице вы можете видеть, что хранимые данные классифицируются как объекты (например, 3D-модели, как собака на рисунке в верхней части этого сообщения) или пользователи.
Этот пост посвящен только тому, как обеспечить взаимодействие между пользователями в реальном времени, но я, вероятно, сделаю еще один последующий пост, чтобы объяснить, как взаимодействие пользователей с другими 3D моделями может быть разделено в реальном времени 😀
Скриншот позиционных данных в таблице DynamoDB
Пока что, если мы посмотрим на пример одного из пользователей в таблице DynamoDB, вы можете увидеть, как выглядят данные о позиционировании и вращении.
Именно эти данные передаются в рендерер Three.JS, который обновляет позиции компонентов Avatar.
{
"type": {
"S": "users"
},
"uid": {
"S": "hi@jamesmiller.blog"
},
"data": {
"M": {
"position": {
"M": {
"x": {
"N": "-0.11293206363916397"
},
"y": {
"N": "0.5589443802833557"
},
"z": {
"N": "-2.7809016704559326"
}
}
},
"rotation": {
"M": {
"x": {
"N": "0"
},
"y": {
"N": "0.08757950419595575"
},
"z": {
"N": "0"
}
}
}
}
}
}
Аватары КомпонентАватар.js
Наконец, когда все данные переданы компоненту Avatar, происходит волшебство визуализации полученных данных.
В этом разделе статьи мы рассмотрим логику фронт-энда, а также то, как выглядят данные для HTTP-данных.
Ключевыми частями этого кода для понимания являются:
- Строки 10-12 : Изображение, переданное в компонент, устанавливается в качестве текстуры для Three.JS, если таковой не существует, чтобы загрузить резервное изображение.
- Линии 16-19 : Здесь обновляется положение и вращение другого пользователя, вошедшего в систему, каждый раз, когда Websocket возвращает новые данные.
- Строки 24-31 : Здесь происходит рендеринг 3D сетки как плоской плоскости с загруженным в качестве текстуры изображением (тем, которое мы определили между строками 10-12).
import React, { useRef, useState, useEffect, Suspense, lazy } from 'react'
import { useLoader, useFrame, useThree } from '@react-three/fiber'
import * as THREE from "three";
import userStore from '../../stores/user';
const Avatar = (props) => {
const { position, rotation, image } = props;
const avatarMesh = useRef();
let setImage;
if(image == undefined) setImage ='photo1.jpg';
else setImage = image;
const texture = useLoader(THREE.TextureLoader, `/images/${setImage}`)
useFrame(() => {
if(avatarMesh != undefined && rotation != undefined && position!= undefined) {
avatarMesh.current.rotation.y = -rotation.y;
avatarMesh.current.position.x = position.x;
avatarMesh.current.position.y = position.y;
avatarMesh.current.position.z = position.z;
}
});
return (
<mesh ref={avatarMesh}>
<planeBufferGeometry attach="geometry" args={[.5, .5]} />
<meshBasicMaterial
attach="material"
side={THREE.DoubleSide}
map={texture}
/>
</mesh>
)
}
export default Avatar;
База данных для пользовательских данных HTTP
Что касается данных не в реальном времени, которые отображаются на Front End, то доступ к ним осуществляется через HTTP api и хранится в DynamoDB.
Каждый пользователь хранится как отдельная запись в таблице DynamoDB и имеет свою роль, адрес электронной почты и изображение.
Скриншот данных о пользователях http в таблице DynamoDB
Если посмотреть на эти данные в формате JSON, возвращается вот этот объект — то, что возвращается на Front End.
{
"uid": {
"S": "hi@jamesmiller.blog"
},
"role": {
"S": "admin"
},
"image": {
"S": "photo1.jpg"
},
"username": {
"S": "hi@jamesmiller.blog"
}
}
Заключение
Вау!!! Если вы дошли до этого места, то вы — чемпион, поздравляю 😀
Если вам повезет, вы достаточно хорошо понимаете практические стороны реализации положения пользователя в реальном времени в вашем WebXR-приложении, чтобы адаптировать его для своих нужд.
В следующем посте я расскажу о том, как реализовать результаты взаимодействия пользователей с 3D-объектами с помощью логики реального времени, чтобы несколько пользователей могли взаимодействовать с окружением вместе.
До тех пор, надеюсь, вам понравился этот пост и удачи 😀