DEV Community

Esteban Herrera
Esteban Herrera

Posted on • Originally published at blog.pusher.com

Building a realtime React VR app

React VR is a library that allows you to write virtual reality apps for the web using JavaScript and React on top of the WebVR API. This specification is now supported by the latest (or in some cases experimental) versions of browsers such as Chrome, Firefox, and Edge, and you don’t need to have a headset to access VR in a browser.

WebVR Experiments is a site that showcases some projects that show you what’s possible with WebVR. One project that caught my attention was The Musical Forest, made by the awesome people of Google Creative Lab using A-Frame, a web framework for WebVR developed by the Mozilla VR team.

In the Musical Forest, hitting a shape triggers a note, and using WebSockets, people can play music together in realtime. However, due to all the features and technologies used, the app is somewhat complicated (you can find the source code here). So, why not use React VR and Pusher to create a simplified version of it?

Here’s how the React VR/Pusher version looks:

A user can enter a channel identifier in the URL. When a 3D shape is hit, a sound will play and a Pusher event will be published so other users in the same channel can receive the event and the app can play that sound too.

We’ll have a Node.js backend to publish the events, so you should be familiar with JavaScript and React. If you’re not very familiar with some VR concepts or React VR, here’s a guide to get you started.

For reference (or if you just want to try the app), you can find the React VR project here and the Node.js backend here.

Setting up the React VR project

Let’s start by installing (or updating) the React VR CLI tool:

npm install -g react-vr-cli
Enter fullscreen mode Exit fullscreen mode

Next, create a new React VR project:

react-vr init musical-exp-react-vr-pusher
Enter fullscreen mode Exit fullscreen mode

Now go to the directory it created and execute the following command to start the development server:

cd musical-exp-react-vr-pusher
npm start
Enter fullscreen mode Exit fullscreen mode

In your browser, go to http://localhost:8081/vr/. Something like the following will show up:
Hello World no VR button

If you’re using a compatible browser, you should also see the View in VR button to view the app with a headset:
Hello World VR button

Now let’s start coding our app.

Creating the background

We’re going to use an equirectangular image as the image background. The main characteristic of this type of images is that the width must be exactly twice the height, so open your favorite image editing software and create an image of size 4096x2048 with a gradient or color of your choice:
Gradient

Create a new folder called images inside the static_assets directory in the root of your app and save your image there. Now open the file index.vr.js and replace the content of the render method with the following:

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

When you reload the page (or if you enable hot reloading), you should see something like this:
Background

Now, to simulate a tree, we’re going to use a Cylinder. In fact, we’ll need a hundred of this to create a forest around the user. In the original Musical Forest, we can find the algorithm to generate the trees around the users in the js/components/background-objects.js file. If we adapt the code into a React component for our project, we can get this:

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

This functional component takes three parameters:

  • trees, which indicates the number of trees the forest will have
  • perimeter, a value to control how far the trees will be rendered from the user
  • colors, an array with values of colors for the trees.

Using Array.apply(null, {length: trees}), we can create an array of empty values to which we can apply the map function to render an array of cylinders with random colors, opacities and positions inside a View component.

We can save this code in a file called Forest.js inside a components directory and use it inside of index.vr.js in the following way:

...
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>
    );
  }
};

...
Enter fullscreen mode Exit fullscreen mode

In the browser, you should see something like this:
Forest

Great, our background is complete, now let’s add the 3D objects to play the sounds.

Creating the 3D shapes

We are going to have six 3D shapes and each will play six different sounds when clicked. Also, a little animation when the cursor enters and exits the shape will come in handy.

To do that, we’ll need a VrButton, an Animated.View, and a Box, a Cylinder, and a Sphere for the shapes. However, as each shape is going to be different, let’s just encapsulate in a component what is the same. Save the following code in the file 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>
    );
  }
};
Enter fullscreen mode Exit fullscreen mode

When the cursor enters the button area, Animated.spring will change the value of this.state.bounceValue from 0 to 1 and show a bouncy effect. When the cursor exits the button area, Animated.timing will change the value of this.state.bounceValue from 1 to 0 in 50 milliseconds. For this to work, we wrap the VrButton with an Animated.View component that will change the rotateX transform of the View on each state change.

In index.vr.js, we can add a SpotLight (you can add any other type of light you want or change its properties) and use the SoundShape component to add a cylinder this way:

...
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>
    );
  }
};
...
Enter fullscreen mode Exit fullscreen mode

Of course, you can change the properties of the shapes or even replace them with 3D models.

Let’s also add a pyramid (which is a cylinder with a zero op radius and four segments):

<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>
Enter fullscreen mode Exit fullscreen mode

A cube:

<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>
Enter fullscreen mode Exit fullscreen mode

A box:

<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>
Enter fullscreen mode Exit fullscreen mode

A sphere:

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

And a triangular prism:

<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>
Enter fullscreen mode Exit fullscreen mode

After you add the necessary imports, save the file and refresh your browser. You should see something like this:
Shapes

Now let’s add some sounds!

Adding sound

