javascript

Создание React VR-приложения, работающего в реальном времени

  • суббота, 1 июля 2017 г. в 03:14:42
https://habrahabr.ru/company/mailru/blog/331816/
  • Разработка под AR и VR
  • Разработка веб-сайтов
  • ReactJS
  • JavaScript
  • Блог компании Mail.Ru Group



Библиотека React VR позволяет писать для веба приложения виртуальной реальности с использованием JavaScript и React поверх WebVR API. Эта спецификация поддерживается последними (в некоторых случаях — экспериментальными) версиями браузеров Chrome, Firefox и Edge. И для этого вам не нужны очки VR.


WebVR Experiments — это сайт-витрина, демонстрирующий возможности WebVR. Моё внимание привлёк проект The Musical Forest, созданный замечательным человеком из Google Creative Lab, который использовал A-Frame, веб-фреймворк для WebVR, разработанный командой Mozilla VR.


В Musical Forest благодаря WebSockets пользователи могут в реальном времени играть вместе музыку, нажимая на геометрические фигуры. Но из-за имеющихся возможностей и используемых технологий приложение получилось достаточно сложным (исходный код). Так почему бы не создать аналогичное приложение, работающее в реальном времени, на React VR с многопользовательской поддержкой на базе Pusher?


Вот как выглядит React VR/Pusher-версия:


Node.js-бэкенд.


Настраиваем VR-проект


Начнём с установки (или обновления) инструмента React VR CLI:


npm install -g react-vr-cli

Создадим новый React VR-проект:


react-vr init musical-exp-react-vr-pusher

Идём в созданную им директорию и исполняем команду для запуска сервера разработки:


cd musical-exp-react-vr-pusher
npm start

В браузере идём по адресу http://localhost:8081/vr/. Должно появиться такое:


image


Если вы используете совместимый браузер (вроде Firefox Nightly под Windows), то должны увидеть ещё и кнопку View in VR, позволяющую просматривать приложение в очках VR:


image


Перейдём к программированию.


Создаём фон


Для фона возьмём эквидистантное изображение (equirectangular image). Главной особенностью таких изображений является то, что ширина должна быть ровно вдвое больше высоты. Так что откройте любимый графический редактор и создайте изображение 4096×2048 с градиентной заливкой. Цвет — на ваш вкус.


image


Внутри директории static_assets в корне приложения создаём новую папку images, и сохраняем туда картинку. Теперь откроем файл index.vr.js и заменим содержимое метода render на:


    render() {
      return (
        <View>
          <Pano source={asset('images/background.jpg')} />
        </View>
      );
    }

Перезагрузим страницу (или активируем горячую перезагрузку), и увидим это:


image


Для эмулирования дерева воспользуемся Cylinder. По факту нам их потребуется сотня, чтобы получился лес вокруг пользователя. В оригинальной Musical Forest в файле js/components/background-objects.js можно найти алгоритм, генерирующий деревья. Если адаптировать код под React-компонент нашего проекта, получим:


    import React from 'react';
    import {
      View,
      Cylinder,
    } from 'react-vr';

    export default ({trees, perimeter, colors}) => {
      const DEG2RAD = Math.PI / 180;

      return (
        <View>
          {Array.apply(null, {length: trees}).map((obj, index) => {
              const theta = DEG2RAD * (index / trees) * 360;
              const randomSeed = Math.random();
              const treeDistance = randomSeed * 5 + perimeter;
              const treeColor = Math.floor(randomSeed * 3);
              const x = Math.cos(theta) * treeDistance;
              const z = Math.sin(theta) * treeDistance;

              return (
                <Cylinder
                  key={index}
                  radiusTop={0.3}
                  radiusBottom={0.3}
                  dimHeight={10}
                  segments={10}
                  style={{
                    color: colors[treeColor],
                    opacity: randomSeed,
                    transform: [{scaleY : 2 + Math.random()}, {translate: [x, 3, z]},],
                  }}
                />
              );
          })}
        </View>
      );
    }

