在本系列教程的第一部分中,我们使用来自 Sketchfab 的 3D 模型和物理引擎创建了一个 A-Frame 游戏。Whack-an-Imp 运行良好,而且风景优美,但它仍然感觉不到很沉浸。
灯光效果全错了。天空是纯白色的,地面是纯红色的。树木没有阴影,也没有从锅炉里冒出火光。月亮出来了,所以一定是晚上,但我们没有看到任何地方有月光反射。A-Frame 为我们提供了默认灯光,但它不再满足我们的需求。让我们添加我们自己的灯光。
灯光
将地面的颜色更改为更像地面的颜色,深绿色。
<a-plane color="#52430e" ...
添加一个黑暗的暮色天空
<!-- background sky -->
<a-sky color="#270d2c"></a-sky>
我尝试过添加雾来营造更多氛围,但它只是遮挡了天空,所以我把它去掉了。
对于月光,我们将使用定向光。这意味着光来自特定方向,但位置无限远,因此光线均匀地照射到所有表面。对于像月亮这样的东西,这就是我们想要的。
<a-entity light="type: directional; color: #ffffff; intensity: 0.5;"
position="31 80 -50"></a-entity>
这是现在的样子
嗯…… 我们正在接近,但它仍然不太对。月光确实很好地反射在岩石顶部,但岩石底部和树木太黑了,无法看到。虽然这可能是一个真实的场景,但它感觉不像是我想去的地方。
拍摄夜间场景的常见电影技巧是在物体下方照射彩色灯光,以照亮物体下方,而不会使场景过亮,从而破坏夜间效果的错觉。我们可以使用半球灯光来实现这一点。
半球灯光为我们提供了上方的颜色和下方的颜色。我使用白色作为上部,使用一种紫色的深蓝色作为下部,强度为 0.4。随意尝试不同的设置。
<!-- hemisphere light going from white to dark blue -->
<a-entity light="type: hemisphere; color: white; groundColor: #5424ff; intensity: 0.4"
></a-entity>
现在就差最后一步了。锅炉下的火焰应该发出温暖的红色光芒,附近的岩石应该反射这种光芒。
<a-entity light="type: point; intensity: 1.6; distance: 5; decay: 2; color: red"
position="0.275 -0.32 -3.77"></a-entity>
这是一个红色的点光源,这意味着它有一个特定的位置,并且会随着距离衰减。我将衰减设置为 2,强度设置为 1.6。它略微偏离锅炉底部,这样我们就可以得到很好的红色反射。我还将距离设置为 5,以便只有最靠近的岩石才会接收到红色光线。
现在的样子。我认为我们终于有一个很酷的场景。它感觉像是正在发生事情的地方,有待探索的秘密。
阴影
只有一件灯光工作要完成。我们需要一些阴影。从计算的角度来说,阴影很昂贵,所以我们只希望为我们真正关心的阴影对象开启阴影。
首先,我们必须启用将创建阴影的灯光(月光)的阴影投射。只需在灯光属性中添加 castShadow:true
。
<a-entity light="type: directional; color: #ffc18f; intensity: 0.5; castShadow: true;"
position="31 80 -50"></a-entity>
现在将 shadow="receive:true"
添加到地面。现在所有对象都自动在地面上投下阴影。
<!-- the ground --->
<a-plane color="#52430e"
static-body
rotation="-90 0 0" width="100"
height="100" shadow="receive:true"></a-plane>
它开始感觉像一个真实的地方。
为了节省循环,阴影只会在称为阴影视锥的区域内投射。要查看此区域,将灯光上的 shadowCameraVisible
设置为 true。
音频
只需要再完善一下我们的游戏。一些音频。有生活气息的世界并不寂静。夏天的夜晚应该有蟋蟀或微风,锅炉的沸腾声,当然,当我们击中小恶魔时,它可能会大声抱怨。为了活跃气氛,我在freesound.org上找到了一些有用的声音。
首先:蟋蟀和其他生物的夜间声音。我在 freesound 用户sagetyrtle那里找到一个名为October Night 2的剪辑。由于此剪辑包含背景声音,我不希望它们具有位置信息。玩家应该能够从任何地方听到它们,并且它应该循环播放。为了实现这一点,我使用 sound
属性将声音放在场景本身。
<a-scene
...
sound="src: url(./audio/octobernight2.mp3); loop:true; autoplay:true; volume:0.5;"
>
请注意,我将音量设置为 50%,这样它就不会盖过其他音效。
接下来,我们需要一个锅炉里冒泡的声音。我使用的是这个名为SFX Boiling Water的声音,由Euphrosyyn提供。
<!-- cauldron -->
<a-entity gltf-model="#cauldron" ...
sound="src: url(./audio/boilingwater-loop.mp3); autoplay: true; loop:true;"
></a-entity>
同样,我已经将音频设置为循环播放,但由于它附加到锅炉实体而不是场景,因此声音将似乎来自锅炉本身。位置音频确实增强了虚拟场景的沉浸感。当然,这种沸腾的水效果对于锅炉来说有点过分。在现实生活中,锅炉不会像这样快速或大声地冒泡,但我们想要的是沉浸感,而不是真实感。
最后,我们需要一个关于小恶魔的声音。我选择了oops sound,由metekavruk提供。
<a-entity id='imp-model' ...
sound="src: url(./audio/gah.mp3); autoplay: false; loop: false;"
></a-entity>
autoplay
和 loop
都设置为 false,因为我们只希望在小恶魔被武器击中时播放声音。转到 collide
事件处理程序,并添加此行以在每次碰撞时播放声音。
$("#imp-model").components.sound.playSound();
来自 freesound.org 的原始文件采用wav格式,完全未压缩,文件很大。如果您打算编辑声音文件,那么这就是您想要的,但为了在网上进行分发,我们希望更小一些。请确保首先将其转换为 MP3 格式,这可以节省 90% 的文件大小。在 Mac 和 Linux 上,您可以使用 ffmpeg
工具以这种方式将其转换
ffmpeg -i boilingwater-loop.wav boilingwater-loop.mp3
更多抛光
创建基本代码是构建游戏的 90%。完善体验是第二部分 90%。在我第一次构建 Whack-an-Imp 之后,我意识到它很快就会变得无聊。玩家唯一能做的事情就是等到小恶魔跳出来,然后击中它。如果偶尔出现一些玩家不应该击中的东西,那会更有意思。让我们添加一个龙蛋。
在球实体内,我们有一个小恶魔模型。在它旁边添加另一个名为 egg-model 的实体,这次使用一个稍微扭曲的球体。
<!-- the ball contains two models that we swap -->
<a-entity id='ball'
position="0 0.1 -4"
rotation="0 0 0"
dynamic-body="shape:sphere; sphereRadius: 0.3; mass:4"
>
<a-entity id='imp-model' gltf-model="#imp" position="0 -0.4 0"
sound="src: url(./audio/gah.mp3); autoplay: false; loop: false;"
></a-entity>
<a-sphere id='egg-model' radius="0.25" segments-height="8" segments-width="8"
scale="1 0.6 0.8"
material="color: purple; flatShading:true; emissive:red; emissiveIntensity:0.2"
sound="src: url(./audio/cowbell.mp3); autoplay: false; loop: false;"
></a-sphere>
</a-entity>
为了使球体看起来更神奇,我为它赋予了一种带有平面着色的紫色材质,但同时也将其发射颜色设置为红色。通常,材质只会反射来自光源的光线,但发射颜色可以让材质产生自己的光线,即使在黑暗中也是如此。实际上,它会发光。我还添加了来自pj1s的牛铃声音,用于玩家击中蛋时。
请注意,在上面的代码中,我将 dynamic-body
从 imp
移动到周围的 ball
实体。这是因为我们希望无论击中哪个物体,物理行为都相同。但是,小恶魔模型略微偏移,并且会粘在球体边界之外,因此我在 y 方向上调整了位置 -0.4。
现在,我们需要使用一个布尔值更新 resetBall
事件处理程序,以指示我们是否应该显示小恶魔或龙蛋。
let showImp = true
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)
showImp = (Math.floor(Math.random()*4)!==0)
$("#imp-model").setAttribute('visible',showImp);
$("#egg-model").setAttribute('visible',!showImp);
hit = false
resetId = setTimeout(resetBall,6000)
}
我们还需要让 collide-handler 播放正确的声音,并且如果您不小心击中了蛋,则将分数减少 10 分。
on($("#weapon"),'collide',(e)=>{
const ball = $("#ball")
if(e.detail.body.id === ball.body.id && !hit) {
hit = true
if(showImp) {
$("#imp-model").components.sound.playSound();
score = score + 1
} else {
$("#egg-model").components.sound.playSound();
score = score - 10
}
$("#score").setAttribute('text','value','Score '+score)
clearTimeout(resetId)
resetId = setTimeout(resetBall,2000)
}
})
更多细节
在开始之前,让我们修复最后几个细节:将分数显示为白色,以便我们可以在黑暗中看到它,在 a-scene
中关闭物理调试,并删除相机内的光标。我们不再需要光标,因为我们有法杖来指示摄像机指向的位置。
Whack-an-Imp 已经完成!现在是时候测试它了。我们已经知道它在桌面上的运行情况。这是在我的手机上。
完整的 VR 头盔
测试 VR 的唯一方法是在真实硬件上运行它。我在我的 Windows Mixed Reality 头盔上运行了它,看起来还不错。图像和位置音频效果都很好。我确实有一种身临其境的感觉。但是,交互感觉很笨拙,因为法杖是连接在我的头上的。相反,我想用我的实际6dof控制器来使用法杖。我们可以通过将法杖移动到具有 laser-controls
的新实体内来实现这一点。
<a-entity id='laser' laser-controls="hand: left" raycaster="showLine:false;" line="opacity:0.0;">
<a-entity rotation="-105 0 0" position="0 0 -3.5" id='weapon' static-body="shape:sphere; sphereRadius: 0.3;">
<a-entity scale="1.8 1.8 1.8" position="0 1.5 0">
<a-entity position="2.3 -2.7 -16.3" gltf-model="#staff" ></a-entity>
</a-entity>
</a-entity>
</a-entity>
laser-controls
将自动将其内容附加到用户的六自由度运动控制器。这些通常是与 Vive、Rift 和 MR 头盔等 PC 头盔一起提供的大的手柄。laser-controls
组件也适用于 Google Daydream 和 Gear VR 等头盔附带的三自由度控制器。
这会产生一个新的问题。游戏现在可以在传统的 VR 头戴设备中使用控制器玩,但不能再使用手机了。这个问题没有标准的解决方案,所以我选择启用这两种行为,并在运行时启用或禁用正确的行为。为此,我们需要稍微更改一下标记。
将激光组添加到场景中,然后将两个法杖的id=weapon
更改为class='weapon'
,并在相机内部的法杖中添加一个额外的类gaze
,并在激光内部的法杖中添加一个额外的类dof6
。
这是最终结果。
<a-entity camera look-controls position="0 1.5 0">
<a-text id="score" value="Score" position="-0.2 -0.5 -1" color="white" width="5" anchor="left"></a-text>
<a-entity rotation="-90 0 0" position="0 0 -4" class='weapon gaze' static-body="shape:sphere; sphereRadius: 0.3;">
<a-entity position="2.3 -2.7 -16.3" gltf-model="#staff" ></a-entity>
</a-entity>
</a-entity>
<a-entity id='laser' laser-controls="hand: left" raycaster="showLine:false;" line="opacity:0.0;">
<a-entity rotation="-105 0 0" position="0 0 -3.5" class='weapon dof6' static-body="shape:sphere; sphereRadius: 0.3;">
<a-entity scale="1.8 1.8 1.8" position="0 1.5 0">
<a-entity position="2.3 -2.7 -16.3" gltf-model="#staff" ></a-entity>
</a-entity>
</a-entity>
</a-entity>
让我们添加一些函数来打开或关闭一组控件。下面的setEnabled
函数设置所需元素的可见属性,并关闭其静态体的物理属性。然后,switch6DOF
和switchGaze
函数使用正确的参数调用setEnabled
。
function setEnabled(sel,vis) {
const elem = $(sel)
elem.setAttribute('visible', vis)
if(elem.components['static-body']) {
const sb = elem.components['static-body'];
if(vis) {
sb.play()
} else {
sb.pause()
}
}
}
function switch6DOF() {
$('#laser').setAttribute('visible',true)
setEnabled('.weapon.gaze',false)
setEnabled('.weapon.dof6',true)
}
function switchGaze() {
$('#laser').setAttribute('visible',false)
setEnabled('.weapon.gaze',true)
setEnabled('.weapon.dof6',false)
}
玩 whack-an-imp
现在,我们只需要决定何时使用哪组组件。我们可以检查设备是否为移动设备,但这无法处理没有连接头戴设备的桌面情况。相反,我们应该查找“enter-vr”事件,然后检查是否连接了头戴设备。如果是,则切换到 6DOF 模式,否则使用凝视模式。
on($('a-scene'),'enter-vr',()=>{
if(AFRAME.utils.device.checkHeadsetConnected()) {
switch6DOF()
} else {
switchGaze()
}
})
on($('a-scene'),'exit-vr',switchGaze)
//always start up in gaze mode
on($('a-scene'),'loaded',switchGaze)
这样,Whack-an-Imp 将始终适应当前情况。玩家可以无缝地在 VR 模式之间切换。这是 Web 应用程序的标志,应用于混合现实体验:响应式设计.
至此,我们结束了这个项目。您可以在我的网站上玩实时版本,并在 GitHub 上查看代码。请记住,向我们目前的WebVR 挑战提交您自己的作品。有很多很棒的奖品可以获得,并且有很多东西可以学习。让我们知道进展如何……
关于 Josh Marinacci
我是一名作家、研究员和恢复中的工程师。以前在 Sun 的 Swing 团队、Palm 的 webOS 团队和诺基亚研究中心工作。我传播良好的用户体验的理念。我和妻子以及天才的乐高搭建师孩子住在阳光明媚的俄勒冈州尤金。