2016 年 1 月

第 31 卷,第 1 期

游戏开发 - Babylon.js: 用于改进您的首个 Web 游戏的高级功能

作者:Raanan Weber

在 12 月刊中,我查看了 Babylon.js(基于 WebGL 的 3D 游戏引擎)的基本构造块,然后开始编写此教程 (msdn.com/magazine/mt595753)。一开始,我使用 Babylon.js 提供的工具设计了非常简单的保龄球游戏。目前,游戏包括所需对象—保龄球、球道、球槽和 10 个球瓶。

现在,我将向您演示如何设计栩栩如生的游戏—如何掷球、击中球瓶、添加音效、提供不同的照相机视图等。

本教程此部分自然依赖于第一部分中的代码,并对代码进行扩展。要区分这两部分,我创建了一个新的 JavaScript 文件以包含将用于此部分的代码。即使在扩展某些对象(如场景或主照相机)的功能时,我也不会在第一部分所创建对象的功能中实施它。我必须扩展的功能只有 init,它包含本教程这两部分所需的全部变量。

这样做是为了简单起见。当然,实施您自己的游戏时,您可以根据自己的意愿使用任何编程模式和语法。我建议尝试下 TypeScript 及其面向对象的编程方式。这对于整理项目非常有用。

在我编写本文期间,发布了新版本的 Babylon.js。我将继续使用版本 2.1。要查看 Babylon.js 2.2 的新增功能,请转到 bit.ly/1RC9k6e

本机碰撞检测

游戏中使用的主照相机是自由照相机,玩家可使用鼠标和键盘浏览整个 3D 场景。但是,无需进行特定修改,照相机应可以在场景中浮动、穿过墙壁,甚至穿过地板和球道。要创建逼真的游戏,玩家应该只能在地面和球道上移动。

要启用此功能,我将使用 Babylon 的内部碰撞检测系统。碰撞系统可阻止两个对象相互合并在一起。它也具有重力功能,可防止玩家在抬着头向前行走时飘浮在空中。

首先,启用碰撞检测和重力:

function enableCameraCollision(camera, scene) {
  // Enable gravity on the scene. Should be similar to earth's gravity. 
  scene.gravity = new BABYLON.Vector3(0, -0.98, 0);
  // Enable collisions globally. 
  scene.collisionsEnabled = true;
  // Enable collision detection and gravity on the free camera. 
  camera.checkCollisions = true;
  camera.applyGravity = true;
  // Set the player size, the camera's ellipsoid. 
  camera.ellipsoid = new BABYLON.Vector3(0.4, 0.8, 0.4);
}

此功能在游戏场景和照相机中启用碰撞系统,设置照相机的椭圆体,这可视作玩家的大小。这是一个箱子,大小(在本例中)为 0.8x1.6x0.8 单位,约为人体的平均尺寸。照相机需要这个箱子,因为它不是网格。Babylon.js 碰撞系统仅检查网格之间的碰撞,正因为如此,应为照相机模拟网格。椭圆体定义对象的中心,因此大小从 0.4 转换为 0.8。我也启用了场景的重力,这将应用于照相机的移动。

一旦照相机启用碰撞,我需要启用地面和球道碰撞检查。通过在每个要碰撞的网格上设置布尔标志可以实现此目的:

function enableMeshesCollision(meshes) {
  meshes.forEach(function(mesh) {
    mesh.checkCollisions = true;
  });
}

最后一步是将两个功能调用添加到 init 功能:

// init function from the first part of the tutorial.
  ...
  enableCameraCollision(camera, scene);
  enableMeshesCollision[[floor, lane, gutters[0], gutters[1]]);
}

碰撞检测的唯一作用是防止网格相互合并在一起。实施重力功能以使照相机保持在地面上。要在特定网格间创建逼真的物理交互—本例中是球和球道—需要更复杂的系统:物理引擎。

掷球—Babylon.js 中的物理集成