Функциональный компонент берёт три параметра:


  • trees — количество деревьев, которое должно получиться в лесу;
  • perimeter — значение, позволяющее управлять дальностью отрисовки деревьев от пользователя;
  • colors — массив значений цветов деревьев.

С помощью Array.apply(null, {length: trees}) можно создать массив пустых значений, к которому применим map-функцию, чтобы отрисовать массив цилиндров случайных цветов, прозрачности и позиций внутри компонента View.


Можно сохранить код в файле Forest.js внутри директории компонента и использовать его внутри index.vr.js:


    ...
    import Forest from './components/Forest';

    export default class musical_exp_react_vr_pusher extends React.Component {
      render() {
        return (
          <View>
            <Pano source={asset('images/background.jpg')} />

            <Forest trees={100} perimeter={15} colors={['#016549', '#87b926', '#b1c96b']} 
            />
          </View>
        );
      }
    };

    ...

В браузере увидим это. Отлично, фон готов, создадим 3D-объекты, которые будут создавать звуки.


Создаём 3D-формы


Нужно создать шесть 3D-форм, при касании каждая будет проигрывать шесть разных звуков. Также пригодится маленькая анимация, когда курсор помещается и убирается с объекта.


Для создания форм нам нужны VrButton, Animated.View, Box, Cylinder и Sphere. Но поскольку все формы будут отличаться, просто инкапсулируем в компонент, это будет то же самое. Сохраните следующий код в файл components/SoundShape.js:


    import React from 'react';
    import {
      VrButton,
      Animated,
    } from 'react-vr';

    export default class SoundShape extends React.Component {

      constructor(props) {
        super(props);
        this.state = {
          bounceValue: new Animated.Value(0),
        };
      }

      animateEnter() {
        Animated.spring(  
          this.state.bounceValue, 
          {
            toValue: 1, 
            friction: 4, 
          }
        ).start(); 
      }

      animateExit() {
        Animated.timing(
          this.state.bounceValue,
          {
            toValue: 0,
            duration: 50,
          }
        ).start();
      }

      render() {
        return (
          <Animated.View
            style={{
              transform: [
                {rotateX: this.state.bounceValue},
              ],
            }}
          >
            <VrButton
              onEnter={()=>this.animateEnter()}
              onExit={()=>this.animateExit()}
            >
              {this.props.children}
            </VrButton>
          </Animated.View>
        );
      }
    };

Когда курсор попадает в область кнопки, Animated.spring меняет значение this.state.bounceValue с 0 на 1 и показывает эффект подпрыгивания. Когда курсор уходит из области кнопки, Animated.timing меняет значение this.state.bounceValue с 1 на 0 в течение 50 миллисекунд. Чтобы это работало, обернём VrButton в компонент Animated.View, который будет менять rotateX-преобразование View при каждом изменении состояния.


В index.vr.js можно добавить SpotLight (можете выбрать любой другой тип источника света и изменить его свойства) и использовать компонент SoundShape, тем самым сделав цилиндр:


    ...
    import {
      AppRegistry,
      asset,
      Pano,
      SpotLight,
      View,
      Cylinder,
    } from 'react-vr';
    import Forest from './components/Forest';
    import SoundShape from './components/SoundShape';

    export default class musical_exp_react_vr_pusher extends React.Component {
      render() {
        return (
          <View>
            ...

            <SpotLight intensity={1} style={{transform: [{translate: [1, 4, 4]}],}} />

            <SoundShape>
              <Cylinder
                radiusTop={0.2}
                radiusBottom={0.2}
                dimHeight={0.3}
                segments={8}
                lit={true}
                style={{
                  color: '#96ff00', 
                  transform: [{translate: [-1.5,-0.2,-2]}, {rotateX: 30}],
                }}
              />
            </SoundShape>
          </View>
        );
      }
    };
    ...