For audio, React VR supports wav, mp3, and ogg files, among others. You can find the complete list here.

You can go to Freesound or other similar sites to get some sound files. Download the ones you like and place them in the directory static_assets/sounds. For this project, we’re going to use six animal sounds, something like a bird, another bird, another bird, a cat, a dog, and a cricket (as a quick note, I had to re-save this file lowering its bitrate so it can be played by React VR).

For our purposes, React VR give us three options to play a sound:

However, only the Sound component supports 3D/positional audio so the left and right balance of the sound will change as the listener moves around the scene or turns their head. So let’s add it to our SoundShape component along with an onClick event to the 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>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

We’re going to use a MediaPlayerState to control the playing of the sound. Both will be passed as properties of the component.

This way, let’s define an array with this information in 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({})},
        ];
  }

  ...
}
Enter fullscreen mode Exit fullscreen mode

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();
  }

  ...
}
Enter fullscreen mode Exit fullscreen mode

Now, we only need to pass all this information to our SoundShape components. So let’s group our shapes in an array and use a map function to generate the components:

...

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>
    );
  }

  ...
}
Enter fullscreen mode Exit fullscreen mode

If you restart your browser and try, you should hear the sounds as you click on the shapes.

Now let’s add to our app multi-user support in realtime with Pusher.

Setting up Pusher

Create a free account at https://pusher.com/signup.
When you create an app, you'll be asked to enter some configuration options:
Create Pusher App

Enter a name, choose React as your front-end tech, and Node.js as the back-end tech. This will give you some sample code to get you started:
Pusher Getting Started Code

But don't worry, this won't lock you into this specific set of technologies as you can always change them. With Pusher, you can use any combination of libraries.

Next, copy your cluster ID (next to the app title, in this example mt1), App ID, Key, and Secret information as we'll need them next. You can also find them in the App Keys tab.

Publishing the event

React VR acts as a Web Worker (you can know more about React VR architecture in this video) so we need to include Pusher’s worker script in index.vr.js this way:

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

export default class musical_exp_react_vr_pusher extends React.Component {
  ...
}
Enter fullscreen mode Exit fullscreen mode

We have two requirements that need to be taken care of. First, we need to be able to pass an identifier through the URL (like http://localhost:8081/vr/?channel=1234) so users can choose which channel they want to be in and share it with their friends.

To address this, we need to read the URL. Luckily, React VR comes with the native module Location, which makes available to the React context the properties of the object window.location.

Next, we need to make a call to a server that will publish the Pusher event so all the connected clients can also play the event. However, we don’t want the client that broadcasts the event to receive it too, because in that case, the sound will be played twice, and there’s no point in waiting to receive the event to play the sound when you can play it immediately when the user clicks the shape.

Each Pusher connection is assigned a unique socket ID. To exclude recipients from receiving events in Pusher, we just need to pass to the server the socket ID of the client we want to be excluded a socket_id when this is triggering an event. (You can find more information here.)

This way, adapting a little bit a function (getParameterByName) to read the parameters of the URL, and saving the socketId when a successful connection is made to Pusher, we can address both requirements with this:

...
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, " "));
  }

  ...
}
Enter fullscreen mode Exit fullscreen mode

If there isn’t a channel parameter in the URL, by default we assign the ID 0. This ID will be added to the Pusher channel to make it unique.

Finally, we just need to call an endpoint on the server side that will publish the event, passing the socket ID of the client and the channel where we’ll be publishing events:

...
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,
      })
    });
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

And that’s all the code of the React part. Now let’s take a look at the server.

Creating the Node.js backend

Execute the following command to generate a package.json file:

npm init -y
Enter fullscreen mode Exit fullscreen mode

Add the following dependencies:

npm install --save body-parser express pusher
Enter fullscreen mode Exit fullscreen mode

And save the following code in a file:

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 }));
/*
  The following headers are needed because the development server of React VR
  is started on a different port than this server. 
  When the final project is published, you may not need this 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}`));
Enter fullscreen mode Exit fullscreen mode

As you can see, here we set up an Express server, the Pusher object, and the route /pusher/trigger, which just triggers an event with the index of the sound to be played and the socketID to exclude the recipient of the event.

And we’re done. Let’s test it.

Testing

Execute the Node.js backend with:

node server.js
Enter fullscreen mode Exit fullscreen mode

Update your server URL in index.vr.js (with your IP instead of localhost) and enter in your browser an address like http://localhost:8081/vr/?channel=1234 in two browser windows. When you click on a shape, you should hear the sound played twice (of course, it’s more fun doing this with a friend in another computer):

Conclusion

React VR is a great library to create virtual reality experiences in an easy way, especially if you already know React/React Native. Pair it with Pusher and you’ll have powerful tools to program the next generation of web applications.

You can build a production release of this project to deploy it in any web server by following the steps on this page.

Also, you can customize this code by changing the colors, the shapes, the sounds, or add more functionality from the original Musical Forest.

Finally, remember that you can find the code of the app in this GitHub repository.

This post first appeared on Pusher's blog.

Top comments (0)