Создание React VR-приложения, работающего в реальном времени
- суббота, 1 июля 2017 г. в 03:14:42
Библиотека 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-бэкенд.
Начнём с установки (или обновления) инструмента 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-форм, при касании каждая будет проигрывать шесть разных звуков. Также пригодится маленькая анимация, когда курсор помещается и убирается с объекта.
Для создания форм нам нужны 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 предоставляет три опции проигрывания звука:
Однако 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-приложение многопользовательскую поддержку в реальном времени.
Создадим бесплатный аккаунт на 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-части. Теперь разберёмся с сервером.
С помощью команды генерируем файл 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.