注意:这是两部分教程的第一部分。
沉浸式和真实感之间存在很大差异。具有详细模型和强大 GPU 的高端电脑游戏可能感觉很真实,但仍然不会让人感觉沉浸。要营造出身临其境的感觉,不仅仅是多边形数量。通过精心设计的布景和灯光选择,低多边形体验可以让人感觉非常沉浸,而根本不追求真实感。
今天,我将向您展示如何使用 A-Frame 和来自先前 Sketchfab 设计挑战的模型构建一个简单但沉浸式的游戏。与 我之前教程 不同,在本教程中,我们将逐步创建整个应用程序。不仅仅是基本交互,还包括添加和定位 3D 模型、以编程方式构建带有岩石的景观、添加声音和灯光以使玩家沉浸在环境中,以及针对不同外形的交互调整。
以下是我教程的简短视频版本,它介绍了构建 WebVR 游戏所需遵循的步骤
我希望这篇博客能激励您参加我们目前与 SketchFab 共同举办的 挑战。在 4 月 2 日截止提交作品之前,您仍然有时间参加。
样板
我们的 WebVR 打小鬼游戏是打地鼠游戏的变体,只是在我们这里,小鬼会从冒泡的锅炉中飞出来。然而,在我们开始使用炫酷的 3D 模型之前,我们必须从包含 A-Frame 库的空 HTML 文件开始。
<html>
<head>
<!-- aframe itself -->
<script src="https://aframe.io/releases/0.7.0/aframe.min.js"></script>
</head>
<body>
</body>
</html>
首先,我们不会使场景变得漂亮。我们只想证明我们的概念是可行的,所以我们会保持简单。这意味着没有灯光、模型或音效。一旦证明了基本概念,我们就会使其变得漂亮。
让我们从一个场景开始,其中打开了统计信息,然后添加一个带有 look-controls
的摄像头,高度为 1.5 米;这对于 VR 交互来说是一个很好的摄像头高度(大致对应于大多数成年人的平均眼部高度)。
<a-scene stats>
<a-entity camera look-controls position="0 1.5 0">
<a-cursor></a-cursor>
</a-entity>
</a-scene>
请注意摄像头内的 a-cursor
。这将绘制一个小的圆形光标,这对于没有控制器的显示器(如 Cardboard)来说很重要。
我们的游戏将有一个从锅炉中弹出来的物体,然后在重力的作用下落回地面。玩家将有一根球拍或一根棍子来击打物体。如果玩家错过,物体应该落到地面上。现在,让我们用一个球体代表物体,用一个简单的平面代表地面。将此代码放在 a-scene
内。
<a-entity id='ball'
position="0 1 -4"
material="color:green;"
geometry="primitive:sphere; radius: 0.5;"
></a-entity>
<a-plane color='red' rotation="-90 0 0"
width="100" height="100"></a-plane>
请注意,我正在使用 a-entity
的长语法来表示球,而不是 a-sphere
。这是因为我们稍后会将几何图形切换到外部加载的模型。但是,平面始终是平面,因此我将为此使用更短的 a-plane
语法。
我们有一个要击打的物体,但没有击打它的东西。现在添加一个用于球拍的盒子。我们不会使用控制器来挥动球拍,而是从最简单的交互开始:将盒子放在摄像头内。然后,您可以通过转动头部(或在桌面上的鼠标上拖动场景摄像头)来挥动它。有点笨拙,但这对我们来说现在足够好。
还要注意,我将球拍盒子放在 z -3 处。如果我将其保留在默认位置,它似乎会消失,但它实际上还在那里。球拍太靠近摄像头,我们看不到。如果我低头看我的脚,我可以看到它。无论何时处理 VR 并且您的物体没有显示出来,首先检查它是否在您身后或太靠近摄像头。
<a-entity position="0 0 -3" id="weapon">
<a-box color='blue' width='0.25' height='0.5' depth='3'></a-box>
</a-entity>
很好。现在,我们场景中的所有元素都已到位。如果您按照步骤操作,您应该在桌面上看到一个看起来像这样的场景。
如果您使用此演示版,您会发现您可以移动头部,球拍会随之移动,但尝试击球不会有任何效果。这是因为我们只有几何图形。计算机知道我们的物体的外观,但不知道它们的行为方式。为此,我们需要物理引擎。
物理引擎
物理引擎可能很复杂,但幸运的是,Don McCurdy 为优秀的 Cannon.js 开源物理框架创建了 A-Frame 绑定。我们只需要包含他的 aframe-extras
库即可开始使用物理引擎。
将其添加到 html 页面中的 head
部分
<!-- physics and other extras -->
<script src="//cdn.rawgit.com/donmccurdy/aframe-extras/v3.13.1/dist/aframe-extras.min.js"></script>
现在,我们可以通过将 physics="debug:true;”
添加到 a-scene
中来打开物理引擎。
当然,仅仅打开物理引擎不会有任何效果。我们仍然需要告诉它场景中哪些物体应受到重力和其他力的影响。我们使用动态体和静态体来做到这一点。动态体是具有完整物理属性的物体。它可以传递力和受到其他力(包括重力)的影响。静态体在被物体撞击时可以传递力,但在其他情况下不受力的影响。通常,您会对静止的物体(如地面或墙壁)使用静态体,对在场景中四处移动的物体(如我们的球体)使用动态体。
让我们通过将 dynamic-body
和 static-body
添加到它们组件中,使地面变为静态,球体变为动态
<a-entity id='ball'
position="0 1 -4"
material="color:green;"
geometry="primitive:sphere; radius: 0.5;"
dynamic-body
></a-entity>
<a-plane color='red'
static-body
rotation="-90 0 0" width="100" height="100"></a-plane>
很好。现在,当您重新加载页面时,球体将落到地面上。您可能还会在球体或平面上看到网格线或点。这些是物理引擎用于让我们从物理角度查看物体边缘的一些调试信息。物理引擎可能使用与实际绘制的几何图形不同的尺寸或形状来表示我们的物体。我知道这听起来很奇怪,但这实际上非常有用,我们将在后面看到。
现在,我们需要使球拍能够击打球体。由于球拍是移动的,您可能会认为我们应该使用 dynamic-body
,但实际上,我们希望我们的代码(以及摄像头)来控制球拍的位置,而不是物理引擎。我们只想让球拍在那里对球体施加力,而不是反过来,因此我们将使用 static-body
。
<a-entity camera look-controls position="0 1.5 0">
<a-cursor></a-cursor>
<a-entity position="0 0 -3" id='weapon'>
<a-box color='blue' width='0.25' height='0.5' depth='3'
static-body></a-box>
</a-entity>
</a-entity>
现在,我们可以移动摄像头来挥动球拍并击打球体。如果您用力击打它,它会飞到一边而不是滚动,这正是我们想要的!
您可能会问,为什么不直接为所有物体打开物理引擎?有两个原因:首先,物理引擎需要 CPU 时间。如果更多物体具有相关的物理属性,它们将消耗更多的 CPU 资源。
第二个原因:对于场景中的许多物体,我们实际上并不希望打开物理引擎。如果我的场景中有一棵树,我不希望它仅仅因为离地面一毫米而倒下。我不希望月亮仅仅因为在空中而从天空中掉下来。只对那些真正需要物理引擎的物体打开物理引擎。
碰撞
通过击打来移动球体很有趣,但对于真正的游戏,我们需要跟踪球拍何时击中球体,以便增加玩家的分数。我们还需要将球体重置回中间,以便进行下一击。我们使用碰撞来做到这一点。物理引擎在每次物体撞击另一个物体时都会发出 collide
事件。通过监听此事件,我们可以知道何时发生了碰撞、发生了什么碰撞,以及我们可以对其进行哪些操作。
首先,让我们为访问 DOM 元素创建一些实用程序函数。我将它们放在页面的顶部,以便它们可以被所有代码访问。
<script>
$ = (sel) => document.querySelector(sel)
$$ = (sel) => document.querySelectorAll(sel)
on = (elem, type, hand) => elem.addEventListener(type,hand)
</script>
让我们来谈谈我们需要哪些函数。首先,我们希望在玩家击中球体后或他们在一定时间后错过球体时重置球体。重置意味着将球体移回中心,将力重置为零,并初始化一个超时。让我们创建 resetBall
函数来完成此操作
let hit = false
let resetId = 0
const resetBall = () => {
clearTimeout(resetId)
$("#ball").body.position.set(0, 0.6,-4)
$("#ball").body.velocity.set(0, 5,0)
$("#ball").body.angularVelocity.set(0, 0,0)
hit = false
resetId = setTimeout(resetBall,6000)
}
在上面的代码中,我使用 $
函数和一个选择器来查找页面中的球体元素。物理引擎向包含所有物理属性的元素添加了一个 body
属性。我们可以从这里重置位置、速度和 angularVelocity
。上面的代码还设置了一个超时,以便在六秒钟后再次调用 resetBall
,如果没有其他事件发生。
这里有两点需要注意。首先,我正在设置 body.position
,而不是所有 A-Frame 实体都具有的常规 position
组件。这是因为物理引擎负责此对象,因此我们需要告诉物理引擎这些更改,而不是 A-Frame。
第二点需要注意的是,速度没有重置为零。相反,它被设置为向量 0,5,0
。这意味着在 x 和 z 方向上的速度为零,但在 y 方向上为 5。这使球体具有初始垂直速度,将其向上发射。当然,一旦球体跳起来,重力就会开始影响它,因此速度会很快减慢。如果我想让游戏更难,我可以在这里增加初始速度,或将向量指向随机方向。有很多改进的机会。
现在,我们需要知道碰撞何时真正发生,以便我们可以增加分数并触发重置。我们将通过处理 #weapon
实体上的 collide
事件来做到这一点。请注意,此代码应放在 </a-scene>
的后面,以便它在场景准备就绪后才加载。
<script>
let score = 0;
let hit = false
let resetId = 0
on($("#weapon"),'collide',(e)=>{
const ball = $("#ball")
if(e.detail.body.id === ball.body.id && !hit) {
hit = true
score = score + 1
clearTimeout(resetId)
resetId = setTimeout(resetBall,2000)
}
})
setTimeout(resetBall,3000)
</script>
上面的代码通过比较主体 ID 来检查碰撞事件是否是针对球体的。它还会确保玩家没有已经击中球体,否则他们可能会在重置球体之前反复击中球体。如果球体被击中,则将 hit 设置为 true,清除重置超时,并在两秒钟后安排新的超时。
很好,现在我们可以反复发射球体并跟踪分数。当然,如果我们看不到分数,分数就没有什么用。让我们在摄像头内部添加一个文本元素,以便它始终可见。这被称为抬头显示或 HUD。
<a-entity camera ....
<a-text id="score" value="Score" position="-0.2 -0.5 -1" color="red" width="5" anchor="left"></a-text>
</a-entity>
我们需要在分数发生变化时更新分数文本。让我们将其添加到 collide
事件处理程序的末尾。
on($("#weapon"),'collide',(e)=>{
const ball = $("#ball")
if(e.detail.body.id === ball.body.id && !hit) {
...
$("#score").setAttribute('text','value','Score '+score)
}
})
现在,我们可以在屏幕上看到分数。它应该看起来像这样
模型
我们已经运行了一个基本的游戏。玩家可以使用球拍击打球体并获得分数。现在是时候使用真正的 3D 模型来让它看起来更好了。我们需要一个看起来很酷的小鬼来用棍子打。
在 上一次挑战 中,人们创建了大量围绕低多边形中世纪奇幻主题构建的精彩 3D 场景。其中许多已经拆分成单个资产并使用 medievalfantasyassets 标签进行标记。
在这个项目中,我选择使用这个 imp 模型 作为球,以及这个 员工模式 作为球拍。
由于我们要加载大量模型,因此应将它们作为资源加载。资源是游戏启动时自动预加载和缓存的大块数据(图像、声音、模型)。将此放在场景的顶部,并调整 src
URL 以指向模型的下载位置。
<a-assets>
<a-asset-item id="imp" src="models/imp/scene.gltf"></a-asset-item>
<a-asset-item id="staff" src="models/staff/scene.gltf"></a-asset-item>
</a-assets>
现在我们可以用 imp 替换球体,用员工替换球拍框。像这样更新武器元素
<a-entity position="0 0 -3" id="weapon">
<a-entity gltf-model="#staff"></a-entity>
</a-entity>
以及球元素
<a-entity id='ball'
position="0 1 -4"
dynamic-body
>
<a-entity id='imp-model' gltf-model="#imp"></a-entity>
</a-entity>
我们可以看到 imp,但员工不见了。发生了什么?
问题出在员工模型本身。imp 模型(大部分)位于其坐标系中心,因此它会在我们放置的位置视觉显示。但是,员工模型的中心与其坐标系的中心有很大偏差;大约 15 到 20 米。这是在线找到的模型的常见问题。为了解决这个问题,我们需要将模型的位置平移以说明偏移量。在调整员工模型后,我发现 2.3、-2.7、-16.3 的偏移量可以解决问题。我还需要将其旋转 90 度以使其水平,并将其向前移动 4 米以使其可见。将模型用另一个实体包装,以应用平移和旋转。
<a-entity id=“weapon” rotation="-90 0 0" position="0 0 -4">
<a-entity position="2.3 -2.7 -16.3"
gltf-model="#staff"
static-body></a-entity>
</a-entity>
现在我们可以看到员工了,但我们仍然有一个问题。员工不是简单的几何形状,而是一个完整的 3D 模型。物理引擎无法直接使用完整网格。相反,它需要知道使用哪个基本对象。我们可以像最初那样使用盒子,但我选择使用一个以员工末端为中心的球体。这就是玩家应该实际用来击打 imp 的部分,通过使其比员工的直径更大,我们可以使游戏比现实生活中更容易。我们还需要将 static-body
定义移动到外部实体,这样它就不会受到模型偏移的影响。
<a-entity rotation="-90 0 0" position="0 0 -4" id='weapon'
static-body="shape:sphere; sphereRadius: 0.3;">
<a-entity position="2.3 -2.7 -16.3"
gltf-model="#staff" ></a-entity>
</a-entity>
风景
我们已经使用新模型正确地运行了核心游戏机制,接下来让我们添加一些装饰。我从 SketchFab 获取了更多模型,用于 月亮、大锅、石头 和 两棵 不同的 树。将它们放置在场景的不同位置。
<a-assets>
<a-asset-item id="imp" src="models/imp/scene.gltf"></a-asset-item>
<a-asset-item id="staff" src="models/staff/scene.gltf"></a-asset-item>
<a-asset-item id="tree1" src="models/arbol1/scene.gltf"></a-asset-item>
<a-asset-item id="tree2" src="models/arbol2/scene.gltf"></a-asset-item>
<a-asset-item id="moon" src="models/moon/scene.gltf"></a-asset-item>
<a-asset-item id="cauldron" src="models/cauldron/scene.gltf"></a-asset-item>
<a-asset-item id="rock1" src="models/rock1/scene.gltf"></a-asset-item>
</a-assets>
...
<!-- cauldron -->
<a-entity position="1.5 0 -3.5" gltf-model="#cauldron"></a-entity>
<!-- the moon -->
<a-entity gltf-model="#moon"></a-entity>
<!-- trees -->
<a-entity gltf-model="#tree2" position="38 8.5 -10"></a-entity>
<a-entity gltf-model="#tree1" position="33 5.5 -10"></a-entity>
<a-entity gltf-model="#tree1" position="33 5.5 -30"></a-entity>
我们的小游戏开始看起来像一个真实的场景了!
大锅里有气泡,这些气泡似乎在 SketchFab 上动画,但它们在这里没有动画。动画存储在模型中,但如果没有其他组件,它不会自动播放。只需将 animation-mixer
添加到大锅的实体中即可。
最终游戏中,岩石散布在场地周围。但是,我们真的不想手动放置 50 块不同的岩石。相反,我们可以编写一个组件来为我们随机放置它们。
A-Frame 文档解释了 如何创建组件,因此我在这里不再赘述。它的要点是:组件具有一些输入属性,然后在调用 init()
时(以及其他一些函数)执行代码。在这种情况下,我们想要接受模型的来源,一些控制如何在场景中分配模型的变量,然后让一个函数创建 N 个模型的副本。
以下是代码。我知道它看起来很吓人,但实际上它很简单。我们将一步一步地介绍它。
<!-- alternate random number generator -->
<script src="js/random.js"></script>
<!-- our `distribute` component -->
<script>
AFRAME.registerComponent('distribute', {
schema: {
src: {type:'string'},
jitter: {type:'vec3'},
centerOffset: {type:'vec3'},
radius: {type:'number'}
},
init: function() {
const rg = new Random(Random.engines.mt19937().seed(10))
const center = new THREE.Vector3(this.data.centerOffset.x,
this.data.centerOffset.y, this.data.centerOffset.z)
const jx = this.data.jitter.x
const jy = this.data.jitter.y
const jz = this.data.jitter.z
if($(this.data.src).hasLoaded) {
const s = this.data.radius
for(let i = -s; i<s; i++) {
for(let j=-s; j<s; j++) {
const el = document.createElement('a-entity')
el.setAttribute('gltf-model', this.data.src)
const offset = new THREE.Vector3(i*s + rg.real(-jx,jx),
rg.real(-jy,jy),
j*s - rg.real(-jz,jz));
el.setAttribute('position', center.clone().add(offset));
el.setAttribute('rotation',{x:0, y:rg.real(-45,45)*Math.PI/180, z:0})
const scale = rg.real(0.5,1.5)
el.setAttribute('scale',{x:scale,y:scale,z:scale})
$('a-scene').appendChild(el)
}
}
}
}
})
</script>
首先,我导入 random.js
。这是 Cameron Knight 的 random-js 项目 中的随机数生成器。我们可以使用 Javascript 内置的标准 Math.random()
函数,但我希望确保每次运行游戏时,岩石的位置都相同。这个其他生成器允许我们提供一个种子。
在 init()
代码的第一行,你可以看到我使用了种子 10。我实际上尝试了几个不同的种子,直到找到一个我喜欢的种子。如果我真的想让每次加载都不同,例如在游戏的不同关卡中,那么我可以在每个关卡中提供不同的种子。
distribute 组件的核心是嵌套的 for
循环。代码创建了一个实体网格,每个实体都附加到同一个模型。对于每个副本,我们将从原始模型的自然中心点(modelCenter
参数)进行平移,使用 jitter
参数添加随机偏移量。Jitter 表示岩石从该网格点移动的最大距离。使用 0 0 0
表示没有抖动。使用 0 10 0
会使岩石在 -10 到 10 之间的任何地方垂直移动,但在水平面上不会移动。对于这个游戏,我使用了 2 0.5 2
,让它们主要水平移动,但上下移动一点。循环代码还使岩石具有随机的比例和绕 Y 轴的旋转,只是为了使场景看起来更有机。
这是最终结果。
这篇博客已经很长了,我们还没有处理灯光、声音或润色。让我们现在在 第二部分 中继续进行游戏。
如果你想查看完成项目的源代码,我已将其全部放入这个 github 仓库 中。
关于 Josh Marinacci
我是一名作家、研究员和正在康复的工程师。曾任 Sun 的 Swing 团队、Palm 的 webOS 团队和诺基亚研究中心的成员。我传播良好的用户体验。我和我的妻子以及天才的乐高搭建师孩子住在阳光明媚的俄勒冈州尤金。
10 条评论