Build a realtime React VR app

Introduction

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 create a similar realtime React VR app with multi-user support using Pusher Channels?

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

https://www.youtube.com/watch?v=vm5vKh7h0b4&

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.

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

Next, create a new React VR project:

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

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

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

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

realtime-reactvr-first-no-vr

If you’re using a compatible browser (like Firefox Nightly on Windows), you should also see the View in VR button to view the app with a headset:

realtime-reactvr-first-vr

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:

realtime-reactvr-background

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:

1render() {
2      return (
3        <View>
4          <Pano source={asset('images/background.jpg')} />
5        </View>
6      );
7    }

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

realtime-reactvr-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:

1import React from 'react';
2    import {
3      View,
4      Cylinder,
5    } from 'react-vr';
6
7    export default ({trees, perimeter, colors}) => {
8      const DEG2RAD = Math.PI / 180;
9
10      return (
11        <View>
12          {Array.apply(null, {length: trees}).map((obj, index) => {
13              const theta = DEG2RAD * (index / trees) * 360;
14              const randomSeed = Math.random();
15              const treeDistance = randomSeed * 5 + perimeter;
16              const treeColor = Math.floor(randomSeed * 3);
17              const x = Math.cos(theta) * treeDistance;
18              const z = Math.sin(theta) * treeDistance;
19
20              return (
21                <Cylinder
22                  key={index}
23                  radiusTop={0.3}
24                  radiusBottom={0.3}
25                  dimHeight={10}
26                  segments={10}
27                  style={{
28                    color: colors[treeColor],
29                    opacity: randomSeed,
30                    transform: [{scaleY : 2 + Math.random()}, {translate: [x, 3, z]},],
31                  }}
32                />
33              );
34          })}
35        </View>
36      );
37    }

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 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 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:

1...
2    import Forest from './components/Forest';
3
4    export default class musical_exp_react_vr_pusher extends React.Component {
5      render() {
6        return (
7          <View>
8            <Pano source={asset('images/background.jpg')} />
9
10            <Forest trees={100} perimeter={15} colors={['#016549', '#87b926', '#b1c96b']} 
11            />
12          </View>
13        );
14      }
15    };
16
17    ...

In the browser, you should see something like this:

realtime-reactvr-trees

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 VR Button, an Antimated 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:

1import React from 'react';
2    import {
3      VrButton,
4      Animated,
5    } from 'react-vr';
6
7    export default class SoundShape extends React.Component {
8
9      constructor(props) {
10        super(props);
11        this.state = {
12          bounceValue: new Animated.Value(0),
13        };
14      }
15
16      animateEnter() {
17        Animated.spring(  
18          this.state.bounceValue, 
19          {
20            toValue: 1, 
21            friction: 4, 
22          }
23        ).start(); 
24      }
25
26      animateExit() {
27        Animated.timing(
28          this.state.bounceValue,
29          {
30            toValue: 0,
31            duration: 50,
32          }
33        ).start();
34      }
35
36      render() {
37        return (
38          <Animated.View
39            style={{
40              transform: [
41                {rotateX: this.state.bounceValue},
42              ],
43            }}
44          >
45            <VrButton
46              onEnter={()=>this.animateEnter()}
47              onExit={()=>this.animateExit()}
48            >
49              {this.props.children}
50            </VrButton>
51          </Animated.View>
52        );
53      }
54    };

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:

1...
2    import {
3      AppRegistry,
4      asset,
5      Pano,
6      SpotLight,
7      View,
8      Cylinder,
9    } from 'react-vr';
10    import Forest from './components/Forest';
11    import SoundShape from './components/SoundShape';
12
13    export default class musical_exp_react_vr_pusher extends React.Component {
14      render() {
15        return (
16          <View>
17            ...
18
19            <SpotLight intensity={1} style={{transform: [{translate: [1, 4, 4]}],}} />
20
21            <SoundShape>
22              <Cylinder
23                radiusTop={0.2}
24                radiusBottom={0.2}
25                dimHeight={0.3}
26                segments={8}
27                lit={true}
28                style={{
29                  color: '#96ff00', 
30                  transform: [{translate: [-1.5,-0.2,-2]}, {rotateX: 30}],
31                }}
32              />
33            </SoundShape>
34          </View>
35        );
36      }
37    };
38    ...

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):