Конечно, можно менять свойства 3D-форм, и даже заменять их на 3D-модели.


Теперь добавим пирамиду (цилиндр с нулевым радиусом op radius и четырьмя сегментами):


    <SoundShape>
      <Cylinder
        radiusTop={0}
        radiusBottom={0.2}
        dimHeight={0.3}
        segments={4}
        lit={true}
        style={{
          color: '#96de4e',
          transform: [{translate: [-1,-0.5,-2]}, {rotateX: 30}],
        }}
      />
    </SoundShape>

Куб:


    <SoundShape>
      <Box
        dimWidth={0.2}
        dimDepth={0.2}
        dimHeight={0.2}
        lit={true}
        style={{
          color: '#a0da90', 
          transform: [{translate: [-0.5,-0.5,-2]}, {rotateX: 30}],
        }}
      />
    </SoundShape>

Параллелепипед:


    <SoundShape>
      <Box
        dimWidth={0.4}
        dimDepth={0.2}
        dimHeight={0.2}
        lit={true}
        style={{
          color: '#b7dd60',
          transform: [{translate: [0,-0.5,-2]}, {rotateX: 30}],
        }}
      />
    </SoundShape>

Сфера:


    <SoundShape>
      <Sphere
        radius={0.15}
        widthSegments={20}
        heightSegments={12}
        lit={true}
        style={{
          color: '#cee030',
          transform: [{translate: [0.5,-0.5,-2]}, {rotateX: 30}],
        }}
      />
    </SoundShape>

И треугольная призма:


    <SoundShape>
      <Cylinder
        radiusTop={0.2}
        radiusBottom={0.2}
        dimHeight={0.3}
        segments={3}
        lit={true}
        style={{
          color: '#e6e200',
          transform: [{translate: [1,-0.2,-2]}, {rotateX: 30}],
        }}
      />
    </SoundShape>

После импорта сохраняем файл и обновляем браузер. Должно получиться такое:


image


Теперь добавим звуки!


Добавляем звук


Помимо прочего, React VR поддерживает wav, mp3 и ogg-файлы. Полный список есть здесь.
Можно взять сэмплы с Freesound или другого подобного сайта. Скачайте, какие вам нравятся, и поместите в директорию static_assets/sounds. Для нашего проекта возьмём звуки шести животных, птицу, другую птицу, ещё одну птицу, кошку, собаку и сверчка (последний файл пришлось пересохранить, чтобы уменьшить битрейт, иначе React VR его не проигрывал).


React VR предоставляет три опции проигрывания звука:



Однако 3D/объёмный звук поддерживает только компонент Sound, так что баланс левого и правого каналов будет меняться при перемещении слушателя по сцене или при повороте головы. Добавим его в компонент SoundShape, как и событие onClick в VrButton:


    ...
    import {
      ...
      Sound,
    } from 'react-vr';

    export default class SoundShape extends React.Component {
      ...
      render() {
        return (
          <Animated.View
            ...
          >
            <VrButton
              onClick={() => this.props.onClick()}
              ...
            >
              ...
            </VrButton>
            <Sound playerState={this.props.playerState} source={this.props.sound} />
          </Animated.View>
        );
      }
    }

Для управления проигрыванием воспользуемся MediaPlayerState. Они будут передаваться как свойства компонента.


С помощью информации из index.vr.js определим массив:


    ...
    import {
      ...
      MediaPlayerState,
    } from 'react-vr';
    ...

    export default class musical_exp_react_vr_pusher extends React.Component {

      constructor(props) {
        super(props);

            this.config = [
              {sound: asset('sounds/bird.wav'), playerState: new MediaPlayerState({})},
              {sound: asset('sounds/bird2.wav'), playerState: new MediaPlayerState({})},
              {sound: asset('sounds/bird3.wav'), playerState: new MediaPlayerState({})},
              {sound: asset('sounds/cat.wav'), playerState: new MediaPlayerState({})},
              {sound: asset('sounds/cricket.wav'), playerState: new MediaPlayerState({})},
              {sound: asset('sounds/dog.wav'), playerState: new MediaPlayerState({})},
            ];
      }

      ...
    }
