Как создать многопользовательский WebXR опыт в режиме реального времени (часть 2)

Введение

В своей последней статье в блоге я рассказал о концепциях реализации многопользовательских WebXR-проектов в реальном времени.

В этой статье я продемонстрирую практическую сторону этой статьи, чтобы вы могли начать адаптировать примеры кода в своем приложении или, если хотите, использовать созданный мной шаблон Wrapper.JS WebXR (откуда взяты фрагменты кода).

Если вы еще не сделали этого, то, пожалуйста, прочитайте первую часть этой серии уроков, чтобы вы могли понять, как работают приведенные ниже фрагменты кода.

Готовы? Поехали!!! 😀

Примеры кода

В одном из своих недавних постов о том, как сделать WebXR опыт, который работает на любом устройстве, я рассказал о созданном мной компоненте высшего порядка (HOC) под названием XRScene.

В этих примерах кода мы расскажем о том:

  1. как инстанцировать Websockets
  2. как передавать данные с помощью Websockets
  3. получение & визуализация данных 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 происходит следующее:

  1. Строка 62 получает имя пользователя, вошедшего в систему.

  2. В строках 63-67 определяются данные, которые будут переданы в Websocket.

  3. Тип установлен на users, так как мы определяем позиционные данные для пользователей

  4. Уникальный идентификатор (uid) устанавливается на имя пользователя, которое мы только что определили в строке 62.

  5. Фактические данные о перемещении определяются в локальной переменной состояния пользователя

  6. Строки 68-73 проверяют, установлен ли триггер на true, а затем убеждаются, что если есть данные о движении, чтобы сбросить состояние, позволяющее отслеживать данные о движении, и если нет, посылают пустой пакет данных.

  7. Строки 74-77 содержат фактическую функцию, которая отправляет данные в Websocket.

  8. Строка 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-объектами с помощью логики реального времени, чтобы несколько пользователей могли взаимодействовать с окружением вместе.

До тех пор, надеюсь, вам понравился этот пост и удачи 😀

Оцените статью
devanswers.ru
Добавить комментарий