游戏的主要操作是向球瓶中掷球。要求非常简单: 玩家应能够选择方向和投掷的力量。如果击中特定球瓶,它们应倒下。如果球瓶击中其他球瓶,其他球瓶也应倒下。如果玩家将球投掷到边上,它应落入球槽。球瓶应根据球掷向它们的速度倒下。

这正是物理引擎所要管理的领域。物理引擎实时计算网格的体动力学,并根据作用力计算下一次的运动。简单地说,物理引擎可决定在一个网格被另一个网格碰撞时或网格被用户移动时所发生的事件。需考虑网格当前的速度、重量、形状等。

要实时计算刚体动力学(网格在空间中的下一个运动),物理引擎必须简化网格。为实现此目的,每个网格有一个替身—可绑定它的一个简单网格(通常是球体或箱子)。这会降低计算的精度,但可以更快速地计算对象移动的物理作用力。要了解有关物理引擎工作原理的更多信息,请访问 bit.ly/1S9AIsU

物理引擎不属于 Babylon.js。无需致力于单个引擎,框架开发者决定对不同的物理引擎实施界面并让开发者决定要使用哪一个。Babylon.js 目前对两个物理引擎实施了界面: Cannon.js (cannonjs.org) 和 Oimo.js (github.com/lo-th/Oimo.js)。两个都非常棒! 我个人认为 Oimo 集成更好一些,因此我将在保龄球游戏中使用它。Cannon.js 的界面完全是 Babylon.js 2.3 的翻版,现在位于 alpha 中,并支持最新的 Cannon.js 版本,具有大量缺陷修复和新替身,包括复杂的高度图。如果您使用的是 Babylon.js 2.3 或更高版本,我建议可以尝试下。

使用简单的代码行可以启用物理引擎:

scene.enablePhysics(new BABYLON.Vector3(0, -9.8, 0), new BABYLON.OimoJSPlugin());

这可设置场景的重力并定义要使用的物理引擎。将 Oimo.js 替换为 Cannon.js 仅需将第二个变量更改为:

new BABYLON.CannonJSPlugin()

接着,我需要在所有对象上定义替身。使用网格的 setPhysicsState 功能可实现此目的。例如,这是球道的定义:

lane.setPhysicsState(BABYLON.PhysicsEngine.BoxImpostor, {
  mass: 0,
  friction: 0.5,
  restitution: 0
});

第一个变量是替身的类型。因为球道是完整的箱子,我使用箱子替身。第二个变量是身体的物理定义—重量(千克)、摩擦力和恢复系数。球道的质量是 0,因为我想让它处于它的位置。在替身上将质量设置为 0 可使对象锁定在当前位置。

对于球体,我将使用球体替身。保龄球的重量约为 6.8 kg,通常非常光滑,因此无需摩擦力:

ball.setPhysicsState(BABYLON.PhysicsEngine.SphereImpostor, {
  mass: 6.8,
  friction: 0,
  restitution: 0
});

如果您想知道为什么我要在项目中使用千克而不使用磅,因为我要在项目中使用米而不使用英尺: 物理引擎使用公制。例如,默认的重力定义是 (0, -9.8, 0),约为地球引力。使用的单位是米每平方秒 (m/s2)。

现在,我需要能够掷球。因此,我将使用物理引擎的另一个功能—将冲力应用于特定对象。此处,冲力是具有特定方向的力,应用于已启用物理引擎的网格。例如,要向前掷球,我将使用以下内容:

ball.applyImpulse(new BABYLON.Vector3(0, 0, 20), ball.getAbsolutePosition());

第一个变量是冲力的矢量,此处为 Z 轴上 20 个单位,重置场景时会转接。第二个变量指定将作用力应用于对象的位置。在本例中,是球的中心。想像撞球游戏中的花样射球—队列可从不同的位置击中球,不只是中心位置。下面说了如何模拟此类行为。

现在,我可以向前掷球。图 1 显示了球击中球瓶时的情况。

保龄球击中球瓶
图 1 保龄球击中球瓶

但是,我仍未设置方向和作用力。