And a method to play a sound using the MediaPlayerState object when the right index is passed:
    ...

    export default class musical_exp_react_vr_pusher extends React.Component {

      ...

      onShapeClicked(index) {
        this.config[index].playerState.play();
      }

      ...
    }

Осталось только передать всю эту информацию в компонент SoundShape. Сгруппируем наши 3D-формы в массив и воспользуемся map-функцией для генерирования компонентов:


    ...

    export default class musical_exp_react_vr_pusher extends React.Component {

      ...

      render() {
        const shapes = [
          <Cylinder
            ...
          />,
          <Cylinder
            ...
          />,
          <Box
            ...
          />,
          <Box
            ...
          />,
          <Sphere
            ...
          />,
          <Cylinder
            ...
          />
        ];

        return (
          <View>
            ...

            {shapes.map((shape, index) => {
              return (       
                <SoundShape 
                  onClick={() => this.onShapeClicked(index)} 
                  sound={this.config[index].sound} 
                  playerState={this.config[index].playerState}>
                    {shape}
                </SoundShape>
              );
            })}

          </View>
        );
      }

      ...
    }

Перезапустите браузер и попробуйте понажимать на объекты, вы услышите разные звуки.
С помощью Pusher добавим в React VR-приложение многопользовательскую поддержку в реальном времени.


Настраиваем Pusher


Создадим бесплатный аккаунт на https://pusher.com/signup. Когда вы создаёте приложение, вас попросят кое-что сконфигурировать:


image


Введите название, выберите в качестве фронтенда React, а в качестве бэкенда — Node.js. Пример кода для начала:


image


Не переживайте, вас не заставляют придерживаться конкретного набора технологий, вы всегда сможете их изменить. С Pusher можно использовать любые комбинации библиотек.


Копируем ID кластера (идёт после названия приложения, в этом примере — mt1), ID приложения, ключ и секретную информацию, они нам понадобятся. Всё это можно найти также во вкладке App Keys.


Публикуем событие


React VR работает как Web Worker (подробнее об архитектуре React VR в видео), так что нам надо включить скрипт Pusher-воркера в index.vr.js:


    ...
    importScripts('https://js.pusher.com/4.1/pusher.worker.min.js');

    export default class musical_exp_react_vr_pusher extends React.Component {
      ...
    }

Есть два условия, которые надо соблюсти. Во-первых, надо иметь возможность передавать идентификатор посредством URL (вроде http://localhost:8081/vr/?channel=1234), чтобы пользователи могли выбирать, в какие каналы заходить и делиться ими с друзьями.


Для этого нам надо считывать URL. К счастью, React VR идёт с нативным модулем Location, который делает свойства объекта window.location доступными для контекста React.


Теперь нужно обратиться к серверу, который опубликует Pusher-событие, чтобы все подключённые клиенты тоже могли его проиграть. Но нам не нужно, чтобы клиент, сгенерировавший событие, тоже получил его, потому что в этом случае звук будет проигрываться дважды. Да и какой смысл ждать события для проигрывания звука, если это можно сделать немедленно, как только пользователь кликнул на объект.


Каждому Pusher-соединению присваивается уникальный ID сокета. Чтобы получатели не принимали события в Pusher, нужно передавать серверу socket_id клиента, которого нужно исключить при срабатывании события (подробнее об этом здесь).


Таким образом, немного адаптировав функцию getParameterByName для чтения параметров URL и сохранив socketId при успешном подключении к Pusher, мы можем соблюсти оба требования:


  ...
    import {
      ...
      NativeModules,
    } from 'react-vr';
    ...
    const Location = NativeModules.Location;

    export default class musical_exp_react_vr_pusher extends React.Component {
      componentWillMount() {
        const pusher = new Pusher('<INSERT_PUSHER_APP_KEY>', {
          cluster: '<INSERT_PUSHER_APP_CLUSTER>',
          encrypted: true,
        });
        this.socketId = null;
        pusher.connection.bind('connected', () => {
          this.socketId = pusher.connection.socket_id;
        });
        this.channelName = 'channel-' + this.getChannelId();
        const channel = pusher.subscribe(this.channelName);
        channel.bind('sound_played',  (data) => {
          this.config[data.index].playerState.play();
        });
      }

      getChannelId() {
        let channel = this.getParameterByName('channel', Location.href);
        if(!channel) {
          channel = 0;
        }

        return channel;
      }

      getParameterByName(name, url) {
        const regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)");
        const results = regex.exec(url);
        if (!results) return null;
        if (!results[2]) return '';
        return decodeURIComponent(results[2].replace(/\+/g, " "));
      }

      ...
    }

