一步步教你用 WebVR 实现虚拟现实游戏
翻译:疯狂的技术宅
本文首发微信公众号:前端先锋
欢迎关注,每天都给你推送新鲜的前端技术文章
在本教程中,我们将创建三维对象并为它们添加简单的交互。此外,你还可以学到如何在客户端和服务器之间建立简单的消息传递系统。
虚拟现实(VR)是一种依赖计算机生成环境的体验,其应用范围广泛:美国利用虚拟现实进行冬季奥运会的运动训练;外科医生正在试验用虚拟进行医学培训;把虚拟现实用于游戏是最常见的一种应用。
我们将把目光放在最后一类程序上,并将专注于点击式冒险游戏。这是一种休闲类游戏,游戏的目标是通过选择场景中的三维对象来完成拼图。在本教程中,我们将在虚拟现实中构建一个简单的版本。这是一篇关于三维编程的介绍,是在 Web 上部署虚拟现实模型的独立入门指南。你将使用 webVR 进行构建,这个框架具有双重优势 —— 用户可以在VR中玩游戏,而没有VR眼镜的用户也可以在手机或桌面上玩。
在本教程的后半部分中,你将为桌面构建一个“镜像”。这意味着在移动设备上进行的所有移动都将会在桌面预览中进行镜像。这样你可以看到玩家所看到的内容,允许你提供指导、记录游戏,或只是让客人娱乐。
前提条件
在开始之前你需要准备以下内容。对于本教程的后半部分,你将需要一台Mac OSX。虽然代码可以应用于任何平台,但下面依赖项的安装说明适用于Mac。
- 互联网接入,特别是glitch.com;
- VR 眼镜(可选,推荐)。我用的是Google Cardboard,每个售价15美元。
步骤1:设置虚拟现实(VR)模型
在此步骤中,我们将设置一个包含单个静态 HTML 页面的网站。这样可以允许你从桌面进行编码并自动部署到Web上,然后可以将部署的网站加载到手机上并放入VR眼镜内。或者部署的网站可以由独立的 VR 眼镜加载。首先打开https://glitch.com/。然后
- 单击右上角的 “New Project” 。
- 单击下拉列表中的“hello-express”。
接下来,单击左侧边栏中的 views/index.html。我们将此称为你的“编辑器”。
要预览网页,请单击左上角的“Preview”。我们将此作为你的预览。请注意,编辑器中的任何更改都将会自动反映在预览中,除非出现错误或不受支持的浏览器。
返回编辑器,将当前HTML替换为下面 VR 模型的代码框架。
<!DOCTYPE html> <html> <head> <script src="https://aframe.io/releases/0.7.0/aframe.min.js"></script> </head> <body> <a-scene> <!-- blue sky --> <a-sky color="#a3d0ed"></a-sky> <!-- camera with wasd and panning controls --> <a-entity camera look-controls wasd-controls position="0 0.5 2" rotation="0 0 0"></a-entity> <!-- brown ground --> <a-box shadow id="ground" shadow="receive:true" color="#847452" width="10" height="0.1" depth="10"></a-box> <!-- start code here --> <!-- end code here --> </a-scene> </body> </html>
之后可以看到以下内容:
要在VR眼镜上预览此功能,请使用 omnibar 中的URL。在上图中,URL 为 https://point-and-click-vr-game.glitch.me/
。你的工作环境现在已建立,可以随时与家人和朋友分享这个URL。在下一步中,你将创建一个虚拟现实模型。
步骤2:创建一个树的模型
现在,我们将用 aframe.io 中的 primitives 创建一个树。这是 Aframe 为便于使用而预编程的一些标准对象。具体来说,Aframe 将对象称为实体(entities)。与实体相关的概念有三个:
- 几何和材质,
- 转换轴,
- 相对转换。
首先,几何和材质是代码中所有三维对象的两个构建块。几何定义了一系列的“形状” —— 立方体,球体,金字塔等。材质定义了形状的静态属性,例如颜色、反射率、粗糙度。
Aframe 通过定义基元来简化这个概念,例如 <a-box>
,<a-sphere>
,<a-cylinder>
以及许多其他基本原理来简化几何体及其材料。首先定义一个绿色球体。在代码的第19行,也就是 <!-- start code here -->
之后添加以下内容。
<!-- start code here --> <a-sphere color="green" radius="0.5"></a-sphere> <!-- new line --> <!-- end code here -->
其次,有三个轴可以转换对象。 x
轴是水平运动的,当我们向右移动时,x 值会增加。 y
轴垂直运行,y 值随着我们向上移动而增加。 z
轴用垂直你的屏幕,当对象向你移动时,z 值会增加。我们可以沿这三个轴平移,旋转或缩放实体。
例如,要将对象向“右”移动,我们需要增加其x值。要向上旋转对象,我们需要沿 y 轴旋转它。下面我们修改第19行来“向上”移动球体 —— 这意味着你需要增加球体的 y 值。请注意,所有转换都指定为 <x> <y> <z>
,意味着要增加其y值,需要增加第二个值。默认情况下,所有对象都位于 0,0,0 位置。在下面添加 position
。
<!-- start code here --> <a-sphere color="green" radius="0.5" position="0 1 0"></a-sphere> <!-- edited line --> <!-- end code here -->
第三,所有变换都相对于其父对象。要在树中添加树干,就在上方球体内添加圆柱体,这样可确定树干相对于球体的位置,还可以将你的树木整合为一个单元。在<a-sphere ...>
和</ a-sphere>
标签之间添加<a-cylinder>
实体。
<a-sphere color="green" radius="0.5" position="0 1 0"> <a-cylinder color="#84651e" position="0 -0.9 0" radius="0.05"></a-cylinder> <!-- new line --> </a-sphere>
接着添加两个的绿色球体作为更多的叶子。
<a-sphere color="green" radius="0.5" position="0 0.75 0"> <a-cylinder color="#84651e" position="0 -0.9 0" radius="0.05"></a-cylinder> <a-sphere color="green" radius="0.35" position="0 0.5 0"></a-sphere> <!-- new line --> <a-sphere color="green" radius="0.2" position="0 0.8 0"></a-sphere> <!-- new line --> </a-sphere>
切换回预览,你将看到下面这颗树:
重新加载VR眼镜上的网站预览并查看。在下一节中,我们将使这棵树具有交互性。
步骤3:将Click Interaction添加到Model
要使实体具有交互性,你需要:
- 添加动画,
- 点击时触发动画。
由于最终用户使用VR眼镜,点击动作相当于凝视:换句话说,盯着一个对象就是“点击”它。要实现这些更改,我们将从光标开始。用以下内容替换第13行来重新定义相机。
<a-entity camera look-controls wasd-controls position="0 0.5 2" rotation="0 0 0"> <a-entity cursor="fuse: true; fuseTimeout: 250" position="0 0 -1" geometry="primitive: ring; radiusInner: 0.02; radiusOuter: 0.03" material="color: black; shader: flat" scale="0.5 0.5 0.5" raycaster="far: 20; interval: 1000; objects: .clickable"> <!-- add animation here --> </a-entity> </a-entity>
上面的代码添加了一个可以触发单击操作的游标。注意 objects: .clickable
属性。这意味着具有“可点击”类的所有对象将触发动画,并在适当的时候接收“单击”命令。我们还将向单击光标添加动画,以便使用户知道光标何时触发单击。当指向可点击的对象时,光标将缓慢收缩,在一秒钟后捕捉以表示已单击的对象。用以下代码替换注释 <!-- add animation here -->
:
<a-animation begin="fusing" easing="ease-in" attribute="scale" fill="backwards" from="1 1 1" to="0.2 0.2 0.2" dur="250"></a-animation>
将树向右移动 2 个单位,并修改第29行为以下内容将类 “clickable” 添加到树中。
<a-sphere color="green" radius="0.5" position="2 0.75 0" class="clickable">
接下来,我们将:
- 指定动画,
- 点击即可触发动画。
感谢 Aframe 易于使用的动画实体,这两个步骤都可以快速连续完成。
在第33行添加一个 <a-animation>
标记,紧跟在 <a-cylinder>
标记之后但在 </a-sphere>
结尾之前。
<a-animation begin="click" attribute="position" from="2 0.75 0" to="2.2 0.75 0" fill="both" direction="alternate" repeat="1"></a-animation>
上述属性指定了动画的许多配置。动画:
- 由“click”事件触发
- 修改树的
position
- 从原始位置
2 0.75 0
开始 - 结束于
2.2 0.75 0
(向右移动0.2个单位) - 往返目的地时的动画
- 在往返目的地之间的交替动画
- 重复此动画一次。这意味着对象动画总共播放两次: 一次到目的地,一次回到原始位置。
最后,切换到预览,然后从光标拖动到树。一旦黑色圆圈放在树上,树就会向右和向后移动。
这就结束了在虚拟现实中构建点击式冒险游戏所需的所有基础知识。要查看和播放此游戏的更完整版本,请参阅以下短片(http://alvinwan.com/shift/sce...)。任务是通过点击场景中的各种物体打开大门并隐藏大门后面的树。
接下来,我们设置一个简单的nodeJS服务器来提供静态演示。
步骤4:设置NodeJS服务器
在此步骤中,我们将设置一个基本的、功能性的nodeJS服务器,为你现有的VR模型提供服务。在编辑器的左侧边栏中,选择package.json
。
首先删除第2 - 4行。
"//1": "describes your app and its dependencies", "//2": "https://docs.npmjs.com/files/package.json", "//3": "updating this file will download and update your packages",
将名称改为mirrorvr
。
{ "name": "mirrorvr", // change me "version": "0.0.1", ...
在dependencies
下,添加socket.io
。
"dependencies": { "express": "^4.16.3", "socketio": "^1.0.0", },
更新存储库URL以匹配当前的glitch。示例glitch项目名为 point-and-click-vr-game
。用你的glitch项目名称替换它。
"repository": { "url": "https://glitch.com/edit/#!/point-and-click-vr-game" },
最后,将 "glitch"
标签改为 "vr"
。
"keywords": [ "node", "vr", // change me "express" ]
仔细检查你的package.json
是否和以下内容一致。
{ "name": "mirrorvr", "version": "0.0.1", "description": "Mirror virtual reality models", "main": "server.js", "scripts": { "start": "node server.js" }, "dependencies": { "express": "^4.16.3", "socketio": "^1.0.0" }, "engines": { "node": "8.x" }, "repository": { "url": "https://glitch.com/edit/#!/point-and-click-vr-game" }, "license": "MIT", "keywords": [ "node", "vr", "express" ] }
在views/index.html
中仔细检查上一部分的代码是否与以下内容一致。
<!DOCTYPE html> <html> <head> <script src="https://aframe.io/releases/0.7.0/aframe.min.js"></script> </head> <body> <a-scene> <!-- blue sky --> <a-sky color="#a3d0ed"></a-sky> <!-- camera with wasd and panning controls --> <a-entity camera look-controls wasd-controls position="0 0.5 2" rotation="0 0 0"> <a-entity cursor="fuse: true; fuseTimeout: 250" position="0 0 -1" geometry="primitive: ring; radiusInner: 0.02; radiusOuter: 0.03" material="color: black; shader: flat" scale="0.5 0.5 0.5" raycaster="far: 20; interval: 1000; objects: .clickable"> <a-animation begin="fusing" easing="ease-in" attribute="scale" fill="backwards" from="1 1 1" to="0.2 0.2 0.2" dur="250"></a-animation> </a-entity> </a-entity> <!-- brown ground --> <a-box shadow id="ground" shadow="receive:true" color="#847452" width="10" height="0.1" depth="10"></a-box> <!-- start code here --> <a-sphere color="green" radius="0.5" position="2 0.75 0" class="clickable"> <a-cylinder color="#84651e" position="0 -0.9 0" radius="0.05"></a-cylinder> <a-sphere color="green" radius="0.35" position="0 0.5 0"></a-sphere> <a-sphere color="green" radius="0.2" position="0 0.8 0"></a-sphere> <a-animation begin="click" attribute="position" from="2 0.75 0" to="2.2 0.75 0" fill="both" direction="alternate" repeat="1"></a-animation> </a-sphere> <!-- end code here --> </a-scene> </body> </html>
接着修改server.js
。
首先导入几个 NodeJS 包。
- Express
这是服务器的Web框架。 - http
这允许我们启动一个守护进程,监听各种端口上的活动。
- socket.io
用套接字实现允许我们可以实时地在客户端和服务器端之间进行通信。
在导入这些包时,我们还会初始化 ExpressJS 程序。请注意,前两行已经为你编写好了。
var express = require('express'); var app = express(); /* start new code */ var http = require('http').Server(app); var io = require('socket.io')(http); /* end new code */ // we've started you off with Express,
加载包后,服务器会返回 index.html
作为主页。请注意,下面没有新的代码;这只是对现有源代码的解释。
// http://expressjs.com/en/starter/basic-routing.html app.get('/', function(request, response) { response.sendFile(__dirname + '/views/index.html'); });
最后,现有的源代码指示程序绑定并侦听默认情况下为3000的端口,除非另有说明。
// listen for requests :) var listener = app.listen(process.env.PORT, function() { console.log('Your app is listening on port ' + listener.address().port); });
完成编辑后,Glitch会自动重新加载服务器。单击左上角的“Show”预览你的应用程序。
你的Web程序现已启动并运行。接下来,我们将从客户端向服务器发送消息。
步骤5:从客户端向服务器发送信息
在此步骤中,我们将用客户端初始化与服务器的连接。客户端还将通知服务器它是手机还是桌面。首先,在 views/index.html
中导入即将添加的Javascript文件。
在第4行之后,包含一个新脚本。
<script src="/client.js" type="text/javascript"></script>
在第14行,将 camera-listener
添加到相机实体的属性列表中。
<a-entity camera-listener camera look-controls...> ... </a-entity>
然后,切换到左侧边栏中的 public/client.js
。删除此文件中所有的Javascript代码。然后,定义一个工具函数,用于检查客户端是否是移动设备。
/** * Check if client is on mobile */ function mobilecheck() { var check = false; (function(a){if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) check = true;})(navigator.userAgent||navigator.vendor||window.opera); return check; };
接下来,我们将定义一系列与服务器端交换的消息。定义一个新的 socket.io 对象来表示客户端与服务器的连接。套接字连接后,将消息记录到控制台。
var socket = io(); socket.on('connect', function() { console.log(' * Connection established'); });
检查是否为移动设备,并用 emit
函数将相应的信息发送到服务器。
if (mobilecheck()) { socket.emit('newHost'); } else { socket.emit('newMirror'); }
这样就结束了客户端的消息发送。现在修改服务器代码,使其能够接收此消息并做出适当的反应。打开服务器端 server.js
文件。
处理新连接,并立即侦听客户端类型。在文件末尾添加以下内容。
/** * Handle socket interactions */ io.on('connection', function(socket) { socket.on('newMirror', function() { console.log(" * Participant registered as 'mirror'") }); socket.on('newHost', function() { console.log(" * Participant registered as 'host'"); }); });
再次通过单击左上角的“Show”来预览程序。在移动设备上加载相同的网址。在你的终端中,你将看到以下内容。
listening on *: 3000 * Participant registered as 'host' * Participant registered as 'mirror'
这是第一个简单的消息传递,我们的客户端将信息发送回服务器。退出正在运行的 NodeJS 进程。对于此步骤的最后一部分,我们将让客户端将相机信息发送回服务器。打开public/client.js
。
在文件的最后,添加以下内容。
var camera; if (mobilecheck()) { AFRAME.registerComponent('camera-listener', { tick: function () { camera = this.el.sceneEl.camera.el; var position = camera.getAttribute('position'); var rotation = camera.getAttribute('rotation'); socket.emit('onMove', { "position": position, "rotation": rotation }); } }); }
保存并关闭。打开你的服务器代码文件 server.js
来监听这个 onMove
事件。
在套接字代码的newHost
块中添加以下内容:
socket.on('newHost', function() { console.log(" * Participant registered as 'host'"); /* start new code */ socket.on('onMove', function(data) { console.log(data); }); /* end new code */ });
再次在桌面和移动设备上加载预览。连接移动客户端后,服务器将立即开始记录从客户端发送到服务器的摄像机位置和旋转信息。接下来实现相反的操作,从服务器将信息发送回客户端。
步骤6:从服务器向客户端发送信息
在此步骤中,你将向所有镜像发送主机的摄像机信息。打开主服务器源码文件 server.js
。
将 onMove
事件处理更改为以下内容:
socket.on('onMove', function(data) { console.log(data); // delete me socket.broadcast.emit('move', data) });
broadcast
修饰符能够确保服务器将此信息发送给连接到套接字的所有客户端。将此信息发送到客户端后,你需要相应地设置镜像的相机。打开客户端脚本 public/client.js
。
在这里检查客户端是否为桌面。如果是,则接收移动数据并相应地记录。
if (!mobilecheck()) { socket.on('move', function(data) { console.log(data); }); }
在桌面和移动设备上加载预览。在桌面浏览器中,打开开发控制台。然后,在手机上加载应用程序。一旦手机成功加载程序,桌面上的开发控制台就会显示相机位置和旋转等信息。
再次打开客户端脚本 public/client.js
。我们最后将根据发送的信息调整客户端摄像头。
修改上面的事件处理程序以获取 move
事件。
socket.on('move', function(data) { /* start new code */ camera.setAttribute('rotation', data["rotation"]); camera.setAttribute('position', data["position"]); /* end new code */ });
在桌面和手机上加载程序。你手机上的每个动作都会反映在桌面上相应的镜像中!这样就结束了程序的镜像部分。作为桌面用户,你现在可以预览手机用户看到的内容。本节介绍的概念对于进一步开发此游戏至关重要,因为我们还会将单人游戏转变为多人游戏。
结论
在本教程中,我们创建了三维对象并为这些对象添加了简单的交互。还在客户端和服务器之间构建了一个简单的消息传递系统,以实现能对用户看到的内容的在桌面进行预览。
这些概念甚至超越了webVR,因为几何和材料的概念扩展到了 iOS 上的 SceneKit(与ARKit相关),Three.js(Aframe的主干)以及其他三维库。这些简单的构建块组合在一起,使我们能够灵活的创建一个完全成熟的点击式冒险游戏。更重要的是,它们允许我们使用基于点击的界面创建任何游戏。
以下是供你进一步探索的几个资源和示例:
- MirrorVR
上面实时预览功能的完全实现。只需一个Javascript链接,即可将移动设备上的任何虚拟现实模型的实时预览添加到桌面。 - Bit by Bit
儿童画画廊的虚拟现实模型。 - Aframe
虚拟现实开发的例子、开发人员文档和其它资源。 - Google Cardboard Experiences
为教师提供定制工具。
下一次我们将构建一个完整的游戏,使用网络套接字来实现虚拟现实游戏中玩家之间的实时通信。
本文首发微信公众号:前端先锋
欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章
欢迎继续阅读本专栏其它高赞文章:
- 12个令人惊叹的CSS实验项目
- 必须要会的 50 个React 面试题
- 世界顶级公司的前端面试都问些什么
- 11 个最好的 JavaScript 动态效果库
- CSS Flexbox 可视化手册
- 从设计者的角度看 React
- 过节很无聊?还是用 JavaScript 写一个脑力小游戏吧!
- CSS粘性定位是怎样工作的
- 一步步教你用HTML5 SVG实现动画效果
- 程序员30岁前月薪达不到30K,该何去何从
- 14个最好的 JavaScript 数据可视化库
- 8 个给前端的顶级 VS Code 扩展插件
- Node.js 多线程完全指南
- 把HTML转成PDF的4个方案及实现