[译]开发一款实时的VR应用

@GuoYongfeng 2017-06-01 11:17:39发表于 iuap-design/blog

开发一款实时的VR应用

译者:郭永峰
原文请访问:Building a realtime React VR app
简介:这是一篇基于 React VR 开发 VR 应用的入门级教程,希望你喜欢。

React VR是一个基于WebVR API 实现的库,允许我们使用JavaScriptReact 来编写虚拟现实应用程序。此规范现在由Chrome,Firefox和Edge等最新版本(或某些实验版本)的浏览器支持,您无需在浏览器中使用耳机来访问VR。

WebVR Experiments是一个展示一些项目的网站,向您展示了WebVR的可行性。引起我注意的一个项目是由Mozilla VR团队开发的制作的“音乐森林”,这个项目是基于框架A-Frame实现的。

在音乐森林中,敲击形状会触发一个音符,人们可以一起实时地玩音乐。然而,由于使用的所有功能和技术,应用程序有点复杂(您可以在这里找到源代码)。确实挺好玩的,要不,我们也自己动手创建一个类似的实时React VR应用程序吧~~~

1. 初始化

安装 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/,你就可以看到效果:

enter image description here

如果你是使用兼容浏览器,可以点击页面右下角的 VR 按钮进行预览:

enter image description here

OK,接下来就可以开始写代码,实现我们第一个 VR 应用,是不是很激动。

2. 创建应用背景

我们将使用 equirectangular image 作为图片背景,这种类型的图像的主要特点是宽度必须是高度的两倍,因此打开您喜爱的图像编辑软件,并使用您选择的渐变或颜色创建尺寸为4096×2048的图像:

enter image description here

接下来,在应用程序根目录下的 static_assets 目录中创建一个名为 images 的新文件夹,并将图像保存在该文件夹中。现在打开文件 index.vr.js 并将render方法的内容替换为以下内容:

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

重新刷新(或者开启 hot reloading 功能),你就能开发这样的效果:

enter image description here

现在,我们要使用一个圆柱体来模拟一棵树。实际上,我们需要一百个来创建用户周围的森林。在原始的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>
      );
    }

这个 functional component 有三个参数:

  • trees:表示森林将有的树木数量
  • perimeter:用于控制从用户呈现树木的距离的值
  • colors:一个具有树颜色值的数组。

使用 Array.apply(null,{length:trees}),我们可以创建一个空值数组,我们可以通过这个数组应用地图函数来渲染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>
        );
      }
    };

    ...

完美,接下来你可以在浏览器中看一下效果:

enter image description here

太棒了,我们的背景已经完整,接下来我们加入 3D 对象来实现声音的播放。

3. 创建 3D Shapes

我们将有六个 3D 形状,每个点击时都会播放六种不同的声音。此外,当光标进入和退出形状时,一点点动画会派上用场。

要做到这一点,我们需要一个VrButton,一个AnimatedView,一个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.springthis.state.bounceValue 的值从0更改为1,并显示弹跳效果。当光标退出按钮区域时,Animated.timing 将在50毫秒内将 this.state.bounceValue 的值从1更改为0。为了这个工作,我们用一个 Animated.View 组件来包裹 VrButton,当每个状态变化时,它将ViewrotateX进行变换。

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 models 来将其替换。

接着,我们再添加一个金字塔(这是一个零运算半径和四个段的圆柱体):

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

OK,在你添加必要的导入之后,保存文件并刷新浏览器。你应该看到这样的东西:

enter image description here

非常棒,接下来让我们给它添加一些声音。

4. 添加声音

对于音频,React VR支持wav,mp3和ogg等格式的文件。你可以在这里找到完整的清单。

您可以去Freesound或其他类似的网站获取一些声音文件。下载你喜欢的,并将它们放在目录 static_assets / sounds 中。对于这个项目,我们将使用六只动物的声音:鸟叫另一只鸟叫另一只鸟叫猫叫狗叫,和板球声音(作为一个简单的说,我不得不重新保存这个文件降低它比特率,因此可以通过React VR播放)。

为了我们的目的,React VR给了我们三个选项来播放声音:

然而,只有Sound组件支持3D/positional 音频,所以声音的左右平衡将随着听众围绕场景移动或转动头部而发生变化。所以我们将它添加到我们的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({})},
            ];
      }

      ...
    }

当传递正确的声音序号的时候,我们可以使用MediaPlayerState对象来播放声音:

    ...

    export default class musical_exp_react_vr_pusher extends React.Component {

      ...

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

      ...
    }

现在,我们只需将所有这些信息传递给我们的 `SoundShape组件。因此,我们将数组中的形状分组并使用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应用程序多用户支持。

5. 设置Pusher

我们可以在https://pusher.com/signup注册一个免费账户。

当你创建一个app的时候,需要进行一些配置:

enter image description here

输入名称,选择React作为您的前端技术,将Node.js作为后端技术。这将给您一些示例代码,让您开始:

enter image description here

但不要担心,这不会将您锁定到这些特定的技术集合中,因为您可以随时更改它们。使用Pusher,您可以使用库的任意组合。

接下来,复制您的集群ID(应用程序标题旁边,在本示例中为mt1),应用程序ID,密钥和秘密信息,我们将需要它们。您还可以在“应用程序密钥”选项卡中找到它们。

6. 发布事件 Publishing the event

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,这使React上下文可以使用对象window.location的属性。

接下来,我们需要调用一个将发布Pusher事件的服务器,以便所有连接的客户端也可以播放该事件。然而,我们不希望广播事件的客户端也接收它,因为在这种情况下,声音将被播放两次,并且没有任何意义等待接收该事件播放声音,当您可以立即播放当用户点击形状时。

每个Pusher连接被分配一个唯一的套接字ID。要排除收件人接收Pusher中的事件,我们只需要传递给服务器,当触发事件时,我们要排除一个socket_id的客户端的套接字ID。 (您可以在这里找到更多信息。)

这样一来,调整一些函数(getParameterByName)来读取URL的参数,并且在与Pusher成功建立连接时保存了socketId,我们可以通过以下两个方面解决这两个问题:

...
    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将添加到“推送”通道以使其唯一。

最后,我们只需要在服务器端调用一个终端,发布事件,传递客户端的套接字号码和发布事件的通道:

...
    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服务端的开发。

7. 创建 Node.js 后端

npm 初始化 :

npm init -y

安装依赖:

npm install --save body-parser express pusher

创建 server.js 写服务:

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

您可以看到,我们设置了一个Express服务器,Pusher对象和路由/推送器/触发器,它只是触发一个具有要播放的声音索引的事件,并且socketID排除事件的收件人。

我们完成了我们来测试一下。

8. 测试一把

执行

node server.js

在index.vr.js中更新您的服务器URL(使用IP而不是本地主机),并在两个浏览器窗口中在浏览器中输入http:// localhost:8081 / vr /?channel = 1234之类的地址。当您点击一个形状时,您应该听到两次播放的声音(当然,另一台电脑中的朋友更喜欢这样做)

9. 总结

React VR是一个很棒的js库,可以轻松创建虚拟现实体验,特别是如果您已经知道React / React Native。与Pusher配对,您将拥有强大的工具来编程下一代Web应用程序。

您可以构建此项目的生产版本,以便通过执行此页面上的步骤将其部署在任何Web服务器中。

此外,您可以通过更改颜色,形状,声音或从原始Musical Forest添加更多功能来自定义此代码。

最后,请记住,您可以在该GitHub中找到应用程序的代码。