1<SoundShape>
2      <Cylinder
3        radiusTop={0}
4        radiusBottom={0.2}
5        dimHeight={0.3}
6        segments={4}
7        lit={true}
8        style={{
9          color: '#96de4e',
10          transform: [{translate: [-1,-0.5,-2]}, {rotateX: 30}],
11        }}
12      />
13    </SoundShape>

A cube:

1<SoundShape>
2      <Box
3        dimWidth={0.2}
4        dimDepth={0.2}
5        dimHeight={0.2}
6        lit={true}
7        style={{
8          color: '#a0da90', 
9          transform: [{translate: [-0.5,-0.5,-2]}, {rotateX: 30}],
10        }}
11      />
12    </SoundShape>

A box:

1<SoundShape>
2      <Box
3        dimWidth={0.4}
4        dimDepth={0.2}
5        dimHeight={0.2}
6        lit={true}
7        style={{
8          color: '#b7dd60',
9          transform: [{translate: [0,-0.5,-2]}, {rotateX: 30}],
10        }}
11      />
12    </SoundShape>

A sphere:

1<SoundShape>
2      <Sphere
3        radius={0.15}
4        widthSegments={20}
5        heightSegments={12}
6        lit={true}
7        style={{
8          color: '#cee030',
9          transform: [{translate: [0.5,-0.5,-2]}, {rotateX: 30}],
10        }}
11      />
12    </SoundShape>

And a triangular prism:

1<SoundShape>
2      <Cylinder
3        radiusTop={0.2}
4        radiusBottom={0.2}
5        dimHeight={0.3}
6        segments={3}
7        lit={true}
8        style={{
9          color: '#e6e200',
10          transform: [{translate: [1,-0.2,-2]}, {rotateX: 30}],
11        }}
12      />
13    </SoundShape>

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

realtime-reactvr-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:

1...
2    import {
3      ...
4      Sound,
5    } from 'react-vr';
6
7    export default class SoundShape extends React.Component {
8      ...
9      render() {
10        return (
11          <Animated.View
12            ...
13          >
14            <VrButton
15              onClick={() => this.props.onClick()}
16              ...
17            >
18              ...
19            </VrButton>
20            <Sound playerState={this.props.playerState} source={this.props.sound} />
21          </Animated.View>
22        );
23      }
24    }

We’re going to use a Media Player State 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:

1...
2    import {
3      ...
4      MediaPlayerState,
5    } from 'react-vr';
6    ...
7
8    export default class musical_exp_react_vr_pusher extends React.Component {
9
10      constructor(props) {
11        super(props);
12
13            this.config = [
14              {sound: asset('sounds/bird.wav'), playerState: new MediaPlayerState({})},
15              {sound: asset('sounds/bird2.wav'), playerState: new MediaPlayerState({})},
16              {sound: asset('sounds/bird3.wav'), playerState: new MediaPlayerState({})},
17              {sound: asset('sounds/cat.wav'), playerState: new MediaPlayerState({})},
18              {sound: asset('sounds/cricket.wav'), playerState: new MediaPlayerState({})},
19              {sound: asset('sounds/dog.wav'), playerState: new MediaPlayerState({})},
20            ];
21      }
22
23      ...
24    }

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

1...
2
3    export default class musical_exp_react_vr_pusher extends React.Component {
4
5      ...
6
7      onShapeClicked(index) {
8        this.config[index].playerState.play();
9      }
10
11      ...
12    }

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:

1...
2
3    export default class musical_exp_react_vr_pusher extends React.Component {
4
5      ...
6
7      render() {
8        const shapes = [
9          <Cylinder
10            ...
11          />,
12          <Cylinder
13            ...
14          />,
15          <Box
16            ...
17          />,
18          <Box
19            ...
20          />,
21          <Sphere
22            ...
23          />,
24          <Cylinder
25            ...
26          />
27        ];
28
29        return (
30          <View>
31            ...
32
33            {shapes.map((shape, index) => {
34              return (       
35                <SoundShape 
36                  onClick={() => this.onShapeClicked(index)} 
37                  sound={this.config[index].sound} 
38                  playerState={this.config[index].playerState}>
39                    {shape}
40                </SoundShape>
41              );
42            })}
43
44          </View>
45        );
46      }
47
48      ...
49    }

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

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