Если в URL нет параметров канал, то по умолчанию присваивается ID 0. Этот ID будет добавляться к Pusher-каналу, чтобы сделать его уникальным.


Наконец, нам нужно вызвать endpoint на серверной стороне, которая опубликует событие, передав ID сокета клиента и канал, в котором будут публиковаться события:


    ...
    export default class musical_exp_react_vr_pusher extends React.Component {
      ...
      onShapeClicked(index) {
        this.config[index].playerState.play();
        fetch('http://<INSERT_YOUR_SERVER_URL>/pusher/trigger', {
          method: 'POST',
          headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            index: index,
            socketId: this.socketId,
            channelName: this.channelName,
          })
        });
      }
      ...
    }

Вот и весь код для React-части. Теперь разберёмся с сервером.


Создаём Node.js-бэкенд


С помощью команды генерируем файл package.json:


npm init -y


Добавляем зависимости:


    npm install --save body-parser express pusher

И сохраняем в файл этот код:


    const express = require('express');
    const bodyParser = require('body-parser');
    const Pusher = require('pusher');

    const app = express();
    app.use(bodyParser.json());
    app.use(bodyParser.urlencoded({ extended: false }));
    /*
      Эти заголовки необходимы, потому что сервер разработки React VR запущен на другом порту. Когда финальный проект будет опубликован, нужда в middleware может отпасть
    */
    app.use((req, res, next) => {
      res.header("Access-Control-Allow-Origin", "*")
      res.header("Access-Control-Allow-Headers", 
                 "Origin, X-Requested-With, Content-Type, Accept")
      next();
    });

    const pusher = new Pusher({
      appId: '<INSERT_PUSHER_APP_ID>',
      key: '<INSERT_PUSHER_APP_KEY>',
      secret: '<INSERT_PUSHER_APP_SECRET>',
      cluster: '<INSERT_PUSHER_APP_CLUSTER>',
      encrypted: true,
    });

    app.post('/pusher/trigger', function(req, res) {
      pusher.trigger(req.body.channelName, 
                     'sound_played', 
                     { index: req.body.index },
                     req.body.socketId );
      res.send('ok');
    });

    const port = process.env.PORT || 5000;
    app.listen(port, () => console.log(`Running on port ${port}`));

Как видите, мы настроили Express-сервер, Pusher-объект и route/pusher/trigger, который просто запускает событие с индексом звука для проигрывания и socketID для исключения получателя события.


Всё готово. Давайте тестировать.


Тестируем


Выполним Node.js-бэкенд с помощью команды:


node server.js


Обновим серверный URL в index.vr.js (с использованием вашего IP вместо localhost) и в двух браузерных окнах откроем адрес вроде http://localhost:8081/vr/?channel=1234. При клике на 3D-форму вы услышите дважды проигранный звук (это куда веселее делать с друзьями на разных компьютерах):


Скачать код проекта можно из репозитория GitHub.