有许多方法可设置方向。两个最佳选项是使用当前照相机的方向或指针的分接头位置。我将使用第二个选项。

使用 PickingInfo 对象可找到空间中用户触摸的位置,由每个 pointer-down 和 pointer-up 事件发送。PickingInfo 对象包含有关事件触发位置的信息,包括触摸的网格、在网格上触摸的位置、到此位置的距离等。如果未触摸网格,PickingInfo 的 hit 变量将为 false。Babylon.js 场景有两个有用的回叫函数,将帮助我们获取拾捡信息:onPointerUp 和 onPointerDown。触发指针事件时会触发这两个回叫,它们的签名如下:

function(evt: PointerEvent, pickInfo: PickingInfo) => void

evt 变量是触发的原始 JavaScript 事件。第二变量是每个触发的事件上框架生成的捡拾信息。

我可以使用这些回叫在此访问掷球:

scene.onPointerUp = function(evt, pickInfo) {
  if (pickInfo.hit) {
    // Calculate the direction using the picked point and the ball's position. 
    var direction = pickInfo.pickedPoint.subtract(ball.position);
    // To be able to apply scaling correctly, normalization is required.
    direction = direction.normalize();
    // Give it a bit more power (scale the normalized direction).
    var impulse = direction.scale(20);
    // Apply the impulse (and throw the ball). 
    ball.applyImpulse(impulse, new BABYLON.Vector3(0, 0, 0));
  }
}

现在,我可以在特定方向掷球。现在只缺少投掷的作用力。要添加作用力,我将计算 pointer-down 和 pointer-up 事件之间帧的增量。图 2 显示用于通过特定作用力掷球的功能。

图 2 使用作用力和方向掷球

var strengthCounter = 5;
var counterUp = function() {
  strengthCounter += 0.5;
}
// This function will be called on pointer-down events.
scene.onPointerDown = function(evt, pickInfo) {
  // Start increasing the strength counter. 
  scene.registerBeforeRender(counterUp);
}
// This function will be called on pointer-up events.
scene.onPointerUp = function(evt, pickInfo) {
  // Stop increasing the strength counter. 
  scene.unregisterBeforeRender(counterUp);
  // Calculate throw direction. 
  var direction = pickInfo.pickedPoint.subtract(ball.position).normalize();
  // Impulse is multiplied with the strength counter with max value of 25.
  var impulse = direction.scale(Math.min(strengthCounter, 25));
  // Apply the impulse.
  ball.applyImpulse(impulse, ball.getAbsolutePosition());
  // Register a function that will run before each render call 
  scene.registerBeforeRender(function ballCheck() {
    if (ball.intersectsMesh(floor, false)) {
      // The ball intersects with the floor, stop checking its position.  
      scene.unregisterBeforeRender(ballCheck);
      // Let the ball roll around for 1.5 seconds before resetting it. 
      setTimeout(function() {
        var newPosition = scene.activeCameras[0].position.clone();
        newPosition.y /= 2;
        resetBall(ball, newPosition);
      }, 1500);
    }
  });
  strengthCounter = 5;
}

有关指针事件的注意事项—我有意未使用术语“单击”。 Babylon.js 使用指针事件系统,它将浏览器的鼠标单击功能扩展为触摸和其他能够单击、触摸和定位的输入设备。这样,在智能手机上触摸或在台式机上单击鼠标均可触发相同的事件。要在不支持此功能的浏览器上模拟此行为,Babylon.js 使用 hand.js(指针事件的填充代码,也包括在游戏的 index.html 中)。您可以在 GitHub 页面 (bit.ly/1S4taHF) 上阅读更多有关 hand.js 的信息。指针事件草稿可在 bit.ly/1PAdo9J 中找到。请注意,将来版本中 hand.js 将被替换为 jQuery PEP (bit.ly/1NDMyYa)。

这就是物理引擎! 保龄球游戏变得好多了。

添加音效