Setting up Pusher

Create a free account for Channels with Pusher.

When you create a Channels app, you'll be asked to enter some configuration options:

realtime-reactvr-set-up-pusher

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:

realtime-reactvr-pusher-app

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:

1...
2    importScripts('https://js.pusher.com/4.1/pusher.worker.min.js');
3
4    export default class musical_exp_react_vr_pusher extends React.Component {
5      ...
6    }

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 modules 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:

1...
2    import {
3      ...
4      NativeModules,
5    } from 'react-vr';
6    ...
7    const Location = NativeModules.Location;
8
9    export default class musical_exp_react_vr_pusher extends React.Component {
10      componentWillMount() {
11        const pusher = new Pusher('<INSERT_PUSHER_APP_KEY>', {
12          cluster: '<INSERT_PUSHER_APP_CLUSTER>',
13          encrypted: true,
14        });
15        this.socketId = null;
16        pusher.connection.bind('connected', () => {
17          this.socketId = pusher.connection.socket_id;
18        });
19        this.channelName = 'channel-' + this.getChannelId();
20        const channel = pusher.subscribe(this.channelName);
21        channel.bind('sound_played',  (data) => {
22          this.config[data.index].playerState.play();
23        });
24      }
25
26      getChannelId() {
27        let channel = this.getParameterByName('channel', Location.href);
28        if(!channel) {
29          channel = 0;
30        }
31
32        return channel;
33      }
34
35      getParameterByName(name, url) {
36        const regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)");
37        const results = regex.exec(url);
38        if (!results) return null;
39        if (!results[2]) return '';
40        return decodeURIComponent(results[2].replace(/\+/g, " "));
41      }
42
43      ...
44    }

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:

1...
2    export default class musical_exp_react_vr_pusher extends React.Component {
3      ...
4      onShapeClicked(index) {
5        this.config[index].playerState.play();
6        fetch('http://<INSERT_YOUR_SERVER_URL>/pusher/trigger', {
7          method: 'POST',
8          headers: {
9            'Accept': 'application/json',
10            'Content-Type': 'application/json',
11          },
12          body: JSON.stringify({
13            index: index,
14            socketId: this.socketId,
15            channelName: this.channelName,
16          })
17        });
18      }
19      ...
20    }

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

Add the following dependencies:

    npm install --save body-parser express pusher

And save the following code in a file:

1const express = require('express');
2    const bodyParser = require('body-parser');
3    const Pusher = require('pusher');
4
5    const app = express();
6    app.use(bodyParser.json());
7    app.use(bodyParser.urlencoded({ extended: false }));
8    /*
9      The following headers are needed because the development server of React VR
10      is started on a different port than this server. 
11      When the final project is published, you may not need this middleware
12    */
13    app.use((req, res, next) => {
14      res.header("Access-Control-Allow-Origin", "*")
15      res.header("Access-Control-Allow-Headers", 
16                 "Origin, X-Requested-With, Content-Type, Accept")
17      next();
18    });
19
20    const pusher = new Pusher({
21      appId: '<INSERT_PUSHER_APP_ID>',
22      key: '<INSERT_PUSHER_APP_KEY>',
23      secret: '<INSERT_PUSHER_APP_SECRET>',
24      cluster: '<INSERT_PUSHER_APP_CLUSTER>',
25      encrypted: true,
26    });
27
28    app.post('/pusher/trigger', function(req, res) {
29      pusher.trigger(req.body.channelName, 
30                     'sound_played', 
31                     { index: req.body.index },
32                     req.body.socketId );
33      res.send('ok');
34    });
35
36    const port = process.env.PORT || 5000;
37    app.listen(port, () => console.log(`Running on port ${port}`));

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

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):

https://www.youtube.com/watch?v=c1lf8FearWQ&

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 Channels 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.

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.

Are you lost with VR development? Check it out Pusher's guide on how you can become an AR/VR developer.