
Библиотека 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-версия:
Пользователь может ввести в URL идентификатор канала. При нажатии на трёхмерную фигуру проигрывается звук и публикуется Pusher-событие, которые получают другие пользователи в том же канале, и слышат тот же звук.
Для публикации событий возьмём Node.js-бэкенд, поэтому вам нужно иметь какой-то опыт работы с JavaScript и React. Если вы плохо знакомы с React VR и используемыми в VR концепциями, то для начала изучите этот материал.
Ссылки на скачивание (чтобы просто попробовать):
→ React VR-проект.
→ 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/. Должно появиться такое:

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

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

Внутри директории static_assets в корне приложения создаём новую папку images, и сохраняем туда картинку. Теперь откроем файл index.vr.js и заменим содержимое метода render на:
render() { return ( <View> <Pano source={asset('images/background.jpg')} /> </View> ); }
Перезагрузим страницу (или активируем горячую перезагрузку), и увидим это:

Для эмулирования дерева воспользуемся 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>
После импорта сохраняем файл и обновляем браузер. Должно получиться такое:

Теперь добавим звуки!
Добавляем звук
Помимо прочего, React VR поддерживает wav, mp3 и ogg-файлы. Полный список есть здесь.
Можно взять сэмплы с Freesound или другого подобного сайта. Скачайте, какие вам нравятся, и поместите в директорию static_assets/sounds. Для нашего проекта возьмём звуки шести животных, птицу, другую птицу, ещё одну птицу, кошку, собаку и сверчка (последний файл пришлось пересохранить, чтобы уменьшить битрейт, иначе React VR его не проигрывал).
React VR предоставляет три опции проигрывания звука:
- VrSoundEffects
- Событие onClickSound кнопки VrButton.
- Компонент Sound.
Однако 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. Когда вы создаёте приложение, вас попросят кое-что сконфигурировать:

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

Не переживайте, вас не заставляют придерживаться конкретного набора технологий, вы всегда сможете их изменить. С 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-форму вы услышите дважды проигранный звук (это куда веселее делать с друзьями на разных компьютерах):
Заключение
React VR — превосходная библиотека, позволяющая легко создавать VR-проекты, особенно если вы уже знаете React/React Native. Если присовокупить к этому Pusher, то получится мощный комплекс разработки веб-приложений нового поколения.
Вы можете собрать production-релиз этого проекта для развёртывания на любом веб-сервере: https://facebook.github.io/react-vr/docs/publishing.html.
Также можете изменить цвета, формы, звуки, добавить больше функций из оригинальной Musical Forest.
Скачать код проекта можно из репозитория GitHub.