在游戏中添加音效可大大提高 UX。音频可以营造适当气氛,并可使保龄球游戏更加逼真。幸好,Babylon.js 版本 2.0 中引入了音频引擎。音频引擎基于 Web Audio API (bit.ly/1YgBWWQ),所有主要浏览器(Internet Explorer 除外)中均支持它。可使用的文件格式取决于浏览器自身。

我将添加三种音效。第一种是环境声效—模拟周围环境的声音。在保龄球游戏中,保龄球馆的声音通常应非常好听,但因为我创建的球道位于外部草坪上,一些自然的声音将会更适用。

要添加环境音效,我将加载声音、自动播放和不断循环播放:

var atmosphere = new BABYLON.Sound("Ambient", "ambient.mp3", scene, null, {
  loop: true,
  autoplay: true
});

此声音将在加载后不断播放。

将添加的第二种音效是球在保龄球道中滚动的声音。只要球在球道中就会播放此声音,但当球离开球道时就会停止播放。

首先,我将创建滚动声音:

var rollingSound = new BABYLON.Sound("rolling", "rolling.mp3", scene, null, {
  loop: true,
  autoplay: false
});

将加载但并不播放此声音,直到我执行播放功能,这会在掷球时发生。我扩展了图 2 的功能并添加了以下内容:

...ball.applyImpulse(impulse, new BABYLON.Vector3(0, 0, 0));
// Start the sound.
rollingSound.play();
...

当球离开球道时停止播放声音:

...
If(ball.intersectsMesh(floor, false)) {
  // Stop the sound.
  rollingSound.stop();
  ...
}
...

Babylon.js 使我可以将声音附加到特定网格。这样,便可使用网格的位置自动计算音量和平移,并可实现更逼真的体验。要实现此目的,我只需在创建声音后添加以下行:

rollingSound.attachToMesh(ball);

现在,声音将始终从球的位置播放。

要添加的最后一种音效是球击中球瓶。要实现此目的,我将创建相应声音,然后将其附加到第一个球瓶:

var hitSound = new BABYLON.Sound("hit", "hit.mp3", scene);
hitSound.attachToMesh(pins[0]);

此声音不会循环播放,也不会自动播放。

我将在每次球击中其中一个球瓶时播放此声音。要做到这一点,我将添加一个功能,此功能将在掷球后不断检查球是否与任何球瓶接触。如果接触,我取消注册此功能并播放此声音。我通过将以下行添加到图 2 中的 scene.onPointerUp 功能来实现此目的:

scene.registerBeforeRender(function ballIntersectsPins() {
  // Some will return true if the ball hit any of the pins.
  var intersects = pins.some(function (pin) {
    return ball.intersectsMesh(pin, false);
  });
  if (intersects) {
    // Unregister this function – stop inspecting the intersections.
    scene.unregisterBeforeRender(ballIntersectsPins);
    // Play the hit sound.
    hit.play();
  }
});

游戏现在具有我想要添加的所有音效。接着,我将通过添加计分板继续完善游戏。

请注意,由于材料版权所有,我无法纳入用于随附项目的音效。我无法找到任何免费的可用且可发布的音频示例。因此,代码将被注释掉。如果您添加我使用的三种音频示例,则将适用。

添加得分显示屏

玩家击中球瓶后,如果能够看到仍然站立的球瓶数量和倒下的球瓶数量将会非常棒。因此,我将添加记分板。

记分板是一个简单的黑色平面,它使用白色文本。要创建记分板,我将使用动态纹理功能,它基本上是一个 2D 画布,可在游戏中用作 3D 对象的纹理。

创建平面和动态纹理非常简单:

var scoreTexture = new BABYLON.DynamicTexture("scoreTexture", 512, scene, true);
var scoreboard = BABYLON.Mesh.CreatePlane("scoreboard", 5, scene);
// Position the scoreboard after the lane.
scoreboard.position.z = 40;
// Create a material for the scoreboard.
scoreboard.material = new BABYLON.StandardMaterial("scoradboardMat", scene);
// Set the diffuse texture to be the dynamic texture.
scoreboard.material.diffuseTexture = scoreTexture;

