2D 游戏的物理引擎构建教程(全)
一、2D 游戏物理引擎开发简介
电子补充材料
本章的在线版本(doi:10.1007/978-1-4842-2583-7 _ 1)包含补充材料,可供授权用户使用。
物理引擎在许多类型的游戏中扮演着重要的角色。游戏对象之间可信的物理交互已经成为大多数现代 PC 和主机游戏以及最近的浏览器和智能手机游戏的关键元素。游戏物理学的主题范围很广,包括但不限于刚体、流体动力学、软体、车辆物理学和粒子物理学等领域。这本书将涵盖你开始理解和建立一个通用的二维刚体物理引擎所需的基本主题。这本书还旨在为您提供一个可重复使用的游戏物理引擎,可以用于您自己的游戏,通过指导您从零开始一步一步地构建物理引擎。这样,你将对典型的 2D 刚体系统所需的概念和组件有一个基本的了解。
虽然你可以下载一个物理引擎库,然后继续你的游戏或引擎开发,从头开始构建你自己的游戏引擎有它自己的优势。除了让您对物理引擎如何运行有一个基本的了解之外,它还让您对引擎本身的灵活性、性能、功能和可用性有更多的控制。
如上所述,这本书将涵盖 2D 刚体物理学的基础。主题将包括刚体的性质和行为、碰撞检测、碰撞信息编码和碰撞响应。目标是获得对这些概念的基本理解,这些概念是构建一个可用的物理引擎所必需的。
这本书从三个重要的方面介绍了物理引擎的开发:实用性、可接近性和可重用性。在阅读这本书的同时,我们希望你能参与其中,体验构建游戏引擎的过程。分步指南应该有助于这本书的实用性。在这本书里介绍的理论和实践是基于许多来源的研究和调查,这些来源以不同的细节涵盖了这些主题。信息以一种可接近的方式呈现,允许您跟随每个代码片段,同时解释引擎每个组件背后的总体概念。在跟随并创建了自己的引擎之后,您将能够通过添加自己的特性来扩展和重用成品。
本章描述了本书的实现技术和组织结构。然后,讨论将引导您完成下载、安装和设置开发环境的步骤;指导您构建第一个 HTML5 应用;并用 JavaScript 编程语言扩展第一个应用来运行第一个模拟。
设置您的开发环境
您将要构建的物理引擎将可以通过运行在任何操作系统(OS)上的 web 浏览器来访问。您将要设置的开发环境也是与操作系统无关的。为简单起见,以下说明基于 Windows 7/8/10 操作系统。您应该能够在基于 Unix 的环境(如 Apple macOS 或 Ubuntu)中复制一个类似的环境,只需稍加修改。
您的开发过程包括一个集成开发环境(IDE)和一个运行时 web 浏览器,该浏览器能够托管正在运行的游戏引擎。我们发现最方便的系统是 NetBeans IDE,它使用 Google Chrome web 浏览器作为运行时环境。以下是详细情况:
-
IDE :本书中的所有项目都基于 NetBeans IDE。你可以从 https://netbeans.org/downloads 的下载并安装 HTML5 和 PHP 的捆绑包。
-
运行环境:你将在谷歌浏览器中执行你的项目。你可以从 https://www.google.com/chrome/browser/的下载并安装这个浏览器。
-
连接器 Google Chrome 插件:这是一个 Google Chrome 扩展,将 web 浏览器连接到 NetBeans IDE,支持 HTML5 开发。你可以从
chrome . Google . com/web store/detail/netbeans-connector/hafdlehgocfcodbgjnpecfajgkeejnaa
下载安装这个扩展。下载会自动将插件安装到 Google Chrome 上。您可能需要重新启动计算机来完成安装。
请注意,支持 JavaScript 编程语言或 HTML5 没有特定的系统要求。所有这些技术都嵌入在 web 浏览器运行时环境中。
注意
如前所述,我们选择了基于 NetBeans 的开发环境,因为我们发现这是最方便的。还有许多其他的选择也是免费的,包括但不限于 IntelliJ IDEA、Eclipse、Sublime、微软的 Visual Studio 代码和 Adobe 括号。
下载和安装 JavaScript 语法检查器
我们发现 JSLint 是检测潜在 JavaScript 源代码错误的有效工具。您可以通过以下步骤将 JSLint 作为插件下载并安装到 NetBeans IDE 中:
-
从
plugins.netbeans.org/plugin/40893/jslint
下载。请务必记下下载文件的位置。 -
启动 NetBeans,选择“工具”“➤插件”,然后转到“下载”选项卡。
-
单击“添加插件”按钮,搜索步骤 1 中下载的文件。双击该文件以安装插件。
以下是使用 JSLint 的一些有用参考:
-
有关如何使用 JSLint 的说明,请参见
www.jslint.com/help.html
。 -
关于 JSLint 如何工作的细节,请参见
plugins.netbeans.org/plugin/40893/jslint
。
在 NetBeans 开发环境中工作
NetBeans IDE 易于使用,本书中的项目只需要编辑器和调试器。要打开项目,请选择文件➤打开项目。一旦项目打开,你需要熟悉三个基本窗口,如图 1-1 所示。
-
项目窗口:该窗口显示项目的源代码文件。
-
编辑器窗口:该窗口显示并允许您编辑项目的源代码。通过在“项目”窗口中双击相应的文件名,可以选择要使用的源代码文件。
-
Action Items 窗口:该窗口显示 JSLint 检查器输出的错误信息。
图 1-1。NetBeans IDE
注意
如果在 IDE 中看不到窗口,可以单击“窗口”菜单,选择缺少的窗口的名称,使其出现。例如,如果在 IDE 中看不到“项目”窗口,可以选择“窗口”“➤项目”来打开它。
在 NetBeans 中创建 HTML5 项目
您现在已经准备好创建您的第一个 HTML5 项目了。
-
启动 NetBeans。选择文件➤新建项目(或按 Ctrl+Shift+N),如图 1-2 所示。将出现一个新的项目窗口。
图 1-2。创建新项目
-
在新建项目窗口中,在类别部分选择 HTML5,在项目部分选择 HTML5 应用,如图 1-3 所示。单击“下一步”按钮,打开项目配置窗口。
图 1-3。选择 HTML5 项目
-
如图 1-4 所示,输入项目的名称和位置,点击完成按钮,创建你的第一个 HTML5 项目。
图 1-4。命名项目
NetBeans 将为您生成一个简单而完整的 HTML5 应用项目的模板。您的 IDE 看起来应该类似于图 1-5 。
图 1-5。HTML5 应用项目
通过在“项目”窗口中选择并双击 index.html 文件,可以在编辑器窗口中打开它并观察文件的内容。内容如下:
<!DOCTYPE html>
<!--
To change this license header, choose License Headers in Project Properties.
To change this template file, choose Tools | Templates
and open the template in the editor.
-->
<html>
<head>
<title>TODO supply a title</title>
</head>
<body>
<div>TODO write content</div>
</body>
</html>
第一行声明该文件是一个 HTML 文件。在标签内的块是一个注释块。互补的 标签包含了所有的 HTML 代码。在这种情况下,模板定义头部和身体部分。页眉设置网页的标题,而正文是网页所有内容的位置。
您可以通过选择运行➤运行项目或按 F6 键来运行此项目。图 1-6 显示了运行默认项目时的样子。
图 1-6。运行简单的 HTML5 项目
要停止该程序,请关闭该网页,或者单击浏览器中的“取消”按钮,使 NetBeans 停止跟踪该网页。您已经成功运行了您的第一个 HTML5 项目。您可以使用此项目来了解 IDE 环境。
项目文件和文件系统之间的关系
导航到文件系统上的 html 5 应用项目位置,例如使用 Windows 中的 Explorer OS 实用程序。您可以看到,在项目文件夹中,NetBeans 生成了 nbProject、public_html 和 test 文件夹。表 1-1 总结了这些文件夹和 index.html 文件的用途。
表 1-1。NetBeans HTML5 项目中的文件夹和文件
|NetBeans HTML5 项目:文件夹/文件
|
目的
|
| --- | --- |
| nbProject/ | 该文件夹包含 IDE 配置文件。您不能修改该文件夹中的任何文件。 |
| public_html/ | 这是项目的根文件夹。项目中的源代码和资源将在此文件夹中创建。 |
| 公共 _html/index.html | 这是网站的默认入口点。该文件将被修改以加载 JavaScript 源代码文件。 |
| 测试/ | 这是单元测试源代码文件的默认文件夹。此文件夹在本书中没有使用,并且已经从所有项目中删除。 |
HTML5 画布项目
这个项目演示了如何设置引擎的核心绘图功能,以及定义一个用户控制脚本。图 1-7 显示了一个运行这个项目的例子,它是在项目文件夹中定义的。
图 1-7。使用绘图核心和用户控件运行 HTML5 项目
该项目的目标如下:
-
了解如何设置 HTML 画布元素
-
学习如何从 HTML 文档中检索 canvas 元素,以便在 JavaScript 中使用
-
学习如何创建 HTML 画布的引用上下文,并使用它来操作画布
-
熟悉基本的用户控件脚本
拉伸型芯
这个引擎将使用简单的绘图代码来模拟物理引擎代码。毕竟,模拟唯一需要显示的是物理引擎代码实现后简单对象是如何交互的。因此,高级图形功能,如照明、纹理或阴影渲染,只会使代码更加复杂。出于这个原因,一个简单的 HTML5 画布和原始的绘图支持将服务于在创建和调试期间渲染物理模拟的目的。
创建 HTML 画布
在这一步中,您将为所有对象的绘制创建一个空的 HTML5 画布。
-
在编辑器中打开 index.html 文件,方法是在项目视图中双击项目名称,然后打开站点根文件夹,并双击 index.html 文件。
-
要创建用于绘图的 HTML 画布,请在 index.html 文件的 body 元素中添加以下代码行
<table style="padding: 2px"> <tr> <td> <div> <canvas id="canvas"></canvas> </div> </td> </tr> </table>
代码定义了一个 canvas 元素,其 id 为 canvas。id 是元素的名称,可用于在加载网页时检索相应的元素。请注意,代码中没有指定宽度和高度。这是因为您将在下一步中指定这些属性。您将使用画布 id 来检索对实际画布绘制区域的引用,您将在该区域中进行绘制。
创建核心脚本
本节详细介绍了创建第一个脚本所需的步骤,即绘图画布初始化。这个脚本将会包含更多物理引擎的核心功能。对于这一步,您将只为绘图画布编写初始化代码。
-
通过右键单击并创建一个新文件夹,在 SiteRoot(或 public_html)文件夹中创建一个名为 EngineCore 的新文件夹。
-
右键单击 EngineCore 文件夹,在 EngineCore 文件夹中创建一个新的 JavaScript 文件。将文件命名为 Core.js。
-
打开新的 Core.js 文件进行编辑。
-
通过添加以下代码,创建对 gEngine 的静态引用。
var gEngine = gEngine || {}; gEngine.Core = (function () { }());
吉恩。核心是所有物理引擎核心功能将驻留的地方。
注意所有的全局变量名称都以“g”开头,后面跟一个大写字母,就像 gEngine 一样。
-
在基因里。您想要访问的核心,并定义画布元素的宽度和高度。为此,您将创建一个变量 mCanvas,并将其引用到 index.html 的 Canvas 元素,以便您可以设置和修改画布属性。您还需要变量 mContext,它将保存一个对在画布中绘图所需的所有方法和属性的引用。添加以下代码来完成这些任务。
var mCanvas, mContext, mWidth = 800, mHeight = 450; mCanvas = document.getElementById('canvas'); mContext = mCanvas.getContext('2d'); mCanvas.height = mHeight; mCanvas.width = mWidth;
注意所有实例变量名都以“m”开头,后面跟一个大写字母,如 mCanvas。
-
创建一个对象变量 mPublic,因为您需要使一些引擎核心变量和函数在引擎开发的后期可以被其他脚本访问。目前,mPublic 只需要保持三个变量可访问,即 canvas 的宽度和高度,以及要绘制到 canvas 中的 mContext。
var mPublic = { mWidth: mWidth, mHeight: mHeight, mContext: mContext }; return mPublic;
-
最后,要将 Core.js 包含在模拟中,您需要将其添加到 index.html 文件中。为此,只需在 body 元素中添加以下代码。
<script type="text/javascript" src="EngineCore/Core.js"></script>
用户控制
在本节中,将向您介绍使用 JavaScript 的基本用户控件事件处理程序。这是为了让您能够在物理引擎增量开发的每一步中测试您的实现。对于本章,用户控制脚本将用于测试您是否正确初始化了画布并正确实现了绘图功能。
创建用户控制脚本
让我们开始吧:
-
通过右键单击 SiteRoot(或 public_html)文件夹,在 SiteRoot 文件夹中创建一个新的 JavaScript 文件。将文件命名为 UserControl.js。
-
打开新的 UserControl.js 文件进行编辑
-
这里你想创建一个函数来处理所有的键盘输入。让我们将这个函数命名为 userControl。这个函数有一个名为 keycode 的变量,用来跟踪用户的键盘输入。为此,请在 UserControl.js 中添加以下代码。
function userControl(event) { var keycode; }
-
由于某些浏览器处理输入事件的方式不同,所以您想知道模拟将在哪种类型的浏览器中运行。在控件函数中添加以下代码,以区分 IE 键事件处理程序和其他浏览器键事件处理程序。
if (window.event) { // IE keycode = event.keyCode; } else if (event.which) { // Netscape/Firefox/Opera keycode = event.which; }
这个脚本将使您能够处理来自浏览器的键盘输入事件,并相应地处理输入和响应。在这种情况下,您想要测试您在上一节中刚刚创建的画布。这种测试可以通过在接收到键盘输入时绘制矩形和圆形来实现,这将在下一节中详细介绍。
使用用户控制脚本
在本节中,您将通过添加一些用户输入响应来完成本章的 UserControl.js 文件,以便在按下 F 或 G 键时在画布上的随机位置绘制矩形或圆形。
控制脚本将由 HTML onkeydown 事件触发。重要的是要认识到,在浏览器中,每个键盘键都与一个唯一的键码相关联。例如,“a”与 65 的键码相关,“b”是 66,依此类推。
注意
js 将在开发过程中不断发展,以处理更多的键盘输入和更复杂的响应。
-
打开 UserControl.js 文件进行编辑。
-
您需要访问画布的宽度和高度,以及要绘制到画布中的上下文。在控制函数中添加以下代码行。
var width = gEngine.Core.mWidth; var height = gEngine.Core.mHeight; var context = gEngine.Core.mContext;
-
如果按下“F”键(键码值为 70 ),则在随机位置创建一个矩形,如果按下“G”键(键码值为 71 ),则创建一个圆形。添加以下代码行来完成此任务。
if (keycode === 70) { //f //create new Rectangle at random position context.strokeRect(Math.random() * width * 0.8, // x position of center Math.random() * height * 0.8, // y position of center Math.random() * 30 + 10, Math.random() * 30 + 10); // width and height location } if (keycode === 71) { //g //create new Circle at random position context.beginPath(); //draw a circle context.arc(Math.random() * width * 0.8, // x position of center Math.random() * height * 0.8, // y position of center Math.random() * 30 + 10, 0, Math.PI * 2, true); // radius context.closePath(); context.stroke(); }
-
接下来,要将 UserControl.js 包含在模拟中,您需要将其添加到 index.html 文件中。为此,只需在 body 元素中添加以下代码。
<script type="text/javascript" src="EngineCore/Control.js"></script>
-
最后,您希望 HTML 处理按键事件。打开 index.html 文件进行编辑,并将 onkeydown 属性添加到 body 标记中,以调用您的 JavaScript 函数控件。修改您的 index.html 文件,使正文标签看起来像下面这样。
<body onkeydown="return userControl(event);" >
现在,如果您运行项目并按下 F 或 G 键,模拟将在随机位置以随机大小绘制一个圆形或矩形,如上图 1-7 所示。
摘要
至此,物理引擎的基本绘图功能已经初始化,应该能够使用用户要求的基本输入在画布上绘制矩形和圆形。在这一章中,你已经用一种简单的绘制刚体的方法构建了支持未来复杂性增加的源代码。现在,您已经准备好将项目的功能和特性扩展到物理引擎中。下一章将关注任何游戏或物理引擎所需的核心功能(引擎循环,向量计算),以及将矩形和圆形发展成刚体面向对象的对象来封装它们的绘图和行为。
二、实现 2D 物理引擎核心
在上一章中,您实现了支持基本绘图操作的功能。绘图是构建物理引擎的第一步,因为它允许您观察输出,同时继续扩展引擎的功能。在本章中,2D 物理模拟的两个关键组件,核心引擎循环和刚性形状类,将被检查并添加到引擎中。核心引擎循环,或称引擎循环,允许引擎控制和处理游戏对象的实时交互,而刚性形状类抽象并隐藏了未来物理计算所需的位置和旋转角度等详细信息。
本章首先简要介绍一个简单的向量计算库。假设您具有 2D 空间中基本向量运算的背景,因此提供了所需的代码,而没有广泛的概念解释。本章然后向您介绍一个刚体形状类,这是一个关键的抽象,它将封装物理模拟中所需的对象的所有信息,例如(将在下一章中介绍)宽度、高度、中心位置、质量、惯性和摩擦力等信息。通过刚性形状类呈现的这些信息将在引擎发展成为一个全功能的 2D 游戏物理库的过程中被利用。在这一章中,你将从创建刚体形状类开始,它只包含在画布上绘制物体的信息。最后,将向您介绍物理引擎的一个更重要的组件,核心引擎循环。
完成本章后,您将能够:
-
基于用户键盘输入控制对象的位置和旋转。
-
模拟影响场景中所有对象的重力,以及打开和关闭重力的能力。
-
选择并显示特定对象的属性。
-
重置场景。
向量计算库
物理模拟需要一个矢量库来表示对象的位置和方向,并支持改变这些量的模拟中所涉及的计算。2D 物理模拟中涉及的计算是基本的矢量运算,包括加、减、缩放、叉积等。因此,您将创建一个简单的 Vec2 矢量数学库,以包含在所有后续项目中。
创建库
在此步骤中,您将在新的库文件夹中创建一个新文件,以支持所有必需的计算。
-
通过右键单击并创建一个新文件夹,在 SiteRoot(或 public_html)文件夹中创建一个新的文件夹名 Lib。
-
右键单击 Lib 文件夹,在 Library 文件夹中创建一个新的 JavaScript 文件。将文件命名为 Vec2.js。
-
打开新的 Vec2.js 文件进行编辑。
-
添加 Vec2 构造函数。
var Vec2 = function (x, y) { this.x = x; this.y = y; };
-
添加所有支持基本向量运算的函数。
Vec2.prototype.length = function () { return Math.sqrt(this.x * this.x + this.y * this.y); }; Vec2.prototype.add = function (vec) { return new Vec2(vec.x + this.x, vec.y + this.y); }; Vec2.prototype.subtract = function (vec) { return new Vec2(this.x - vec.x, this.y - vec.y); }; Vec2.prototype.scale = function (n) { return new Vec2(this.x * n, this.y * n); }; Vec2.prototype.dot = function (vec) { return (this.x * vec.x + this.y * vec.y); }; Vec2.prototype.cross = function (vec) { return (this.x * vec.y - this.y * vec.x); }; Vec2.prototype.rotate = function (center, angle) { //rotate in counterclockwise var r = []; var x = this.x - center.x; var y = this.y - center.y; r[0] = x * Math.cos(angle) - y * Math.sin(angle); r[1] = x * Math.sin(angle) + y * Math.cos(angle); r[0] += center.x; r[1] += center.y; return new Vec2(r[0], r[1]); }; Vec2.prototype.normalize = function () { var len = this.length(); if (len > 0) { len = 1 / len; } return new Vec2(this.x * len, this.y * len); }; Vec2.prototype.distance = function (vec) { var x = this.x - vec.x; var y = this.y - vec.y; return Math.sqrt(x * x + y * y); };
定义了这些函数后,现在就可以对向量进行操作,以计算和操纵画布上绘制的对象的位置、大小和方向。我们期望你理解这些初等运算符。不要忘记通过使用
<script type="text/javascript" src="Lib/Vec2.js"></script>
物理引擎和刚性形状
这本书关注的是在物理交互过程中不改变形状的原始物体,或者是刚性物体。例如,一个掉落的乐高积木从你的桌子上弹起,落在硬木地板上,这被认为是刚性物体之间的相互作用。这种类型的模拟称为刚体物理模拟,或者更简单地称为刚体模拟。
模拟任意刚性形状之间的相互作用所涉及的计算在算法上是复杂的,并且计算成本很高。由于这些原因,刚体模拟通常基于一组有限的简单几何图形,例如,刚性圆和矩形。在典型的游戏引擎中,这些简单的刚性形状可以附加到几何复杂的游戏对象上,以近似它们的物理模拟,例如,在宇宙飞船上附加刚性圆,并使用刚性圆的刚体物理模拟来近似宇宙飞船之间的物理交互。
您将构建的物理引擎基于模拟刚性圆和矩形之间的相互作用。该模拟包括四个基本步骤:
-
执行动作
-
检测碰撞
-
解决冲突
-
驱动对碰撞的响应
本章的其余部分将引导你构建基础结构来表示简单的刚性圆和矩形。以下章节介绍了碰撞检测、运动近似、碰撞解决和碰撞响应的复杂细节。
刚性形状项目
这个项目演示了如何实现基本的基础设施来封装刚体的特征。你可以在图 2-1 中看到这个项目运行的例子。
图 2-1。运行刚性形状项目
该项目的源代码是在刚性形状项目文件夹中定义的。
项目目标:
-
定义所有刚性形状对象的基类。
-
为建立刚体物理模拟器打下基础。
-
了解刚性形状类别和引擎核心功能之间的关系。
-
定义测试您的工具的初始场景。
引擎核心中的列表对象
您将从定义一个列表对象 mAllObjects 开始,以跟踪所有已定义的刚性形状。正如您将在下一章中看到的,mAllObjects 列表允许模拟所有已定义的刚性形状之间的物理交互。为了方便地支持模拟计算,在 gEngine 中定义了 mAllObjects 列表。核心组件。
-
编辑 Core.js 并在 gEngine.Core 中添加以下行。这将创建一个列表来跟踪所有定义的刚性形状。
var mAllObjects = [];
-
更新 Core.js 中的 mPublic 变量,以允许访问新定义的 list 对象。这在下面的代码片段中完成。
var mPublic = { mAllObjects: mAllObjects, mWidth: mWidth, mHeight: mHeight, mContext: mContext };
刚性形状基类
现在,您可以为矩形和圆形对象定义一个基类。该基类将封装这两个形状共有的所有功能。
-
首先在 SiteRoot(或 public_html)文件夹下创建一个名为 RigidBody 的新子文件夹。在 RigidBody 文件夹中,创建一个新文件,并将其命名为 RigidShape.js
-
编辑 RigidShape.js 以定义构造函数。目前,构造函数只接收一个表示对象中心的向量参数。刚性形状的旋转角度的默认值为 0。然后将创建的对象推入全局对象列表 mAllObjects。
function RigidShape(center) { this.mCenter = center; this.mAngle = 0; gEngine.Core.mAllObjects.push(this); }
刚性矩形类
定义了刚性形状的基本抽象类后,现在可以创建第一个具体的刚性形状,即刚性矩形。
-
在 RigidBody 文件夹下,创建一个新文件,命名为 Rectangle.js。
-
编辑此文件以创建一个接收中心、宽度和高度属性的构造函数。在构造函数中,将刚体的类型定义为 Rectangle,分配一个数组来存储矩形的顶点位置,并分配一个单独的数组来存储面法线向量(稍后将讨论)。
var Rectangle = function (center, width, height) { RigidShape.call(this, center); this.mType = "Rectangle"; this.mWidth = width; this.mHeight = height; this.mVertex = []; this.mFaceNormal = []; };
-
在构造函数中,使用中心、宽度和高度信息计算矩形的顶点位置。
//0--TopLeft;1--TopRight;2--BottomRight;3--BottomLeft this.mVertex[0] = new Vec2(center.x - width / 2, center.y - height / 2); this.mVertex[1] = new Vec2(center.x + width / 2, center.y - height / 2); this.mVertex[2] = new Vec2(center.x + width / 2, center.y + height / 2); this.mVertex[3] = new Vec2(center.x - width / 2, center.y + height / 2);
-
接下来,计算人脸法向量。如图 2-2 所示,面法线是垂直于边并指向远离矩形中心的向量。请注意,面法线向量被归一化为长度 1。此外,请注意矩形顶点和相应面法线之间的关系。面法线索引-0 与从顶点 2 到 1 的向量方向相同。该方向垂直于由顶点 0 和 1 形成的边。这样,面法线索引-0 是指远离与第一条边垂直的矩形的方向,依此类推。面法向量将在以后用于确定碰撞。
图 2-2。矩形的面法线
//0--Top;1--Right;2--Bottom;3--Left //mFaceNormal is normal of face toward outside of rectangle this.mFaceNormal[0] = this.mVertex[1].subtract(this.mVertex[2]); this.mFaceNormal[0] = this.mFaceNormal[0].normalize(); this.mFaceNormal[1] = this.mVertex[2].subtract(this.mVertex[3]); this.mFaceNormal[1] = this.mFaceNormal[1].normalize(); this.mFaceNormal[2] = this.mVertex[3].subtract(this.mVertex[0]); this.mFaceNormal[2] = this.mFaceNormal[2].normalize(); this.mFaceNormal[3] = this.mVertex[0].subtract(this.mVertex[1]); this.mFaceNormal[3] = this.mFaceNormal[3].normalize();
-
通过在构造函数后包含以下代码,确保新定义的 Rectangle 类正确地继承了 RigidShape 基类。
var prototype = Object.create(RigidShape.prototype); prototype.constructor = Rectangle; Rectangle.prototype = prototype;
-
现在,您可以为矩形对象创建 draw 函数。调用上下文的 strokeRect 函数(对画布的引用)来完成这一任务。必须定义相应的平移和旋转,以便在正确的位置和方向绘制矩形。实现如下所示。
Rectangle.prototype.draw = function (context) { context.save(); context.translate(this.mVertex[0].x, this.mVertex[0].y); context.rotate(this.mAngle); context.strokeRect(0, 0, this.mWidth, this.mHeight); context.restore(); };
刚性圆类
现在,您可以基于与刚性矩形相似的整体结构来实现刚性圆形对象。
-
在 RigidBody 文件夹下,创建一个新文件,命名为 Circle.js。
-
编辑此文件以创建一个构造函数,该构造函数初始化圆的半径、刚体类型(如 circle)和 mStartpoint 位置,以便绘制一条参考线来可视化圆的旋转角度。最初,在没有旋转的情况下,参考线是垂直的,将圆心连接到圆周的顶部。改变圆的旋转角度将导致这条线被旋转。
var Circle = function (center, radius) { RigidShape.call(this, center); this.mType = "Circle"; this.mRadius = radius; // The start point of line in circle this.mStartpoint = new Vec2(center.x, center.y - radius); };
-
与 Rectangle 类类似,您必须包含以下代码,以确保 Circle 类正确地从 RigidShape 基类继承。
var prototype = Object.create(RigidShape.prototype); prototype.constructor = Circle; Circle.prototype = prototype;
-
与矩形不同,上下文的 arc 函数用于在画布上绘制圆形。此外,您需要从圆心到圆的顶部 mStartpoint 绘制旋转参考线。
Circle.prototype.draw = function (context) { context.beginPath(); //draw a circle context.arc(this.mCenter.x, this.mCenter.y, this.mRadius, 0, Math.PI * 2, true); //draw a line from start point toward center context.moveTo(this.mStartpoint.x, this.mStartpoint.y); context.lineTo(this.mCenter.x, this.mCenter.y); context.closePath(); context.stroke(); };
修改用户控制脚本
您将修改 UserControl.js 文件来测试新功能。
-
编辑 SiteRoot(或 public_html)文件夹中的 UserControl.js 文件。
-
添加 gObjectNum 变量,该变量是代表当前选定对象的 mAllObjects 数组的索引。注意,这个变量是在定义 userControl 函数之前定义的,并且是一个全局变量。
var gObjectNum = 0;
-
在 userControl 函数中,使用 F 和 G 键定义对创建随机矩形和圆形的支持。
if (keycode === 70) { // f var r1 = new Rectangle(new Vec2(Math.random()*width*0.8, Math.random()*height*0.8), Math.random() * 30+10, Math.random() * 30+10); } if (keycode === 71) { //g var r1 = new Circle(new Vec2(Math.random()*width*0.8, Math.random()*height*0.8), Math.random() * 10 + 20); }
-
在用户控制功能中,定义基于上/下箭头和 0 到 9 键选择对象索引的支持。
if (keycode >= 48 && keycode <= 57) { //number if (keycode - 48 < gEngine.Core.mAllObjects.length) gObjectNum = keycode - 48; } if (keycode === 38) { //up arrow if (gObjectNum > 0) gObjectNum--; } if (keycode === 40) { // down arrow if (gObjectNum < gEngine.Core.mAllObjects.length-1) gObjectNum++; }
融入核心
现在可以修改 Core.js 文件来集成和测试新定义的功能。您的修改将调用所有创建的刚性形状的绘图,并更新用户界面(UI)以正确反映应用的状态。现在,绘图将通过调用适当的绘图函数的简单而连续的循环,或者引擎循环来完成。在本章的下一节,你将实现一个更高级的引擎循环来处理物理引擎的计算。
-
打开 Engine Core 文件夹中的 Core.js 进行编辑。
-
创建新的 runGameLoop 函数。在 runGameLoop 中,调用 windows.requestAnimationFrame 来指定下一帧重绘的函数。此外,调用另外两个函数,draw 和 updateUIEcho 函数,以绘制所有已定义的刚性形状并接收用户键盘输入。
var runGameLoop = function () { requestAnimationFrame(function () { runGameLoop(); }) updateUIEcho(); draw(); };
-
定义 updateUIEcho 函数来更新 HTML 以显示应用的正确状态。
var updateUIEcho = function () { document.getElementById("uiEchoString").innerHTML = "<p><b>Selected Object:</b>:</p>" + "<ul style=\"margin:-10px\">" + "<li>Id: " + gObjectNum + "</li>" + "<li>Center: " + mAllObjects[gObjectNum].mCenter.x.toPrecision(3) + "," + mAllObjects[gObjectNum].mCenter.y.toPrecision(3) + "</li>" + "</ul> <hr>" + "<p><b>Control</b>: of selected object</p>" + "<ul style=\"margin:-10px\">" + "<li><b>Num</b> or <b>Up/Down Arrow</b>: SelectObject</li>" + "</ul> <hr>" + "<b>F/G</b>: Spawn [Rectangle/Circle] at random location" + "<hr>"; };
-
添加 draw 函数来迭代并调用 mAllObjects 列表中刚性形状的相应 draw 函数。strokeStyle 属性被设置为只有当前选定的对象被绘制为红色,而其他对象被绘制为蓝色。
var draw = function () { mContext.clearRect(0, 0, mWidth, mHeight); var i; for (i = 0; i < mAllObjects.length; i++) { mContext.strokeStyle = 'blue'; if (i === gObjectNum) mContext.strokeStyle = 'red'; mAllObjects[i].draw(mContext); } };
-
定义在脚本首次运行时初始化引擎循环的支持。
var initializeEngineCore = function () { runGameLoop(); };
-
通过将 initializeEngineCore 函数包含在 mPublic 变量中,允许公共访问该函数。
var mPublic = { initializeEngineCore: initializeEngineCore, mAllObjects: mAllObjects, mWidth: mWidth, mHeight: mHeight, mContext: mContext };
定义初始场景
现在,您可以定义一个有界的空环境来测试新功能。
-
在 SiteRoot(或者 public_html)文件夹下新建一个文件,命名为 MyGame.js。
-
通过创建一个名为 MyGame 的新函数来编辑这个文件。在此函数中,使用您刚刚实现的新刚性形状对象来创建定义未来物理模拟边界的四个边界。
function MyGame() { var width = gEngine.Core.mWidth; var height = gEngine.Core.mHeight; var up = new Rectangle(new Vec2(width / 2, 0), width, 3); var down = new Rectangle(new Vec2(width / 2, height), width, 3); var left = new Rectangle(new Vec2(0, height / 2), 3, height); var right = new Rectangle(new Vec2(width, height / 2), 3, height); }
请注意,您可以通过编辑此功能来修改初始场景。当您想要测试物理模拟的性能时,这在后面的章节中会变得很方便。
修改 index.html 文件
为了包含新的功能,您需要始终记住在 index.html 文件中包含和调用它们。
-
打开 index.html 文件进行编辑。
-
修改 body 标记以支持键盘事件的处理,通过实例化一个新的 MyGame 对象来定义初始测试环境,并通过调用 initializeEngineCore 来初始化引擎循环。
<body onkeydown="return userControl(event);" onload="var game = new MyGame(); gEngine.Core.initializeEngineCore()">
-
添加一个新的表行来回显应用状态。
<table style="padding: 2px"> <tr> <td> <div><canvas id="canvas"></canvas></div> </td> <td> <div id=”uiEchoString”> </div> </td> </tr> </table>
-
记得包括所有带有
<script type="text/javascript" src="RigidBody/RigidShape.js"></script> <script type="text/javascript" src="RigidBody/Circle.js"></script> <script type="text/javascript" src="RigidBody/Rectangle.js"> </script><script type="text/javascript" src="EngineCore/Core.js"></script> <script type="text/javascript" src="MyGame.js"></script> <script type="text/javascript" src="UserControl.js"></script>
您现在可以运行项目并测试您的实现。它看起来应该如图 2-1 所示。
观察
现在,您可以运行项目来测试您的实现。请注意四个边界和右边的文本输出,它为用户打印指令并回显应用状态,其中包括所选对象的索引。按 F 或 G 键会在随机位置生成一个随机大小的矩形或圆形。这个绘图模拟似乎与之前的项目非常相似。主要区别在于对象抽象和绘制机制——rigid shape 类定义和引擎循环监视用户输入和所有已定义对象的绘制。在下一个项目中,您将改进引擎循环,以支持刚体形状状态的更改,包括允许用户更改场景中每个刚体形状的属性,以及简单模拟下落物体。
核心引擎回路
任何物理引擎最重要的特征之一是支持对象和图形模拟元素之间看似直观和连续的交互。实际上,这些交互被实现为一个连续运行的循环,它接收和处理计算,更新对象状态,并呈现对象。这个持续运行的循环被称为发动机循环。
为了传达适当的直观感,引擎循环的每个周期必须在正常人的反应时间内完成。这通常被称为实时,这是人类视觉上无法察觉的太短的时间量。通常,当发动机回路以高于每秒 40 至 60 个循环的速率运行时,可以实现实时。由于在每个循环周期中通常有一个绘图操作,循环周期的速率也可以表示为每秒帧数(FPS),或帧速率。FPS 为 60 是一个很好的性能目标。也就是说,您的引擎必须在 1/60 秒内处理计算、更新对象状态,然后绘制画布!
循环本身,包括实现细节,是引擎最基本的控制结构。由于主要目标是保持实时性能,引擎循环操作的细节与物理引擎的其余部分无关。因此,引擎循环的实现应该紧密封装在引擎的核心中,其详细的操作对其他元素是隐藏的。
引擎循环实现
引擎循环是一种连续执行逻辑和绘图的机制。一个简单的引擎循环由处理输入、更新对象状态和绘制这些对象组成,如下面的伪代码所示:
initialize();
while(game running) {
input();
update();
draw();
}
如前所述,60 或更高的 FPS 是保持实时交互性的理想选择。当游戏复杂性增加时,可能出现的一个问题是,有时一个循环可能需要 1/60 秒以上的时间才能完成,导致游戏以降低的帧速率运行。当这种情况发生时,整个游戏将会变慢。一个常见的解决方案是确定哪些操作需要强调,哪些操作需要跳过。由于引擎需要正确的输入和更新才能按设计运行,因此在必要时通常会跳过绘制操作。这被称为跳帧,下面的伪代码说明了一个这样的实现:
elapsedTime = now;
previousLoop = now;
while(game running) {
elapsedTime += now - previousLoop;
previousLoop = now;
input();
while( elapsedTime >= UPDATE_TIME_RATE ) {
update();
elapsedTime -= UPDATE_TIME_RATE;
}
draw();
}
在前面的伪代码清单中,UPDATE_TIME_RATE 是所需的实时更新速率。当引擎循环周期之间的经过时间大于 UPDATE_TIME_RATE 时,将调用更新函数,直到它被赶上。这意味着当引擎循环运行太慢时,绘制操作基本上被跳过。当这种情况发生时,整个游戏看起来运行缓慢,游戏输入响应滞后,并且会跳过一些帧。但是,游戏逻辑会继续正确。
请注意,包含更新函数调用的 while 循环模拟了 UPDATE_TIME_RATE 的固定更新时间步长。这种固定的时间步长更新允许在维持确定性游戏状态中的直接实现。
核心引擎循环项目
这个项目演示了如何将一个循环合并到你的引擎中,并通过相应地更新和绘制对象来支持实时模拟。你可以在图 2-3 中看到这个项目运行的例子。该项目的源代码在核心引擎循环项目文件夹中定义。
图 2-3。运行核心引擎循环项目
该项目的目标如下:
-
理解引擎循环的内部操作。
-
实现和封装引擎循环的操作。
-
获得不断更新和绘制模拟动画的经验。
实现引擎循环组件
引擎循环组件是一个核心引擎功能,因此应该作为 gEngine.Core 的一个属性来实现。
-
编辑 Core.js 文件。
-
添加必要的变量来确定回路频率。
var mCurrentTime, mElapsedTime, mPreviousTime = Date.now(), mLagTime = 0; var kFPS = 60; // Frames per second var kFrameTime = 1 / kFPS; var mUpdateIntervalInSeconds = kFrameTime; var kMPF = 1000 * kFrameTime; // Milliseconds per frame.
-
更新 runGameLoop 函数以跟踪帧之间经过的时间,并确保以帧速率频率调用更新函数。
var runGameLoop = function () { requestAnimationFrame(function () { runGameLoop(); }); //compute how much time has elapsed since the last RunLoop mCurrentTime = Date.now(); mElapsedTime = mCurrentTime - mPreviousTime; mPreviousTime = mCurrentTime; mLagTime += mElapsedTime; //Update the game the appropriate number of times. //Update only every Milliseconds per frame. //If lag larger then update frames, update until caught up. while (mLagTime >= kMPF) { mLagTime -= kMPF; update(); } updateUIEcho(); draw(); };
-
修改 updateUIEcho 函数以打印出附加的相关应用状态信息,比如如何旋转和移动选定的刚性形状。粗体代码是对该函数的唯一补充。
var updateUIEcho = function () { document.getElementById("uiEchoString").innerHTML = // ... identical to previous project mAllObjects[gObjectNum].mCenter.y.toPrecision(3) + "</li>" + **"<li>Angle: " + mAllObjects[gObjectNum].mAngle.toPrecision(3) + "</li>" +** "</ul> <hr>" + "<p><b>Control</b>: of selected object</p>" + "<ul style=\"margin:-10px\">" + "<li><b>Num</b> or <b>Up/Down Arrow</b>: SelectObject</li>" + **"<li><b>WASD</b> + <b>QE</b>: Position [Move + Rotate]</li>" +** "</ul> <hr>" + "<b>F/G</b>: Spawn [Rectangle/Circle] at selected object" + **"<p><b>H</b>: Fix object</p>" +** **"<p><b>R</b>: Reset System</p>" +** "<hr>"; };
-
创建一个名为 update 的新函数,它将调用定义的每个刚性形状的 update 函数。
var update = function () { var i; for (i = 0; i < mAllObjects.length; i++) { mAllObjects[i].update(mContext); } };
扩展刚性形状类
您将修改 rigid shape 基类,以及 Rectangle 和 Circle 类,以支持简单行为的实现。虽然 update 函数是在游戏引擎循环调用的刚性形状基类中定义的,但 update 的详细实现必须是特定于子类的。例如,圆形对象通过更改其中心的值来实现移动行为,而矩形对象必须更改顶点和面法线数组中的所有值来模拟相同的移动行为。
刚性形状基类
-
编辑 RigidShape.js 文件。
-
定义引擎循环要调用的更新函数,并通过用一个恒定的 y 方向向量改变中心位置来实现简单的下降行为。请注意,自由落体行为仅在形状位于画布的垂直边界内时应用。
RigidShape.prototype.update = function () { if (this.mCenter.y < gEngine.Core.mHeight && this.mFix !== 0) this.move(new Vec2(0, 1)); };
子类负责定义 mFix 变量和 move 函数,以控制形状是否固定在不应该跟随下落行为的位置,并实现形状的移动。应该强调的是,此处包含的刚体运动行为仅用于测试目的,将在下一个项目中删除。第四章将介绍和讨论基于物理的刚体运动和相关的物理量(包括速度和加速度)。
请注意,默认情况下,画布坐标定义原点(0,0)位于左上角,正 y 方向向下。出于这个原因,为了模拟重力,您将在正 y 方向上移动所有对象。
圆形类
Circle 类被修改以实现移动。
-
编辑 Circle.js 文件。
-
定义 mFix 实例变量以启用或禁用坠落行为。
var Circle = function (center, radius, fix) { // ... code similar to previous project this.mFix = fix; // ... code similar to previous project
-
添加移动函数以定义向量如何移动圆-将移动向量添加到圆心和 mStartpoint。
Circle.prototype.move = function (s) { this.mStartpoint = this.mStartpoint.add(s); this.mCenter = this.mCenter.add(s); return this; };
-
增加旋转功能,实现圆的旋转。请注意,因为圆是无限对称的,所以旋转后的圆看起来与原始形状相同。mStartpoint 位置允许绘制旋转的参考线,以指示圆的旋转角度。
// rotate angle in counterclockwise Circle.prototype.rotate = function (angle) { this.mAngle += angle; this.mStartpoint = this.mStartpoint.rotate(this.mCenter, angle); return this; };
矩形类
与 circle 类类似,Rectangle 类也必须修改以支持新功能。
-
编辑 Rectangle.js 文件。
-
定义 mFix 实例变量以启用或禁用坠落行为。
var Rectangle = function (center, width, height, fix) { // ... code similar to previous project this.mFix = fix; // ... code similar to previous project
-
通过更改所有顶点和中心的值来定义移动函数。
Rectangle.prototype.move = function (v) { var i; for (i = 0; i < this.mVertex.length; i++) { this.mVertex[i] = this.mVertex[i].add(v); } this.mCenter = this.mCenter.add(v); return this; };
-
通过旋转所有顶点并重新计算面法线来定义旋转函数。
Rectangle.prototype.rotate = function (angle) { this.mAngle += angle; var i; for (i = 0; i < this.mVertex.length; i++) { this.mVertex[i] = this.mVertex[i].rotate(this.mCenter, angle); } this.mFaceNormal[0] = this.mVertex[1].subtract(this.mVertex[2]); this.mFaceNormal[0] = this.mFaceNormal[0].normalize(); this.mFaceNormal[1] = this.mVertex[2].subtract(this.mVertex[3]); this.mFaceNormal[1] = this.mFaceNormal[1].normalize(); this.mFaceNormal[2] = this.mVertex[3].subtract(this.mVertex[0]); this.mFaceNormal[2] = this.mFaceNormal[2].normalize(); this.mFaceNormal[3] = this.mVertex[0].subtract(this.mVertex[1]); this.mFaceNormal[3] = this.mFaceNormal[3].normalize(); return this; };
修改用户控制脚本
您需要扩展 UserControl.js 文件中定义的 userControl 函数,以支持移动、旋转、禁用/启用重力以及重置整个场景。
-
编辑 UserControl.js 文件。
-
添加语句以支持在选定对象上移动、旋转和切换重力。
// move with WASD keys if (keycode === 87) { //W gEngine.Core.mAllObjects[gObjectNum].move(new Vec2(0, -10)); } if (keycode === 83) { // S gEngine.Core.mAllObjects[gObjectNum].move(new Vec2(0, +10)); } if (keycode === 65) { //A gEngine.Core.mAllObject[gObjectNum].move(new Vec2(-10, 0)); } if (keycode === 68) { //D gEngine.Core.mAllObjects[gObjectNum].move(new Vec2(10, 0)); } // Rotate with QE keys if (keycode === 81) { //Q gEngine.Core.mAllObjects[gObjectNum].rotate(-0.1); } if (keycode === 69) { //E gEngine.Core.mAllObjects[gObjectNum].rotate(0.1); } // Toggle gravity with the H key if (keycode === 72) { //H if(gEngine.Core.mAllObjects[gObjectNum].mFix === 0) gEngine.Core.mAllObjects[gObjectNum].mFix = 1; else gEngine.Core.mAllObjects[gObjectNum].mFix = 0; }
-
添加一个重置场景的语句。
if (keycode === 82) { //R gEngine.Core.mAllObjects.splice(5, gEngine.Core.mAllObjects.length); gObjectNum = 0; }
-
修改 G 和 F 键的对象创建语句,以便在当前选定对象的位置而不是随机位置创建新对象。
if (keycode === 70) { //f var r1 = new Rectangle(new Vec2(gEngine.Core.mAllObjects[gObjectNum].mCenter.x, gEngine.Core.mAllObjects[gObjectNum].mCenter.y), Math.random() * 30 + 10, Math.random() * 30 + 10); } if (keycode === 71) { //g var r1 = new Circle(new Vec2(gEngine.Core.mAllObjects[gObjectNum].mCenter.x, gEngine.Core.mAllObjects[gObjectNum].mCenter.y), Math.random() * 10 + 20); }
更新场景
要测试已实现的引擎循环和对象移动,您将为场景创建一个初始选定对象。这个初始对象将作为生成创建的刚性形状的光标位置。这可以通过编辑 MyGame.js 文件并创建一个初始对象来完成。
function MyGame() {
var width = gEngine.Core.mWidth;
var height = gEngine.Core.mHeight;
var r1 = new Rectangle(new Vec2(width / 2, height / 2), 3, 3, 0);
var up = new Rectangle(new Vec2(width / 2, 0), width, 3, 0);
var down = new Rectangle(new Vec2(width / 2, height), width, 3, 0);
var left = new Rectangle(new Vec2(0, height / 2), 3, height, 0);
var right = new Rectangle(new Vec2(width, height / 2), 3, height, 0);
}
观察
运行项目以测试您的实现。你会看到场景和之前的项目几乎一样,除了小的初始光标对象。请注意,您可以使用 0 到 9 或上下箭头键来更改所选对象,从而更改光标对象。键入 F 和 G 键以查看在光标对象位置创建的新对象,它们始终遵循下落行为。这种实时平滑的下降行为表明发动机循环已经成功实现。您可以使用 WASD、QE 和 H 键来调整选定的形状位置;以及移动、旋转和切换选定对象的重力。您可能还会注意到,在光标对象没有移动的情况下,新创建的对象会聚集在一起,这可能会令人困惑。这是因为物理模拟还没有被定义。在下一章中,您将了解并实现碰撞检测,作为解决集群对象问题的第一步。
摘要
在本章中,您已经实现了基本的刚性形状类。尽管只支持简单的位置、方向和绘图,但这些类代表了定义良好的抽象,隐藏了实现细节,因此支持未来的复杂性集成。在接下来的章节中,你将学习其他物理量,包括质量、惯性、摩擦力和恢复力。engine loop 项目向您介绍了连续更新循环的基础知识,该循环支持实时的每形状计算,并支持视觉上吸引人的物理模拟。在下一章中,您将通过首先详细检查刚性形状之间的碰撞来开始学习物理模拟。
三、引入碰撞检测
在 2D 电子游戏的背景下,物理模拟的基础包括刚性形状的运动、运动形状的碰撞以及碰撞后的反应。在前一章中,您定义了刚性形状类和核心引擎循环,以支持基本的绘制、更新操作和刚性形状的简单移动。在本章中,您将学习和实现刚体形状碰撞的检测,并计算必要的信息,以便在下一章中,您可以开始解决和实现对碰撞的响应。基于这些概念的适当实现使得当对象在模拟世界中彼此物理交互时能够产生可信的场景。
本章着重于检测碰撞的基础,包括如何近似检测,在任何方向上精确检测碰撞矩形和圆形的理论,以及在检测到碰撞后要捕获的基本信息,以支持相互渗透的解决方案和对碰撞的正确响应。你将以一步一步的方式实现这个系统,从简单的宽相位碰撞检测方法,到更精确和计算成本更高的分离轴定理(SAT)。这样,在每一步,冲突检测将变得更加准确,并适用于更一般的情况,直到您的解决方案准备好在下一章中用于解决和响应冲突。本章的最终结果将是一个碰撞检测系统,它可以检测任意大小的刚性矩形和圆形之间的碰撞,并且可以计算和获得解决和响应碰撞所需的信息。
完成本章后,您将能够:
-
意识到检测物体碰撞的巨大计算成本。
-
使用宽相位碰撞优化对象碰撞检测,以避免不必要的计算。
-
请理解,在计算机模拟中,刚体可以在碰撞过程中相互穿透,并且这种相互穿透必须得到解决。
-
学习并使用分离轴定理(SAT)来检测刚体碰撞。
-
计算必要的信息以支持有效的位置校正。在下一章中,您将学习使用这些计算信息来有效解决刚体相互穿透。
-
实现一个基于 SAT 的高效碰撞检测算法。
-
准确检测刚性矩形和圆形之间的碰撞。
碰撞物体的相互穿透
如图 3-1 所示,上一章介绍的固定更新时间步长意味着连续运动中的物体位置由一组离散的位置来近似。这些近似法最显著的结果是探测碰撞。
图 3-1。连续运动的刚性正方形
在图 3-1 中可以看到一个这样的问题;想象一下,在当前更新和下一次更新之间有一堵薄薄的墙。你会期望在下一次更新中物体会碰撞并停在墙边。然而,如果这堵墙足够薄,物体从一个位置跳到下一个位置时,基本上会直接穿过它。这是很多游戏引擎面临的普遍问题。这类问题的一般解决方案在算法上可能很复杂,而且计算量很大。游戏设计者的工作通常是用设计良好的(例如,适当的尺寸)和行为良好的(例如,适当的行进速度)游戏对象来减轻和避免这个问题。
图 3-2 显示两个物体在一个时间步长后发生碰撞。在时间步长之前,对象不接触。但是,在时间步长之后,运动模拟的结果会将两个对象重叠放置。
图 3-2。碰撞物体的相互渗透
这是具有离散间隔的固定更新时间步长的另一个衍生示例。在现实世界中,假设物体是固体,这两者永远不会相互渗透。在这种情况下,必须计算碰撞的细节,以便可以正确地解决相互渗透的情况。
冲突检出
碰撞检测是一项至关重要的物理模拟,可能会对性能产生重大影响。例如,如果您想要检测五个对象之间的碰撞,在最坏的情况下,您必须对第一个对象执行四次检测计算,然后对第二个对象执行三次计算,对第三个对象执行两次,对第四个对象执行一次。一般来说,如果没有专门的优化,在最坏的情况下你必须执行 O ( N 2 )操作来检测 N 对象之间的冲突。
除了报告是否发生了冲突之外,冲突检测算法还应该支持用于解决和响应冲突的信息的计算。该信息可以包括穿透深度和穿透的法向矢量。重要的是精确地计算该信息,以便能够有效地解决碰撞,并且正确地计算响应以模拟真实世界。请记住,现实世界中不会发生物体相互穿透,因此计算出的信息只是物理定律的近似值。
宽相位法
详细的碰撞检测算法涉及密集的计算。这是因为必须计算精确的结果,以支持有效的穿透解析和真实的碰撞响应模拟。宽相位方法通过利用对象的邻近性来优化该计算:详细且计算密集的算法仅被部署用于物理上彼此接近的对象。
一种流行的宽相位方法使用边界框/圆来近似所有对象之间的碰撞。边界框是一个 x/y 轴对齐的矩形框,完全包围给定对象。术语 x/y 轴对齐是指边界框的四个边平行于水平 x 轴和垂直 y 轴。类似地,边界圆是以一个对象为中心并完全包围该对象的圆。通过执行简单的边界框/圆相交计算,可以将详细碰撞检测操作的候选对象缩小到只有那些具有碰撞边界的对象。
还有其他广泛的阶段方法,这些方法使用空间结构(如统一网格或四叉树)或相干组(如边界碰撞器的层次)来组织对象。来自宽相位方法的结果通常被输入到中间相位和最后的窄相位碰撞检测方法中。每个阶段都缩小了最终碰撞计算的候选范围,并且每个后续阶段都越来越精确,越来越昂贵。
本章仅向您介绍边界圆宽相位碰撞方法,随后是基于分离轴定理(SAT)的窄相位算法。
广泛阶段方法项目
这个项目演示了如何使用边界圆实现一个广泛的相位碰撞检测方法。你可以在图 3-3 中看到这个项目运行的例子。这个项目的源代码是在广泛阶段方法项目文件夹中定义的。
图 3-3。运行广泛阶段方法项目
项目目标:
-
理解包围圆碰撞检测的实现。
-
理解宽相位碰撞检测的优点和缺点。
-
为构建窄相位碰撞检测算法奠定基础。
定义物理引擎组件
现在可以定义物理引擎组件来支持碰撞检测计算。首先,请遵循定义引擎组件的步骤。
-
在 SiteRoot/EngineCore(或 public_html/EngineCore)文件夹中,创建一个新文件,并将其命名为 Physics.js,该文件将实现物理引擎组件。记得在 index.html 加载这个新的源文件。
-
以与定义 gEngine 相似的方式定义物理组件。核心:
var gEngine = gEngine || { }; gEngine.Physics = (function () { var mPublic = { }; return mPublic; }());
-
在 gEngine 中创建一个碰撞函数。测试 mAllObjects 列表中所有对象之间的边界圆的交集。请注意嵌套循环,这些循环测试每个对象之间的碰撞,并且碰撞的对象用绿色绘制。
var collision = function () { var i, j; for (i = 5; i < gEngine.Core.mAllObjects.length; i++) { for (j = i + 1; j < gEngine.Core.mAllObjects.length; j++){ If (gEngine.Core.mAllObjects[i].boundTest(gEngine.Core.mAllObjects[j])) { gEngine.Core.mContext.strokeStyle = 'green'; gEngine.Core.mAllObjects[i].draw(gEngine.Core.mContext); gEngine.Core.mAllObjects[j].draw(gEngine.Core.mContext); } } } };
-
在 mPublic 中添加公共变量以允许访问碰撞函数。
var mPublic = { collision: collision };
调用物理碰撞并更新用户界面
编辑 SiteRoot/EngineCore(或 public_html/EngineCore)文件夹中的 Core.js 文件。
-
从核心引擎循环中的 runGameLoop 函数调用碰撞计算。
//....identical to previous project while (mLagTime >= kMPF) { mLagTime -= kMPF; gEngine.Physics.collision(); update(); } //....identical to previous project
-
修改 updateUIEcho 函数以移除对 H 按钮的支持。不再需要重力开/关功能。
//...identical to previous project "<b>F/G</b>: Spawn [Rectangle/Circle] at selected object" + **"<p><b>H</b>: Fix object</p>" + // remove this line** "<p><b>R</b>: Reset System</p>" +
修改刚性形状类
现在,您可以修改“刚性形状”文件夹中的所有文件,以支持广泛阶段碰撞检测方法的边界圆测试。
-
您需要修改刚性形状基类。打开 SiteRoot/RigidBody(或 public_html/RigidBody)文件夹下的 RigidShape.js。
-
将 mBoundRadius 变量添加到 RigidShape 构造函数中。这是刚性形状的边界圆的半径。
this.mBoundRadius = 0;
-
定义一个新的原型函数,并将其命名为 boundTest,该函数将测试两个边界圆是否发生碰撞。检测两个圆之间碰撞的最直接的方法是确定两个圆心之间的距离是否小于半径之和。该场景如图 3-4 所示。
图 3-4。圆碰撞检测:(a)没有碰撞(b)检测到碰撞
RigidShape.prototype.boundTest = function (otherShape) { var vFrom1to2 = otherShape.mCenter.subtract(this.mCenter); var rSum = this.mBoundRadius + otherShape.mBoundRadius; var dist = vFrom1to2.length(); if (dist > rSum) { return false; //not overlapping } return true; };
-
您还需要删除被定义为 RigidShape 基类的更新函数的运动测试代码。
RigidShape.prototype.update = function () { };
-
接下来,修改同一文件夹中的 Circle.js 文件,以初始化构造函数中 mBoundRadius 变量的值。刚性圆形状的边界圆与刚性形状的半径相同。记得删除 mFix 变量。
this.mBoundRadius = radius; **this.mFix = fix;** //remove this line
-
出于类似的目的,修改 Rectangle.js 文件,在构造函数中初始化 mBoundRadius 变量。在这种情况下,矩形刚性形状的边界圆定义为矩形对角线距离的一半。再次提醒,记得删除未使用的 mFix 变量。
this.mBoundRadius = Math.sqrt(width*width + height*height)/2;
观察
运行项目以测试您的实现。请注意,默认情况下,对象在同一位置创建,具有重叠的边界圆,因此以绿色绘制。您可以选择一个对象并移动/旋转它,以观察当相应的边界圆没有重叠时,绿色变回黑色。现在,创建一个矩形和一个圆形,并把它们分开。旋转矩形,并将其移近圆形,但不要实际接触圆形。您可能会注意到,这两个形状没有接触,但都是用绿色绘制的。这是因为矩形的碰撞边界是一个圆,这高估了对象的边界,如图 3-5 所示。这是这种宽相位方法的最大缺点:虽然有效,但不准确。这个问题将由后面一节介绍的 SAT 算法来解决。
图 3-5。矩形 A 和圆 B 之间的假阳性碰撞
碰撞信息
实施宽相位碰撞方法后,现在可以开始定义窄相位方法来检测不同刚性形状之间的碰撞。如前所述,必须计算有关冲突细节的信息,以支持正确解决相互渗透和响应。如图 3-6 所示,碰撞的基本信息包括:碰撞深度、法线、开始和结束。碰撞深度是对象相互穿透的最小量,其中碰撞法线是测量碰撞深度的方向。起点和终点是为了方便将贯穿绘制为线段而定义的贯穿的起点和终点位置。任何穿透都可以通过将碰撞对象沿碰撞法线移动从起点到终点位置的碰撞深度距离来解决。
图 3-6。碰撞信息
本节将引导您开发基于刚性圆形状之间的碰撞来计算和处理碰撞信息的基础结构,这是对上一个项目的直接扩展。在本节之后,通过对存储和访问碰撞信息的适当支持,将介绍和实现分离轴定理(SAT)。
圆形碰撞检测项目
该项目构建了计算和处理基于圆之间碰撞的碰撞信息的基础设施。正如将要讨论的,冲突信息记录了冲突的具体细节,用于解决相互渗透和生成响应。请注意,基于边界圆的宽相位碰撞检测方法为刚性圆形状计算精确的碰撞检测解决方案。由于这个原因,这个项目可以利用以前的项目,并专注于计算和处理碰撞信息。你可以在图 3-7 中看到这个项目运行的例子。这个项目的源代码是在圆形碰撞检测项目文件夹中定义的。
图 3-7。运行圆形碰撞检测项目
项目目标:
-
定义碰撞信息。
-
构建计算和处理碰撞信息的基础设施。
-
计算和显示圆的碰撞信息。
定义碰撞信息对象
必须定义一个新的类来支持冲突信息的存储。
-
在 SiteRoot/Lib(或 public_html/Lib)文件夹下,创建一个新文件,并将其命名为 CollisionInfo.js,记住要在 index.html 加载这个新的源文件。
-
定义对象的构造函数以包含碰撞深度、碰撞法线以及开始和结束位置。这些是碰撞穿透的开始和结束位置。
function CollisionInfo() { this.mDepth = 0; this.mNormal = new Vec2(0, 0); this.mStart = new Vec2(0, 0); this.mEnd = new Vec2(0, 0); }
-
定义对象的 getter 和 setter。
CollisionInfo.prototype.setNormal = function (s) { this.mNormal = s; }; CollisionInfo.prototype.getDepth = function () { return this.mDepth; }; CollisionInfo.prototype.getNormal = function () { return this.mNormal; }; CollisionInfo.prototype.setInfo = function (d, n, s) { this.mDepth = d; this.mNormal = n; this.mStart = s; this.mEnd = s.add(n.scale(d)); };
-
创建一个函数来改变法线的方向。该功能将用于确保法线始终从主对象指向正在进行碰撞测试的对象。
CollisionInfo.prototype.changeDir = function () { this.mNormal = this.mNormal.scale(-1); var n = this.mStart; this.mStart = this.mEnd; this.mEnd = n; };
计算两个圆之间的碰撞信息
在前一个项目中,您实现了检测两个圆之间碰撞的功能。在下文中,您将修改碰撞信息的计算,以包括从圆碰撞中获得的信息。
-
在 SiteRoot/RigidBody(或者 public_html/RigidBody)文件夹下新建一个文件,命名为 Circle_collision.js,这个文件将包含一个刚性圆形状与其他刚性形状碰撞的实现。
-
定义 collisionTest 函数以使一个刚性圆形与另一个 RigidShape 对象发生碰撞。请注意,实际的碰撞测试函数是特定于形状的。目前,一个圆只知道如何与一个圆碰撞,对于任何其他形状,它总是返回 false。
Circle.prototype.collisionTest = function (otherShape, collisionInfo) { var status = false; if (otherShape.mType === "Circle") status = this.collidedCircCirc(this, otherShape, collisionInfo); else status = false; return status; };
-
定义 collideCircCirc 函数来检测两个圆之间的碰撞,并在检测到碰撞时计算相应的碰撞信息。碰撞检测有三种情况:没有碰撞,两个圆的中心位于不同的位置和完全相同的位置。下面的代码显示了无冲突检测。细节如图 3-8 所示;vFrom1to2 是从 c1 的中心指向 c2 的中心的向量,rSum 是半径的总和,dist 是两个圆的中心之间的距离。
图 3-8。圆-圆碰撞的细节
Circle.prototype.collidedCircCirc = function (c1, c2, collisionInfo) { var vFrom1to2 = c2.mCenter.subtract(c1.mCenter); var rSum = c1.mRadius + c2.mRadius; var dist = vFrom1to2.length(); if (dist > Math.sqrt(rSum * rSum)) { return false; //not overlapping } // ... details in the following steps };
-
当 dist(两个圆的中心之间的距离)小于半径之和时,检测到碰撞。在这种情况下,如果两个圆没有位于完全相同位置的中心,则可以计算碰撞深度和法线。如图 3-8 所示,由于 c2 是另一个刚体形状的参考,所以碰撞法线是从 c1 指向 c2 的矢量,或者与 vFrom1to2 的方向相同。碰撞深度是 rSum 和 dist 之间的差值,c1 的开始位置是 c2 沿法线从 2 到 1 方向距离 c2 中心的简单半径距离。
//... continue from the previous step if (dist !== 0) { // overlapping but not same position var normalFrom2to1 = vFrom1to2.scale(-1).normalize(); var radiusC2 = normalFrom2to1.scale(c2.mRadius); collisionInfo.setInfo(rSum - dist, vFrom1to2.normalize(), c2.mCenter.add(radiusC2)); } //... details in the next step
-
两个碰撞圆的最后一种情况是两个圆的中心位于完全相同的位置。在这种情况下,如以下代码所示,碰撞法线被定义为负 y 方向,碰撞深度只是两个半径中较大的一个。
//...continue from the previous step if (dist !== 0) { //...identical to previous step } else { //same position if (c1.mRadius > c2.mRadius) collisionInfo.setInfo(rSum, new Vec2(0, -1), c1.mCenter.add(new Vec2(0, c1.mRadius))); else collisionInfo.setInfo(rSum, new Vec2(0, -1), c2.mCenter.add(new Vec2(0, c2.mRadius))); }
与矩形碰撞的情况
矩形的碰撞计算将在本章后面介绍。现在,将定义一个空结构来避免运行时错误。
-
在 SiteRoot/RigidBody(或者 public_html/RigidBody)文件夹下新建一个文件,命名为 Rectangle_collision.js。
-
将以下代码添加到该文件中,以暂时为矩形刚体形状的所有碰撞返回一个 false 条件。
Rectangle.prototype.collisionTest = function (otherShape, collisionInfo) { var status = false; if (otherShape.mType === "Circle") status = false; else status = false; return status; };
修改物理引擎组件
现在,在计算圆到圆碰撞时,可以修改物理组件以支持碰撞信息的计算。
-
编辑 EngineCore/Physics.js,支持绘制碰撞信息,调用新定义的刚体形状 collisionTest 函数。
-
出于调试和测试的目的,定义 drawCollisionInfo 函数,将碰撞深度和法线绘制为刚性形状上的橙色线。
var drawCollisionInfo = function (collisionInfo, context) { context.beginPath(); context.moveTo(collisionInfo.mStart.x, collisionInfo.mStart.y); context.lineTo(collisionInfo.mEnd.x, collisionInfo.mEnd.y); context.closePath(); context.strokeStyle = "orange"; context.stroke(); };
-
在碰撞函数中,首先创建一个 collisionInfo 对象来记录碰撞的细节。在 broad phase boundTest 返回 true 之后,必须通过调用您刚刚定义的刚体形状碰撞测试函数来确定碰撞的详细信息。
//....identical to previous project var collisionInfo = new CollisionInfo(); for (i = 0; i < gEngine.Core.mAllObjects.length; i++) { for (j = i + 1; j < gEngine.Core.mAllObjects.length; j++) { if (gEngine.Core.mAllObjects[i].boundTest(gEngine.Core.mAllObjects[j])) { if (gEngine.Core.mAllObjects[i].collisionTest(gEngine.Core.mAllObjects[j], collisionInfo)) { // ... details in the next step } } //....identical to previous project
-
当碰撞被认为有效时,重要的是确保碰撞法线总是朝着被测试物体的方向。如以下代码所示,这可以通过碰撞法线和由碰撞对象的中心定义的向量之间的点积的符号来确定。调用 drawCollisionInfo 函数绘制相应的碰撞信息。
//... continue from the previous step if (gEngine.Core.mAllObjects[i].collisionTest(gEngine.Core.mAllObjects[j], collisionInfo)) { **//make sure the normal is always from object[i] to object[j]** if (collisionInfo.getNormal().dot( gEngine.Core.mAllObjects[j].mCenter.subtract({ gEngine.Core.mAllObject[i].mCenter)) < 0) { collisionInfo.changeDir(); } //draw collision info (a black line that shows normal) drawCollisionInfo(collisionInfo, gEngine.Core.mContext); **}** //... identical to previous project
观察
运行项目以测试您的实现。请注意,当您创建两个圆时,它们的碰撞不再由颜色的变化来指示。相反,在碰撞圆内绘制橙色线来指示相应的碰撞深度和法线。您可以创建并观察绘制在所有碰撞圆上的碰撞信息。碰撞信息将用于解决碰撞穿插。最后,观察刚性矩形中缺少碰撞信息。这是因为您尚未实现该功能,并且相应的 collisionTest 函数始终返回 false。接下来的两个项目将指导你实现刚性矩形形状之间的碰撞计算。
分离轴定理
分离轴定理(SAT)是 2D 用于检测一般凸形之间碰撞的最流行算法之一的基础。由于推导出的算法对于实时系统来说计算量可能过大,因此通常会先进行宽相位法的初始处理,如前一节所述。沙特德士古公司表示:
如果存在一条线(或轴)垂直于两个多边形的给定边之一,且当两个多边形的所有边都投影到该轴上时,投影边不会重叠,则两个凸多边形不会碰撞。
换句话说,给定 2D 空间中的两个凸形,您可以遍历凸形的所有边,一次一条。对于每条边,计算一条垂直于该边的线(或轴),将两个凸形的所有边投影到这条线上,并计算投影边的重叠部分。如果您可以找到一条没有投影边重叠的垂直线,那么这两个凸形就不会碰撞。
图 3-9 使用两个轴对齐的矩形说明了该描述。在这种情况下,有两条直线垂直于两个给定的形状,即 X 轴和 Y 轴。
图 3-9。存在不重叠的投影
将所有形状边缘投影到这两条线上时,请注意 Y 轴上的投影结果重叠,而 X 轴上没有重叠。由于存在一条垂直于矩形边之一的线,在该线处投影的边不重叠,SAT 断定两个给定的矩形不冲突。
源自 SAT 的算法的主要优点是,对于非碰撞形状,它具有早期退出能力。一旦检测到没有重叠投影边的轴,算法就可以报告没有碰撞,并且不需要继续对其他轴进行测试。在图 3-9 的情况下,如果算法从处理 X 轴开始,则不需要执行 Y 轴的计算。
一种简单的 SAT 算法
基于 SAT 导出的算法通常包括四个步骤:
-
第一步计算 面法线:计算垂直轴,或投影边缘的面法线。如图 3-10 所示,矩形有四条边,每条边都有一条对应的垂直轴。例如,A1 是的对应轴,因此垂直于边 e A1 。请注意,在您的刚性矩形实现中,mFaceNormal 或面法线是垂直轴 A1、A2、A3 和 A4。
图 3-10。矩形边和面法线
-
步骤 2 投影顶点:将两个凸形的顶点投影到面法线上。图 3-11 展示了图 3-10 中所有顶点在 A1 轴上的投影。
图 3-11。将每个顶点投影到面法线上(示例显示 A1)
-
步骤 3 确定边界:确定每个凸形投影顶点的最小和最大边界。继续前面的矩形示例。图 3-12 显示了识别两个矩形的最小和最大位置。请注意,最小/最大位置是相对于给定轴的方向定义的。
图 3-12。确定每个矩形的最小和最大边界位置
-
步骤 4 确定重叠:确定两个最小/最大边界是否重叠。图 3-13 显示两个投影边界确实重叠。在这种情况下,算法不能结束,必须继续处理下一个面法线。请注意,如图 3-10 右图所示,面法线 B1 的过程将导致无碰撞的确定性结论。
图 3-13。测试每个投影轴的重叠(使用 A1 的示例)
给定的算法能够在没有附加信息的情况下确定是否发生了碰撞。回想一下,在检测到碰撞后,物理引擎还必须解决潜在的相互渗透,并为碰撞的形状获得响应。这两种计算都需要额外的信息——图 3-6 中介绍的碰撞信息。下一节将介绍一种有效的基于 SAT 的算法,该算法计算支持点,以告知碰撞检测的真/假结果,并作为导出碰撞信息的基础。
一种有效的 SAT 算法:支持点
形状-A 的面法线的支撑点定义为形状-B 上的顶点位置,在该位置,顶点与形状-A 的相应边的距离为负。图 3-14 显示了形状-A 的面法线 A1。当沿 A1 方向测量时,形状-B 上的顶点 S A1 与边 e A1 的距离为负,因此 S A1 是面法线 A1 的支撑点。负距离表示测量是有方向的,并且支持点必须与面法线方向相反。
图 3-14。面法线的支持点
一般来说,给定面法线的支持点在每个更新周期中可能不同,因此必须在每次碰撞调用中重新计算。此外,非常重要的是,面法线完全可能没有定义的支持点。
面法线可能不存在支持点
仅当沿面法线测量的距离为负值时,才定义支持点。例如,图 3-14 中形状 B 的面法线 B1 在形状 A 上没有对应的支持点。这是因为当沿着 B1 测量时,形状 A 上的所有顶点距离对应的边 e B1 都是正距离。正距离表示形状 A 的所有顶点都在边 e B1 的前面。换句话说,整个形状 A 在形状 B 的边 e B1 的前面,因此这两个形状没有物理接触,因此它们没有碰撞。
因此,当计算两个形状之间的碰撞时,如果任何一个面法线都没有相应的支撑点,那么这两个形状就没有碰撞。同样,早期退出能力是一个重要的优势——一旦检测到第一个未定义的支持点,算法就可以返回一个决定。
为了讨论和实现的方便,支持点和相应边缘之间的距离被称为支持点距离,并且该距离被计算为正数。这样,支撑点距离实际上是沿着负面法线方向测量的。这将是本书其余讨论中遵循的惯例。
最少穿透和碰撞信息的轴
当为凸形的所有面法线定义支持点时,最小支持点距离的面法线是导致最小贯穿的轴。图 3-15 显示了两个形状之间的碰撞,其中定义了形状 B 的所有面法线的支撑点:形状 A 上的顶点 S B1 是面法线 B1 的相应支撑点,S B2 是面法线 B2 的支撑点,以此类推。在这种情况下,S B1 具有最小的对应支持点距离,因此面法线 B1 是导致最小贯穿的轴。图 3-15 右图显示,在这种情况下,支撑点距离为碰撞深度,面法线 B1 为碰撞法线,支撑点 S B1 为碰撞的起点,碰撞的终点很容易计算;简单来说就是 S B1 在碰撞法线方向偏移碰撞深度。
图 3-15。最小穿透轴和相应的碰撞信息
该算法
根据背景描述,计算两个凸形 A 和 B 之间的碰撞的有效的基于 SAT 的算法可以概括为:
-
计算形状 a 上所有面法线的支撑点。
-
如果没有定义任何支持点,则不存在碰撞。
-
如果定义了所有支撑点,计算最小穿透轴。
-
-
计算形状 b 上所有面法线的支撑点。
-
如果没有定义任何支持点,则不存在碰撞。
-
如果定义了所有支撑点,计算最小穿透轴。
-
碰撞信息只是上述两个结果中较小的碰撞深度。您现在已经准备好实现支持点 SAT 算法了。
矩形碰撞项目
这个项目将指导你实现支撑点 SAT 算法。你可以在图 3-16 中看到这个项目运行的例子。该项目的源代码是在矩形碰撞项目文件夹中定义的。
图 3-16。运行矩形碰撞项目
项目目标:
- 深入了解并实现支持点 SAT 算法。
修改矩形碰撞
首先修改 Rectangle_collision.js 文件来实现矩形之间的碰撞检测。
-
编辑 RigidBody 文件夹中的 Rectangle_collision.js 文件。
-
创建一个新函数 findSupportPointto,根据 dir(反向面法线方向)和 ptOnEdge(给定边上的一个位置,如顶点)计算一个支持点。下面的代码遍历所有顶点;计算从顶点到顶点的向量 vToEdge 将该矢量投影到输入方向,并记录最大的正投影距离。回想一下,dir 是否定的面法线方向,因此最大的正距离对应于最远的顶点位置。此外,所有投影距离都为负也是完全可能的。在这种情况下,所有顶点都在输入方向的前面,给定边不存在支持点,因此两个矩形不会冲突。
Rectangle.prototype.findSupportPoint = function (dir, ptOnEdge) { //the longest project length var vToEdge; var projection; // initialize the computed results tmpSupport.mSupportPointDist = -9999999; tmpSupport.mSupportPoint = null; //check each vector of other object for (var i = 0; i < this.mVertex.length; i++) { vToEdge = this.mVertex[i].subtract(ptOnEdge); projection = vToEdge.dot(dir); //find the longest distance with certain edge //dir is -n direction, so the distance should be positive if ((projection > 0) && (projection > tmpSupport.mSupportPointDist)) { tmpSupport.mSupportPoint = this.mVertex[i]; tmpSupport.mSupportPointDist = projection; } } };
-
有了定位任何面法线的支持点的能力,下一步就是通过实现 findAxisLeastPenetration 函数找到穿透最少的轴。回想一下,最少穿透的轴是基于距离支持点最近的支持点得出的。以下代码循环遍历四个面法线,找到相应的支持点和支持点距离,并记录最短距离。while 循环表示,如果没有为任何面法线定义支持点,那么这两个矩形不会发生冲突。
Rectangle.prototype.findAxisLeastPenetration = function (otherRect, collisionInfo) { var n; var supportPoint; var bestDistance = 999999; var bestIndex = null; var hasSupport = true; var i = 0; while ((hasSupport) && (i < this.mFaceNormal.length)) { // Retrieve a face normal from A n = this.mFaceNormal[i]; // use -n as direction and // the vectex on edge i as point on edge var dir = n.scale(-1); var ptOnEdge = this.mVertex[i]; // find the support on B // the point has longest distance with edge i otherRect.findSupportPoint(dir, ptOnEdge); hasSupport = (tmpSupport.mSupportPoint !== null); //get the shortest support point depth if ((hasSupport) && (tmpSupport.mSupportPointDist < bestDistance)) { bestDistance = tmpSupport.mSupportPointDist; bestIndex = i; supportPoint = tmpSupport.mSupportPoint; } i = i + 1; } if (hasSupport) { //all four directions have support point var bestVec = this.mFaceNormal[bestIndex].scale(bestDistance); collisionInfo.setInfo(bestDistance, this.mFaceNormal[bestIndex], supportPoint.add(bestVec)); } return hasSupport; };
-
现在,您可以通过计算两个矩形中每个矩形的最小穿透轴并选择两个结果中较小的一个来实现 collidedRectRect 函数。
Rectangle.prototype.collidedRectRect = function (r1, r2, collisionInfo) { var status1 = false; var status2 = false; //find Axis of Separation for both rectangle status1 = r1.findAxisLeastPenetration(r2, collisionInfoR1); if (status1) { status2 = r2.findAxisLeastPenetration(r1, collisionInfoR2); if (status2) { //choose the shorter normal as the normal if (collisionInfoR1.getDepth() < collisionInfoR2.getDepth()) { var depthVec = collisionInfoR1.getNormal().scale(collisionInfoR1.getDepth()); collisionInfo.setInfo(collisionInfoR1.getDepth(), collisionInfoR1.getNormal(), collisionInfoR1.mStart.subtract(depthVec)); } else { collisionInfo.setInfo(collisionInfoR2.getDepth(), collisionInfoR2.getNormal().scale(-1), collisionInfoR2.mStart); } } } return status1 && status2; };
-
通过修改 collisionTest 函数来完成实现,以调用新定义的 collidedRectRect 函数来计算两个矩形之间的冲突。
Rectangle.prototype.collisionTest = function (otherShape, collisionInfo) { var status = false; if (otherShape.mType === "Circle") { status = false; } else { status = this.collidedRectRect(this, otherShape, collisionInfo); } return status; };
观察
现在,您可以运行项目来测试您的实现。尝试用 F 键创建多个矩形。当两个或多个矩形发生碰撞时,可以看到一条橙色线表示碰撞信息(碰撞深度,在碰撞法线方向,从开始到结束)。请记住,这条线显示了解决碰撞所需的最少量的位置校正。使用向上和向下箭头选择和旋转矩形,并观察碰撞信息如何相应变化。在这个阶段,您已经实现了圆和圆之间以及矩形和另一个矩形之间的碰撞检测。如果您尝试碰撞一个矩形和一个圆形,不会生成碰撞信息,因为您没有实现对这种类型碰撞的支持。这将在下一个项目中解决。
矩形和圆形之间的碰撞
计算碰撞检测的支持点方法不适用于圆,因为圆没有可识别的顶点位置。相反,您将实现一个算法,根据圆心相对于矩形的相对位置来检测矩形和圆形之间的冲突。
在讨论实际算法之前,如图 3-17 所示,通过延伸连接边,可以很方便地将矩形边外的区域分为三个不同的区域。在这种情况下,虚线将给定边缘之外的区域分成:R1,左侧/顶部的区域;R2,右边/底部的区域;和 R3,紧接在给定边缘之外的区域。
图 3-17。矩形给定边外的三个区域
在这种背景下,矩形和圆形之间的碰撞可以检测如下:
-
步骤 A : Edge =计算最近的边(矩形上最靠近圆心的边)。
-
步骤 B :如果圆心在外面
-
步骤 B1 :如果在 R1 区域:圆心和左上顶点到边的距离决定是否发生碰撞。
-
步 B2 :如果在 R2 区域:圆心与边的右/下顶点之间的距离决定是否发生碰撞。
-
步 B3 :如果在 R3 区域:中心与边缘的垂直距离决定是否发生碰撞。
-
-
步骤 C :如果圆心在矩形内:检测到碰撞。
矩形圆形碰撞项目
这个项目指导你实现所描述的矩形-圆形碰撞检测算法,并对每个步骤进行详细的讨论。你可以在图 3-18 中看到这个项目运行的例子。该项目的源代码是在矩形圆碰撞项目文件夹中定义的。
图 3-18。运行矩形圆碰撞项目
项目目标:
- 理解并实现矩形圆碰撞检测算法。
修改矩形碰撞
您将在 Rectangle_collision.js 文件中实现所描述的算法。
-
编辑 RigidBody 文件夹中的 Rectangle_collision.js 文件。
-
创建一个新函数,collidedRectCirc,来检测矩形和圆形之间的冲突。因此,这一职能将有五个主要步骤。下面的清单折叠了所有的步骤,详细信息将在本节的剩余部分中填写。
Rectangle.prototype.collidedRectCirc = function (otherCir, collisionInfo) { // **Step A**: Compute the nearest edge if (!inside) { // **Step B1**: If center is in Region R1 // **Step B2**: If center is in Region R2 // **Step B3**: If center is in Region R3 } else { // **Step C**: If center is inside } return true; };
-
步骤 A :计算最近的边。最近的边可以通过计算圆心到矩形每条边的垂直距离来计算。这个距离就是每个顶点和圆心之间的矢量在相应面法线上的投影。以下代码显示了遍历所有顶点,计算从顶点到圆心的向量,并将计算的向量投影到相应的面法线。
// **Step A**: Compute the nearest edge for (i = 0; i < 4; ++i) { //find the nearest face for center of circle circ2Pos = otherCir.mCenter; v = circ2Pos.subtract(this.mVertex[i]); projection = v.dot(this.mFaceNormal[i]); if (projection > 0) { //if the center of circle is outside of rectangle bestDistance = projection; nearestEdge = i; inside = false; break; } if (projection > bestDistance) { bestDistance = projection; nearestEdge = i; } }
如图 3-19 所示,一个有趣的观察结果是,当圆心在矩形内时,所有顶点到中心的矢量将与它们对应的面法线方向相反,因此将导致负投影长度。这与中心在矩形之外的情况相反;那么,至少一个投影长度是正的。因此,“最近的投影距离”是负值最小的距离,因此实际上是最大的数值。
图 3-19。(a)矩形内的中心将导致所有负投影长度;( b)矩形外的中心将导致至少一个正投影长度
-
步骤 B1 :如果中心在矩形外,在 R1 区域内。如图 3-20 -a 所示,当中心和边缘顶点之间的矢量与边缘的方向相反时,可以检测到区域 R1。也就是说,当这两个向量的点积为负时,圆心在 R1 区域。图 3-20 -b 显示当矢量的长度小于圆的半径时会发生碰撞,在这种情况下,碰撞法线简单地沿着矢量,碰撞深度是半径与矢量长度 dist 之差
图 3-20。(a)当中心在 R1 区域时的条件,( b)相应的碰撞信息
// **Step A**: Compute the nearest edge (*details discussed*) if (!inside) { //the center of circle is outside of rectangle // **Step B1**: if ceter is in Region R1 //v1 is from left vertex of face to center of circle //v2 is from left vertex of face to right vertex of face var v1 = circ2Pos.subtract(this.mVertex[nearestEdge]); var v2 = this.mVertex[(nearestEdge + 1) % 4].subtract(this.mVertex[nearestEdge]); var dot = v1.dot(v2); if (dot < 0) { // Region R1 //the center of circle is in corner region of mVertex[nearestEdge] var dis = v1.length(); //compare the distance with radium to decide collision if (dis > otherCir.mRadius) return false; var normal = v1.normalize(); var radiusVec = normal.scale(-otherCir.mRadius); collisionInfo.setInfo(otherCir.mRadius - dis, normal, circ2Pos.add(radiusVec)); } else { // Not in Region R1 // ... *details to follow* ... // **Step B2**: If center is in Region B2 if (...) { // in Region R2 // ... *details to follow* ... } else { // not in Region R2 // **Step B3**: If center is in Region B3 // ... *details to follow* ... } } } else { // *else of (!inside)* // **Step C**: If center is inside the rectangle // ... *details to follow* ... }
-
步进 B2 :如果中心在矩形之外且在 R2 区域内。下面的代码补充了步骤 B1 的代码,唯一的区别是向量沿着边缘的方向。在这种情况下,与使用区域 R1 相比,沿边缘的向量方向相反。
// **Step A**: Compute the nearest edge (*details discussed*) if (!inside) { // **Step B1**: If center is in Region R1 (*detailed discussed*) } else { // **Step B2**: If center is in Region R2 //the center of circle is in corner region of mVertex[nearestEdge+1] //v1 is from right vertex of face to center of circle //v2 is from right vertex of face to left vertex of face var v1 = circ2Pos.subtract(this.mVertex[(nearestEdge + 1) % 4]); var v2 = v2.scale(-1); var dot = v1.dot(v2); if (dot < 0) { var dis = v1.length(); //compare the distance with radium to decide collision if (dis > otherCir.mRadius) return false; var normal = v1.normalize(); var radiusVec = normal.scale(-otherCir.mRadius); collisionInfo.setInfo(otherCir.mRadius - dis, normal, circ2Pos.add(radiusVec)); } else { // **Step B3**: If center is in Region B3 // ... *details to follow* ... }
-
步进 B3 :如果中心在 R3 区域。圆心可能位于的最后一个区域是紧接在最近边缘外面的区域。在这种情况下,先前在步骤 A 中计算的最佳距离是距离;如果这个距离小于圆的半径,那么就发生了碰撞。
// **Step B3**: If center is in Region B3 //the center of circle is in face region of face[nearestEdge] if (bestDistance < otherCir.mRadius) { var radiusVec = this.mFaceNormal[nearestEdge].scale(otherCir.mRadius); collisionInfo.setInfo(otherCir.mRadius - bestDistance, this.mFaceNormal[nearestEdge], circ2Pos.subtract(radiusVec)); } else { return false; }
-
步骤 C :如果圆心在矩形内,则检测到碰撞,计算并返回相应的碰撞信息。
if (!inside) { //... *conditions for Region R1, R2, and R3 as discussed* } else { //the center of circle is inside of rectangle var radiusVec = this.mFaceNormal[nearestEdge].scale(otherCir.mRadius); collisionInfo.setInfo(otherCir.mRadius - bestDistance, this.mFaceNormal[nearestEdge], circ2Pos.subtract(radiusVec)); } return true; };
-
最后一步是修改 collisionTest 函数,以相应地调用新定义的碰撞函数。
Rectangle.prototype.collisionTest = function (otherShape, collisionInfo) { var status = false; if (otherShape.mType === "Circle") { status = this.collidedRectCirc(otherShape, collisionInfo); } else { status = this.collidedRectRect(this, otherShape, collisionInfo); } return status; };
观察
现在,您可以运行项目来测试您的实现。您可以创建矩形和圆形,移动和旋转它们以观察用橙色线表示的相应碰撞信息。旋转碰撞矩形以观察碰撞信息,适应形状的旋转。这是因为计算的碰撞信息依赖于矩形的顶点和面法线的位置。但是,当旋转碰撞圆时,碰撞信息不会改变。这是因为计算出的碰撞信息仅取决于圆的中心位置及其半径。因此,圆的旋转不会改变其碰撞信息。
摘要
在此阶段,物理引擎模拟能够准确检测碰撞,并在刚性形状碰撞时计算适当的碰撞信息。已经向您介绍了宽相位法、分离轴定理和有效检测凸形碰撞的支撑点。您已经实现了基于这些概念的算法,这些算法成功地检测到冲突,并计算出解决任何潜在渗透所需的相关信息。下一章将向你介绍一些关于运动的基本物理学,以及如何使用计算出的碰撞信息,通过正确地解决碰撞来模拟 2D 空间中真实世界的物理相互作用。
四、完成物理引擎和刚性形状组件
在前一章中,你已经实现了检测刚性圆和矩形之间碰撞的算法。除了碰撞是否确实发生的布尔条件之外,您实现的算法还计算了告诉您重要细节的信息——碰撞信息,包括穿透深度和法向。在本章中,您将进一步扩展物理引擎,使用碰撞信息来修正相互穿透条件,并了解模拟类似于真实世界刚性形状行为的碰撞响应。最初,您的响应将是线性运动,最终您将支持由于碰撞而旋转的对象。
从调查的最后一个阶段开始,您将首先修改刚体形状类,以支持牛顿运动的正确模拟,并包括相关的物理属性,以允许模拟碰撞对象之间的能量传递。在物理引擎中实现运动以及上一章的碰撞检测算法后,您可以开始解决碰撞。通过纠正刚性形状的相互渗透状态,并建立适当的响应,可以解决碰撞问题。将通过移动碰撞形状使它们不重叠来纠正相互渗透,并且将基于脉冲方法建立碰撞响应来模拟线性和角动量的转移。
完成本章后,您将能够:
-
了解如何用欧拉法和辛欧拉积分近似积分。
-
辛欧拉积分的近似牛顿运动公式。
-
基于数值稳定松弛方法解决互穿碰撞。
-
计算并实现对碰撞的响应,类似于真实世界中刚体的响应。
-
完成模拟刚性圆和矩形的碰撞和响应的物理引擎。
活动
运动是对模拟世界中物体位置如何变化的描述。从数学上讲,运动可以用多种方式来表达。在前面的章节中,你已经体验了用一个常量或一个位移来不断改变物体位置的运动。尽管可以获得期望的结果,但是从数学上来说这是有问题的,因为速度和位置是具有不同单位的不同类型的量,并且这两者不能简单地组合。如图 4-1 和下面的等式所示,实际上,你一直在基于恒定位移来描述运动。
图 4-1。基于恒定位移的运动
当需要随时间改变位移量时,由恒定位移公式控制的运动变得受限。牛顿力学通过在运动公式中考虑时间来解决这一限制,如下面的方程所示。
这两个方程实现了基于牛顿的运动,其中 v ( t )是描述位置随时间变化的速度,而 a ( t )是描述速度随时间变化的加速度。
请注意,速度和加速度都是矢量,对大小和方向的变化进行编码。速度向量的大小定义了速度,标准化的速度向量标识了对象行进的方向。加速度矢量通过加速度的大小和方向让你知道物体是在加速还是减速。加速度因作用在物体上的力而改变。例如,如果你将一个球抛向空中,地球的引力会随着时间的推移影响物体的加速度,这反过来会改变物体的速度。
显式欧拉积分
以下两个方程表明,欧拉方法,或显式欧拉积分,基于初始值近似积分。尽管可能不稳定,但这是最简单的方法之一,因此是学习积分近似方法的良好起点。如下面两个方程所示,在牛顿运动公式化的情况下,物体的新速度 v 新 ,可以近似为当前速度 v 当前 ,加上当前加速度当前 ,乘以经过的时间量。同样,物体的新位置, p 新 ,可以近似为物体的当前位置, p 当前 ,加上当前速度, v 当前 ,乘以经过的时间量。
*###### 注意
数值不稳定系统的一个例子是,在重力的作用下,一个弹跳的球会减速,但永远不会停止抖动,在某些情况下,甚至会再次开始弹跳。
图 4-2 的左图展示了一个用显式欧拉积分近似运动的简单例子。请注意,新位置pnew是基于当前速度vcurrent计算的,而新速度vnew是为下一个更新周期移动位置而计算的。
图 4-2。显式(左)和辛(右)欧拉积分
辛欧拉积分
实际上,由于系统稳定性的考虑,显式欧拉积分很少实现。这个缺点可以用你将要实现的方法来克服,这种方法被称为半隐式欧拉积分或辛欧拉积分,其中中间结果用于随后的近似。下面的方程显示了辛欧拉积分。注意,除了在计算新位置时使用新速度 v new 和 p new 之外,它与欧拉方法几乎相同。这实质上意味着下一帧的速度被用来计算该帧的位置。
图 4-2 的右图说明了利用辛欧拉积分,基于新计算的速度计算出新位置pnew, v new 。
实现辛欧拉积分并定义属性以支持碰撞响应
现在你已经准备好实现辛欧拉积分了。游戏引擎的固定时间步长更新函数架构允许将 dt 量实现为更新时间间隔和每个更新周期评估一次的积分。
除了实现辛欧拉积分,这个项目还定义了属性及其相应的访问器和 getter 函数。虽然相对简单,但这里介绍这些函数是为了避免分散对后续项目中涉及的更复杂概念的讨论。
您将为此实现修改 RigidShape 类。
刚体运动项目
该项目将指导您完成刚性形状组件,以支持运动计算和碰撞响应。除了实现辛欧拉积分,您要添加的信息还包括碰撞模拟和响应所需的属性,如质量、惯性、摩擦和恢复。正如将要解释的,这些属性中的每一个都将在基于欧拉积分的模拟物体运动和碰撞响应的计算中起作用。你可以在图 4-3 中看到这个项目运行的例子。该项目的源代码是在“刚性形状运动”项目文件夹中定义的。
图 4-3。运行刚性形状运动项目
项目目标:
-
体验基于辛欧拉积分的动作实现。
-
完成 RigidShape 类的实现,以包含相关的物理属性。
-
来建立应对碰撞的基础设施。
实现辛欧拉积分
您必须在引擎核心和刚性形状中定义运动支持和常数。
修改发动机旧件
让我们从发动机核心开始:
-
修改 Core.js 文件,在构造函数中再包含两个实例变量,第一个支持对所有对象应用重力,第二个支持启用/禁用对象移动。
var mGravity = new Vec2(0, 10); var mMovement = false;
-
更新 mPublic 变量以允许外部访问新定义的实例。
var mPublic = { initializeEngineCore: initializeEngineCore, mAllObject: mAllObject, mWidth: mWidth, mHeight: mHeight, mContext: mContext, mGravity: mGravity, mUpdateIntervalInSeconds: mUpdateIntervalInSeconds, mMovement: mMovement };
修改 RigidShape 类
修改 RigidShape 类构造函数以支持速度、角速度和加速度,如以下代码所示。
function RigidShape(center, mass, friction, restitution) {
this.mCenter = center;
this.mVelocity = new Vec2(0, 0);
this.mAcceleration = gEngine.Core.mGravity;
//angle
this.mAngle = 0;
//negetive-- clockwise
//positive-- counterclockwise
this.mAngularVelocity = 0;
this.mAngularAcceleration = 0;
gEngine.Core.mAllObject.push(this);
}
实现辛欧拉积分
现在,您可以将行为添加到刚性形状对象,以进行数值积分。继续使用 RigidShape 基类,并完成更新函数以将辛欧拉积分应用于刚性形状,其中更新的速度用于计算新位置。请注意线性运动和角运动在实现上的相似之处。在这两种情况下,在将结果应用于位移之前,会更新速度。旋转将在本章的最后一节详细讨论。
RigidShape.prototype.update = function () {
if (gEngine.Core.mMovement) {
var dt = gEngine.Core.mUpdateIntervalInSeconds;
//v += a*t
this.mVelocity = this.mVelocity.add(this.mAcceleration.scale(dt)) ;
//s += v*t
this.move(this.mVelocity.scale(dt));
this.mAngularVelocity += this.mAngularAcceleration * dt;
this.rotate(this.mAngularVelocity * dt);
}
};
定义支持碰撞模拟和响应的属性
如前所述,为了在后面的章节中集中讨论更复杂的概念,在这个项目中引入了支持碰撞的属性和相应的支持功能。这些属性在 RigidShape 类中定义。
修改 RigidShape 类
现在是刚性形状类的时间了:
-
再次修改 RigidShape 类构造函数,这次是为了支持质量、恢复(弹性)和摩擦力,如以下代码所示。请注意,质量值的倒数实际上是为了计算效率而存储的(通过在每次更新计算期间避免额外的除法)。此外,请注意,零质量用于表示静止物体。
function RigidShape(center, mass, friction, restitution) { this.mCenter = center; this.mInertia = 0; if (mass !== undefined) this.mInvMass = mass; else this.mInvMass = 1; if (friction !== undefined) this.mFriction = friction; else this.mFriction = 0.8; if (restitution !== undefined) this.mRestitution = restitution; else this.mRestitution = 0.2; this.mVelocity = new Vec2(0, 0) ; if (this.mInvMass !== 0) { this.mInvMass = 1 / this.mInvMass; this.mAcceleration = gEngine.Core.mGravity; } else { this.mAcceleration = new Vec2(0, 0); } //angle this.mAngle = 0; //negetive-- clockwise //positive-- counterclockwise this.mAngularVelocity = 0; this.mAngularAcceleration = 0; this.mBoundRadius = 0; gEngine.Core.mAllObject.push(this); }
-
定义函数 updateMass,以支持运行时质量的变化。请注意,updateInertia 函数是空的。这反映了这样一个事实,即转动惯量是特定于形状的,实际的实现是各个子类(矩形和圆形)的责任。
RigidShape.prototype.updateMass = function (delta) { var mass; if (this.mInvMass !== 0) mass = 1 / this.mInvMass; else mass = 0; mass += delta; if (mass <= 0) { this.mInvMass = 0; this.mVelocity = new Vec2(0, 0); this.mAcceleration = new Vec2(0, 0) ; this.mAngularVelocity = 0; this.mAngularAcceleration = 0; } else { this.mInvMass = 1 / mass; this.mAcceleration = gEngine.Core.mGravity; } this.updateInertia(); }; RigidShape.prototype.updateInertia = function () { // subclass must define this. // must work with inverted this.mInvMass };
修改圆形和矩形类
接下来,修改 Circleand 和 Rectangle 类:
-
修改 Circle 类以实现 updateInertia 函数。该函数计算质量变化时圆的转动惯量。
Circle.prototype.updateInertia = function() { if (this.mInvMass === 0) { this.mInertia = 0; } else { // this.mInvMass is inverted!! // Inertia=mass * radius² // 12 is a constant value that can be changed this.mInertia = (1 / this.mInvMass) * (this.mRadius * this.mRadius) / 12; } };
-
更新 Circle 对象构造函数,调用新的 RigidShape 基类,并接受物理属性的相关参数。记得调用新定义的 updateInertia 进行初始化。
var Circle = function (center, radius, mass, friction, restitution) { RigidShape.call(this, center, mass, friction, restitution); this.mType = "Circle"; //...identical to previous project this.updateInertia(); };
-
修改 Rectangle 类以实现其 updateIntertia 函数。
Rectangle.prototype.updateInertia = function() { // Expect this.mInvMass to be already inverted! if (this.mInvMass === 0) this.mInertia = 0 ; else { //inertia=mass*width²+height² this.mInertia = (1 / this.mInvMass) * (this.mWidth * this.mWidth + this.mHeight * this.mHeight) / 12; this.mInertia = 1 / this.mInertia; } };
-
以类似于 Circle 类的方式更新 Rectangle 构造函数,以接受物理属性的相关参数,并调用新定义的特定于形状的 updateIntertia 函数。
var Rectangle = function (center, width, height, mass, friction, restitution) { RigidShape.call(this, center, mass, friction, restitution); this.mType = "Rectangle"; this.mWidth = width; this.mHeight = height; //...indetical to previous project this.updateInertia(); } ;
修改 updateUIEcho 函数
由于引擎变得更加强大和灵活,您希望 UI 显示相应的属性,并允许用户出于测试目的控制这些属性。修改 Core.js 文件中的 updateUIEcho 函数,打印出用户控制的所有选项。
var updateUIEcho = function () {
document.getElementById("uiEchoString").innerHTML =
"<p><b>Selected Object:</b>:</p>" +
"<ul style=\"margin:-10px\">" +
"<li>Id: " + gObjectNum + "</li>" +
"<li>Center: " + mAllObject[gObjectNum].mCenter.x.toPrecision(3) +
"," + mAllObject[gObjectNum].mCenter.y.toPrecision(3) + "</li>" +
"<li>Angle: " + mAllObject[gObjectNum].mAngle.toPrecision(3) + "</li>" +
"<li>Velocity: " + mAllObject[gObjectNum].mVelocity.x.toPrecision(3) +
"," + mAllObject[gObjectNum].mVelocity.y.toPrecision(3) + "</li>" +
"<li>AngluarVelocity: " + mAllObject[gObjectNum].mAngularVelocity.toPrecision(3) + "</li>" +
"<li>Mass: " + 1 / mAllObject[gObjectNum].mInvMass.toPrecision(3) + "</li>" +
"<li>Friction: " + mAllObject[gObjectNum].mFriction.toPrecision(3) + "</li>" +
"<li>Restitution : " + mAllObject[gObjectNum].mRestitution.toPrecision(3) + "</li>" +
"<li>Movement: " + gEngine.Core.mMovement + "</li>" +
"</ul> <hr>" +
"<p><b>Control</b>: of selected object</p>" +
"<ul style=\"margin:-10px\">" +
"<li><b>Num</b> or <b>Up/Down Arrow</b>: Select Object</li>" +
"<li><b>WASD</b> + <b>QE</b>: Position [Move + Rotate]</li>" +
"<li><b>IJKL</b> + <b>UO</b>: Velocities [Linear + Angular]</li>" +
"<li><b>Z/X</b>: Mass [Decrease/Increase]</li>" +
"<li><b>C/V</b>: Frictrion [Decrease/Increase]</li>" +
"<li><b>B/N</b>: Restitution [Decrease/Increase]</li>" +
"<li><b>,</b>: Movement [On/Off]</li>" +
"</ul> <hr>" +
"<b>F/G</b>: Spawn [Rectangle/Circle] at selected object" +
"<p><b>H</b>: Excite all objects</p>" +
"<p><b>R</b>: Reset System </p>" +
"<hr>";
};
修改用户控制功能
出于测试目的,您希望更新 UserControl.js 文件,以允许在运行时修改游戏引擎属性。将以下案例添加到 userControl 函数中。
//... identical to previous project
if (keycode === 73) //I
gEngine.Core.mAllObject[gObjectNum].mVelocity.y -= 1;
if (keycode === 75) //k
gEngine.Core.mAllObject[gObjectNum].mVelocity.y += 1;
if (keycode === 74) //j
gEngine.Core.mAllObject[gObjectNum].mVelocity.x -= 1;
if (keycode === 76) //l
gEngine.Core.mAllObject[gObjectNum].mVelocity.x += 1;
if (keycode === 85) //U
gEngine.Core.mAllObject[gObjectNum].mAngularVelocity -= 0.1;
if (keycode === 79) //O
gEngine.Core.mAllObject[gObjectNum].mAngularVelocity += 0.1;
if (keycode === 90) //Z
gEngine.Core.mAllObject[gObjectNum].updateMass(-1);
if (keycode === 88) //X
gEngine.Core.mAllObject[gObjectNum].updateMass(1);
if (keycode === 67) //C
gEngine.Core.mAllObject[gObjectNum].mFriction -= 0.01;
if (keycode === 86) //V
gEngine.Core.mAllObject[gObjectNum].mFriction += 0.01;
if (keycode === 66) //B
gEngine.Core.mAllObject[gObjectNum].mRestitution -= 0.01;
if (keycode === 78) //N
gEngine.Core.mAllObject[gObjectNum].mRestitution += 0.01;
if (keycode === 188) //’
gEngine.Core.mMovement = !gEngine.Core.mMovement;
if (keycode === 70) //f
var r1 = new Rectangle (new Vec2(gEngine.Core.mAllObjects[gObjectNum].mCenter.x,
gEngine.Core.mAllObjects[gObjectNum].mCenter.y),
Math.random() * 30 + 10, Math.random() * 30 + 10,
Math.random() * 30, Math.random(), Math.random());
if (keycode === 71) //g
var r1 = new Circle(new Vec2(gEngine.Core.mAllObjects[gObjectNum].mCenter.x,
gEngine.Core.mAllObjects[gObjectNum].mCenter.y),
Math.random() * 10 + 20, Math.random() * 30,
Math.random(), Math.random ());
if (keycode === 72) { //H
var i;
for (i = 0; i < gEngine.Core.mAllObject.length; i++) {
if (gEngine.Core.mAllObject[i].mInvMass !== 0)
gEngine.Core.mAllObject[i].mVelocity =
new Vec2(Math.random() * 20 - 10, Math.random() * 20 - 10);
}
}
//... identical to previous project
观察
运行项目以测试您的实现。在场景中创建一些对象;您可以检查所选对象的属性。请注意,当您通过按下逗号(,)键来启用移动时,具有较高向下初始速度的对象会因为重力或加速度而下落得更快。现在创建一个物体,并设置它的初始 y 速度为负。观察到物体会向上运动,直到 y 分量速度为零,然后由于重力加速度,它开始向下下落。你也可以改变物体的初始 x 速度,观察抛射体的运动。另一个有趣的例子是创建一些对象,通过按 H 键来激发它们。观察所有的物体如何按照它们自己的速度运动。您可能会看到移动到场景边界之外的对象。这是因为此时物理引擎不支持碰撞解决。这将在下一节中进行补救。
解决相互渗透
在游戏引擎的上下文中,冲突解决是指确定对象在冲突后如何响应的过程,包括解决可能发生的潜在穿插情况的策略。请注意,在真实世界中没有碰撞解决过程,因为碰撞受物理定律的严格控制,所以不会发生刚性对象的相互渗透。只有在虚拟模拟世界中,相互渗透的分辨率才是相关的,在虚拟模拟世界中,运动是近似的,不可能的情况可能发生,但可以用开发者或设计者期望的方式解决。
一般来说,有三种常见的方法来响应互穿碰撞。第一种是简单地通过穿透深度使物体彼此移位。这就是所谓的投影方法,因为您只需移动一个对象的位置,使其不再穿透另一个对象。虽然这很容易计算和实现,但当许多物体相互靠近并相互依靠时,它缺乏稳定性。一对相互渗透的对象的简单解析可以导致与其他附近对象的新渗透。但是,对于简单的引擎或者对象交互规则简单的游戏来说,这仍然是一种常用的方法。例如,在乒乓球比赛中,球永远不会停在球拍或墙上,而是通过反弹与它碰撞的任何物体来持续保持运动。投影方法非常适合解决这些类型的简单对象交互的碰撞。第二种方法被称为脉冲方法,它使用物体速度来计算和应用脉冲,以启动物体在碰撞点向相反方向移动。这种方法倾向于快速减慢碰撞物体的速度,并收敛到相对稳定的解。这是因为冲量是根据动量转移计算的,而动量转移又对碰撞物体的速度产生阻尼效应。第三种方法称为罚函数法,该方法将对象渗透的深度建模为弹簧的压缩程度,并近似加速度以施加力来分离对象。最后一种方法最复杂,也最难实现。
对于您的引擎,您将结合投影和脉冲方法的优势。投影方法将用于分离相互渗透的物体,而脉冲方法将用于施加小脉冲,以降低导致相互渗透的方向上的物体速度。如上所述,简单的投影方法会导致系统不稳定,例如堆叠时物体会相互陷入。您将通过实现一个松弛循环来克服这种不稳定性,在该循环中,通过在单个更新周期中重复应用投影方法来递增地分离相互穿透的对象。对于松弛循环,应用投影方法的次数被称为松弛迭代。在每次松弛迭代过程中,投影法按总渗透深度的固定百分比逐渐减少渗透。例如,默认情况下,引擎将松弛迭代次数设置为 15,在每次松弛迭代期间,穿插减少 80%。这意味着在一次更新函数调用中,在运动积分近似之后,碰撞检测和解决过程将被执行 15 次。虽然成本高,但重复的增量分离确保了正常情况下系统的稳定。然而,当系统经历突然的大变化时,15 次松弛迭代可能是不够的。例如,如果大量明显重叠的对象,例如 100 个重叠的圆,被同时添加到系统中,那么 15 次松弛迭代可能是不够的。这种情况可以通过以性能损失为代价增加松弛迭代来解决。根据我们的经验,在正常工作条件下,15 左右的松弛迭代是精度和性能之间的平衡。
位置校正项目
这个项目将指导你通过松弛迭代的实现来逐步解决物体间的相互渗透。您将使用上一章计算的碰撞信息来校正碰撞对象的位置。你可以在图 4-4 中看到这个项目运行的例子。该项目的源代码在位置校正项目文件夹中定义。
图 4-4。运行位置校正项目
项目目标:
-
理解所计算的碰撞信息的重要性。
-
用松弛迭代实现位置校正。
-
理解并体验实现互穿解决方案。
更新物理引擎
这个项目将只修改 Physics.js,因为这是实现碰撞细节的文件。
-
编辑 Physics.js 并添加以下变量,以支持通过松弛迭代增量校正位置。
//...identical to previous project gEngine.Physics = (function () { var mPositionalCorrectionFlag = true; // number of relaxation iteration var mRelaxationCount = 15; // percentage of separation to project objects var mPosCorrectionRate = 0.8; //... identical to previous project var mPublic = { collision: collision, mPositionalCorrectionFlag: mPositionalCorrectionFlag }; return mPublic; }());
-
修改碰撞函数以在碰撞检测循环上包括封闭松弛迭代循环。
var collision = function () { var i, j, k; for (k = 0; k < mRelaxationCount; k++) { for (i = 0; i < gEngine.Core.mAllObject.length; i++) { //...identical to previous project } } };
-
在 gen engine 中创建新函数。物理学并命名为位置校正。此函数通过预定义的常量 mPosCorrectionRate(默认值为 80%)减少对象之间的重叠。为了在模拟中正确支持对象动量,每个对象的移动量由其相应的质量控制。例如,在两个物体碰撞时,质量较大的物体的移动量通常小于质量较小的物体。请注意,移动方向是沿着碰撞法线,如 collisionInfo 结构中所定义的。
var positionalCorrection = function (s1, s2, collisionInfo) { var s1InvMass = s1.mInvMass; var s2InvMass = s2.mInvMass; var num = collisionInfo.getDepth() / (s1InvMass + s2InvMass) * mPosCorrectionRate; var correctionAmount = collisionInfo.getNormal().scale(num); s1.move(correctionAmount.scale(-s1InvMass)); s2.move(correctionAmount.scale(s2InvMass)); };
-
创建另一个函数,并将其命名为 resolveCollision。该函数接收两个 RigidShape 对象作为参数,并确定检测到的碰撞是否应该进行位置校正。如前所述,质量无限大或质量倒数为零的物体是静止的,碰撞后不会参与位置校正。
var resolveCollision = function (s1, s2, collisionInfo) { if ((s1.mInvMass === 0) && (s2.mInvMass === 0)) return; // correct positions if(gEngine.Physics.mPositionalCorrectionFlag) positionalCorrection(s1, s2, collisionInfo); };
-
最后,当检测到冲突时,应该从碰撞函数中调用新定义的 resolveCollision 函数。调用 drawCollisionInfo 函数后,可以调用 resolveCollision。
var collision = function () { var i, j, k; var collisionInfo = new CollisionInfo(); for (k = 0; k < mRelaxationCount; k++) { //....identical to previous project drawCollisionInfo(collisionInfo, gEngine.Core.mContext); resolveCollision(gEngine.Core.mAllObject[i], gEngine.Core.mAllObject[j], collisionInfo); //... identical to previous project
请注意,drawCollisionInfo 函数是一个绘制操作,严格来说,它不属于 collision 函数中的更新循环。此外,该绘制操作在松弛循环迭代的核心内被调用,这在计算上是昂贵的。幸运的是,这个函数是用于调试目的的,在这个项目之后将被注释掉。
观察
运行项目以测试您的实现。在场景中创建一些对象。请注意,使用 M 键,您可以控制新创建的对象是否重叠。现在,使用 R 键重置场景,然后创建一些对象,然后启用移动。你会注意到有少量的相互渗透发生,如果不去管它,物体可能会沉入场景的底部。选择任意对象,注意不断增加的负 y 速度分量。在每个更新周期中,所有物体的 y 速度都被重力加速度改变,然而位置校正松弛迭代阻止它们向下移动。通过禁用移动,您会注意到重叠完全消失,因为位置校正将不再被抵消。当试图创建一个稳定的系统时,对象的不断增加的 y 速度是一个严重的问题。不断增加/减少数字将导致不稳定和不可预测的行为,正如在物体下沉到底部边界以下时所看到的那样。在接下来的部分中,您将了解进一步提高碰撞分辨率的脉冲方法。
解决冲突
有了正常工作的位置校正系统,您现在可以开始实现碰撞解决,并支持类似于真实世界情况的行为。为了将重点放在碰撞解决系统的核心功能上,包括理解和实现脉冲方法以及确保系统稳定性,您将继续使用与轴对齐的刚性形状。在完全理解并实现线性脉冲分辨率背后的机制之后,我们将在下一节讨论与角度脉冲分辨率相关的复杂性。
在下面的讨论中,矩形和圆形不会因碰撞而旋转。然而,所描述的概念和实现概括为支持旋转碰撞响应。本项目旨在帮助您理解基于冲量的碰撞解决方案的基本概念,以及轴对齐的形状。
制定脉冲方法
你将通过首先回顾在一个完美的世界中,一个圆如何从一面墙和其他圆上弹开来制定脉冲方法的解决方案。这将随后用于导出适当碰撞响应的近似值。注意,下面的讨论集中在推导冲量法的公式上,并不试图对牛顿力学的基本原理进行综述。下面是一些相关术语的简要回顾。
-
质量是物体中物质的数量,或者说物体的密度。
-
力是施加在物体上的任何相互作用或能量,它将改变该物体的运动。
-
相对速度是两个运动形状之间的速度差。
-
恢复系数是碰撞前后相对速度的比值。这是一个衡量物体从一个物体反弹到另一个物体的动能剩余量,或弹性的指标。
-
摩擦系数是一个描述两个物体之间摩擦力比率的数字。在你非常简单的实现中,摩擦力被直接用来减缓线性运动或旋转。
-
冲量是一段时间内积累的力,它可以导致速度的变化,例如,由碰撞引起的速度变化。
分解碰撞中的速度
图 4-5 显示了三个不同阶段的圆 A。在第一阶段,圆圈以速度向右边的墙移动。在第二阶段,圆圈与墙壁相撞。在阶段 3,圆被反射并以速度离开墙壁。
图 4-5。完美世界中圆与墙的碰撞
从数学上讲,这种碰撞及其响应可以通过将初始速度分解成平行于或相切于碰撞壁的分量,以及垂直于或正交于碰撞壁的分量来描述。如下面的等式所示。
在没有摩擦和动能损失的完美世界中,碰撞后,沿切线方向的分量不受影响,而法向分量将简单地反转。这样,反射向量可以表示为的法向分量和切向分量的线性组合,如下所示。
注意组件前面的负号。你可以在图 4-5 中看到,由于碰撞,矢量的分量指向与相反的方向。还要注意,切线分量仍然指向相同的方向,因为它平行于墙的方向,并且不受碰撞的影响。这演示了一个矢量反射。
碰撞形状的相对速度
这种将向量分解为碰撞的法线方向和切线方向的方法也适用于碰撞形状都在运动的一般情况。例如,图 4-6 显示了两个行进的圆形 A 和 B 发生碰撞。
图 4-6。两个圆之间的碰撞
在图 4-6 的情况下,在碰撞之前,形状 A 以速度行进,而形状 B 以速度行进。碰撞的法线方向被定义为两个圆心之间的矢量,而碰撞的切线方向是在碰撞点与两个圆相切的矢量。为了解决这种碰撞,必须计算碰撞后形状 A 和 B 的速度和。
形状 A 和 B 之间的相对速度定义如下。
碰撞矢量分解现在可以应用于相对速度的法线方向,其中碰撞后的相对速度为。
- ①
恢复系数 e 模拟了真实世界的情况,即在碰撞过程中,一些动能转变为其他形式的能量。请注意,等式右侧的所有变量( 1 )都已定义,因为它们在碰撞时是已知的,并且形状 A 和 B 碰撞后相对速度的法向分量也已定义。重要的是要记住,
- 。
你现在已经准备好估算和,碰撞后碰撞形状的速度。
近似脉冲响应
准确描述碰撞涉及复杂的考虑因素,包括能量变化形式或不同材料特性产生的摩擦等因素。如果不考虑这些高级问题,对发生在一个形状上的碰撞的简单描述是,一个恒定质量的物体在与另一个物体接触后,其速度从变为。方便的是,这就是脉冲的定义,如下所示。
或者,在求解时,
从数学上退一步,想想这个公式说明了什么。直觉上讲得通。它指出速度的变化与形状的质量成反比。换句话说,一个形状的质量越大,碰撞后它的速度变化就越小。冲量法实现了这种观察,对于法向分量,它定义了形状 A 和 B 碰撞后的速度,和,如下所示。在这种情况下,mA, m * B * 是形状 A 和 B 的质量
减去上述两个方程计算出相对速度的法向分量。
回想一下,就是简单的,而就是,这个等式简化为如下。
将等式( 1 )代入左侧,可导出以下等式。
收集项,求解公式为 j N ,法线方向的冲量,给你以下。
- (2g)
最后,切线方向上的冲量, j T ,可以以类似的方式导出下面的结果。
- (3)
摩擦系数 f ,是一个简单的摩擦近似值。
解决冲突的步骤
现在,您可以修改 Physics.js 文件中的 resolveCollision 函数来实现两个碰撞形状之间的碰撞解决。解决过程需要访问两个 RigidShape 对象和相应的碰撞信息。以下是涉及的详细步骤:
-
步骤 答:确保至少有一个碰撞的形状不是静态的(质量倒数不等于 0)。
-
步骤 B :调用位置校正功能,以一定百分比的渗透深度将形状分开。回想一下,在您的实现中,默认情况下,碰撞的形状将被推开 80%的穿透深度。
-
步骤 C :计算两个形状之间的相对速度。如推导中所述,相对速度对于计算法向和切线方向的冲量至关重要。
-
步骤 D :计算相对速度在碰撞法线方向的分量。该分量表示两个形状相互靠近或远离的速度。正值表示形状彼此远离,不需要脉冲响应。
-
步骤 E :根据上一步的结果、复原(弹性)和碰撞形状的质量,计算法线方向的冲量。
-
步骤 F :计算切线方向的冲量。
-
步骤 G :应用脉冲来修改形状速度的法向和切向分量,以模拟碰撞和摩擦后两个形状的反射。
在模拟碰撞结果时,冲量的法向分量和切向分量实现了不同的目的。法线组件模拟形状的弹性,而切线组件处理摩擦力。如图 4-7 所示,当球从左侧抛向右侧时,其初始旋转方向将决定与地面碰撞后的运动。在图的左边 4-7 球开始逆时针旋转,而在图的右边球开始顺时针旋转。在与地板碰撞时,由相应摩擦力改变的切线冲量分量将根据球的初始旋转方向降低或增加球的向右线速度。这种特殊的功能将在下一节旋转碰撞响应中实现。但是,请注意,不管物体旋转,在碰撞后,球的高度是相等的。这是由于摩擦只影响切向脉冲分量,而恢复影响法向脉冲分量。
图 4-7。切线分量冲量和摩擦力
碰撞冲动项目
这个项目将指导您实现概述的步骤,以创建一个函数,使用脉冲方法解决轴对齐的形状之间的冲突。你可以在图 4-8 中看到这个项目运行的例子。这个项目的源代码是在碰撞脉冲项目文件夹中定义的。
图 4-8。运行碰撞脉冲项目
项目目标:
-
理解脉冲法计算的细节。
-
建立一个系统来解决碰撞形状之间的碰撞。
修改物理引擎组件
为了正确支持冲突解决,您只需要修改 physics.js 文件来实现前面概述的步骤。
-
打开 Physics.js 文件,转到 resolveCollision 函数。
-
位置校正后,您将通过计算碰撞法线、相对速度、恢复系数和碰撞形状的摩擦力来开始实施。
var resolveCollision = function (s1, s2, collisionInfo) { if ((s1.mInvMass === 0) && (s2.mInvMass === 0)) return; // correct positions if (gEngine.Physics.mPositionalCorrectionFlag) positionalCorrection(s1, s2, collisionInfo); var n = collisionInfo.getNormal(); var v1 = s1.mVelocity; var v2 = s2.mVelocity; var relativeVelocity = v2.subtract(v1) ; // Relative velocity in normal direction var rVelocityInNormal = relativeVelocity.dot(n); // if objects moving apart ignore if (rVelocityInNormal > 0) return; // compute and apply response impulses for each object var newRestituion = Math.min(s1.mRestitution, s2.mRestitution); var newFriction = Math.min(s1.mFriction, s2.mFriction); //... details in the following steps };
-
根据方程式( 2 )计算碰撞法线方向的冲量。
//...continue from the previous step // Calc impulse scalar var jN = -(1 + newRestituion) * rVelocityInNormal; jN = jN / (s1.mInvMass + s2.mInvMass); //... details in the next step
-
将脉冲应用于碰撞图形的速度。
//...continue from the previous step //impulse is in direction of normal ( from s1 to s2) var impulse = n.scale(jN); // impulse = F dt = m * Δv // Δv = impulse / m s1.mVelocity = s1.mVelocity.subtract(impulse.scale(s1.mInvMass)); s2.mVelocity = s2.mVelocity.add(impulse.scale(s2.mInvMass)); //... details in the next step
-
计算与碰撞法线相切的方向。
//... continue from the previous step var tangent = relativeVelocity.subtract(n.scale(relativeVelocity.dot(n))); // relativeVelocity.dot(tangent) should less than 0 tangent = tangent.normalize().scale(-1); //... details in the next step
-
根据方程式( 3 )计算与碰撞法线相切方向的冲量 jT,并将冲量应用于碰撞形状的速度。
//...continue from the previous step var jT = -(1 + newRestituion) * relativeVelocity.dot(tangent) * newFriction; jT = jT / (s1.mInvMass + s2.mInvMass); // friction should be less than force in normal direction if (jT > jN) jT = jN; //impulse is from s1 to s2 (in opposite direction of velocity) impulse = tangent.scale(jT); s1.mVelocity = s1.mVelocity.subtract(impulse.scale(s1.mInvMass)); s2.mVelocity = s2.mVelocity.add(impulse.scale(s2.mInvMass)) ;
在 Mygame.js 中定义初始矩形
为了进行测试,您需要修改 Mygame.js 文件来定义一个初始的矩形 RigidShape 对象。编辑 Mygame.js 并添加以下代码来定义一个质量无限大的静止矩形。
function MyGame() {
//...identical to previous project
var r2 = new Rectangle(new Vec2(200, 400), 400, 20, 0, 1, 0);
//...identical to previous project
}
观察
您应该用两种方式测试您的实现。首先,确保移动的形状碰撞和行为自然。第二,当有许多形状非常接近时,确保碰撞解决系统是稳定的。您还可以测试规则形状和具有无限质量的形状之间的碰撞分辨率。
请注意,场景现在具有类似平台的形状。这是一个具有无限质量的形状,可以测试它与其他规则移动形状的碰撞解决方案。现在确保用逗号(,)键打开移动,用 F 和 G 键创建几个矩形和圆形。请注意,形状逐渐落在地板上,它们的运动停止,并略有反弹。这清楚地表明欧拉积分、碰撞检测和解决方案的基本情况都按预期运行。按 H 键激发所有形状。请注意,漫游的形状与游戏世界的平台和墙壁进行了适当的交互,具有柔和的反弹,没有明显的相互渗透。另外,注意碰撞时能量的表观转移。尝试调整形状属性,例如,质量,并观察当两个质量非常不同的形状碰撞时会发生什么。请注意,质量更大的形状在碰撞后不会改变太多轨迹。最后,请注意,形状不会因为碰撞而旋转。这是因为您当前的实现只考虑了形状的线速度。在下一个项目中,您将改进分辨率函数,以考虑碰撞导致的角速度变化。
可以通过增加场景中形状的数量来测试系统的稳定性。除了脉冲校正之外,松弛循环计数为 15 会在每次迭代过程中持续将互穿形状推开 80%的互穿深度。例如,您可以使用逗号和 M 键关闭移动和位置校正,并在完全相同的位置创建多个(例如 10 到 20 个)重叠的形状。现在,使用 M 键启用位置校正,注意,短暂停顿后,形状将再次出现,没有任何穿插。
支持碰撞响应中的旋转
既然您已经有了具体的理解,并且已经成功地实现了线性速度碰撞响应的脉冲方法,那么是时候集成对更一般的旋转情况的支持了。在讨论细节之前,把牛顿线性力学的相关对应关系与旋转力学的对应关系联系起来是有帮助的。也就是说,线性位移对应于旋转,速度对应于角速度,力对应于扭矩,质量对应于转动惯量。从基础力学来看,转动惯量也被称为角质量。它决定了围绕旋转轴的期望角加速度所需的扭矩。下面的讨论集中在将旋转整合到冲量方法公式中,并不试图呈现关于旋转的牛顿力学的评论。方便地,将适当的旋转整合到脉冲方法中不涉及任何新算法的推导。所需要的只是适当考虑旋转属性的脉冲响应公式。
旋转的牛顿力学积分
将旋转整合到脉冲方法公式中的关键是认识到这样一个事实,即你一直在处理的线速度,例如形状 A 的速度,实际上是该形状在其中心位置的速度。在没有旋转的情况下,该速度在整个形状中是恒定的,并且可以应用于任何位置。然而,如图 4-9 所示,当一个形状的运动包含角速度时,其在位置 P 、处的线速度,实际上是该点与该形状旋转中心的相对位置的函数。
图 4-9。存在旋转时某一位置的线速度
注意
角速度是一个垂直于线速度的矢量。在这种情况下,由于线速度是在 X/Y 平面上定义的,是 z 方向上的矢量,因为物体围绕其质心旋转。为了简单起见,在您的实现中,将被存储为一个简单的标量,表示矢量的 z 分量幅度。
用旋转表述冲量法
类似于线性脉冲响应的情况,碰撞后角速度的变化与转动惯量成反比也是事实。如图 4-10 所示,对于转动惯量为IA和 I B 的形状 A 和 B;以及和的初始角速度;碰撞后,角速度和定义如下。
其中和是从每个形状的旋转中心到碰撞点的位置向量,P;和是碰撞的法线和切线。
图 4-10。两个碰撞形状的角速度
回想一下,冲量法公式是基于将碰撞后的相对速度分解为法线方向和切线方向而得出的。是碰撞前的相对速度,上一节的方程式( 1 重复如下。
注意,这个方程是在考虑旋转之前导出的,公式假设每个形状的速度在整个形状上是恒定的。为了支持旋转,这个方程必须推广,在碰撞点求解, P 。
- (4)
在这种情况下,和是碰撞位置 P 处的相对速度,在碰撞之前和之后,以下对于这些矢量仍然成立。
如前所述,现在可以将下列方程和相对矢量的定义一起代入方程( 4 )并求解脉冲 j 。
虽然繁琐,但简化代数相对直接,由此产生的碰撞法向冲量, j N ,可表示如下。
- (5)
类似于线性响应的情况,切线方向上的脉冲 j T ,可以推导并表示如下。
- (6)
同样,摩擦系数, f ,是一个简单的摩擦近似值。此外,请注意,由于和是 X/Y 平面中的向量,因此在实现中是代表结果向量的 z 分量幅度的标量。
现在,您已经准备好实施支持旋转或角度脉冲的脉冲方法碰撞响应。
角脉冲项目
这个项目将指导你完成角脉冲的实现。你可以在图 4-11 中看到这个项目运行的例子。这个项目的源代码在 Angular Impulse 项目文件夹中定义。
图 4-11。运行角脉冲项目
项目目标:
-
为了理解角冲量的细节
-
将旋转整合到碰撞解决方案中
-
完成物理部分
要实现角冲量,在解决碰撞函数中,只需要修改 Physics.js 文件来实现导出的广义公式。
-
编辑 Physics.js 文件,并转到您在前面的项目中创建的 resolveCollision 函数。
-
计算碰撞位置和的速度很重要。在下面,r1 和 r2 是形状 A 和 b 的和位置向量。注意,在实现中,碰撞位置 P 只是碰撞信息中的 mStart 位置。变量 v1 和 v2 是实际的和向量。
var resolveCollision = function (s1, s2, collisionInfo) { //..identical to previous project var n = collisionInfo.getNormal(); //the direction of collisionInfo is always from s1 to s2 //but the Mass is inversed, so start scale with s2 and end scale with s1 var start = collisionInfo.mStart.scale(s2.mInvMass / (s1.mInvMass + s2.mInvMass)); var end = collisionInfo.mEnd.scale(s1.mInvMass / (s1.mInvMass + s2.mInvMass)); var p = start.add(end); //r is vector from center of shape to collision point var r1 = p.subtract(s1.mCenter); var r2 = p.subtract(s2.mCenter); //newV = V + mAngularVelocity cross R var v1 = s1.mVelocity.add(new Vec2(-1 * s1.mAngularVelocity * r1.y, s1.mAngularVelocity * r1.x)); var v2 = s2.mVelocity.add(new Vec2(-1 * s2.mAngularVelocity * r2.y, s2.mAngularVelocity * r2.x)); var relativeVelocity = v2.subtract(v1); // Relative velocity in normal direction var rVelocityInNormal = relativeVelocity.dot(n); //..details in the next step };
-
下一步是根据方程( 5 )计算碰撞法向冲量 j N 。
//...identical to previous project //...continue from previous step var newFriction = Math.min(s1.mFriction, s2.mFriction); //R cross N var R1crossN = r1.cross(n); var R2crossN = r2.cross(n); // Calc impulse scalar // Reference: http://www.myphysicslab.com/collision.html var jN = -(1 + newRestituion) * rVelocityInNormal; jN = jN / (s1.mInvMass + s2.mInvMass + R1crossN * R1crossN * s1.mInertia + R2crossN * R2crossN * s2.mInertia); //...details in the next step
-
现在,根据引入的冲量法公式更新角速度。
s1.mAngularVelocity -= R1crossN * jN * s1.mInertia; s2.mAngularVelocity += R2crossN * jN * s2.mInertia; //...details in the next step
-
现在,根据方程( 6 ),计算碰撞切线方向的冲量, j T 。
//...identical to previous project //relativeVelocity.dot(tangent) should less than 0 tangent = tangent.normalize().scale(-1); var R1crossT = r1.cross(tangent); var R2crossT = r2.cross(tangent); var jT = -(1 + newRestituion) * relativeVelocity.dot(tangent) * newFriction; jT = jT / (s1.mInvMass + s2.mInvMass + R1crossT * R1crossT * s1.mInertia + R2crossT * R2crossT * s2.mInertia); //...identical to previous project
-
最后,基于切线方向脉冲更新角速度
s1.mAngularVelocity -= R1crossT * jT * s1.mInertia; s2.mAngularVelocity += R2crossT * jT * s2.mInertia;
观察
运行项目以测试您的实现。您插入到场景中的形状现在应该以类似于真实世界的方式旋转、碰撞和响应。当其他形状与圆形碰撞时,圆形会滚动,而矩形在碰撞时会自然旋转。形状之间的相互渗透在正常情况下应该是不可见的。然而,两个原因仍然会导致明显的相互渗透。首先,一个小的松弛迭代,或者第二,你的 CPU 正在与形状的数量作斗争。在第一种情况下,您可以尝试增加松弛迭代来防止任何渗透。现在,您的 2D 物理引擎实现已经完成。您可以通过创建额外的形状来继续测试,以观察您的 CPU 何时开始努力保持实时性能。
摘要
这一章将引导你理解物理引擎背后的基础。一步一步地推导模拟的相关公式,然后详细指导如何构建一个运行系统。您已经计算了形状的运动,解决了碰撞后的相互渗透,实现了基于线性和旋转形状的脉冲方法的解决方案。现在你已经完成了你的物理引擎,你可以将这个系统集成到几乎所有的 2D 游戏引擎中。此外,您可以通过支持其他形状来测试您的实现。您还可以仔细检查系统,并确定优化和进一步抽象的潜力。对物理引擎的许多改进仍然是可能的。*
五、物理引擎总结
恭喜你!您已经学习了背后的基本思想和概念,并完成了 2D 物理引擎的实现。这一章将总结你从第一章到第四章所做的所有工作,你应该从这本书里了解和学到什么,并强调你所创造的物理引擎的改进或未来的探索。
本章首先总结了你在整本书中学到和使用的所有物理引擎理论和概念。接下来,会显示源代码文件的详细列表,以及您编写的相关函数,作为一个简单的“自述”文件。最后,你可以在你的物理引擎中探索和实现的更多主题将作为你未来游戏物理引擎努力的起点。本章还将包括一个简单的项目,作为您的引擎的最终和完整的功能和特性测试。您可以按照项目指南来设置和运行模拟,或者发挥创造力,设置自己的测试用例。
概念和理论
这本书旨在指导你建立自己的物理模拟。因此,介绍的所有主题都与这样一个系统的构建相关。
-
刚性形状 -在物理交互过程中不改变形状的原语。为了支持有效的交互模拟,这些通常是简单的几何形状,例如圆形和矩形。刚性形状有自己的属性,支持物理模拟,如质量、宽度、高度、重心、惯性、摩擦、恢复等。
-
引擎循环 -一个持续运行的循环,更新对象状态,调用对象间交互的计算,并渲染对象。引擎循环必须循环所有操作,并保持实时性能。通过在循环中实现固定的时间步长更新,模拟运动整合和维持确定的游戏状态变得简单明了。
-
碰撞检测 -一种确定物体是否与其他物体重叠和/或相互穿透的算法。
-
宽相位法 -通过利用物体的接近度来优化碰撞检测。引擎使用轴对齐的边界框来减少调用实际碰撞检测算法的开销。
-
分离轴定理-2D 最流行的检测一般凸形之间碰撞的算法之一。通常在此之前先进行一次宽相位方法的初始处理,以提高其整体性能。该算法可以检测轴对齐以及旋转形状之间的碰撞。
-
碰撞信息 -描述碰撞细节的信息,包括穿插深度、导致穿插的法线方向以及穿插的开始和结束。此信息对于解决冲突至关重要。
-
辛欧拉积分——一种基于初值的积分逼近方法。该引擎使用辛欧拉积分来逼近对象的新的线性和旋转速度及其新的位置。
-
位置修正——使用碰撞检测过程中收集的碰撞信息分离两个穿插物体的过程。
-
松弛循环 -物理引擎核心中的一个迭代循环,它重复并递增地对相互渗透的对象应用位置校正,试图消除碰撞对象相互渗透的发生。
-
脉冲法- 一种大大简化的基于物理的碰撞响应公式,能够捕捉碰撞过程中物体的弹性和摩擦因素。
-
碰撞解决 -一个决定物体在碰撞后如何反应的过程。当应用脉冲方法来解决碰撞时,碰撞的对象会获得新的线速度和角速度。
引擎源代码
下面是源代码文件和相关功能的列表。
-
Core.js
-
核心引擎回路
-
更新功能
-
绘图功能
-
用户界面控件
-
-
物理学. js
-
冲突检出
-
松弛循环
-
位置校正
-
解决冲突
-
-
CollisionInfo.js
-
碰撞信息对象
-
构造函数和 getter/setter
-
-
事情 2.js
- 2D 矢量计算
-
刚性形状. js
-
刚性形状的基类
-
构造器
-
更新功能
-
支持宽相位方法的包围盒碰撞测试
-
-
Rectangle.js & Circle.js
-
从刚性形状基类继承
-
每个的特定构造函数
-
旋转功能
-
绘图功能
-
移动功能
-
-
rectangle _ collision . js & Circle _ collision . js
-
碰撞检测功能
-
收集碰撞信息
-
-
使用者控制. js
- 用户输入控制器
-
MyGame.js .我的游戏. js
- 模拟场景控制器
-
Index.html
-
脚本调用
-
初始化模拟场景
-
很酷的演示项目
这个项目指导你设置场景来测试你的物理引擎实现的功能。你可以在图 5-1 中看到这个项目运行的例子。这个项目的源代码在一个很酷的演示项目文件夹中定义。
图 5-1。运行很酷的演示项目
项目目标:
- 测试并使用物理引擎的所有功能和特性
修改模拟场景
让我们从修改模拟场景开始:
-
编辑 MyGame.js 文件。
-
替换 MyGame 构造函数中的所有代码,为模拟创建一个新场景。
"use strict"; /* global height, width, gEngine */ function MyGame() { }
-
在 MyGame 构造函数中,创建四个平台,其中一个旋转以测试角度运动。
//...continue from previous step var r1 = new Rectangle(new Vec2(500, 200), 400, 20, 0, 0.3, 0); r1.rotate(2.8); var r2 = new Rectangle(new Vec2(200, 400), 400, 20, 0, 1, 0.5); var r3 = new Rectangle(new Vec2(100, 200), 200, 20, 0); var r4 = new Rectangle(new Vec2(10, 360), 20, 100, 0, 0, 1); //...more in next step
-
创建 10 个具有随机属性的圆形和矩形对象,以开始模拟。
//...continue from previous step for (var i = 0; i < 10; i++) { var r1 = new Rectangle(new Vec2(Math.random() * gEngine.Core.mWidth, Math.random() * gEngine.Core.mHeight / 2), Math.random() * 50 + 10, Math.random() * 50 + 10, Math.random() * 30, Math.random(), Math.random()); r1.mVelocity = new Vec2(Math.random() * 60 - 30, Math.random() * 60 - 30); r1 = new Circle(new Vec2(Math.random() * gEngine.Core.mWidth, Math.random() * gEngine.Core.mHeight / 2), Math.random() * 20 + 10, Math.random() * 30, Math.random(), Math.random()); r1.mVelocity = new Vec2(Math.random() * 60 - 30, Math.random() * 60 - 30); }
观察
您可以看到场景中没有边界。这允许对象从屏幕上掉落,而不会挤满空间。这样,您可以继续创建新对象,并观察对象行为的模拟。您还可以通过在模拟开始时创建更多对象来测试引擎的性能。请注意,这本书为您提供了创建自己的物理引擎的基本理解。从选择替代算法、支持不同特性到优化计算效率等等,都有很大的改进空间。下一节将指出一些你可以用来改进引擎的话题。
进一步探索和相关主题
现在你的物理引擎完成了,你可能会问自己,现在做什么?我应该如何继续我已经获得的知识,我应该如何处理我创建的物理引擎或者我接下来应该学习什么?最终,正如最常见的情况一样,答案是视情况而定。这首先取决于你对游戏物理引擎的兴趣,以及你为什么决定阅读并跟随这本书。如果您希望从头开始创建游戏或游戏引擎,您可能希望将这个物理引擎集成到您自己的游戏引擎或现有的游戏引擎中,以便将刚体物理功能添加到项目中。如果你的理由更多的是学术性的,目的是学习和理解游戏物理引擎是如何工作的,你可能想进一步探索游戏物理中的相关主题。
无论您属于哪个类别,您都可能希望通过添加更高级的特性或组件来改进物理引擎的性能和功能,从而扩展其功能。如果是这样的话,那么下面的主题为你提供了一些建议,让你在游戏物理学中进一步探索。
物理话题
-
高级 2-D 刚体物理学 -如果您喜欢脉冲方法,并希望通过添加运动学(通常用于移动平台)、关节(用于更复杂的刚体行为)或许多其他优秀功能来改进物理引擎的功能,我们建议您查看 Box2D 物理引擎及其创建者 Erin Catto 的文献。Box2D 是普及了脉冲方法的游戏物理引擎,有几种编程语言版本。http://box2d.org/
-
如果你想模拟软体物理学,那么我们建议你探索一下 Verlet 物理学。Verlet physics 通过使用粒子、约束(弹簧)和 Verlet 集成来构建复杂的软体对象,提供了一种快速简单的方法来模拟软体,如布娃娃、绳子、果冻状的对象甚至布料。特别是,我们建议你看看 Thomas Jakobsen 关于高级角色物理的论文,这可能是对游戏物理感兴趣的人最受欢迎的起点,因为它易于实现和理解。当应用于刚体模拟时,Verlet 物理学的缺点是潜在的不稳定性。
-
网络物理学 -网络物理学的主题包含了它自己独特的一组需要解决的问题,其中许多都围绕着同步。为了让你对这个问题有所了解,我们建议你看看下面的网站。http://gafferongames.com/game-physics/
-
3d 刚体物理学 -如果你对尝试 3d 物理模拟感兴趣,一个很好的起点就是脉冲法!impulse 方法的伟大之处在于,它也可以用于三维物理和二维物理。纽卡斯尔大学提供了一些关于在三维物理中实现 Impulse 方法的重要信息。
research . ncl . AC . uk/game/masters degree/gamte technologies/
碰撞检测主题
-
连续碰撞 -连续碰撞是一种解决物理对象穿过其他太小的物理对象几何体或者以太高的速度行进的方法。由于游戏引擎的离散时间步长特性,这是一个问题。有几种方法可以解决这个问题。Erin Catto 的 GDC(游戏开发者大会)演示是一个很好的开始和了解这个主题的地方。
www . gdcvault . com/play/1018239/Physics-for-Game-Programmers-Continuous
-
碰撞回调——碰撞回调提供了一种更高级、更灵活的碰撞行为。它们可用于自定义物理对象的行为,如 OnCollisionEnter 或 OnCollisionExit。此外,它们还可以用于传递任何游戏逻辑所需的任何碰撞信息。碰撞回调通常是更高级的物理引擎的一个关键特性。
-
GJK 碰撞检测——GJK(Gilbert-Johnson-keer thi)算法是分离轴定理的替代碰撞检测方法。GJK 提供了更大的灵活性,并对多面凸多边形执行碰撞检测。
-
空间分割 -空间分割是一种更先进的宽相位方法,常用于物理引擎,以提高碰撞检测和响应的性能。该方法将世界空间分成离散的区域,以便检测可能的碰撞。在 2D,一种更常用的空间划分技术被称为四叉树。
参考
以下是我们学习这个题目时查阅的一些参考资料。
-
一般定义:
en.wikipedia.org/
-
物理形状和属性:
buildnewgames.com/gamephysics/
-
分离轴定理:
www . metanet software . com/technique/tutoriala . html # section 3
-
旋转碰撞冲量公式:
www.myphysicslab.com/collision.html