动态纹理使我可以使用 getContext 功能直接在底层画布上涂画,它会返回 CanvasRenderingContext2D (mzl.la/1M2mz01)。如果我不想要直接使用画布背景,动态纹理对象还提供了一些非常有用的帮助功能。一个是 drawText 功能,我可以使用特定字体在此画布顶部绘制线条。我将在倒下的球瓶数量发生变化时更新画布:

var score = 0;
scene.registerBeforeRender(function() {
  var newScore = 10 - checkPins(pins, lane);
  if (newScore != score) {
    score = newScore;
    // Clear the canvas. 
    scoreTexture.clear();
    // Draw the text using a white font on black background.
    scoreTexture.drawText(score + " pins down", 40, 100,
      "bold 72px Arial", "white", "black");
  }
});

检查球瓶是否倒下非常简单—我只需检查它们在 Y 轴的位置是否等于所有球瓶的原始 Y 轴(预定义的变量“pinYPosition”)。

function checkPins(pins) {
  var pinsStanding = 0;
  pins.forEach(function(pin, idx) {
    // Is the pin still standing on top of the lane?
    if (BABYLON.Tools.WithinEpsilon(pinYPosition, pin.position.y, 0.01)) {
      pinsStanding++;
    }
  });
  return pinsStanding;
}

动态纹理如图 3 所示。

游戏记分板
图 3 游戏计分板

我现在还缺少的重置球道和记分板的功能。我将添加一个操作触发器,在按下键盘上的 R 键时它将起作用(参见图 4)。

图 4 重置球道和记分板

function clear() {
  // Reset the score.
  score = 0;
  // Initialize the pins.
  initPins(scene, pins);
  // Clear the dynamic texture and draw a welcome string.
  scoreTexture.clear();
  scoreTexture.drawText("welcome!", 120, 100, "bold 72px Arial", "white", "black");
}
scene.actionManager.registerAction(new BABYLON.ExecuteCodeAction({
  trigger: BABYLON.ActionManager.OnKeyUpTrigger,
  parameter: "r"
}, clear));

按下 R 键将重置/初始化场景。

添加跟随照相机

我要在游戏中添加的一个不错的效果是在投掷时会跟随保龄球移动的照相机。我想要照相机能够在球向球瓶滚动时一起“滚动”,并在球回到原始位置时停止滚动。使用 Babylon.js 的多视图功能可实现此目的。

在本教程的第一部分,我通过使用以下功能将自由的照相机对象设置为场景的活动照相机:

scene.activeCamera = camera

活动照相机变量告知场景要呈现的照相机,以防定义了多个。当我想要在游戏中使用单个照相机时,这非常适用。但如果我想要具有“画中画”特效,一个活动照相机是不够的。相反,我需要使用以变量名称 scene.activeCameras 存储在场景中的活动照相机数组。此数组中的照相机将相继呈现。如果 scene.activeCameras 不为空,将忽略 scene.activeCamera。

第一步,将原始自由照相机添加到此数组。在 init 功能中可轻松执行此操作。将 scene.activeCamera = camera 替换为:

scene.activeCameras.push(camera);

第二步,掷球时创建跟随照相机:

var followCamera = new BABYLON.FollowCamera("followCamera", ball.position, scene);
followCamera.radius = 1.5; // How far from the object should the camera be.
followCamera.heightOffset = 0.8; // How high above the object should it be.
followCamera.rotationOffset = 180; // The camera's angle. here - from behind.
followCamera.cameraAcceleration = 0.5 // Acceleration of the camera.
followCamera.maxCameraSpeed = 20; // The camera's max speed.

这会创建照相机并将其配置为处于要跟随对象的后方 1.5 单位和上方 0.8 单位处。跟随的对象应为球,但存在一个问题—球可能旋转且照相机将随它一起旋转。我想实现的是对象后方有“飞行轨迹”。要实现此目的,我将创建一个可获得球位置,而不是其旋转状态的跟随对象:

// Create a very small simple mesh.
var followObject = BABYLON.Mesh.CreateBox("followObject", 0.001, scene);
// Set its position to be the same as the ball's position.
followObject.position = ball.position;

然后,我将照相机的目标设置为 followObject:

followCamera.target = followObject;

现在,照相机将跟随与球一起移动的跟随照相机。

照相机需要的最后一个配置是视区。每个照相机可以定义将使用的屏幕空间。视区变量可实现此目的,使用以下变量进行定义即可:

var viewport = new BABYLON.Viewport(xPosition, yPosition, width, height);

所有值均介于 0 到 1 之间,如同相对于屏幕高度和宽度的百分比(1 即百分之百)。前两个值定义屏幕上照相机的矩形起点,宽度和高度定义矩形相对于屏幕实际尺寸的宽度和高度。视区的默认设置是 (0.0, 0.0, 1.0, 1.0),覆盖整个屏幕。对于跟随照相机,我将高度和宽度设置为屏幕的 30%。

followCamera.viewport = new BABYLON.Viewport(0.0, 0.0, 0.3, 0.3);

图 5 显示掷球后游戏视图的外观。注意左下角的视图。

跟随照相机的画中画特效
图 5 跟随照相机的画中画特效

输入控件—对指针或键盘事件等输入事件有反应的屏幕—将留在本教程第一部分中定义的自由照相机。这已在 init 功能中设置。

如何进一步开发游戏

我一开始就说过这个游戏将是原型。可以实施更多事项以使游戏更逼真。

首先是添加 GUI,需要用户名并显示配置选项(或许我们可以在火星上玩保龄球游戏! 只需设置不同的重力)和用户可能需要的任何其他内容,等等。

Babylon.js 未提供创建 GUI 的本机方法,但社区成员已创建了一些扩展以用于创建更出色的 GUI,包括 CastorGUI (bit.ly/1M2xEhD)、bGUi (bit.ly/1LCR6jk) 以及 bit.ly/1MUKpXH 中的对话扩展。首先使用 HTML 和 CSS 在 3D 画布的顶部添加层。然后,使用常规网格和动态纹理将 3D 对话添加到场景自身。我建议您在编写自己的解决方案之前尝试下这些功能。它们均非常易于使用,且可极大地简化 GUI 创建过程。

另一项改进是使用更好的网格。我游戏中的网格均使用 Babylon.js 内部功能创建而成,且显然仅使用代码所实现的情况会存在限制。有许多站点提供免费和付费三维对象。我认为,最好的是 TurboSquid (bit.ly/1jSrTZy)。查找低多边形网格以提高性能。

添加更好的网格后,为什么不添加可实际掷球的人体对象? 要实现此目的,您将需要 Babylon.js 中集成的骨骼动画功能和支持此功能的网格。您在 babylonjs.com/BONES 中将可找到相关演示。

最后,尝试使游戏实现友好型虚拟现实。本例中唯一要更改的是使用的照相机。将 FreeCamera 替换为 WebVRFreeCamera,查看如何轻松地定位到 Google Cardboard。

您还可以进行许多其他改进,例如在球瓶上方添加照相机、在边上添加更多球道实现多玩家功能、限制照相机的运动和掷球的位置,等等。我将让您自己发现一些其他功能。

总结

我希望您在阅读本教程时获得了和我在编写本教程时同样多的乐趣,并可以为您指引正确的方向,使您可以尝试下 Babylon.js。这绝对是一个非常棒的框架,深受开发者喜爱。有关此框架的更多演示,请访问 babylonjs.com。再强调一次,赶快加入支持论坛吧,询问任何您遇到的问题。没有愚蠢的问题,只有愚蠢的答案!


Raanan Weber既是一名 IT 顾问、完整堆栈开发者,也是一名丈夫和父亲。空闲时,他为 Babylon.js 和其他开放源代码项目献计献策。您可以阅读他的博客 (blog.raananweber.com)。

衷心感谢以下技术专家对本文的审阅: David Catuhe