面向-JavaScript-游戏动画仿真的物理学教程-全-

龙哥盟 / 2024-10-06 / 原文

面向 JavaScript 游戏动画仿真的物理学教程(全)

原文:Physics for JavaScript Games, Animation, and Simulations

协议:CC BY-NC-SA 4.0

一、物理编程导论

您选择这本书是因为您对在编程项目中实现物理学感兴趣。但是你为什么要这么做呢?它能为你做什么?难度会有多大?本章将为这些问题提供答案。

本章涵盖的主题包括以下内容:

  • 为什么要模拟真实的物理?这一部分将解释为什么你想在你的项目中加入物理学。
  • 什么是物理?这里我们揭开神秘的面纱,用简单的术语解释什么是物理。简而言之,我们也告诉你,在你能写涉及物理的代码之前,你需要知道什么。
  • 编程物理。谢天谢地,一旦你理解了一些基本原理,编程物理并不像你想象的那么难。这一部分解释了你需要做什么。
  • 一个简单的例子。作为一个具体的例子,我们将用最少的代码编写一个简单的物理动画。

为什么要模拟真实的物理?

您可能对使用 JavaScript 建模物理感兴趣有很多原因。以下是一些最常见的:

  • 创建逼真的动画效果
  • 创造真实的游戏
  • 建立模拟和模型
  • 从代码中生成艺术

让我们依次看看每一个。

创建逼真的动画效果

由于 HTML5 canvas 元素,现在可以在不需要 Flash 等插件的情况下创建动画。使用一点 JavaScript 并熟悉一些物理知识,也可以制作出外观和行为都像真实事物的动画。例如,假设您正在制作一个场景,其中有人踢球,球从地面反弹。您可以尝试创建一个模拟球的行为的动画,但是无论您如何努力,它可能看起来都不太真实。只需要一点编码和一些基础物理知识,你就可以制作出更加真实的动画。如果像作者一样,你是程序员而不是设计师,你可能会觉得更容易!我们将在本章末尾的例子中向您展示这有多简单。

创建真实的游戏

基于网络的游戏非常受欢迎。随着现代网络浏览器功能的不断改进,可以开发出更好、更强大的游戏。硬件加速和 3D 支持只是有可能显著改善在线游戏用户体验的新兴发展中的两个。但游戏除了性能和外观,手感和外观的逼真也同样重要。如果球员投出一个球,根据万有引力定律,球应该会落下;如果一名球员在水下发射一枚鱼雷,它的运动方式应该与球在空中的运动方式不同。换句话说,你的游戏需要融入真实的物理。

你如何在游戏中建立物理意识?这本书会告诉你怎么做。

建筑模拟和模型

计算机模拟或计算机模型是一种试图模仿物理系统某些关键方面的程序。模拟在完整性或准确性方面有所不同,这取决于目的和资源。让我们以一个飞行模拟器程序为例。我们希望为训练飞行员设计的飞行模拟器比为游戏设计的更全面、更准确。模拟在电子学习、培训和科学研究中极为常见。在本书的最后一章,你将建立模拟——即一艘潜艇、一个基本的飞行模拟器和一个太阳系模型。事实上,整本书中的许多编码示例都是模拟的,即使通常更简单。

从代码生成艺术

近年来,生成艺术越来越受欢迎。一些基础物理可以带来很多乐趣,例如,可以使用粒子(可以用代码创建和激活的小图形对象)和不同种类的力来产生复杂的视觉效果和运动。这些效果可以包括逼真的动画,如烟和火,以及更抽象的生成艺术示例,这些示例可以通过混合使用算法、随机性和用户交互来创建。在混音中加入一些物理效果可以增强真实感和/或更丰富的效果。

我们将探索生成艺术的世界,并提供额外的工具和算法,可用于创建原始和有趣的效果,如复杂力场中的粒子轨迹。

什么是物理?

物理学是最基本的科学。从广义上讲,物理学是对支配事物行为的自然规律的研究。更具体地说,它关注的是空间、时间和物质(定义为存在于空间和时间中的任何“东西”)。物理学的一个方面是阐明支配物质行为、相互作用及其时空运动的普遍规律。另一个方面是使用这些定律来预测特定事物移动和相互作用的方式——例如,根据重力定律预测日食或根据空气动力学定律预测飞机如何飞行。

物理学是一门庞大的学科,在这种性质的书中,我们只能触及皮毛。幸运的是,你可能需要了解的大部分物理学都属于一个叫做力学的分支,这是最容易理解的分支之一。力学支配着物体运动的方式,以及这种运动是如何受到环境影响的。因为大多数游戏和动画都包含运动,力学显然与开发使对象在代码中表现真实的算法相关。

一切都根据物理定律运行

就物理学家所能观察到的而言,不要过于哲学化,可以公平地说物理定律确实是普遍适用的。这意味着一切都必须按照物理规律运行。这与生物学定律不同,后者只适用于生物。抛向空中的一块石头,绕太阳运行的一颗行星,人体的运转,人造机器的运行和运动,都必须遵守物理定律。此外,许多看似不同的现象是由相同的法律子集管理的。换句话说,一个或一组定律可以解释物理世界中多种观察到的事实或行为模式。例如,一块落下的石头和一颗绕太阳运行的行星都遵守万有引力定律。另一个例子是,所有的电、磁和辐射现象(如光和无线电波)都受电磁定律支配。

这些定律可以写成数学方程式

最棒的是物理定律可以写成数学方程式。好吧,如果你不喜欢数学,这听起来可能不太好!但这里的要点是,要使法律有用,它必须精确。数学方程是最精确的。与在法庭上争论不休的法律相比,在如何应用一条用数学表达的法律方面没有可能的模糊性!第二,这意味着几个世纪以来数学的发展被证明适用于物理学,使许多物理学问题的解决成为可能。第三,也是与我们最相关的一点:数学方程很容易转换成代码。

预测运动

让我们说得更具体些。作为一名 JavaScript 程序员,您最感兴趣的是事物如何运动。物理学的大部分内容是关于事物在不同类型的影响下如何运动的。这些“影响”可以来自其他事物,也可以来自环境。例子包括重力、摩擦力和空气阻力。在物理学中,我们对这些影响有一个特殊的名称:它们被称为力。真正的好消息是这些力有精确的数学形式。虽然物体的运动通常很复杂,但描述力的基本数学定律通常很简单。

力和运动之间的一般关系可以用符号表示如下:

运动=功能{力}

这里使用单词 function 并不是为了表示实际的代码功能。相反,它旨在强调两件事。首先,它意味着一种因果关系。力使物体以不同的方式运动。其次,它还指出了力和运动在代码中的算法关系,即物体的运动可以被视为以力为输入的函数的输出。用实际的话来说就是这样的:指定作用在物体上的力,并把它们放入一个数学方程中,然后你就可以计算出物体的运动。

Note

运动就是效果。力是原因。物体的运动是力作用于其上的结果。力和运动之间的数学关系被称为“运动定律”

为了能够应用本笔记中所述的原理,您需要了解以下内容:

  • 定义。运动和力的精确定义。
  • 运动定律。换句话说,将力与它产生的运动联系起来的函数的精确数学形式。
  • 强制法。换句话说,如何计算力。有公式告诉你如何计算每一种力。

所以有两种定律你需要知道:运动定律和力定律。你还需要知道正确的概念(称为物理量)来描述和分析运动和力以及它们之间的关系。最后,你需要知道处理和组合这些量的数学方法。我们将在第三章中讲述相关的数学,在第四章中讲述基本的物理概念,在第五章中讲述运动定律,在第六章–第十章中讲述各种类型力的力定律。

编程物理学

那么,你如何编写物理代码呢?你是对运动编程,还是对力编程,或者两者都编程?它包括什么?

一旦你知道了一些基本的物理知识(和一些相关的数学知识),只要你用正确的方法去做,编写代码并不会比你作为一个程序员所习惯的有太大的不同或者更难。让我们花一些时间来解释这个“正确的方法”是什么,通过描述模拟真实物理所涉及的内容,以及如何通过涉及数学方程、算法和代码的步骤来完成。

动画和模拟的区别

某个聪明人曾经说过“一幅画胜过千言万语”之类的话。你可以把它延伸为“一部电影抵得上一千张照片”一部电影(或动画)比一幅静态图像增加了我们更多的感知,因为它包含了时间变化的元素,一个额外的维度。但是有一种感觉,动画仍然是静态的,而不是动态的。不管你放多少遍,动画的开头和结尾都是一样的。一切都以完全相同的方式发生。虽然我们可能会看到从书面文字到视觉图像到动画电影的真实世界的进步,但仍然缺少一些东西:与媒体互动的能力,以及以复制现实生活中事物行为的方式影响结果的能力。下一步我们称之为模拟。当我们在本书中使用这个词时,模拟意味着真实性和交互性。当你模拟某样东西时,你不只是描绘它在一组条件下的行为;你允许许多,甚至无限多的情况。构建包括物理在内的交互式模拟使事物表现得像在真实世界中一样:与环境和用户交互以产生多样而复杂的结果。

还有更多。如果你真的很注意准确性,你甚至可以建立一个如此逼真的模拟,可以用作虚拟实验室。你可以在你的电脑上用它进行实验,了解现实世界中的事情是如何运作的!事实上,您将在本书中构建这样的模拟。

物理定律是简单的方程式

我们已经说过,物理定律是数学方程式。好消息是,你将遇到的大多数定律(以及方程)实际上都很简单。显然坏消息是这些定律会产生非常复杂的运动。事实上,这可能也是一件好事;否则宇宙将会是一个相当无聊的地方。

例如,支配重力的定律可以写成两个简单的方程(它们在第六章中给出)。但是它们负责月球绕地球的运动,行星绕太阳的运动,以及星系中恒星的运动。所有这些运动的净效应,加上不同天体之间的引力相互作用,将产生非常复杂的运动,这些运动仅由两个方程产生。

方程式可以很容易地编码!

我们现在可以回答本节开始时提出的前两个问题了。运动和力的定律很简单;它们产生的实际运动是复杂的。如果你知道定律,你可以计算不同条件下的运动。因此,对定律和力进行编码比对它们产生的运动进行编码更有意义。

动画试图直接再现物体的运动。模拟对运动规律进行编程,然后推导出物体的运动。对运动的原因进行编码要比对其结果进行编码容易得多。此外,一个动画通常描述一个单一的场景。但是模拟可以处理无限多种不同的场景。

Note

简单的运动定律和简单的力定律可以产生复杂的运动。因此,一般来说,编写法律比编写动议更容易。因此,矛盾的是,模拟可能比动画更容易。

模拟就像扮演上帝。你重新创造了一个虚拟世界,不是通过盲目复制你看到的所有行为,而是通过复制支配事物行为方式的规律,然后让这一切发生。

物理编程的四个步骤

为了回答我们在本节开始时提出的第三个问题,编程物理的过程可以分解为四个步骤,如图 1-1 所示。

A978-1-4302-6338-8_1_Fig1_HTML.jpg

图 1-1。

Steps in programming physics

第一步是确定适用于您正在建模的情况的物理原理。如果你没有物理学背景,这可能会很棘手。这本书会对你有所帮助:它不仅仅是一本操作指南,也是为了教你一些物理知识。第二步是回忆、研究或推导相关方程。显然,这一步涉及到一些数学。不用担心;我们会给你所有你需要的帮助!第三步是开发求解方程的算法。有时方程可以解析求解(我们将在后面的章节中解释这意味着什么),在这种情况下算法非常简单。更常见的情况是,人们需要使用数值方法,这些方法可能简单,也可能不那么简单,这取决于问题和所需的精度水平。虽然前两步似乎显而易见,但第三步往往被忽视。事实上,许多开发人员甚至没有意识到它的存在或必要性。同样,我们会在这方面花些时间,尤其是在本书的第四部分。第四步,也是最后一步,用你最喜欢的编程语言编写代码。你已经很擅长这个了,不是吗?

一个简单的物理模拟例子

为了了解图 1-1 中描述的过程在实践中是如何工作的,我们现在来看一个简单的例子。我们将用几行代码模拟一个球被抛向地面的运动。

首先,让我们描绘一下我们试图建模的场景,它在现实中的行为方式。假设你把一个排球抛向空中。它是如何移动的?你可能已经注意到这样一个球不是直线运动,而是沿着一条曲线运动。此外,球似乎在曲线的顶部移动缓慢,而在底部靠近地面的地方移动迅速。当它落地时,通常会反弹,但总是比它落下的高度低。在我们试图重现这种运动之前,让我们更仔细地研究一下引起这种运动的物理学。

弹跳球的物理学

正如你现在已经知道的,力是导致物体运动的原因。因此,理解排球为什么以这种方式运动的第一条线索是找出是什么力量在作用于它。稍后你会了解到,在日常情况下,通常有许多力一起作用在物体上。但在这种情况下,有一种力量比其他任何力量都重要得多。这是地球施加在球上的重力。

所以让我们假设一旦球被抛向空中,重力是唯一作用在球上的力。谢天谢地,重力以一种简单的方式起作用。靠近地球表面,如本例所示,它是一个垂直向下的恒力。因此,它的作用是将物体向下拉,使它们加速。加速?是的,这意味着它增加了物体的速度。正如我们将在后面的章节中更详细地讨论的那样,重力每秒钟以恒定的量增加一个物体的垂直速度。但是因为重力向下作用,所以不影响物体的水平速度。

球每落地一次,后者就对其施加一个接触力(接触力是两个固体物体直接接触时相互施加的力)。这个力向上作用的时间很短。与重力不同,直接对这种接触力建模并不容易。因此,我们将简化事情并对其效果建模。它的作用是在降低球的速度的同时,将球的运动从向下逆转到向上。

在 2D 编码一个弹跳球

为了简化场景和生成的代码,我们将假设我们生活在一个 2D 世界中。2D 中的物体可以沿着两个独立的方向移动:水平和垂直。我们将用两个数字来表示球在任意给定时间的位置,xy,其中x表示水平位置,y表示垂直位置。我们将球沿这两个方向运动的速度记为vxvy

根据我们所说的,时钟每滴答一次,重力就会导致vy增加一个恒定的量,但是vx会保持不变。

因为vxvy是速度,它们告诉我们时钟每次滴答时物体移动了多少。换句话说,在时钟的每一个滴答声中,x增加了一个量vx,而y增加了一个量vy

这实现了重力的效果。要实现地面的效果,我们要做的就是把vy的符号反过来,在每次球落地的时候减小它的大小。信不信由你,差不多就是这样了。

终于有代码了!

图 1-2 中所示示例的 JavaScript 代码包含在bouncing-ball.js文件中,该文件可以在apress.com与书中的所有其他源代码一起下载。

A978-1-4302-6338-8_1_Fig2_HTML.jpg

图 1-2。

The bouncing ball created by this example

Here is the code that does it all:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var radius = 20;

var color = "#0000ff";

var g = 0.1; // acceleration due to gravity

var x = 50;  // initial horizontal position

var y = 50;  // initial vertical position

var vx = 2;  // initial horizontal speed

var vy = 0;  // initial vertical speed

window.onload = init;

function init() {

setInterval(onEachStep, 1000/60); // 60 fps

};

function onEachStep() {

vy += g; // gravity increases the vertical speed

x += vx; // horizontal speed increases horizontal position

y += vy; // vertical speed increases vertical position

if (y > canvas.height - radius){ // if ball hits the ground

y = canvas.height - radius; // reposition it at the ground

vy *= -0.8; // then reverse and reduce its vertical speed

}

if (x > canvas.width + radius){ // if ball goes beyond canvas

x = -radius; // wrap it around

}

drawBall(); // draw the ball

};

function drawBall() {

with (context){

clearRect(0, 0, canvas.width, canvas.height);

fillStyle = color;

beginPath();

arc(x, y, radius, 0, 2*Math.PI, true);

closePath();

fill();

};

};

我们将在下一章全面解释 JavaScript 代码的所有元素以及它嵌入的 HTML5 标记。包含物理的重要行是那些旁边有注释的行。变量g是重力加速度。这里我们已经设置了一个值,这个值将使动画看起来更真实。接下来的几行设置了球的初始水平和垂直位置以及速度。所有的物理动作都发生在名副其实的函数onEachTimestep()中,它以电影设定的帧速率执行。这里我们增加了vy,但没有增加vx,因为重力只垂直作用。然后我们通过将xy分别增加vxvy来更新球的位置。后续代码负责球的反弹,并在球离开画布时回收球。函数drawBall()在每个时间步擦除并重新绘制球,函数的内部结构将在下一章与其余代码一起变得清晰。

运行代码并查看结果。看起来很逼真,不是吗?这么少的指令球怎么知道怎么表现?这就像魔术一样。我们挑战你在没有物理的情况下创造同样的效果!

真的那么容易吗?等等!我们仅仅触及了可能性的表面。有很多方法可以改进模拟,使其更加真实,但它们需要更多的物理和更多的编码。例如,你可以增加摩擦力,这样当球沿着地面移动时,它的水平速度会降低。假设你正在构建一个游戏,其中包含了移动的球。你可能想让球感受空气阻力的影响,除了在重力的作用下移动之外,还想让球被风吹动。如果把它扔进水中,下沉然后上升,在静止和漂浮之前在水面上振荡,你可能希望它表现正常。可能会有很多球相撞。或者,您可能希望创建一个精确的模拟,让学校学生可以用来了解重力。在这种情况下,您需要特别注意实现适当的边界效果以及精确稳定的时间步进算法。当你读完这本书时,你将能够做到所有这些,甚至更多。你会知道你在做什么。我们保证。

摘要

物理学以数学形式概括了自然法则。这些定律很简单,很容易编码。因此,创建看起来真实的效果通常很容易。

编程物理包括四个步骤:确定你需要什么物理原理,写下相关方程,设计求解方程的数值算法,编写代码。因此,它涉及四个不同领域的知识和技能:物理、数学、数值方法和编程。这本书在前三个方面给你帮助;假设你已经精通第四部分:JavaScript 通用编程。

话虽如此,下一章将提供 JavaScript 和 HTML5 中选定主题的快速概述,强调与物理编程特别相关的方面。

二、JavaScript 和 HTML5 画布基础

本章简要回顾了 JavaScript 和 HTML5 的元素,我们将在本书的其余部分充分利用这些元素。它并不意味着是一个全面的 JavaScript 教程;相反,它总结了理解书中的代码示例需要知道的内容。本章的另一个目的是涵盖 HTML5 canvas 元素和 JavaScript 的相关方面,它们将设置应用物理的上下文。

本章是在假设读者至少具备 HTML 和 JavaScript 的基础知识的情况下编写的。如果你是一个有经验的 JavaScript 程序员,你可以安全地跳过这一章的大部分内容,也许可以浏览一下 canvas 元素末尾的一些内容,并用代码制作动画。另一方面,如果你以前没有用 JavaScript 做过任何编程,我们建议你拿起最后的总结中提到的一本书。如果你已经用另一种语言编程,你将会从详细阅读这一章中受益。虽然概述本身不会让你成为一名熟练的 JavaScript 程序员,但它应该能让你毫无困难地使用和构建书中的代码示例。

本章涵盖的主题包括以下内容:

  • HTML5 和 canvas: HTML5 是 HTML 的最新标准,为 web 浏览器带来了激动人心的新特性。对于我们的目的来说,最重要的附加元素是 canvas 元素,它支持图形和动画的渲染,而不需要外部插件。
  • JavaScript 对象:对象是 JavaScript 的基本构件。现实世界中的“事物”在 JavaScript 中可以表示为对象。对象有属性。他们也可以用方法做事。
  • JavaScript 语言基础:为了完整起见,我们回顾了 JavaScript 的基本结构及其语法,如变量、数据类型、数组、运算符、函数、数学、逻辑和循环。
  • 事件和用户交互:我们简要回顾一些基本概念和语法,举例说明如何使事情发生以响应程序或用户交互的变化。
  • 画布坐标系:这相当于画布世界中的空间。可以使用画布元素的 2D 呈现上下文将对象定位在画布元素上。我们回顾了画布坐标系和数学中常用的笛卡尔坐标系之间的区别。
  • canvas drawing API:仅使用代码绘制事物的能力是一个强大的工具,尤其是在与数学和物理相结合时。画布绘制应用编程接口(API)的一些最常见的方法,将在整本书中使用,在这里简单回顾一下。
  • 使用代码制作动画:我们回顾了使用代码制作动画的不同方法,并在本书的其余部分解释了我们将用于基于物理的动画的主要方法。

HTML5、canvas 元素和 JavaScript

HTML5 是 HTML 标准的最新体现,它为 web 浏览器带来了许多新功能。作为一名使用 JavaScript 的准物理程序员,我们将只介绍利用最重要的动画特性 canvas 元素所需了解的最基本的知识。

最小的 HTML5 文档

出于本书的目的,你需要对 HTML5 了解得出奇的少。这是一个最小的 HTML5 文档的例子。假设你熟悉基本的 HTML 标记,大部分应该是有意义的。注意与早期 HTML 版本相比,doctype声明的形式非常简单。

<!doctype html>

<html>

<head>

<meta charset="utf-8">

<title>A minimal HTML5 document</title>

</head>

<body>

<h1>Hello HTML5!</h1>

</body>

</html>

我们将在本书中使用的 HTML5 文档不会比这个复杂太多!本质上,我们将添加一些标签来包含画布元素、CSS 样式和 JavaScript 代码。

画布元素

HTML5 规范中最令人兴奋的新增内容之一是 canvas 元素,它支持在 web 浏览器中呈现图形,从而呈现动画,而不需要外部插件,如 Flash Player。向 HTML5 文档添加 canvas 元素再简单不过了。只需在文档的正文部分包含以下行:

<canvas id="canvas" width="700" height="500"></canvas>

这将生成一个指定维度的 canvas 实例,可以通过其指定的 ID 在文档对象模型(DOM)中访问该实例。

您可以像处理任何常规 HTML 元素一样处理画布的样式。在示例canvas-example.html(源文件可以从 http://apress.com 网站下载)中,我们通过在 head 部分插入以下代码链接了一个名为style.css的 CSS 文件:

<link rel="stylesheet" href="style.css">

如果你查看文件style.css,你会发现我们为 body 部分和 canvas 元素选择了不同的背景颜色,这样我们就可以在前者的背景下更好地看到后者。

没有什么可以阻止你在一个 HTML5 文档中添加多个 canvas 元素。您甚至可以重叠不同的画布实例。这种技术可以证明对某些目的非常有用,例如在固定背景下渲染快速移动的动画。文件canvas-overlap.html显示了一个简单的例子,文件style1.css指定了两个画布实例所需的 CSS 定位代码(见下一节中的图 2-1 )。

A978-1-4302-6338-8_2_Fig1_HTML.jpg

图 2-1。

Top: Two overlapping canvas elements. Bottom: JavaScript console in the Chrome browser

添加 JavaScript

有两种方法可以将 JavaScript 添加到 HTML5 文档中:将代码嵌入 HTML 文件本身的<script></script>标签中,或者链接到包含 JavaScript 代码的外部文件。在本书中,我们将采用后一种做法。让我们再来看看上一章中的弹跳球的例子。以下是该示例的完整 HTML 文件(bouncing-ball.html):

<!doctype html>

<html>

<head>

<meta charset="utf-8">

<title>Bouncing ball</title>

<link rel="stylesheet" href="style.css">

</head>

<body>

<canvas id="canvas" width="700" height="500"></canvas>

<script src= "bouncing-ball.js"></script>

</body>

</html>

请注意脚本主体部分中链接到文件bouncing-ball.js的代码行,其中包含 JavaScript 代码。这一行正好放在结束 body 标记的末尾之前,这样 DOM 就有机会在脚本执行之前完全加载。你已经看过第一章的剧本了。

JavaScript 调试控制台

现代浏览器为调试 JavaScript 代码提供了一个非常有用的工具,称为控制台。了解如何使用控制台的最好方法是进行实验。要在 Chrome 浏览器中启动控制台,请使用以下键盘快捷键:Control-Shift-J (Win/Linux)或 Command-Option-J (Mac)。

您可以在控制台的命令行中直接键入 JavaScript 代码,然后按 Enter 键对其进行评估(参见图 2-1 )。尝试以下方法:

2 + 3

console.log("I can do JavaScript");

a=2; b=3; console.log(a*b);

JavaScript 对象

如果您已经用面向对象编程(OOP)语言(如 C++、Java 或 ActionScript 3.0)进行了编程,那么您已经接触过作为对象所基于的基本结构的类。然而,JavaScript 是一种无类语言,尽管它有 OOP 能力。在 JavaScript 中,对象本身是基本单位。

那么什么是对象,它们为什么有用?对象是一个相当抽象的实体。所以在我们定义它之前,让我们用一个例子来解释它。假设您想要在项目中创建粒子。这些粒子将具有某些特性,并且能够执行某些功能。您可以定义一个具有这些属性和功能的通用 JavaScript 对象(称为粒子)。然后每次你需要一个粒子,你可以创建一个粒子对象的实例。以下部分描述了如何做这些事情。

对象和属性

我们可以从刚才给出的例子中进行归纳,将 JavaScript 中的对象定义为属性的集合。属性又可以被定义为名称和值之间的关联。构成财产价值的范围相当广泛;它还可以包括函数,见下一节。这使得对象非常通用。

除了现有的 JavaScript 对象,您可以随意创建具有自定义属性的自定义对象。预定义对象的例子包括String, Array, DateMath对象(我们将在本章后面讨论)。要创建一个新的对象,可以使用两种不同形式的语法

obj = new Object();

或者

obj = {};

这两种方法都会创建一个Object的实例。结果对象obj没有属性。为了赋予属性和相应的值,以及随后访问这些属性,我们使用点符号:

obj.name = "First object";

obj.length = 20;

console.log(obj.name,obj.length);

另一种语法是括号符号:

obj["name"] = "First object";

obj["length"] = 20;

功能和方法

我们已经看到了如何给对象分配属性,但是我们如何让一个对象做一些事情呢?这就是函数的用武之地。函数是调用函数名时执行的代码块。函数定义的一般语法如下:

function functionName(){

code block

}

或者,函数可以携带任意数量的变量或参数:

function functionName(arg1, arg2){

code block

}

它们可以使用 return 语句返回值,例如:

function multiply(x,y){

return x*y;

}

在本例中,multiply(2,3)将返回值 6。

回到对象,我们将方法定义为函数对象的属性。因此,方法允许对象做一些事情。方法的定义方式与函数相同,但还需要被指定为对象的属性。这可以通过多种方式实现。一种语法是这样的:

objectName.methodName = functionName;

例如,要将multiply()函数指定为obj对象的属性,我们可以输入

obj.multiply = multiply;

函数multiply现在是obj的方法(我们可以使用不同的方法名),然后obj.multiply(2,3)将返回 6。在下一节中,当我们看构造函数时,我们会遇到其他方法来给对象赋值。

原型、构造函数和继承

OOP 中的一个重要概念是继承,它允许你从一个现有的对象构建一个新的对象。然后,新对象继承旧对象的属性和方法。在基于类的语言中,继承适用于类——这就是所谓的经典继承。在 JavaScript 中,对象直接从其他对象继承——这是通过称为原型的内部对象来实现的。因此,JavaScript 中的继承是基于原型的。

原型实际上是任何函数的属性。函数也是对象,因此具有属性。归属于函数原型的属性被从函数对象构造的新对象自动继承。用于构造新对象的函数对象因此被称为构造函数。构造函数没有什么特别的——任何函数都可以用作构造函数。但是有一个普遍的惯例,用以大写字母开头的函数名来表示构造函数。

下面的示例展示了实际使用的语法:

function Particle(pname){

this.name = pname;

this.move = function(){

console.log(this.name + " is moving");

};

}

这段代码创建了一个带有属性name和方法move()的构造函数Particle。关键字this确保这些属性在构造函数之外是可访问的。然后,new关键字可以创建Particle对象的任何实例,并且它会自动继承这些属性,如下例所示:

particle1 = new Particle("electron");

particle1.name; //``returns

particle1.move(); // returns "electron is moving"

要向父对象添加新属性,以便该对象的所有实例都可以继承这些属性,您需要将这些属性分配给父对象的原型。例如,要向Particle对象添加一个新属性mass和一个新方法stop(),我们可以键入:

Particle.prototype.mass = 1;

Particle.prototype.stop = function(){console.log("I have stopped");};

然后,Particle的所有实例都可以使用它们,甚至包括之前实例化的实例,例如:

particle1.mass; // returns 1

注意,particle1.mass的值此后可以独立于从Particle.prototype.mass继承的默认值而改变,例如:

particle1.mass = 2;      // returns 2

Particle.prototype.mass; // returns 1;

其他属性可以添加到实例中,当然不会传播到父对象或其他实例。例如,这一行:

particle1.spin = 0;

将名为spin的新属性添加到particle1中,并赋予其值 0。默认情况下,Particle的其他实例没有该属性。

静态属性和方法

在上一节的例子中,假设我们将一个新属性直接分配给Particle(而不是它的原型),例如:

Particle.lifetime = 100;

该语句创建了一个静态属性Particle,无需实例化对象即可访问该属性。另一方面,Particle的实例不继承静态属性。

自然,也可以定义静态方法。例如,假设在一个名为Physics的对象中有下面的static方法:

function calcGravity(mass,g) {

return(mass*g);

}

Physics.calcGravity = calcGravity;

然后函数Physics.calcGravity(4, 9.8)会给你地球上一个 4 公斤物体的重力。

Math对象是具有静态属性和方法的内置 JavaScript 对象的一个例子,比如Math.PIMath.sin()

示例:球对象

作为上几节讨论的原则的一个例子,文件ball.js包含创建一个Ball对象的代码:

function Ball (radius, color) {

this.radius = radius;

this.color  = color;

this.x      = 0;

this.y      = 0;

this.vx     = 0;

this.vy     = 0;

}

Ball.prototype.draw = function (context) {

context.fillStyle = this.color;

context.beginPath();

context.arc(this.x, this.y, this.radius, 0, 2*Math.PI, true);

context.closePath();

context.fill();

};

注意,Ball对象被赋予了六个属性和一个方法。绘图代码已经放在了Ball.draw()方法中,并带有一个强制参数,即要在其上绘制球的画布上下文。

文件ball-object.js提供了一个从Ball对象创建球实例的简单例子:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var ball = new Ball(50,'#0000ff');

ball.x = 100;

ball.y = 100;

ball.draw(context);

我们将在整本书中广泛使用这个Ball对象,并对其进行各种修改。作为一个例子,文件bouncing-ball-object.js是对第一章的的弹跳球模拟的修改,以利用Ball对象——请看!

JavaScript 框架、库和 API

如果你接触过 JavaScript,你可能知道有很多库和框架,比如 jQuery 和 MooTools。它们的优势在于为通常需要的任务提供了一组核心功能。但是,每个都有自己的学习曲线;因此,我们通常不会在本书中使用现有的库或框架(值得注意的例外是当我们在第一章第五章探索 3D 时)。相反,当我们继续阅读各个章节时,我们将从头开始创建一个小型的数学和物理相关对象库。

同样,大量的 JavaScript APIs 为 web 浏览器带来了扩展的功能。特别值得注意的是 WebGL API,它使用 HTML5 canvas 元素来提供 3D 图形功能。WebGL 基于 OpenGL ES 2.0,包括在计算机的 GPU(图形处理单元)上执行的着色器代码。WebGL 编码超出了本书的范围。然而,在第一章 5 中,我们将利用一个 JavaScript 库,它将极大地简化结合 WebGL 创建 3D 动画的任务。

JavaScript 语言基础

在这一节中,我们回顾 JavaScript 语言中的基本代码元素。特别强调它们与数学和物理的相关性。

变量

变量是保存一些数据的容器。在这里,数据可能意味着不同的东西,包括数字和文本。使用var关键字定义或声明变量:

var x;

随后,x可能被赋予某个值。例如:

x = 2;

这种赋值可以与下面的变量声明一起完成,也可以在代码中的任何地方完成:

var x = 2;

也可以对x进行算术运算;例如,以下代码将x乘以一个数字,将结果添加到另一个变量y,并将结果赋给第三个变量z:

z = 2*x + y;

这类似于代数,但有一些显著的区别。第一个区别纯粹是语法问题:我们使用运算符*将 2 和x相乘。很快会有更多关于运营商的内容。

第二个区别更微妙,它与任务的意义有关。虽然前面的代码表面上看起来像一个代数方程,但重要的是要注意,赋值不是一个方程。通过考虑这样一个任务,可以突出这种差异:

x = x + 1;

如果这是一个代数方程,这将意味着 0 = 1,这是不可能的!这里,它的意思是我们把x(不管它是什么)的值加 1。

JavaScript 中的变量可以有数值以外的值。变量可以保存的值类型称为其数据类型。

数据类型

JavaScript 中的变量具有动态数据类型。这意味着它们可以在不同的时间保存不同的数据类型。JavaScript 中的数据类型可以分为两类:基本的和非基本的。原始数据类型有Number, String, Boolean, UndefinedNull(后两者有时称为特殊数据类型);非原始数据类型包括Object, ArrayFunction(都是对象类型)。表 2-1 列出了所有这些数据类型。变量的数据类型可以由typeof操作符决定。

表 2-1。

Data Types in JavaScript

| 数据类型 | 描述 | | --- | --- | | `Number` | 64 位双精度浮点数 | | `String` | 16 位字符序列 | | `Boolean` | 有两个可能的值:`true`和`false`,或者`1`和`0` | | `Undefined` | 为不存在的对象属性或没有值的变量返回 | | `Null` | 只有一个值:null | | `Object` | 保存属性和方法的集合 | | `Array` | 由任何类型的数据列表组成的对象 | | `Function` | 执行代码块的可调用对象 |

民数记

与许多其他编程语言不同,JavaScript 中只有一种数值数据类型:Number。例如,整数和浮点数之间没有区别。

根据 IEEE 754 规范,Number类型是双精度 64 位浮点数。它能够存储正实数和负实数(不仅是整数,也包括分数)。Number能存储的最大值是 1.8 × 10 308 。鉴于可见宇宙中的原子数量估计“只有”10 80 ,即使对于最大的科学计算来说,这也应该足够了!它还允许小至 5×10–324的数字。

Number数据类型还包括以下特殊值:NaN(非数字)、Infinity–Infinity. NaN表示尚未赋值的数值。你会得到NaN作为一个产生非真实或未定义结果的数学运算的结果(例如,取-1 的平方根或将 0 除以 0)。无穷大是一个非零数除以 0 的结果。根据被零除的数的符号,你将得到正无穷大或负无穷大。

用线串

一个String是一组字符。例如,以下内容

var str = "Hello there!";

console.log(str);

会给出这样的输出:"Hello there!"

注意,String的值必须用引号括起来(单引号或双引号)。双引号可以包含在用单引号括起来的字符串中,反之亦然。

布尔运算

一个Boolean只能有两个值之一:truefalse。例如:

var bln = false;

请注意,值truefalse没有用引号括起来;它不是一个字符串。必须特别小心,因为 JavaScript 变量是动态类型的。因此,如果bln后来被赋值如下:

bln = "true";

因为有引号,它将变成一个字符串变量!

未定义和空

Undefined数据类型只有一个值:undefined。不存在的属性或已声明但未赋值的变量假定值为 undefined。没有 return 语句的函数返回 undefined。函数的未提供的参数也假定一个未定义的值。

Null数据类型也只有一个值:null。例如,nullundefined的一个重要区别是null被有意地赋给了一个变量

var noVal = null;

对具有空值的变量使用typeof操作符揭示了一种Object类型,而不是Undefined类型或Null类型。

对象、函数和数组

我们在本章前面已经遇到过对象和函数。就像函数一样,数组是特定类型的对象。数组是保存项目集合的对象。假设您必须跟踪动画中的大量粒子。为此,您可以将它们分别命名为 particle1、particle2、particle3 等。如果你有几个粒子,这可能很好,但如果你有 100 或 10,000 个粒子呢?这就是数组派上用场的地方。例如,你可以定义一个名为particles的数组,把所有的粒子放在里面。

创建数组的一个简单方法是将数组元素指定为用方括号括起来的逗号分隔列表:

var arr = new Array();

arr = [2, 4, 6];

arr[1]; // gives 4

如前面的代码片段所示,生成的数组元素然后由arr[ n ]访问,其中n是一个称为数组索引的无符号整数。注意,数组索引从 0 开始,所以第一个数组元素是arr[0]。数组元素也可以单独赋值,例如,创建第四个数组元素并为其赋值 8:

arr[3] = 8;

还有其他几种创建数组的方法。数组和数组元素的操作也有很多规则。我们很快就会遇到这样的例子。

您也可以通过创建元素也是数组的数组来创建多维数组。下面的示例从两个一维数组创建一个二维数组:

var xArr = new Array();

var yArr = new Array();

xArr     = [1,2];

yArr     = [3,4];

var zArr = new Array(xArr,yArr);

zArr[0][1]; // gives 2

zArr[1][0]; // gives 3

注意,我们以不同的方式创建了第三个数组,直接将数组元素作为参数传递给Array()

可以将不同类型的数据添加到同一数组中。这是因为 JavaScript 中的数组不是类型化的,不像 C++和 Java 等其他语言。

运算符

您可以使用常见的运算符(+-*/)对数字进行基本的算术运算,以实现数字的加、减、乘和除。

还有许多其他不太明显的运算符。模运算符%给出一个数被另一个数除时的余数。递增运算符(++)将数字的值增加 1,递减运算符(--)将数字的值减少 1。

var x = 5;

var y = 3;

x%y; // gives 2

var z;

z = x++; // assigns the value of x to z, then increments x

console.log(z); // gives 5

z = ++x // increments the value of x, then assigns it to z

console.log(z); //gives 7

运算符也可以与赋值结合使用。例如:

var a = 1;

a = a + 1;

console.log(a); // gives 2

a += 1;         // shortened form of a = a + 1

console.log(a); // gives 3

a = 4*a;

console.log(a); // gives 12

a *= 4;         // shortened form of a = a*4

console.log(a); // gives 48

数学

除了上一节描述的基本操作符,Math对象包含更多的数学函数。

表 2-2 给出了Math功能的一些常见示例以及它们的作用。在下一章中,你会遇到更多的Math方法,比如三角函数、指数函数和对数函数。

表 2-2。

Math Methods

| 方法 | 它返回什么 | | --- | --- | | `Math.abs(a)` | a 的绝对值 | | `Math.pow(a,b)` | a 的 b 次方 | | `Math.sqrt(a)` | a 的平方根 | | `Math.ceil(a)` | 大于的最小整数 | | `Math.floor(a)` | 小于的最大整数 | | `Math.round(a)` | 最接近 a 的整数 | | `Math.max(a,b,c,...)` | a、b、c 中最大的…… | | `Math.min(a,b,c,...)` | 最小的 a,b,c,… | | `Math.random()` | 伪随机数 n,其中 0 <= n < 1 |

最后一种方法Math.random(),是一种有趣的方法。它生成一个介于 0 和 1 之间的随机数,包括 0 但不包括 1。严格地说,这个数字是伪随机的,因为生成它遵循一种算法。但是对于您可能使用它的大多数目的来说,它已经足够好了。

这里有一个如何使用Math.random()方法的例子。在bouncing-ball-random.js中,我们做了一个简单的修改,这样每次动画运行时,球都有不同的初速度。我们通过如下初始化水平和垂直速度来实现:

vx = Math.random()*5;

vy = (Math.random()-0.5)*4;

第一行将初始水平速度设置在 0 到 5 之间。第二行将垂直速度设置在–2 和 2 之间。负垂直速度意味着什么?它意味着与 y 增加方向相反的速度。因为在画布坐标系中,y 随着我们向下移动而增加(我们将在本章后面看到),负垂直速度意味着对象向上移动。所以每次你重新加载页面,你会看到球最初以不同的水平和垂直速度向上或向下移动。

逻辑

在任何编程语言中,逻辑都是编码的重要组成部分。逻辑使代码能够根据某个表达式的结果采取不同的操作。

在 JavaScript 中实现逻辑的最简单的方法是通过一个基本的if语句,其结构如下:

if (``logical expression

do this code

}

一个if语句主要检查一个逻辑表达式是否为真。例如,在bouncing-ball.js代码中,有以下逻辑:

if (y > canvas.height - radius){

y = canvas.height - radius;

vy *= -0.8;

}

这将测试球的垂直位置是否低于地板水平,如果是,将球准确地重新定位在地板水平,然后将其垂直速度乘以–0.8。在这个例子中,要测试的逻辑表达式是y > canvas.height – radius,而>是一个逻辑运算符,意思是“大于”。

其他常用的逻辑运算符还有<(小于)、==(等于)、<=(小于等于)、>=(大于等于)、!=(不等于)。还有一个严格的等式运算符===,它与等式运算符==的不同之处在于,它在比较两个变量时会考虑数据类型。

必须注意不要混淆等式运算符==和赋值运算符=。这是错误和随之而来的调试挫折的常见来源!

还有&& (and)和|| (or)运算符,使您能够组合条件:

if (a < 10 || b < 20){

c = a+b;

}

if语句有更复杂的形式。if else语句的形式如下:

if``(logical expression)

do this if expression is true

} else {

do this if expression is false

}

您还可以使用if else if ... else语句来检查不同的可能性:

if (a == 0){

do this if a is zero

} else if (a < 0 ) {

do this if a is negative

} else if (a > 0) {

do this if a is positive

} else {

do this if a is NaN

}

其他逻辑结构包括开关和三元条件运算符,但我们不会在本书中使用它们。

尝试这个练习:修改bouncing-ball-random.js代码来回收球,这样当它在右边界消失时,它会在初始位置重新开始,但速度是随机的。答案在bouncing-ball-recycled.js里。

就像逻辑一样,循环是编程的基本要素。使计算机有用的一个原因是它们能够一遍又一遍地重复操作,比人类快得多,而且从不感到厌烦。他们通过循环来实现。

在 JavaScript 中有几种循环。我们将在这里回顾其中的几个。

for循环是我们最常用的一个。下面是一个fo r 循环的例子,用于对前 100 个正整数求和:

var sum = 0;

for (var i = 1; i <= 100; i++) {

sum += i;

}

console.log(sum);

第一行将变量sum的值初始化为 0。下一行设置了循环——变量i是一个计数器,设置为从 1 开始(您可以从 0 或任何其他整数开始),一直到 100(包括 100 ),并被告知在每一步增加 1 ( i++)。因此该循环执行 100 次,每次都将i的当前值加到sum中。

对数组元素进行循环是一种特别有用的技术。假设您想要制作五个弹跳球的动画,而不是一个。要了解如何做,请看一下bouncing-balls.js中的代码,它建立在bouncing-ball-object.js中的代码之上。

主要思想是修改init()函数,这样我们创建一堆球,给每个球一个位置和速度,并使用push()方法将它们放入一个名为balls的数组中:

function init() {

balls = new Array();

for (var i=0; i<numBalls; i++){

var ball = new Ball(radius,color);

ball.x = 50;

ball.y = 75;

ball.vx = Math.random()*5;

ball.vy = (Math.random()-0.5)*4;

ball.draw(context);

balls.push(ball);

}

setInterval(onEachStep, 1000/60); // 60 fps

};

自然地,事件处理程序(参见本章后面的“事件监听器和处理程序”一节)也被修改为循环所有的球:

function onEachStep() {

context.clearRect(0, 0, canvas.width, canvas.height);

for (var i=0; i<numBalls; i++){

var ball = balls[i];

ball.vy += g;

ball.x += ball.vx;

ball.y += ball.vy;

if (ball.y > canvas.height - radius){

ball.y = canvas.height - radius;

ball.vy *= -0.8;

}

if (ball.x > canvas.width + radius){

ball.x = -radius;

}

ball.draw(context);

}

};

不用担心球相遇时只是互相穿过。那是因为你的代码还不知道碰撞检测!我们将在后面的章节中解决这个问题。

注意,为了使用一个for循环,你需要准确地知道你想要循环多少次。如果你没有,或者如果你有不连续的数组键,那么还有其他的选择,比如for ... infor each ... inwhile循环。我们不会描述前两个,因为我们在本书中不会用到它们。

在一个while循环中,你告诉循环只要某个条件为真就执行,不管循环需要多少次。while回路的基本结构如下:

while (``some condition

do something

}

例如,假设您想知道从 1 开始必须求和的连续整数的最小数目,以获得至少 1000。这里有一个while循环来实现这一点:

var sum = 0;

var i = 1;

while (sum < 1000) {

sum += i;

i++;

}

console.log(i-1);

您也可以像使用for循环一样使用while循环来执行固定次数的运算,例如,对前 100 个正整数求和:

var sum = 0;

var i = 1;

while (i <= 100) {

sum += i;

i++;

}

console.log(sum);

小心while循环——如果条件总是为真,你将结束一个无限循环,代码将永远不会停止执行!

一种变化是do ... while循环,在循环之后而不是之前检查条件。这确保循环中的代码至少执行一次:

do {

do something

} while (``some condition

事件和用户交互

事件允许给定的动作过程被不同的动作过程所替代。用户交互,例如通过键盘或鼠标,产生特殊的事件类型。事件和用户交互在很大程度上使交互媒体变得有趣。JavaScript 可用于对 HTML DOM 事件做出反应。

事件侦听器和处理程序

事件管理有两个方面:跟踪事件和响应事件。事件侦听器“侦听”事件,事件处理程序采取适当的操作。侦听器是 HTML DOM 元素。将特定 DOM 元素设置为特定事件的侦听器的语法如下:

someElement.addEventListener(event_type, handler [, useCapture]);

可以指定为event_type的不同类型的事件将在下一节讨论。这里的handler只是一个函数,每当发生类型为event_type的事件时就会被调用。第三个参数useCapture,通常是可选的;然而,在一些较旧的浏览器实现中却不是这样。因此,在本书中,我们将始终将其指定为假。useCapture的值决定了事件如何在 DOM 树中冒泡,这里不需要我们关心。

您也可以用完全相同的方式删除事件监听器,用removeEventListener替换addEventListener:

someElement.removeEventListener(event_type, handler [, useCapture]);

用户交互:键盘、鼠标和触摸事件

我们通常感兴趣的事件是键盘、鼠标和触摸事件。这些类型的事件非常棒,因为它们允许用户与动画或模拟进行交互。我们没有足够的空间来回顾所有不同类型的事件,将仅仅提供一些例子来解释用法。读者可以参考本章末尾提到的书籍以获得更详细的信息。举个简单的例子,假设我们想在用户点击并按住鼠标时暂停弹跳球动画,并在松开鼠标时继续播放。这可以通过使用'mousedown''mouseup'事件轻松实现。只需修改init()方法如下:

function init() {

canvas.addEventListener('mousedown',stopAnim,false);

canvas.addEventListener('mouseup',startAnim,false);

startAnim();

};

并包括这些事件处理程序:

function startAnim() {

interval = setInterval(onEachStep, 1000/60); // 60 fps

}

function stopAnim() {

clearInterval(interval);

}

代码在bouncing-ball-pause.js里。

拖放

人们通常希望在交互式动画中拖动和移动对象。一个简单的技巧是,当鼠标按在对象上并四处移动时,强制对象的位置与鼠标光标的位置相匹配。为了说明该方法,我们将修改bouncing-ball-object.js代码,以便现在您可以单击球,将其移动到舞台上的任何位置,然后再次释放它。

为此,请进行以下更改。首先,将以下代码块添加到init()函数中:

canvas.addEventListener('mousedown', function () {

canvas.addEventListener('mousemove',onDrag,false);

canvas.addEventListener('mouseup',onDrop,false);

}, false);

这就建立了一个'mousedown'事件监听器,它又建立了'mousemove''mouseup'监听器。然后添加以下事件处理程序:

function onDrag(evt){

isDragging = true;

ball.x = evt.clientX;

ball.y = evt.clientY;

}

function onDrop(){

isDragging = false;

canvas.removeEventListener('mousemove',onDrag,false);

canvas.removeEventListener('mouseup',onDrop,false);

}

clientXclientY属性提供了一种简单的方法来跟踪鼠标的位置。必须在代码开头声明isDragging Boolean变量并将其设置为false:

var isDragging = false;

顾名思义,isDragging告诉我们对象是否被拖动。这需要在拖动发生时停止代码的物理部分的执行。因此,我们将物理代码包装在函数onEachStep的下面的if块中:

if (isDragging==false){

execute physics code

}

您还需要将vxvy的初始值设置为零,ball.y的初始值设置为canvas.height – radius(这样它最初在地面上是静止的),并将ball.x设置为任何合适的值,以便它在舞台上可见。修改后的代码在bouncing-ball-drag-drop.js中。试试看。您会注意到几个奇怪的地方——首先,即使您在球外单击,球也会移动到鼠标位置;第二,球的中心跳到鼠标位置。当你在下一章学习如何计算两点之间的距离时,你将能够解决这些问题。

画布坐标系

在现实世界中,事物存在于空间中。在 HTML5 世界中,等价的是对象存在于 canvas 元素上。要知道如何在画布上定位对象,有必要了解画布坐标系。

画布坐标系与数学中常见的笛卡尔坐标系有些不同。在普通坐标几何中,x 坐标从左到右,y 坐标从下到上(见图 2-2b )。然而,在 canvas 中,y 坐标以相反的方式运行,从上到下(见图 2-2a )。原点在可视舞台的左上角。

A978-1-4302-6338-8_2_Fig2_HTML.jpg

图 2-2。

2D coordinate systems compared: (a) in canvas and (b) math

通常的笛卡尔系统被称为右手坐标系,因为如果你握住右手,手指部分闭合,拇指伸出纸面,手指将从正 x 轴指向正 y 轴。言下之意,canvas 中的坐标系是左手坐标系。

画布坐标系中的另一个奇怪之处是,角度是从正 x 轴方向顺时针测量的(见图 2-3a )。数学上的通常惯例是从正 x 轴逆时针测量角度(见图 2-3b )。

A978-1-4302-6338-8_2_Fig3_HTML.jpg

图 2-3。

Angles as measured in (a) canvas and (b) math coordinate systems

画布绘制 API

画布绘制应用编程接口(API)允许您使用 JavaScript 绘制形状和填充等内容。画布绘制 API 通过相对较少的方法提供了丰富的功能。为了便于说明,我们将只讨论其中的几个。

画布背景

允许访问画布绘制 API 的对象是画布呈现上下文。API 只是该对象的属性和方法的集合。第一章的中bouncing-ball.js的前两行代码显示了如何访问画布上下文:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

canvas 元素的getContext方法中的字符串'2d'是不言自明的:HTML5 标准指定了一个 2D 绘图 API,所有现代浏览器都支持该 API。

canvas context 有许多属性和方法——我们不会列出所有的属性和方法,我们只挑选几个来说明如何完成某些常见的绘图任务。

画直线和曲线

以下是使用直线和曲线绘制基本形状的画布上下文的一些基本属性和方法:

  • 属性指定 CSS 样式格式的线条颜色。默认值为'#000000' (black)
  • lineWidth属性以像素为单位指定线条粗细。默认值为 1。
  • beginPath()方法重置当前路径。路径是子路径的集合。每个子路径是由直线或曲线连接的一组点。
  • closePath()方法关闭当前子路径,并从关闭的子路径的末尾开始一个新的子路径。
  • moveTo(x, y)方法将光标移动到指定的位置(x, y)而不绘制任何东西,也就是说,它从指定的点创建一个新的子路径。
  • lineTo(x, y)方法从当前位置到其参数中指定的新位置(x, y)画一条直线,也就是说,它向子路径添加一个新点,并用直线将该点连接到子路径中的前一个点。
  • arc(x, y, radius, startAngle, endAngle, anticlockwise)方法向路径添加一个以(x, y)为中心的指定半径的圆弧。开始和结束角度以弧度为单位(参见第三章)。逆时针参数是一个boolean:如果true,逆时针方向画圆弧;如果为 false,则以顺时针方向绘制。
  • rect(x, y, w, h)方法创建一个新的封闭的矩形子路径,其左上角位于(x, y),宽度为w,高度为h
  • stroke()方法使用当前的笔画样式呈现当前的子路径。
  • strokeRect(x, y, w, h)方法结合了最后两种方法来呈现指定矩形的轮廓。

举个简单的例子,要画一条从点(50,100)到(250,400)的蓝色 2 像素直线,您应该这样做:

context.strokeStyle = '#0000ff';

context.lineWidth = 2;

context.beginPath() ;

context.moveTo(50, 100);

context.lineTo(250, 400);

context.stroke();

注意,如果没有调用stroke()方法,那么将不会呈现任何内容,并且路径将不可见!

作为练习,试着用这些方法画一个网格。参见drawing-api-grid.js中的代码。

创建填充和渐变

借助以下命令,生成填充是一个简单的过程:

  • 属性获取或设置填充形状的样式。可以是颜色,也可以是渐变。
  • fill()方法使用当前填充样式填充子路径。
  • fillRect(x, y, w, h)方法使用当前填充样式创建一个填充的矩形,其左上角位于(x, y),宽度为w,高度为h

以下代码片段生成一个带有蓝色边框的绿色矩形:

context.strokeStyle = '#0000ff';

context.lineWidth = 2;

context.beginPath() ;

context.moveTo(50, 50);

context.lineTo(150, 50);

context.lineTo(150, 200);

context.lineTo(50, 200);

context.lineTo(50, 50);

context.stroke();

context.fillStyle = '#00ff00';

context.fill();

这一行额外的代码将生成一个没有边框的绿色矩形:

context.fillRect(250,50,150,100);

您可以使用以下附加方法来创建渐变:

  • createLinearGradient(x0, y0, x1, y1)方法创建一个线性渐变对象,其中(x0, y0)是渐变的起点,(x1, y1)是渐变的终点。
  • createRadialGradient(x0, y0, r0, x1, y1, r1)方法创建一个径向渐变对象,其中(x0, y0)r0是起始圆的圆心和半径,(x1, y1)r1是渐变结束圆的圆心和半径。
  • 方法在画布渐变对象中添加指定的颜色和偏移位置。偏移量是一个介于 0 和 1 之间的十进制数,其中 0 和 1 表示渐变的起点和终点。

以下示例创建了一个具有径向渐变的球,背景是使用线性渐变表示的“天空”(见图 2-4 )。

A978-1-4302-6338-8_2_Fig4_HTML.jpg

图 2-4。

A linear gradient and a radial gradient produced using the canvas drawing API

gradient = context.createLinearGradient(0,0,0,500);

gradient.addColorStop(0,'ffffff');

gradient.addColorStop(1,'0000ff');

context.fillStyle = gradient;

context.fillRect(0,0,700,500);

gradient1 = context.createRadialGradient(350,250,5,350,250,50);

gradient1.addColorStop(0,'ffffff');

gradient1.addColorStop(1,'ff0000');

context.fillStyle = gradient1;

context.arc(350,250,50,0,2*Math.PI,true);

context.fill();

使用画布上下文制作动画

到目前为止,我们一直在画静态的图形——但是我们如何在画布上制作动画呢?方法非常简单——简单地删除所有内容,然后一遍又一遍地重画!这可以使用clearRect()方法来完成:clearRect(x, y, w, h)方法清除一个矩形内画布上下文上的像素,该矩形的左上角位于(x, y),宽度为w,高度为h

下面一行代码清除了 canvas 元素的全部内容:

context.clearRect(0,0,canvas.width,canvas.height);

在每个时间步之前重复应用clearRect()会创建一个空的画布背景来绘制。下一节将描述如何进行时间步进。

使用代码制作动画

使用代码制作动画是本书的主题之一。此外,由于我们的重点是制作基于物理的动画,我们需要一种方法来衡量时间的前进。我们需要一个钟。我们已经介绍了setInterval()函数,它在某种程度上完成了这项任务。因此,让我们先来看看这个函数和相关的函数。

使用 JavaScript 计时器

JavaScript 中制作动画的“古老”经典方式涉及到定时器函数的使用,有几种:setTimeout()setInterval()。到目前为止,我们在示例中使用的是后者。

  • setTimeout(func,timeDelay)功能将在timeDelay(毫秒)的延迟后执行一次指定的功能func()
  • setInterval(func,timeDelay)功能将在连续延迟timeDelay(毫秒)后重复执行指定的功能func()
  • 相应的函数clearTimeout(timerId)clearInterval(timerId)分别清除setTimeout()setInterval()定时器,其中timerId是它们被分配的变量。

下面演示了使用这些函数的一般语法:

intervalID = setInterval(func,timeDelay);

function timeDelay(){

some code

}

clearInterval(intervalId);

我们已经在各种版本的弹跳球模拟中遇到了setInterval()的使用。在timer-example.js文件中给出了一个使用setInterval制作动画的简单例子。

我们可以在setInterval()函数中指定时间延迟为 1000/fps,其中 fps 是动画的帧率,即每秒的更新次数或帧数。

帧速率与动画的感知速度有什么关系?要回答这个问题,我们需要做一些简单的数学计算。假设我们要以每秒 100 像素的恒定速度移动一个物体,假设动画的帧率为 50 fps。让我们每帧增加对象的水平位置vx,如反弹球示例所示:

function onEachStep(){

ball.x += vx;

}

换句话说,vx是以每帧像素为单位的水平速度。我们必须给vx什么值?嗯,每秒像素单位的速度是 100,每秒有 50 帧。所以vx的值是 100/50 或者 2。一般来说,我们有以下关系:

(每秒像素速度)=(每帧像素速度)×(每秒帧速率)

好的,如果我们设置vx = 2,我们应该看到球以每秒 100 像素的速度移动。每秒像素的速度是我们在屏幕上实际感知的速度。但是,不能保证影片运行的帧速率就是所设置的帧速率。假设你的机器很慢或者有其他东西在上面运行,这样实际帧率更接近 30 fps。这给出的实际速度仅为每秒 60 像素。您的对象似乎移动得更慢了。因此,在setInterval()功能中设置帧速率并不能保证动画的速度。我们将很快研究如何解决这个问题。但是首先我们将介绍另一种更近的使用 JavaScript 制作动画的方法——requestAnimationFrame()方法。

使用 requestAnimationFrame()

近年来,web 浏览器中出现了一种新的 API,允许开发人员创建受益于基于浏览器的优化的 HTML5 动画,与旧的setInterval()setTimeout()方法相比,性能有了显著提高。

函数requestAnimationFrame(someFunction)在重绘浏览器屏幕前调用函数someFunction()。一些浏览器实现还包括第二个参数来指定重绘应用的 HTML5 元素,例如requestAnimationFrame(someFunction, canvas)

要使用requestAnimationFrame()创建动画循环,您只需将它包含在它调用的函数中!例如:

function animFrame(){

requestAnimationFrame(animFrame,canvas);

onEachStep();

}

onEachStep()函数是包含动画代码的函数。例如,timer-example.js代码已被修改为使用requestAnimationFrame(),结果代码在frame-example.js中给出。这是一个重要的例子,因为我们将使用其中的基本代码设置作为本书其余部分中大多数动画的基础。因此,我们在此复制完整的代码以供快速参考:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var ball;

window.onload = init;

function init() {

ball = new Ball(20,"#0000ff");

ball.x = 50; ball.y = 250;

ball.vx = 2;

ball.draw(context);

animFrame();

};

function animFrame(){

requestAnimationFrame(animFrame,canvas);

onEachStep();

};

function onEachStep() {

ball.x += ball.vx;

context.clearRect(0, 0, canvas.width, canvas.height);

ball.draw(context);

};

尽管requestAnimationFrame()功能的性能优于setInterval()setTimeout(),但它受到没有内置方法来控制帧速率的限制。约束帧速率的一个简单技巧是将requestAnimationFrame()函数嵌套在setTimeout()函数中。在前面的例子中,我们可以将animFrame()函数修改如下:

function animFrame(){

setTimeout(function() {

requestAnimationFrame(animFrame,canvas);

onEachStep();

}, 1000/60);

}

这当然不能保证帧速率正好是 60 fps。其中一个主要问题是,计时器事件之间的时间间隔实际上包括在指定延迟之上执行事件处理程序中所有代码所需的时间。如果您的事件处理程序中有大量代码,这可能意味着您的计时器计时速度比您指定的慢得多。

使用 getTime()计算运行时间

不守时真的会搞糟你的物理。对于真正精确的计时,我们需要的是一种测量实际运行时间的方法。幸运的是,有一个简单的方法可以做到这一点:使用getTime()函数。并且该方法可以与setInterval()requestAnimationFrame()一起使用。

getTime()函数是内置 JavaScript Date对象的一个方法,它返回一个整数,该整数等于自 1970 年 1 月 1 日午夜以来经过的毫秒数。因此,如果你在代码的不同部分调用Date.getTime()两次,并计算出返回值的差异,就会得到这两次调用之间经过的时间。

这对我们制作动画有什么帮助?关键是我们可以计算出自从一个物体的位置被最后一次更新以来已经过去的实际时间。然后我们可以用这段时间来计算它的移动量。

为了看到这一点,getTime-example.js修改了frame-example.js代码来制作一个以恒定水平速度移动的球的运动动画vx。下面是修改后的事件处理程序:

function onEachStep() {

var t1 = new Date().getTime(); // current time in milliseconds

dt = 0.001*(t1-t0); // time elapsed in seconds since last call

t0 = t1; // reset t0

ball.x += ball.vx * dt;

context.clearRect(0, 0, canvas.width, canvas.height);

ball.draw(context);

};

我们在这里添加了三行。第一行通过调用Date().getTime()获得以毫秒为单位的当前时间。第二行计算出从最后一次调用onEachStep()以来经过的时间dt(这个符号将在下一章中变得清晰)。这里的t0是在动画开始前初始化为new Date().getTime()的变量。下一行复位t0,以便可以用于下一次调用。

您还会看到我们修改了更新球的位置的代码。我们现在向球的当前位置添加一个数量vx*dt,而不是像以前一样添加vx。这是怎么回事?这就是计算运行时间dt的全部意义。你看,之前我们将速度vx解释为每帧(如果使用requestAnimationFrame)或每节拍(如果使用setInterval)移动的像素。这样做时,我们假设帧或节拍具有固定的持续时间,而该持续时间正是我们在帧速率或计时器延迟参数中指定的时间。只要这些假设成立,我们就可以使用帧或计时器刻度作为时间的良好代理,用每帧或计时器刻度的像素来考虑速度是一个好主意。但是我们要说的是:让我们回到以正确的方式思考速度,以每秒移动的像素为单位。因此,在dt秒内,移动的距离是vx*dt,所以新位置是这样的:

ball.x += vx*dt;

回到速度的真正含义的优点是,运动总是被正确地计算出来,与帧速率或定时器滴答速率无关。当我们开始研究更复杂的物理时,这项技术会派上用场。但是,即使是这个简单的例子,您也可以通过改变ball.vx的值来看到动画如何反映真实的物理,并看到球如何以每秒像素的指定速度准确移动。

下面是 get Time-example.as的完整代码:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var ball;

var t;

window.onload = init;

function init() {

ball = new Ball(20,"#0000ff");

ball.x = 50; ball.y = 250;

ball.vx = 200;

ball.draw(context);

t = new Date().getTime(); // initialize value of t

animFrame();

};

function animFrame(){

requestAnimationFrame(animFrame,canvas);

onEachStep();

};

function onEachStep() {

var dt = (new Date().getTime() - t)/1000; // time elapsed in seconds since last call

t = new Date().getTime(); // reset t

ball.x += ball.vx * dt;

context.clearRect(0, 0, canvas.width, canvas.height);

ball.draw(context);

};

预计运动

正如你现在所知道的,我们使用的用代码制作对象动画的方法是通过“动态”计算对象位置的更新来实现的。也有可能预先计算一个物体的运动,然后制作动画。这可以通过使用forwhile循环来表示时间步进,计算每一步的粒子位置,并将位置坐标保存在一个数组中来实现。

你为什么要这么做?例如,如果计算需要很长时间才能完成,并且无法在合理的帧速率内完成,那么它就非常有用。

这种方法的缺点是它不支持交互性。因为用户交互通常是基于物理的应用的一个重要方面,所以我们一般不会使用这种方法。

摘要

哇哦!这一章是对 JavaScript 和 HTML5 画布的旋风之旅。希望您现在已经理解了它们对于基于物理的动画是如何有用的。

如果你对本章的任何内容都感到困惑,我们强烈建议你重温 JavaScript 和 HTML5 的知识。以下是我们特别推荐给初学者的几本书:

  • 基础 HTML5 画布:游戏和娱乐,作者 Rob Hawkes(出版社,ISBN: 978-1430232919)。
  • Billy Lamberta 和 Keith Peters 的基础 HTML5 动画和 JavaScript(Apress,ISBN: 978-1430236658)。

三、一些数学背景

即使是最简单的物理编程也不可避免地涉及到一些数学。因此,我们假设这本书的读者对数学符号很熟悉,并且至少有一些数学知识。这种知识不需要非常复杂;熟悉基本代数和简单的方程和公式的代数运算是最重要的要求,无需复习。此外,对坐标几何以及三角学和向量的一些基本知识的理解将提供一个良好的基础。如果您需要,本章提供了这些主题的回顾或复习。微积分的先验知识是额外的,但不是必需的;数值方法也是如此。我们在足以在物理编程中应用它们的水平上提供这些主题中涉及的基本概念的概述。

本章涵盖的主题包括以下内容:

  • 坐标和简单的图形:数学函数和它们的图形在物理应用中不断出现。我们回顾一些常见的数学函数,并使用自定义的 JavaScript 图形绘制对象绘制它们的图形。我们还展示了如何让一个物体沿着数学方程描述的任何曲线移动。
  • 基本三角学:一般来说,三角学在图形和动画中经常出现,并且是基于物理的动画中不可或缺的工具。在这一节中,我们将复习一些将在后面章节中用到的三角学基础知识。我们还将使用诸如 sin 和 cos 之类的触发函数来制作一些很酷的动画效果。
  • 向量和向量代数:向量很有用,因为使用向量可以更简单地表达、操作和编码物理方程。在回顾了 vector 概念之后,我们将构建一个 JavaScript Vector2D对象,该对象将在本书的其余部分中使用。
  • 简单的微积分思想:微积分处理的是不断变化的事物,包括运动。因此,它是应用于物理学的自然工具。微积分通常被认为是高等数学的一部分。因此,我们将在这里只给出基本概念的概述,以显示它们如何在物理方程和代码中实现。

尽管这一章包含了复习资料,但大部分内容都涵盖了数学在 JavaScript 和物理学中的应用。因此,即使你有扎实的数学背景,我们建议你至少浏览一下这一章,看看我们是如何应用数学的。

为了说明抽象的数学概念在物理学中的应用,我们挑选了一些物理概念的例子,这些例子将在后面的章节中更全面地解释。因此,如果您没有立即获得我们将在这里介绍的所有内容,请不要担心;你可以随时根据需要回到这一章。

坐标和简单图形

坐标几何提供了一种以数学函数或方程式来可视化关系的方法。这一节将回顾一些你在学校数学课上已经学过的东西,但是混合了大量的 JavaScript 代码来强调这些数学概念的应用。

首先,让我们记住如何在图上绘制函数。假设你想画出函数 y = x 2 的图形。你必须做的第一件事是决定你要绘制的数值范围。假设您希望 x 的范围从–4 到 4。那么你可能在学校里做过的就是用 y = x 2 把 x 的值和相应的 y 的值列表。然后,您可能将每个(x,y)值对绘制成一个点,然后将这些点连接起来,形成一条平滑的曲线。不用担心;我们不会要求你在这里这样做。与其伸手去拿绘图纸,不如用 JavaScript 来做吧!

构建绘图仪:图形对象

Graph对象是我们创建的一个定制对象,正如它的名字所暗示的那样:绘制图形。欢迎您查看代码,但是它做的事情非常简单:它有使用绘图 API 绘制一组轴、主网格线和次网格线的方法。它还有一个绘制数据的方法。Graph对象在其构造函数中有 9 个参数:

Graph(context,xmin, xmax, ymin, ymax, x0, y0, xwidth, ywidth)

第一个参数是画布上下文,在其上绘制任何Graph对象的实例。接下来的四个参数(xminxmaxyminymax)表示 x 和 y 的最小和最大期望值。接下来的两个参数(x0y0)表示原点在画布坐标系中的坐标,以像素为单位。最后两个参数以像素为单位指定图形对象的宽度和高度。

函数drawgrid()采用四个数字参数,指定主要和次要划分:

drawgrid(xmajor, xminor, ymajor, yminor)

它绘制相应的网格线,并在相关的主要网格位置标注值。

函数drawaxes()采用两个可选参数,将轴上的文本标签指定为字符串。它绘制轴并给它们加标签,默认标签是"x""y"

drawaxes(xlabel, ylabel)

一个例子将使用法变得清楚。假设我们想要绘制函数 y = x 2 在 x 的指定值范围内,从–4 到 4。那么 y 值的相应范围将是从 0 到 16。这告诉我们,图形应该适应 x 的正值和负值,但只需要 y 的正值。如果可用区域是 550×400 像素,放置原点的最佳位置应该是(275,380)。让我们将图形的宽度和高度分别选择为 450 和 350,这样它可以占据大部分可用空间。我们将 x 和 y ( xminxmaxyminymax)中的范围设置为(–4,4,0,20)。(xmajorxminorymajoryminor)的合理选择是(1、0.2、5 和 1):

var graph = new Graph(context, -4, 4, 0, 20, 275, 380, 450, 350);

graph.drawgrid(1, 0.2, 5, 1);

graph.drawaxes('x','y');

你可以在graph-example.js中找到代码,从这本书的下载页面上 http://apress.com 。继续,自己试试,改参数看看效果。这并不难,你很快就会掌握它的窍门。

使用图形对象绘制函数

到目前为止,我们所做的是制作一张相当于图表的纸,上面标有轴。为了绘制图表,我们使用了Graph对象的plot()方法。此方法绘制成对的 x 和 y 值,并选择性地用指定颜色的线将它们连接起来:

public function plot(x, y, color, dots, line)

xy的值在前两个参数中被指定为单独的数组。第三个可选参数color是一个表示绘图颜色的字符串。它的默认值是“#0000ff”,代表蓝色。最后两个参数dotsline,是可选的布尔参数。如果dotstrue(默认),在每个点用指定的颜色画一个小圆(半径 1 像素)。如果linetrue(默认),这些点由一条相同颜色的线连接。如果您查看代码,您会看到点是使用arc()方法绘制的,线是使用lineTo()方法绘制的。

现在让我们画出 y = x 2 的曲线图:

var xvals = new Array(-4,-3,-2,-1,0,1,2,3,4);

var yvals = new Array(16,9,4,1,0,1,4,9,16);

graph.plot(xvals, yvals);

这是我们的图表,但它看起来有点奇怪,根本不是一条平滑的曲线。问题是我们的积分不够。lineTo()方法使用直线连接点,如果相邻点之间的距离很大,绘图就不好看。

通过在代码中计算数组元素,我们可以做得更好。让我们这样做:

var xA = new Array();

var yA = new Array();

for (var i=0; i<=100; i++){

xA[i] = (i-50)*0.08;

yA[i] = xA[i]*xA[i];

}

graph.plot(xA, yA, “0xff0000”, false, true);

我们现在使用了 101 个 x-y 值对,而不是 9 个。请注意,我们已经将索引 I 减去 50,并将结果乘以 0.08,以给出与前面的数组xvals中相同的 x 范围(即从–4 到 4)。结果如图 3-1 所示。如你所见,该图给出了一条平滑的曲线。这条特殊的曲线有一种叫做抛物线的形状。在接下来的几节中,我们将使用Graph类为不同类型的数学函数生成图表。

A978-1-4302-6338-8_3_Fig1_HTML.jpg

图 3-1。

Plotting the function y = x2 using the Graph object

画直线

我们从线性函数开始,比如 y = 2x + 1。键入以下代码或从graph-functions.js中复制:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var graph = new Graph(context,-4,4,-10,10,275,210,450,350);

graph.drawgrid(1,0.2,5,1);

graph.drawaxes('x','y');

var xA = new Array();

var yA = new Array();

for (var i=0; i<=100; i++){

xA[i] = (i-50)*0.08;

yA[i] = f(xA[i]);

}

graph.plot(xA,yA,'#ff0000',false,true);

function f(x){

var y;

y = 2*x + 1;

return y;

}

请注意,我们已经将 math 函数移到了一个名为f()的 JavaScript 函数中,以便于查看和修改。

这个图形是一条直线。如果你愿意,可以用 y = ax + b 形式的不同线性方程来模拟 a 和 b 的不同值,你会发现它们总是直线。

这条线在哪里与 y 轴相交?你会发现它总是在 y = b,那是因为在 y 轴上,x = 0。将 x = 0 代入等式,得到 y = b。b 值称为 y 轴截距。a 的意义是什么?你会在这一章的后面找到答案,在微积分那一节。

绘制多项式曲线

你已经看到方程 y = x 2 给出了一条抛物线。通过绘制 y = ax 2 + bx + c 形式的不同二次函数,对不同的 a、b 和 c 值进行试验,例如:

y = x*x - 2*x - 3;

您可能需要更改图表的范围。你会发现它们都是抛物线。如果 a 的值为正,你总会得到一条碗状曲线;如果 a 为负,曲线将呈山形。

线性和二次函数是多项式函数的特例。一般来说,多项式函数是由 x 的不同次幂的项相加而成的。x 的最高次幂称为多项式的次数。所以二次多项式是二次多项式。多项式越高,曲折越多;例如,该多项式将给出如图 3-2 所示的曲线图:

y = -0.5*Math.pow(x,5) + 3*Math.pow(x,3) + x*x - 2*x - 3;

A978-1-4302-6338-8_3_Fig2_HTML.jpg

图 3-2。

A polynomial curve y = –0.5 x5 + 3x3 + x2 – 2x – 3

增长和衰退的事物:指数函数和对数函数

还有许多其他类型的函数可以显示有趣的行为。一个有趣的例子是指数函数,它在物理学中随处可见。它的数学定义如下

A978-1-4302-6338-8_3_Figa_HTML.jpg

你可能想知道 e 代表什么。这是一个特殊的数字,大约等于 2.71828。在这方面,e 是一个类似π的数学常数,不能用精确的十进制形式写下来,但大约等于 3.14159。π、e 等常数出现在物理学各处。

指数函数有时也写作 exp(x)。JavaScript 有一个内置的Math.exp()函数。它还有一个Math.E静态常数,值为 2.718281828459045。

那我们就来剧情Math.exp(x)吧!

从图 3-3 中可以看出,当 x 变得更负时,exp(x)的曲线变为零,当 x 变得更正时,曲线增加得更快。你听说过指数增长这个术语。就是这里。如果你现在绘制 exp(–x),你会看到相反的情况:随着 x 从负值增加到正值,exp(–x)迅速衰减到零。这是指数衰减。请注意,当 x 为 0 时,exp(x)和 exp(–x)都正好等于 1。那是因为任何数字的零次方都等于 1。

A978-1-4302-6338-8_3_Fig3_HTML.jpg

图 3-3。

Exponential growth function exp(x) (solid curve) and decay function exp(–x) (dotted curve)

当然,没有什么能阻止我们组合功能,你可以通过这样做得到有趣的形状。例如,尝试绘制–x2的指数:

A978-1-4302-6338-8_3_Figb_HTML.jpg

这给出了一条钟形曲线(技术上称之为高斯曲线),如图 3-4 所示。它在中间有一个最大值,在两边迅速下降到零。在图 3-4 中,由于绘制图形的分辨率有限,曲线似乎下降到零。实际上,它永远不会精确到零,因为对于 x 的大的正值和负值,exp(–x2)变得小到几乎为零,但永远不会精确到零。

A978-1-4302-6338-8_3_Fig4_HTML.jpg

图 3-4。

The bell-shaped (Gaussian) function exp(–x2)

使物体沿曲线移动

让我们找点乐子,让球沿着曲线运动。为此,我们对graph-functions.js进行了重组,并在新代码move-curve.js中添加了三个新函数:

function placeBall(){

ball = new Ball(6,"#0000ff");

ball.x = xA[0]/xscal+ xorig;

ball.y = -yA[0]/yscal + yorig;

ball.draw(context);

}

function setupTimer(){

idInterval = setInterval(moveBall, 1000/60);

}

function moveBall(){

ball.x = xA[n]/xscal + xorig;

ball.y = -yA[n]/yscal + yorig;

context.clearRect(0, 0, canvas.width, canvas.height);

ball.draw(context);

n++;

if (n==xA.length){

clearInterval(idInterval);

}

}

函数placeBall()init()中的plotGraph()之后被调用,并简单地在曲线的开始处放置一个Ball实例。然后在placeBall()之后调用setupTimer(),正如它的名字所示,它使用setInterval()函数设置了一个定时器。事件处理程序moveBall()在每次setInterval()调用时运行,并沿着曲线移动球,当球到达曲线末端时清除setInterval()实例。

与山丘同乐

你可以让一个物体沿着你喜欢的任何函数的曲线移动;例如 y = x 2 。但是让我们疯狂一下,尝试一些更有趣的东西,比如这个 6 次多项式:

y = 0.2*(x+3.6)*(x+2.5)*(x+1)*(x-0.5)*(x-2)*(x-3.5);

你可以看到这是一个 6 次多项式,因为如果你展开因子,x 的最高幂的项将是 0.2 x 6

运行这个函数的代码来看动画。这让你有什么想法吗?也许你可以用一条曲线来代表一个丘陵景观,并创建一个游戏,在这个游戏中你试图将一个球推过山丘。唯一的问题是,曲线在端点处突然变得很大。这是多项式不可避免的。原因是对于 x 的较大正值或负值,最高项(在本例中为 0.2 x 6 )将始终支配其余项。因此,在这个例子中,由于 0.2 x 6 项的影响,我们将得到非常大的值。用数学术语来说,曲线在这些极限内“爆炸”。

让我们用一个 exp(–x2)类型的钟形函数来弥补这一点,如你所知,它在末端趋于零:

y = (x+3.6)*(x+2.5)*(x+1)*(x-0.5)*(x-2)*(x-3.5)*Math.exp(-x*x/4);

图 3-5 展示了我们得到的东西。酷。贝尔函数使曲线末端变平,解决了我们的问题。注意,我们已经将exp()中的 x 2 除以 4。这是为了拓宽钟形;否则,多项式会很快消失,留下更少的山丘。去掉 4 或者用 2 来代替,看看我们的意思。试着将 4 改为 5 或 6,看看相反的效果。

A978-1-4302-6338-8_3_Fig5_HTML.jpg

图 3-5。

Animating a ball on a curve created by multiplying a polynomial and a Gaussian

以下是修改曲线的另一种方法:将它乘以另一个因子 x(以及一个适当的数字,使其保持在显示的范围内):

y = 0.5*x*(x+3.6)*(x+2.5)*(x+1)*(x-0.5)*(x-2)*(x-3.5) *Math.exp(-x*x/4);

这以不同的方式修改曲线。因为 x 在原点附近小,远离原点大,通过将函数乘以 x,我们倾向于减少离原点较近的山丘,增加离原点较远的山丘的相对高度。你可能会注意到另一个变化:大负值 x 的 y 值现在是负值,景观从山谷而不是山丘开始,因为通过引入 x 的额外因子,该函数中的主导项现在是 0.5 x 7 。与之前的 0.2 x 6 相比。当主导项的幂是奇数时,它对于 x 的负值将是负的,但当幂是偶数时,即使 x 是负的,它也将是正的。通过乘以另一个系数 x 来检验这一点:

y = 0.1*x*x*(x+3.6)*(x+2.5)*(x+1)*(x-0.5)*(x-2)*(x-3.5) *Math.exp(-x*x/4);

如果你把这个函数乘以一个负数,你会反转整个曲线。

很明显,使用函数创建不同类型的形状有很大的灵活性。希望你能找到它们的用处!或许可以开发一个游戏,把球射过山丘。或者也许去掉图表和曲线,制作一些很酷的基于数学的动画,而不泄露你是如何做到的。

除了用于绘制已知函数之外,Graph对象还将作为一个有用的诊断工具来帮助您可视化您的代码实际上在计算什么,比普通的console.log()函数更直观、更详细。它可以帮助您理解代码中的逻辑或物理,并识别任何潜在的问题。把它想象成汽车或飞机上的仪表板或仪器面板,很快你就会比你想象的更多地使用它!

圆圈的麻烦在于

现在,让我们尝试使用前两节中使用的相同方法围绕一个圆移动一个对象。这应该够简单了吧?首先,我们需要一个圆的方程。你可以在任何一本初等几何教科书中找到这个。

A978-1-4302-6338-8_3_Figc_HTML.jpg

以(a,b)为原点,半径为 r 的圆的方程式如下:

如果您尝试遵循与前两节完全相同的步骤,您会希望将 y 作为 x 的函数。回想一下您的代数,您应该能够操作前面的等式来得到以下结果:

A978-1-4302-6338-8_3_Figd_HTML.jpg

为了使任务简单,选择 a = b = 0,r = 1(圆心在原点,半径为 1):

A978-1-4302-6338-8_3_Fige_HTML.jpg

现在我们来绘制图表。你需要在两个轴上使用相同的缩放比例,这样你的圆看起来就不会扭曲。如果您这样做并运行代码,您将得到看起来部分正确的东西,但是有几个奇怪的问题。第一个是,你最终只能得到一个半圆,而不是整个圆。第二个问题是,球需要一点时间出现,然后在结束时消失。图 3-6 显示了结果图。

A978-1-4302-6338-8_3_Fig6_HTML.jpg

图 3-6。

First attempt to plot a circle

我们先处理后一个问题。这种奇怪的球行为的出现是因为我们已经超出了这个方程的适用范围。如果你看前面的等式,你会发现如果 x 的大小大于 1,我们有一个负数的平方根,它不会给你一个实数。所以当这种情况发生时,球无处可去!

与您之前看到的函数不同,圆被限制在平面的有限区域内。用数学术语来说,函数不能被定义在这个范围之外。让我们通过只计算范围为 1 < = x < = 1 的 x 的值来修正代码。这很容易在计算函数值的循环中完成。看一看move-circle.js。这是修改后的代码,可确保 x 限制在正确的范围内:

for (var i=0; i<=1000; i++){

xA[i] = (i-500)*0.002;

yA[i] = f(xA[i]);

}

第一个问题更棘手。只创建一个半圆是因为当我们用平方根来得到前面的等式时,我们也应该包括负数:

A978-1-4302-6338-8_3_Figf_HTML.jpg

问题是你不能让一个函数同时返回两个不同的值。还有一个问题,这次是关于动画的。你可能已经注意到,球似乎沿着圆圈的某些部分快速移动,而在其他地方缓慢移动。这通常不是我们想看到的。它的出现是因为我们一直在等量增加 x。但是在曲线“陡峭”的地方,与曲线更“平坦”的地方相比,x 的相同增量导致 y 的更大增量

通常我们想让一个物体以匀速圆周运动。有没有办法知道球的位置是如何依赖于时间和转速的?有。是时候学习一下参数方程了。

使用参数方程

我们在上一节中遇到的基本问题是,我们知道 y 是 x 的函数,但不知道 x 或 y 如何依赖于时间。我们所追求的是这种形式的一对方程,其中 f (t)和 g (t)是时间的函数:

A978-1-4302-6338-8_3_Figg_HTML.jpg

A978-1-4302-6338-8_3_Figh_HTML.jpg

它们被称为参数方程,因为 x 和 y 是用另一个参数 t 来表示的,在这种情况下,t 代表时间。从这些参数方程中,应该可以恢复出连接 x 和 y 的方程,但反过来就不一定了。参数方程不是唯一的;可能有不同的参数方程对。

我们在这里给你一个答案。虽然在你学过一些三角学和我们讲过角速度的概念(下一节)之前不会有完全的意义,但在这里(其中 r 是圆的半径,ω是所谓的角速度,基本上是绕圆旋转的速率):

A978-1-4302-6338-8_3_Figi_HTML.jpg

A978-1-4302-6338-8_3_Figj_HTML.jpg

请注意,我们在这里使用的概念将在本章的后面部分详细解释。因此,尽管这些概念现在可能还不完全清楚,但它们应该很快就会变得清晰。

让我们选择 r = 1 和 w = 1,并修改代码以相应地计算 x 和 y(参见move-circle-parametric.js):

for (var i=0; i<=1000; i++){

var t = 0.01*i;

xA[i] = Math.sin(t);

yA[i] = Math.cos(t);

}

这完美地完成了工作(参见图 3-7 )。选择从计数器i计算时间t的乘法因子,以产生至少一次完整的旋转。当然,你不必这样做。你可以完全去掉数组和循环,直接计算 x 和 y。但是很高兴知道,除了动态计算对象的位置之外,还有另一种方法可以用代码来制作对象的动画。这种方法可以证明是有用的,例如,如果计算太耗时;然后可以在“初始化”期间完成这些操作,并将位置存储在一个数组中(如前所述),随后以通常的方式制作动画。

A978-1-4302-6338-8_3_Fig7_HTML.jpg

图 3-7。

Moving an object around a circle using parametric equations

求两点之间的距离

一个常见的问题是在给定两个物体位置的情况下找出它们之间的距离,这在例如碰撞检测中是需要的。一些物理定律也涉及两个物体之间的距离。比如牛顿万有引力定律(在第六章中涉及)涉及两个物体之间距离的平方。

幸运的是,有一个简单的公式,基于勾股定理,允许我们计算两点之间的距离。勾股定理实际上是一个关于三角形边长的定理。具体来说,它适用于一种特殊类型的三角形,称为直角三角形,其中一个角为 90 度。三角形的最长边称为斜边,总是与 90 度角相对(见图 3-8 )。

A978-1-4302-6338-8_3_Fig8_HTML.jpg

图 3-8。

A right triangle

毕达哥拉斯定理指出,如果我们取其他每条边,计算它们的长度的平方,并将结果相加,我们将得到最长边的长度的平方。用公式表示,如下,其中 c 是斜边的长度,a 和 b 是其他两条边的长度:

A978-1-4302-6338-8_3_Figk_HTML.jpg

图 3-9 展示了我们如何使用这个公式计算两点之间的距离:通过画一个直角三角形。如果两点的坐标分别为(x1,y1)和(x2,y2),则两个较短边的长度分别为(x2–x1)和(y2–y1)。

A978-1-4302-6338-8_3_Fig9_HTML.jpg

图 3-9。

Using the Pythagorean Theorem to calculate the distance between two points

因此,斜边的长度,实际上是两点之间的距离 d,由下式给出:

A978-1-4302-6338-8_3_Figl_HTML.jpg

距离 d 然后通过取前一个表达式的平方根来获得。就这样。此外,该公式可以很容易地推广到 3D,给出如下公式:

A978-1-4302-6338-8_3_Figm_HTML.jpg

请注意,如果我们用(x1–x2)2代替(x2–x1)2并不重要,其他项也是如此,因为负数的平方与对应的正数的平方相同。

基础三角学

三角学是数学的一个分支,专门研究三角形的性质以及它们的边长和角之间的关系。换句话说,三角学听起来可能不是很特别,但事实上,它是动画或游戏程序员工具集不可或缺的一部分。例如,我们以前在产生圆周匀速运动的参数方程中使用三角函数。

你可能还记得学生时代的基础知识,比如三角形内角之和是 180 度的定理。但也许你不记得什么是正弦函数。这一节将回顾这门学科的要点。

角度和弧度

大家都知道一次完整的旋转有 360 度。很少有人知道 360 度等于 2π弧度(或者可能从未听说过弧度)。所以我们先来解释一下什么是弧度。

简单的解释是:弧度是角度的度量单位(就像度一样)。以下是这两种方法的关系:

  • 2π弧度等于 360 度
  • 所以π弧度等于 180 度
  • 所以 1 弧度等于 180/π度,大约是 57.3 度

现在你可能对弧度的概念感到不舒服。为什么我们需要这样一个奇怪的角度单位?难道我们都不知道并热爱学位吗?好吧,重点是度数也是任意的——为什么一个圆有 360 度?为什么不是 100?

事实上,弧度在很多方面是一个更“自然”的角度单位。这是根据它的定义得出的:长度等于半径的弧在圆心所对的角。参见图 3-10 。

A978-1-4302-6338-8_3_Fig10_HTML.jpg

图 3-10。

A radian is the angle subtended at the center of a circle by an arc of length equal to the circle’s radius

您将经常需要在角度和弧度之间进行转换。这是转换公式:

  • (角度单位为度)=(角度单位为弧度)× 180 / π
  • (弧度角度)=(角度角度)× π / 180

正弦函数

三角函数是根据直角三角形的边来定义的。参考图 3-11 ,你知道斜边(hyp)是三角形的最长边,与直角相对。选择其他角度中的一个,比如 x。然后,相对于角度 x,不接触 x 的远边称为对面(opp)。接触 x 的近边称为邻边(adj)。请注意,相对和相邻与您选择的角度(在本例中为 x)有关。相对于另一个角度,对立和相邻的角色颠倒了。另一方面,斜边总是最长的一边。

A978-1-4302-6338-8_3_Fig11_HTML.jpg

图 3-11。

Definition of hypotenuse, adjacent, and opposite sides of a right triangle

正弦函数被简单地定义为对边的长度与斜边的长度之比:

A978-1-4302-6338-8_3_Fign_HTML.jpg

现在你可以为不同的角度 x 画许多直角三角形,测量 opp 和 hyp,计算它们的比率,并制表和绘制 sin (x)来看看它是什么样子。但是你肯定更喜欢挖出那个Graph物体并绘制Math.sin()。这就是我们在trig-functions.js所做的。

图 3-12 显示了我们得到的结果。我们在–720 度(–4π弧度)和 720 度(4π弧度)之间绘制了图表,以显示函数的周期性质。周期(它重复的间隔)是 360 度,或 2π弧度。曲线就像平滑的波浪。

A978-1-4302-6338-8_3_Fig12_HTML.jpg

图 3-12。

The graph of a sine function

请注意,sin (x)始终介于–1 和 1 之间。在量级上永远不可能大于 1,因为 opp 永远不可能大于 hyp。我们说正弦波的峰值振幅为 1。sin (x)的值在 0 度以及此后和之前每隔 180 度为零。

余弦函数

与正弦类似,余弦函数定义为邻边长度与斜边长度之比:

A978-1-4302-6338-8_3_Figo_HTML.jpg

图 3-13 所示的曲线与 sin (x)相似,除了 cos (x)看起来相对于 sin (x)移动了 90 度。其实结果是 cos(x–π/2)= sin(x)。继续,通过绘制这个函数来证明它。这表明 cos 和 sin 仅相差一个常数。我们说它们有 90 度的相位差。

A978-1-4302-6338-8_3_Fig13_HTML.jpg

图 3-13。

The graph of a cosine function

正切函数

第三种常见的 trig 函数是正切函数,定义如下:

A978-1-4302-6338-8_3_Figp_HTML.jpg

这个定义相当于:

A978-1-4302-6338-8_3_Figq_HTML.jpg

如果绘制 tan x 的曲线图,乍一看可能会有点奇怪(见图 3-14 )。忽略 90 度的垂直线,以此类推。他们不应该真的在那里。该图仍然是周期性的,但它由每隔 180 度的不连续分支组成。此外,tan (x)可以取任何值,而不仅仅是–1 和 1 之间的值。90 度的情况是 tan (x)变得无穷大。回到 tan (x)的定义,这是因为对于 90 度,adj 为零,所以我们最后除以零。

A978-1-4302-6338-8_3_Fig14_HTML.jpg

图 3-14。

The graph of a tangent function

你可能不会像罪恶和 cos 那样频繁地使用 tan。但是你肯定会用到它的逆,我们接下来会介绍。

反向触发功能

通常需要根据某个角度的 sin、cos 或 tan 值来确定该角度。在数学中,这是使用三角函数的逆函数来完成的,分别称为 arcsin、arccos 和 arctan。

在 JavaScript 中,这些被称为Math.asin()Math.acos()Math.atan()的函数以一个数字作为参数。显然,Math.asin()Math.acos()的数字必须在-1 和 1 之间,但是Math.atan()可以是任何值。

请注意,反向 trig 函数返回的是以弧度表示的角度,因此如果需要的话,必须转换成度数。

JavaScript 中还有一个反 tan 函数:Math.atan2()。区别是什么,为什么我们需要两个?

Math.atan()采用单个参数,即您正在计算的角度的 opp/adj 比率。Math.atan2()取两个参数,是 opp 和 adj 的实际值,如果你碰巧知道的话。那么,为什么需要Math.atan2()——你不能只做Math.atan(opp/adj)

要回答这个问题,请执行以下操作:

console.log(Math.atan(1)*180/Math.PI);

console.log (Math.atan2(2,2)*180/Math.PI);

console.log (Math.atan2(-2,-2)*180/Math.PI);

所有三行代码的 opp/adj 比率都为 1,但是您会发现前两行返回 45 度,而第三行将返回–135 度。所发生的是,第三个选项指定角度指向左上方,并且因为角度是从画布坐标系中的正 x 轴以顺时针方向测量的,所以角度实际上是–135 度(参见图 3-15 )。现在,你没有办法告诉Math.atan()函数,因为它只有一个参数:1,和 45 度一样。

A978-1-4302-6338-8_3_Fig15_HTML.jpg

图 3-15。

Understanding the result of Math.atan2(-2,-2)

为动画使用 trig 函数

你已经看到了 sin 和 cos 如何被用来使一个物体做圆周运动。它们对于产生任何类型的重复或振荡运动也特别有用。但是在我们开始使用 trig 函数制作动画之前,我们需要引入一些新概念。

波长、周期、频率和角频率

再看一下图 3-12 ,它显示了正弦函数的图形。如果 x 代表空间中的距离,我们在空间中有一个以规则间隔重复的正弦波。这个间隔称为波的波长。它是相邻相似点之间的距离,例如,连续波峰之间的距离。

如果我们用正弦函数对时间作图,重复间隔就是一个时间尺度,称为波的周期。例如,如果这是一个上下运动的球,周期(用符号 T 表示)就是它回到初始位置的时间。让我们称之为一个循环。

现在,假设一个周期需要 0.5 秒,我们可以表述为 T = 0.5 秒。球在 1 秒钟内完成几个循环?很明显是两个。这就是一秒钟有多少个半秒钟。这被称为运动频率(用符号 f 表示)。

不难看出,一般来说,频率是由周期的倒数给出的:

A978-1-4302-6338-8_3_Figr_HTML.jpg

现在,事实证明,你总是可以把球的上下运动想象成另一个在圆周上匀速运动的假想球的位置的投影(见图 3-16 )。一个周期的振荡(类似波浪的运动)相当于围绕圆周的一次完整的旋转。想想旋转的球移动的角度,那是 2π弧度。因为球每秒移动 f 个周期,每个周期是 2π弧度,这意味着球每秒通过 2πf 弧度。这被称为运动的角频率或角速度(通常用希腊字母ω,omega 表示)。它告诉你旋转的球每秒移动的角度,单位是弧度。

A978-1-4302-6338-8_3_Figs_HTML.jpg

A978-1-4302-6338-8_3_Fig16_HTML.jpg

图 3-16。

Relationship between oscillation, circular motion, and a sine wave

最后,因为ω是假想球每秒移动的角度,所以在 t 秒内,球将移动ω t 弧度。因此,如果它以 0 弧度的角度开始,在 t 秒时,它的投影位移等于 sin (ω t):

A978-1-4302-6338-8_3_Figt_HTML.jpg

如果我们知道振荡的角频率,我们可以计算出球在任何时候的位置,如果它的初始位置是已知的。当然,如果我们知道周期或频率,我们可以通过前面的公式计算出角频率。

让我们看一些例子。对于这些例子,我们使用的是trig-animations.js,它是对move-curve.js的修改。我们现在将并排展示一个 2D 动画和它的 1D 版本,在这个版本中你只需要沿着一个方向移动一个物体。

振动

基本振荡(2D 的波动)很容易用正弦或余弦函数来实现。这种振荡被称为简谐运动(SHM)。振荡由单一频率的波组成。用trig-animations.js试试看:

function f(x){

var y;

y = Math.sin(x*Math.PI/180);

return y;

}

通过将自变量乘以不同的因子,可以产生不同频率的波。

阻尼振荡

正弦波振荡会永远持续下去。如果你想让它们随着时间消失,你可以把正弦乘以一个负系数的指数:

y = Math.sin(x*Math.PI/180)*Math.exp(-0.002*x);

您可以通过将系数 0.002 更改为更小或更大的数字来进行实验。图 3-17 显示了您可能会看到的典型模式。

A978-1-4302-6338-8_3_Fig17_HTML.jpg

图 3-17。

Damped oscillations using sin(x) and exp(–x)

组合正弦波

你可以通过组合正弦波和余弦波产生各种奇异的效果。这是一个组合的例子:

y = Math.sin(x*Math.PI/180) + Math.sin(1.5*x*Math.PI/180);

或者尝试两个角频率几乎相同的正弦波:

y = 0.5*Math.sin(3*x*Math.PI/180) + 0.5*Math.sin(3.5*x*Math.PI/180);

这给了你一个“节拍”动作;一个快速振荡叠加一个较慢的振荡(见图 3-18 )。

A978-1-4302-6338-8_3_Fig18_HTML.jpg

图 3-18。

Pattern produced by superimposing two sine waves of nearly equal frequency

使用正弦波的组合可以产生各种重复的图案。有一种叫做傅立叶分析的数学技术,可以让你计算出产生特定模式所需的正弦波组合。正弦波的和称为傅立叶级数。

例如,您可以通过以下方式添加正弦波来产生看起来像方波(阶跃函数波)的东西:

y = Math.sin(x*Math.PI/180) + Math.sin(3*x*Math.PI/180)/3 + Math.sin(5*x*Math.PI/180)/5;

添加的波越多,结果就越接近方波。例如,添加系列中的下一个术语(Math.sin(7*x*Math.PI/180)/7)以查看您得到的结果。

我们写了一个小函数fourierSum(N,x),它计算方波的 N 项傅里叶和。

function f(x){

var y;

y = fourierSum(10,x);

return y;

}

function fourierSum(N,x){

var fs=0;

for (var nn=1; nn<=N; nn=nn+2){          fs += Math.sin(nn*x*Math.PI/180)/nn;

}

return fs;

}

图 3-19 显示了 N = 10 的结果。当 N = 1000 时,曲线几乎是完美的方波。

A978-1-4302-6338-8_3_Fig19_HTML.jpg

图 3-19。

Fourier series pattern for a square wave with N = 10

向量和基本向量代数

向量是有用的数学构造,它简化了我们处理速度和力等物理量的方式,这将在下一章详细讨论。向量代数由操作和组合向量的规则组成。当你们讨论速度分量的时候,你们已经非正式地接触过向量了。现在让我们更正式地介绍矢量。

什么是矢量?

用位移的例子可以最直观地说明矢量。位移的概念将在第四章中详细介绍,但本质上它指的是给定距离和给定方向的运动。这里有一个例子。

假设那只虫子,瓢虫,正在一张图表纸上爬行。Bug 从原点开始,爬行 10 个单位的距离;然后他停下来。Bug 接着继续爬行 10 个单位,然后再次停止。Bug 离原点有多远?有人可能会说 20 个单位。但是如果 Bug 第一次向上爬,第二次向右爬呢?(参见图 3-20 )。显然 20 个单位是错误的答案。通过使用毕达哥拉斯定理,答案实际上是A978-1-4302-6338-8_3_Figu_HTML.jpg,或者大致是 14.1 个单位。

A978-1-4302-6338-8_3_Fig20_HTML.jpg

图 3-20。

The direction of displacement matters!

不能简单的把两个距离相加,因为还需要考虑到 Bug 移动的方向。为了分析位移,你需要一个大小和一个方向。

在数学中,我们将这个概念抽象为向量的概念,向量是一个有大小和方向的量。

位移不是唯一的向量。许多运动的基本概念,如速度、加速度和力,都是矢量(它们将在下一章讨论)。这一节概述了作为抽象数学对象的矢量代数,不管它们是位移、速度还是力。掌握一点向量代数,你可以通过减少你必须处理的方程的数量来简化计算。这节省了时间,减少了出错的可能性,简化了生活。

向量与标量

向量通常与标量形成对比,标量是只有大小的量。距离或长度是一个标量,你只需要一个数字来指定它。但是位移是一个向量,因为你需要指定位移的方向。形象地说,向量通常被表示为有方向的直线——一条上面有箭头的线。

注意,向量的唯一特征是它的大小和方向。这意味着两个具有相同大小和方向的矢量被认为是相等的,不管它们在空间的什么位置(见图 3-21a)。换句话说,矢量的位置无关紧要。然而,这个规则有一个例外:位置向量。物体的位置矢量是连接固定原点和物体的矢量,所以它是相对于固定原点的位移矢量(见图 3-21b)。

A978-1-4302-6338-8_3_Fig21_HTML.jpg

Figure 3-21. (a) Parallel vectors of equal length are equal; (b) Position vectors relate to the origin

加法和减法向量

在 Bug 的例子中,两个位移之和是多少?自然的答案是,它是由从初始位置指向最终位置的矢量给出的合成位移(参见图 3-20 )。如果 Bug 现在向下移动了 5 个单位的距离呢?你可能会说合成位移是从起点(在这种情况下是原点)指向终点的向量。这就是所谓的头尾法则:

  • 要添加两个或多个向量,请将它们连接起来,使它们“头尾相接”,必要时可以四处移动它们(只要它们的方向不变,即它们保持与原始方向平行)。和矢量或合成矢量是连接起点和终点的矢量。

要从另一个向量中减去一个向量,我们只需加上那个向量的负值。向量的负值是指大小相同但方向相反的向量。

从向量本身(或向量和它的负数之和)中减去一个向量,得到零向量——一个长度为零、方向任意的向量。

我们来看一个例子。假设 Bug 向上移动了 10 个单位后,他决定以 45 度角移动 10 个单位(见图 3-22 )。合成位移是多少?这就更棘手了:你不能直接应用勾股定理,因为你不再有直角三角形了。你可以使用一些更复杂的三角学,或者精确地画出矢量,然后拿一把尺子测量合成的距离和角度。

但实际上,你不会这么做。使用矢量分量来加减矢量要容易得多。现在我们来看看矢量分量。

A978-1-4302-6338-8_3_Fig22_HTML.jpg

图 3-22。

Addition of vectors

解析向量:向量分量

如前所述,矢量有大小和方向。这在 2D 意味着你需要两个数字来指定一个向量。看一下图 3-23 。这两个数字是向量的长度 r 和它与 x 轴的夹角θ。但是我们也可以将这两个数字指定为向量在 x 和 y 方向上的范围,分别用 x 和 y 表示。这些被称为矢量分量。

A978-1-4302-6338-8_3_Fig23_HTML.jpg

图 3-23。

Vector components

(r,θ)和(x,y)之间的关系可以通过上一节讨论的简单三角学得到。再次查看图 3-23 ,我们知道:

A978-1-4302-6338-8_3_Figv_HTML.jpg

这给出了以下内容:

A978-1-4302-6338-8_3_Figw_HTML.jpg

以这种方式从矢量的大小和角度计算出矢量分量被称为沿 x 轴和 y 轴“分解矢量”。实际上,你可以沿着任意两个相互垂直的方向分解一个矢量。但大多数时候,我们会沿着 x 轴和 y 轴来做。

向量有许多不同的符号,很容易陷入符号的困境。但是在 JavaScript 中,你只需要记住向量是有分量的。所以任何有助于记忆的符号都是一样好的。为了简洁起见,我们将使用下面的符号,用粗体字母表示向量,用方括号括起它们来表示它们的组成部分:

A978-1-4302-6338-8_3_Figx_HTML.jpg

在 3D 中,你不需要两个,而是三个组件来指定一个向量,所以我们这样写:

A978-1-4302-6338-8_3_Figy_HTML.jpg

位置向量

物体的位置向量是从原点指向该物体的向量。因此,我们可以用物体的坐标来表示位置向量。在 2D:

A978-1-4302-6338-8_3_Figz_HTML.jpg

在 3D 中:

A978-1-4302-6338-8_3_Figaa_HTML.jpg

使用组件添加向量

下面是如何使用组件添加两个向量:

A978-1-4302-6338-8_3_Figbb_HTML.jpg

很简单,不是吗?为什么会这样,从图 3-24 中应该很清楚。将两个矢量的水平分量和垂直分量分别相加。这就给出了合成矢量的分量。

A978-1-4302-6338-8_3_Fig24_HTML.jpg

图 3-24。

Adding vectors using components

作为一个例子,将此应用于图 3-22 所示的 Bug 位移问题,可以得到以下位移,您可以很容易地在图上验证:

A978-1-4302-6338-8_3_Figcc_HTML.jpg

随意用纸笔练习做向量加法;在经历了一些之后,这个过程应该变得明显,并且帮助你建立你对向量和向量分量的直觉。

类似地,使用分量的矢量减法也很简单:

A978-1-4302-6338-8_3_Figdd_HTML.jpg

将一个向量乘以一个数

将一个向量乘以一个数,就是将它的每个分量乘以那个数:

A978-1-4302-6338-8_3_Figee_HTML.jpg

特别是,如果我们将一个向量乘以–1,我们会得到这个向量的负值:

A978-1-4302-6338-8_3_Figff_HTML.jpg

将一个向量除以一个数 N 就等于将这个向量乘以这个数的倒数 1/N。

矢量幅度

矢量的大小(长度)是通过将勾股定理应用于其分量而得到的:

  • 在 2D,[x,y]的量级是A978-1-4302-6338-8_3_Figgg_HTML.jpg;或者用 JavaScript 代码:Math.sqrt(x*x+y*y)
  • 在 3D 中,[x,y,z]的大小为A978-1-4302-6338-8_3_Fighh_HTML.jpg;或者用 JavaScript 代码:Math.sqrt(x*x+y*y+z*z)

注意,如果我们把一个向量除以它的大小,我们会得到一个长度为一个单位的向量,它与原始向量的方向相同。这叫做单位向量。

向量角度

从矢量的分量计算矢量的角度并不比计算其大小更困难。在 JavaScript 中,使用Math.atan2()函数最容易做到这一点。

向量[x,y]的角度由Math.atan2(y, x)给出。注意——您需要首先将参数指定为 y,然后是 x。

乘法向量:标量或点积

两个向量相乘可能吗?答案是肯定的。数学家定义了一种叫做标量积的“乘法”运算,其中两个向量产生一个标量。回想一下,标量只有大小,没有方向;换句话说,它基本上只是一个数字。规则是将每个向量的相应分量相乘,然后将结果相加:

A978-1-4302-6338-8_3_Figii_HTML.jpg

标量积用一个点表示,所以也叫点积。

用矢量幅度和方向表示,点积由下式给出,其中θ是两个矢量之间的角度,r1 和 r2 是它们的长度:

A978-1-4302-6338-8_3_Figjj_HTML.jpg

标量积的几何解释是,它是一个向量的长度与另一个向量的投影长度的乘积(见图 3-25 )。

A978-1-4302-6338-8_3_Fig25_HTML.jpg

图 3-25。

The scalar product between two vectors

当您需要找出两个向量之间的角度时,这很有用,您可以通过相等前面两个方程的右侧并求解θ来获得该角度。在 JavaScript 中,您只需这样做:

angle = Math.acos((x1*x2+y1*y2)/(r1*r2))

注意,如果两个向量之间的角度为零(它们是平行的),cos (θ) = 1,点积正好是它们大小的乘积。

如果两个向量垂直,则 cos (θ) = cos (π/2) = 0,因此点积为零。这是检验两个向量是否垂直的好方法。所有这些公式和事实都适用于 3D,其点积如下所示:

A978-1-4302-6338-8_3_Figkk_HTML.jpg

标量积出现在物理学的几个地方。下一章我们会碰到一个例子,关于功的概念,它被定义为力和位移的点积。因此,尽管点积作为一种数学构造可能显得有些抽象和神秘,但当你在第四章的应用上下文中再次遇到它时,它将有望变得更直观。

乘法向量:向量或叉积

在 3D 中,还有另一种类型的乘积,称为矢量乘积,因为它给出的是矢量而不是标量。这条规则要复杂得多。

如果有两个向量 a = [x1,y1,z1]和 b = [x2,y2,z2],并且它们的矢量积由向量 c = a ×b = [x,y,z]给出,则 c 的分量由下式给出:

A978-1-4302-6338-8_3_Figll_HTML.jpg

这不太直观,对吧?就像数学中的一切一样,这肯定是有一些逻辑的。但试图解释这一点会转移太多的注意力。所以我们就接受这个公式吧。

矢量积也称为叉积,因为它用“叉”符号来表示。两个矢量 a 和 b 的矢量积给出了第三个矢量,它垂直于 a 和 b。

就矢量幅度和方向而言,叉积由下式给出:

A978-1-4302-6338-8_3_Figmm_HTML.jpg

其中 a 和 b 分别是 a 和 b 的大小,θ是 a 和 b 之间的较小角度,n 是垂直于 a 和 b 的单位向量,并且根据右手法则定向(参见图 3-26 ):握住右手,食指指向第一个向量 a,中指指向 b 的方向,然后 n 将指向拇指的方向,保持垂直于 a 和 b。

A978-1-4302-6338-8_3_Fig26_HTML.jpg

图 3-26。

The right-hand rule for the vector product

注意,当θ = 0 时,叉积为零(因为 sin (0) = 0)。因此,两个平行向量的叉积给出零向量。

像标量积一样,矢量积出现在物理学的几个地方;例如在旋转运动中。

用向量代数构建向量对象

我们构建了一个轻量级的Vector2D对象,赋予了 2D 所有相关的向量代数。下面是代码(见vector2D.js)。选择这些方法名称是为了直观理解。

function Vector2D(x,y) {

this.x = x;

this.y = y;

}

// PUBLIC METHODS

Vector2D.prototype = {

lengthSquared: function(){

return this.x*this.x + this.y*this.y;

},

length: function(){

return Math.sqrt(this.lengthSquared());

},

clone: function() {

return new Vector2D(this.x,this.y);

},

negate: function() {

this.x = - this.x;

this.y = - this.y;

},

normalize: function() {

var length = this.length();

if (length > 0) {

this.x /= length;

this.y /= length;

}

return this.length();

},

add: function(vec) {

return new Vector2D(this.x + vec.x,this.y + vec.y);

},

incrementBy: function(vec) {

this.x += vec.x;

this.y += vec.y;

},

subtract: function(vec) {

return new Vector2D(this.x - vec.x,this.y - vec.y);

},

decrementBy: function(vec) {

this.x -= vec.x;

this.y -= vec.y;

},

scaleBy: function(k) {

this.x *= k;

this.y *= k;

},

dotProduct:     function(vec) {

return this.x*vec.x + this.y*vec.y;

}

};

// STATIC METHODS

Vector2D.distance =  function(vec1,vec2){

return (vec1.subtract(vec2)).length();

}

Vector2D.angleBetween = function(vec1,vec2){

return Math.acos(vec1.dotProduct(vec2)/(vec1.length()*vec2.length()));

}

文件vector-examples.js包含了一些使用Vector2D对象的例子。你很快会在整本书中看到大量使用它的例子。

简单的微积分思想

正如本章开始时所述,我们并不假设所有读者都有微积分背景。如果你这样做,那当然是方便的;但是我们仍然建议您浏览这一部分,尤其是代码示例和离散微积分部分,看看我们如何在代码中应用微积分。如果微积分对你来说是全新的,这一节是作为一些基本概念的概述而设计的。因此,虽然你不能仅仅通过阅读这一部分来“做”微积分,但你将有希望获得足够的理解来理解涉及微积分的物理公式的意义。此外,你将被介绍到离散微积分和数值方法的主题,这将为接近更复杂的物理模拟提供基础。

那么什么是微积分呢?一句话,这是一种数学形式,用来处理相对于其他量连续变化的量。因为物理学研究的是将一些量与其他量联系起来的规律,微积分显然是相关的。

微积分由两部分组成。微分学(或微分)处理的是量的变化率。积分学(或积分)处理连续和。这两者通过微积分的基本定理联系在一起,该定理指出积分是微分的逆运算。

如果你以前从未接触过微积分,这听起来非常神秘,所以让我们从你知道的一些东西开始,给你一个温和的介绍。到本章结束时,这些陈述对你来说会更有意义。

线的斜率:梯度

变化率是物理学中的一个重要概念。如果你想一想,我们正在试图制定定律来告诉我们,例如,行星的运动是如何随时间变化的;行星上的力如何随位置变化等等。这里的关键词是“随…而变”事实证明,物理定律包含位置、速度等不同物理量的变化率。

通俗地说,“变化率”告诉我们事物变化的速度有多快。通常我们指的是它随时间变化的速度,但我们也可能对某个事物相对于其他量的变化速度感兴趣,而不仅仅是时间。例如,当一颗行星在其轨道上运行时,重力随其位置的变化有多快?

所以,用数学术语来说,让我们考虑一个量 y 相对于另一个量 x 的变化率(其中 x 可以是时间 t 或其他量)。为此,我们将使用图表来帮助我们更容易地可视化关系。

让我们从两个相互之间有简单线性关系的量开始;也就是说,它们之间的关系是

A978-1-4302-6338-8_3_Fignn_HTML.jpg

正如你在坐标几何部分看到的,这意味着 y 对 x 的图形是一条直线。常数 b 是 y 轴截距;直线与 y 轴相交的点。我们现在将证明常数 a 给出了 y 随 x 的变化率的度量。

看图 3-27 。两条线中,哪一条 y 随 x 变化更快?

A978-1-4302-6338-8_3_Fig27_HTML.jpg

图 3-27。

The gradient (slope) of a line

很明显,它是 A,因为它“更陡”,所以 x 的相同增加导致 y 的更大增加。因此,直线斜率的陡度给出了 y 相对于 x 的变化率的度量。我们可以通过如下定义变化率来使这一想法更精确:

变化率=(y 的变化)/(x 的变化)

习惯上使用符号δ来表示“变化”,因此我们可以这样写:

A978-1-4302-6338-8_3_Figoo_HTML.jpg

让我们给变化率取一个较短的名字;姑且称之为梯度。想法是这样的:在坐标为(x1,y1)和(x2,y2)的直线上取任意两点,计算 y 的差值和 x 的差值,并将前者除以后者:

A978-1-4302-6338-8_3_Figpp_HTML.jpg

有了一条直线,你会发现无论你取哪一对点,你都会得到相同的梯度。此外,该梯度等于 a,即公式 y = ax + b 中 x 的倍数。

这是有意义的,因为一条直线有一个恒定的斜率,所以你在哪里测量它并不重要。这是一个非常好的开始。我们有一个计算梯度或变化率的公式。唯一的问题是,它仅限于彼此线性相关的量。那么我们如何将这个结果推广到更一般的关系(比如当 y 是 x 的非线性函数时?)

变化率:衍生品

你已经知道非线性函数的图形是曲线而不是直线。直观上,曲线的斜率不是恒定的,而是沿着曲线变化的。因此,无论非线性函数的变化率或梯度如何,它都不是常数,而是取决于 x。换句话说,它也是 x 的函数。

我们现在的目标是找到这个梯度函数。在数学课程中,你会做一些代数运算,然后得出梯度函数的公式。比如你可以从函数 y = x 2 开始,说明它的梯度函数是 2x。但是因为你没有上过数学课,所以让我们用代码来代替。但是首先,我们需要定义曲线的梯度是什么意思。

因为一条曲线的坡度是沿着曲线变化的,所以我们把重点放在曲线上的一个固定点 P 上(图 3-28 )。

A978-1-4302-6338-8_3_Fig28_HTML.jpg

图 3-28。

The gradient of a curve

然后让我们在 P 点附近选取任何其他点 Q,如果我们画一条连接 P 和 Q 的线段,那条线的斜率就近似于曲线的斜率。如果我们想象 Q 接近 P,我们可以期望线段 PQ 的梯度越来越接近曲线在 P 处的梯度。

实际上,我们所做的是使用之前的直线梯度公式,并减少δx 和δy 的间隔:

A978-1-4302-6338-8_3_Figqq_HTML.jpg

然后,我们可以沿着曲线在不同的位置对点 P 重复这个过程,以找到梯度函数。现在让我们在gradient-function.js中做这件事:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var numPoints=1001;

var numGrad=50;

var xRange=6;

var xStep;

var graph = new Graph(context,-4,4,-10,10,275,210,450,350);

graph.drawgrid(1,0.2,2,0.5);

graph.drawaxes('x','y');

var xA = new Array();

var yA = new Array();

// calculate function

xStep = xRange/(numPoints-1);

for (var i=0; i<numPoints; i++){

xA[i] = (i-numPoints/2)*xStep;

yA[i] = f(xA[i]);

}

graph.plot(xA,yA,'#ff0000',false,true); // plot function

// calculate gradient function using forward method

var xAr = new Array();

var gradA = new Array();

for (var j=0; j<numPoints-numGrad; j++){

xAr[j] = xA[j];

gradA[j] = grad(xA[j],xA[j+numGrad]);

}

graph.plot(xAr,gradA,'#0000ff',false,true); // plot gradient function

function f(x){

var y;

y = x*x;

return y;

}

function grad(x1,x2){

return (f(x1)-f(x2))/(x1-x2);

}

相关的线是计算梯度函数和函数grad()的线。给定 x1 和 x2 作为输入,此函数简单地计算梯度(y2–y1)/(x2–x1)。决定使用哪些点的行如下:

gradA[j] = grad(xA[j],xA[j+numGrad]);

这里numGrad是 Q 远离点 P(我们正在评估梯度的点)的网格点数。显然,numGrad的值越小,计算出的梯度就越精确。

我们在 x 区间 6(从–3 到 3)中总共使用了 1,000 个网格点。这使得我们的步长为每个网格点 0.006 个单位。先用numGrad=1开始吧。这是最小的可能值。运行代码绘制梯度函数和原始函数 y = x 2 。梯度函数的图形是一条穿过原点的直线。当 x = 1 时,y = 2,当 x = 2 时,y = 4,依此类推。你大概可以猜到,这条线的方程是 y = 2x。现在任何微积分课本都会告诉你 y = x 2 的梯度函数是 y = 2x。所以这很好。你已经成功计算了你的第一个梯度函数!

现在将numGrad的值改为 50。你会看到计算出的梯度函数线移动了一点。不再是 y = 2x。不太好。试着减少numGrad。您会发现,最大值约为 10 时,结果看起来非常接近 y = 2x。那就是 10 × 0.006 = 0.06 的步长。比这大得多的话,你会开始失去准确性。

让我们通过介绍更多的术语和符号来结束这一节。梯度函数也叫做关于 x 的导函数或导数,我们可以互换使用这两个术语。计算导数的过程叫做微分。

我们证明了比率δy/δx 给出了曲线在某一点的梯度,只要δx 和δy 很小。在形式微积分中,我们说当δx 和δy 趋于零时,δy/δx 趋于梯度函数。

为了表达梯度函数实际上是δy/δx 的一个极限值,我们这样写:

A978-1-4302-6338-8_3_Figrr_HTML.jpg

这是函数 y 相对于 x 的导数的标准符号。另一个符号是 y’(“y 素数”);或者我们写 y = f(x),导数也可以用 f’(“f 素数”)来表示。

没有什么可以阻止你计算导数的导数。这被称为二阶导数,写作如下:

A978-1-4302-6338-8_3_Figss_HTML.jpg

事实上,你会在下一章发现,速度是位置(相对于时间)的导数,加速度是速度的导数。所以加速度是位置对时间的二阶导数。记数法中,v = dx/dt,a = dv/dt,所以 a = d 2 x/dt 2

我们计算了标量函数的导数,但是你也可以对向量求导。你只需要分别求出每个矢量分量的导数。

例如,vx = dx/dt、vy = dy/dt 和 vz = dz/dt 是三个速度分量。我们可以更简洁地将它写成向量形式,如下所示,其中 v = [vx,vy,vz]和 r = [x,y,z]:

A978-1-4302-6338-8_3_Figtt_HTML.jpg

离散微积分:差分方程

我们在前面的代码中计算梯度函数时所做的是离散微积分的一个例子:使用数值方法计算导数。数值方法基本上是一种使用代码以近似方式执行数学计算的算法。如果我们没有计算数量的精确公式,这是需要的。

在前面的例子中,我们有 y = x 2 并且需要使用导数的离散形式来计算 y ’:

A978-1-4302-6338-8_3_Figuu_HTML.jpg

为此,我们使用了以下形式的差分方程:

A978-1-4302-6338-8_3_Figvv_HTML.jpg

这被称为向前差分方案,因为我们在第 n 步使用下一步 n+1 的函数值计算导数。

还有很多其他的差分方案。让我们试试中心差分格式:

A978-1-4302-6338-8_3_Figww_HTML.jpg

它被称为中心差分格式,因为我们在点 P 的两边选择一个点,在这里我们计算梯度。下面是执行此操作的代码:

// calculate gradient function using centered method

var xArc = new Array();

var gradAc = new Array();

for (var k=numGrad; k<numPoints-numGrad; k++){

xArc[k-numGrad] = xA[k];

gradAc[k-numGrad] = grad(xA[k-numGrad],xA[k+numGrad]);

}

如果运行这段代码,您会看到它给出了与前面的方案相同的答案,即numGrad = 1(回想一下,numGrad是 P 和 Q 之间的网格点数,它越小,计算出的梯度就越精确)。但是如果你现在尝试更大的numGrad值,你会发现它对于 250 这样大的值仍然是相当准确的,相当于 1.5 的网格大小。与前向差分方案的最大步长 0.06 相比,它大了 25 倍!

这表明中心差分格式比正演格式精确得多。图 3-29 显示了用两种方法计算的numGrad = 50的导数。注意我们在这个图上画了两种不同的东西:原函数 y 和它的梯度函数 dy/dx。因此 y 轴上的标签。恰好通过原点的直线就是利用中心差分格式得到的梯度函数。以你的坐标几何知识,你应该能够推断出这是函数 2x 的一个图。换句话说,dy/dx = 2x。任何做过微积分的人都会立刻认出 2x 是 y = x 2 的梯度函数,也就是我们的原始函数——所以中心差分格式做得非常好。使用前向差分方案计算的另一行有一点偏移,这意味着它将有一个相关的误差。这被称为数值积分误差。当然,所有的数值方法都会有误差。但是在这种情况下,由于中心差分方案产生的误差非常小,以至于在图上看不到。我们将在第十四章的中更详细地讨论数值精度。

A978-1-4302-6338-8_3_Fig29_HTML.jpg

图 3-29。

Derivatives computed by the forward (thin line) and central (thick line) difference schemes

做加法:积分

现在我们问这样一个问题:反过来有可能吗?假设我们知道一个函数的导数;我们能找到函数吗?答案是肯定的。这就叫融合。同样,通常在数学中,你会用分析的方法来做。但这里我们将通过代码进行数值积分。

作为数值积分的一个例子,让我们反转前面的向前差分方案以找到 y(n+1),它给出如下结果:

A978-1-4302-6338-8_3_Figxx_HTML.jpg

现在 y '给定了,我们就可以从前一个 y(n)算出 y(n+1)。你可以看到这将是一个迭代过程,我们增加 y 的值,这是一个和,这就是积分。积分的结果叫做积分,就像微分的结果叫做导数一样。

让我们把这个应用到导数 y' = 2x 上。我们应该可以恢复函数 y = x 2 。下面是实现这一点的代码:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

numPoints=1001;

var numGrad=1;

var xRange=6;

var xStep;

var graph = new Graph(context,-4,4,-10,10,275,210,450,350);

graph.drawgrid(1,0.2,2,0.5);

graph.drawaxes('x','y');

var xA = new Array();

var yA = new Array();

// calculate function

xStep = xRange/(numPoints-1);

for (var i=0; i<numPoints; i++){

xA[i] = (i-numPoints/2)*xStep;

yA[i] = f(xA[i]);

}

graph.plot(xA,yA,'#ff0000',false,true); // plot function

// calculate gradient function using forward method

var xAr = new Array();

var gradA = new Array();

for (var j=0; j<numPoints-numGrad; j++){

xAr[j] = xA[j];

gradA[j] = grad(xA[j],xA[j+numGrad]);

}

graph.plot(xAr,gradA,'#0000ff',false,true); // plot gradient function

// calculate integral using forward method

var xAi = new Array();

var integA = new Array();

xAi[0] = -3;

integA[0] = 9;

for (var k=1; k<numPoints; k++){

xAi[k] = xA[k];

integA[k] = integA[k-1] + f(xA[k-1])*(xA[k]-xA[k-1]);

}

graph.plot(xAi,integA,'#00ff00',false,true); // plot integral

function f(x){

var y;

y = 2*x;

return y;

}

function grad(x1,x2){

return (f(x1)-f(x2))/(x1-x2);

}

function integ(x1,x2){

return (f(x1)-f(x2))/(x1-x2);

}

完整的源代码在integration.js中。你会注意到的一件事是,你必须指定起点。从前面的等式中可以清楚地看出这一点;因为 y(n+1)依赖于 y(n),所以首先需要知道 y(0)(和 x(0))的值。这叫做初始条件。当你积分时,你必须指定一个初始条件。在代码中,我们通过指定 x(0)=–3 和 y(0) = 9 来实现这一点。尝试另一个初始条件,看看你得到什么;例如,x(0)=–3,y(0) = 0。

这个例子可能看起来是人为的,但事实上它描述了一个真实的物理例子:垂直向上扔球。函数 f(x)表示垂直速度,它的积分是垂直位移。实际上,导数 f '(x)就是加速度。如果你计算它(就像我们在代码中做的那样),你会发现它是一个常数——一条对所有 x 都有相同 y 值的直线,因为重力加速度是常数。如果你还没猜到这个例子中 x 代表什么,它代表时间。初始条件 x(0)=–3,y(0) = 9 代表球的初始位置。现在很清楚为什么你首先需要一个初始条件,为什么不同的初始条件给出不同的曲线。当然,x 代表时间,你可能会从 x(0) = 0 开始。如果你想这么做,继续使用 y 的积分值来制作一个球的动画,以显示它确实产生了一个向上抛的球。您需要做的一件事是缩放 y,使它在画布元素上延伸足够多的像素。

你可以从恒定加速度 f’(x)开始,积分得到速度,然后积分速度得到位移。事实上,这就是我们所做的,以一种简化的方式,在本书的第一个例子中:bouncing-ball.js回到第一章。

我们在两个示例(integration.jsbouncing-ball.js)中使用的正向方案是最简单的集成方案。在第十四章,我们将详细讨论数值积分和不同的积分方案。

摘要

哇哦!很好地幸存了这一章。你现在有了一套出色的工具供你使用。希望你能开始看到应用这些想法和方法的潜力。毫无疑问,你有许多自己的想法。

如果你对本章的一些内容有困难,不要担心。当你在后面的章节中看到实践中应用的概念时,事情会变得清楚得多。你可能想要再次回到这一章来刷新你的记忆和加强你的理解。

你现在准备好上物理课了。所以,好好休息一下,当你觉得准备好了,让我们进入下一章。

四、基本物理概念

前两章涵盖了大量关于 JavaScript 编码和数学工具的背景材料。这一章将提供一个基本物理学的概述,以建立你开始基于物理学的编程所需要的最后一组关键概念。在此过程中,我们将使用 JavaScript 示例来说明其中的一些概念,并将构建将在整本书中使用的有用的物理对象。

本章涵盖的主题包括以下内容:

  • 普通物理学概念和符号:物理学处理可测量的量。这个简短的部分回顾了一些关于物理量及其单位的性质的基本事实,并解释了使用科学符号来表示物理量的值。
  • 事物——物理学中的粒子和其他物体:物理学中的事物使用理想化的概念建模,例如粒子。我们构建一个 JavaScript Particle对象来实现适当的粒子属性和方法,并扩展它来创建一个可见的Ball对象。
  • 描述运动——运动学:本节解释描述和分析运动所需的概念和方法。它包含任何准物理程序员必须知道的一些基本定义和公式。
  • 预测运动——力和动力学:要预测运动,你需要了解运动的原因:力。动力学是对力和运动的研究。通过指定作用在物体上的力,你可以计算出它的运动。
  • 能量概念:能量是物理学中一个强有力的概念,它使我们能够以简单的方式解决一些棘手的问题。

普通物理概念和符号

这一小段回顾了一些一般概念,并建立了一些适用于所有物理学的基本术语和符号。

物理量和单位

在物理学中,当你谈论长度或质量或能量时,你希望尽可能精确。理想情况下,你希望能够测量或计算你所谈论的事情。例如,物体的大小不仅仅是一种质量;这是一个数量。物理量是可测量的属性。它们可以被赋予一个数值或数量级。

物理量还必须有另外一个东西:单位。比如我们不只是说某个特定表的长度是 2;我们说(在世界上大多数地方)是 2 米。米(m)是单位。为什么我们需要一个单位?一个单位做两件事。首先,它告诉我们正在谈论什么样的事情。米告诉我们,我们正在处理一个长度,而不是温度或其他东西。第二,单位建立了一个参照物,我们用它来比较量的大小。当我们说一张桌子的长度是 2 米时,我们实际上是说它的长度是我们称之为米的参照物的两倍。所有的测量值只有在我们与之比较的参考值下才有意义。

如你所想,有不同的单位选择。例如,要测量长度或距离,可以使用米或英尺。科学文献中通常的长度单位是米(m)及其细分数和倍数,如厘米(cm)和千米(km)。关键是使用一致的单位,而不是混合不同类型的单位。否则,我们可以使用任何合适的单位制。例如,在计算机图形和动画工作中,我们通常以像素为单位来测量距离。

在前一章中,你遇到了标量和向量。简单回顾一下,向量是一个有大小和方向的数学对象,而标量只有大小。物理量可以是标量或矢量。前一章提到的矢量的一个例子是位移。在这一章的后面你会遇到更多的例子。

科学符号

在物理学中,你经常会遇到非常大和非常小的数字。例如,光速大约为 300 000 000 m/s。我们通常将这写成 3×108m/s。108(“10 的 8 次方”)是 1 后跟 8 个 0,等于 1 亿。这就是所谓的科学记数法。想法是用这种形式写大或小的数,其中 A 大于等于 1 但小于 10,B 是正整数或负整数:

A978-1-4302-6338-8_4_Figa_HTML.jpg

如示例所示,如果 B 为正,10 B 为 1,后跟 B 个零。如果 B 是负整数呢?在这种情况下,我们将 1 放在小数位后的第 B 位,在它之前有零:例如,10 -4 与 0.0001 相同。

举个例子,一个电子(围绕原子中心的微小粒子)的质量大约是 9.1 × 10 -31 kg。

在 JavaScript 中,你把这个写成 9.1e-31,光速写成 3e8。这里的“e”一定不能和上一章介绍的数字 e 混淆,在 JavaScript 中是Math.E。这里,e 用来表示 10 的幂,我们用它乘以它前面的数。

事物:物理学中的粒子和其他物体

为了描述和模拟真实世界,物理学必须有一些物理事物的表示。物理学的理论是现实的概念和数学模型。它们由现实生活中存在的事物的理想化概念组成。一个这样的理想化是粒子的概念。虽然在这一节我们将专门关注粒子,但为了将它们放在上下文中,这里列出了您将在本书中遇到的各种“事物”:

  • 粒子:从这一节开始,本书的大部分内容将涉及粒子。我们很快会谈到更多的粒子,但简单地说,它们可以被认为是存在于空间离散点的不可分割的单元。因此,它们不同于可以扩展或由多个部分组成的更复杂的对象。粒子以一种简单的方式运动:它们基本上可以改变它们的位置。这也被称为翻译。
  • 刚体:刚体是一种扩展的物体,其大小和形状不容易改变,如桌子或汽车。刚体可以像粒子一样通过平移运动,但除此之外,它们还可以进行旋转。刚体运动将在第十三章中介绍。
  • 可变形物体:像刚体一样,可变形物体可以平移和旋转,但除此之外,它们的形状和/或大小也可以改变。例子包括一个橡皮球、一个布娃娃或一块布。建模可变形物体的方法将在第十三章中讨论。
  • 流体:流体不同于之前描述的物体,因为它们没有明确的大小或形状,但具有从空间的一部分流向另一部分的能力。因为流体的任何部分都可以移动,所以精确模拟流体运动要困难得多。但有时也有可能通过用粒子模拟流体来“作弊”。这是我们在第十二章中用来创造一些有趣的流体视觉效果的方法。
  • 字段:字段是更抽象的实体。广义上讲,场是在空间上连续存在的某种物理量。例如,我们将在第十章中探讨力场的概念。

什么是粒子?

因为我们要用粒子做很多事情,所以让我们多讨论一下。什么是粒子?可能首先想到的是微小的实体,如电子。在物理学中,这些被称为基本粒子。那不一定是我们在这里谈论的。

我们使用“粒子”这个词的意义在于,它是对任何物理对象的数学理想化,这些物理对象的内部结构或成分对我们所考虑的任何问题都不重要。这可能包括原子,或台球,甚至是星系中的恒星!从这个意义上来说,粒子基本上只是一组表征物体个体的属性。

粒子属性

让我们更准确地解释一下上一节中最后一句话的意思。我们在谈论什么属性?粒子具有以下特性:

  • 位置:粒子一定在某个地方!这意味着它必须具有坐标 x 和 y(以及 3D 中的 z)。
  • 速度:粒子运动,所以有速度。我们将在下一节正式定义速度;现在只需要注意它是一个有分量的向量,就像位置一样。
  • 质量:作为一个物理事物,粒子也一定有质量。
  • 电荷:一些基本粒子,如电子,也有一种称为电荷的属性,这使它们经历一种有趣的力,称为电磁力。因为我们将在本书中讨论这个力,我们也想给我们的粒子一个电荷属性。
  • 其他(自旋,寿命,等等):我们可以包括其他属性,从物理学的基本粒子中获得灵感。但是现在,我们将坚持使用这里列出的属性。

这个背景给了我们足够的素材来开始构建一些 JavaScript 对象来表示粒子和它们的行为,尽管在我们这样做时引入的一些粒子属性和运动概念将在本章的后续章节中更深入地讨论。

首先,我们将创建一个实现刚才描述的粒子属性的Particle对象。然后,我们将创建一个扩展了ParticleBall对象,绘制图形以便能够看到Ball实例,同时表现为粒子。最后,我们将展示如何使粒子运动。

构建粒子对象

我们需要创建基于物理属性的对象属性,如质量、电荷、位置和速度,如前一节所述。我们应该能够读取和修改这些属性。表 4-1 显示了我们想要创建的属性。

表 4-1。

Properties of the Particle object

| 财产 | 类型 | 可能的价值和意义 | | --- | --- | --- | | `mass` | `Number` | 任何正值(默认值为 1) | | `charge` | `Number` | 任何值:正数、负数或零(默认值) | | `x` | `Number` | 任何值;位置的 x 分量 | | `y` | `Number` | 任何值;位置的 y 分量 | | `vx` | `Number` | 任意值(默认为 0);速度的 x 分量 | | `vy` | `Number` | 任意值(默认为 0);速度的 y 分量 | | `pos2D` | `Vector2D` | 任何`Vector2D`值;2D 位置向量 | | `velo2D` | `Vector2D` | 任何`Vector2D`值;2D 速度矢量 |

这些属性的选择和它们可能的值需要一些解释。很明显mass不可能是负数或者零,因为每个粒子都有质量。正如你将在第十章中看到的,charge可以是正的、负的或零。电荷为零的粒子会表现得好像根本没有电荷一样。

在前一章赞美了向量的优点后,我们不使用它们就是伪君子了。因此,我们将位置和速度向量pos2Dvelo2D创建为来自它们各自组件的Vector2D对象。

这些属性的实现过程如下。首先,我们在其构造函数中定义了Particle masschargexyvxvy属性:

function Particle(mass,charge){

if(typeof(mass)==='undefined') mass = 1;

if(typeof(charge)==='undefined') charge = 0;

this.mass = mass;

this.charge = charge;

this.x = 0;

this.y = 0;

this.vx = 0;

this.vy = 0;

}

我们希望能够在创建粒子时设置它们的质量和电荷,因此使用构造函数并将其作为参数输入构造函数是有意义的。请注意,Particle实例的质量和电荷值分别默认为 1 和 0,而位置和速度分量最初都被赋值为 0。

通过 getters 和 setters 将pos2Dvelo2D属性添加到Particle原型中:

Particle.prototype = {

get pos2D (){

return new Vector2D(this.x,this.y);

},

set pos2D (pos){

this.x = pos.x;

this.y = pos.y;

},

get velo2D (){

return new Vector2D(this.vx,this.vy);

},

set velo2D (velo){

this.vx = velo.x;

this.vy = velo.y;

}

};

(请注意,如果您使用 Internet Explorer,尤其是 IE8 或更早版本,使用这些 getter/setter 可能会出错。)通过这些访问器,您可以简单地通过键入particle.pos2D来获取或设置名为particleParticle实例的pos2D属性,对于velo2D属性也是如此。自然,您可以使用组件xypos2D向量读取或分配Particle实例的位置坐标,对于velo2D也是如此。这就是Particle对象的全部内容。

文件particle-example.js包含了Particle对象的用法示例。您需要启动 JavaScript 控制台来查看代码的输出。

为了简化更新粒子的pos2Dvelo2D值的代码,我们向Vector2D对象添加了两个方法multiply(k)addScaled(vec, k)(其中vec是一个Vector2Dk是一个Number)。vec1.multiply(k)方法将向量vec1乘以标量kvec1.addScaled(vec, k)k乘以vec加到vec1

例如,要更新名为particleParticle实例的位置,请编写以下代码:

particle.pos2D = particle.pos2D.add(particle.velo2D.multiply(dt));

或者这样做,它包含一个方法调用addScaled(),而不是两个方法调用multiply()add():

particle.pos2D = particle.pos2D.addScaled(particle.velo2D, dt);

这些相当于组件形式:

particle.x += particle.vx * dt;

particle.y += particle.vy * dt;

扩展粒子对象

我们在前面部分所做的是很棒的东西,但是你还没有看到任何粒子。那是因为我们的粒子目前是不可见的。对象被有意地保持在最低的复杂程度。现在是时候扩展它,加入一些我们能看到的东西了。为此,我们将通过对我们在前面章节中使用的旧版本做一些修改来重新发明Ball对象。

球对象

与其从前面章节中已有的Ball对象开始,不如从Particle对象开始,并添加一些额外的属性和方法,将它变成可见的东西。首先我们添加一个radius和一个color属性,类似于第三章中的Ball对象。然后我们引入一个额外的属性gradient,一个Boolean,它指定Ball对象是否要用渐变来绘制。最后,我们将draw()方法添加到Ball的原型中,增加了绘制带有或不带渐变的球的选项。Ball对象的完整代码如下所示:

function Ball(radius,color,mass,charge,gradient){

if(typeof(radius)==='undefined') radius = 20;

if(typeof(color)==='undefined') color = '#0000ff';

if(typeof(mass)==='undefined') mass = 1;

if(typeof(charge)==='undefined') charge = 0;

if(typeof(gradient)==='undefined') gradient = false;

this.radius = radius;

this.color = color;

this.mass = mass;

this.charge = charge;

this.gradient = gradient;

this.x = 0;

this.y = 0;

this.vx = 0;

this.vy = 0;

}

Ball.prototype = {

get pos2D (){

return new Vector2D(this.x,this.y);

},

set pos2D (pos){

this.x = pos.x;

this.y = pos.y;

},

get velo2D (){

return new Vector2D(this.vx,this.vy);

},

set velo2D (velo){

this.vx = velo.x;

this.vy = velo.y;

},

draw: function (context) {

if (this.gradient){

grad = context.createRadialGradient(this.x,this.y,0,this.x,this.y,this.radius);

grad.addColorStop(0,'#ffffff');

grad.addColorStop(1,this.color);

context.fillStyle = grad;

}else{

context.fillStyle = this.color;

}

context.beginPath();

context.arc(this.x, this.y, this.radius, 0, 2*Math.PI, true);

context.closePath();

context.fill();

}

};

可以看到,构造函数有五个可选参数:radiuscolormasschargegradient,默认值分别为 20、“#0000ff”、1、0 和falseBoolean gradient选项决定球是用渐变填充还是简单填充绘制。和以前一样,实际的绘制发生在draw()方法中。

这里,我们通过简单地复制Particle的属性和方法,从头开始创建了Ball对象。一个更聪明的方法是使用Object.create()方法模拟经典继承,用Particle原型创建Ball对象:

Ball.prototype = Object.create(Particle.prototype);

Ball.prototype.constructor = Ball;

然后可以用附加的draw()方法来扩充Ball对象,因此:

Ball.prototype.draw = function (context) {

// code as before

}

我们演示了在文件ball2.js中创建Ball对象的方法。要使用它,只需在相关的 HTML 文件中包含ball2.js文件,而不是ball.js。记得也包括particle.js文件。在以后的例子中使用ball.js还是ball2.js纯粹是个人喜好问题,不会以任何方式影响结果。

使用球对象

使用Ball对象非常简单。这段代码片段创建一个ball对象,初始化它的位置,并绘制它:

var ball = new Ball(20,'#ff0000',1,0,true);

ball.pos2D = new Vector2D(150,50);

ball.draw(context);

这段代码生成一堆大小和位置随机的球,并将它们放入一个数组中,以便在后面的代码中引用它们(见图 4-1 ):

var balls = new Array();

var numBalls = 10;

for (var i=1; i<=numBalls; i++){

var ball;

var radius = (Math.random()+0.5)*20;

var color = '#0000ff';

ball = new Ball(radius,color,1,0,true);

ball.pos2D = new Vector2D(Math.random()*canvas.width,Math.random()*canvas.height);

ball.draw(context);

balls.push(ball);

}

A978-1-4302-6338-8_4_Fig1_HTML.jpg

图 4-1。

A random bunch of  Ball objects

这些例子的代码在ball-test.js中。为了运行这段代码,ball-test.html文件使用了ball.js文件,而ball-particle-inheritance-test.html使用了ball2.js(参见上一节)。

移动粒子

图 4-1 中描述的Ball实例看起来很酷,但是它们只是坐在那里,没有做太多事情。我们需要让他们动起来。原则上,让Particle(和Ball)实例移动并不困难:我们只需要建立一个动画循环,更新粒子的位置,并在每个时间步重画它。为此,我们将使用基于requestAnimationFrame()方法的动画循环,以及Date.getTime()方法来精确计算时间间隔。我们建议你看一下第二章、getTime-example.js的最后一个例子,刷新一下你对基本逻辑和代码结构的记忆。ball-move.js中的示例代码建立在getTime-example.js(第二章)中的代码之上,在此完整展示:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var ball;

var t;

var t0;

var dt;

var animId;

var animTime = 5; // duration of animation

window.onload = init;

function init() {

ball = new Ball(20,'#ff0000',1,0,true);

ball.pos2D = new Vector2D(150,50);

ball.velo2D=new Vector2D(30,20);

ball.draw(context);

t0 = new Date().getTime();

t = 0;

animFrame();

};

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

t += dt;

if (t < animTime){

move();

}else{

stop();

}

}

function move(){

ball.pos2D = ball.pos2D.addScaled(ball.velo2D,dt);

context.clearRect(0, 0, canvas.width, canvas.height);

ball.draw(context);

}

function stop(){

cancelAnimationFrame(animId);

}

大部分代码处理设置计时,如第二章中的所述,因此应该很熟悉。init()函数创建一个Ball实例,初始化它的位置和速度,并在画布上绘制它。然后它初始化时间变量并调用animFrame()函数,该函数设置动画循环。在每个时间步长触发的onTimer()函数计算从第一次调用dt起经过的时间间隔(以秒为单位),然后更新从模拟开始的持续时间t(以秒为单位)。后者是通过将所有经过的时间间隔dt相加到当前时间间隔来计算的。onTimer()函数还包含一个if循环,如果模拟的总运行时间小于指定的持续时间animTime,则调用move()函数,否则调用stop()函数,从而终止动画循环。move()函数根据球的速度向量更新球的位置向量,擦除画布上的所有内容,并重新绘制球对象。在网络浏览器中打开文件ball-move.html,您将看到一个球在画布上匀速运动。

这段代码中计时的基本设置将在以后的例子中反复使用。下一个例子balls-move.js,扩展代码使几个球同时移动。唯一的实质性变化发生在init()move()方法中:

function init() {

balls = new Array();

for (var i=0; i<numBalls; i++){

var radius = (Math.random()+0.5)*20;

var ball = new Ball(radius,'#0000ff',1,0,true);

ball.pos2D = new Vector2D(canvas.width/2,canvas.height/2);

ball.velo2D = new Vector2D((Math.random()-0.5)*20,(Math.random()-0.5)*20);

ball.draw(context);

balls.push(ball);

}

t0 = new Date().getTime();

t = 0;

animFrame();

};

init()中,现在创建了许多球,并初始化了它们的位置和速度。然后将它们放入一个名为balls的数组中,就像上一节中的例子一样。这一次,这个数组实际上是用在了move()函数中:

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

for (var i=0; i<numBalls; i++){

var ball = balls[i];

ball.pos2D = ball.pos2D.addScaled(ball.velo2D,dt);

ball.draw(context);

}

}

正如你所看到的,我们遍历了所有的balls元素来更新每个球的位置,并在画布元素上重新绘制它。通过在浏览器中打开文件balls-move.html运行代码,您将看到不同大小的球从相同的初始位置移出。

在所有这些例子中,球以恒定的速度运动。真正有趣的情况是当速度随时间变化时。我们将在本章中很快介绍这一点。但首先我们需要介绍一些运动概念。

描述运动:运动学

在这一节中,我们将开始用精确的概念、方程和图形来更正式地描述运动。对运动的描述叫做运动学。在运动学中,我们不关心是什么引起运动,而只关心如何描述它。下一节,“预测运动:力和动力学”,将着眼于运动的原因:力。

概念:位移,速度,速度,加速度

到目前为止,我们已经相当松散地讨论了速度和加速度之类的东西。现在是时候准确地说出这些量的含义了。

以下是运动学中需要定义的主要概念:

  • 排水量
  • 速度(以及相关的速度概念)
  • 加速

这些物理量依赖于更基本的概念——位置、距离和时间——这些概念不言而喻,不需要定义。对于矢量来说,还有第四个基本概念:角度,它定义了空间中的一个方向。

排水量

位移是一个与物体运动相关的矢量。更准确地说,它是连接物体初始位置和最终位置的向量。还记得第三章的 Bug 的例子吗?Bug 从原点沿 y 轴移动 10 个单位的距离,这就是位移。从那里开始,它再向右移动 10 个单位(在 x 轴的正方向)。

在这个例子中,Bug 的净位移或合成位移是A978-1-4302-6338-8_4_Figb_HTML.jpg,或者在 45 度角上大约 14.1 个单位。但是它移动的净距离当然是 20 个单位。这个例子说明了位移和距离的区别。一般来说,一个物体可能会沿着任何复杂的轨迹移动:移动的距离就是沿着轨迹的长度,而位移总是连接物体初始位置和最终位置的向量(见图 4-2 )。

A978-1-4302-6338-8_4_Fig2_HTML.jpg

图 4-2。

Displacement vector (arrow) contrasted with distance along trajectory

位移通常用符号 s 或 x 表示,其大小用 s 或 x 表示。位移(和距离)的常用单位是米及其倍数(例如 km)和分数(cm、mm 等)。在画布上,你在电脑屏幕上看到一个舞台,而不是真实的空间,你会用像素来考虑距离和位移,我们通常缩写为 px。

速度

速度被定义为位移随时间的变化率。它告诉你某物移动的速度,以及它移动的方向。因此,速度是一个矢量。

速度的通常符号是 u 或 v;对于它的大小,u 或 v。

还记得微积分里变化率是什么意思吗?这是梯度函数,或者导数。因此,速度是位移对时间的导数。在微积分符号中,我们把它写成

A978-1-4302-6338-8_4_Figc_HTML.jpg

同样,随着时间间隔δt(以及因此位移变化δs)变小,该导数是比率δs/δt 的极限。因此,对于有限的间隔,我们可以写

A978-1-4302-6338-8_4_Figd_HTML.jpg

这给出了时间间隔δt 内的平均速度,而 v = d s/dt 给出了给定时间的瞬时速度。在速度恒定的特殊情况下,任意时刻的平均速度等于瞬时速度。

如前所述,物理学中通常的速度单位是米每秒(m/s),尽管在日常生活中通常使用公里/小时和英里/小时。在 canvas 上下文中,时间的度量仍然是秒,但是距离的度量是像素。所以,你通常会认为速度是以像素每秒(px/s)来表示的。

在计算机代码中,我们总是在处理离散区间,所以我们实际上是在处理平均速度。但是如果我们使用足够小的时间间隔,平均值就接近瞬时值。可以重新排列前面的等式,用 v 和δt 表示δs:

A978-1-4302-6338-8_4_Fige_HTML.jpg

这个方程告诉我们,位移增量是速度和时间间隔的乘积。这正是我们在ball-move.js示例中的move()方法中所做的:

ball.pos2D = ball.pos2D.addScaled(ball.velo2D,dt);

你也可能认为这等同于使用正向方案进行时间积分(参见第三章)。这很有道理——因为速度是位移对时间的导数,位移是速度对时间的积分。我们将使用这个积分方案,具体地说就是欧拉方案,直到我们读到本书的第四部分。

速度

速度是速度的标量形式;它没有方向。速度被定义为距离随时间的变化率,换句话说,就是单位时间内移动的距离。速度的单位和速率的单位是一样的。

物体的平均速度是移动的总距离除以花费的总时间:

A978-1-4302-6338-8_4_Figf_HTML.jpg

在这本书里,大部分时间我们会讨论速度和速度分量,而不是速度。

加速

加速度被定义为速度随时间的变化率。它告诉你物体的速度变化有多快。如果你以 20 米/秒(约 45 英里/小时)的匀速直线行驶,你没有加速——加速度为零。但如果你踩下油门,在 10 秒内将汽车速度从 20 米/秒提高到 30 米/秒,那么平均加速度就是(30–20)/10 米/秒,或者 1 米/秒 2 。这意味着速度每秒增加 1 米/秒。加速度也可以是负的;在这种情况下,速度值减小。

加速度的微积分定义是

A978-1-4302-6338-8_4_Figg_HTML.jpg

离散版本是

A978-1-4302-6338-8_4_Figh_HTML.jpg

这些方程类似于速度方程,只是用速度代替了位移。与相应的速度方程一样,a =δv/δt 给出时间间隔δt 内的平均加速度,而 a = d v/dt 给出给定时间的瞬时加速度。

Note

在加速度恒定的特殊情况下,平均加速度等于瞬时加速度。我们将在下一节说明这一事实。

颠倒前面的等式得出:

A978-1-4302-6338-8_4_Figi_HTML.jpg

解释为一个离散方程,这给了我们一个积分方案,更新速度给定的加速度。这是您使用Vector2D在 JavaScript 中编写代码的方式:

particle.velo2D = particle.velo2D.add(acc.multiply(dt));

或者

particle.velo2D = particle.velo2D.addScaled(acc, dt);

这里acc是代表加速度的变量。它是一个Vector2D对象。假设我们使用一个固定值:

var acc:Vector2D = new Vector2D(0,10);

这会让我们的粒子以 10 px/s 2 的速度向下加速,模拟重力的作用。

因为加速度是速度的变化率,如果运动方向改变,即使速度不变,加速度也不为零。一个常见的例子是物体匀速圆周运动。因为物体的运动方向改变了,它的速度也就改变了。因此,它的加速度不为零。我们将在第九章中详细讨论这种情况。

组合向量

因为位移、速度和加速度都是矢量,所以不言而喻,它们必须用矢量方法组合(加或减)。在前一章中你已经看到了一个例子,所以我们在这里不再重复。

为什么要把这些量结合起来?您可能经常想要添加位移和速度来计算它们的总和(也称为它们的合力)。同样,你在第三章中看到了一个合成位移的例子。一个你想要增加速度的例子是一艘船在流动的河流上。船只的合成速度由船只在水中的速度与水流速度的矢量和给出(见图 4-3 )。

A978-1-4302-6338-8_4_Fig3_HTML.jpg

图 4-3。

Resultant velocity

当计算相对速度时,你需要使用矢量减法。假设你有一艘宇宙飞船 A 在追逐另一艘宇宙飞船 B,在太空中以不同的速度 A 和 B 运动(见图 4-4 )。B 相对于 A 的速度是多少?那是从 A 的角度看 B 的速度,换句话说,是 B 在 A 不动(我们说它静止)的参照系中的速度。稍微静下心来想一想,你应该会相信它是由 B 减去 A 的速度给出的,在矢量意义上:B–A。图 4-4 向你展示了 B–A 在几何上的含义:它与 b+(–A)相同,因为(–A)以相同的大小指向 A 的相反方向,我们最终得到 B–A 指向所示的方向。这是从 A 的角度看 B 的速度。

A978-1-4302-6338-8_4_Fig4_HTML.jpg

图 4-4。

Relative velocity

用图形描述运动

物体的运动可以用不同的方式用图形表示。对于一个二维运动的粒子,你可以在不同的时间绘制它的 y 坐标和 x 坐标。当然,最终的图形,只会给你粒子的轨迹。

还可以绘制位移、速度或加速度随时间变化的曲线。这些图表能告诉我们关于粒子如何运动的有用信息。

你将很快看到一些例子,在这些例子中,我们使用了在前一章中介绍的Graph对象来创建这样的图形。

匀加速运动方程

对于匀加速运动的特殊情况(也称为匀速运动),仅从速度和加速度的定义出发,就有可能得到一组非常有用的方程。这些运动方程可以用来分析加速度为常数的问题,包括重力作用下物体的下落运动和抛射体的运动。

我们可以只陈述运动方程,并要求你接受它们。但是如果你看到他们来自哪里,你可能会更好地理解他们。这并不困难。你只需要做少量的代数运算。起点是平均加速度的定义:

A978-1-4302-6338-8_4_Figj_HTML.jpg

如果加速度不变,a av 也等于任意时刻 t 的瞬时加速度 a。

现在让我们建立初始条件,假设在时间 t = 0(初始),s = 0(零初始位移),v = u(初始速度用 u 表示)。

因此,在时间 t,我们有δv = v–u,和δt = t–0 = t,因此

A978-1-4302-6338-8_4_Figk_HTML.jpg

现在很容易改变这个公式的主题:

A978-1-4302-6338-8_4_Figl_HTML.jpg

这个公式给出了任意时刻 t 的速度 v,给定了初速度 u 和(恒定)加速度 a。

如果也有一个类似的位移方程就好了。能做到吗?是的,你只需要运用同样的技巧,这次从平均速度的定义开始:

A978-1-4302-6338-8_4_Figm_HTML.jpg

现在我们有δs = s–0 = s,δt = t–0 = t,这就给出

A978-1-4302-6338-8_4_Fign_HTML.jpg

因为速度线性增加,平均速度 v av 正好是A978-1-4302-6338-8_4_Figo_HTML.jpg (u + v),初速度和终速度的平均值。代入前面的等式得出:

A978-1-4302-6338-8_4_Figp_HTML.jpg

现在你可以用已经导出的公式(v = u + a t)来代替 v,这样做并简化,你应该得到这个:

A978-1-4302-6338-8_4_Figq_HTML.jpg

现在坐下来,欣赏你努力工作的非凡成果。这里有一个公式,告诉你一个质点在任意时刻 t 的位移 s,用质点的初速度 u 和(恒定)加速度 a 来表示。

如果质点在时间 t = 0 时从原点出发,这将等于质点在时间 t 时的位置向量。一般来说,如果粒子在时间 t = 0 从位置 pos0 = (x0,y0,z0)开始,那么它在时间 t 的位置向量 pos = (x,y,z)将由以下伪代码给出:

pos = u * t  + 0.5 * a * t * t + pos0

这给了你粒子的位置,作为一个精确的解析方程。在这种情况下,没有必要使用数值积分。

您可以直接以矢量形式编码这个解决方案(正如我们所做的那样),或者如果您愿意,也可以将它拆分成组件。在伪代码中,你得到的是 3D(在 2D 忽略 z 分量):

x = ux * t + 0.5 * ax * t * t + x0

y = uy * t + 0.5 * ay * t * t + y0

z = uz * t + 0.5 * az * t * t + z0

在我们离开这一部分之前,你应该知道有第三个公式,有时证明是有用的。前面推导的两个公式(v = u + a t,s = u t + A978-1-4302-6338-8_4_Figr_HTML.jpg a t 2 )给出了速度 v 和位移 s,它们都是时间 t 的函数。如果去掉这两个方程之间的时间 t,就可以得到关于 v 和 s 的方程:

A978-1-4302-6338-8_4_Figs_HTML.jpg

注意,这个是标量方程,不是矢量方程。

Caution

如前所述,本节推导的运动方程只对恒定加速度有效。如果加速度随时间变化,他们会给出错误的答案。

示例:将方程式应用于抛体运动

是时候举个例子了。让我们应用前面的方程来模拟炮弹等抛射体的运动。为此,我们将忽略除重力之外的所有其他力。在那种情况下,射弹在飞行过程中的任何时候所受的力都是恒定的。这会给它一个恒定的加速度(下一章你会完全明白为什么)。

我们首先不模拟发射炮弹的爆炸力。这个力非常短暂,它的作用是给炮弹一个初速度 u。

在这种特殊情况下,分量形式的射弹位置方程简化为伪代码形式:

x = ux * t + x0

y = uy * t + 0.5 * g * t * t + y0

z = uz * t  + z0

因为重力产生的加速度指向垂直下方,所以其 x 和 z 分量 ax 和 az 为零,ay = g,如果使用矢量,只需在方程 pos = u * t + 0.5 * a * t * t + pos0 中指定加速度矢量为 3D 中的(0,g,0)和 2D 中的(0,g)。

让我们用这些公式来编写一个简单的抛射体模拟程序。代码在projectile-test.js中,在此复制:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var ball1;

var ball2;

var t;

var t0;

var dt;

var animId;

var pos0 = new Vector2D(100,350);

var velo0 = new Vector2D(20,-80);

var acc = new Vector2D(0,10); // acceleration due to gravity

var animTime = 16;

window.onload = init;

function init() {

ball1 = new Ball(15,'#000000',1,0,true);

ball1.pos2D = pos0;

ball1.velo2D = velo0;

ball2 = new Ball(15,'#aaaaaa',1,0,true);

ball2.pos2D = pos0;

ball2.velo2D = velo0;

ball1.draw(context);

ball2.draw(context);

t0 = new Date().getTime();

t = 0;

animFrame();

};

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;}; // fix for bug if user switches tabs

t += dt;

if (t < animTime){

move();

}

}

function move(){

// numerical solution - Euler scheme

ball1.pos2D = ball1.pos2D.addScaled(ball1.velo2D,dt);

ball1.velo2D = ball1.velo2D.addScaled(acc,dt);

// analytical solution

ball2.pos2D = pos0.addScaled(velo0,t).addScaled(acc,0.5*t*t);

ball2.velo2D = velo0.addScaled(acc,t);

// display

context.clearRect(0, 0, canvas.width, canvas.height);

ball1.draw(context);

ball2.draw(context);

}

init()函数中,代码创建了两个球(一个灰球和一个黑球),它们最初位于相同的位置(100,350),具有相同的初速度(20,–80),并受到相同的向下加速度(0,10)来模拟重力。在move()函数中,这两个球的位置和速度被不同地更新。对于第一个球,我们实现了欧拉积分方案(如速度一节中所讨论的),通过将加速度乘以当前时间间隔dt来更新速度。球的位置也是如此。对于第二个球,我们根据精确的解析公式 pos = u * t + 0.5 * a * t * t + pos0 指定一个新的当前位置。不涉及数值近似。

在运行代码之前,请注意onTimer()中的以下附加代码行:

if (dt>0.2) {dt=0;};

这是对用户切换浏览器标签然后返回时出现的错误的修复。当这种情况发生时,requestAnimationFrame()停止活动,直到用户返回到当前选项卡,因此动画“冻结”在当前帧上。然而,当用户返回时,经过的时间dt仍然被计算为对getTime()的最后一次调用和当前时间之间的差值,导致该帧中dt的人为大值。然后move()函数中的代码在很长的时间间隔dt内使用相同的速度更新粒子的位置。如果速度是恒定的,就像我们在本章前面看到的ball-move.js例子一样,这是没问题的。但一般来说,如本例所示,速度实际上会因加速度而变化。人为的大值dt会导致粒子位置的非物理偏差或“跳跃”。如果dt的值恰好大于 0.2 秒的阈值,前面一行代码简单地通过将dt的值设置为 0 来解决这个问题。0.2 的值基本上是任意的,您可以使用任何您喜欢的值,只要它比动画正常运行时的典型值dt大得多(这里大约是 20 毫秒,比 0.2 秒小 10 倍),并且比切换到一个新选项卡并返回所需的时间小得多。

如果你运行这个代码,你会看到两个球一起开始,并像抛射体应该做的那样以抛物线轨迹运动。随着模拟的进行,你会看到它们开始轻微分离,如图 4-5 所示,这是由于欧拉方案造成的数值误差。

A978-1-4302-6338-8_4_Fig5_HTML.jpg

图 4-5。

Simulating the motion of a projectile with both numerical and analytical solutions

灰球就在它应该在的地方,因为它遵循运动方程的精确解析解。现在你可能通常不会关心这种差异。但是如果你正在建立一个精确的投射物模拟或者一个台球游戏,你会的。图 4-5 所示的位置差异仅发生在 16 秒的运行时间之后。如果你要运行几分钟,并包括反弹或与其他粒子的碰撞,你的粒子很快就会在它不应该在的地方。

那么,如果你的模拟或游戏要求很高的准确性,对此可以做些什么呢?如果您的模拟足够简单,可以获得解析解(如本例所示),那么使用解析解就可以解决问题。然而,在绝大多数情况下,解析解并不存在。在这些情况下,你需要使用比欧拉方法更精确的积分方法。

本书第四部分的第十四章涵盖了一些更精确的集成方案。但是在我们到达那里之前,我们将在大多数例子中使用欧拉方案,因为欧拉是最简单和最快的方案,并且我们在本书第四部分之前的大部分中的主要目的是演示物理效果,而不必太担心绝对精度。随着我们的继续,我们将指出缺乏准确性可能特别重要的例子,尽管我们将把任何解决方案推迟到第十四章。

如果担心数值精度,积分方案的选择不是唯一的考虑因素。处理突然变化(如弹跳和碰撞)的算法也会产生误差。我们将在本书的后面讨论这些错误的来源以及如何处理它们。

更多与运动相关的概念:惯性、质量和动量

到目前为止,我们被限制在几个运动概念上,而我们能取得的进展也相当有限。最大的力量来自于力的概念以及它们如何影响运动。我们将在下一节中从概念上介绍这种方法,然后在下一章中对其进行全面阐述。

在此之前,我们需要引入两个新概念,作为运动和产生运动的力之间的联系:质量和动量。

先说质量。在物理学中,质量有一个非常具体的含义:它是惯性的量度。惯性(字面意思是“懒惰”)是对运动的阻力。物体质量越大,运动阻力越大——推汽车比推自行车更难。所以我们说汽车的质量大于自行车的质量。物理学中通常的质量单位是千克(kg)。质量的通常符号是 m。

在物理学中,物体的质量和重量是不一样的。重量其实是一种力,地球施加在它身上的重力。质量是标量;重量是一个向量。我们将在下一节回到这个问题。

一个物体的动量,通常用符号 p 表示,大小为 p,定义为其质量和速度的乘积。它是一个矢量。

A978-1-4302-6338-8_4_Figt_HTML.jpg

因为它等于标量(质量)乘以速度,所以它的方向与速度矢量的方向相同。

通俗地说,动量是一个物体拥有的“运动量”的度量——对于相同的速度,汽车比自行车“运动更多”,因为它更难停下来。动量之所以重要,有两个原因:它与力密切相关,有一个定律叫做动量守恒定律,对于解决粒子间的碰撞等问题非常有用。我们将在下一章仔细研究动量及其守恒。

预测运动:力和动力学

在前一节中,我们可以通过得到一个抛射体位置随时间变化的公式来“解决”抛射体问题。这是可能的,因为我们知道加速度,它是常数,等于重力加速度 g。

预测物体运动的问题基本上归结为计算每一时刻 a(t)的加速度,无论是通过某种解析公式还是通过某种数值方法。一旦我们知道了 a,我们就知道如何去做剩下的事情——我们可以进行数值积分(使用欧拉或其他方案),或者使用解析解(如果有的话)。

在地球表面附近有重力的情况下,你现在知道 a = g,这很简单。但是我们如何计算一个物体的加速度呢?在我们给出答案之前,我们需要再谈一谈力。

运动的原因:力

力是物理学中的一个抽象概念,表示使物体运动的“某物”。更准确地说,如果有点神秘的话,力改变了事物运动的方式。这句话的意思一会儿就清楚了。

直觉上,你可以把力想象成某种推力或拉力。重力是一种力;它把你拉向地球。摩擦力是另一种力;它推着移动的物体。

力是可以测量和计算的。力的单位是牛顿,用符号 n 表示。

力是有用的,因为知道作用在物体上的力就可以预测它的运动。接下来将解释这是如何进行的。

力、质量和加速度之间的关系

让我们回到我们在本节开始时提出的问题。我们怎样才能算出物体的加速度?答案出人意料地简单:

A978-1-4302-6338-8_4_Figu_HTML.jpg

f 是作用在物体上的合力,m 是其质量,a 是力产生的加速度。就是这样。这是一个非凡的公式,可能是本书中最重要的。它之所以引人注目,是因为它以你能想象到的最简单的方式将运动(更准确地说是加速度)与其原因(力)联系起来。

方程 F = m a 是牛顿第二运动定律的特例。这是这条法律最常见的形式。在下一章中,我们将研究法律的一般形式。

你可以将公式改写如下,给定一个物体的质量 m 和作用在其上的力 F,你可以用它来计算加速度 a:

A978-1-4302-6338-8_4_Figv_HTML.jpg

正如我们在第一章中所讨论的,物体的运动是作用在物体上的力的函数:

运动=功能{力}

嗯,a = F/m 就是这个意思。这是我们的职能。力引起加速度;它们会改变物体的速度。这就是我们如何计算运动中的变化。

这个公式与我们关于力和质量分别引起和阻止运动的概念是一致的。对于一个给定的力,质量越大,产生的加速度越小,因为我们将力除以一个更大的数。

下一个问题是:我们如何知道力 F?事实上,有许多不同类型的力。幸运的是,每种力都有公式。公式来自物理理论或实验。当然,对我们来说,它们来自哪里并不重要。重点是我们可以计算出任意时刻作用在物体上的所有力,然后把它们全部加起来,得到合力 F,我们再把这个合力 F 除以物体的质量 m;这给了我们物体的加速度 a。问题解决了。

力的类型

在这本书的第二部分,我们将会详细探讨不同类型的力。这里有一些简单的例子。

重力也许是力的最明显的例子。作用在物体上的重力也称为其重量。地球(或任何其他行星或恒星)对其附近的任何物体施加与该物体质量成比例的引力。所以质量为 m 的物体的重量由下式给出

A978-1-4302-6338-8_4_Figw_HTML.jpg

其中 g 是一个垂直向下的向量,大小不变。

用 a = F/m,这就给出了 a = m g/m = g,换句话说,一个物体的重量(重力对它的作用力)产生加速度 g,如果它是唯一作用在物体上的力。这表明 g 实际上是一个加速度,称为重力加速度。在地球表面附近,其震级约为 9.81 米/秒 2 。注意,物体的质量 m“掉出来了”——所有物体不论质量大小都以相同的加速度下落(只要空气阻力等其他力与重力相比可以忽略不计)。

另一种常见的力是接触力,它是一个物体与另一个物体直接物理接触时所受到的力。例如,当你推动某物时,当两个物体碰撞时,或者当两个物体被另一个力压在一起时(例如,一本书因其重量而压在桌子上),就会发生这种情况。如果你正在读这本书的纸质版,把它放在桌子上。它不会从桌子上掉下来的原因是后者施加了一个向上的接触力,平衡了书上的重力。

另一种类型的接触力是摩擦力,当两个接触的物体相对运动时,摩擦力就会起作用。如果你沿着桌子推书,摩擦力就是阻止书运动的力。我们将在第七章中研究接触力。

还有几个由流体引起的力的例子,如压力、阻力(一种摩擦力)和上推力。我们将在第七章中详细讨论这些力。

然后还有电力和磁力,它们也可以共同作用成一个电磁力。这些力是由具有电荷这一物理属性的粒子和物体施加和经历的。事实上,除了重力之外,所有的日常作用力如接触力、流体力等等都来源于原子和分子之间相互施加的电磁力。我们将在第十章中研究这些力,看看它们如何被用来产生有趣的效果。

合力:力图和合力

在方程 F = m a 中,F 是合力。因为力是一个矢量,两个或两个以上的力产生的合力必须通过矢量相加得到,如第三章所述。

显示作用在物体上的力的矢量图叫做力图。力图是有用的工具,因为它能帮助我们算出合力。关键是作用在物体上的力是否不同并不重要。例如,只要使用矢量加法,就可以将重力添加到摩擦力和阻力中。

作为矢量,力可以被“分解”成垂直分量,如第三章中的解释。有时,通过将力分解成它们的分量,并将所有的水平和垂直分量分别组合起来,可能有助于分析问题。

图 4-6 显示了一个力图的例子。它显示了物体沿斜面下滑时所受的力。有三个力作用在这个物体上:它的重量 m g,向下作用;由表面施加的摩擦力 f,其作用方向与其运动方向相反;和表面施加在其上的垂直于表面作用的接触力 R。注意,你不关心物体施加在表面上的力。如果你正在模拟一个物体的运动,你只关心作用在它上面的力,而不是它施加的力。

A978-1-4302-6338-8_4_Fig6_HTML.jpg

图 4-6。

Force diagram for a body sliding down an inclined plane

如果你要模拟一个物体的运动,如图 4-6 所示(可能是一辆下坡的汽车),你会怎么做?一如既往,用一点常识逻辑,一些物理公式,和一些代码。我们不会详细讨论物理和编码(我们会把这些留到《??》第七章,当我们详细讨论摩擦力和接触力的时候),但是这里有一些常识性的部分。凭经验,你知道物体会沿着表面滑动,同时始终保持接触。所以沿着平面分解力是有意义的。嗯,f 已经沿着平面了;r 垂直于它,所以没有沿它的分量;重力的分量(重量)是 mg sin 30 沿斜面向下。因此,沿斜面向下的合力大小由下式给出

A978-1-4302-6338-8_4_Figx_HTML.jpg

当然,你需要知道如何计算摩擦力 f,我们会在第七章告诉你。一旦你知道了 F,你就可以计算加速度 a = F/m,现在你肯定知道了。

平衡力

有时,有两个或两个以上的力作用在一个物体上,碰巧力的矢量和(合力)为零。在那种情况下,力被认为是平衡的。就物体的运动而言,就好像没有力作用在物体上一样。

让我们再一次回到等式 a = F/m,这个等式意味着如果合力 F = 0,那么 a = 0。因此,如果没有合力作用在一个物体上,这个物体就不会加速。换句话说,无论速度是多少,它都不会改变。这意味着两件事。首先,如果物体不运动(如果它是静止的),它将保持静止。但是,如果物体已经以某个速度运动,它将保持这个速度,既不加速也不减速(这实际上是一个负加速度)。后一个结论可能会让你大吃一惊;我们将在下一章讨论牛顿运动定律时继续讨论这个问题。

图 4-7 显示了一个物体在多个力的作用下处于平衡状态的两个例子。在第一个例子中,一本书静止不动地放在一个水平的桌子上,因此它必须经受至少两个加起来为零的力。你知道其中一个一定是向下作用的重力。还有另一个力,接触力 R,桌子施加在书上,向上作用,正好与重力相反。

A978-1-4302-6338-8_4_Fig7_HTML.jpg

图 4-7。

Examples of forces in equilibrium

第二个稍微复杂一点的例子再次展示了一个静止的物体。但这次有三种力量在起作用。这个物体是用两根绳子悬挂起来的。所以每根弦上都有张力,还有重力作用在物体上。如果你把这三个力加起来,它们的矢量和一定是零。如果你把每个力分解成水平分量和垂直分量,然后把所有的水平分量加在一起,所有的垂直分量加在一起,两者相加为零。

图 4-8 中的例子显示了匀速飞行的飞机的力图。从前面的讨论中,我们再次推导出,无论有多少个力,无论它们有多复杂,作用在它上面的合力一定为零。事实上,有四个主要的力作用在飞机上:一个向前的推力,一个相反的阻力,一个向下的重量和一个向上的升力。升力平衡飞机的重量,推力平衡飞机上的阻力。推力和阻力相等,这听起来可能有点违反直觉。事实上,只有当飞机加速时,推力才必须超过阻力,例如在起飞和上升时。但是一旦飞机达到匀速,只需要推力来克服阻力。

A978-1-4302-6338-8_4_Fig8_HTML.jpg

图 4-8。

Forces on a plane moving at constant velocity are also in equilibrium

示例:物体在重力和阻力作用下下落

所有这些关于力的讨论可能看起来有点抽象,你可能很想看看它是如何应用到实践中的。尽管在第二部分中我们会有大量的例子,但是现在让我们看一个简单的例子来吊起你的胃口。如果您不能立即理解所有内容,也不要担心:当您在第二部分中看到更多的例子后,就会完全理解了。

在这个例子中,一个球落入诸如空气的流体中,当它下落时,它受到向下的重力 W 和向上的阻力 D(见图 4-9 )。当然,重力是不变的,由下式给出:

A978-1-4302-6338-8_4_Figy_HTML.jpg

A978-1-4302-6338-8_4_Fig9_HTML.jpg

图 4-9。

A ball falling in air, experiencing forces of gravity W and drag D

我们将在第七章中对阻力有更多的描述;现在我们只是借用下面的公式:

A978-1-4302-6338-8_4_Figz_HTML.jpg

换句话说,阻力与球通过流体的速度成比例(负号表示阻力与速度方向相反,k 是比例常数)。利用这两个公式,我们可以作出如下推论。最初,球是静止的,所以它受到的阻力为零。当球下落时,它在重力的作用下加速,因此它的速度增加,阻力 d 也增加。最终(如果球下落足够长的时间而不落地),阻力将变得与重力相等,因此两者将平衡。在这一点上,我们会达到平衡,所以加速度为零,就像上一节讨论的那样。球将继续以恒定的速度下落,这就是所谓的极限速度。根据物理理论,这是我们预计会发生的情况,但我们能在模拟中再现它吗?

为了证明我们确实可以模拟球在这些力的作用下的运动,我们将制作球下落的动画,同时随着时间的推移绘制球的速度和加速度。看一下文件forces-example.js中的代码和嵌入它的文件forces-example.html中的标记。

首先,您会注意到我们在 HTML 文件中有两个 canvas 实例,id 分别为canvascanvas_bgcanvas实例被精确地放置在canvas_bg之上,并被透明化(参见样式文件style1.css了解这是如何完成的)。我们的想法是在canvas_bg上放置一个静态的Graph实例,在canvas上制作球的动画。还要注意,除了 HTML 文件中的vector2D.jsball.js之外,我们还必须包含graph.js文件。

forces-example.js中的代码建立在前面的例子之上,但也添加了一些重要的新元素。init()函数看起来像这样:

function init() {

ball = new Ball(15,'#000000',1,0,true);

ball.pos2D = new Vector2D(75,20);

ball.velo2D=new Vector2D(0,0);

ball.draw(context);

setupGraphs();

t0 = new Date().getTime();

t = 0;

animFrame();

};

这是非常熟悉的:这里新增了setupGraphs()方法,它在canvas_bg上设置了几个Graph实例,用于绘制球下落时的速度和加速度:

function setupGraphs(){

//graph = new Graph(context,xmin,xmax,ymin,ymax,xorig,yorig,xwidth,ywidth);

graphAcc = new Graph(context_bg,0,30,0,10,150,250,600,200);

graphAcc.drawgrid(5,1,5,1);

graphAcc.drawaxes('time (s)','acceleration (px/s/s)');

graphVelo = new Graph(context_bg,0,30,0,25,150,550,600,200);

graphVelo.drawgrid(5,1,5,1);

graphVelo.drawaxes('time (s)','velocity (px/s)');

}

init()中调用的用于设置动画的animFrame()函数本质上与前面的抛射体示例相同,但是时间步进move()方法现在看起来有所不同:

function move(){

moveObject();

calcForce();

updateAccel();

updateVelo();

plotGraphs();

}

主要出于教学的原因,我们将代码分成了独立的函数,从它们的名字中可以明显看出它们的任务。首先,moveObject()方法更新球的位置并重新绘制它,它看起来像这样:

function moveObject(){

ball.pos2D = ball.pos2D.addScaled(ball.velo2D,dt);

context.clearRect(0, 0, canvas.width, canvas.height);

ball.draw(context);

}

这并不是我们以前没有见过的新东西。重要的补充是三个方法calcForce()updateAccel()updateVelo(),如下所示:

function calcForce(){

force = new Vector2D(0,ball.mass*g-k*ball.vy);

}

function updateAccel(){

acc = force.multiply(1/ball.mass);

}

function updateVelo(){

ball.velo2D = ball.velo2D.addScaled(acc,dt);

}

如您所见,这些函数分别使用 F = mg–kv 计算合成向下力,使用 a = F /m 计算加速度,使用δv = aδt 计算新速度。因此,我们让 ball 实例受到重力和阻力这两个力的作用,计算它们的合力,然后计算加速度和速度。在moveObject()方法的下一个时间步更新位置。

最后一种方法plotGraphs(),在canvas_bg的各个Graph实例上绘制加速度和速度的垂直分量:

function plotGraphs(){

graphAcc.plot([t], [acc.y], '#ff0000', false, true);

graphVelo.plot([t], [ball.vy], '#ff0000', false, true);

}

运行代码,您将看到类似图 4-10 的内容。当球下落时,它最初以 g = 10 的值(如代码中设置的)加速,并且它的速度开始急剧增加。但是之后阻力开始增加。这减小了向下的合力,因此也减小了向下的加速度,导致速度增加得不太快。模拟进行到大约 10 秒钟时,加速度已经下降到零,因为阻力现在已经增长到足以完全平衡向下的重力。从那时起,球的速度恒定在 20 px/s。这都是如前所述,所以模拟真的工作!事实上,最终速度的值与物理理论预测的完全一样。

为了看到这一点,我们推论当达到最终速度时:

A978-1-4302-6338-8_4_Fig10_HTML.jpg

图 4-10。

Simulating a ball falling under gravity and drag

阻力=重力,

因此

A978-1-4302-6338-8_4_Figaa_HTML.jpg

这给出了 v = mg/k。在我们的模拟中,m = 1,g = 10,k = 0.5。这给出了 v = 20 px/s,与仿真计算的完全相同。对于力的第一个例子来说还不错,你不同意吗?

能源概念

我们已经谈了很多关于力的问题,但是还有一个物理概念可能同样重要:能量。从我们的角度来看,重要的是,能量概念有时提供了一种替代方法来解决问题,这些问题可能被证明是棘手的,甚至不可能用力的方法来解决。正如你看到的动量,这是因为有一个强大的能量守恒定律(我们很快会谈到)。事实上,能量守恒和动量守恒可以一起应用来解决涉及碰撞的问题,正如我们将在第五章中看到的,更详细的内容将在第一章 1 中看到。

与动量不同,能量的定义有点棘手。在此之前,我们需要引入一个密切相关的概念——工作。

物理学中的功的概念

我们常说力导致运动,但我们也看到一种力可以与其他力平衡存在,所以运动不会发生。为了区分一个力产生运动的效果和仅仅平衡另一个力的效果,我们引入了功的概念。

在物理学中,我们说,当一个力在它作用的方向上产生运动时,它就做功。所做的功 W 定义为力的大小 F 和力的方向上的位移 s 的乘积:

A978-1-4302-6338-8_4_Figbb_HTML.jpg

这听起来可能有点抽象,所以让我们看一个例子。假设一块石头从离地面一定高度 h 处落下(见图 4-11 )。

A978-1-4302-6338-8_4_Fig11_HTML.jpg

图 4-11。

An object dropped from rest at a height h above the ground

那么石头的重量(重力)通过向下移动它来做功。根据前面的定义,下落距离 h 到地面所做的功由下面的公式给出,因为这里 F = mg,s = h:

A978-1-4302-6338-8_4_Figcc_HTML.jpg

因为功是力和距离的乘积(记得力的单位是牛顿,N),所以它的单位是 Nm(牛顿乘以米)。这个单位也有一个特殊的名字,焦耳(J)。所以,J = Nm。功是一个标量。

如果位移和力的方向不一样呢?举个例子,如果石头是斜着扔的,而不是垂直向下掉的,会怎么样?那么所做的功就是力和位移在其方向上的投影的乘积(见图 4-12 )。

A978-1-4302-6338-8_4_Fig12_HTML.jpg

图 4-12。

Work done is the scalar product of force and displacement

你可能认为这是 F 和 s 的点积,其中θ是力 F 和位移 s 之间的角度:

A978-1-4302-6338-8_4_Figdd_HTML.jpg

因此,如果角度θ为零,则 W = F s(因为 cos (0) = 1)。但是如果θ是 90 度,那么 cos θ = 0,那么 W = 0。换句话说,如果位移垂直于力,力所做的功为零。

Note

合成加速度必须总是在合力的方向上。然而,合成运动(位移)可能与合力方向一致,也可能不一致。

做功的能力:能量

既然我们知道什么是功,能量的定义就很简单:能量是做功的能力。以一块从高处落下的石头为例:因为它能通过下落来做功,所以它只要在地面上就一定拥有能量。这个能量叫做势能(通常缩写为 PE,符号 E p )。同样,一个运动的物体可以通过与另一个物体碰撞,在碰撞过程中施加一个接触力,并使其在这个力的作用下运动来做功。因此,运动的物体也有能量,称为动能(通常缩写为 KE,符号为 E k )。

事实上,功引起能量的转移或转换。例如,施加一个力使一个物体运动,就给了它动能。这导致施加力的力的能量减少。这通常用功能定理来表示:

能量转移=做功

在符号中:

A978-1-4302-6338-8_4_Figee_HTML.jpg

因为我们把能量和功等同起来,所以能量和功有相同的单位:焦耳(J)。像功一样,能量是一个标量。

能量转移、转换和守恒

正如力有多种类型一样,能量也有多种类型。例子包括光能、热能、核能等等。但是通常我们只关心两种形式的能量:动能和势能。在基本的微观层面上,所有形式的能量都是这两种基本形式的表现。

能量可以从一个身体转移到另一个身体。能量也可以从一种形式转换成另一种形式。无论能量是转移还是转化,总能量总是守恒的。

能量转移的一个例子是两个粒子的碰撞,其中一个粒子失去 KE,另一个粒子获得 KE。弹性碰撞被定义为总动能守恒的碰撞(KE 不会因碰撞而转化为其他形式的能量);否则,碰撞是非弹性的。因此,如果碰撞是弹性的,一方得到的 KE 的量正好等于另一方失去的量。

作为能量转换的一个例子,从一定高度释放的物体失去势能,但在向下加速时获得动能——其 PE 转换为 KE。如果物体只在重力的作用下下落,KE 中的增益正好等于 PE 中的损耗;动能加上势能的总能量保持不变。如果有其他的力,比如摩擦力(由于空气阻力),通过对摩擦力做功,将少量的能量转化为身体和周围空气中的热能。所有形式的总能量仍然保持不变。这就是众所周知的能量守恒原理。

这个原则如此重要,让我们用一种更通用的方式再陈述一次:

能量守恒原理:在相互作用过程中,能量可以从一种形式转化为另一种形式,或者从一个物体转移到另一个物体,但一个封闭系统的总能量是恒定的。

“封闭系统”是指除自身外没有其他相互作用的系统。在系统中,交互和交换可以是多种多样的,也可以是复杂的,这个原则仍然适用。例如,如果你有数百个粒子在一个封闭的系统中相互碰撞,该原理将成立。如果你有无数的分子在一个孤立的气体中相互作用,这个原理仍然成立。

势能和动能

因为势能和动能这么重要,我们来看看怎么计算。让我们从势能开始。当我们讨论功的概念时,回想一下,一个物体从离地高度 h 下落所做的功等于 mgh。因为做的功等于传递的能量,所以物体在下落之前,一定拥有这个能量。因为它在高度 h 从静止状态落下,它的速度最初为零,所以它没有动能。因此,它的能量一开始都是势能。因此,我们得出结论,一个质量为 m 的物体在离地高度 h 处,其势能的大小由下式给出

A978-1-4302-6338-8_4_Figff_HTML.jpg

这是我们的势能公式。

现在让我们算出一个动能公式。考虑同样的落石的例子。忽略摩擦损失的能量,石头落地前的动能必须等于它最初的势能(因为它在地面上没有任何 PE,所以它必须全部转化为 KE)。所以最后的 KE 在数值上也等于 mgh。这里 h 是石头落下的距离。因为 KE 是由于运动而拥有的能量,我们真的想要一个公式,作为石头速度的函数,而不是它所经历的位移。还记得连接速度和位移的公式吗?使用 v 2 = u 2 + 2 a . s,并注意到 u = 0,a = g,s = h,我们得到 v 2 = 2gh。

使用这个,我们现在需要做的就是用A978-1-4302-6338-8_4_Figgg_HTML.jpg v 2 代替 gh,我们最终得到这个:

A978-1-4302-6338-8_4_Fighh_HTML.jpg

这是质量为 m 的物体以速度 v 运动时的动能公式。

力量

我们要介绍的最后一个概念是权力。在谈论能量时,我们似乎已经放弃了时间的概念。我们已经讨论了做功和能量交换或转移,但没有提到它发生的速度。在能量方法中,功率引入了时间。

功率被定义为做功的速率。因此,在微积分符号中,乘方 P 由下式给出:

A978-1-4302-6338-8_4_Figii_HTML.jpg

或者,以离散形式:

A978-1-4302-6338-8_4_Figjj_HTML.jpg

换句话说,P 定义为所做的功除以所用的时间。根据这个定义,功率的单位是 J/s。这个单位有一个特殊的名称。它叫做瓦特。另一个常用的单位是马力(HP)。功率是一个标量,就像能量一样。

我们可以颠倒最后一个公式,以获得当使用功率 P 时,在时间间隔δt 内所做的功:

A978-1-4302-6338-8_4_Figkk_HTML.jpg

功率是一个非常有用的概念,用于分析机器的运行,例如车辆。假设一台机器通过施加一个力 F 并在 F 的方向上以速度 v 移动其施力点来做功,那么,所做的功就是δW = Fδs(注:这只是为小变化δW 和δs 写的公式 W = Fs)

根据功率是做功的速率的定义,机器的功率输出因此是这样的:

A978-1-4302-6338-8_4_Figll_HTML.jpg

因为δs/δt = v,我们最终得到这个公式:

A978-1-4302-6338-8_4_Figmm_HTML.jpg

所以机器的功率等于它施加的力 F 和它产生的速度 v 的乘积。

如果你正在模拟由人类设计的机器,比如汽车和船,你通常需要应用功率概念。

示例:一个基本的“汽车”模拟

我们通过展示一个简单的例子来结束这一章,这个例子展示了如何在不明确使用力的情况下,应用功率和能量的概念来模拟运动。在本例中,您将对一个Ball对象(汽车)施加动力,以加速其摩擦和其他动力损失,就像您踩下汽车的油门踏板以保持其移动一样。

该示例的代码在文件energy-example.js中,在此完整显示:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_bg = document.getElementById('canvas_bg');

var context_bg = canvas_bg.getContext('2d');

var car;

var t;

var t0;

var dt;

var animId;

var graph;

var force;

var acc;

var g = 10;

var k = 0.5;

var animTime = 60; // duration of animation

var powerLossFactor=0.1;

var powerApplied=50;

var ke;

var vmag;

var mass;

var applyThrust=false;

window.onload = init;

function init() {

car = new Ball(15,'#000000',1,0,true);

car.pos2D = new Vector2D(50,50);

car.velo2D=new Vector2D(20,0);

car.draw(context);

mass = car.mass;

vmag = car.velo2D.length();

ke = 0.5*mass*vmag*vmag;

window.addEventListener('keydown',startThrust,false);

window.addEventListener('keyup',stopThrust,false);

setupGraphs();

t0 = new Date().getTime();

t = 0;

animFrame();

};

function setupGraphs(){

//graph = new Graph(context,xmin,xmax,ymin,ymax,xorig,yorig,xwidth,ywidth);

graph= new Graph(context_bg,0,60,0,50,100,550,600,400);

graph.drawgrid(5,1,5,1);

graph.drawaxes('time (s)','velocity (px/s)');

}

function startThrust(evt){

if (evt.keyCode==38){

applyThrust = true;

}

}

function stopThrust(){

applyThrust = false;

}

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;}; // fix for bug if user switches tabs

t += dt;

//console.log(dt,t,t0,animTime);

if (t < animTime){

move();

}else{

stop();

}

}

function move(){

moveObject();

applyPower();

updateVelo();

plotGraphs();

}

function moveObject(){

car.pos2D = car.pos2D.addScaled(car.velo2D,dt);

context.clearRect(0, 0, canvas.width, canvas.height);

car.draw(context);

}

function applyPower(){

if (applyThrust){

ke += powerApplied*dt;

}

ke -= powerLossFactor*vmag*vmag*dt;

}

function updateVelo(){

vmag = Math.sqrt(2*ke/mass);

car.vx = vmag;

}

function plotGraphs(){

graph.plot([t], [car.vx], '#ff0000', false, true);

}

function stop(){

cancelAnimationFrame(animId);

}

从前面的例子来看,大部分代码应该是熟悉的。init()方法包括设置 keydown 和 keyup 事件监听器的额外代码。如果按下向上箭头键,相应的事件处理程序将变量Boolean的值applyThrust设置为true,否则设置为false

代码中使用了一些方便的变量:mass(粒子的质量)vmag(其速度大小)ke(其动能)powerApplied(施加的功率)和powerLossFactor(我们用来计算由于摩擦和其他因素造成的功率损失的系数)。vmagke的值在init()中初始化。我们给球在 x 方向的初速度是 20 px/s。球的质量是 1 个单位。

代码中最重要也是最新颖的部分是两个方法applyPower()updateVelo(),它们在move()方法中的每个时间步都会被调用。

applyPower()中,我们正在更新球的动能。布尔变量applyThrust告诉我们向上箭头键是否被按下;如果是,则通过将功率乘以时间间隔dt来更新 KE。我们现在做的是应用这个公式:

A978-1-4302-6338-8_4_Fignn_HTML.jpg

你可能不会马上认出这个公式,但它是通过将做功δW = pδt 与动能相等得到的,动能是根据功能定理得到的,如前所述。从概念上讲,所发生的是所施加的功率对球做机械功,这导致球的动能增加。

applyPower()中,你会注意到我们也减少了球的动能,减少量等于powerLossFactor*vmag*vmag*dt。这相当于应用由下式给出的功率损耗,其中 k 是对应于powerLossFactor的常数:

A978-1-4302-6338-8_4_Figoo_HTML.jpg

这个公式来源于应用我们在上一节看到的公式 P = F v,并用 F =–k v 模拟所有阻力(如摩擦力和阻力)的总和,作为与球的速度成比例的力(见第七章)。这是一个粗略的近似值,但是对于我们这个简单的例子来说已经足够了。

updateVelo()方法首先通过该公式从更新的动能计算速度大小,该公式是通过反转动能 E k = A978-1-4302-6338-8_4_Figpp_HTML.jpg m v 2 的公式获得的:

A978-1-4302-6338-8_4_Figqq_HTML.jpg

然后它相应地更新球的速度。

还有一些代码用于设置一个Graph实例,并随着模拟的进行绘制球的水平速度分量的图形。这些代码很简单,所以我们不会在这里深入讨论。

如果你运行代码,你会发现球开始移动,但是由于能量损失的影响,它的速度逐渐降低。如果你接着按住向上箭头键,施加的力量将导致球的动能增加,使其加速。如果持续通电,最终速度会趋于一个恒定值,但如果松开上箭头键,速度会再次降低,如图 4-13 所示。这种行为以简单的方式反映了汽车加速器的操作。

A978-1-4302-6338-8_4_Fig13_HTML.jpg

图 4-13。

A rudimentary car simulation

摘要

这是另一个很长的概述章节,你现在已经学到了很多物理概念。这一章完成了这本书的第一部分。下一章将把你到目前为止所学的东西结合在一起,列出如何模拟各种各样的力和运动的原理。

准备好真正的行动吧!

五、支配运动的定律

第四章提供了一些描述和分析运动的一般概念背景。背景知识包括对运动相关概念的讨论,如速度、加速度和力。在这一章中,我们将使用这些概念来阐述物理定律,这将允许你计算粒子在任何类型的力下的运动。接下来的五章将把这些定律应用到一些不同的力上。我们将用简短的例子来说明这些原则的应用,目的是在接下来的几章中以它们为基础。

我们特意让这一章保持简短,并把重点放在你需要应用到本书这一部分和其他部分的例子中的基本法则和原则上。你可以根据需要回到这一章来更新你对基本物理原理的理解,就像你在这本书的其余部分研究它们的应用一样。

本章涵盖的主题包括以下内容:

  • 牛顿运动定律:牛顿三定律提供了力和运动之间的联系。这些定律使我们能够预测物体在已知力的作用下的运动。
  • 应用牛顿定律:我们向你展示了如何在代码中实现牛顿运动定律,并用几个例子来说明它们的用法。
  • 牛顿第二定律作为微分方程:在物理教科书中,牛顿第二定律有时被表示为微分方程,并使用微积分进行解析求解。我们简要讨论了该公式和我们的数值方法之间的联系,并用一个例子来说明。
  • 能量守恒原理:我们通过考虑运动粒子的势能和动能,将能量守恒原理应用于运动。
  • 动量守恒原理:我们来看看动量守恒及其在一个简单碰撞例子中的应用。
  • 支配旋转运动的定律:我们将简要地注意到前面的定律可以扩展到经历旋转运动的物体。我们在这里不讨论旋转,但将在后面的章节中讨论。

牛顿运动定律

牛顿的三大运动定律,在他 1687 年的经典著作《数学原理》中阐明,提供了力和运动之间的联系。你可能还记得,我们在前一章说过,公式 F = m a 提供了这种联系。这个公式实际上是牛顿三大运动定律中的第二个。事实上,它是法律一种特殊形式。在这一章中,我们将深入一点,看看牛顿第二定律以及其他两个定律的一般形式。

牛顿第一运动定律(N1)

运动第一定律告诉你如果没有力作用在物体上会发生什么(见图 5-1 )。想象一个静止不动的物体。如果你不对它施加任何力(换句话说,如果你不管它),你认为会发生什么?没错——什么都没有。事物不会突然自己开始运动。这基本上就是第一定律所说的。常识?但是等等,还有更多。假设物体已经在运动,没有力作用在它上面。它会怎么样?日常经验可能会诱使你认为物体会减速并最终停止。所以当你听到第一定律说只要没有外力作用,物体就会继续以初速度运动时,你可能会感到惊讶。

A978-1-4302-6338-8_5_Fig1_HTML.jpg

图 5-1。

Schematic illustration of Newton’s first law of motion

这个命题似乎与我们的日常经验有些背道而驰。例如,一个在地上滚动的球总是会停下来。事实上,它这样做是因为与地面的摩擦导致它减速。如果没有摩擦力,球会无限期地运动。这可以通过减少与底层表面的摩擦来证明——例如,在非常光滑的水平面上使用弹珠。然后,弹珠以直线移动很长一段距离,其速度不会显著降低。

另一种说法是,你不需要一个力来让一个物体继续匀速运动。你只需要一个力来改变它的速度——例如,开始移动,加速或减速。换句话说,你只需要一个力来使一个物体加速(或减速)。

有了这种认识,我们可以用下面的形式简洁地表述牛顿第一运动定律,它涵盖了物体静止和匀速运动的两种情况:

  • N1:如果作用在物体上的合力为零,那么它的加速度也为零。F = 0 意味着 a = 0。

这条定律非常重要,所以我们可以用另一种方式来表述:如果没有合力作用在一个物体上,它的速度一定是恒定的(包括零)。反过来,如果一个物体有一个恒定的速度(可能是零),你肯定知道它受到的合力一定是零。

最后注意,我们说合力为零。我们并不是说没有力作用在物体上。正如你在第四章中看到的,作用在同一物体上的两个或更多的力可以处于平衡状态,因此它们的合力为零。在这种情况下,第一定律仍然成立。它没有区分没有合力和根本没有力。

为了将这些概念放入上下文中,第四章中的最后两个例子中的moveObject()方法实现了第一定律;它带走一个粒子,并永远以它的速度运动。它对力一无所知。

牛顿第二运动定律(N2)

牛顿第二运动定律建立在第一定律的基础上(见图 5-2 )。第一定律告诉你当没有合力作用在物体上时会发生什么。第二定律告诉你当合力作用在物体上时会发生什么。它告诉你物体的运动改变了。但这还不是全部。它会准确地告诉你它改变了多少——它会给出一个精确的公式,将施加的力和产生的运动变化联系起来。

A978-1-4302-6338-8_5_Fig2_HTML.jpg

图 5-2。

Schematic illustration of Newton’s second law of motion

回想一下上一章,动量(用符号 p 表示,定义为 p = m v)代表一个物体所拥有的“运动量”。牛顿第二定律把施加的力和它产生的动量变化联系起来。我们不会去探究牛顿是如何得出那个公式的,但这里有:

  • N2:如果一个合力 F 作用在一个物体上,它的动量会改变,所以动量的变化率等于所施加的力:F = d p/dt。

这是牛顿第二定律的一般形式,可能看起来有些不直观,至少在你习惯之前是这样的!用微积分的话来说,这告诉我们作用力等于动量对时间的导数。本质上,动量的时间导数告诉我们,动量在瞬间的变化有多快。这意味着在任何给定时间施加的力等于单位时间内动量的变化。

我们以前见过类似的公式。比如 v = d s/dt,a = d v/dt。牛顿第二定律的形式完全相同:F = d p/dt。请注意,前面的速度和加速度方程实际上是定义,而 F = d p/dt 是告诉我们现实世界中事物如何表现的定律。

在这种一般形式下,第二定律可能看起来与第一定律大不相同。在第一定律中,我们在讨论加速度,现在我们在讨论动量变化率。当我们将第二定律应用于质量不变的物体时,比如粒子,这两者之间实际上是有联系的。

要了解这种联系,请再次回忆动量是由 p = m v 定义的,对于一个粒子,质量 m 是常数。那样的话,微积分的规则告诉我们 d p/dt = m d v/dt。如果你对微积分感兴趣,我们在这里做的是计算 p 的(时间)导数,它必须等于 m v 的导数(因为这两件事是相等的:p = m v)。但是 m 是常数,所以 m v 的导数等于 m 乘以 v 的导数,你大概还记得 d v/dt = a(加速度的定义)。因此,我们得出结论,对于一个粒子,d p/dt = m a。因为牛顿向我们保证 F = d p/dt,我们已经恢复了我们的老朋友 F = m a。所以这里是牛顿第二运动定律的特殊形式:

N2(特殊):如果一个合力 F 作用在一个质量为 m 的物体上,它会产生一个加速度 a,公式为 F = m a

如果你现在将第二定律的这种形式与上一节给出的第一定律的陈述相比较,你会发现 N1 实际上只是 N2 的一个特例。从 F = m a 开始,放 F = 0,得到 m a = 0,因此 a = 0。换句话说,F = 0 给出 a = 0,这正是第一定律所说的。所以第一定律被“包含”在第二定律中。好;少了一条需要担心的法律!

大多数时候你会用到牛顿第二定律,以 F = m a 的形式。但是请记住,这不是定律最普遍的形式,它只适用于质量 m 不变的情况。有第三种形式的定律适用,当你有一股以一定速度运动的物质流,而不是一个固定质量的物体。例如,火箭的废气以相对于火箭恒定的速度 v 被推出。因为 v 现在是常数,微积分规则告诉我们 d p/dt = v dm/dt。因此,气体上的力由 F = v dm/dt 给出,其中 dm/dt 是质量变化率(每秒释放的气体质量)。我们将在第六章中使用这种形式的牛顿第二定律来模拟火箭:

N2(交替):以恒定速度 v 以 dm/dt 的速率移动任何物质所需的力 F 由 F = v dm/dt 给出。

尽管简单,牛顿第二定律可以说是牛顿力学中最重要的定律。它是如何将力和运动联系起来的,这几乎是不可思议的。但是,除非我们知道如何计算出作用在物体上的力,否则这也是毫无用处的。我们需要强制法。我们很快会看一些力定律的例子。但在这之前,你想知道牛顿第三定律,不是吗?

牛顿第三运动定律(N3)

在前一章中,我们说过力是运动变化的原因。正如你刚才看到的,牛顿第二定律使这个陈述更加精确,给了我们一个公式来计算一个给定力引起的运动变化。但是到目前为止,关于力从何而来,我们还没有说太多。许多力的产生是因为其他物体的存在。牛顿第一和第二定律告诉我们物体的运动是如何对外力做出反应的。牛顿第三定律告诉我们两个物体是如何通过相互施加力来相互作用的。这是这样的:

N3:如果一个物体 A 对另一个物体 B 施加一个力 F,物体 B 必然反过来对物体 A 施加一个大小相等方向相反的力 F

这意味着物体之间的力总是以作用力-反作用力对的形式存在。两种力量同时存在;他们之间没有延迟。即使它们的大小和方向随时间变化,它们始终保持大小相等,方向相反。

牛顿第三运动定律经常被误解和错误引用。一个常见的错误是认为作用力和反作用力相互“平衡”。事实上,在作用力-反作用力对中,作用力作用在不同的物体上。所以,认为它们互相“平衡”或“抵消”是错误的。换句话说,它们并不均衡。每个力作用在不同的物体上,单独影响其运动。诸如此类的细微之处使得牛顿第三定律在抽象层面上有些难以深入理解。但是看看这个定律在分析具体问题时是如何应用的,会有很大帮助。例如,在接下来的“动量守恒原理”一节中,牛顿第三定律被用来分析两个粒子之间的相互作用。你也会看到它在整本书不同例子的分析中的应用。

以下是力的作用力-反作用力对的一些例子:

  • Two colliding bodies exert equal and opposite forces on each other, even if they have different masses, as depicted in Figure 5-3.

    A978-1-4302-6338-8_5_Fig3_HTML.jpg

    图 5-3。

    Schematic illustration of Newton’s third law of motion

  • 地球对你施加的重力等于你的体重。这暗示着你也在对地球施加一个大小相等方向相反的重力!

  • 在火箭中,发动机对废气施加一个向下的力,废气反过来对火箭施加一个大小相等方向相反的力,推动火箭前进。

运用牛顿定律

实际上,这本书的其余部分都是关于应用牛顿运动定律。在这里,我们将建立方法,创建一些力函数,并用一些简单的例子说明它们的应用。

应用 F = ma 的一般方法

要分析应用 F = m a 的问题,请使用以下步骤:

Draw a diagram representing the interacting objects in the problem.   Choose the object whose motion is to be calculated and indicate using arrows all the forces acting on it due to other objects. Ignore the forces exerted by the object on other objects. This gives you a force diagram, as described in Chapter 4.   Calculate the resultant force F on the object (using vector addition, as described in Chapter 3) and apply the second law F = m a to calculate the acceleration a.

原则上,这就是全部内容。你必须用纸和笔完成第一步和第二步。让我们使用 JavaScript 来帮助我们做第 3 步。

对任何力下的运动进行编码

为了实现上一节中的步骤 3,我们需要一段通用代码,它允许你计算一个粒子上的力,找到它们的合力,从而计算它们的加速度。然后,您可以使用加速度来更新粒子的速度和位置。回想一下我们使用术语“粒子”的意义,正如在第四章中所讨论的:从模拟的角度来看,任何内部结构不相关的物体。因此,为了我们的目的,一个粒子可以是一个球,也可以是一个行星。

这样做的一般代码结构看起来非常类似于上一章示例forces-example.js中的move()方法:

function move(){

moveObject();

calcForce();

updateAccel();

updateVelo();

}

如前所述,moveObject()方法只是根据粒子的现有速度来移动粒子:

function moveObject(){

particle.pos2D = particle.pos2D.addScaled(particle.velo2D,dt);

context.clearRect(0, 0, canvas.width, canvas.height);

particle.draw(context);

}

剩下的代码计算力,计算加速度,并更新速度,准备在下一个时间步的moveObject()方法中使用。calcForce()的工作是计算作用在质点上的合力。我们将在最后讨论这个问题。一旦我们知道了力,?? 方法就会更新加速度。我们如何做到这一点?当然,使用 F = m a 给出 a = F/m。因此,将accforce定义为Vector2D对象,updateAccel()只是一行代码:

function updateAccel(){

acc = force.multiply(1/particle.mass);

}

回顾第四章中的可知,δv = aδt,updateVelo()方法同样简单:

function updateVelo(){

particle.velo2D = particle.velo2D.addScaled(acc,dt);

}

最后,calcForce()方法应该计算出作用在粒子上的每一个力,然后将它们相加,得到它们的合力,即force变量。进入calcForce()的代码将取决于问题和涉及的力量。在第四章的forces-example.js代码中,calcForce()看起来是这样的:

function calcForce(){

force = new Vector2D(0,particle.mass*g-k*ball.vy);

}

在这个简单的例子中,我们直接指定了力。但是随着我们的例子变得越来越复杂,将我们将遇到的不同种类的力定律聚集到一个我们称之为Forces的对象中的静态方法中是很有用的。为了调用特定力的作用,我们可以这样做:

function calcForce(){

force = Forces.zeroForce();

}

这种方法目前所做的就是将力设置为零。它使用Forces.zeroForce()方法来实现这一点,该方法产生一个没有组件的Vector2D对象。当然,关于Forces对象我们还没有说太多,所以现在让我们开始吧。

力对象

确切地说,进入calcForce()取决于手头的问题,但它总是涉及指定粒子上的力,然后将它们相加。因此,让我们构建一个新的对象来帮助我们完成这些任务。

我们将构建的对象基本上只包含不同类型力的静态方法。因此,我们将把这个对象命名为Forces,这样就足够恰当了:

function Forces(){

}

一般来说,作用在粒子上的力可能取决于粒子的性质(大小、位置、速度、质量、电荷),以及它所处环境的性质,或其他物体的性质。这些属性需要被指定为相关方法中的参数。

让我们看一些例子。首先,让我们创建一个零力。这是一个大小为零的力,因此分量等于零。

下面是将创建一个的静态方法:

Forces.zeroForce = function() {

return (new Vector2D(0,0));

}

接下来,让我们创建一个重力方法:

Forces.constantGravity = function(m,g){

return new Vector2D(0,m*g);

}

质量为 m 的物体所受的重力由 mg 给出,并指向下方。因此,我们给出 m 和 g 的值作为自变量,并返回一个向量,其垂直(y)分量为 mg,水平(x)分量为零。你会注意到我们将函数命名为constantGravity,而不是简单的gravity。这是因为我们将重力这个名称保留给更一般形式的重力,你会在第六章的中了解到。mg 给出的引力形式是近地引力,就像地球表面附近的物体所经历的那样。在下一章你会学到更多。

作为另一个例子,让我们看看阻力,这是一个物体在流体(如空气或水)中运动时受到的阻力。我们将在第七章中更深入地研究阻力,但现在我们只能说在低速时,阻力由–k v 给出。这是一个常数 k 乘以物体的速度 v。负号表示阻力与速度方向相反。让我们为这种类型的阻力创建一个函数,我们称之为linearDrag

下面是静态方法linearDrag:

Forces.linearDrag = function(k,vel){

var force;

var velMag = vel.length();

if (velMag > 0) {

force = vel.multiply(-k);

}else {

force = new Vector2D(0,0);

}

return force;

}

如你所见,linearDrag函数有两个参数:物体的阻力常数k(一个Number)和速度vel(一个Vector2D)。

接下来,我们创建一个静态方法add()来添加任意数量的力:

Forces.add = function(arr){

var forceSum = new Vector2D(0,0);

for (var i=0; i<arr.length; i++){

var force = arr[i];

forceSum.incrementBy(force);

}

return forceSum;

}

add()方法将力的数组arr作为参数。它遍历数组,依次添加力并返回最终的矢量和。这就是我们目前所需要的。在接下来的几章中,我们将添加更多的力函数作为Forces对象的静态方法。要使用Forces对象,不要忘记在你的 HTML 文件中添加文件forces.js(可以在 http://apress.com 找到所有的源代码)。

一个简单的例子:有阻力的抛射体

为了演示如何使用Forces类,让我们看一个简单的例子,它将我们在前面两节中讨论的内容结合在一起。假设我们想让一个粒子在重力作用下运动,同时经历阻力(比如一个物体在空气或水等流体中抛出或下落)。文件forces-test.js显示了如何做:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var ball;

var t;

var t0;

var dt;

var animId;

var force;

var acc;

var g = 10;

var k = 0.1;

var animTime = 10; // duration of animation

window.onload = init;

function init() {

ball = new Ball(15,'#0000ff',1,0,true);

ball.pos2D = new Vector2D(50,400);

ball.velo2D = new Vector2D(60,-60);

ball.draw(context);

t0 = new Date().getTime();

t = 0;

animFrame();

};

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

t += dt;

if (t < animTime){

move();

}else{

stop();

}

}

function move(){

moveObject();

calcForce();

updateAccel();

updateVelo();

}

function stop(){

cancelAnimationFrame(animId);

}

function moveObject(){

ball.pos2D = ball.pos2D.addScaled(ball.velo2D,dt);

context.clearRect(0, 0, canvas.width, canvas.height);

ball.draw(context);

}

function calcForce(){

var gravity = Forces.constantGravity(ball.mass,g);

var drag = Forces.linearDrag(k,ball.velo2D);

force = Forces.add([gravity, drag]);

}

function updateAccel(){

acc = force.multiply(1/ball.mass);

}

function updateVelo(){

ball.velo2D = ball.velo2D.addScaled(acc,dt);

}

这里的新物理发生在calcForce()方法中,我们通过使用Forces类的相关静态方法来包括重力和线性阻力。所以我们调用了Forces.constantGravity(),使用 g = 10 和粒子质量。我们还使用 k = 0.1 调用了Forces.linearDrag()。然后我们通过将它们作为数组参数传递给Forces.add()方法来添加这两个力,并将结果赋给force变量。

运行代码,你会看到一个球被向上抛;然后它被重力拉下,并因阻力而减速。

为了理解附加阻力对球运动的影响,用下面的行替换calcForce()中的最后两行:

force = gravity;

这使得球只在重力作用下运动。如果你现在运行代码,你会看到球遵循抛物线轨迹,就像第四章中的抛射体模拟一样。

另一方面,如果你保持阻力并将阻力系数 k 增加到 0.5,阻力将产生更极端的影响,迅速扼杀球的水平运动,并使它在此后几乎垂直下落——类似于你向上撞击气球可能发生的情况。

这个简单的例子演示了利用Forces对象构建模拟是多么容易,以及如何通过在calcForce()方法中改变力及其参数来获得不同的效果。

为了让您了解这种方法有多灵活和强大,让我们看一个稍微复杂一些的例子。

一个更复杂的例子:浮球

我们要看的例子包括在空中或水中扔一个球,并让它像在现实生活中那样运动。这个例子使用了比我们所涵盖的更多的物理知识,所以我们不会深入到所涉及的物理或编码的细节中,留给第七章更完整的讨论。在这个阶段,我们只是想通过向您展示使用本节中概述的方法,通过相当少量的简单编码可以完成什么来吊起您的胃口。

这个例子的源代码在文件floating-ball.js中。在我们看这个之前,在 HTML 设置上简单说一下,给出想要的视觉环境,如图 5-4 所示。正如你从这个截图中看到的,我们有一个代表水的矩形区域,和一个看起来部分浸入其中的球。为了实现这种视觉效果,水被绘制在前景中的透明画布实例canvas_fg上,动画发生在另一个名为canvas的画布实例上。看看文件floating-ball.htmlstyle2.css,看看这是如何实现的。

A978-1-4302-6338-8_5_Fig4_HTML.jpg

图 5-4。

The floating ball simulation

以下是floating-ball.js的完整代码:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_fg = document.getElementById('canvas_fg');

var context_fg = canvas_fg.getContext('2d');

var ball;var t0;

var dt;

var animId;

var force;

var acc;

var g = 50;

var k = 0.01;

var rho = 1.5;

var V = 1;

var yLevel = 300;

var vfac = -0.8;

window.onload = init;

function init() {

// create a ball

ball = new Ball(40,'#0000ff',1,0,true);

ball.pos2D = new Vector2D(50,50);

ball.velo2D = new Vector2D(40,-20);

//ball.velo2D = new Vector2D(20,-60);

ball.draw(context);

// create water

context_fg.fillStyle = "rgba(0,255,255,0.5)";

context_fg.fillRect(0,yLevel,canvas.width,canvas.height);

// set up event listeners

addEventListener('mousedown',onDown,false);

addEventListener('mouseup',onUp,false);

// initialize time and animate

initAnim();

};

function onDown(evt) {

ball.velo2D = new Vector2D(0,0);

ball.pos2D = new Vector2D(evt.clientX,evt.clientY);

moveObject();

stop();

}

function onUp(evt) {

ball.velo2D = new Vector2D(evt.clientX-ball.x,evt.clientY-ball.y);

initAnim();

}

function initAnim(){

t0 = new Date().getTime();

animFrame();

}

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

moveObject();

calcForce();

updateAccel();

updateVelo();

}

function stop(){

cancelAnimationFrame(animId);

}

function moveObject(){

ball.pos2D = ball.pos2D.addScaled(ball.velo2D,dt);

context.clearRect(0, 0, canvas.width, canvas.height);

ball.draw(context);

}

function calcForce(){

//force = new Vector2D(0,ball.mass*g-k*ball.vy);

var gravity = Forces.constantGravity(ball.mass,g);

var rball = ball.radius;

var xball = ball.x;

var yball = ball.y;

var dr = (yball-yLevel)/rball;

var ratio; // volume fraction of object that is submerged

if (dr <= -1){ // object completely out of water

ratio = 0;

}else if (dr < 1){ // object partially in water

//ratio = 0.5 + 0.5*dr; // for cuboid

ratio = 0.5 + 0.25*dr*(3-dr*dr); // for sphere

}else{ // object completely in water

ratio = 1;

}

var upthrust = new Vector2D(0,-rho*V*ratio*g);

var drag = ball.velo2D.multiply(-ratio*k*ball.velo2D.length());

force = Forces.add([gravity, upthrust, drag]);

//force = Forces.add([gravity, upthrust]);

if (xball < rball){

ball.xpos = rball;

ball.vx *= vfac;

}

if (xball > canvas.width - rball){

ball.xpos = canvas.width - rball;

ball.vx *= vfac;

}

}

function updateAccel(){

acc = force.multiply(1/ball.mass);

}

function updateVelo(){

ball.velo2D = ball.velo2D.addScaled(acc,dt);

}

不用深入细节,你可以看到我们有一个更复杂的calcForce()方法,包括三个力:重力、上推力和阻力。calcForce()中也有一些逻辑,告诉代码如何根据球的位置计算出球上的力。此外,还有一些逻辑告诉代码在边界上做什么。最后,不同的力有许多参数。不过,总的来说,这肯定不是一段过于复杂的代码,您应该有可能了解它在做什么。

onDown()onUp()方法允许用户通过点击鼠标与模拟交互。如果你点击画布上的任何地方,球会立即移动到那里。如果您按住鼠标按钮,拖动光标,然后释放鼠标,球将被赋予一个速度,该速度在数值上等于球与释放鼠标的点之间的距离。

运行模拟,看看它在多大程度上像真实的东西一样,所有这些都是用相对少量的代码完成的。很好玩!

作为微分方程的牛顿第二定律

这一节特别为那些想了解我们在这里所做的事情和你通常在物理教科书、物理网站或维基百科上找到的东西之间的联系的读者准备。它提供了对材料更深入的理解,但在本书的其余部分中并不是严格要求的。如果你愿意,你可以安全地跳过它。

如果你是一个严肃的物理程序员,你可能会在某个时候发现自己在钻研物理教科书或在线资源,也许是为了寻找一些公式或寻找特定问题的解决方案。现在,如果你在寻找任何涉及牛顿第二定律的问题的答案,你很可能会遇到微分方程,以及用数学来解析地解决它们。所有这些数学与我们在前面讨论的数值求解牛顿定律的方法有什么关系?在接下来的两个小节中,我们从概念上解释这种联系,然后用一个具体的例子来说明它。

深入了解 F = ma

微分方程包含量的导数。解微分方程一般比解普通代数方程更复杂,因为它涉及积分(参见第三章)。

F = m a 形式的牛顿第二定律表面上看起来可能像一个简单的代数方程。

然而,记住加速度实际上是速度的导数是有用的,a = d v/dt,这样我们也可以把牛顿第二定律写成

A978-1-4302-6338-8_5_Figa_HTML.jpg

这是所谓的关于速度的一阶微分方程,因为它涉及速度的一阶导数。回想 v = d s/dt,我们可以将前面的等式写成

A978-1-4302-6338-8_5_Figb_HTML.jpg

这现在是一个关于位移的二阶微分方程,因为它涉及到位移的二阶导数(参考第三章)。

一般来说,力 F 可以是位置和速度的函数。你很快就会看到这样一个力函数的例子。

如果你看物理教科书,你有时会看到牛顿第二定律以这些形式表达。原则上,前述微分方程可以通过解析或数值积分来求解,以产生作为时间函数的位移 s 和速度 v。大部分物理教材侧重于解析解。但是只有在特殊情况下解析解才是可能的,这需要应用微积分积分技术。另一方面,用数值方法求解微分方程总是可能的。事实上,这正是我们在我们所看到的例子中所做的。具体来说,我们在updateVelo()方法中积分微分方程的第一种形式,然后在moveObject()方法中积分速度以给出位移。

下一个例子将说明一种情况,在这种情况下,我们可以用解析方法和数值方法求解牛顿第二定律。我们将用这个例子向你展示这个微分方程的典型解析解。我们还将比较精确的解析解和数值积分解,看看后者有多好。

示例:重温重力和阻力下的坠落

这个例子建立在上一章所描述的例子的基础上,在这个例子中,我们模拟了一个球在重力和阻力的联合作用下下落,并表明它达到了简单物理理论所预测的极限速度。这里,我们将更新示例,以比较详细的解析解和模拟。

这种情况下的微分方程如下(注意这是 1D 情况;因此没有必要使用向量符号):

A978-1-4302-6338-8_5_Figc_HTML.jpg

或者是速度的一阶形式:

A978-1-4302-6338-8_5_Figd_HTML.jpg

这个方程的解析解在很多物理教材中都有给出(有兴趣的可以从 www.physicscodes.com 下载一个推导作为补充资料)。对于从静止状态掉落的物体,它是这样的:

A978-1-4302-6338-8_5_Fige_HTML.jpg

当时间 t 较大时,指数项趋于零(回想一下第三章对指数函数的复习)。所以根据这个方程,v 趋向于一个极限值 mg/k,当然就是终速度。所以这个解决方案与我们在第四章中发现的一致。另外,它现在告诉我们在任意时刻 t 的速度,而不仅仅是终端速度。

这个解可以反过来积分,给出任意时刻 t 的位移,结果如下:

A978-1-4302-6338-8_5_Figf_HTML.jpg

现在可以将位移 s 和速度 v 的这些解析解与模拟结果进行比较。在forces-example2.js中,我们从第四章更新了forces-example.js来做这个比较。我们也在calcForce()中使用了Forces方法;除此之外,物理学与forces-example.js中的完全相同,因为涉及到相同的力(重力和阻力)。

相关代码是函数plotGraphs(),它绘制图表来比较 s 和 v 的分析值和数值:

function plotGraphs(){

graphDisp.plot([t], [ball.y-y0], '#ff0000');

graphDisp.plot([t], [m*g/k*(t+m/k*Math.exp(-k/m*t)-m/k)], '#0000ff');

graphVelo.plot([t], [ball.vy], '#ff0000');

graphVelo.plot([t], [m*g/k*(1-Math.exp(-k/m*t))], '#0000ff');

}

在这段代码中,我们绘制了球的垂直位移,即ball.y减去其初始值y0,以及由Graph实例graphDisp的解析解给出的值。类似地,graphVelo显示由代码和解析解计算的垂直速度。模拟如图 5-5 所示。两者之间的一致性如此之好,以至于在两个图中各自的曲线位于彼此的顶部。因此,我们很高兴在这种情况下,代码中实现的简单欧拉积分方案实际上做得相当不错。

A978-1-4302-6338-8_5_Fig5_HTML.jpg

图 5-5。

Comparing numerical and analytical solutions for a ball falling under gravity and drag

能量守恒原理

我们在前一章中介绍了能量及其守恒。这里要重申的关键点是,我们可以应用能量守恒原理来计算事物如何运动以及它们如何与其他事物相互作用。

同样重要的是要记住,该原理可以应用于不同形式之间的能量转换,以及不同物体之间的能量转移。以下是原则的陈述,形式略有不同:

  • 能量守恒原理:在不同形式的能量转换中,或在不同物体间的能量转移中,转换或转移前后的能量总量总是相等的。

该原理的一个特别有用的形式是当能量转换或转移只涉及势能和动能时。我们把 PE 和 KE 统称为机械能。

机械能守恒

虽然能量守恒原理的一般形式是强有力的,而且总是正确的,但在实践中可能很难应用,因为计算一个相互作用中涉及的所有能量形式并不总是容易的。例如,想一想当一个球落下,穿过空气落下,从一个表面弹回时所涉及的能量转换。球最初有 PE,当它穿过空气下落时,由于重力的作用,当它加速时,PE 逐渐转化为 KE。由于空气中的摩擦(阻力),少量能量也转化为热量。当它撞击地表时,大量的能量可能会转移到地表。更多的能量通常在撞击时转化为热能,一些也可以转化为声音。现在,我们将避开试图计算像热能和声能这样的东西,因为那会变得相当复杂。但有时,如果它们可以被假设为很小,我们只需要处理 PE 和 KE 在那种情况下,我们说机械能守恒。

机械能守恒是一个特别有用的原理,因为它涉及到与运动(KE)和位置(PE)有关的能量形式。在这一章中,我们将看两个应用它的例子。一种是两个粒子的弹性碰撞,此时粒子的总动能守恒。我们将在后面的“动量守恒原理”一节中讨论这个问题另一个例子是抛射体的运动(忽略空气阻力产生的阻力)。现在我们来看看这个例子。

例子:射弹中的能量变化

为了简化这个例子,让我们假设抛射体以某个初速度 u 从地面水平垂直向上发射。然后我们有一个在重力作用下恒定加速度的 1D 问题,我们可以使用在第四章中介绍的运动分析方程的 1D 版本:

A978-1-4302-6338-8_5_Figg_HTML.jpg

A978-1-4302-6338-8_5_Figh_HTML.jpg

这里 s = h,即抛射体离地面的高度,a =–g,其中 g 是重力加速度。所以我们可以这样写:

A978-1-4302-6338-8_5_Figi_HTML.jpg

A978-1-4302-6338-8_5_Figj_HTML.jpg

现在可以很容易地用下面的公式计算出射弹在任何时候的 PE 和 KE:

A978-1-4302-6338-8_5_Figk_HTML.jpg

A978-1-4302-6338-8_5_Figl_HTML.jpg

以下代码使用 m = 1、g = 10 px/s 2 和 u = 50 px/s 的值计算并绘制 10 秒钟的 PE、KE 及其总和。源代码在projectile-energy.js中。

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_bg = document.getElementById('canvas_bg');

var context_bg = canvas_bg.getContext('2d');

var ball;

var animId;

var graph;

var m = 1;  // particle mass

var g = 10; // gravity

var u = 50; // initial velocity

var groundLevel = 350;

var n = 0;

var tA = new Array();

var hA = new Array();

var peA = new Array();

var keA = new Array();

var teA = new Array();

window.onload = init;

function init() {

ball = new Ball(10,'#000000',m,0,true);

ball.pos2D = new Vector2D(550,groundLevel);

ball.draw(context);

setupGraph();

setupArrays();

animFrame();

};

function setupGraph(){

//graph = new Graph(context,xmin,xmax,ymin,ymax,xorig,yorig,xwidth,ywidth);

graph = new Graph(context_bg,0,10,0,1500,50,350,450,300);

graph.drawgrid(1,0.5,500,100);

graph.drawaxes('t','p.e., k.e., total');

}

function setupArrays(){

var t;

var v;

for (var i=0; i<=100; i++){

tA[i] = i*0.1;

t = tA[i];

v = u - g*t;

hA[i] = u*t - 0.5*g*t*t;

peA[i] = m*g*hA[i];

keA[i] = 0.5*m*v*v;

teA[i] = peA[i] + keA[i];

}

}

function animFrame(){

setTimeout(function() {

animId = requestAnimationFrame(animFrame,canvas);

animate();

}, 1000/10);

}

function animate(){

moveObject();

plotGraphs();

n++;

if (n==hA.length){

stop();

}

}

function moveObject(){

ball.y = groundLevel-hA[n];

context.clearRect(0, 0, canvas.width, canvas.height);

ball.draw(context);

}

function plotGraphs(){

graph.plot([tA[n]], [peA[n]], '#ff0000', true, false);

graph.plot([tA[n]], [keA[n]], '#0000ff', true, false);

graph.plot([tA[n]], [teA[n]], '#000000', true, false);

function stop(){

cancelAnimationFrame(animId);

}

代码应该容易理解。注意,我们通过在setTimeout()函数中嵌套对requestAnimationFrame()animate()的调用,将帧速率限制为 10 fps。这将使动画变慢,足以使球的运动在视觉上与图上的相应位置相匹配。在图 5-6 中,我们显示了作为时间函数的三个曲线图。向上弯曲的一系列点是 PE,向下弯曲的一系列点是 KE,水平的(常数)是总能量(PE 和 KE 之和)。从这些图中,我们了解到,当抛射体最初在地面上发射时,它具有零 PE 和最大 KE,然后,在它上升的前 5 秒,它的 PE 以其 KE 为代价增加。恰好在 5 秒钟时,它的 KE 为零,表明它暂时处于静止状态。当它达到最高点时,就会发生这种情况。然后它又开始往下掉。当它这样做时,它的 PE 又开始下降,它的 KE 随着向地面加速而增加。在整个运动过程中,PE 和 KE 的总和是恒定的,如水平系列的点所示。这证明了能量守恒。注意,PE 和 KE 之和只是常数,因为我们忽略了阻力效应。如果你包括阻力,那么你会发现 PE 和 KE 之和会随着时间的推移而略微减小。如果你特别感兴趣,你可以通过增加一个阻力项,对运动方程进行数值积分,给出 v 和 h 作为时间的函数。

A978-1-4302-6338-8_5_Fig6_HTML.jpg

图 5-6。

Energy graphs for a projectile—downward curve: KE; upward curve: PE; constant line: total

动量守恒原理

如前一章所述,动量守恒定律和能量守恒定律一样。先说原理,再解释:

  • 动量守恒原理:对于任何相互作用的粒子系统,只要没有外力作用于该系统,所有粒子的总动量保持不变。

“相互作用的粒子”是指粒子通过相互施加力来相互影响。力可以是任何类型的;例如,星系中恒星之间的引力。不管粒子间力的性质如何,这个原理都是正确的。一旦你定义了你的系统,该系统中的粒子可能会相互受到任何数量的内力——只要没有外力,该系统的总动量就会守恒。

动量守恒与牛顿运动定律密切相关。实际上,在一定条件下,可能是从牛顿定律推导出来的。让我们看看怎么做。

起点是牛顿第二定律 F = d p/dt,我们可以写成离散形式 F =δp/δt,两边乘以δt 得出:

A978-1-4302-6338-8_5_Figm_HTML.jpg

当然,这只是牛顿第二定律的一种稍微不同的形式,对于一个小但有限的时间间隔δt。它告诉我们的是,如果一个力 F 作用在一个粒子上一个小的持续时间δt,用 F 乘以δt 就可以得到粒子动量的变化δp。我们称这个量 fδt 为力引起的冲量。前面的关系叫做冲量-动量定理。

接下来,想象两个粒子相互作用(相互施加力;例如通过碰撞),如图 5-7 所示。从牛顿第三运动定律来看,它们同时对彼此施加大小相等方向相反的力。如果它们在δt 的时间间隔内相互施加力 F 和–F,它们分别经历–Fδt 和 Fδt 的冲量。根据冲量-动量定理,这意味着它们交换动量,分别获得–δp 和δp。然而,两个粒子的总动量仍然相同,因为一个粒子获得的动量正好是另一个粒子获得的动量的负值;换句话说,一个粒子失去的动量被另一个粒子获得。这个论点延伸到任何数量的相互作用的粒子,所以我们有动量守恒原理。

A978-1-4302-6338-8_5_Fig7_HTML.jpg

图 5-7。

Two interacting particles exchanging momentum

动量守恒的例子包括:

  • 一个苹果落到地上,每一瞬间都会获得向下的动量。考虑到苹果-地球系统,地球本身必须“倒向”苹果以进行补偿。但是因为地球的质量如此之大,它朝向苹果的速度非常小。
  • 火箭排出的废气和火箭本身在相反的方向上获得相等的动量变化。
  • 在爆炸中,所有碎片的总动量必须等于爆炸前整个物体的动量。如果物体最初是静止的,爆炸后的总动量(所有碎片动量的矢量和)仍然为零。

为了让你感觉到如何在实践中应用这个原则,这里有一个数值例子。假设你从一把质量为 1.6 kg 的步枪中射出一颗质量为 40 g 的子弹,子弹的出膛速度为 80 m/s,那么步枪的后坐速度是多少?

让我们分别用 m 和 v 来表示子弹的质量和速度。我们将用 M 和 V 分别表示步枪的质量和速度。最初,两者都是静止的,所以总动量为零。

因此,动量守恒原理意味着最终动量也应该为零:

A978-1-4302-6338-8_5_Fign_HTML.jpg

这个等式可以很容易地重新排列为:

A978-1-4302-6338-8_5_Figo_HTML.jpg

代入 M、v 和 M 的值,得到如下结果:

A978-1-4302-6338-8_5_Figp_HTML.jpg

因此,步枪的后坐力速度是–2 米/秒。负号表示它与子弹的速度方向相反。

例子:两个粒子之间的 1D 弹性碰撞

动量守恒在处理粒子间的碰撞时特别有用。碰撞是一种特殊类型的相互作用,在这种相互作用中,粒子在很短的时间内相互施加很大的力。

第十一章的全部内容都致力于碰撞。在这里,我们将简要地看一下 1D 中两个粒子之间弹性碰撞的最简单情况。这里的弹性意味着动能守恒。换句话说,在碰撞过程中,没有能量转化为其他形式(如热量)。当然,动量总是守恒的。

假设粒子的质量为 m1 和 m2,碰撞前的初速度为 u1 和 u2,碰撞后的终速度为 v1 和 v2(见图 5-8 )。运用动量守恒告诉我们

A978-1-4302-6338-8_5_Figq_HTML.jpg

应用动能守恒得出:

A978-1-4302-6338-8_5_Figr_HTML.jpg

记住动能的公式是 1/2 m v 2

A978-1-4302-6338-8_5_Fig8_HTML.jpg

图 5-8。

Change in particle velocities caused by a collision

这里我们有两个方程,包含两个未知量:两个碰撞粒子的最终速度 v1 和 v2。其他的都知道了。可以求解这些方程,根据已知量 m1、m2、u1 和 u2 给出 v1 和 v2 的一般公式。但是我们会把它留到第十一章来讲。

现在,我们来讨论 m1 = m2 的情况;也就是说,这两个粒子具有相同的质量。在那种情况下,可以证明 v1 = u2,v2 = u1 换句话说,粒子 1 的最终速度等于粒子 2 的初始速度,反之亦然。相同质量的粒子弹性碰撞只是交换了它们的速度!

我们现在将构建一个实现这种特殊情况的示例。代码在collisions-test.js里。由于它与您到目前为止遇到的其他示例有些不同,所以在讨论它之前,我们在这里复制完整的代码:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var t0;

var dt;

var animId;

var radius = 15; // ball radius

var balls = new Array();

window.onload = init;

function init() {

makeBalls();

t0 = new Date().getTime();

animFrame();

};

function makeBalls(){

setupBall('#0000ff',new Vector2D(50,200),new Vector2D(30,0));

setupBall('#ff0000',new Vector2D(500,200),new Vector2D(-20,0));

setupBall('#00ff00',new Vector2D(300,200),new Vector2D(10,0));

}

function setupBall(color,pos,velo){

var ball = new Ball(radius,color,1,0,true);

ball.pos2D = pos;

ball.velo2D = velo;

ball.draw(context);

balls.push(ball);

}

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};     checkCollision();

move();

}

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

for (var i=0; i<balls.length; i++){

var ball = balls[i];

ball.pos2D = ball.pos2D.addScaled(ball.velo2D,dt);

ball.draw(context);

}

}

function checkCollision(){

for (var i=0; i<balls.length; i++){

var ball1 = balls[i];

for (var j=i+1; j<balls.length; j++){

var ball2 = balls[j];

if (Vector2D.distance(ball1.pos2D,ball2.pos2D)<=ball1.radius+ball2.radius){

var vtemp = ball1.velo2D;

ball1.velo2D = ball2.velo2D;

ball2.velo2D = vtemp;

}

}

}

}

这里我们在函数makeBalls()中创建和初始化三个球,使用一个特殊的函数setupBalls()来最小化代码重复。为了让事情更有趣一点,我们创建了三个水平排列的球,并给它们不同的水平速度。动画循环代码看起来没什么特别的,但是触发每个时间步长的onTimer()方法现在除了move()方法之外还包含了一个额外的函数checkCollision()

checkCollision()中,我们测试了阵列中粒子对之间的碰撞。为此,我们使用Vector2D.distance(vec1,vec2)静态方法,用位置向量vec1vec2计算两点之间的距离。碰撞检测算法的逻辑很简单:如果两个粒子中心之间的距离小于或等于它们半径的总和,这意味着它们已经碰撞。然后我们交换两个粒子的速度。

顺便说一下,Vector2D.distance()方法通过勾股定理计算两点之间的距离(参见第三章),如下面相关Vector2D方法列表所示:

Vector2D.prototype = {

lengthSquared: function(){

return this.x*this.x + this.y*this.y;

},

length: function(){

return Math.sqrt(this.lengthSquared());

},

}

Vector2D.distance =  function(vec1,vec2){

return (vec1.subtract(vec2)).length();

}

如果你运行代码,你会看到,最初,所有三个球靠得更近了。然后,它们经历三次连续的碰撞,交换每一对碰撞的速度。最后,他们都彼此远离。

支配旋转运动的定律

在这一章中,我们集中讨论了平移运动,在这种运动中,所考虑的物体改变了它的位置。但是如果一个物体围绕一个中心旋转或者(如果它是一个延伸的物体)围绕某个轴自转呢?

事实证明,旋转运动学、动力学和守恒定律也存在类似的原理。例如,牛顿运动定律的模拟可以写成旋转运动。类似于动量,有一个量叫做角动量,它也是守恒的。

我们将在第九章和第十三章中研究旋转力学。

摘要

这一章为模拟粒子在任何类型的力下的运动奠定了基础。这里讨论的运动定律和守恒原理可以应用于许多不同的场景。在第二部分的剩余章节中,我们将应用这些定律来模拟物体在各种力定律作用下的运动。

六、重力、轨道和火箭

从这一章开始,你将探索许多不同的力和它们产生的运动类型。这里我们只关注重力。你将学习重力如何使地球和太空中的物体运动。在你知道之前,你将能够编码轨道和火箭!

本章涵盖的主题包括以下内容:

  • 重力:引力,或称万有引力,是地球对其附近所有物体施加的力。但是重力有比我们到目前为止讨论的更多的东西。
  • 轨道:重力效应的一个例子是让行星保持在围绕太阳的轨道上。了解如何轻松构建简单的轨道模拟。
  • 局部重力:在地球表面附近,重力在局部尺度上的表现。
  • 火箭:制造一个简单的火箭,发射它,并让它围绕一个行星运行。

重力

作为一个星球上的居民,我们可能比任何其他力量都更了解重力。重力,也称为万有引力,将在你在本书中构建的大多数模拟中发挥作用。事实上,你已经遇到了几个例子。然而,到目前为止,在你遇到的所有情况下,我们都是把地球表面附近存在的重力作为一个恒力来处理的。现在是时候深入了解重力了。

重力、重量和质量

让我们首先简要回顾一下在前面的章节中你已经学到的关于重力的知识。如果一个物体的质量为 m,那么地球会对它施加一个垂直向下的力。这个重力的大小等于 mg,其中 g 是一个常数,在地球表面附近大约等于 9.81 m/s 2 。这个力也叫做物体的重量。

常数 g 等于任何物体在重力的单独作用下运动时所受到的加速度。我们可以用牛顿第二定律 F = ma 来看这个。如果合力 F 只是重力,那么 F = mg。因此,我们有了这个:

A978-1-4302-6338-8_6_Figa_HTML.jpg

两边除以 m 得到这个:

A978-1-4302-6338-8_6_Figb_HTML.jpg

这个结果告诉我们,所有在重力作用下运动的物体,不管它们的质量如何,或者说,不管它们有什么其他性质,都经历相同的加速度。所以,假设所有其他的力都可以忽略不计,如果你从相同的高度落下一把锤子和一栋房子,两者会以相同的速度加速,因此会同时落下!

当然,如果你掉了一根羽毛,要花很长时间才能掉下来。这是因为有一个向上的阻力,这是由于空气阻碍了它的下落。在石头或房子的情况下也有阻力,但在这些情况下,与它们的重量相比,这种力小得可以忽略不计,因此对它们的运动影响也可以忽略不计。如果你把一根羽毛和一把锤子扔到月球上,那里没有空气,因此没有阻力,它们会同时落下。事实上,宇航员大卫·斯科特在阿波罗 15 号任务中正是这样做的。你甚至可以在美国宇航局的网站或 YouTube 上看到这一壮举的视频(搜索“锤子和羽毛”)。

牛顿万有引力定律

地球不是唯一施加重力的物体。牛顿告诉我们,任何物体都会对其他物体产生引力。因此,不仅地球对我们施加引力,而且我们每个人也对地球施加引力,事实上对彼此也是如此!

牛顿给出了计算任意两个物体间引力的精确公式,这就是引力的力定律:

A978-1-4302-6338-8_6_Figc_HTML.jpg

在这个公式中,m 1 和 m 2 是所涉及物体的质量,r 是它们中心之间的距离。符号 G 是一个通用常数,其值约为 6.67×10-11Nm2/kg2。它被称为引力常数。我们所说的普适性是指,不管相互作用的物体的属性如何,它都具有相同的值。牛顿假设,同样的公式,同样的 G 值,适用于任何两个物体——从微小的粒子到行星和恒星。因此,我们称之为牛顿万有引力定律。

让我们试着理解这个公式告诉我们什么。首先,引力 F 与两个物体的质量 m 1 和 m 2 成正比。因此,质量越大,力就越大。第二,这个力与它们之间距离的平方成反比;这意味着我们除以 r 2 。因此,两个物体之间的距离越大,力就越小(因为我们将一个数除以一个非常大的数的平方)。第三,因为我们是乘以一个很小的数 G (6.67 × 10 -11 等于 0.0000000000667),所以力会很小,除非涉及非常大的质量。所以,重力是一种很弱的力,除非至少有一个物体质量很大。

为了让你了解我们的意思,让我们计算两个体重 80 公斤、相距 1 米的人(我们称他们为 Joe1 和 Joe2)之间的重力,并将其与地球施加在他们每个人身上的重力进行比较。

利用前面的公式,Joe1 和 Joe2 之间的作用力由 F = 6.67×10-11×80×80/12N = 4.3×10-7N 给出(近似)。那是 0.43 百万分之一牛顿。

地球对每一个施加的力,当然等于它们的重量:mg = 80 × 9.81 N = 785 N(近似值)。你也可以用前面的公式和地球的质量(5.97 × 10 24 kg)和半径(6.37 × 10 6 m)算出 F = 6.67×10-11×5.97×1024×80/(6.37×1062N = 785N .那

我们还没说力的方向。牛顿说力总是沿着连接两个物体中心的线作用(见图 6-1 )。

A978-1-4302-6338-8_6_Fig1_HTML.jpg

图 6-1。

Newton’s law of universal gravitation

还要注意,Joe1 和 Joe2 上的力形成一个作用力-反作用力对。因此,Joe1 对 Joe2 施加 4.3 × 10 -7 N 的力,反之亦然,两个力大小相等方向相反——两个 Joe 中的每一个与地球之间的力也是如此。

创建重力函数

我们现在将创建一个Forces对象的静态方法,它将以牛顿引力定律的形式实现重力。该方法将把进入引力定律的变量作为输入,即 G,m 1 ,m 2 和 r。它必须返回一个矢量力。因此,我们需要一个矢量形式的牛顿引力定律,它实现了力沿着连接两个物体的直线指向的事实。我们先给出公式,然后再解释:

A978-1-4302-6338-8_6_Figd_HTML.jpg

与之前重力定律的公式相比,我们所做的是把力写成一个向量;然后我们把力的大小乘以–r/r,回想一下第三章 r/r 是 r 方向的单位向量,这里的 r 是其中一个物体相对于另一个物体位置的位置向量。

如图 6-2 所示,r 是两个物体的位置矢量之差,r 是其大小,简单来说就是两个物体之间的距离。回想一下,有两个力,而不是一个:物体 2 对物体 1 的力,反之亦然。这两个力大小相等,方向相反。我们总是把 r 定义为力所作用的物体相对于施加力的物体的位置矢量。我们需要负号,因为力的方向与 r 相反。

A978-1-4302-6338-8_6_Fig2_HTML.jpg

图 6-2。

Vector form of Newton’s law of universal gravitation

下面是实现重力的静态方法:

Forces.gravity = function(G,m1,m2,r){

return r.multiply(-G*m1*m2/(r.lengthSquared()*r.length()));

}

我们只是使用Vector2D对象的multiply()方法返回一个新的向量,该向量将 r 乘以(-G m1m2/r3)。现在让我们在接下来的几个例子中使用这个gravity()方法。

在应用gravity函数之前,我们必须为 G、m 1 、m 2 和 r 选择合适的值。因此,我们必须适当地缩放物理值,以便在合理的时间内在屏幕上获得正确的运动量。例如,如果我们正在模拟地球围绕太阳的运动,我们不会想要使用所有变量的真实值,例如太阳的质量,等等。那样做意味着我们要等一年才能看到地球绕太阳转一周!

在第四部分,我们将向你展示如何正确地创建一个按比例缩小的计算机模型。现在,我们将简化这种方法,并做出一些合适的选择,只是为了使运动在屏幕上看起来大致正确。为此,我们将为模型的所有变量选择自己的单位。

让我们从距离 r 开始。这很简单,这里使用的自然单位是像素。这就给我们留下了质量和 G,我们可以给 G 任何我们喜欢的值,所以我们选 G = 1。做了这两个选择后,我们现在需要为质量选择合适的值,以便在屏幕上产生明显的适量运动。我们来看一个例子:轨道!

轨道

使用我们的重力功能,很容易创建一个逼真的轨道模拟。图 6-3 显示了我们将要创建的截图:一颗行星围绕着一个太阳旋转,背景是固定的恒星。为简单起见,我们假设太阳保持不变。事实上,正如我们之前看到的,太阳和行星都将经历大小相同但方向相反的引力(每个行星上的力都指向另一个)。所以行星和太阳都会移动。但是,如果太阳的质量比行星的质量大得多,太阳的运动将是如此之小,无论如何,它几乎是可感知的。那是因为 F = maagain,所以加速度 a = F/m,这样,如果质量 m 很大,加速度 a 就会很小。所以为了节省编码和 CPU 时间,我们可以完全忽略太阳的运动。

A978-1-4302-6338-8_6_Fig3_HTML.jpg

图 6-3。

A planet orbiting a stationary sun

轨道代码

实现基本轨道模拟的代码比您想象的要简单。文件orbits.js包含完整的代码,如下所示:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_bg = document.getElementById('canvas_bg');

var context_bg = canvas_bg.getContext('2d');

var planet;

var sun;

var m = 1; // planet's mass

var M = 1000000; // sun's mass

var G = 1;

var t0,dt;

window.onload = init;

function init() {

// create 100 stars randomly positioned

for (var i=0; i<100; i++){

var star = new Ball(Math.random()*2,'#ffff00');

star.pos2D= new Vector2D(Math.random()*canvas_bg.width,Math.random()*canvas_bg.height);

star.draw(context_bg);

}

// create a stationary sun

sun = new Ball(70,'#ff9900',M,0,true);

sun.pos2D = new Vector2D(275,200);

sun.draw(context_bg);

// create a moving planet

planet = new Ball(10,'#0000ff',m,0,true);

planet.pos2D = new Vector2D(200,50);

planet.velo2D = new Vector2D(70,-40);

planet.draw(context);

// make the planet orbit the sun

t0 = new Date().getTime();

animFrame();

};

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.1) {dt=0;};

move();

}

function move(){

moveObject(planet);

calcForce();

updateAccel();

updateVelo(planet);

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

context.clearRect(0, 0, canvas.width, canvas.height);

obj.draw(context);

}

function calcForce(){

force = Forces.gravity(G,M,m,planet.pos2D.subtract(sun.pos2D));

}

function updateAccel(){

acc = force.multiply(1/m);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

代码非常简单。首先,我们创建一组 100 颗星星作为Ball实例,并将它们随机放在舞台上。当然,这纯粹是出于审美原因。如果你不喜欢明星,那就去除掉他们吧。接下来,我们创建一个太阳作为另一个Ball实例,设置其半径为 70 像素,质量为 1,000,000。我们把太阳定位在(275,200)度。默认情况下,太阳的速度为零。然后我们创建一个行星作为Ball实例,设置它的半径为 10,质量为 1。然后设定行星的初始位置和速度。

动画循环类似于前面的例子;唯一的区别是我们选择在moveObject()updateVelo()中将行星作为参数传递,只是为了一些变化。记住updateAccel()实现了牛顿第二定律,F = m a,让一个粒子在calcForce()方法指定的力的作用下运动。在calcForce()函数中,我们将太阳施加在行星上的重力指定为唯一的力。在Forces.gravity()方法中,我们传递 G 的值(设置为 1)、两个物体的质量以及行星相对于太阳的位置向量:planet.pos2D.subtract(sun.pos2D)

继续运行代码——它真的工作了!

观察行星试图以其初始速度离开,但被太阳引力拉回。你可能想知道为什么行星不落入太阳。事实上,它一直朝着太阳“下落”,但是由于它的初始位置和速度,它每次都“错过”太阳。还要注意,当它靠近太阳时,行星运行得更快。这是因为越靠近太阳,重力越大,因此加速度越大。

这个代码产生了一个真实的轨道模拟,因为它基本上包含了所有在重力作用下控制轨道运动的物理过程。物理学可以归结为四个方程:

A978-1-4302-6338-8_6_Fige_HTML.jpg

A978-1-4302-6338-8_6_Figf_HTML.jpg

A978-1-4302-6338-8_6_Figg_HTML.jpg

A978-1-4302-6338-8_6_Figh_HTML.jpg

在这些方程中,M 是行星的质量,M 是太阳的质量。前两个方程是定律,后两个只是定义。概括一下,这就是我们的代码在每个时间步所做的:从第一定律开始,你计算重力。然后你用第二定律来计算加速度。知道了加速度,你可以积分第三个方程来计算速度。最后,知道了速度,你积分第四个方程,给出位移。这告诉你在每个时间步你必须移动你的粒子到哪里。

尝试更改代码中的参数以查看它们的效果。例如,你可以改变行星和太阳的质量,或者行星的初始位置和速度。我们选择太阳的质量是行星的 100 万倍。这是一个现实的比例;比如太阳的质量是地球的 33 万倍,水星的 610 万倍。

把太阳的质量改成 200 万。行星被拉得离太阳更近,因为引力与太阳的质量成正比。太阳质量增加一倍,引力也增加一倍,因此朝向太阳的加速度也增加一倍。相反,如果你将太阳的质量减少到 900,000,你会看到太阳对行星的吸引力变弱,因此行星漂移得更远。

现在改为改变行星的质量。先做 0.001 再做 1000。你会发现行星的质量对它的运动没有影响。这是怎么回事,你是通过增加行星的质量来增加行星上的力,但然后你必须用更大的力除以它更大的质量。看看上面的前两个方程。在第一个例子中,你乘以行星的质量来计算引力。在第二个例子中,你需要用这个力除以相同的质量来计算加速度。最终结果是加速度与行星的质量无关。这是理所应当的。这相当于我们在本章开始时提到的锤子和羽毛实验。

这是否意味着即使行星的质量与太阳相当,运动也不变?不,不是真的。还记得我们一开始做的近似吗?我们假设太阳的质量比行星的质量大得多,这样我们就可以忽略太阳在行星施加给它的引力作用下的运动。如果行星的质量是太阳质量的很大一部分,我们也需要模拟太阳的运动,即使我们只是对行星的运动感兴趣!这是因为随着太阳的运动,它与行星的距离将会改变,这将改变行星上的力。到目前为止,我们所做的是模拟单体运动。为了解决这个问题,我们需要模拟两体运动。这并没有多难,我们稍后会讲到。在下一章中,我们甚至会研究大量粒子在相互引力作用下的运动。

接下来,用不同的行星初速度做实验。例如,将速度向量更改为(70,–40)会产生一个更圆的轨道,类似于地球的轨道。(85,-40)的速度给出了一个像彗星一样的高度拉长的轨道。在这本书的后面,我们会告诉你如何计算出产生圆形轨道所需的精确速度。

如果行星的速度很小,它会被吸进太阳。例如,将速度更改为(0,0)。行星朝着太阳的中心加速,就像它应该做的那样,但是当它实际到达那里时,奇怪的事情发生了。例如,它可能会反弹回来或高速飞走。这是一种不符合物理的行为,它的发生是因为我们没有考虑代码中对象的有限大小。因为牛顿万有引力定律与 1/r 2 成正比,所以距离 r 越小,力越大。例如,当 r 为 10 个像素时,1/r 2 的值为 0.01;当 r 为 1 时,1/r 2 为 1;而当 r 为 0.1 时,1/r 2 为 100。所以当 r 接近零时(当两个粒子的中心几乎重合时),引力变得无限大。与此同时,向量 r,即从太阳指向行星的位置向量,变得如此之小,以至于数值误差使其方向不可预测。因此,行星在太阳中心受到某个明显随机方向的大加速度。

这是一个普遍的问题,你会遇到像牛顿万有引力定律这样的力定律。您处理它的方式取决于您想要建模的确切内容。如果有东西落入太阳,你很可能想让它消失——这很容易编写代码,你不需要任何特殊的物理知识。如果有东西与像地球这样的固体行星相撞,它将在地球的大气层中燃烧起来,形成一个陨石坑,或者粉碎地球,这取决于它的大小。尽管你可以想象出一些电影风格的特效,但是要精确地建模这些都是相当困难的。如果有东西落入像木星或土星这样的气态巨行星,你可能想要模拟行星内部重力的变化。在本章的后面,我们将会看到一个例子来说明你如何做到这一点。

逃逸速度

事实证明,如果行星的速度大小小于某个临界值,它将总是被太阳的引力“捕获”,要么绕太阳运行,要么以与太阳碰撞而告终。超过这个临界值,行星就会脱离太阳的引力,这个临界值被称为逃逸速度。逃逸速度的公式由下式给出,其中 M 是太阳的质量,r 是距其中心的距离:

A978-1-4302-6338-8_6_Figi_HTML.jpg

所以逃逸速度是离吸引体中心距离的函数。

请注意,我们所说的围绕太阳运行的行星更普遍地适用于任何在重力作用下围绕另一个物体运行的物体——例如,围绕行星运行的卫星(天然的或人造的)。因此,使用上述公式,地球表面任何物体的逃逸速度由下式给出,其中 M e 和 r e 分别是地球的质量和半径:

A978-1-4302-6338-8_6_Figj_HTML.jpg

将 G、M e 和 r e 的值代入上述公式,得到 v = 11.2 km/s。这是地球表面的逃逸速度:如果你能以至少等于这个速度的速度扔出一个抛射体,它将摆脱地球的引力。相比之下,从太阳表面逃逸的速度为 618 公里/秒。相比之下,从 Eros(一颗尺寸约为 34 公里× 11 公里× 11 公里的近地小行星)表面逃逸的速度仅为 10 米/秒。你可以开车离开小行星!

回到Orbits模拟,G = 1,太阳的质量是 1,000,000,行星最初距离太阳的距离是A978-1-4302-6338-8_6_Figk_HTML.jpg个像素。所以行星初始位置的逃逸速度是每秒A978-1-4302-6338-8_6_Figl_HTML.jpg像素。你可以通过模拟来验证这一点。首先把行星的初速度改成(80,0),这样它的星等就是 80。这颗行星将绕太阳运行。现在,通过将速度更改为(100,0),将初始速度值增加到 100。这颗行星将离太阳更远,但仍将围绕太阳运行。即使你将速度改为(105,0)并等待足够长的时间(大约五分钟左右),行星也会从画布的边缘消失,但最终会回来完成一个轨道。但是如果你把速度增加到 109.2 以上,它就再也不会回来了。我们不建议你等待!

两体运动

到目前为止,我们已经让一个物体在另一个物体的引力下运动。但是我们知道重力是一个作用力-反作用力对,所以另一个物体也一定会受到一个大小相等方向相反的力。结果是另一个物体也会移动。修改orbits.js代码来实现这一点相当简单。结果代码在文件two-masses.js中:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_bg = document.getElementById('canvas_bg');

var context_bg = canvas_bg.getContext('2d');

var ball1;

var ball2;

var r1 = 10;

var r2 = 40;

var m1 = 1;

var m2 = 60;

var G = 100000;

var t0, dt;

window.onload = init;

function init() {

var ball1Init = new Ball(r1,'#9999ff',m1,0,true);

ball1Init.pos2D = new Vector2D(150,200);

ball1Init.draw(context_bg);

var ball2Init = new Ball(r2,'#ff9999',m2,0,true);

ball2Init.pos2D = new Vector2D(350,200);

ball2Init.draw(context_bg);

ball1 = new Ball(r1,'#0000ff',m1,0,true);

ball1.pos2D = ball1Init.pos2D;

ball1.velo2D = new Vector2D(0,150);

ball1.draw(context);

ball2 = new Ball(r2,'#ff0000',m2,0,true);

ball2.pos2D = ball2Init.pos2D;

ball2.velo2D = new Vector2D(0,0);

ball2.draw(context);

t0 = new Date().getTime();

animFrame();

};

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.1) {dt=0;};

move();

}

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

moveObject(ball1);

moveObject(ball2);

calcForce(ball1,ball2); // calc force on ball1 due to ball2

update(ball1);

calcForce(ball2,ball1); // calc force on ball2 due to ball1

update(ball2);

}

function update(obj){

updateAccel(obj.mass);

updateVelo(obj);

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

obj.draw(context);

}

function calcForce(obj1,obj2){

force = Forces.gravity(G,obj1.mass,obj2.mass,obj1.pos2D.subtract(obj2.pos2D));

}

function updateAccel(m){

acc = force.multiply(1/m);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

我们在这里所做的基本上很简单。我们创建了几个Ball实例,ball1ball2,然后让每个球在另一个球施加的重力下移动。根据之前指定的数值,ball2的半径比ball1大 4 倍,质量大 60 倍。另外,ball1的初始速度为(0,150),而ball2最初是静止的。我们还创建了另外两个Ball实例ball1Initball2Init,以较浅的颜色表示它们各自对应部分的初始位置。

比较two-masses.jsorbits.js,你会发现我们已经重组了一些动画循环和物理代码。首先,calcForce()方法现在有两个参数:第一个参数是受到力的球;第二个参数是施加力的球。updateAccel()方法现在还需要一个参数,即正在计算加速度的物体的质量。为了方便起见,我们还引入了一个新功能update(),它将对updateAccel()updateVelo()的调用组合在一起。move()方法为每个球连续调用moveObject()calcForce()update()。仔细注意这些函数调用的顺序——顺序很重要!我们把它作为一个练习,让您找出原因,并试验如果您改变函数调用的顺序会发生什么。

我们在这里做的另一件事是将 G 的值改为 100,000。如果通过使用适当的质量值进行补偿来创建所需的效果,则可以给 G 任何想要的值。

如果你运行代码,你会看到ball1围绕ball2旋转,而后者似乎在摆动。这是有道理的。ball1ball2在任何给定时间都经历相同的力,但是因为ball2ball1大 60 倍,所以它的加速度小 60 倍(应用 a = F/m)。所以ball2ball1移动的少很多,如图 6-4 截图所示。请注意,这两个球经历了垂直向下的净缓慢漂移。这是由于动量守恒(见第五章):总的初始动量不为零(ball1最初垂直向下移动,而ball2最初静止),因此这种垂直向下的动量必须一直守恒,即使ball2移动。通过赋予ball2一个初速度,使系统的总初始动量为零,即 m1 × u1 + m2 × u2 = 0,可以很容易地消除这种漂移。因此,ball2的初始垂直速度为 U2 =–m1×u1/m2 =–1×150/60 =–2.5 px/s。在ball2创建后,通过添加以下代码行来实现:

ball2.velo2D = new Vector2D(0,-2.5);

A978-1-4302-6338-8_6_Fig4_HTML.jpg

图 6-4。

Two-body motion (lighter images show initial positions of respective balls)

如果您现在重新运行代码,您会发现残余漂移已经消失,并且由于ball1施加的重力,ball2只经历了轻微的抖动。

现在,通过修改相应的线,使ball2的质量和大小与ball1相同,如下所示:

ball2 = new Ball(r1,'#ff0000',m1,0,true);

然后,通过对代码进行以下更改来更改两个球的初始速度:

ball1.velo2D = new Vector2D(0,10);

ball2.velo2D = new Vector2D(0,-10);

如果你现在运行这段代码,你会发现这两个球以一种奇怪的方式绕着对方旋转,交替地靠近然后远离。实际情况是,它们围绕着一个共同的中心旋转,这个中心就是它们的质量中心。事实上,当球的质量不同时,这种情况也会发生。但是那时质量的中心在大球里面,所以它看起来在摇晃。

局部重力

我们最熟悉的是重力特写。本地的近地重力与你目前所学的有什么关系?在本节中,我们将建立牛顿万有引力定律和我们之前用于引力的公式 F = mg 之间的联系。这将引导我们阐明重力加速度 g 的意义。然后,我们将讨论 g 如何随着与地球中心的距离而变化,以及它在其他天体上如何不同。

地球表面附近的重力

是时候回到地球了!本章开始时(以及前面几章)我们说过,地球附近重力对质量为 m 的物体的作用力由 F = mg 给出,g 约为 9.81 m/s 2 。然后,到目前为止这一章的大部分时间,我们一直在用牛顿万有引力定律,它说引力由 F = Gm1m2/r2给出。我们如何调和这两个不同的公式?

答案是他们都是正确的。如果 M 是地球(或我们正在考虑的任何其他行星或天体)的质量,M 是地球上或附近物体的质量,则第二个公式给出:

A978-1-4302-6338-8_6_Figm_HTML.jpg

这必须给出与 F = mg 相同的力。因此,这一定是真的:

A978-1-4302-6338-8_6_Fign_HTML.jpg

现在我们可以用这个等式的两边除以 m 来去掉它。我们只剩下这个:

A978-1-4302-6338-8_6_Figo_HTML.jpg

这个公式表明,g 与地球的质量 M 和距其中心的距离 r 有关。它告诉我们,g 其实是 GM/r 2 的“昵称”。

如果一个物体坐在地球表面,它离地心的距离等于地球的半径。地球的质量为 5.974 × 10 24 千克,其半径为 6.375 × 10 6

因此,使用最后一个公式,地球表面的 g 值由下式给出,这是之前引用的精确值:

g = 6.67×10-11×5.974×1024/(6.374×106)2= 9.81 米/秒 2

所以 F = mg 符合牛顿万有引力定律。

因为它总是指向地球的中心,所以无论我们在地球表面上还是附近,重力都垂直向下作用(见图 6-5 )。

A978-1-4302-6338-8_6_Fig5_HTML.jpg

图 6-5。

Near-Earth gravity

重力随高度的变化

公式 g = GM/r 2 告诉我们,我们可以用 F = mg 来计算离地球任意距离的重力,前提是我们要考虑 g 随离地心距离的变化。

如果一个物体在地球表面上方的高度 h 处,它到地球中心的距离由下式给出,其中 R 是地球的半径:

A978-1-4302-6338-8_6_Figp_HTML.jpg

因此,地球表面上方高度 h 处的 g 值由下式给出:

A978-1-4302-6338-8_6_Figq_HTML.jpg

因为 g 在地球表面的值是 9.81 m/s 2 ,稍微用点代数就给出了两个等价的公式:

A978-1-4302-6338-8_6_Figr_HTML.jpg

A978-1-4302-6338-8_6_Figs_HTML.jpg

利用这个公式,可以得出在地球表面 350 公里的国际空间站高度上,g 的值约为 8.8 米/秒 2 。在月球距离(384000 公里)的 g 值约为 0.0026 米/秒 2 。记住 g 给你一个物体在重力作用下的加速度,这个加速度和物体的质量无关。所以,如果把月亮换成一个足球,它会经历同样的加速度。

你可能很好奇 g 在地球内部是如何变化的。一个简单的模型表明,从地球表面到地心,g 近似线性地减小到零。公式如下,其中 R 是地球的半径:

A978-1-4302-6338-8_6_Figt_HTML.jpg

图 6-6 显示了 g 随距离地心的归一化距离 r/R 的变化。可以看到,g 从地心到地表线性增加(其中 r/R = 1,g = 9.81)。然后,它会随着与地心距离的平方成反比而迅速减小。

A978-1-4302-6338-8_6_Fig6_HTML.jpg

图 6-6。

Variation of g with distance from the center of the Earth

其他天体上的引力

公式 g = GM/r 2 可以让你计算出行星、恒星等其他天体上的 g 值,前提是你知道它们的质量和半径。

表 6-1 给出了几个天体表面 g 的近似值。这些数值表明,太阳的引力大约是地球的 28 倍,而月球的引力大约是地球的六分之一。太阳系最大的行星木星表面的 g 值大约是地球的 2.5 倍。谷神星和爱神星都是小行星,引力极其微弱:地球引力大约是谷神星的 36 倍,是爱神星的 1660 多倍。

表 6-1。

Gravity on Selected Celestial Bodies

| 天体 | 以米/秒为单位的 g 值 2 | | --- | --- | | 地球 | Nine point eight one | | 太阳 | Two hundred and seventy-four | | 月球 | one point six | | 木星 | Twenty-five | | 谷神星 | Zero point two seven | | 厄洛斯 | 0.0059 |

火箭

火箭被用于各种目的,包括发射宇宙飞船和导弹或作为烟火。在所有类型的火箭中,火箭是由火箭内燃烧的推进剂产生的废气推动的。在接下来的内容中,我们给出了一个非常简单的关于火箭运动的物理描述,并向你展示了如何将一个简单的火箭模拟整合到你的游戏中。

这是火箭科学!

不涉及各种类型火箭发动机运行的工程细节,我们只说高压热气产生并以高速通过喷嘴。根据牛顿第三定律,废气反推火箭,推动它前进。产生的向前的力叫做推力。

除了推力,火箭上可能还有其他力,这取决于它的位置和运动方式。重力就是其中之一。如果火箭穿过地球的大气层,它也会受到一个减速阻力和一个升力(见下一章)。这里我们将忽略阻力和升力的影响。

在地球上,由于压力随高度的变化也会产生影响,这将影响推力、阻力和升力。我们也会忽略这些影响。

当火箭上升时,它所受到的重力强度会像本章前面所描述的那样减小。我们将考虑这种影响。

火箭通常携带相当大一部分质量作为燃料。因此,随着燃料的消耗,火箭的质量会有很大的变化。这种质量的减少将影响火箭的加速度,因此必须包括在建模中。

模拟火箭的推力

为了模拟火箭的推力,我们利用了第五章中给出的牛顿第二定律的另一种形式。

记住,以速度 v 以 dm/dt 的速率移动物质所需的力由公式 F = v dm/dt 给出。火箭排出的废气以有效排气速度 v e 和可控制的质量速率 dm/dt 排出。因此,气体上的力由下式给出:

A978-1-4302-6338-8_6_Figu_HTML.jpg

根据牛顿第三定律,火箭的推力由下式给出:

A978-1-4302-6338-8_6_Figv_HTML.jpg

前面矢量方程中的负号表示产生的推力与排气速度方向相反。

火箭上也可能有侧向推进器,根据需要提供侧向推力。这些工作原理与主机相同。

构建火箭模拟

好吧,让我们建立一个火箭模拟!但是在你兴高采烈地跳上跳下之前,请注意我们这里只关注物理;我们把它作为一个练习,让你想出漂亮的图形和特殊效果。

我们将再次从orbits.js文件开始,进行一些修改,并将新文件保存为rocket-test.js。它应该是这样的:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_bg = document.getElementById('canvas_bg');

var context_bg = canvas_bg.getContext('2d');

var rocket;

var massPlanet;

var centerPlanet;

var radiusPlanetSquared;

var G = 0.1;

var dmdt = 0.5;

var dmdtSide= 0.1;

var fuelMass = 3.5;

var fuelSideMass = 3.5;

var fuelUsed = 0;

var fuelSideUsed = 0;

var ve = new Vector2D(0,200);

var veSide = new Vector2D(-100,0);

var applySideThrust = false;

var showExhaust = true;

var orientation = 1;

var animId;

var t0, dt;

window.onload = init;

function init() {

// create 100 stars randomly positioned

for (var i=0; i<100; i++){

var star = new Ball(1,'#ffff00');

star.pos2D= new Vector2D(Math.random()*canvas_bg.width,Math.random()*canvas_bg.height);

star.draw(context_bg);

}

// create a stationary planet planet

planet = new Ball(100,'#0033ff',1000000);

planet.pos2D = new Vector2D(400,400);

planet.draw(context_bg);

massPlanet = planet.mass;

centerPlanet = planet.pos2D;

radiusPlanetSquared = planet.radius*planet.radius;

// create a rocket

rocket = new Rocket(12,12,'#cccccc',10);

rocket.pos2D = new Vector2D(400,300);

rocket.draw(context,showExhaust);

// set up event listeners

window.addEventListener('keydown',startSideThrust,false);

window.addEventListener('keyup',stopSideThrust,false);

// launch the rocket

t0 = new Date().getTime();

animFrame();

};

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.1) {dt=0;};

move();

}

function move(){

moveObject();

calcForce();

updateAccel();

updateVelo();

updateMass();

monitor();

}

function moveObject(){

rocket.pos2D = rocket.pos2D.addScaled(rocket.velo2D,dt);

context.clearRect(0, 0, canvas.width, canvas.height);

rocket.draw(context,showExhaust);

}

function calcForce(){

var gravity = Forces.gravity(G,massPlanet,rocket.mass,rocket.pos2D.subtract(centerPlanet));

var thrust = new Vector2D(0,0);

var thrustSide = new Vector2D(0,0);

if (fuelUsed < fuelMass){

thrust = ve.multiply(-dmdt);

}

if (fuelSideUsed < fuelSideMass && applySideThrust){

thrustSide = veSide.multiply(-dmdtSide*orientation);

}

force = Forces.add([gravity, thrust, thrustSide]);

}

function updateAccel(){

acc = force.multiply(1/rocket.mass);

}

function updateVelo(){

rocket.velo2D = rocket.velo2D.addScaled(acc,dt);

}

function updateMass(){

if (fuelUsed < fuelMass){

fuelUsed += dmdt*dt;

rocket.mass += -dmdt*dt;

}

if (fuelSideUsed < fuelSideMass && applySideThrust){

fuelSideUsed += dmdtSide*dt;

rocket.mass += -dmdtSide*dt;

}

}

function monitor(){

if (showExhaust && fuelUsed >= fuelMass){

showExhaust = false;

}

if (rocket.pos2D.subtract(centerPlanet).lengthSquared() < radiusPlanetSquared){

stop();

}

}

function startSideThrust(evt){

if (evt.keyCode==39){ // right arrow

applySideThrust = true;

orientation = 1;

}

if (evt.keyCode==37){ // left arrow

applySideThrust = true;

orientation = -1;

}

}

function stopSideThrust(evt){

applySideThrust = false;

}

function stop(){

cancelAnimationFrame(animId);

}

init()中的初始设置与orbits.js中的非常相似,除了我们现在用一个静止的行星代替了太阳,用一个火箭代替了行星。请注意,火箭是Rocket对象的一个实例,稍后我们将会看到。我们在init()中引入了几个事件监听器。这些分别监听keydownkeyup事件。相应的事件处理器startSideThrust()stopSideThrust()设置Boolean参数applySideThrust的值。每当按下右箭头键或左箭头键时,applySideThrust Boolean变量被设置为true;释放按键时,applySideThrust恢复为false。这个变量将控制侧向推进器施加的推力。变量orientation被赋予一个+1 或–1 的整数值,以区分左右按键。这个变量决定了施加推力的方向。

calcForce()中,我们指定作用在火箭上的力,然后像往常一样把它们加起来。第一个力是行星对它施加的重力。然后是主推力和侧推力。只要有燃料,就施加主推力;燃料量在变量fuelMass中设定。如果有燃料并且applySideThrust为真,则施加侧向推力。在任一情况下,推力都是用公式 F =–veDM/dt 计算的。主推进器和侧推进器的燃料量、排气速度和质量率是独立设置的。(实际上,可以改变排气速度和质量率来控制推力,但为了简单起见,我们在这里把它们固定下来。)

move()方法中有几个新的函数调用。首先,它调用一个新方法updateMass()。它需要这样做,因为火箭的质量在变化。updateMass()中的代码检查主推进器和侧推进器中是否还有燃料剩余;如果是这样的话,它会增加所用燃料的质量,并以相同的量减少火箭的质量。对于侧推进器,它还检查applySideThrust是否为真。

move()中调用的第二个新函数是monitor()。该方法执行两项检查。首先,它检查主油箱中的所有燃油是否已经用完,如果是,它将把showExhaust布尔变量的值设置为false,这样在下一次绘图更新时,排气将不再显示。不管有没有排气,火箭的绘制都是通过moveObject()中的函数调用rocket.draw(context,showExhaust)来完成的。

第二件事monitor()做的是检查火箭是否与行星相撞;如果是这样,它简单地调用stop()方法,停止时间步进,冻结所有运动。

如果你运行模拟,你会看到火箭自动发射。一开始它移动得很慢;然后它的速度和加速度都增加,直到它的燃料耗尽。然后它在行星的引力作用下减速。如果你什么都不做,它就会落回星球。如果您按下右箭头键或左箭头键,将分别向右侧或左侧施加侧向推力。如果你施加过多或过少的侧向推力,火箭要么会脱离行星的引力,要么会被拉回与行星碰撞的轨道。看看能不能让火箭绕行星运行(图 6-7 )!这包括在正确的时间施加正确的侧向推力。

A978-1-4302-6338-8_6_Fig7_HTML.jpg

图 6-7。

A rocket orbiting a planet!

火箭物体

最后,让我们快速看一下创建可见火箭的Rocket对象。令人尴尬的是,我们的“火箭”只是一个三角形,另一个(透明的)三角形附在末端,代表废气中的气体。但是,如果您此时迫切需要运用自己的艺术技巧,请继续创作一个更好看的版本。

以下是rocket.js中的完整源代码:

function Rocket(width,height,color,mass){

if(typeof(width)==='undefined') width = 20;

if(typeof(height)==='undefined') height = 40;

if(typeof(color)==='undefined') color = '#0000ff';

if(typeof(mass)==='undefined') mass = 1;

this.width = width;

this.height = height;

this.color = color;

this.mass = mass;

this.x = 0;

this.y = 0;

this.vx = 0;

this.vy = 0;

}

Rocket.prototype = {

get pos2D (){

return new Vector2D(this.x,this.y);

},

set pos2D (pos){

this.x = pos.x;

this.y = pos.y;

},

get velo2D (){

return new Vector2D(this.vx,this.vy);

},

set velo2D (velo){

this.vx = velo.x;

this.vy = velo.y;

},

draw: function (context,firing) {  // firing is a Boolean

if (firing){

var exhaust=new Triangle(this.x,this.y+0.5*this.height,this.width,this.height,'#ffff00',0.8);

exhaust.draw(context);

}

var capsule = new Triangle(this.x,this.y,this.width,this.height,this.color);

capsule.draw(context);

}

};

使用宽度、高度和颜色的参数值绘制火箭,这些参数值在构造函数中作为参数提供。代码看起来非常类似于Ball对象的代码,但是使用了不同的draw()方法,该方法有两个参数:contextfiringBoolean参数firing决定是否绘制“排气”。火箭体和排气都用一个方便的Triangle物体绘制成三角形,你可以在我们的生长物体库中找到。可以随意看一下triangle.js中的代码。

很容易看出如何将这个简单的模拟开发成一个有趣的游戏。除了改进的图形和特殊效果,你可以添加额外的功能和控制,以获得更大的真实感。考虑到这一点,我们需要指出,在我们实现火箭的一些操作功能的方式上有一些限制。对于一个简单的在线游戏来说,这些限制可能没什么大不了的,但是如果你试图构建一个真实的模拟器,这些限制就很重要了。无论如何,了解他们是值得的。

我们已经提到,在这次演示中,我们已经固定了火箭的推力,尽管实际上它可以通过改变排气速度和喷射气体的质量率来控制。更重要的是,主推力和侧推力的方向已经以一种严格来说不正确的方式实现了。无论火箭的角度是多少,火箭的主推力都是沿着它的轴线作用的,而它的侧推力会相对于火箭的角度处于某个特定的角度。我们在这个简化的模拟中所做的是固定主推力和侧推力,使其在垂直(y)和水平(x)方向上起作用,而不管火箭的倾斜度如何。我们还忽略了火箭在大气中运动时气压、阻力和升力的影响。

我们做的另一个明显的简化是将整个火箭送入轨道。实际上,太空火箭包括一个或多个一次性燃料箱,当燃料耗尽时,这些燃料箱就会被丢弃。我们把这个留给你做练习。类也可以在功能上得到增强。例如,将燃料量、使用的燃料、排气速度和质量率作为属性包含在Rocket中,而不是将它们硬编码到rocket-test.js中,这可能是有意义的。

最后,我们在本章中编写的例子是真实的,因为它们包含正确的物理方程,但是为各种参数选择的值不一定是成比例的。例如,在轨道模拟中,行星和太阳的大小与它们的距离相比被严重夸大了。为了在计算机屏幕上看到物体及其运动,这是必要的。缩放问题将在本书的第四部分详细讨论。

摘要

这一章向你展示了重力可以展现的丰富性和多样性,以及它可以产生的不同类型的运动。在相当短的一章中,你已经能够编写轨道,两个物体在相互引力下以有趣的方式运动,以及一个从发射到轨道的火箭模拟。

令人惊讶的是,仅仅几个物理公式和一点点代码就能带来如此多的乐趣。你现在尝到了什么是可以做到的,即使是一个单一的力量,如重力。我们将在后面的章节中更多地讨论重力。同时,我们将在下一章介绍许多其他的力。

七、接触力和流体力

在前一章中,我们详细研究了重力,这是一种远距离作用的力。然而,除了重力之外,我们在日常生活中遇到的大多数力都来自于与其他物体的直接接触,无论是固体还是液体。因为在这个星球上,我们通常与固体地板接触,并被流体(空气或水)包围,所以知道它们对物体施加什么力肯定是一件好事。本章将着眼于这些力量。

本章涵盖的主题包括以下内容:

  • 接触力:接触力是两个固体直接接触时相互施加的力。例子包括法向力、摩擦力和张力。
  • 压力:流体施加压力。由于这种流体压力,流体中的任何物体都会受到力的作用。
  • 上推力或浮力:上推力,也称为浮力,是部分或全部浸没在流体中的物体所承受的合成压力。
  • 阻力:物体在流体中运动时会受到一种阻碍其运动的阻力。这就是阻力。
  • 升力:在流体中移动的物体可能会受到另一个垂直于运动方向的力,称为升力。升力是飞机飞行的动力。
  • 风:虽然风本身不是一种力,但它确实会对它吹过的任何物体施加一种力。风还会产生湍流,这会导致物体运动不稳定。

如该列表所示,这些力的变化包括固体物体之间的相互作用以及固体物体和流体之间的相互作用,这两种情况都是直接物理接触的结果。但从技术上讲,接触力这个术语是专指存在于两个固体之间的力。只有第一节将从技术的角度讨论接触力。本章的大部分将集中在流体对固体物体施加的力上。这部分是因为后者更复杂,但它们也表现出更丰富的多样性,具有有趣的功能和效果。在研究流体力时,我们将把注意力限制在固体在流体中所受的力上。我们不考虑作用在流体上的力以及这些力对流体本身的影响。流体运动是一个更复杂的问题,不在本书中讨论。

这里只是一些你在阅读本章后能够创建的模拟:摩擦运动、漂浮物体、气泡、气球、降落伞、升力、风和湍流效果。你也将知道建造潜艇模拟所需的大部分物理知识(见第十六章)。

接触力

简单地说,接触力是一个固体物体与另一个固体物体接触时受到的力。推和拉涉及接触力。跑步或者只是站着也是如此。碰撞涉及持续时间很短的大接触力。

法向接触力

当两个固体物体接触时,每个物体都会对另一个物体施加一个法向力,阻止它们融合在一起。法向力的大小取决于将两个物体推到一起的力。

Note

在我们做其他事情之前,我们必须解释一下,这里的正常这个词是指这个词的数学意义,它是垂直的,而不是不正常的对立面。

正常接触力的一个例子是地板对你的反作用力。事实上,这个力就是我们意识到自己体重的原因。如果没有地板的正常反作用力(因此没有正常的力),我们会从地板上摔下来。

现在,每当我们在这本书里引入一个新的力,我们都会给你一个计算它的方法。你就可以这么模拟了。如果你不能计算一个力,谈论它就没有意义了!

法向力怎么算?这要看情况。不像重力,它没有通用的公式来告诉你法向力是什么。通常,你可以从作用在物体上的其他力推导出来。

这里有一个例子。假设有一本书放在一张水平的桌子上(见图 7-1 )。作用在书上的力主要有两个:地球施加的垂直向下作用的重力 W = mg,桌子施加在书上垂直向上作用的法向接触力 N(法向接触力通常用 N 或 R 表示)。因为书不动,所以对它的合力一定为零(牛顿第一运动定律)。因此,我们可以推导出,这种情况下的法向力 N 在量级上等于书的重量 W。

A978-1-4302-6338-8_7_Fig1_HTML.jpg

图 7-1。

The normal contact force acting on a book resting on a table

例如,如果桌子倾斜,法向力就会改变。你会在本章的第一个例子(“滑下斜坡”)中看到如何计算这种情况下的法向力。

拉伸和压缩

弦的张力是另一种接触力。如果你用一根绳子系住一个物体,然后握住绳子的另一端将物体悬挂起来(见图 7-2 ),绳子会对物体施加拉力,防止其下落。绳子只有绷紧时才能做到这一点。根据牛顿第三运动定律,物体对绳子施加一个大小相等方向相反的力。绳子上的力叫做张力。非正式地,我们也把绳子施加在物体上的力称为张力。张力对细绳本身的作用是将它稍稍拉长。如果物体停止运动,那么张力一定等于物体的重量。如果物体很重,张力会把绳子拉至断裂点。

A978-1-4302-6338-8_7_Fig2_HTML.jpg

图 7-2。

Tension in a string

弹簧也会受到张力。弹簧的设计使得相对较小的张力可以产生较大的伸长。因为弹簧在拉伸时会恢复正常长度,所以它会拉回拉伸它的物体。张力的反义词是压缩。弹簧也可以被压缩;然后,它们对压缩它们的物体施加推力。

弹簧既有趣又非常有用,可以产生各种各样的效果。事实上,下一章专门讨论弹簧和类似弹簧的运动。

摩擦

摩擦力是阻止两个相接触的物体相对运动或阻止一个物体在流体中运动的力。例如,如果你沿着桌子推一本书,它会慢下来并停下来。那是因为桌面对它施加的摩擦力。两个固体之间的摩擦也称为干摩擦。空气或水等流体也会产生摩擦力,称为粘性流体阻力。

我们将在本章后面讨论流体阻力。在这一节,我们将看看两个固体之间的干摩擦,以及我们如何对它建模。

如前所述,摩擦力是阻止两个相互接触的物体相对运动的力。这意味着作用在物体上的摩擦力将与其运动方向相反。但是一个物体不一定要移动才能经历摩擦。如果一个物体受到一个使它向另一个物体移动的力,它也会受到摩擦,即使它实际上并没有移动。事实上,是摩擦力阻止了它的运动。所以实际上有两种类型的摩擦力:静止物体所受的摩擦力,和运动物体所受的摩擦力。我们分别称之为静摩擦力和动摩擦力。

模拟静摩擦和动摩擦

我们用摩擦系数的概念来模拟这两种类型的摩擦。让我们从动摩擦开始,因为它简单一点。

普通的经验告诉我们,如果我们将两个表面压在一起,同时试图使一个表面滑过另一个表面,摩擦会更大。那是因为两个物体如果压在一起会更“粘在一起”。同样清楚的是,摩擦力的大小取决于构成物体的材料。例如,橡胶会比玻璃产生更大的摩擦力。因此,摩擦力 F 的大小由下式给出就不足为奇了,其中 N 是两个物体之间的法向力,C k 是一个取决于两个表面的数,称为动摩擦系数:

A978-1-4302-6338-8_7_Figa_HTML.jpg

根据牛顿第三定律,大小相同的力作用在两个物体上;对于每个物体,力的作用方向与它相对于另一个物体的运动方向相反。动摩擦有时也被称为动态或滑动摩擦。

静态摩擦有点不同。对于给定的一对表面和法向接触力,动摩擦力只有一个值,而静摩擦力可以取最大值以下的任何值。最大值由下式给出:

A978-1-4302-6338-8_7_Figb_HTML.jpg

这在形式上类似于动摩擦公式,但有一个不同的系数,称为静摩擦系数。对于相同的两个表面,其值通常大于 C k 的相应值。因此,最大静摩擦力大于动摩擦力。

为了理解静摩擦和动摩擦,考虑下面的思维实验。假设你正在推动一个物体接触一个表面。如果你施加的力的大小小于前面公式给出的 F max ,摩擦力将等于作用力,因此合力为零,物体不会移动。如果你现在更用力,使施加的力大于 F max 的值,摩擦力将等于 F max (因为它不能超过那个最大值)。所以摩擦力不能完全平衡施加的力,物体会在合力的作用下开始加速。一旦它开始运动,摩擦力就会减小到与动摩擦力相等的值。因此,合力会突然增加,物体会加速。如果你曾经试图移动一件沉重的家具,你会很容易理解这个事实:一旦它已经在移动了,就更容易推动了。那是因为动摩擦力小于静摩擦力的最大值。

摩擦系数

表 7-1 中给出了一些摩擦系数的例子。关键是数值一般都小于 1。摩擦系数大于 1 是非常罕见的,因为这意味着摩擦力大于将两个物体固定在一起的正常接触力。

表 7-1。

Static and Kinetic Coefficients of Friction

| 材料 | C s | C k | | --- | --- | --- | | 木头上的木头 | 0.25–0.5 | Zero point two | | 钢对钢 | Zero point seven four | Zero point five seven | | 混凝土上的橡胶 | One | Zero point eight | | 玻璃对玻璃 | Zero point nine four | Zero point four | | 冰上的冰 | Zero point one | Zero point zero three |

请注意,尽管这些值代表了所引用的材料,但实际的摩擦系数可能会因实际物体表面的性质而有所不同。对于游戏编程来说,这不是一个大问题。

不可能给出一个完整的摩擦系数列表;但是如果你需要的东西没有列在这里,在网上应该很容易找到。

示例:滑下斜坡

举例子的时间到了!现在,您可以应用所学的法向接触力和摩擦力来模拟一个物体沿斜面下滑。首先,让我们将物理概念应用到这个特殊的例子中。

物理学

本次模拟的力图如图 7-3 所示。如图所示,有三个力作用在物体上:重力 m g,法向接触力 N,摩擦力 f,由于物体沿表面直线运动,合力沿表面作用。没有垂直于表面的合力。

A978-1-4302-6338-8_7_Fig3_HTML.jpg

图 7-3。

Force diagram for an object sliding down an inclined plane

因此,分解沿表面的力,得到合力 F 的大小如下,其中θ是倾斜平面的角度:

A978-1-4302-6338-8_7_Figc_HTML.jpg

垂直于表面的分解力的合力必须为零,这样法向接触力 N 就被垂直于表面的重力分量所平衡。注意摩擦力 f 沿着表面作用,所以它垂直于表面的分量为零。因此,

A978-1-4302-6338-8_7_Figd_HTML.jpg

当物体运动时,摩擦力 f 的大小由下式给出:

A978-1-4302-6338-8_7_Fige_HTML.jpg

如果它不动,沿表面的合力 F 必然为零,所以 F 必须平衡重力沿表面的分量。根据上式,设置 F = 0 得出:

A978-1-4302-6338-8_7_Figf_HTML.jpg

达到以下最大值时为真:

A978-1-4302-6338-8_7_Figg_HTML.jpg

如果重力分量的值(mg sin (θ))超过这个临界值(C s N),f 将具有后者的值,至少暂时是这样。一旦物体开始移动,f 的值将是 C k N

现在你知道了物理,有了相关的公式,就可以开始编码了。这将是一个两步的过程,与你在上一章中所做的非常相似。首先,您将创建可视化设置;然后,您将编写驱动模拟的代码。

创建视觉设置

代码在一个名为sliding.js的文件中。在声明和初始化画布、上下文和其他变量之后,我们在init()函数中处理可视化设置:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_bg = document.getElementById('canvas_bg');

var context_bg = canvas_bg.getContext('2d');

var ball;

var m = 1;          // mass of ball

var g = 10;         // acceleration due to gravity

var ck = 0.2;       // coeff of kinetic friction

var cs = 0.25;      // coeff of static friction

var vtol = 0.000001 // tolerance

// coordinates of end-points of inclined plane

var xtop = 50; var ytop = 150;

var xbot = 450; var ybot = 250;

var angle = Math.atan2(ybot-ytop,xbot-xtop); // angle of inclined plane

var t0,dt;

window.onload = init;

function init() {

// create a ball

ball = new Ball(20,'#0000ff',m,0,true);

ball.pos2D = new Vector2D(50,130);

ball.velo2D = new Vector2D(0,0);

ball.draw(context);

// create an inclined plane

context_bg.strokeStyle = '#333333';

context_bg.beginPath();

context_bg.moveTo(xtop,ytop);

context_bg.lineTo(xbot,ybot);

context_bg.closePath();

context_bg.stroke();

// make the ball move

t0 = new Date().getTime();

animFrame();

};

init()中,我们创建一个球和一条线(后者在不同的画布元素上,背景),并将球放在线上。使用端点和Math.atan2()函数计算线条的角度,并存储在angle变量中。如果你不确定这里发生了什么,再看一下第三章中的“反向触发函数”小节。最后,我们通过调用animFrame()函数让球沿着斜线向下移动。

编码动画

animFrame()函数完成了设置动画循环的常规工作,相关代码与前一章中的例子非常相似,但只是提醒你我们在这里复制了代码:

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

moveObject(ball);

calcForce();

updateAccel();

updateVelo(ball);

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

context.clearRect(0, 0, canvas.width, canvas.height);

obj.draw(context);

}

function updateAccel(){

acc = force.multiply(1/m);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

这几乎是全部的动画代码,你可以看到到目前为止什么都没有改变。唯一的变化是在calcForce()函数中,它包含了新的物理特性:

function calcForce(){

var gravity = Forces.constantGravity(m,g);

var normal = Vector2D.vector2D(m*g*Math.cos(angle),0.5*Math.PI-angle,false);

var coeff;

if (ball.velo2D.length() < vtol){  // static friction

coeff = Math.min(cs*normal.length(),m*g*Math.sin(angle));

}else{  // kinetic friction

coeff = ck*normal.length();

}

var friction = normal.perp(coeff);

force = Forces.add([gravity, normal, friction]);

}

calcForce()方法计算每个时间步的三个相关力gravitynormalfriction,然后使用Forces.add()方法将它们相加得到合力。法向力和摩擦力的计算需要解释。对于法向力,我们使用一种叫做vector2D()Vector2D新方法,定义如下:

Vector2D.vector2D = function(mag,angle,clockwise){

if (typeof(clockwise)==='undefined') clockwise = true;

var vec = new Vector2D(0,0);

vec.x = mag*Math.cos(angle);

vec.y = mag*Math.sin(angle);

if (!clockwise){

vec.y *= -1;

}

return vec;

}

该方法采用两个必需的Number参数magangle(以弧度表示),以及一个可选的布尔参数clockwise,并返回一个Vector2D对象,该对象是一个具有指定大小和角度的 2D 向量。它通过将(幅度、角度)表示转换为矢量的分量表示来实现这一点(参见第三章中的“解析矢量”一节)。clockwise参数告诉vector2D()角度是顺时针方向(默认)还是逆时针方向。

使用前面的公式和图 7-3 ,我们知道法向力的大小等于 mg cos (θ),其角度等于逆时针方向上的π/2–θ(即 90–θ,单位为弧度)。因此,法向力由下式给出:

var normal = Vector2D.vector2D(m*g*Math.cos(angle),0.5*Math.PI-angle,false);

摩擦力垂直于法向力,大小为coeff*N,其中coeff为摩擦系数,N为法向力的大小。因此它由下面给出,其中perp()是我们在Vector2D类中创建的另一个新方法(我们将很快描述它),当对象开始移动时coeff等于 CkN;并且等于 mg sin (θ),当物体不运动时,最大值为 C s N:

var friction = normal.perp(coeff);

这是通过以下方式实现的:

if (ball.velo2D.length() < vtol){  // static friction

coeff = Math.min(cs*normal.length(),m*g*Math.sin(angle));

}else{  // kinetic friction

coeff = ck*normal.length();

}

注意,我们利用Math.min()函数来实现这样一个事实,即对于静摩擦力,coeff等于 mg sin (θ)或 C s N,以较小者为准。

Vector2Dperp()方法是这样定义的:

Function perp(u,anticlockwise){

if (typeof(anticlockwise)==='undefined') anticlockwise = true;

var length = this.length();

var vec = new Vector2D(this.y, -this.x);

if (length > 0) {

if (anticlockwise){ // anticlockwise with respect to canvas coordinate system

vec.scaleBy(u/length);

}else{

vec.scaleBy(-u/length);

}

}else{

vec = new Vector2D(0,0);

}

return vec;

}

这个定义意味着,如果vec是一个Vector2D对象,k是一个数字,vec.perp(k)返回一个垂直于vec且长度为kVector2D对象,逆时针方向指向vec(在画布坐标系中)。参考图 7-4 ,图中显示了该设置的截图,normal.perp(coeff)因此给出了一个指向斜坡上方的矢量coeff,正如摩擦力应该是。

A978-1-4302-6338-8_7_Fig4_HTML.jpg

图 7-4。

An object sliding down a slope

当检查对象是否没有移动时,我们实际上并没有检查它的速度是否为零,而是检查它是否小于某个小值vtol(在代码中设置为 0.000001)。g的值设置为 10,假设滑动物体和表面都是由木头制成的,我们选择ck = 0.2cs = 0.25

如果您使用给定的参数值运行代码,您将看到什么也没有发生。物体只是停留在那里。这怎么可能呢?为了理解这一点,再看一下图 7-3 。很明显,只有当重力沿斜面的分量超过最大静摩擦力时,物体才能滑动。换句话说,如果以下条件成立,它将滑动:

A978-1-4302-6338-8_7_Figh_HTML.jpg

因为 N = mg cos (θ),所以这相当于:

A978-1-4302-6338-8_7_Figi_HTML.jpg

将这个不等式的两边除以 mg cos (θ)得出:

A978-1-4302-6338-8_7_Figj_HTML.jpg

所以只有当倾斜角的正切大于静摩擦系数时,物体才会滑动。

现在,您可以根据xtopytopxbotybot的值计算 tan (θ),如下所示:

A978-1-4302-6338-8_7_Figk_HTML.jpg

这正好等于代码中规定的 C s 的值。因此,重力不足以克服摩擦力,物体不会滑动。如果你稍微增加斜面的角度,物体就会滑动。比如把ybot的值改成 260,这就得出 tan (θ) = 110/400 = 0.275,大于 C s 的值。试一试!

在这个例子中,物体沿着表面滑动而不滚动。滚动包括刚体的旋转运动(将在第十三章中讨论)。

本章的其余部分将讨论源于压力的力,更具体地说是流体压力。从技术上讲,压力与固体、液体和气体都有联系。但是这个概念对于流体特别有用,所以它更倾向于与液体和气体联系在一起。

压力的意义

那么什么是压力呢?它是施加在物体表面的单位面积上的法向(垂直)力。在不同情况下,垂直力的来源可以不同。固体和液体产生的压力通常是由于重力。气体中的压力是由气体分子的碰撞产生的。

关键问题是:我们如何计算压力,从而计算流体中物体所受的力?一旦我们做到了这一点,我们就可以尝试模拟物体在流体中的运动。

根据压力的定义,如果力 F 垂直作用于面积为 A 的表面,则表面上的平均压力 P 由下式给出:

A978-1-4302-6338-8_7_Figl_HTML.jpg

因为压力是力除以面积,所以它的单位是 N/m 2 。这个单位有一个特殊的名字:帕斯卡,或 Pa。

如果 F 与法线成一个角度呢?然后你需要求解 F 来找到法线方向的分量,这个法线分量就是你在前面的公式中使用的分量。这给了我们一种方法,如果我们知道力,就可以计算压力,反之亦然。

压力并不局限于流体(尽管流体压力将是我们在这一章中主要关心的),用一个简单的固体例子来介绍计算压力的方法实际上更容易些。

假设一个长 l、宽 w、高 h、质量 m 的长方体(盒子)坐在一张桌子上(见图 7-5 )。它对桌子施加的压力是多少?

A978-1-4302-6338-8_7_Fig5_HTML.jpg

图 7-5。

A box sitting on a table

它施加在桌子上的力等于它的重量,这个力垂直于桌面,所以我们有 F = mg。面积 A = lw。因此,压力 P 由下式给出:

A978-1-4302-6338-8_7_Figm_HTML.jpg

密度

通过引入密度的概念,我们可以取得更多的进展。

密度被定义为每单位体积的质量。密度给了你一种比较不同物质相对“重量”的方法——你可以取同样体积的每种物质,然后比较每种物质的重量。

我们用希腊符号ρ(ρ)表示密度。所以,基于这个定义,我们可以写成:

A978-1-4302-6338-8_7_Fign_HTML.jpg

因为密度是质量除以体积,所以单位是 kg/m 3 。因为物体受热时会膨胀,尤其是液体和气体,所以流体的密度会随着温度(以及压力)而变化。在 20°C 和标准大气压下,水的密度约为 1000 kg/m 3 ,空气的密度约为 1.2 kg/m 3 。这意味着一个 1 米×1 米×1 米的盒子(体积为 1 米)可以容纳 1000 千克(1 吨)水和 1.2 千克空气。

操纵上面的公式给出如下结果:

A978-1-4302-6338-8_7_Figo_HTML.jpg

它也给出了这个:

A978-1-4302-6338-8_7_Figp_HTML.jpg

我们将在本章的其余部分频繁使用这些公式。

就像压力一样,密度的概念不仅适用于液体,也适用于固体。回到前面的长方体示例,我们可以用 m = ρV = ρlwh 来代替压力公式中的质量 m,因为长方体的体积是 V = lwh:

A978-1-4302-6338-8_7_Figq_HTML.jpg

这是一个非常有用的公式。它给出了一个规则物体(在这种情况下,是一个长方体)在一个表面上施加的压力,这三个已知的量是:物体的密度、重力加速度和物体的高度。请注意,我们可以使用不同于盒子的其他形状,只要它具有规则的横截面 A。面积 A 将以相同的方式抵消,留下相同的公式:P = ρgh。

在下一节,我们会发现这个公式实际上更普遍,也适用于流体施加的压力,只要我们重新解释 h。

流体中压力随深度的变化

现在考虑一个与上一节中的固体形状相同的液体柱,而不是固体。

应用完全相同的数学公式,我们再次得出以下结果,其中 P 是液柱下方的压力,ρ是流体的密度,h 是液柱的高度:

A978-1-4302-6338-8_7_Figr_HTML.jpg

现在在精神上把自己传送到海洋。

在海面下的深度 h 处,有一个高度为 h 的水柱。因此,表面下深度 h 处的压力由相同的公式给出:

A978-1-4302-6338-8_7_Figs_HTML.jpg

请注意,这是仅由水产生的压力。由于水面上方的空气,还有一个附加压力。这是大气压力,我们可以用 A 来表示,那么假设水的密度随深度不变,海洋中深度 h 处的总压力是 A + ρgh。这个公式表明,流体中的压力随着深度的增加而增加,这很有道理,因为上面有更多的流体在“下沉”

大气层也是如此。我们在地球表面感受到的气压是由于我们头上的空气柱。标准大气压约为 100 千帕或 100,000 帕。这有时简称为 1 大气压(atm)。

因为水的密度是 1000 kg/m 3 ,g 大约是 10 m/s 2 ,用公式 P = ρgh 告诉我们,10 m 的水将会施加大约和 1 个大气压相同的压力。因此,在 10 米的海洋深度,压力将是 2 个大气压,包括上面空气的压力。

静态和动态压力

到目前为止,当我们谈到流体中的压强时,我们指的是静压。公式 P = ρgh 适用于静压。静压定义在流动中的任何一点,它是各向同性的;也就是说,它在任何方向都有相同的值。它甚至存在于静态流体(静止的流体)中。

在流动的流体中,还有另一种压力,称为动压。动态压力是由于流体的流动。例如,从水龙头流出的水流会产生动压力。当水碰到水槽时,会对水槽施加一个力。类似地,由于与之相关的动态压力,风施加一个力。流体中任一点的动态压力公式如下,其中 v 是流体在该点的速度:

A978-1-4302-6338-8_7_Figt_HTML.jpg

对于在流体中以速度 v 运动的物体也是如此;重要的是物体和流体之间的相对速度。

运动流体中的总压力是静压和动压之和。

上升推力(浮力)

在了解了压力和密度的背景之后,我们现在准备介绍流体产生的力。所以我们先从上推力说起,也就是浮力。

这里有一个简单的实验让你在洗澡的时候做。试着在水下推一个空心球。你有什么感觉?你应该感觉到有一股力量试图把球向上推回去。这是向上推力。现在放开球。会发生什么?在沉淀下来之前,它会重新弹起并在水面振荡。你很快就会创造出这样的效果。

上推也使我们在游泳池或浴缸里感觉更轻。它向上运动,对抗重力。

什么导致了上升?上冲的物理起源是流体中不同高度存在的压力差。为了理解我们的意思,看一下图 7-6a ,图中显示了一个物体部分浸没在诸如水的液体中。物体顶面的压力等于大气压 A 并向下推动物体,而底面的压力等于 A + ρgh,其中ρ是流体的密度,h 是物体被淹没的深度。这个压力向上作用,所以有一个等于ρgh 的净向上压力。因此,因为力=压力×表面面积,所以压力差会产生一个向上的合力 U,由下式给出(其中 S 是物体顶面和底面的面积,为简单起见,此处假设相等):

A978-1-4302-6338-8_7_Figu_HTML.jpg

这就是上推力或浮力。

注意,因为压力是各向同性的,所以它也作用于物体的侧面。但是在每一个高度上,相对两侧的压力大小相等,方向相反,所以它们的影响被抵消了。如果物体完全浸没在液体中(见图 7-6b ),包括空气中的物体,类似的结论也成立。这基本上是因为流体中的压力随着深度的增加而增加,所以水下物体底部的压力总是大于顶部的压力。所以流体会对物体产生一个向上的净压力。

A978-1-4302-6338-8_7_Fig6_HTML.jpg

图 7-6。

An object immersed in a fluid experiences upthrust due to a pressure difference

阿基米德原理

让我们重温一下上一节中导出的最后一个公式。发现上冲的幅度 U 由 U = ρghS 给出。现在 h 是物体浸没部分的高度,S 是它的横截面积(为简单起见,我们假设它是常数)。因此,hS 等于物体浸没部分的体积 V。所以我们可以这样写:

A978-1-4302-6338-8_7_Figv_HTML.jpg

但是ρV = m,液体的质量被物体的水下部分所取代,所以我们现在可以这样写:

A978-1-4302-6338-8_7_Figw_HTML.jpg

这只是排出液体的重量。因此,我们推导出阿基米德原理:

  • 阿基米德原理:浸没在液体中的物体所受的上推力等于它所排开的液体重量。

阿基米德是古希腊科学家、数学家和全才。根据传说,当他发现这个原理时,他从浴缸里跳出来大喊“找到了!”,意思是“找到了!”

视重量

上冲导致视重现象。图 7-7 说明了这个概念。任何部分或全部浸在液体中的物体都会受到向上的推力 U,这个力与重力 W 作用在物体上的向下的力相反。

A978-1-4302-6338-8_7_Fig7_HTML.jpg

图 7-7。

Upthrust reduces the apparent weight of an object to W – U

因此,物体的表观重量由下式给出

A978-1-4302-6338-8_7_Figx_HTML.jpg

表观体重现象是我们在浴缸或游泳池中感觉更轻的原因。利用明显的重量效应,宇航员在一个巨大的水槽中进行训练,以模拟失重或零重力。

水下物体

完全浸没在液体中的物体显然会排出其自身体积的液体。

由于物体和排出的流体具有相同的体积 V,因此完全浸没的物体的表观重量可以写为:

A978-1-4302-6338-8_7_Figy_HTML.jpg

因此,它可以写成以下形式:

A978-1-4302-6338-8_7_Figz_HTML.jpg

这是一个非常有用的公式,它根据物体的密度和体积给出了浸没在液体中的物体的向下合力或表观重量。

从这个公式中,我们可以推导出:

  • 如果物体的密度大于流体的密度,则表观重量将为正(即向下),但小于实际重量。你可以在浴室或游泳池中体验这种较低的表观重量。
  • 如果物体的密度比流体的密度大得多,那么表观重量就几乎等于实际重量,因为与物体的重量相比,上推力可以忽略不计。例如,一块石头在空气中的上推力通常可以忽略不计。
  • 如果物体的密度正好等于液体的密度,那么表观重量将为零,因此物体将漂浮在液体中。
  • 如果物体的密度小于流体的密度,则表观重量为负(它向上作用),因此物体会上升。例如,水中的气泡与它们的重量相比有很大的上升力,因为空气的密度比水的密度低得多,所以它们上升很快。

漂浮物体

阿基米德原理的一种特殊形式适用于浮体。这就是所谓的漂浮定律。

  • 漂浮定律:一个漂浮物排出其自身重量的液体。

这是逻辑。浮体上没有净力作用;否则,它要么上升,要么下沉。因为作用在它身上的仅有的两个力是它的重量和向上的推力,它们必须平衡。因此,浮体上的上推力必须等于浮体的重量。根据阿基米德原理,这意味着排出液体的重量等于物体的重量。所以一个漂浮物体的表观重量是零——它“感觉没有重量”

如前所述,当物体浸没在密度等于物体自身密度的流体中时,也是这种情况。事实上,漂浮在液体表面的物体,如水上的船只,其“有效密度”与水的密度相同,尽管它可能由密度比水高的金属制成。这是因为船在水面以下的部分也包括封闭在船内部的空气,这降低了它的平均密度。

示例:气球

我们已经有了相当多的理论,所以让我们看一个例子。这个例子(如图 7-8 所示)模拟了一个简单的热气球。热气球的工作原理是加热空气。热空气的密度小于周围空气的密度,所以它上升。通过控制气球中空气的温度,你可以控制它的密度,从而控制它的上升力。

A978-1-4302-6338-8_7_Fig8_HTML.jpg

图 7-8。

Simulating a hot-air balloon

该模拟的代码可在balloon.js中找到。init()功能创建如图 7-8 所示的视觉背景,并创建一个气球作为Ball对象,最初静止在地面上。它还设置了keydownkeyup事件监听器,相应的keydown事件监听器响应UPDOWN箭头的按下,分别从气球的初始值 1.1 增加或减少气球的密度rhoP。空气密度rho保持恒定在 1.2。您可以查看代码以了解更多细节。

到目前为止,你可能不需要太多的帮助就可以编写动画代码了。这里唯一的实质性变化是calcForce()方法,它指定了两个力(gravityupthrust),然后将它们相加:

function calcForce(){

var gravity = Forces.constantGravity(m,g);

var V = m/rhoP; // volume of air displaced

var upthrust = Forces.upthrust(rho,V,g);

force = Forces.add([gravity, upthrust]);

}

使用新创建的Forces.upthrust()静态函数计算upthrust,该静态函数基本上使用公式 U = ρgV,其中ρ(代码中的变量rho)是流体的密度,V(代码中的变量V是置换的空气体积。这是气球的体积,因为它完全浸没在空气中。所以V是用气球的质量和它的有效密度rhoP计算出来的,公式是体积=质量/密度。有效密度rhoP是整个气球的密度,包括它所承载的任何载荷(不仅仅是其中空气的密度)。

模拟中的关键参数是气球密度与环境空气密度的比率rhoP/rho。如果有效气球密度rhoP小于空气密度rho(该比值小于 1),气球将上升。如果rhoPrho大,就会沉下去。

为了使模拟更具交互性,我们还在move()中包含了对函数changeBalloonDensity()的调用,该函数允许您通过分别按上下箭头键来增加和减少气球的密度rhoP。每当rhoP改变时,该代码向控制台输出比率值(rhoP/rho)。看看你能否让气球上升,然后在某个高度保持静止。

这个模拟中缺少了一些东西。气球升得太快;它基本上是在多余的上推力的作用下加速的,所以它向上的速度一直在上升。现实中会被拖慢。因此,让我们来看看我们如何可以包括拖动效果。

对于不熟悉流体动力学的人来说,尝试对阻力建模可能会有些困惑。一个原因是阻力的类型不止一种,而是几种。根据要模拟的对象和流,可能需要考虑不同类型的拖动。

困难的另一个来源是,我们对阻力的许多了解都是基于经验工作。阻力定律是通过大量的实验发现的,而不是像引力那样作为一个美丽的普遍理论的一部分。

底线是,你会碰到从实验结果中发展出来的公式,你必须毫无争议地接受这些公式。

对于本书中的大部分内容,我们将使用两个阻力定律中的一个:一个适用于低速(或所谓的层流),另一个(更常见)适用于高速(或所谓的湍流)。

低速阻力定律

对于以非常低的速度在流体中移动的物体,物体周围的流动是层流或流线型的。这产生相对较低的阻力。那么阻力就服从斯托克斯定律,即阻力与物体的速度成正比。对于球形物体,斯托克斯阻力公式如下,其中 r 是球体的半径,希腊字母η表示称为粘度(更准确地说是动态粘度)的流体属性:

A978-1-4302-6338-8_7_Figaa_HTML.jpg

注意,动态粘度也用希腊字母μ表示。流体的粘度是它对通过它的物体所提供的阻力的量度。直观上,它代表了流体的“厚度”。所以水的粘度比空气高,油的粘度比水高。因此,在水中以相同速度运动的物体比在空气中受到更大的阻力。该公式还告诉我们,较大的物体受到的阻力更大,因为较大物体的参数 r 更大。

要在精确的模拟中使用该公式,您需要知道对象在其中运动的流体的粘度。像密度一样,粘度取决于温度。20°C 时,水的动力粘度为 1.0 × 10 -3 kg/(ms),空气的动力粘度为 1.8×10-5kg/(ms)——约为 1/55。

在对这种阻力进行编码时,我们将把所有的因素放在一起,把速度乘以一个由 k 表示的单一因素,这样,线性阻力定律就变成如下,其中 k = 6ρρr 对于一个球体:

A978-1-4302-6338-8_7_Figbb_HTML.jpg

以这种形式写出线性阻力定律实际上更普遍,因为它也适用于其它非球形物体,对于这些物体,这个公式没有给出 k。通过选择合适的 k 值,我们可以模拟任何形状物体上的线性阻力。

这基本上是我们在第五章的中编码到Forces.linearDrag()函数中的拖动形式:

Forces.linearDrag = function(k,vel){

var force;

var velMag = vel.length();

if (velMag > 0) {

force = vel.multiply(-k);

}else {

force = new Vector2D(0,0);

}

return force;

}

高速时的阻力定律

斯托克斯阻力定律只适用于低速。在较高的速度下,浸没物体周围的流动变得紊乱,产生扰乱流动并使其混乱的涡流。阻力定律不同于层流定律,它与物体速度的平方成正比。阻力公式由下式给出,其中ρ是流体的密度,A 是物体的正面面积(当物体通过时流体冲击的面积),C d 是称为阻力系数的参数,其取决于物体的特性,例如其形状、表面特性和流动特性:

A978-1-4302-6338-8_7_Figcc_HTML.jpg

请注意,该公式涉及速度 v 与其大小 v 的乘积。这给出了在 v 方向上的大小为 v 2 的矢量。因此,阻力定律在速度上是二次的,大小由下式给出:

A978-1-4302-6338-8_7_Figdd_HTML.jpg

在这种形式中,很明显二次阻力取决于动压 P = 1/2 ρv 2 ,实际上我们可以这样写:

A978-1-4302-6338-8_7_Figee_HTML.jpg

如前所述,阻力系数 C d 可能取决于大量因素。它的值是通过对特定对象和设置的实验得到的。例如,球体的阻力系数范围可以从 0.07 到 0.5。同样,你可以在物理或工程教科书或网站上找到大量物体形状的阻力系数(2D 和 3D)。

至于层流阻力公式,我们将通过定义高速阻力常数 k 来简化前面的公式,如下所示:

A978-1-4302-6338-8_7_Figff_HTML.jpg

所以高速时的阻力定律可以写成这种形式:

A978-1-4302-6338-8_7_Figgg_HTML.jpg

我们现在定义名为Forces. drag()的第二个阻力函数,如下所示:

Forces.drag = function(k,vel) {

var force;

var velMag = vel.length();

if (velMag > 0) {

force = vel.multiply(-k*velMag);

}

else {

force = new Vector2D(0,0);

}

return force;

}

我应该使用哪种阻力定律?

我们已经描述了两个阻力定律,线性的和二次的,说前者适用于低速,后者适用于高速。但是到目前为止,我们对“低”和“高”的含义相当模糊。区分高低的“临界流速”是什么?

为了回答这个问题,我们引入了流动雷诺数的概念。简单地说,雷诺数告诉我们流动是层流还是湍流。因此,它告诉我们,除了别的以外,是线性还是二次阻力定律适用。用符号 Re 表示的雷诺数由以下方程定义,其中 u 是与流动相关的特征速度,d 是特征长度标度,希腊符号ν (nu)是所谓的流体运动粘度:

A978-1-4302-6338-8_7_Fighh_HTML.jpg

运动粘度定义为流体的动力粘度与密度之比:

A978-1-4302-6338-8_7_Figii_HTML.jpg

利用前面给出的动力粘度和密度值,我们可以推导出在 20°C 时,水的运动粘度为 1.0×10-6m2s,空气的运动粘度为 1.5×10-5m2s

选择什么样的速度 u 和什么样的长度尺度 d 来计算雷诺数取决于问题。对于在流体中运动的球体这样的物体,u 只是物体的速度,d 是线性维度(球体的直径)。

现在,实验发现层流占主导地位,因此,当雷诺数远小于 1 时,线性阻力的斯托克斯定律成立。使用雷诺数的公式,并设置 Re = 1,这意味着决定哪个阻力定律适用的临界速度由下式给出

A978-1-4302-6338-8_7_Figjj_HTML.jpg

如果物体的速度远小于这个临界值 v c ,则阻力定律是线性的;如果远大于 v c ,阻力定律为二次型。如果速度介于这两个极限之间,则可以使用两个定律的组合。

例如,足球的直径为 22 厘米,即 0.22 米。使用前面给出的水和空气的运动粘度值,我们推断出这种球的临界速度在水中为 0.0045 毫米/秒,在空气中为 0.068 毫米/秒(是的,这些都是以毫米/秒为单位)。这些都是很小的速度,所以实际上你可以假设这种大小的球在水中或空气中的运动总是遵循二次阻力定律。这也适用于大多数在空气或水中以正常速度运动的日常物体。

另一方面,直径为 1 mm 的滚珠轴承落入甘油中(在 20°C 时,其运动粘度为 1.2 × 10 -3 m 2 /s),其临界速度为 1.2 m/s。这是一个大得多的临界速度。滚珠轴承达到的最大速度(其终端速度)比这个要小得多。因此,线性阻力定律适用于这种情况。

向气球模拟添加阻力

如前一节所示,适用于大多数在空气或水中运动的日常物体的阻力定律是二次阻力定律,正如在Forces.drag()函数中编码的那样。给气球模拟的最后一个例子添加阻力是一件非常简单的事情。我们简单地更新calcForce()如下:

function calcForce(){

var gravity = Forces.constantGravity(m,g);

var V = m/rhoP; // volume of air displaced

var upthrust = Forces.upthrust(rho,V,g);

var drag = Forces.drag(k,balloon.velo2D);

force = Forces.add([gravity, upthrust, drag]);

}

新变量k是阻力常数。在提供的示例代码中,我们给它的值是 0.01。如果您使用这些修改运行模拟,您将会看到气球不再以加速度上升。阻力的增加减缓了它的上升速度,效果更加真实。

示例:浮球

现在让我们回到我们在第五章中介绍的浮球模拟来说明阻力、上推力和重力的影响。该代码模拟了一个球在水中被抛出、落下或释放的运动。

代码在文件floating-ball.js中。代码的视觉和交互方面将在第五章中讨论。在这里,我们将把重点放在物理学上,它本质上包含在calcForce()方法中:

function calcForce(){

var rball = ball.radius;

var xball = ball.x;

var yball = ball.y;

var dr = (yball-yLevel)/rball;

var ratio;                            // volume fraction of object that is submerged

if (dr <= -1){                        // object completely out of water

ratio = 0;

}else if (dr < 1){                    // object partially in water

//ratio = 0.5 + 0.5*dr;          // for cuboid

ratio = 0.5 + 0.25*dr*(3-dr*dr); // for sphere

}else{                                // object completely in water

ratio = 1;

}

var gravity = Forces.constantGravity(ball.mass,g);

var upthrust = new Vector2D(0,-rho*V*ratio*g);

var drag = ball.velo2D.multiply(-ratio*k*ball.velo2D.length());

force = Forces.add([gravity, upthrust, drag]);

// bouncing off walls

if (xball < rball){

ball.xpos = rball;

ball.vx *= vfac;

}

if (xball > canvas.width - rball){

ball.xpos = canvas.width - rball;

ball.vx *= vfac;

}

}

由位于calcForce()末端的两个if块组成的代码段处理球从墙上弹回的情况。之前的四行代码应该是显而易见的:我们正在计算作用在球上的三个力(重力、上推力和阻力),然后将它们相加得到合力。这里物理学的新内容是包含了一个叫做ratio的因素,它是浸没在水中的球的体积分数。这用于计算水中的上推力(根据阿基米德原理)和水中的阻力(假设这也与浸没的体积分数成比例——这实际上是一个相当粗略的近似,但有助于使算法更加简单)。为了简单起见,我们忽略了球在空气中可能受到的上推力和阻力。不难看出,如果需要的话,也可以把它们包括在内。所有这些应该很容易理解;微妙之处在于计算ratio的代码。

要计算ratio,需要做一些几何思考。看一下图 7-9 ,它代表一个长方体和一个部分浸入水中的球体。在我们的动画中,假设相应对象的坐标在对象的中心,我们需要一个公式,根据对象的位置和水位的位置给出ratio

A978-1-4302-6338-8_7_Fig9_HTML.jpg

图 7-9。

Objects partially immersed in water

要做到这一点,首先定义一个参数dr是很方便的,它告诉你物体的中心在水面之上或之下多少,作为球的半径(或长方体的半高)的一部分。所以dr定义如下,其中 r 是物体的半高(等于球的半径):

A978-1-4302-6338-8_7_Figkk_HTML.jpg

稍微思考一下应该就能让你相信,dr=–1 表示物体(不管是球还是长方体)刚好完全出水,dr = 1 表示刚好完全没入水中。因此,如果dr<=–1,ratio为零,如果dr > = 1,ratio为 1。更棘手的是当物体部分浸没在水中时。以长方体为例,简单的几何告诉我们(是的,你可以自己算出来):ratio = 0.5 + 0.5 dr

A978-1-4302-6338-8_7_Figll_HTML.jpg

对于球体,计算起来要复杂一点(你需要做一点微积分),但公式如下:ratio = 0.5+0.25 dr(3dr 2)

A978-1-4302-6338-8_7_Figmm_HTML.jpg

这就是我们所需要的。

代码中,球的体积V设为 1,质量也是 1。所以它的密度(等于质量/体积)是 1。水的密度设定为 1.5。所以,因为球的密度小于水的密度,所以会浮起来。

floating-ball.js中,球的初始位置和速度给定如下:

ball.pos2D = new Vector2D(50,50);

ball.velo2D = new Vector2D(40,-20);

这使得球最初在水面上,并产生一个向上的速度分量和一个向右的分量。如果你运行这个代码,你会看到球在空中沿着一条曲线(抛物线)运动,直到它碰到水。然后,由于水中的阻力和上升推力,它突然减速,下沉一点,然后再次浮出水面,在水面上振荡,直到停止。仿真截图如图 7-10 所示。

A978-1-4302-6338-8_7_Fig10_HTML.jpg

图 7-10。

A floating ball

这个模拟是如此的真实,你可以用它来做实验以了解其中的物理过程!点击水面上方的任何地方,将球移动到那里。然后把它放在水面上。它将落入水中,减速,上升,并在停止前再次在水面上振荡几次。你把它丢得越高,它就会沉得越深。现在点击水下并释放它。它将上升到表面,并在停止前再次振荡几次。

您还可以通过改变初始条件或参数值来进行试验。比如把球的质量改成 2,或者体积改成 0.5,这样密度就是 2。然后球会下沉而不是漂浮,因为它的密度大于水的密度,水的密度是 1.5。

想尝试不同的东西吗?如果球很轻会发生什么?试试看。通过将体积增加到 2,将密度减少到 0.5。当你在水下释放球时会发生什么?它射向空中,然后落回水中。如果你曾经试着用一个真实的球来做这件事,你会知道这是一个真实的效果。我们的模拟非常逼真,可以用来进行“虚拟实验”。我们甚至还没有注意到数字的准确性(我们将在本书的第四部分讨论)。

这个例子特别有启发性,因为它显示了包含相关物理的模拟如何表现得非常像真实生活中的真实事物。模拟知道如何处理初始条件或参数的任何变化,而不需要你告诉它更多的东西!这就是真实物理模拟的威力。它让你物有所值,可能比你想象的还要多——而且比造假容易多了!

终端速度

正如你在前面的例子中发现的,阻力的存在意味着上升或下降物体的速度不可能无限增加。为了理解为什么,考虑一个物体从高处(远离地面)释放并在重力作用下下落的情况。这个例子在第四章的中讨论过,在第五章的中详细阐述过,但是现在你对阻力有了更多的了解,这里有必要回顾一下以获得更深的理解。特别是,前面的讨论是根据线性阻力公式进行的;了解结果如何推广到二次阻力,以及何时考虑上推力,是很有用的。

在图 7-11 中,有一个向下的重力 W = mg 作用在物体上,还有一个向上的拖曳力 d。拖曳力的大小在非常低的速度下由 kv 给出,但通常由 kv 2 给出。最初物体的速度是零,所以阻力也是零。当它在重力作用下加速时,它的速度增加,因此阻力也增加。这将向下的合力减小到 W–D,从而减小加速度。因为重力是不变的,随着速度的增加,会有一个点,在这个点上拖曳力会和重力相等;换句话说,合力 W–D 为零,所以物体的加速度也为零。换句话说,它最终会以一个恒定的速度运动:终极速度。

A978-1-4302-6338-8_7_Fig11_HTML.jpg

图 7-11。

Force balance giving rise to terminal velocity

如第四章所示,很容易算出终端速度的大小。这是 W–D = 0 时的 v 值。因为 W = mg,如果我们用 D = kv,我们得到这个:

A978-1-4302-6338-8_7_Fignn_HTML.jpg

以便

A978-1-4302-6338-8_7_Figoo_HTML.jpg

这是层流的极限速度。它适用于在高粘度流体(如石油)中运动的物体。

如果我们对更高的速度使用阻力定律,D = kv 2 ,我们反而得到

A978-1-4302-6338-8_7_Figpp_HTML.jpg

这就意味着

A978-1-4302-6338-8_7_Figqq_HTML.jpg

我们也可以通过用视重量 W = (ρ 物体–ρ流体 ) V g 的绝对值代替重量 W = mg,将这些公式推广到包括上推(见图 7-12 )

A978-1-4302-6338-8_7_Fig12_HTML.jpg

图 7-12。

Force balance with upthrust

如果你正在做一个模拟,其中物体达到终极速度非常快,你可以利用前面的公式,只给物体一个与终极速度相等的恒定速度。这将保持模拟的物理真实性(如果初始加速度不重要),同时显著减少计算时间。例如,在水中上升的气泡达到极限速度非常快,如果除了重力、上推力和阻力之外没有其他力,就可以用这种方式模拟。

正如在第五章中所讨论的,一个物体在重力、上推力和阻力下的运动可以用微积分来解析求解,不仅可以给出最终速度,还可以给出任意时刻的速度。例如,对于在流体中静止下落的物体,假设线性阻力并忽略上推力,解析解由下式给出

A978-1-4302-6338-8_7_Figrr_HTML.jpg

这给出了物体在 t 时刻下落后的速度。二次阻力和包括上升推力的解看起来有点复杂,你可以在物理教科书中找到。

示例:降落伞

降落伞的工作原理是利用阻力与物体面积成正比的事实。回到二次阻力 F =–kv v 的公式,用 k = 1/2 ρAC d 给出阻力常数 k,我们可以看到物体的面积越大,阻力就会越大。打开降落伞突然增加了暴露在空气中的表面积,从而增加了 k 值。这大大增加了阻力,因此使跳伞者减速。

我们目前的例子是说明这一原则的教育练习。看看parachute.js中的init()函数:

function init() {

ball = new Ball(20,'#0000ff',m);

ball.pos2D = new Vector2D(650,50);

ball.velo2D=new Vector2D(0,0);

ball.draw(context);

setupGraph();

window.addEventListener('mousedown',openParachute,false);

t0 = new Date().getTime();

t = 0;

animFrame();

};

这只是创建了一个Ball实例(代表一个跳伞者!)然后通过animFrame()和相关的运动代码以通常的方式制作它下落的动画。calcForce()方法增加了重力、上推力和阻力:

function calcForce(){

var gravity = Forces.constantGravity(m,g);

var drag = Forces.drag(k,ball.velo2D);

var upthrust = Forces.upthrust(rho,V,g);

force = Forces.add([gravity, upthrust, drag]);

}

此外,在init()中为mousedown事件设置了一个事件监听器。在相应的事件处理程序openParachute()中,降落伞的半径增加了一个等于linearFactor的因子(在代码中被赋予 3 的值),阻力常数k的值增加了一个等于linearFactor的平方的因子(增加了 9)。然后删除事件侦听器。

function openParachute(evt){

k *= linearFactor*linearFactor;

ball.radius *= linearFactor;

window.removeEventListener('mousedown',openParachute,false);

}

这意味着当用户第一次点击鼠标时,降落伞会增大三倍(以半径计),阻力常数会增大九倍。最后在setupGraph()中设置一个Graph对象,在init()函数中调用,通过调用move()方法中的plotGraph()方法来绘制降落伞在每一时刻的速度的vy分量。

如果你现在运行这个代码(不打开降落伞),你会发现降落伞开始加速很快,然后变得更慢,直到它在下落大约 10 秒后达到一个恒定的速度(极限速度)。终端速度的值大约是每秒 30 个像素。如果你点击“打开降落伞”,它将变得更大,并立即减速,在几秒钟内达到大约每秒 10 像素的新的更低的终端速度。无论何时打开降落伞,最终速度都是一样的:它与时间无关,只取决于降落伞的质量 m,重力加速度 g,以及阻力常数 k(通过公式A978-1-4302-6338-8_7_Figss_HTML.jpg)。图 7-13 显示了速度-时间图的典型形状。

A978-1-4302-6338-8_7_Fig13_HTML.jpg

图 7-13。

Velocity-time graph for a parachute

电梯

你看到了阻力取决于动压。还有一个力也依赖于动压,它叫做升力。更准确地说,是物体两侧的动态压力差造成了这些力。物体上的阻力与物体的速度方向相反,而升力则与速度方向垂直(见图 7-14 )。现在,因为运动物体的前后总有一个动压差,所以运动物体上总有一个拖曳力。然而,如果一个物体沿运动方向的轴线完全对称,则该物体两侧的流量(以及动态压力)将完全相同。在这种情况下,没有升力。但是气流中的任何不对称——例如,由不对称的机翼形状(对于飞机)或迎角引起的不对称——都会产生垂直于运动方向的升力。

A978-1-4302-6338-8_7_Fig14_HTML.jpg

图 7-14。

Drag and lift force on an object moving in a fluid

升力使飞机能够飞行。在那种情况下,运动的方向通常是水平的,所以升力垂直向上作用,平衡了飞机的重量。但是升力并不总是垂直向上的。根据运动的方向,它可以向任何方向运动。

升力有时与上推力相混淆。事实上,上冲有时被误认为是升力。像上升一样,上升是因为压力差。上冲是由物体顶部和底部的静压力差引起的,静压力差是由被驱替流体的重量(浮力)引起的;但是升力是由物体沿其运动方向相对两侧的动态压力差引起的。这就造成了前面提到的两侧静压的差异。因此,尽管上升总是向上的(正如它的名字所暗示的),但对升力来说却不一定如此。更重要的是,物理定律以及支配这两种力的公式完全不同。想想这个:升力是使飞机飞行的力量;上推力是使气球漂浮的力量;这两种情况下的机制是不同的。

升力系数

升力是用升力系数来模拟的,与二次阻力完全一样。因此,升力的大小可以写如下,其中 C L 是升力系数,A 是物体沿运动方向的面积(而不是像阻力一样垂直于运动方向):

A978-1-4302-6338-8_7_Figtt_HTML.jpg

像阻力系数 C d 一样,升力系数 C L 取决于多种因素。对于飞机来说,重要的变量是机翼形状和迎角。

正如我们对阻力所做的那样,我们可以定义一个升力常数 k = 1/2 ρAC L ,这样我们就可以把升力的大小写成 F = kv 2

升力的方向垂直于速度的方向。考虑到这一点,我们可以定义一个Forces.lift()函数如下:

Forces.lift = function(k,vel) {

var force;

var velMag = vel.length();

if (velMag > 0) {

force = vel.perp(k*velMag);

}

else {

force = new Vector2D(0,0);

}

return force;

}

例如:一架飞机

现在让我们来演示升力是如何使飞机飞行的。文件airplane.js包含基本飞行演示的代码。我们将在这里复制代码的关键部分。首先,看看变量声明/初始化和init()函数:

var plane;

var m = 1;

var g = 10;

var kDrag = 0.01;

var kLift = 0.5;

var magThrust = 5;

var groundLevel = 550;

var t0,dt;

window.onload = init;

function init() {

makeBackground();

makePlane();

t0 = new Date().getTime();

animFrame();

};

变量名应该是不言自明的。makeBackground()makePlane()方法产生的视觉设置如图 7-16 所示。在makePlane()中,一架飞机被创建为Plane对象的实例,并被放置在初始速度为零的“跑道”上。欢迎您查看一下plane.js文件,看看Plane对象的代码是什么样子(如果您愿意,甚至可以对它进行改进!).但是视觉细节对我们目前的目的并不重要。真正重要的是如何让飞机飞起来。这是由animFrame()和相关方法发起的。这里唯一的新代码在calcForce()中。

如图 7-15 所示,飞机在飞行中主要受四种力:重力(W)、推力(T)、阻力(D)和升力(L)。当飞机在地面上时,还有地面产生的法向接触力。因此,calcForce()方法计算这些力并将它们相加:

function calcForce(){

var gravity = Forces.constantGravity(m,g);

var velX = new Vector2D(plane.vx,0);

var drag = Forces.drag(kDrag,velX);

var lift = Forces.lift(kLift,velX);

var thrust = new Vector2D(magThrust,0);

var normal;

if (plane.y >= groundLevel-plane.height){

normal = gravity.multiply(-1);

}else{

normal = new Vector2D(0,0);

}

force = Forces.add([gravity, drag, lift, thrust, normal]);

}

A978-1-4302-6338-8_7_Fig15_HTML.jpg

图 7-15。

The four forces on a airplane in flight

引力和往常一样简单明了。阻力和升力的计算方法很简单,只考虑飞机速度的水平分量。同样,假设飞机在任何时候都是水平的,迎角为零。推力被模拟为一个恒定的力。最后,当飞机在地面上时,法向力被设置为与重力大小相等方向相反,否则为零。

当你运行模拟时,你会看到飞机只有在获得足够高的速度以产生足够的升力来克服其重量时才会起飞(见图 7-16 )。如果你把推力的大小减少到 3 或 4,飞机会沿着跑道移动,但永远不会起飞。在水平推力和阻力的作用下,它达到的最大水平速度(类似于极限速度)不足以产生足够的升力来克服其重量。

A978-1-4302-6338-8_7_Fig16_HTML.jpg

图 7-16。

Plane flying thanks to the lift force

重申一下,我们在这个例子中做了很多简化。我们将在最后一章建立一个更加完整和真实的飞行模拟器,在这一章中,我们还将更详细地研究飞机上的阻力、升力和推力。

风和湍流

风本身不是一种力,但它能产生力。风是空气的运动,它在空间和时间的每一点都有一个相关的速度。这可能相当复杂,但即使使用简单的风模型也可以实现相当真实的效果。一股持续的风吹过一个静止的物体,可以被看作是一个物体在空气中运动,只是方向相反。它以类似的方式产生阻力和升力。

风产生的力

正如你在本章开始时看到的,流动的流体有一个相关的动态压力。风只不过是流动的空气。当风吹在一个物体或表面上时,由于动态压力,它会施加一个力。

我们如何计算这个力?根据我们所说的,只有相对速度是重要的。因此,风施加的力正好是反方向的阻力,其中 w 是风速:

A978-1-4302-6338-8_7_Figuu_HTML.jpg

因此,我们可以使用阻力函数来模拟风,使用风速和负阻力常数 k。

风和阻力

当然,即使有风,在空气中运动的物体仍然会受到阻力。因此,由物体运动和空气(风)运动产生的净力将是阻力,以物体和空气之间的相对速度为参考。因此,风和阻力的合力由下式给出,其中| w–v |表示矢量(w–v)的大小:

A978-1-4302-6338-8_7_Figvv_HTML.jpg

注意| w–v |通常不等于 w 和 v 的幅度差(w–v)。

我们可以使用阻力函数,通过物体和空气之间的相对速度来计算风和阻力的综合影响。

稳定和湍流

我们现在知道了在给定风速的情况下,如何计算有风时物体所受的力。但是我们如何模拟风速 w 本身呢?

答案是,这取决于我们正在建模的情况。风的行为相当复杂,因为它随时间和空间而变化。风取决于复杂的大气条件以及建筑物和树木等障碍物。然而,在大多数情况下,你可能希望事情尽可能简单。所以我们将只讨论一些简单的风速建模方法。

最简单的风模型是假设速度 w 在空间和时间上都是恒定的。这相当于所谓的均匀稳定流动。一个稍微复杂一点的模型是假设一个水平恒定但随高度变化的稳定流(在时间上仍然恒定)。这个模型可以解释这样一个事实,即近地面的风速较低,越往上风速越高。

在现实世界中,风通常不是稳定的,而是由明显随机吹动的阵风组成。阵风产生所谓的湍流,这种湍流可以使事物以复杂的方式运动。

示例:稳定风中的气泡

在这个例子中,我们将模拟一个稳定均匀的风在空气中吹一些气泡。文件bubbles-wind.js包含代码。由于它与前面的示例略有不同(因为它涉及多个粒子),我们在此完整复制代码:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var bubbles; var t0;

var dt;

var force;

var acc;

var numBubbles = 10;

var g = 10;

var rho = 1;

var rhoP = 0.99;

var kfac = 0.01;

var windvel = new Vector2D(40,0);

window.onload = init;

function init() {

bubbles = new Array();

var color = 'rgba(0,200,255,0.5)';

for (var i=0; i<numBubbles; i++){

var radius = Math.random()*20+5;

var V = 4*Math.PI*Math.pow(radius,3)/3;

var mass = rho*V;

var bubble = new Ball(radius,color,mass,0,true);

bubble.pos2D = new Vector2D(Math.random()*canvas.width,Math.random()*canvas.height);

bubble.velo2D = new Vector2D((Math.random()-0.5)*20,0);

bubble.draw(context);

bubbles.push(bubble);

}

t0 = new Date().getTime();

animFrame();

};

function animFrame(){

requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

for (var i=0; i<numBubbles; i++){

var bubble = bubbles[i];

moveObject(bubble);

calcForce(bubble);

updateAccel(bubble.mass);

updateVelo(bubble);

}

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

obj.draw(context);

}

function updateAccel(mass){

acc = force.multiply(1/mass);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

function calcForce(particle){

var V = particle.mass/rhoP;

var k = Math.PI*particle.radius*particle.radius*kfac;

var gravity = Forces.constantGravity(particle.mass,g);

var upthrust = Forces.upthrust(rho,V,g);

var relwind = windvel.subtract(particle.velo2D);

var wind = Forces.drag(-k,relwind);

force = Forces.add([gravity, upthrust, wind]);

}

init()中,我们创建了许多气泡作为Ball物体,并给每个气泡一个随机的半径。(如果你有一台很慢的电脑,你可能想减少气泡的数量!)然后使用球体体积和气泡密度的公式计算它们的体积和质量,该公式指定为 0.99。气泡被赋予一个随机的位置和一个小的随机水平速度。然后它们被收集到一个数组中,然后在move()方法中使用该数组来依次将方法moveObject(), calcForce(), updateAccel()updateVelo()应用于每个气泡。

calcForce()方法中,三种力被计算并相加:重力、上推力和风(阻力)。上升推力是用每个气泡的体积来计算的,体积=质量/密度。为了计算风力,通过从风速windvel中减去气泡的当前速度来计算相对速度relwind,风速以恒定的水平向量给出。然后通过使用具有负阻力系数的Forces.drag()k和相对速度作为自变量来计算风/阻力。注意k包括一个等于气泡投影面积的因子,考虑到拖曳力与物体的面积成比例。

如果你运行模拟,你会看到气泡顺着风向漂移,同时缓慢上升,如你所料(见图 7-17 )。玩风速和其他参数,看看运动是如何修改的。

A978-1-4302-6338-8_7_Fig17_HTML.jpg

图 7-17。

Bubbles in a steady wind

模拟湍流

湍流是一种复杂的现象。事实上,它是如此复杂,以至于你需要带有数百个处理器的巨大超级计算机来精确计算湍流,即使是简单的配置。另一方面,有许多近似模拟湍流的方法。一个简单的方法是使用随机数。

通过在稳定的风场上叠加随机噪声,可以产生看起来像湍流的效果。这利用了湍流的统计特性,通常可以通过以下方式分解任何时间的风速来表示,其中 w 稳定是稳定部分,w 波动是捕捉湍流波动的时变部分:

A978-1-4302-6338-8_7_Figww_HTML.jpg

稳定部分 w 稳定部分只是一个恒定矢量,正如我们在之前的气泡模拟中建模的那样。我们可以用一个向量来模拟 w 波动,这个向量的分量是在每个时间步长更新的随机数。要在 bubbles 模拟中做到这一点,只需在计算relwind的代码行之前添加以下代码行:

windvel = new Vector2D(20 + (Math.random()-0.5)*1000,(Math.random()-0.5)*1000);

我们这样做了,并将新文件保存为bubbles-turbulence.js。我们还在从move()调用的函数showArrow()中添加了一些代码来指示风矢量 w 的变化。代码在舞台中间产生一条线,沿着瞬时风向,长度与风速的大小成比例。

前面一行代码将风速 w 指定为以每秒 20 个像素的速度向右吹的稳定风矢量(20,0)和具有每秒–500 到 500 个像素的 x 和 y 分量的随机风矢量(Math.random()-0.5)*1000,(Math.random()-0.5)*1000)之和。舞台上所有位置的风速都相同。

尝试将这些值更改为您喜欢的值。稳定风的大小(此处设置为值 20)使气泡稳定漂移。波动的幅度(这里由因子 1000 设定)控制风的阵风并使气泡波动。你可以用这种方式产生一个相当令人信服的湍流效果。

摘要

本章介绍了许多作用在与其他固体或流体接触的固体上的力。如果你正在模拟地球上的几乎任何东西,很可能你需要模拟这些力。你在这一章学到的东西给了你一个坚实的基础,在此基础上你可以创建更复杂的效果和模拟。在最后一章,你将有机会使用这里解释的物理学来开发更完整的模拟。与此同时,在下一章中,你将了解到一种产生振荡(类似弹簧)运动的新型力。

八、回复力:弹簧和振动

弹簧是你会遇到的最有用的工具之一,尤其是用于创建有趣的物理效果。许多系统可以用弹簧和类似弹簧的运动来模拟。因此,学习弹簧运动的基本原理是值得的。但是要小心:春天会让人奇怪地上瘾!

本章涵盖的主题包括以下内容:

  • 自由振荡:物体将在弹簧力的作用下振荡,也称为回复力。
  • 阻尼振荡:阻尼消耗振荡的能量,使振荡随时间而消失。
  • 强迫振荡:振荡可以由抵抗阻尼力的外力驱动。
  • 耦合振荡器:使用多个弹簧和物体耦合在一起,可以产生有趣的效果。

弹簧和振动:基本概念

在讨论正弦波及其组合时,我们在第三章中介绍了振荡(参见“使用触发函数制作动画”一节)。振荡是围绕某一中心位置的重复运动。振荡系统的简单例子包括钟摆和秋千。

自然系统中也有大量的振荡现象:树木在风的吹拂下振荡,漂浮的物体在水面上振荡是因为经过的波浪。包括弹簧在内的人造机械会产生振动。因此,振荡和弹簧之间有一种直观的联系;事实上,我们将交替使用振荡和类似弹簧的运动这两个术语。这种联系不仅仅是口头上的:振荡系统通常可以用虚拟的“弹簧”来建模。

类似弹簧的运动

你已经在这本书里遇到了春天般的运动,可能还没有意识到。前一章中的浮球模拟提供了一个例子,其中球在水面上以类似弹簧的方式摆动。许多其他东西也经历类似弹簧的运动:汽车的悬挂装置;树、树枝和树叶在风中摇摆。其他东西,虽然它们不一定看起来像弹簧,但仍然可以使用弹簧建模:可变形的物体,如绳子、衣服和头发。当然,这些系统一定有一些共同的特征,使得它们可以用弹簧来建模。那么,振荡系统的一般特征是什么呢?

回复力、阻尼和作用力

振荡系统通常包括以下成分:

  • 一个平衡位置,如果物体不运动,它将保持在这个位置。
  • 如果物体发生位移,将物体拉回到平衡位置的恢复力。
  • 随时间减少振荡的阻尼力。
  • 将物体从平衡位置移开的驱动力。

其中,前两个是产生振荡的关键,而后两个可能存在,也可能不存在,这取决于系统。虽然更复杂的系统可能看起来没有这些特征,但它们可以由具有这些特征的组件组成(或由这些组件建模),例如,一根绳子可以表示为一串弹簧。

在理解恢复力、阻尼和作用力的作用时,一个相关的概念是振幅。振动的振幅是从平衡位置的最大位移。如果你把一个秋千拉离它的平衡位置,然后放开,初始位移将是振荡的振幅。

回复力随时间改变位移,但不改变振幅(最大位移)。振幅取决于系统中的能量大小。阻尼力从系统中带走能量,因此振幅随时间减小。如果在最初移动秋千之后,让它自己摆动,就会发生这种情况。驱动力将能量注入系统,因此倾向于增加振荡的振幅。当阻尼和强迫同时存在时,输入系统的能量有可能正好补偿阻尼损失的能量。在这种情况下,振幅随时间保持不变,就好像只有恢复力存在一样。在挥杆的例子中,这是通过用适当的力量定期推动挥杆来实现的。

胡克定律

在大多数振荡系统中,支配恢复力的力定律被称为胡克定律(因为一位名叫罗伯特·胡克的绅士在历史的某个时刻发现了它)。

胡克定律很简单。我们先解释弹簧,因为这是定律的原始形式。看一下图 8-1 ,它显示了一个自然长度为 l 的弹簧,固定在一端,然后被拉伸 x,所以它的长度变成 l + x。

A978-1-4302-6338-8_8_Fig1_HTML.jpg

图 8-1。

A stretched spring experiences a restoring force proportional to the extension x

胡克定律表明,弹簧将被一个 F 级的力拉回,该力由下式给出:

A978-1-4302-6338-8_8_Figa_HTML.jpg

换句话说,回复力与延伸量 x 成比例。比例常数 k 称为弹簧常数,它是弹簧刚度的一个度量。k 值越大,对于给定的拉伸,力就越大,因此弹簧在拉伸时被拉回的力就越大。

负号表示力的方向与延伸方向相反。因此,如果弹簧被压缩,力会反推增加其长度。

方程的矢量形式是这样的,其中 r 被解释为弹簧自由端点的位移矢量:

A978-1-4302-6338-8_8_Figb_HTML.jpg

对于我们在这一章将要做的大部分事情(可能也是你想要做的),我们不会太关心实际的春天。我们更感兴趣的是附在弹簧末端的质点的运动。这样一个物体会如何运动?每个孩子都知道,它会在平衡位置附近摆动。

事实上,我们可以完全去掉弹簧,只考虑恢复力对粒子的影响。这就是我们在很多例子中要做的。在这种情况下,虎克定律中的位移矢量 r 被解释为粒子相对于其振荡的平衡位置的位移,如图 8-2 所示。

A978-1-4302-6338-8_8_Fig2_HTML.jpg

图 8-2。

A particle oscillating about an equilibrium position

在我们结束这一节之前,有一个警告:不应该假设所有的振荡都遵循胡克定律;许多人不知道。然而,许多类型的运动系统遵循胡克定律,至少近似如此。所以在这一章中我们将坚持胡克定律。

自由振荡

让我们从模拟自由振荡开始。这意味着,系统纯粹在恢复力的作用下振荡,没有任何其他的力。当然,对于一个要振荡的物体来说,首先一定有什么东西(一个施加的力)把它从平衡位置移开了。但是我们在这里感兴趣的是,在初始力被移除,振荡系统被留给它自己之后会发生什么。

弹簧力函数

首先,我们需要在Forces对象中为恢复力创建一个新的力函数。姑且称之为spring()。这是一个非常简单的功能:

Forces.spring = function(k,r){

return r.multiply(-k);

}

函数spring()有两个参数,分别是弹簧常数k和位移向量r(这只是前面公式中的向量 r)。它返回恢复力 F =–k r。

现在让我们用这个函数来创建一个基本振荡器。

创建基本振荡器

你知道接下来会发生什么,不是吗?下面是从文件basic-oscillator.js中提取的一些代码,该文件创建了一个对象,我们希望该对象作为名为ballBall对象进行振荡(我们省略了画布上的标准行和上下文变量)。我们实际上并没有创建一个弹簧(在视觉上),而是创建了另一个Ball对象,我们称之为attractor,它的位置将是平衡位置。两者的初速度都是零,通过给它们不同的位置向量,它们被放置在一定距离之外pos2D:

var ball;

var displ;

var center = new Vector2D(0.5*canvas.width,0.5*canvas.height);

var m = 1;

var kSpring = 1;

var t0, dt;

var acc, force;

var animId;

window.onload = init;

function init() {

// create a ball

ball = new Ball(15,'#0000cc',m,0,true);

ball.pos2D = new Vector2D(100,50);

ball.draw(context);

// create an attractor

var attractor = new Ball(2,'#000000');

attractor.pos2D = center;

attractor.draw(context_bg);

// make the ball move

t0 = new Date().getTime();

animFrame();

}

动画代码相当标准;唯一的新颖之处在于calcForce()方法:

function calcForce(){

displ = ball.pos2D.subtract(center);

force = Forces.spring(kSpring,displ);

}

这段代码非常简单:它首先计算物体相对于吸引子的位移向量displ,然后用它来计算吸引子对物体施加的弹力。参数kSpring是弹簧常数 k。

运行代码,你会看到球在吸引子周围振荡,就像预期的那样。现在将弹簧常数值从 1 更改为 10。这给你一个硬弹簧。如果你再次运行代码,你会看到球摆动得更快了。如果你想让它以不同的振幅振荡,只需改变basic-oscillations.js中的初始位置。

接下来将 k 的值改回 1,然后将球的初速度改为(200,0):

ball.velo2D = new Vector2D(200,0);

当您运行代码时,您现在应该看到球以某种椭圆形轨迹“环绕”吸引子。有点像你在第六章看到的重力。重力和弹力都是吸引力,总是作用在一个点上。但是重力随着距离的增加而减小,而弹力随着距离的增加而增加。这是因为重力与 1/r 2 成正比,而弹力与 r 成正比,其中 r 是与引力中心的距离。

简谐运动

你刚才看到的这种振荡运动在技术上被称为简谐运动(SHM)。如果作用在物体上的唯一力是弹簧力,物体就会经历 SHM,就像前面的例子一样。

因为 SHM 中唯一的力是回复力 F =–k r,而牛顿第二定律表明 F = m a,所以这两个方程一起告诉我们,对于 SHM,以下是正确的:

A978-1-4302-6338-8_8_Figc_HTML.jpg

将等式两边除以 m 得出:

A978-1-4302-6338-8_8_Figd_HTML.jpg

这里 m 是振荡粒子的质量,k 是弹簧常数,所以比率 k/m 是常数。因此,这个方程告诉我们,振动粒子的加速度与它离中心的位移矢量成正比。比例常数为负,这意味着加速度总是与位移矢量相反(它总是指向中心,因为位移矢量根据定义总是指向远离中心的方向)。参见图 8-3 。

A978-1-4302-6338-8_8_Fig3_HTML.jpg

图 8-3。

The acceleration in SHM always points toward the center, opposite to displacement

还记得在第三章第一节中,我们谈到了导数,加速度是位移的二阶导数吗?这意味着以下情况成立:

A978-1-4302-6338-8_8_Fige_HTML.jpg

因此,我们可以将 SHM 方程 a =–(k/m)r 写成等价形式:

A978-1-4302-6338-8_8_Figf_HTML.jpg

以这种方式表述清楚了支配 SHM 的方程是一个二阶微分方程,如第五章中所述。basic-oscillator.js中的动画代码所做的就是使用欧拉格式数值求解这个二阶微分方程(再次如第五章所述)。

事实上,前面的微分方程也可以解析求解,以给出作为时间函数的位移公式。这是大学水平微积分中的一个练习题,下面是解法,其中 A 和 B 是依赖于初始条件的常矢量,ω是振荡的角速度(见第三章):

A978-1-4302-6338-8_8_Figg_HTML.jpg

因此,SHM 由正弦和余弦函数组成。这并不奇怪,因为 SHM 基本上是一种振荡运动,正如你从第三章中所知,sin 和 cos 是振荡函数。ω的值决定了振动的频率和周期,而 A 和 B 的值决定了振动的振幅(物体从平衡位置的最大位移)。

稍微懂点微积分的人,都可以通过对前面的 r 的表达式求导,立刻写下速度向量 v,因为 v = d r/dt。结果是这样的:

A978-1-4302-6338-8_8_Figh_HTML.jpg

A、B 和ω的值分别是多少?a 和 B 由您在开始时为对象的位移和速度指定的初始条件设定(时间 t = 0 时的 r 和 v 值)。我们称初始位移矢量 r 0 和初始速度矢量 v 0 。如果我们现在将 t = 0 代入前面的 r 和 v 方程,因为 cos(0) = 1,sin(0) = 0,我们得到:

A978-1-4302-6338-8_8_Figi_HTML.jpg

我们还得到以下信息:

A978-1-4302-6338-8_8_Figj_HTML.jpg

这马上告诉我们,A = r 0 和 B = v 0 /ω。

如果你要解这个微分方程,你也会发现角速度ω实际上是由这个公式给出的:

A978-1-4302-6338-8_8_Figk_HTML.jpg

这称为系统的固有频率。如果给振荡系统一个初始扰动(使物体离开其平衡位置),但此后任其自生自灭,它将以该频率振荡,并且仅在没有阻尼等其他影响的情况下以该频率振荡。

现在,如果您还记得(再次从第三章)ω= 2πf 和 f = 1/T,其中 f 是频率(每秒的振荡次数),T 是振荡周期(完成一次振荡的时间),您可以使用前面的公式根据参数 k 和 m 获得振荡的频率和周期:

A978-1-4302-6338-8_8_Figl_HTML.jpg

A978-1-4302-6338-8_8_Figm_HTML.jpg

这些公式告诉你,如果你增加弹簧刚度 k,振荡的频率将增加,其周期将减少(它将振荡得更快)。如果你增加振荡粒子的质量,情况会相反,它会振荡得更慢。去试试吧!

这是仅有的两个影响频率和周期的参数;物体的初始位置和速度不变。你可能会想,如果物体最初离中心较远,完成一次摆动就需要更长的时间。但不是和 SHM。发生的情况是,如果物体离得更远,它在开始时会经历更大的加速度,因此平均来说,它会获得更大的速度,这补偿了它完成一次振荡所必须行进的更长的距离。怀疑?尝试用秒表计时振荡或输出时间。

事实上,我们可以做得更好。让我们画一张图表。

振荡和数值精度

为了绘制图表,我们通过改变振荡物体的初始位置来修改basic-oscillations.js,如下所示:

object.pos2D = new Vector2D(100,50);

我们也改变吸引子的位置:

var center = new Vector2D(0.5*canvas.width,50);

在添加了一些额外的代码来绘制显示对象相对于时间的位移的图形,并在固定的持续时间(20 秒)内运行模拟之后,我们将文件保存为free-oscillations.js

下面是设置和绘制图表的代码:

function setupGraph(){

//graph= new Graph(context,xmin,xmax,ymin,ymax,xorig,yorig,xwidth,ywidth);

graph = new Graph(context_bg,0,20,-250,250,50,300,600,300);

graph.drawgrid(5,1,50,50);

graph.drawaxes('t (s)','displacement (px)');

}

function plotGraph(){

graph.plot([t], [displ.x], '#ff0000', false, true);

graph.plot([t], [displ.y], '#0000ff', false, true);

}

在每个时间步调用的plotGraph()方法调用Graphplot()方法来绘制球相对于吸引子的位移的 x 和 y 坐标。

如果您现在运行代码,您将看到类似图 8-4 的内容。果然,物体随时间的水平位移(x)是一个正弦波,我们在上一节已经说过了。由于我们选择的初始条件,垂直位移始终为零。但是很容易改变它,给物体一个速度的初始垂直分量,或者一个不同的初始垂直位置。那么 y 位移也将正弦变化。你也可以改变 k、m 的值和物体的初始位置,以验证我们在上一节结束时所做的关于振荡的频率和周期如何随这些参数变化(或不变化)的陈述。

A978-1-4302-6338-8_8_Fig4_HTML.jpg

图 8-4。

Plotting the displacement of an oscillating object as a function of time

现在,让我们通过将振荡物体的轨迹与上一节给出的解析解预测的轨迹进行比较,来看看我们的模拟有多精确。

为此,我们首先通过改变振荡物体的初始速度将free-oscillations.js修改为free-oscillations2.js,如下所示:

object.velo2D=new Vector2D(0,50);

然后我们添加代码,使用之前给出的等式A978-1-4302-6338-8_8_Fign_HTML.jpg、A = r 0 和 B = v 0 /ω计算 A、B 和ω的值(在代码中定义为变量ABomega)。这是在init()方法中完成的,因此它包含这些附加行:

omega = Math.sqrt(kSpring/m);

A = ball.pos2D.subtract(center);

B = ball.velo2D.multiply(1/omega);

然后,我们修改代码,创建两个Graph对象graphXgraphY(通过适当修改的setupGraph()方法建立),在plotGraph()中分别绘制 x 和 y 位移。在plotGraph()方法中,我们插入以下附加行来绘制解析解 r = A cos (ωt) + B sin (ωt)的 x 和 y 分量:

var r = A.multiply(Math.cos(omega*t)).add(B.multiply(Math.sin(omega*t)));

graphX.plot([t], [r.x], '#00ff00', false, true);

graphY.plot([t], [r.y], '#ff00ff', false, true);

如果您现在运行代码,您将看到对象以拉长的轨道围绕吸引子运行,并且您将看到对象的每个水平和垂直位移的一对图形。在每对图表中,一个对应于通过指定公式计算的解析解,而另一个对应于通过模拟计算的数值解。你可以看到它们彼此非常接近,几乎重叠(见图 8-5 )。欧拉积分在这种情况下做得还不错。

A978-1-4302-6338-8_8_Fig5_HTML.jpg

图 8-5。

Comparing the numerical and analytical solutions for the oscillator

然而,如果您现在将kSpring的值更改为20,例如,使“弹簧”更硬,对象移动更快,您会看到两者之间的差异更显著,表明欧拉正在失去准确性。如果你尝试让kSpring的值大于大约100,那么你可能会发现欧拉给出的完全是垃圾,物体在错误的时间出现在错误的地方。所以在这里你开始看到,如果你不小心对待你的积分器,你可能会得到错误的物理结果,特别是在弹簧的情况下。你会在第十四章中看到如何克服这个问题。同时,在本章的其余部分,我们将避免非常高的弹簧常数值。

阻尼振荡

在前面的例子中,振荡会一直持续下去。实际上,这种情况很少发生。振荡系统通常是阻尼的。这意味着随着能量从系统中移除,振荡会减少并及时消失。这类似于曳力,它耗散了运动物体的动能,使其减速。为了在弹簧中实现阻尼,我们需要一个阻尼力。

阻尼力

阻尼力通常被建模为与运动物体的速度成比例。这意味着,在任何时刻,它由下式给出,其中 c 是一个称为阻尼系数的常数,负号表示力的方向与速度相反:

A978-1-4302-6338-8_8_Figo_HTML.jpg

请注意,这与线性阻力的力定律的形式完全相同。因此,我们可以使用linearDrag()函数来实现弹簧运动的阻尼。然而,尽管阻力确实是阻尼的一种形式,但它并不是阻尼的唯一形式,尽管这两个术语有时可以互换使用。阻力是流体对浸入其中的运动物体施加的阻力。另一方面,弹簧阻尼可由流体阻力或外部摩擦等外部因素引起,或由弹簧的分子材料特性引起的内部因素如内部摩擦引起。事实上,你可以同时拥有由弹簧阻力产生的内部阻尼和由流体阻力产生的阻力(当然,系数不同)。例如,悬挂在弹簧上并在空气或水等流体中振荡的质量既受到内部摩擦,又受到流体阻力。出于这些原因,我们更喜欢为阻尼创建一个单独的函数。该函数的形式与linearDrag()的形式相同,如清单所示:

Forces.damping = function(c,vel){

var force;

var velMag = vel.length();

if (velMag>0) {

force = vel.multiply(-c);

}

else {

force = new Vector2D(0,0);

}

return force;

}

阻尼对振荡的影响

我们现在将修改free-oscillations.js来处理弹簧力之外的阻尼。新档名为damped-oscillations.js,基本上引入了一个新的力:阻尼。因此,与free-oscillator.js的一个不同之处在于,它声明了一个阻尼系数cDamping,并给它赋值:

var cDamping = 0.5;

另一个不同点是,它在calcForce()中包含了一个阻尼力,并将其与弹簧力相加,计算出合力:

function calcForce(){

displ = ball.pos2D.subtract(center);

var restoring = Forces.spring(kSpring,displ);

var damping = Forces.damping(cDamping,ball.velo2D);

force = Forces.add([restoring, damping]);

}

就这样。如果你用kSpring = 10cDamping = 0.5运行代码,你会看到如图 8-6 所示的东西。正如你所料,振荡会及时消失,物体最终会停留在平衡位置。

A978-1-4302-6338-8_8_Fig6_HTML.jpg

图 8-6。

Damped oscillations

此时,你应该摆弄一下阻尼常数cDamping,看看你得到了什么。选择一个较低的值,你会看到振荡持续更长时间,而一个较高的值会更快杀死它们。你可能认为 c 值越大,物体停止的越快。事实上,存在一个临界值 c,使振荡在最短时间内消失。在这个值下,根本没有振荡。物体只是平稳地移动到平衡位置,而不会振荡超过它。这称为临界阻尼,如图 8-7 所示。对于我们模拟中的kSpring = 10,cDamping的临界值约为 5.5,物体在 1.5 秒左右到达平衡位置。如果你增加cDamping超过这个临界值,例如增加到 10 或 20,你会看到物体实际上需要更长的时间到达它的平衡位置。这是因为增加的阻尼使它慢了下来,尽管它没有超过平衡位置,但需要更长的时间才能到达。临界阻尼是一种非常有用的现象,应用于门上的阻尼机构。

A978-1-4302-6338-8_8_Fig7_HTML.jpg

图 8-7。

Critical damping

有阻尼振荡的解析解

这个简短的部分是为爱好者准备的。如果你不喜欢复杂的公式,你可以放心地跳过它。本节的重点是向您展示在阻尼存在的情况下,前面引用的自由振荡的解析解是如何变化的,以及我们的模拟在再现它方面做得有多好。因此,它的教育意义大于实用性。

在弹簧力 F =–k r 和阻尼力 F =–c v 的作用下,振动的牛顿第二定律如下:

A978-1-4302-6338-8_8_Figp_HTML.jpg

这可以用导数的形式写成(两边除以 m 之后):

A978-1-4302-6338-8_8_Figq_HTML.jpg

该微分方程的解析解由下式给出:

A978-1-4302-6338-8_8_Figr_HTML.jpg

其中常数γ,ω 0 ,ω d ,A 和 B 由下式给出:

A978-1-4302-6338-8_8_Figs_HTML.jpg

A978-1-4302-6338-8_8_Figt_HTML.jpg

A978-1-4302-6338-8_8_Figu_HTML.jpg

A978-1-4302-6338-8_8_Figv_HTML.jpg

A978-1-4302-6338-8_8_Figw_HTML.jpg

将此与之前无阻尼的解决方案进行比较,您会注意到 r 的表达式中有一个额外的指数因子,这是正弦振荡衰减的原因(参见第三章)。新参数γ是由阻尼引起的。在没有阻尼的情况下,c = 0,所以γ= 0;于是,解就减少到没有阻尼的情况。还要注意,振荡的角频率现在是ω d (因为它出现在正弦和余弦曲线中),小于没有阻尼时的固有频率ω 0 (之前用ω表示)。

我们将这些方程编码成我们称之为damped-oscillations2.jsfree-oscillations.js的修改版本。如果你愿意,可以看一下代码。如果你运行代码,你会发现这个解析解和模拟计算的数值解之间又有一些差异。事实上,有时你可能会发现摆动的物体看起来很疯狂,最终停在了它不应该在的地方。从所有这些中得到的重要信息是,除了最简单的运动之外,欧拉积分在这种模拟中根本没有用。在第十四章中,我们将讨论解决这个问题的替代集成方案。

强迫振荡

在阻尼存在的情况下,系统的振荡会及时消失。因此,首先需要一个力来维持振荡或引发振荡。这就是所谓的驱动力或压力。

驱动力

驱动力可以是任何形式,所以让我们简单地用 f (t)来表示。这仅仅意味着力是时间的函数,而没有具体说明力定律的形式。

例如,我们可以有一个形式为 f = A cos (ωt) + B sin (ωt)的周期力,其中 A 和 B 是给出力的振幅(最大值)和方向的恒定矢量,ω是力的角频率。例如,这可以表示一个振荡器与另一个振荡器相互作用并驱动另一个振荡器,如周期性的风吹过吊桥并导致其振动。

先说个例子。

示例:周期性驱动力

我们从修改damped-oscillations2.js开始。我们不想在这里画出解析解,所以我们去掉了plotGraph()中的相关行,但是我们将保留在init()方法中计算gammaomega0omegad的三行。这些变量对应于常数γ、ω 0 和ω d 。删除任何不必要的变量声明后,我们将文件重命名为forced-oscillations.js

记住omega00 是无阻尼系统的固有角频率,omegadd )是有阻尼的角频率。让我们从给calcForce()添加以下几行开始:

var forcing = new Vector2D(200*Math.cos(2*omegad*t)+200*Math.sin(2*omegad*t),0);

force = Forces.add([restoring, damping, forcing]);

这增加了一个形式为 f = A cos (ωt) + B sin (ωt)的驱动力,其中 A 和 B 的幅度均为 200,并且在 x 方向上(我们需要相当大的值才能看到对振荡的作用力的显著差异),ω = 2 ω d 。因此,我们以两倍于阻尼系统频率的角频率,对系统施加随时间呈正弦变化的驱动力。

运行代码,你会看到振荡开始消失,就像没有外力时一样。然后,过了一会儿,物体开始以两倍于之前的频率振荡,但是振幅要小得多。这种情况会无限期持续下去。因此,正弦驱动力的作用是最终使系统以驱动频率振荡(尽管振幅减小),而不是以系统自己喜欢的频率振荡。你可以用不同的强制频率来尝试。例如,如果使用ω d /2,阻尼系统频率的一半,振荡的频率最终将是初始非受迫频率的一半。结论是,如果你强迫一个系统以不同于其固有频率的频率振荡,它仍然会振荡,但振幅会减小。

现在,通过将强制向量修改为以下形式,使强制频率恰好等于ω d :

var forcing = new Vector2D(200*Math.cos(omegad*t)+200*Math.sin(omegad*t),0);

当您运行代码时,您会看到振荡很快在原始频率ω d 处达到平衡,并保持较大的恒定振幅而不衰减。这个系统现在是“快乐的”,因为它被强迫以它喜欢的频率振荡。因此,它很快稳定下来,并以大幅度振荡。因此,虽然阻尼降低了系统的能量,但强迫将能量放回系统并维持振荡。

当强迫频率等于振荡系统的固有频率时,我们就有了所谓的共振。它发生在很多情况下,从推动孩子的秋千到调整无线电电路。共振也会产生不良影响,比如周期性阵风引起的桥梁有害振动。事实上,阵风引起的共振是 1940 年华盛顿塔科马海峡大桥倒塌的罪魁祸首。

示例:随机驱动力

接下来,我们来试试随机强制。这就像替换一行代码一样简单。让我们先试试这个:

var forcing = new Vector2D(1000*Math.random(),0);

这将在 x 轴正方向(向右)施加高达 1,000 个单位的随机力。如果这是唯一的力,它会使物体向右飞去,再也看不见了。但是回复力的存在(顾名思义)将物体拉回到吸引子。事实上,因为恢复力与吸引子的位移成正比,物体移动得越远,被拉回的力就越大。这创造了一个有趣的效果,你肯定想尝试一下。因为我们使力只作用于右边,所以物体大部分时间都在吸引子的右边,尽管偶尔会被稍微拉向左边。

要使吸引子周围的振荡更加对称,只需将该行代码改为:

var forcing = new Vector2D(1000*(Math.random()-0.5),0);

这会产生一个 x 分量在–500 和+500 之间的随机力,其中负号表示力指向左侧而不是右侧。运行代码,你会得到一个非周期振荡器(物体无规律地振荡)。

最后,通过将同一行代码修改为以下内容,让物体在 2D 的随机力的作用下振动:

var forcing = new Vector2D(1000*(Math.random()-0.5),1000*(Math.random()-0.5));

如果你运行代码,你会看到系统很快进入一种状态,其中 x 和 y 方向的振荡幅度相当,物体在吸引子周围盘旋(见图 8-8 )。在每个时间步长,对象被踢向不同的方向,但也会被拉向吸引子。不管作用力有多大,它都不能逃脱,因为它走得越远,被拉回来的力就越大。这是一个有趣的效果。这是某种疯狂的随机轨道运动。或者甚至像蜜蜂在花周围嗡嗡叫。一定要玩那个!

A978-1-4302-6338-8_8_Fig8_HTML.jpg

图 8-8。

Oscillator with random forcing

重力作为驱动力:蹦极

现在让我们来看一些不同的东西:蹦极!有人从桥上或类似的建筑上跳下来,绳子的另一端系在建筑上的固定支架上。弹力绳有一个自然的未拉伸长度,比如说cordLength。因此,如果跳线与固定支架的距离小于cordLength,则重力和阻力是跳线上仅有的力。但是一旦这个距离超过cordLength,弹性(弹簧)力就会生效,将跳线拉回。

所以这里重力作为驱动力,所以 f(t) = mg,这里 m 是 jumper 的质量。这是一种持续的压力,不像之前讨论的那种压力。主要的阻尼机制是由空气中的阻力提供的,因此可以使用Forces.drag()方法来实现。最后,仅当跳线与固定支架的距离超过电线的自然未拉伸长度cordLength时,才施加弹簧力。这个距离最初可能小于cordLength(在跳跃者从一些支撑结构上跳下之前),但也可能在跳跃者跳回足够高的时候。

因为这个例子与前几个有一点不同,我们将列出完整的源代码,然后讨论要点。该文件简称为bungee.js:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var jumper;

var fixedPoint;

var displ = new Vector2D(0,0);

var center = new Vector2D(0.5*canvas.width,50);

var mass = 90;

var g = 20;

var kDamping = 0.02;

var kSpring = 25;

var cordLength = 100;

var t0, dt;

var acc, force;

var animId;

window.onload = init;

function init() {

// create a bungee jumper

jumper = new StickMan();

jumper.mass = mass;

jumper.pos2D = center;

jumper.draw(context);

// create a fixedPoint

fixedPoint = new Ball(2,'#000000');

fixedPoint.pos2D = center;

fixedPoint.draw(context);

// make the ball move

t0 = new Date().getTime();

animFrame();

};

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

drawSpring(jumper);

moveObject(jumper);

calcForce(jumper);

updateAccel();

updateVelo(jumper);

}

function drawSpring(obj){

fixedPoint.draw(context);

context.save();

if (displ.length() > cordLength){

context.lineStyle = '#999999';

context.lineWidth = 2;

}else{

context.lineStyle = '#cccccc';

context.lineWidth = 1;

}

context.moveTo(center.x,center.y);

context.lineTo(obj.x,obj.y);

context.stroke();

context.restore();

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

obj.draw(context);

}

function calcForce(obj){

displ = obj.pos2D.subtract(center);

var gravity = Forces.constantGravity(mass,g);

var damping = Forces.drag(kDamping,obj.velo2D);

var extension = displ.subtract(displ.unit().multiply(cordLength));

var restoring;

if (displ.length() > cordLength) {

restoring = Forces.spring(kSpring,extension);

}else{

restoring = new Vector2D(0,0);

}

force = Forces.add([gravity, damping, restoring]);

}

function updateAccel(){

acc = force.multiply(1/mass);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

请注意,我们在这里创建了两个对象:一个固定点和一个跳线。jumper 是StickMan的一个实例,一个男人的穷人版,欢迎你通过编辑文件stickman.js来改进。

首先,注意我们为弹簧常数kSpring (25)使用的大值。这是因为我们在bungee.js中将跳线的质量指定为 90,而不是之前所有模拟中的 1。这反映了一个事实,如果你想让一个大家伙在一根弹性绳索的末端摆动,绳索最好是相当硬的!

接下来看看calcForce()方法,我们看到我们包括了重力、阻尼和回复力,如前所述。前两个通常以简单的方式实现。与前面的例子相比,我们计算恢复力的方法有两个不同之处。首先,必须用来计算回复力的位移矢量现在是弹性索(延伸部分)端点的位移矢量,正如本章开头所解释的,而不是物体离开固定支座的位移。因此,我们首先在下面一行中计算作为向量的延伸:

var extension = displ.subtract(displ.unit().multiply(cordLength));

请注意对新添加到Vector2D对象中的unit()方法的巧妙使用,其中vec.unit()返回一个长度为unit的向量,其方向为向量vec

然后我们提供extension作为Forces.spring()中的第二个参数。

第二个区别是,只有当物体离支撑物的距离大于绳子的长度时,回复力才不为零。那就等于说displ.length() > cordLength。这解释了if块中的代码:

if (displ.length() > cordLength) {

restoring = Forces.spring(kSpring,extension);

}else{

restoring = new Vector2D(0,0);

}

另一个新特性是我们包含了一个在move()方法中调用的drawSpring()方法,该方法在每个时间步长绘制一条线来表示弹性线。当绳子不拉伸时,线被拉得越来越细,越来越模糊。像往常一样,运行代码并随意使用参数。物体的初始位置与固定支撑的位置相同。这使得物体沿垂直直线振荡(这使得振荡 1D)。要使其成为 2D,只需在bungee.js中改变物体的初始 x 坐标即可;例如:

object.pos2D = new Vector2D(300,50);

演示的截图如图 8-9 所示。

A978-1-4302-6338-8_8_Fig9_HTML.jpg

图 8-9。

A bungee jump simulation

示例:用户交互的驱动力

用户交互也可以被视为某种强迫。在下面的例子中,我们将构建一个模拟,其中用户可以单击并拖动对象,然后在任何位置释放它。这将扰乱系统,使其振荡。

修改蹦极模拟来做我们想要的事情是相当简单的。我们将把这个新文件称为dragging-oscillations.js。在其他修改中,我们将对象的质量更改为 1,并添加代码以支持拖动。如果您想了解如何做到这一点,请查看该文件——对于我们目前的目的来说,细节并不特别有趣。这里的要点是,代码更改使您,即用户,能够随时通过拖动和释放对象来施加驱动力。因此,这种强迫不是由一个数学函数确定的,而是人类随机互动的产物。

然而,正如calcForce()方法所反映的那样,基本的物理原理并没有改变:

function calcForce(obj){

displ = obj.pos2D.subtract(center);

var gravity = Forces.constantGravity(mass,g);

var damping = Forces.drag(kDamping,obj.velo2D);

var extension = displ.subtract(displ.unit().multiply(springLength));

var restoring = Forces.spring(kSpring,extension);

force = Forces.add([gravity, damping, restoring]);

}

calcForce()中,我们像以前一样包括重力,同时使用Forces.damping()函数包括阻尼。然后,我们计算弹簧的伸长量,并像之前一样将其输入回复力。然而,因为这是一个也可以被压缩的弹簧,而不是一个弹性绳,所以即使伸长量为负,我们也会施加回复力,所以我们已经删除了前面蹦极例子中的if语句。

运行代码,你会看到物体在重力作用下下落,然后又被弹簧拉回来,由于阻尼作用,物体的振动幅度减小。如果你拖动物体并释放它,由于外力的作用,它会再次振荡。

耦合振荡器:多个弹簧和物体

到目前为止,在所有的例子中,我们只考虑了一个物体和弹簧系统。当您将多个对象和弹簧系统耦合在一起时,效果会变得更加有趣。事实上,您可以基于质量由弹簧连接的对象创建扩展系统。在下一小节中,您将看到一个可以做什么的示例。然后我们将在第十三章中看到更复杂的例子。

示例:由弹簧连接的对象链

你将创建的东西如图 8-10 所示:由弹簧连接的许多球,第一个球连接到一个支架上。然后支架会四处移动,导致悬浮球链也四处移动。

A978-1-4302-6338-8_8_Fig10_HTML.jpg

图 8-10。

A chain of objects held together by springs

将要建模的力包括重力、阻尼和恢复力。弹簧将有一个自然的长度,就像最后两个例子一样。因此,每个球都会受到重力、阻尼、上方弹簧产生的回复力和下方弹簧产生的回复力的作用(最后一个球除外)。弹簧本身将被假定为无质量的。

代码在一个名为coupled-oscillations.js的文件中。我们首先列出变量声明和init()函数:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var balls;

var support;

var center = new Vector2D(0.5*canvas.width,50);

var g = 20;

var kDamping = 0.5;

var kSpring = 10;

var springLength = 50;

var numBalls = 6;

var t0, t, dt;

var acc, force;

var animId;

window.onload = init;

function init() {

// create a support

support = new Ball(2,'#000000');

support.pos2D = center;

support.draw(context);

// create a bunch of balls

balls = new Array();

for (var i=0; i<numBalls; i++){

var ball = new Ball(15,'#0000ff',1,0,true);

ball.pos2D = new Vector2D(0.5*canvas.width,100+60*i);

ball.pos2D = new Vector2D(0.5*canvas.width+60*i,100+60*i);

ball.draw(context);

balls.push(ball);

}

// make the balls move

t0 = new Date().getTime();

t = 0;

animFrame();

}

和前面两个例子一样,我们首先创建一个Ball对象作为支撑。然后我们创建一大堆Ball对象,并将它们放入一个名为balls的数组中。

我们对代码的动画部分做了一些结构上的修改,为了便于参考,我们也在这里完整地复制了这些修改:

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

t += dt;

move();

}

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

drawSpring();

for (var i=0; i<numBalls; i++){

var ball = balls[i];

moveObject(ball);

calcForce(ball,i);

updateAccel(ball.mass);

updateVelo(ball);

}

}

function drawSpring(){

support.draw(context);

context.save();

context.lineStyle = '#999999';

context.lineWidth = 2;

context.moveTo(center.x,center.y);

for (var i=0; i<numBalls; i++){

var X = balls[i].x;

var Y = balls[i].y;

context.lineTo(X,Y);

}

context.stroke();

context.restore();

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

obj.draw(context);

}

function calcForce(obj,num){

var centerPrev;

var centerNext;

if (num > 0){

centerPrev = balls[num-1].pos2D;

}else{

centerPrev = center;

}

if (num < balls.length-1){

centerNext = balls[num+1].pos2D;

}else{

centerNext = obj.pos2D;

}

var gravity = Forces.constantGravity(obj.mass,g);

var damping = Forces.damping(kDamping,obj.velo2D);

var displPrev = obj.pos2D.subtract(centerPrev);

var displNext = obj.pos2D.subtract(centerNext);

var extensionPrev = displPrev.subtract(displPrev.unit().multiply(springLength));

var extensionNext = displNext.subtract(displNext.unit().multiply(springLength));

var restoringPrev = Forces.spring(kSpring,extensionPrev);

var restoringNext = Forces.spring(kSpring,extensionNext);

force = Forces.add([gravity, damping, restoringPrev, restoringNext]);

}

function updateAccel(mass){

acc = force.multiply(1/mass);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

注意move()方法的变化,它循环遍历balls数组的元素,依次对每个球应用moveObject(), calcForce(), updateAccel()updateVelo()方法。我们修改了方法calcForce(),加入了另一个参数Number。在move()中,当我们调用calcForce()时,我们包含一个额外的参数i,它是数组中相关粒子的索引号。为什么我们需要这样做?对于每一个球,我们需要计算与它相连的弹簧所施加的弹力。因此,如果我们确切地知道我们在处理哪个球,以及哪个球在它之前和之后,这将是很方便的。这就是额外参数的作用。

calcForce()中,我们通过首先定义变量centerPrevcenterNext,考虑到每个物体由于其最近的邻居而经受恢复力的事实。我们通过使用数组索引num给出当前球之前和之后球的位置,数组索引作为参数在calcForce()中传递。对于第一个球,centerPrev给出了固定支撑的位置;对于最后一个球,centerNext给出当前球的位置。然后,我们使用centerPrevcenterNext的值,以通常的方式计算前一个球和下一个球的位移矢量。然后,根据弹簧springLength的自然长度,计算出每个弹簧的弹簧延伸长度,如最后两个示例所示。最后,使用Forces.spring()计算每个弹簧产生的弹簧力,并将其添加到重力和阻尼力中。

move()方法调用而来的drawSpring()方法,现在从固定支撑开始通过每个球画一条线。

用不同的初始位置运行代码,看看球是如何在重力和弹簧力的作用下重新排列到它们的平衡位置的。

同样,不要害怕尝试。例如,添加下面的方法moveSupport()并在move()中调用它,使支撑正弦振动,同时拖动链条:

function moveSupport(){

support.x = 100*Math.sin(1.0*t)+0.5*canvas.width;

center = support.pos2D;

}

这给了你类似于图 8-10 中所示的东西。

试试不同的质量和不同的弹簧常数怎么样?还是允许球被拖动?或者看看能不能弄清楚怎么做封闭链。我们不打扰你了。

摘要

我们希望你能从本章的例子中获得很多乐趣。正如我们在本章开始时所说,弹簧不仅有趣,而且非常有用。弹簧可以以多种方式创造性地使用,例如,您可以将它们应用于速度,以便对象平稳地加速和减速,以获得给定的“平衡”速度。希望你能找到它们的许多用途。你的想象力是极限。在本书的后面,我们肯定会再次碰到春天。现在,是时候在下一章讨论一些不同的东西了:旋转运动。

九、向心力:旋转运动

从旋转木马到行星的轨道和自转,圆周运动和自转运动在自然界和人造机器中都很普遍。为了分析和模拟这种类型的运动,我们需要一些特殊的工具:第五章中介绍的线性运动学和动力学需要扩展到旋转运动。简而言之,这就是本章将要开始探讨的内容。

本章涵盖的主题包括以下内容:

  • 匀速圆周运动的运动学:旋转运动的运动学可以类比线性运动学来发展。
  • 向心加速度和向心力:匀速圆周运动的物体仍然受到加速度,因此受到一个指向旋转中心的力。
  • 非匀速圆周运动:如果除了向心分量之外还有一个切向分量的力,就会发生这种情况。

本章涵盖的材料也与第十三章中的刚体运动处理相关。

匀速圆周运动的运动学

在第四章中,我们用位移、速度和加速度等概念设计了描述运动的框架。正如我们在那里所说,这个框架在技术上被称为运动学。这些概念足以描述所谓的线性或平移运动。但是为了分析圆周或旋转运动,我们需要引入额外的概念。

事实上,旋转运动学的概念可以完全类比线性运动学的概念来发展。首先,我们有位移、速度和加速度的旋转模拟,分别称为角位移、角速度和角加速度。所以让我们从看它们开始。

角位移

回想一下,在第四章中,位移被定义为在指定方向上移动的距离。那么角位移的等价定义是什么呢?

首先,我们需要弄清楚什么概念等同于旋转运动中的距离和方向。看一下图 9-1 ,它比较了平移运动和旋转运动。在左边,一个物体沿给定方向移动距离 d。在右边,一个物体以给定的方向(在这种情况下是顺时针方向)绕某个中心旋转了θ角。所以很明显,在旋转运动学中,角度代替了距离,旋转感(顺时针或逆时针)代替了方向。

A978-1-4302-6338-8_9_Fig1_HTML.jpg

图 9-1。

Translation and rotation compared for particle motion

产生角位移的另一种情况是刚体绕固定轴旋转时,如图 9-2 所示。在这种情况下,刚体上的每个点都绕旋转轴移动相同的角度θ。

A978-1-4302-6338-8_9_Fig2_HTML.jpg

图 9-2。

Rotation of a rigid body about an axis

因此,在任何一种情况下(粒子绕中心旋转或刚体绕轴旋转),想法都是一样的。因此,我们将角位移定义为一个物体围绕一个特定的中心以特定的方式运动的角度。

这个定义意味着角位移的大小是一个角度。通常的惯例是用弧度来表示角度,所以我们用它来表示角位移。

角速度

与线速度类似,角速度被定义为角位移随时间的变化率。角速度的常用符号是ω(希腊字母 omega),因此它在微积分符号中的定义方程是这样的:

A978-1-4302-6338-8_9_Figa_HTML.jpg

对于较小的时间间隔δt 和角度变化δθ,它可以用以下离散形式表示:

A978-1-4302-6338-8_9_Figb_HTML.jpg

后一个等式也可以写成以下形式:

A978-1-4302-6338-8_9_Figc_HTML.jpg

等式的最后一种形式是我们在代码中最常使用的,用来计算在时间间隔δt 内角速度ω引起的角位移。

角速度用弧度每秒(rad/s)来度量,因为我们用角度除以时间来计算它。

你会经常遇到角速度恒定的情况。比如做匀速圆周运动的质点,虽然它的线速度不恒定(因为它的运动方向改变了),但是它的角速度是恒定的。另一个例子是一个匀速旋转的物体,比如地球。

角加速度

角加速度是角速度的变化率。它由希腊字母α (alpha)表示,其定义等式如下:

A978-1-4302-6338-8_9_Figd_HTML.jpg

角加速度的定义意味着,如果角速度不变,角加速度为零。所以匀速圆周运动的物体,角加速度为零。这并不意味着它的线性加速度为零。实际上,线加速度不可能为零,因为运动的方向(因此线速度)是不断变化的。我们将在本章稍后回到这一点。

周期、频率和角速度

匀速圆周运动或旋转是周期运动的一种(见第三章中的“基础三角学”),因为物体以固定的间隔回到同一位置。事实上,正如在第三章中所讨论的,圆周运动与振荡、正弦运动有关。当时,我们定义了振荡的角频率、频率和周期,并表明它们与以下等式相关:

A978-1-4302-6338-8_9_Fige_HTML.jpg

A978-1-4302-6338-8_9_Figf_HTML.jpg

同样的关系也适用于圆周运动,从图 3-16 中可以明显看出,图 3-16 说明了这两种运动。对于圆周运动,ω现在是角速度;它与相应正弦振荡的角频率相同。同样,f 是旋转的频率(每秒的转数)。最后,T 是旋转周期(完成一次完整旋转所需的时间)。

例如,让我们使用公式ω = 2π/T 来计算地球绕太阳运行的角速度。我们知道周期 T 约为 365 天。以秒为单位,它具有以下值:

t = 365×24×60×60s≈3.15×107s

因此,角速度具有以下值:

φ=2π/t =2π/(3.15×107)≈2.0×10–7rad/s

这是以弧度为单位的角度,地球每秒钟绕太阳旋转一周。

现在让我们用同样的公式计算地球绕轴旋转的角速度。在这种情况下,公转周期为 24 小时,因此 T 具有以下值:

T = 24 × 60 × 60 秒= 86400 秒

因此,角速度如下:

ω=2π/t =2π/86400 = 7.3×105rad/s

请注意,这是地球上每个点的角速度,包括地球表面和地球内部。这是因为地球是一个刚体,所以当它绕轴旋转时,每个点都在同一时间旋转相同的角度。但是,不同的点有不同的线速度。角速度和线速度有什么关系?是时候找出答案了!

角速度和线速度的关系

匀速圆周运动物体的线速度和角速度之间有一个简单的关系。为了得出这种关系,我们首先注意到,物体绕半径为 r 的圆移动一个角度δθ的位移δs 是旋转中心处对着该角度的弧的长度。因此,它由下式给出:

A978-1-4302-6338-8_9_Figg_HTML.jpg

这是根据第三章给出的弧度定义得出的:一个弧度是一个长度等于圆半径的弧对着圆心的角度。所以,按比例,在中心成δθ弧度角的圆弧的长度一定是 rδθ(见图 9-3 )。

A978-1-4302-6338-8_9_Fig3_HTML.jpg

图 9-3。

Relationship between length of arc and angle at center

现在,我们可以将这个等式两边除以物体移动通过该角度的时间间隔δt:

A978-1-4302-6338-8_9_Figh_HTML.jpg

但是根据定义,δs/δt = v 和δθ/δt =ω。因此,我们有以下内容:

A978-1-4302-6338-8_9_Figi_HTML.jpg

这个公式给出了物体以角速度ω在半径为 r 的圆周上运动的线速度。

它也适用于绕轴旋转的刚体。在这种情况下,它给出了距离旋转轴 r 处的线速度。对于给定的角速度ω,那么公式告诉我们,速度与离轴的距离成正比(离得越远,线速度越快)。

让我们将这个公式应用到上一节讨论的例子中。首先,我们将计算地球绕太阳运行的线速度。为了解决这个问题,我们需要知道地球轨道的半径(地球到太阳的距离):1.5 × 10 11 米。我们已经计算出了地球在其轨道上的角速度。它是 2.0×10–7弧度/秒

因此,地球的轨道速度由下式给出:

v = rω= 1.5×1011×2.0×10–7= 3×104m/s

这是 30 千米/秒,或 100 000 千米/小时,大约是飞机典型速度的 100 倍!

作为第二个例子,因为地球的半径是 6.4 × 10 6 米,其绕自身轴的角速度是 7.3×10–5rad/s(正如我们在上一节中计算出的),地球表面赤道上任意一点由于地球自转而产生的速度值如下:

v = rω= 6.4×106×7.3×10–5m/s≈470m/s

这大约是 1500 公里/小时,或者大约是飞机典型速度的一倍半。

示例:滚动的轮子

我们现在知道的足够多,可以把一些简单的例子放在一起。我们的第一个例子将是创造滚动的轮子。与前几章中的大多数例子不同,这一个将不涉及任何动力学,而仅涉及运动学。所以我们不会用力来计算运动,而只是通过直接指定速度来告诉物体如何运动。

首先,让我们创建一个轮子。我们希望轮子看起来如图 9-4 所示,有一个内轮缘和一个外轮缘,以及一些等间距的辐条(这样我们可以看到它旋转)。

A978-1-4302-6338-8_9_Fig4_HTML.jpg

图 9-4。

A rolling wheel

下面是一个完成这项工作的Wheel对象:

function Wheel(innerRadius,outerRadius,numSpokes){

this.ir = innerRadius;

this.or = outerRadius;

this.nums = numSpokes;

this.x = 0;

this.y = 0;

this.vx = 0;

this.vy = 0;

}

Wheel.prototype = {

get pos2D (){

return new Vector2D(this.x,this.y);

},

set pos2D (pos){

this.x = pos.x;

this.y = pos.y;

},

get velo2D (){

return new Vector2D(this.vx,this.vy);

},

set velo2D (velo){

this.vx = velo.x;

this.vy = velo.y;

},

draw: function (context) {

var ir = this.ir;

var or = this.or;

var nums = this.nums;

context.save();

context.fillStyle = '#000000';

context.beginPath();

context.arc(this.x, this.y, or, 0, 2*Math.PI, true);

context.closePath();

context.fill();

context.fillStyle = '#ffffaa';

context.beginPath();

context.arc(this.x, this.y, ir, 0, 2*Math.PI, true);

context.closePath();

context.fill();

context.strokeStyle = '#000000';

context.lineWidth = 4;

context.beginPath();

for (var n=0; n<nums; n++){

context.moveTo(this.x,this.y);

context.lineTo(this.x+ir*Math.cos(2*Math.PI*n/nums),this.y+ir*Math.sin(2*Math.PI*n/nums));

}

context.closePath();

context.stroke();

context.restore();

}

}

Wheel的构造函数有三个参数:内径、外径和辐条数。

现在我们将创建一个Wheel实例并让它滚动。从本书的可下载文件中,看看wheel-demo.js中的代码:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var r = 50;       // outer radius

var w = 1;        // angular velocity in radians per second

var dt = 30/1000; // timestep = 1/FPS

var fac = 1;      // slipping/sliding factor

var wheel = new Wheel(r-10,r,12);

wheel.x = 100;

wheel.y = 200;

wheel.draw(context);

var v = fac*r*w;     // v = r w

var angle = 0;

setInterval(onTimer, 1/dt);

function onTimer(evt){

wheel.x += v*dt;

angle += w*dt;

context.clearRect(0, 0, canvas.width, canvas.height);

context.save();

context.translate(wheel.x,wheel.y);

context.rotate(angle);

context.translate(-wheel.x,-wheel.y);

wheel.draw(context);

context.restore();

}

我们创建一个Wheel实例,然后设置一个调用onTimer()函数的setInterval()循环,该函数在每个时间步增加轮子的位置和角度方向。这是使用线速度v和角速度w通过以下公式完成的:

wheel.x += v*dt;

angle += w*dt;

第一个公式使用速度值v,该值是使用该公式预先计算的:

v = fac*r*w*;

这基本上是公式 v = rω,但包括了一个附加系数fac来模拟滑动或滑行(打滑)的影响,如果fac = 1为纯滚动。

第二个公式来自角速度的定义。变量angle表示车轮需要转动的角度。实际的转向由onTimer()中的其余代码处理。问题是没有办法旋转画布元素上的单个对象——您必须旋转整个画布!我们在下面的代码行中使用了context.rotate()方法:

context.rotate(angle);

然而,仅这样做将围绕画布原点(0,0)旋转画布,画布原点是画布元素的左上角。我们想围绕物体的中心旋转它。为了实现这一点,我们首先使用context.translate()方法移动画布,使其原点在旋转前位于对象的中心。旋转画布后,在绘制轮子之前,我们将画布平移回其原始位置:

context.translate(wheel.x,wheel.y);

context.rotate(angle);

context.translate(-wheel.x,-wheel.y);

wheel.draw(context);

请注意,整个代码块都包含在一对context.save()context.restore()命令中,以防止画布转换影响随后可能在同一画布上绘制的其他对象。

最后,dt是时间步长,被赋予 0.03 的值。这是对连续调用onTimer()的时间间隔的估计。我们期望dt大约等于 1/FPS,其中 FPS 是由setInterval()函数产生的帧速率,我们将其设置为 30。我们重申这只是近似的(参考第二章对setInterval()的讨论),但是对于这个简单的演示来说已经足够好了。

fac = 1运行代码,你会看到轮子移动,这样它的平移速度就和它的旋转速率一致了。这给人一种纯粹滚动的感觉,不会打滑。如果您将fac的值更改为小于 1,比如 0.2,车轮相对于其旋转速度向前移动的速度不够快;它看起来像陷在泥里,正在往下滑。如果你给fac一个大于 1 的值,比如说 5,轮子向前移动的速度会比它单纯旋转的速度快:它看起来像是在冰上打滑。

示例:围绕旋转地球的卫星

我们在这个例子中想要实现的本质上很简单:一个卫星在围绕旋转的地球的圆形轨道上的动画。问题是,我们希望卫星在轨道上运行,以便它总是在地球上的同一个地方。这被称为地球静止轨道;正如你可能猜到的,它对电信和间谍活动非常有用。

同样,这个例子只涉及运动学,不涉及动力学(没有力)。在这一章的后面,我们将加入力。

完整代码在文件satellite-demo.js中,在此复制:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var earth;

var satellite;

var r, x0, y0, omega;

var angle = 0;

window.onload = init;

function init() {

// create a stationary earth

earth = new Ball(70,'#0099ff',1000000,0,true,true);

earth.pos2D = new Vector2D(400,300);

earth.angVelo = 0.4;

earth.draw(context);

// create a moving satellite

satellite = new Satellite(8,'#0000ff',1);

satellite.pos2D = new Vector2D(600,300);

satellite.angVelo = earth.angVelo;

satellite.draw(context);

// set params

r = satellite.pos2D.subtract(earth.pos2D).length();

omega = earth.angVelo;

x0 = earth.x;

y0 = earth.y;

// make the satellite orbit the earth

t0 = new Date().getTime();

t = 0;

animFrame();

};

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

t += dt;

move();

}

function move(){

satellite.pos2D = new Vector2D(r*Math.cos(omega*t)+x0,r*Math.sin(omega*t)+y0);

angle += omega*dt;

context.clearRect(0, 0, canvas.width, canvas.height);

rotateObject(earth);

rotateObject(satellite);

}

function rotateObject(obj){

context.save();

context.translate(obj.x,obj.y);

context.rotate(angle);

context.translate(-obj.x,-obj.y);

obj.draw(context);

context.restore();

}

大部分代码都很简单,但是有一些值得注意的新特性。我们创建一个 E arth和一个satellite对象。这些物体的质量在这里是不相关的,因为没有动力学。您可以使用任何您想要的值,动画将以完全相同的方式运行。注意 E arthBall的一个实例,而satelliteSatellite的一个实例。后一个对象基本上类似于Ball,增加了在卫星上绘制三条线的代码,使其具有老式的类似 Sputnik 的外观。BallSatellite对象都有一个名为angVelo的新属性,它存储它们实例的角速度值。我们还在Ball中引入了一个可选的布尔参数line,如果设置为true,将在Ball实例上绘制一对交叉线。这样可以观察到球的旋转。

我们给地球一个非零的角速度。然后我们赋予卫星相同的角速度。卫星到地球的距离被计算出来并存储在变量r中。这个距离不变,因为轨道是圆形的。然后地球的角速度和 x、y 位置分别存储在变量omegax0y0中。

动作发生在move()方法中。我们在这里所做的就是告诉卫星它在任何时候的位置,使用这些公式:

A978-1-4302-6338-8_9_Figj_HTML.jpg

A978-1-4302-6338-8_9_Figk_HTML.jpg

你可能会从第三章中回忆起,这样使用 cos 和 sin,会产生角频率为ω,半径为 r 的圆周运动,加上 x0 和 y0(地心坐标)就使卫星绕地球运行。另一个重要的是,ω选择为等于地球角速度。这使得卫星以与地球自转完全相同的速度绕地球旋转。运行模拟,你会发现事实确实如此。

此外,由于卫星绕其轴的角速度与地球相同,其天线始终指向地球(见图 9-5 )。

A978-1-4302-6338-8_9_Fig5_HTML.jpg

图 9-5。

A spy satellite orbiting the Earth

产生地球和卫星旋转的代码在rotateObject()方法中,从前面的例子看应该很熟悉。注意,这里使用一个变量angle来计算两个对象在每个时间步必须旋转的角度。这是因为它们具有相同的角速度。

机警的读者会注意到,我们把这个例子称为动画,而不是模拟。这是因为我们基本上告诉卫星如何移动,而不是规定作用在它身上的力,让它们决定卫星的运动。虽然动画确实有一些物理真实感,但并不完全真实。一个原因是,我们已经迫使卫星以与地球绕其轴自转相同的速率(相同的角速度)绕地球旋转。你可以改变卫星离地球中心的距离,但它仍会以同样的速度运行。在现实中,卫星只能在离地球中心特定距离的地方这样做;换句话说,所有的地球同步卫星都必须与地球保持相同的距离!为什么会这样还不清楚,但是下一节将会帮助你澄清这一点,并帮助你在动画中构建这个特性。

向心加速度和向心力

既然我们已经讨论了基础知识,是时候继续讨论一些稍微难一点的概念了:向心加速度和向心力。实际上,它们并不难,但是经常被误解。因此,在我们进行的过程中,我们将试图澄清一些可能的混淆。

向心加速度

正如我们在角速度一节中所指出的,一个做圆周运动(或一般做曲线运动)的物体,其线速度是随时间变化的,因为它的方向一直在变化。因此,从加速度作为速度变化率的定义来看,这意味着它具有非零加速度——我们从未说过加速度仅仅意味着速度(速度)大小的变化;这也可能意味着方向的改变。

可能一点都不明显,但是对于做圆周运动的物体来说,这个加速度其实是指向圆心的。因此,它被称为向心加速度(来自拉丁语:centr = "center,"和 petere = "to seek ")。理解这一点的一种方法是将中心想象成拉动物体,从而不断改变其方向(见图 9-6 )。我们一会儿会看到如何计算向心加速度。

A978-1-4302-6338-8_9_Fig6_HTML.jpg

图 9-6。

Centripetal acceleration

向心加速度不能和角加速度混淆。正如本章前面所解释的,角加速度指的是角速度的变化率。在匀速圆周运动的情况下,角加速度为零是因为角速度不变(这就是“匀速”的意思),但向心加速度不为零。向心加速度是线速度变化率的一种度量,此时线速度仅改变方向,而其大小保持不变。

向心加速度、速度和角速度

我们如何计算向心加速度?有一个简单的公式。这不容易证明,所以最好的事情就是相信我们!下面是公式的一种形式:

A978-1-4302-6338-8_9_Figl_HTML.jpg

参考图 9-6 ,这个公式告诉我们一个物体以速度 v 做半径为 r 的圆周或圆弧运动时的向心加速度,由此我们推导出如果速度大,加速度也大(对于同样的半径);而如果半径小,加速度就大(对于同样的速度量级)。

记住 v = rω,您可以用 r 和ω替换前面公式中的 v,得出公式的另一种形式:

A978-1-4302-6338-8_9_Figm_HTML.jpg

这个公式现在告诉我们,角速度越大,向心加速度越大(半径相同)。但它也告诉我们,对于相同的角速度,半径越大,向心加速度越大。这与我们之前所说的公式的另一种形式并不矛盾,但乍一看似乎令人困惑!关键的一点是,这里的线速度的大小并没有被假定为不同半径的常数。事实上,如果角速度不变,那么线速度的大小一定随半径增加,因为 v = rω。

前面公式的一个结果是,对于绕轴旋转的刚体,离轴越远的点加速度越大(因为刚体上所有点的角速度都一样),速度也越大(因为 v = rω)。

向心力

我们已经看到,任何做匀速圆周运动的物体都必须经历一个朝向圆周轨迹中心的加速度。因此,根据牛顿第二定律,一定有一个合力也指向中心。这就是所谓的向心力。

向心力是使物体保持圆周运动所需的力。它需要由某个物理代理持续提供;否则,物体不能保持圆周运动。

举个例子,一个被绳子绕着旋转的物体从绳子的张力中获得向心力。围绕圆形轨道运动的汽车从地面对轮胎施加的摩擦力中获得向心力。由于太阳的引力,围绕太阳运行的行星受到向心力的作用。

解出向心力的公式非常容易。因为 F = ma,你只需要将向心加速度的公式乘以 m(运动物体的质量)就可以得到:

A978-1-4302-6338-8_9_Fign_HTML.jpg

为了得到这个:

A978-1-4302-6338-8_9_Figo_HTML.jpg

在向量形式中,第一个公式可以写成如下:

A978-1-4302-6338-8_9_Figp_HTML.jpg

或者可以这样写,其中 r u 是运动物体相对于中心的位置矢量 r 方向的单位矢量:

A978-1-4302-6338-8_9_Figq_HTML.jpg

负号的出现是因为力指向中心(与向量 r 相反)。类似的向量形式也适用于公式 F = mrω 2

向心力的常见误解

向心力可能是物理学中最容易被误解的概念之一。让我们澄清一些困惑。

  • 向心力不是一种独特的“类型”的力,就像重力和摩擦力是力的类型一样。它只是使一个物体做圆周运动所必须存在的力。不同的力可以起到向心力的作用,包括重力和摩擦力。
  • 有时人们会错误地谈论需要“平衡”向心力。例如,一个常见的误解是,围绕太阳运行的轨道上的行星的引力平衡了向心力。出于几个原因,这是没有意义的。第一,向心力一定是指向太阳的,引力也是,所以两者方向一致。第二,如果这两个力真的平衡了,行星上的合力将为零;因此,它的加速度为零,所以它必须以恒定速度沿直线运动(根据牛顿第一运动定律)。第三,向心力甚至不是一种“类型”的力。正确的思路是,引力提供了行星绕太阳运行所必需的向心力。
  • 你可能还听说过一个叫离心力的东西,它与向心力大小相同,但作用方向相反(远离旋转中心)。离心力不是真正的力,而是所谓的伪力。它起源于试图在牛顿定律的框架内分析运动,但是是从旋转参照系的角度。一个例子是从地球上的参考系(当然是旋转的)来描述大气运动。虽然离心力在分析这类问题的物理学中肯定有它的位置,但它也能引起许多混乱。因此,我们的建议是避免考虑离心力。在本章后面的“例子:汽车绕弯道行驶”一节中,我们给出了一个离心力的例子的向心力解释。

示例:重温卫星动画

作为向心力概念应用的第一个例子,让我们回到卫星动画,并像承诺的那样给它注入更多的真实感。

这里向心力是由卫星上的重力提供的。因此,我们可以将向心力的表达式与引力的表达式等同起来,以获得以下等式,其中 M 是卫星质量,M 是地球质量,r 是卫星到地心的距离,ω是卫星的角速度:

A978-1-4302-6338-8_9_Figr_HTML.jpg

很容易重新排列这个等式,得到如下结果:

A978-1-4302-6338-8_9_Figs_HTML.jpg

这个公式告诉我们,为了以角速度ω绕轨道运行,卫星必须达到的半径。换句话说,卫星不可能在任何距离都有任何角速度;向心力决定了它必须以固定的角速度旋转,距离地球任意给定的距离!特别是,我们可以使用公式来计算它必须以与地球自转相同的角速度旋转的距离。如果你把 G、M(地球质量)和ω(我们之前算出的地球角速度)的值代入,你会发现地球静止轨道的半径是 42,400 公里,大约是地球半径的 6.6 倍。

回到我们的动画,我们需要让卫星意识到它与地球的距离和它绕地球的角速度之间的约束,如前面的等式所示。事实上,在动画中,我们将指定卫星的位置,从而指定到地球的距离 r,并希望计算出它的最终角速度ω,而不是将其设置为等于地球的角速度。

因此,我们需要一个用 r 表示的ω公式,利用上一个公式可以很容易地得出:

A978-1-4302-6338-8_9_Figt_HTML.jpg

这就是我们所需要的。我们复制了satellite-demo.js来创造satellite-demo2.js。如果您查看后一个文件,您会发现我们所做的主要更改是替换了这一行:

omega = earth.angVelo;

用下面一行:

omega = Math.sqrt(G*M/(r*r*r));

其中M是地球的质量(这里设为 1000000),引力常数G的值设为 1。我们现在也有单独的变量omegaEangleE来存储地球的角速度和角位移。

如果你现在用先前卫星和地球的位置和质量以及地球的角速度运行代码,你会看到卫星不再在地球静止轨道上。事实上,当它完成一个轨道时,它的触角已经指向错误的方向。如果使用前面的公式计算地球静止轨道的 r 值,给定值为 G = 1,M = 1000000,ω = 0.4,则 r = 184.2。这是卫星应该离地球中心的距离。假设地球位于(400,300),你只需要把卫星的位置换到 184.2 个单位以外的位置,比如satellite-demo2.js中的(584.2300):

satellite.pos2D = new Vector2D(584.2,300);

这样做,然后运行代码。你会看到卫星确实以和地球相同的角速度运行。

示例:重力作用下的圆形轨道

在本例中,我们回到完全动态模拟。事实上,我们已经在第六章的轨道模拟中做过了,我们模拟了一颗行星围绕太阳的运动。模拟已经“知道”万有引力是向心力,所以没有其他要补充的了。但是如果我们想要圆形轨道,我们可以用向心力公式,精确计算出我们需要给行星多大的速度。

同样,这只是一个向心力和引力表达式相等的问题。这次,我们用 F = mv 2 /r 的形式来表示向心力:

A978-1-4302-6338-8_9_Figu_HTML.jpg

稍加代数运算,我们就可以得出以下公式,这是行星在距离其中心 r 处围绕太阳运行的圆形轨道所需的速度大小:

A978-1-4302-6338-8_9_Figv_HTML.jpg

让我们把这个编码到轨道模拟的修改版本中。新的源文件名为circular-orbits.js,这里显示了完整的代码:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_bg = document.getElementById('canvas_bg');

var context_bg = canvas_bg.getContext('2d');

var planet;

var sun;

var m = 1;       // planet's mass

var M = 1000000; // sun's mass

var G = 1;

var t0,dt;

var acc, force;

window.onload = init;

function init() {

// create a stationary sun

sun = new Ball(70,'#ff9900',M,0,true);

sun.pos2D = new Vector2D(400,300);

sun.draw(context_bg);

// create a moving planet

planet = new Ball(10,'#0000ff',m);

planet.pos2D = new Vector2D(400,50);

var r = planet.pos2D.subtract(sun.pos2D).length();

var v = Math.sqrt(G*M*m/r);

planet.velo2D = new Vector2D(v,0);

planet.draw(context);

// make the planet orbit the sun

t0 = new Date().getTime();

animFrame();

};

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

moveObject(planet);

calcForce();

updateAccel();

updateVelo(planet);

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

context.clearRect(0, 0, canvas.width, canvas.height);

obj.draw(context);

}

function calcForce(){

force = Forces.gravity(G,M,m,planet.pos2D.subtract(sun.pos2D));

}

function updateAccel(){

acc = force.multiply(1/m);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

大部分代码应该非常熟悉。这个模拟的新特点是,我们精确地给出了行星在圆形轨道上运动所需的初速度。这是在用粗体突出显示的两行中完成的,使用的是我们刚刚导出的公式A978-1-4302-6338-8_9_Figw_HTML.jpg

使用前面代码清单中给出的初始值运行模拟,您会发现行星确实描述了围绕太阳的圆形轨道。为了让自己相信这在离太阳任意远的地方都有效,将行星的初始位置改为如下:

planet.pos2D = new Vector2D(400,150);

这使得行星更靠近太阳。如果您现在运行代码,您将看到行星以更快的速度绕太阳运行,但轨道仍然是圆形的。

注意速度必须是切向的,没有径向分量(见图 9-7 )。如果有任何径向速度分量,行星要么靠近太阳,要么远离太阳(它的轨道不会是圆形的)。例如,当行星的初始位置为(400,50)时,尝试以下操作:

planet.velo2D = new Vector2D(v/Math.sqrt(2), v/Math.sqrt(2));

这给了行星一个与之前相同大小的初始速度v,但是方向不同,因此它有一个朝向太阳的径向分量和一个切向分量。你会发现行星的轨道现在是高度椭圆形的,行星离太阳非常近,然后又远离太阳。

A978-1-4302-6338-8_9_Fig7_HTML.jpg

图 9-7。

Tangential velocity

鼓励你尝试不同的初始位置和速度,看看你得到的轨道类型。你应该会发现,如果不使用公式A978-1-4302-6338-8_9_Figx_HTML.jpg,通过简单的试错法,很难得到恰到好处的初始条件来产生甚至近似圆形的轨道!

示例:汽车在转弯处行驶

在下一个例子中,我们模拟了一辆汽车在环形轨道上的运动,并用它来演示汽车行驶过快时的打滑现象。

这里的基本物理原理是,当汽车绕着一个弯道或弯道行驶时,它需要这样做的向心力是由轮胎与路面接触时的摩擦力提供的(见图 9-8 )。

A978-1-4302-6338-8_9_Fig8_HTML.jpg

图 9-8。

Friction provides the centripetal force for a car to negotiate a bend

回到向心力的公式,汽车移动得越快,保持它绕过弯道所需的向心力就越大:

A978-1-4302-6338-8_9_Figy_HTML.jpg

顺便说一句,该公式还表明,向心力更大的更尖锐的弯曲,弯曲半径较小。

摩擦力是静摩擦力,如第七章中所述,其最大值由 f = C s N 给出,其中 N 是法向力(这里等于汽车重量 mg),C s 是轮胎和路面之间的静摩擦系数。因此,如果通过弯道所需的向心力超过最大静摩擦力,汽车将无法跟随弯道的曲率——它将打滑。

我们现在构建一个模拟,可以以自然的方式处理这个场景。设置如图 9-9 所示。

A978-1-4302-6338-8_9_Fig9_HTML.jpg

图 9-9。

The skidding car simulation

下面是实现它的代码(在文件car-demo.js中):

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_bg = document.getElementById('canvas_bg');

var context_bg = canvas_bg.getContext('2d');

var center = new Vector2D(canvas.width/2,canvas.height/2);

var car;

var mass = 90; // car mass

var g = 10;

var Cs = 1;

var angle = 0;

var omega;

var t0,dt;

var acc, force;

window.onload = init;

function init() {

// create a circular track, e.g. a roundabout

context_bg.fillStyle = '#cccccc';

context_bg.beginPath();

context_bg.arc(canvas.width/2, canvas.height/2, 100, 0, 2*Math.PI, true);

context_bg.closePath();

context_bg.fill();

context_bg.fillStyle = '#ffffff';

context_bg.beginPath();

context_bg.arc(canvas.width/2, canvas.height/2, 50, 0, 2*Math.PI, true);

context_bg.closePath();

context_bg.fill();

// create a car

car = new Box(10,20,'#0000ff',mass);

car.pos2D = new Vector2D(center.x+75,center.y);

car.velo2D = new Vector2D(0,-10);

car.angVelo = -car.velo2D.length()/(car.pos2D.subtract(center).length());

omega = car.angVelo;

car.draw(context);

// make the car move

t0 = new Date().getTime();

animFrame();

};

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

moveObject(car);

calcForce();

updateAccel();

updateVelo(car);

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

angle += omega*dt;

context.clearRect(0, 0, canvas.width, canvas.height);

rotateObject(obj);

}

function rotateObject(obj){

context.save();

context.translate(obj.x,obj.y);

context.rotate(angle);

context.translate(-obj.x,-obj.y);

obj.draw(context);

context.restore();

}

function calcForce(){

var dist = car.pos2D.subtract(center);

var velo = car.velo2D.length();

var centripetalForce = dist.unit().multiply(-mass*velo*velo/dist.length());

var radialFriction = dist.unit().multiply(-Cs*mass*g);

if(radialFriction.length() > centripetalForce.length()) {

force = centripetalForce;

}

else{

force = radialFriction;

}

}

function updateAccel(){

acc = force.multiply(1/mass);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

如图所示,我们从init()方法开始,画一个圆形轨迹。下一步引入了一点艺术上的新奇。因为我们正在处理一辆汽车,一个Ball实例根本不行。相反,我们的车是一个Box实例。Box是一个本质上类似于Ball的类,除了它绘制一个填充的矩形而不是一个填充的圆形。Box有四个参数(widthheightcolormass),它们都有默认值,因此是可选的。将汽车创建为Box实例后,我们将它放置在轨道上,并给它一个初始切向速度。然后,下一条线通过使用公式ω = v/r,给出汽车的旋转角速度等于其“轨道”角速度,其中 v 是汽车的速度,r 是其距中心的距离。

代码的其余部分应该很容易理解。在calcForce()中,我们用向量形式的公式 F = mv 2 /r 计算向心力,用公式 F = C s mg 计算汽车上的最大摩擦力(见第七章),其中 m 为汽车质量。为简单起见,我们假设静摩擦力和动摩擦力是相同的。还有,我们假设小车上的合力是径向的摩擦力。if模块检查最大摩擦力是否足以提供向心力。如果是这样,合力就取为向心力相等;如果不是,合力等于最大摩擦力。

如果你以 10 或 20 px/s 的初速度运行模拟,你会发现赛车在赛道上运动没有问题。然而,如果你把车速提高到 30 英里以上,汽车就会打滑!

非匀速圆周运动

到目前为止,本章我们只讨论了匀速圆周运动。当角速度不恒定时,我们如何处理旋转?在最后一节中,我们将开始研究这个问题,并用一个例子来说明它。对非匀速转动的更全面的论述将涉及到对角动量和力矩等概念的深入研究。为此,你必须等待第十三章。

切向力和加速度

匀速圆周运动和非匀速圆周运动的根本区别在于,在前者中,向心力是唯一的力;而后者既有向心力又有切向力(见图 9-10 )。切向的字面意思是“沿着圆的切线”。非匀速圆周运动的向心力由与匀速圆周运动相同的公式给出。与向心力一样,切向力可能是由许多因素引起的,如重力或摩擦力。

A978-1-4302-6338-8_9_Fig10_HTML.jpg

图 9-10。

Tangential (Ft ) and centripetal (Fc) forces

注意向心力改变的只是运动的方向,这就是匀速圆周运动为什么是匀速的原因。但是因为切向力作用在速度矢量的方向上,所以它们会引起旋转物体速度的变化。这也意味着当存在切向力时,存在非零的角加速度。

底线:为了模拟非匀速圆周运动,我们需要提供向心力和切向分力的力,正如我们下一个也是最后一个例子所展示的。

例子:单摆

所谓的单摆由一个重锤组成,重锤用绳子吊在一个固定的枢轴上。通过轻微移动摆锤并释放摆锤,摆锤开始摆动。模拟单摆并不完全简单,需要一点思考来获得正确的物理原理。

图 9-11 显示了单摆的力图。绳索通常相对于垂直线倾斜一定角度θ。我们假设绳子的质量可以忽略不计,并且没有摩擦力或阻力。设 m 表示摆锤的质量。于是有两个力作用在摆锤上:重力垂直向下作用,大小为毫克;并且绳索中朝向固定枢轴作用的张力的大小为 t

A978-1-4302-6338-8_9_Fig11_HTML.jpg

图 9-11。

Geometrical and force diagram for a pendulum

显然,摆锤以枢轴为中心做圆弧运动。但是摆锤的角速度是不均匀的。事实上,当摆锤处于最高位置时,它是零,当摆锤处于最低点时,它是最大值。因此,有一个角加速度,因为有一个切向分力。因为张力是径向的(垂直于切向),所以没有切向分量。但是重力总的来说有一个切向分量,由 mg sin(θ)给出。这是引起角加速度的力。注意,因为角度θ随时间变化,所以切向力和角加速度不是恒定的。

提供向心力的径向分力是什么?从图 9-11 可以清楚地看出,重力有一个远离支点的分量 mg cos(θ)。因此,净径向力分量为 T–mg cos(θ),它必须等于向心力:

A978-1-4302-6338-8_9_Figz_HTML.jpg

因此,这意味着张力 T 的大小由下式给出:

A978-1-4302-6338-8_9_Figaa_HTML.jpg

从图 9-11 中可以清楚地看出,张力的方向与摆锤相对于枢轴的位置矢量 r 相反。

聪明的部分来了。我们所要做的就是指定重力 mg 和张力 T,就像上一个等式给出的那样。然后,一旦我们给它一个初始位置,摆锤应该知道如何在这两个力的作用下运动。让我们继续编码吧。

代码在文件pendulum .js中:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var bob;

var pivot;

var displ = new Vector2D(0,0);

var center = new Vector2D(0.5*canvas.width,50);

var mass = 1;

var g = 100;

var lengthP = 200;

var initAngle = 30;

var t0, t, dt;

var acc, force;

var animId;

window.onload = init;

function init() {

// create a pivot

pivot = new Ball(2,'#000000');

pivot.pos2D = center;

pivot.draw(context);

// create a bob

bob = new Ball(10,'#333333',mass);

bob.mass = mass;

var relativePos = new Vector2D(lengthP*Math.sin(initAngle*Math.PI/180),lengthP*Math.cos(initAngle*Math.PI/180));

bob.pos2D = pivot.pos2D.add(relativePos);

bob.draw(context);

// make the bob move

t0 = new Date().getTime();

t = 0;

animFrame();

};

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

t += dt;

move();

}

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

drawSpring(bob);

moveObject(bob);

calcForce(bob);

updateAccel();

updateVelo(bob);

}

function drawSpring(obj){

pivot.draw(context);

context.save();

context.strokeStyle = '#999999';

context.lineWidth = 2;

context.moveTo(center.x,center.y);

context.lineTo(obj.x,obj.y);

context.stroke();

context.restore();

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

obj.draw(context);

}

function calcForce(obj){

displ = obj.pos2D.subtract(center);

var gravity = Forces.constantGravity(mass,g);

var tension;

if (displ.length() >= lengthP) {

tension = displ.unit().multiply(-(gravity.projection(displ)+mass*bob.velo2D.lengthSquared()/lengthP));

}else{

tension = new Vector2D(0,0);

}

force = Forces.add([gravity, tension]);

}

function updateAccel(){

acc = force.multiply(1/mass);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

我们首先创建一个枢轴和一个摆锤。摆锤的位置应确保绳索与垂直线成 30 度的初始角度。为此,参考图 9-11 ,该图显示摆锤相对于枢轴的 x 和 y 坐标由下式给出,其中 l 是摆锤的“长度”,定义为摆锤中心到固定枢轴的距离:

A978-1-4302-6338-8_9_Figbb_HTML.jpg

A978-1-4302-6338-8_9_Figcc_HTML.jpg

物理上,l 等于弦的长度和摆锤的半径之和。在代码中,我们用变量lengthP来表示。这个相对位置向量被计算并存储在变量relativePos中,然后被添加到枢轴的pos2D属性中以设置摆锤的位置。初始倾斜角度(以度为单位)由参数initAngle设定。

代码中动画部分的新颖之处基本上是在calcForce()方法中。我们用通常的方法计算重力。if代码块检查摆锤到枢轴的距离是否大于或等于绳索长度。如果是这样,那就意味着绳子是绷紧的,因此张力不为零;是用公式 T = mg cos(θ) + mv 2 /r 计算出来的,否则绳子没有拉紧,所以张力为零。在当前设置中,没有必要严格检查绳索是否拉紧,因为模拟从拉紧的绳索开始,并且一直保持拉紧状态。但是实现这种检查会使代码对未来可能的修改更加健壮。请注意,重力的径向分量 mg cos(θ)是使用漂亮的projection()方法计算的,我们将该方法引入到Vector2D对象中,以便vec1.projection(vec2)给出矢量vec1在矢量vec2方向上的投影。下面是projection()的定义:

function projection(vec){

var length = this.length();

var lengthVec = vec.length();

var proj;

if( (length == 0) || ( lengthVec == 0) ){

proj = 0;

}else {

proj = (this.x*vec.x + this.y*vec.y)/lengthVec;

}

return proj;

}

如果你运行代码并调整参数,你会发现如果绳子越短,g 值越大,钟摆摆动得越快。这在现实中是理所应当的。事实上,你可以通过比较振荡周期和理论值来测试你的模拟摆有多好。

物理学理论认为,对于小振幅的振荡,单摆的周期大致由下式给出,其中 l 是摆的长度:

A978-1-4302-6338-8_9_Figdd_HTML.jpg

我们在模拟中使用的值是 l = 200 和 g = 100。这样就得到 T = 2π,大约是 8.9 秒。检查模拟是否给出该值,例如,通过将时间输出到控制台并计时为 10 次振荡,然后将总时间除以 10。为了使这更容易,文件pendulum2.js包含了一些额外的代码,允许你通过点击鼠标来停止模拟。你应该得到一个非常接近 8.9 秒的值。正如你在图 9-12 中看到的,它几乎像真的一样工作!。

A978-1-4302-6338-8_9_Fig12_HTML.jpg

图 9-12。

The pendulum simulation

摘要

本章介绍了处理圆周和旋转运动所需的一些物理学知识,重点是粒子和简单刚体旋转。本章为更详细地研究刚体转动奠定了基础,我们将在第十三章中介绍。这里介绍的一些概念也将在下一章——远程部队——中进一步应用和发展。

十、远程力

在第二部分的最后一章,我们将介绍更多的力定律。然而,这里的方法和目的将比前几章更有趣。您将学习一些概念和技术,这些概念和技术可能不一定用于制作真实的模拟,但更多的是用于制作有趣的动画和效果。

本章涵盖的主题包括以下内容:

  • 粒子相互作用和力场:有两种方式来观察作用在远处的力:粒子之间的直接相互作用和力场。
  • 引力:我们重新把引力作为一个长程作用力的例子,给出进一步的例子,并建立一个黑洞游戏!
  • 静电力:这是静止的带电粒子之间的力,可以是吸引的,也可以是排斥的。电力可以产生有趣的效果。
  • 电磁力:运动的电荷受到磁力和电力的作用。它们合在一起构成了一种电磁力,可以用来产生更复杂类型的运动。
  • 其他力定律:没有理由你不能发明你自己的力定律来产生酷的效果。我们来看一些例子,包括不同类型的所谓中心力。

粒子相互作用和力场

到目前为止,每当我们在本书中谈到力时,我们都认为它们是由粒子对其他粒子施加的。但是还有另一种方式来思考在远处施加的力——我们可以认为它们是由力场引起的。这是一个相当微妙的概念,所以我们将在接下来的几节中花一些时间来解释它。

远距离互动

在这一章中,我们将专门讨论在远处施加和感受到的力;相互作用的物体不需要接触。实际上,从根本上来说,所有的力都是这样的,即使是接触力。不同之处在于,当两个物体靠得很近时,就会感觉到接触力。碰撞是相似的;当两个物体靠得如此之近,以至于它们的组成分子相互施加了电力时,就会发生这种现象。所以真正的区别是短程和远程部队。接触力和碰撞涉及短程力。重力是一种长程作用力。一般来说,短程力和长程力都可以是吸引的或排斥的。

从粒子相互作用到力场

从历史上看,物理学家对这样一个事实不太满意,即像重力这样的力似乎是在真空中施加的,两个遥远的物体显然能够立即相互影响。这就是所谓的超距作用问题。所以他们提出了力场的概念来代替超距作用的概念。

力场的概念,虽然看似深奥,其实相当简单。力场只是一个能感觉到力的空间区域。如果你有两个有质量的物体,比如一颗恒星和一颗行星,通常认为引力是它们各自对彼此施加一个引力。在场的概念中,你说恒星建立了一个引力场,弥漫在它周围的空间。任何其他物体,如行星,都会受到其所在点的场的作用力。关键的想法是场调节恒星和行星之间的相互作用。这样就不再有超距作用了;由于局部存在的力场,行星感受到的力是局部的。事实上,无论是否有行星存在,力场都会存在。

请注意,在前面的例子中,行星也建立了自己的引力场,然后根据牛顿第三运动定律,这导致恒星受到与行星所受力大小相等方向相反的力。

正如我们所知,行星受到的力取决于它与恒星的距离。在场的概念中,力随距离的变化包含在场强的概念中。在引力的情况下,场强随着距离场源(在这种情况下,是一颗恒星)的距离而减小。在下一节中,我们将看到由恒星产生的磁场强度的精确形式。习惯上将场强简称为场。

类似的想法也适用于其他远程力,比如电力。粒子产生力场。然后,其他粒子在该场的存在下受到力的作用。它们受到的力的大小取决于磁场强度。

你为什么要关心力场?一个重要的实际优势是,力场使我们不再需要粒子来施加力。正如你将在本章后面看到的,我们可以产生一个力场,而不用担心它是如何由粒子产生的。这给了我们很大的灵活性,可以让粒子受到的力的类型。力场和场强的概念在与电力和磁力的联系中特别有用,但是让我们在引力的背景下更仔细地研究它们。当我们谈论更抽象的电场和磁场时,它们会更容易理解。

牛顿引力

重力是长程作用力的一个典型例子。在这种情况下,场强被定义为在电场中任意给定点作用于粒子的每单位质量的力。这是一个明智的定义:它通过告诉我们重力力场对单位质量物体施加的力,来告诉我们重力力场有多强。

引力场强度是一个向量,用符号 g 表示。这个定义告诉我们

A978-1-4302-6338-8_10_Figa_HTML.jpg

你会发现这相当于我们熟悉的,质量为 m 的物体的重力公式:

A978-1-4302-6338-8_10_Figb_HTML.jpg

因此,引力场强度是一个矢量,其大小等于重力 g 产生的加速度。特别是,这意味着它对所有物体都具有相同的值,而不管它们的质量或其他特性如何。一般来说,引力场强度矢量 g 可以随位置变化,就像(非粗体)标量 g 一样,在均匀引力场中,比如靠近地球表面,g 和 g 都是常数。

重申一个重要的观点,引力场强度定义的关键是,它只是场的一个属性,因为它独立于任何可能受到它的力的物体而存在。一个物体上的重力取决于它的质量,而重力场强度 g 本身告诉我们在场中任何一点每单位质量的力。由此,我们可以通过乘以 m,计算出任意质量 m 上的力 F。

粒子产生的引力场

质量为 M 的粒子(如恒星)的引力场强度可以从牛顿引力定律开始计算:

A978-1-4302-6338-8_10_Figc_HTML.jpg

这里,M 是质量 M 产生的场中任何其他粒子的质量,r 是它相对于场源的位置矢量。

因为引力场强度是由 g = F/m 定义的,我们只需将前面的公式除以 m 就可以得到这个:

A978-1-4302-6338-8_10_Figd_HTML.jpg

正如你在前面的公式中所看到的,引力场 g 只取决于产生它的粒子的质量 M 和该粒子的位置矢量,而不取决于任何其他可能存在的粒子。我们还可以推导出 g 的大小由 g = GM/r 2 给出,与第六章中推导出的重力引起的加速度变化一致。

一般来说,g 可以比这个简单的公式复杂得多。例如,在一个星系中,g 是各个恒星产生的所有场的矢量和(当然,所有的场都在运动)。但是,模拟星系中恒星的运动,使用场的概念比直接计算每对恒星之间的所有力要有效得多,正如你将在第十二章中看到的。与此同时,是时候开始玩一些更简单的重力作为远程力的例子了。

多轨道飞行器引力

我们的第一个例子将涉及一个单一的引力吸引子,它将保持不变。我们借用了第六章中的轨道模拟,但我们会添加更多的轨道行星,使其更有趣,并说明每个行星在任何给定的时间都经历不同的引力场强度,因此沿着不同的轨道运动。下面是在orbits.js中修改后的代码:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_bg = document.getElementById('canvas_bg');

var context_bg = canvas_bg.getContext('2d');

var M = 1000000; // sun's mass

var G = 1;

var sun;

var planets;

var t;

var t0;

var dt;

var force;

var acc;

var numPlanets = 3;

window.onload = init;

function init() {

// create a stationary sun

sun = new Ball(70,'#ff9900',M,0,true);

sun.pos2D = new Vector2D(400,300);

sun.draw(context_bg);

// create planets

planets = new Array();

var radius = new Array(10,6,12);

var mass = new Array(10,3,15);

var color = new Array('#0000ff','#ff0000','#00ff00');

var pos = new Array(new Vector2D(400,50),new Vector2D(500,300),new Vector2D(200,300));

var velo = new Array(new Vector2D(65,0),new Vector2D(0,100),new Vector2D(0,-70));

for (var i=0; i<numPlanets; i++){

var planet = new Ball(radius[i],color[i],mass[i],0,true);

planet.pos2D = pos[i];

planet.velo2D = velo[i];

planet.draw(context);

planets.push(planet);

}

t0 = new Date().getTime();

t = 0;

animFrame();

}

function animFrame(){

requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

dt = 0.001*(new Date().getTime() - t0);

t0 = new Date().getTime();

t += dt;

move();

}

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

for (var i=0; i<numPlanets; i++){

var planet = planets[i];

moveObject(planet);

calcForce(planet);

updateAccel(planet.mass);

updateVelo(planet);

}

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

obj.draw(context);

}

function updateAccel(mass){

acc = force.multiply(1/mass);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

function calcForce(planet){

force = Forces.gravity(G,M,planet.mass,planet.pos2D.subtract(sun.pos2D));

}

这里的要点是,我们正在创建三个行星,然后初始化它们的位置和速度,以便它们最终绕太阳运行。这些行星被放置在离太阳不同的距离,然后每个行星被赋予一个初始切向速度。速度的大小基本上是通过反复试验来选择的;或者你可以使用第九章中给出的方法来选择速度,这将给出一个大致圆形的轨道。注意,move()函数在每个行星上循环,依次对每个行星应用函数moveObject()calcForce()updateAccel()updateVelo()

如果你运行代码,你会得到一个漂亮的太阳系玩具模拟(见图 10-1 )。基本的物理原理就在那里。例如,一颗行星离太阳越近,它运行的速度越快。但是这个模拟并不完全准确。正如您在前面的例子中所看到的,欧拉积分方案会迅速累积误差。如果你想做一个精确的太阳系模拟,你需要一个更好的积分器。我们将在第十六章的中讨论这个问题,在那里我们也将考虑行星之间的作用力以及输入行星质量、大小、位置和速度的精确数据。

A978-1-4302-6338-8_10_Fig1_HTML.jpg

图 10-1。

Multiple orbiting planets around a sun

多重吸引子引力

让我们通过添加另一个吸引子来使前面的模拟稍微复杂一些。在那个例子中,我们将保持吸引子不变。让他们动起来并不难;这会产生一些有趣的整体运动。但是我们必须更加小心我们的数值积分方案。我们将在第十四章中回到这个问题。

新代码在一个名为attractors.js的文件中,与orbits.js的本质区别在于init()calcForce()方法。我们先来看前者:

var numOrbiters = 3;

var numAttractors = 2;

function init() {

// create attractors

attractors = new Array();

var radiusA = new Array(20,20);

var massA = new Array(1000000,1000000);

var colorA = new Array('#ff9900','#ff9900');

var posA = new Array(new Vector2D(300,300),new Vector2D(500,300));

for (var i=0; i<numAttractors; i++){

var attractor = new Ball(radiusA[i],colorA[i],massA[i],0,true);

attractor.pos2D = posA[i];

attractor.draw(context_bg);

attractors.push(attractor);

}

// create orbiters

orbiters = new Array();

var radius = new Array(8,8,8);

var mass = new Array(1,1,1);

var color = new Array('#0000ff','#ff0000','#00ff00');

var pos = new Array(new Vector2D(400,300),new Vector2D(400,400),new Vector2D(300,400));

var velo = new Array(new Vector2D(0,60),new Vector2D(10,60),new Vector2D(90,0));

for (var i=0; i<numOrbiters; i++){

var orbiter = new Ball(radius[i],color[i],mass[i],0,true);

orbiter.pos2D = pos[i];

orbiter.velo2D = velo[i];

orbiter.draw(context);

orbiters.push(orbiter);

}

t0 = new Date().getTime();

t = 0;

animFrame();

}

代码基本上是不言自明的:我们创建了两个吸引子和三个轨道器,都作为Ball实例。吸引子被赋予相同的半径和质量。然后设定轨道器的初始位置和速度。正如我们将要看到的,完全不同的轨迹是由不同的初始条件产生的。

calcForce()中,我们计算并加总所有吸引子作用在每个轨道器上的力。代码可以处理任意数量的吸引子。

function calcForce(orbiter){

var gravity;

force = Forces.zeroForce();

for (var i=0; i<numAttractors; i++){

var attractor = attractors[i];

gravity = Forces.gravity(G,attractor.mass,orbiter.mass,orbiter.pos2D.subtract(attractor.pos2D));

force = Forces.add([force, gravity]);

}

}

按照attractors.js中指定的三个轨道器的初始条件运行代码。蓝色轨道飞行器有位置向量(400,300)和速度向量(0,60)。这将轨道器准确地定位在两个吸引子的中间,并使其向下的速度为 60 px/s。您将看到轨道器上下振荡,因此它与每个吸引子的距离相同。

红色轨道飞行器有位置矢量(400,400)和速度矢量(10,60)。你会发现这个轨道器以一种有趣的方式运动:交替地绕着每个吸引子运动,但在两者之间做了一个扭曲,所以它以顺时针方向绕着两个吸引子运动。如果你现在保持相同的初始位置,但是改变初始速度为(120,0),你会看到轨道器围绕两个吸引子旋转。一般来说,你可以有不同的吸引子的分布,并且一个物体仍然有可能围绕着整个簇运动。

绿色轨道器有位置矢量(300,400)和速度矢量(90,0)。这个轨道器交替环绕每个吸引子,但方向相反,遵循 8 字形轨道。

请注意,如果您让模拟运行足够长的时间,您可能会开始看到由于积分方案中的数值不准确而导致的轨道变化。如前一节所述,我们将在第十四章中探讨如何解决这个问题。

从这些实验中学到的关键经验(见图 10-2 )是,虽然引力场是由吸引子固定的,但粒子在引力场中的实际轨迹也将取决于它们的位置和速度。

A978-1-4302-6338-8_10_Fig2_HTML.jpg

图 10-2。

Orbiters moving under the gravitational influence of two attractors

不用说,您可以用许多方法来试验这种模拟。作为一个练习,你为什么不添加另一个吸引子,看看你会得到什么样的运动?

重力场中的粒子轨迹

在下一个例子中,我们有许多吸引子,产生了一个复杂的重力场。粒子在这种场中的最终轨迹可能相当复杂,有时难以预测。为了让这个例子更有趣,我们假设我们的吸引子是黑洞。我们修改前一个例子中的attractors.js代码来实现这些新特性。让我们先来看看init()的方法:

function init() {

// create attractors

attractors = new Array();

for (var i=0; i<numAttractors; i++){

var r = 20*(Math.random()+0.5);

var m = (0.5*c*c/G)*r;

var attractor = new Ball(r,'#000000',m,0,false);

attractor.pos2D = new Vector2D(Math.random()*canvas.width,Math.random()*canvas.height);

attractor.draw(context_bg);

attractors.push(attractor);

}

// create orbiters

orbiters = new Array();

var color = new Array('#0000ff','#ff0000','#00ff00');

for (var i=0; i<numOrbiters; i++){

var orbiter = new Ball(5,color[i],1,0,true);

orbiter.pos2D = new Vector2D(Math.random()*canvas.width,Math.random()*canvas.height);

orbiter.velo2D = new Vector2D((Math.random()-0.5)*100,(Math.random()-0.5)*100);

orbiter.draw(context);

orbiters.push(orbiter);

}

setupGraph();

t0 = new Date().getTime();

t = 0;

animFrame();

}

在这段代码中,我们创建了一些黑洞作为球实例,将它们放置在画布上的随机位置,并将对它们的引用放在一个名为attractors的数组中。然后,我们创建三个轨道器,也作为球实例(在前面的例子中是蓝色、红色和绿色),并给它们分配一个随机位置和一个随机速度。正如你从代码中看到的,为了设置黑洞的质量,我们使用了公式 M = (0.5c 2 /G) R,其中 M 是质量,R 是黑洞的半径(在代码中是 10 到 30 个像素之间的随机值)。c 是什么,这个公式从何而来?这是根据半径计算黑洞质量的公式。常数 c 是光速。我们的黑洞实际上是假设的牛顿黑洞;真正的(爱因斯坦)黑洞不遵守牛顿的引力定律,而是需要爱因斯坦的广义相对论(一个复杂得多的理论,我们无意在本书中模拟!).c 的值将决定黑洞的质量范围。这是一个玩弄价值的例子,使黑洞产生足够的,但不是太多的吸引力,这样粒子就有机会在被黑洞吞噬之前做一些有趣的事情。我们选择的值是 300。

calcForce()方法循环遍历所有的吸引子,并像前面的例子一样对它们的重力求和。此外,如果轨道飞行器与任何黑洞相撞,它就会消失,并通过调用recycleOrbiter()方法被“回收”,如calcForce()中高亮显示的代码所示:

function calcForce(obj){

var gravity;

force = Forces.zeroForce();

for (var i=0; i<numAttractors; i++){

var attractor = attractors[i];

var dist = obj.pos2D.subtract(attractor.pos2D);

if (dist.length() > attractor.radius+obj.radius){

gravity = Forces.gravity(G,attractor.mass,obj.mass,dist);

force = Forces.add([force, gravity]);

}else{

recycleOrbiter(obj);

}

}

}

recycleOrbiter()方法只是重新初始化轨道器的位置和速度:

function recycleOrbiter(obj){

obj.pos2D = new Vector2D(Math.random()*canvas.width,Math.random()*canvas.height);

obj.velo2D = new Vector2D((Math.random()-0.5)*100,(Math.random()-0.5)*100);

}

根据moveObject()方法中的一段if逻辑,如果轨道器离开可见的载物台区域,它也会被“回收”:

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

if (obj.x < 0 || obj.x > canvas.width || obj.y < 0 || obj.y > canvas.height){

recycleOrbiter(obj);

}

obj.draw(context);

}

你会注意到我们还在move()方法中调用了一个plotGraph()方法。这是怎么回事?嗯,我们并不是真的在绘制一个图表,而是轨道飞行器的轨迹。但是我们可以使用与 stage 具有相同范围的Graph实例来实现。这是在setupGraph()方法中完成的,该方法在init()方法中被调用。然后plotGraph()方法绘制出轨道器的 y 位置和 x 位置。因为plotGraph()move()内被调用,它在每个时间步执行,所以代码有效地描绘出轨道器的轨迹:

function setupGraph(){

graph = new Graph(context_bg,0,canvas.width,0,canvas.height,0,0,canvas.width,canvas.height);

}

function plotGraph(obj){

graph.plot([obj.x], [-obj.y], obj.color, false, true);

}

如果您想了解所有这些代码是如何组合在一起的,请看一下文件gravity-field .js。运行代码,当每个轨道飞行器穿过复杂的重力场并最终进入黑洞时,你会看到一些有趣的轨迹。每次这种情况发生时,它都会在其他地方重新出现,并描绘出另一条轨迹,要么结束于黑洞,要么就在舞台区域之外。图 10-3 显示了您将能够看到的结果类型。粒子初始条件的随机性会导致不同的模式随着时间的推移而出现,因此您可能希望让模拟运行一段时间来查看这一点。此外,每次你运行模拟,你会得到不同的黑洞配置,这也将给出不同的粒子轨迹模式。

A978-1-4302-6338-8_10_Fig3_HTML.jpg

图 10-3。

Particle trajectories in the gravity field of black holes

构建一个简单的黑洞游戏

在下一个项目中,我们将会更加冒险,通过修改前面的模拟来构建一个简单的游戏。图 10-4 显示了游戏将会是什么样子的截图。游戏的目标是驾驶一艘宇宙飞船(三角形)穿过黑洞领域。宇宙飞船在舞台下端附近出发,如果你能越过顶端的终点线,你就赢了。每赢一次,就增加一个新的黑洞。黑洞的位置是随机初始化的,因此它们的中心都位于获胜线和更低的另一条水平线之间。每当黑洞捕获宇宙飞船时,你就失去一条生命。如果你走下舞台,宇宙飞船会重新回到它的初始位置。

A978-1-4302-6338-8_10_Fig4_HTML.jpg

图 10-4。

The black hole game

创建视觉设置

创建可视化设置的代码位从文件black-holes.js中的init()函数调用:

function init() {

ship = new Rocket(12,12,'#ff0000',1);

ship.pos2D = new Vector2D(0.5*canvas.width,canvas.height-50);

ship.draw(context);

attractors = new Array();

addAttractor();

setupText();

setupScene();

setupEventListeners();

t0 = new Date().getTime();

t = 0;

animFrame();

}

首先我们创建一艘名为ship的飞船作为Rocket对象的实例,从第六章中借用。接下来我们创建一个名为attractors的数组,然后调用一个函数addAttractor(),这个函数创建了我们的第一个黑洞,并在attractors数组中放置了对它的引用:

function addAttractor(){

var r = 20*(Math.random()+0.5);

var m = (0.5*c*c/G)*r;

var attractor = new Ball(r,'#000000',m,0,false);

attractors.push(attractor);

}

创建黑洞并指定其属性的代码与上一个示例中的代码相似。在black-holes.js中的代码中,我们将光速降低到 200 px/s,这样吸引力就不会太强,从而实际上有可能避开黑洞!

接下来在init()中,调用setupText()函数。此函数设置文本属性,如字体大小和字体系列。

function setupText(){

context_bg.font = "18pt Arial";

context_bg.textAlign = "left";

context_bg.textBaseline = "top";

}

然后调用setupScene()方法;它在canvas_bg上显示视觉元素:

function setupScene(){

context_bg.clearRect(0, 0, canvas_bg.width, canvas_bg.height);

drawLines();

showLives();

showScore();

for (var i=0; i<attractors.length; i++){

var attractor = attractors[i];

attractor.pos2D = new Vector2D(Math.random()*canvas.width,Math.random()*(yposEdge-yposWinning)+yposWinning);

attractor.draw(context_bg);

}

}

setupScene()函数首先清空画布,然后调用三个函数,drawLines()showLives()showScore()。首先,drawLines()功能在两个垂直高度yposWinningyposEdge绘制水平线(这里分别设置为 50 和 400)。showLives()showScore()函数分别显示当前生命数(初始化为 3)和当前分数(等于成功避开的黑洞数)。然后setupScene()将所有黑洞放置在上述两条水平线之间的随机位置。

回到init(),在调用动画代码之前,调用setupEventListeners()方法。下一节将描述相应的事件侦听器。

游戏功能编程

接下来,我们看看实现游戏功能的代码部分。首先,有六个变量在代码开始时被初始化,它们在游戏的交互性中起着关键作用:

var applyThrust = false;

var direction = "";

var dir = new Vector2D(0,1);

var vedmdt = 10; // ve*dm/dt

var numLives = 3;

var score = 0;

四个变量applyThrustdirectiondirvedmdt一起工作,因为它们需要指定飞船上的推力。

  • 如果施加推力,第一个变量applyThrust是一个值为trueBoolean
  • 第二个变量direction是一个字符串,它告诉我们推力施加的方向。
  • 第三个,dir,是指向下方的单位矢量;它将被用来计算推力矢量。
  • 第四个,vedmdt,是ve*dm/dt的值,给出火箭推力的大小(参考第六章)。
  • 最后两个变量numLivesscoreNumber变量,分别存储当前的生命数和分数。

setupEventListeners()方法如下所示:

function setupEventListeners(){

window.addEventListener('keydown',startThrust,false);

window.addEventListener('keyup',stopThrust,false);

window.addEventListener('dblclick',changeSetup,false);

}

如果一个键被按下,事件处理程序startThrust()将被调用;如果该键被释放,事件处理程序stopThrust()将被调用;如果我们双击,事件处理程序changeSetup()将被调用。这里是startThrust()stopThrust()的样子:

function startThrust(evt){

applyThrust = true;

if (evt.keyCode==38){ // up arrow

direction = "UP";

}

if (evt.keyCode==40){ // down arrow

direction = "DOWN";

}

if (evt.keyCode==39){ // right arrow

direction = "RIGHT";

}

if (evt.keyCode==37){ // left arrow

direction = "LEFT";

}

}

function stopThrust(evt){

applyThrust = false;

direction = "";

}

因此,startThrust()applyThrust设置为true,并根据哪个方向键被按下,给出"UP""DOWN""RIGHT""LEFT"direction的值。就其本身而言,stopThrust()applyThrust重置为false,并将一个空白字符串分配给direction

通过双击窗口上的任意位置调用的changeSetup()事件处理程序如下所示:

function changeSetup(evt){

setupScene();

recycleOrbiter(ship);

}

我们已经在上一节讨论了setupScene()方法。recycleOrbiter()方法如其名,将飞船的位置和速度重新初始化为初始值:

function recycleOrbiter(obj){

obj.pos2D = new Vector2D(0.5*canvas.width,canvas.height-50);

obj.velo2D = new Vector2D(0,0);

}

所以你可以通过双击浏览器窗口的任何地方来随时重置黑洞和宇宙飞船的位置。

现在让我们看看calcForce()是什么样子的:

function calcForce(obj){

force = Forces.zeroForce();

// calculate and add gravity due to all black holes

var gravity = Forces.zeroForce();

for (var i=0; i<attractors.length; i++){

var attractor = attractors[i];

var dist = obj.pos2D.subtract(attractor.pos2D);

if (dist.length() > attractor.radius){

gravity = Forces.gravity(G,attractor.mass,obj.mass,dist);

force = Forces.add([force, gravity]);

}else{

updateLives();

setupScene();

recycleOrbiter(obj);

}

}

// calculate and add thrust

var thrust = Forces.zeroForce();

if (applyThrust){

if (direction=="UP"){

thrust = dir.para(-vedmdt);

}else if (direction=="DOWN"){

thrust = dir.para(vedmdt);

}else if (direction=="RIGHT"){

thrust = dir.perp(vedmdt);

}else if (direction=="LEFT"){

thrust = dir.perp(-vedmdt);

}else{

thrust = new Vector2D(0,0);

}

force = Forces.add([force, thrust]);

}

}

每个黑洞产生的重力在一个for循环中计算,然后加到总力上(这里 G 在代码开始时被赋予值 1)。碰撞检测测试检查飞船不在当前的黑洞内;如果是,调用方法updateLives(),飞船被回收。然后根据按下的箭头键计算出飞船的推力,这由存储在direction变量中的字符串决定。例如,如果direction=="UP",通过使用Vector2Dpara()公共方法,将dir缩放为-vedmdt,推力被施加到与单位矢量dir(指向下方)相反的方向。如果按下右箭头键或左箭头键,则使用perp()方法,给出长度为vedmdt的水平向量。然后推力被加到合力上。

前面提到的updateLives()方法减少了生命变量numLives的数量:

function updateLives(){

numLives--;

}

接下来我们来看看moveObject()方法:

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

if (obj.x < 0 || obj.x > canvas.width || obj.y < 0 || obj.y > canvas.height){

recycleOrbiter(obj);

}

if (obj.y < yposWinning){

updateScore();

addAttractor();

setupScene();

recycleOrbiter(obj);

}

obj.draw(context);

}

这里的新比特是代码的if块。如果船在画布区域之外,第一个if块回收船。第二个if模块检查船只是否通过了获胜线。如果有,它会更新分数,添加一个新的黑洞,重新定位所有的黑洞,并回收轨道飞行器。先前描述了addAttractor()setupScene()recycleOrbiter()方法。updateScore()方法将当前得分变量score增加当前黑洞数(因此score的值等于被避开的黑洞总数):

function updateScore(){

score += attractors.length;

}

为了完整起见,我们展示了从setupScene()调用的showLives()showScore()方法:

function showLives(){

txtLives = numLives.toString().concat(" lives left");

if (numLives==0){

txtLives = "Game over";

stop();

}

writeText(txtLives,50,20);

}

function showScore(){

if (score==0){

txtScore = "";

}else if (score==1){

txtScore = "You've just dodged a black hole!"

}else{

txtScore = "You've dodged ";

txtScore = txtScore.concat(score.toString()," black holes!");

}

writeText(txtScore,400,20);

}

function writeText(txt,x,y){

context_bg.fillText(txt,x,y);

}

实用函数writeText()showLives()showScore()中被调用,并简单地在画布上的指定位置书写文本。这里需要注意的另一件事是,如果numLives变为零,在showLives()中调用stop()方法并停止动画:

function stop(){

cancelAnimationFrame(animId);

}

这就完成了游戏的描述。看看源文件,玩得开心点!请随意进一步开发游戏。它当然可以增强图像,也许还有一些声音效果。在功能性方面,看看能不能改进控件;例如,允许用户通过同时按下向上或向下键和向左或向右键来对角移动飞船。

静电力

我们要研究的下一个长程作用力是存在于带电粒子之间的作用力。这被称为电力或静电力。但是在我们讨论力的定律之前,我们需要解释一些关于电荷的事实。

电荷

电荷是粒子的一种物理性质,就像质量一样。基本粒子是物质的基本组成部分,经常带电。例如,电子是围绕原子核运行的粒子,它们带有负电荷。原子核包括带正电荷的质子和带零电荷的中子。我们所说的电流实际上是金属中自由电子的流动——运动的电荷(不管是不是电子)构成了电流。

一个有质量的粒子会产生一个引力场,对其他有质量的粒子施加一个力。同样,一个带电粒子会产生一个电场,对其他带电粒子施加一个力。电荷有两种:正电荷和负电荷。

同性电荷相斥;异性电荷相吸。

所以正电荷会吸引负电荷,反之亦然。但是正电荷会排斥另一个正电荷;同样,一个负电荷会排斥另一个负电荷。这和质量是不同的,质量总是正的,总会产生引力。

库仑静电学定律

两个粒子间的电力定律称为库仑定律。它在形式上与牛顿万有引力定律几乎相同,由下式给出,其中 Q 1 和 Q 2 是两个粒子上的电荷,r 是它们之间的距离,k 是类似于 G 的常数:

A978-1-4302-6338-8_10_Fige_HTML.jpg

不用担心 k 的值;我们不打算模拟真实的电荷,但我们将研究一下力定律,看看它会产生什么样的运动。所以我们可以给 k 任何我们喜欢的值。

根据牛顿第三定律,一个粒子对另一个粒子施加的力大小相等,方向相反。和重力一样,力的方向是沿着两个粒子的连线,所以库仑定律可以用以下两种方式写成矢量形式,其中 r u 是 r 方向的单位矢量:

A978-1-4302-6338-8_10_Figf_HTML.jpg

或者

A978-1-4302-6338-8_10_Figg_HTML.jpg

这里 r 是受力物体相对于施力物体的位置矢量,就像重力一样(参考第六章)。

请注意,前面的公式中没有负号(回想一下,重力的等效矢量公式有一个负号)。这是因为同性电荷相斥,而异性电荷相吸。所以如果 Q 1 和 Q 2 都是正的或者都是负的,它们的乘积就是正的,力 F 是 r 的正数倍,那么 F 的方向就是 r(它是排斥的)。但是如果其中一个电荷是正的,另一个是负的,F 是负的数乘以 r,方向与 r 相反(它是吸引的)。

让我们在Forces对象中创建一个类似重力函数的电力函数:

Forces.electric = function(k,q1,q2,r){

return r.multiply(k*q1*q2/(r.lengthSquared()*r.length()));

}

这在形式上类似于重力函数,但没有负号。如前所述,乘积q1*q2的符号决定了我们得到的是吸引力还是排斥力。仅仅通过允许电荷有两种不同的符号,我们就得到一个比重力更“丰富”的力,尽管这个公式在形式上几乎是相同的。

带电粒子的吸引和排斥

为了看到电力的作用,让我们建立一个类似于gravity-field.js的模拟,但用带电粒子代替黑洞。新文件是electric-field.js。下面重点介绍了init()calcForce()方法的主要区别:

function init() {

// create attractors

attractors = new Array();

for (var i=0; i<numAttractors; i++){

var r = 20*(Math.random()+0.5);

var charge = (Math.random()-0.5)*1000000;

if (charge<0){

color = '#ff0000';

}else if(charge>0){

color = '#0000ff';

}else{

color = '#000000';

}

var attractor = new Ball(r,color,1,charge,true);

attractor.pos2D = new Vector2D(Math.random()*canvas.width,Math.random()*canvas.height);

attractor.draw(context_bg);

attractors.push(attractor);

}

// create orbiters

orbiters = new Array();     for (var i=0; i<numOrbiters; i++){

var orbiter = new Ball(5, ’#0000ff’,1,1,true);

orbiter.pos2D = new Vector2D(Math.random()*canvas.width,Math.random()*canvas.height);

orbiter.velo2D = new Vector2D((Math.random()-0.5)*100,(Math.random()-0.5)*100);

orbiter.draw(context);

orbiters.push(orbiter);

}

setupGraph();

t0 = new Date().getTime();

t = 0;

animFrame();

}

主要的区别是,我们给吸引子随机的正负电荷,如果它们的电荷是正的,就把它们涂成蓝色,如果它们的电荷是负的,就涂成红色(如果它们的电荷为零,就涂成黑色,这种情况很少发生,因为Math.random()必须返回 0.5 才能成立)。每个移动的粒子,仍然被称为orbiter,被赋予 1 的正电荷,并被染成蓝色。所以它会被红色中心吸引,被蓝色中心排斥。

calcForce()中,我们用Forces.electric()代替Forces.gravity(),用电荷代替质量:

function calcForce(obj){

var electric;

force = Forces.zeroForce();

for (var i=0; i<numAttractors; i++){

var attractor = attractors[i];

var dist = obj.pos2D.subtract(attractor.pos2D);

if (dist.length() > attractor.radius+obj.radius){

electric = Forces.electric(k,attractor.charge,obj.charge,dist);

force = Forces.add([force, electric]);

}else{

recycleOrbiter(obj);

}

}

}

图 10-5 显示了运行代码时应该看到的示例。

A978-1-4302-6338-8_10_Fig5_HTML.jpg

图 10-5。

Trajectories in the electric field of multiple charged particles

电场

和重力一样,我们可以引用静电学中力场的概念。回想一下,重力场强度 g 定义为每单位质量所施加的力,g = F/m。同样,电场强度 E 定义为每单位电荷所施加的力:

A978-1-4302-6338-8_10_Figh_HTML.jpg

因此,如果我们知道电场强度,我们可以很容易地通过乘以粒子的电荷来计算电场对粒子施加的电力:

A978-1-4302-6338-8_10_Figi_HTML.jpg

将此与重力的等效公式进行比较:

A978-1-4302-6338-8_10_Figj_HTML.jpg

因为我们要用公式 F = q E,为什么不为它创建一个力函数呢?姑且称之为Forces.forceField:

Forces.forceField = function(q,E) {

return E.multiply(q);

}

注意,如果我们用 m 代表参数 q,用 g 代表 e,没有什么能阻止我们用同样的函数来计算 F = m,g。

带电粒子产生的电场

作为一个例子,使用库仑定律,我们可以计算一个带电粒子产生的电场。设粒子带电荷 q,那么它对距离 r 远的带电荷 q 的粒子施加的力是这样的:

A978-1-4302-6338-8_10_Figk_HTML.jpg

因此,每单位电荷的力是这样的:

A978-1-4302-6338-8_10_Figl_HTML.jpg

在向量形式中,它可以写成如下形式:

A978-1-4302-6338-8_10_Figm_HTML.jpg

这是带电粒子产生的电场的公式。但是我们也可以随意改变 E 的任何形式,而不用担心它实际上是如何产生的。让我们看几个例子。

时变电场

如前一节所述,静止电荷的电场在时间上是恒定的,尽管它在空间上是变化的。这类似于静止质量的引力场。但是如果带电粒子运动,它建立的电场会随时间变化。例如,振荡电荷会产生振荡电场。

一个简单的例子是像正弦或余弦波一样随时间变化的电场。频率和振幅等波动概念可以应用于这种正弦变化的场,如第三章和第八章所述。此外,因为电场是一个矢量,它的分量可以彼此独立地变化。如果这一切听起来有点抽象,那是因为电场是一个抽象的概念!你不能想象一个有两个或更多分量的振荡电场,就像你能想象一个振荡的质量弹簧系统一样。然而,同样的数学可以应用于两者。

随时间变化的电场可以产生有趣的效应。在文件electric-field-examples.js中,我们来看看不同电场产生的运动类型:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_bg = document.getElementById('canvas_bg');

var context_bg = canvas_bg.getContext('2d');

var particle;

var mass = 1;

var charge = 1;

var E;

var t0, t, dt;

var acc, force;

var graph;

var animId;

var animTime = 25;

window.onload = init;

function init() {

particle = new Ball(5,'#ff0000',mass,charge,true);

particle.pos2D = new Vector2D(100,300);

particle.draw(context);

setupGraph();

t0 = new Date().getTime();

t = 0;

animFrame();

}

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

dt = 0.001*(new Date().getTime() - t0);

t0 = new Date().getTime();

t += dt;

if (t < animTime){

move();

}else{

stop();

}

}

function move(){

moveObject(particle);

calcForce();

updateAccel();

updateVelo(particle);

plotGraph();

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

context.clearRect(0, 0, canvas.width, canvas.height);

obj.draw(context);

}

function calcForce(){

E = new Vector2D(20*Math.sin(1*t),20*Math.cos(1*t));

force = Forces.forceField(charge,E);

}

function updateAccel(){

acc = force.multiply(1/mass);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

function stop(){

cancelAnimationFrame(animId);

}

function setupGraph(){

graph = new Graph(context_bg,0,canvas.width,0,canvas.height,0,0,canvas.width,canvas.height); }

function plotGraph(){

graph.plot([particle.x], [-particle.y], '#666666', false, true);

}

如前面的代码所示,我们在calcForce()中指定的电力的影响下移动一个带电的球实例,并使用plotGraph()方法绘制其轨迹,以绘制之前在setupGraph()中设置的Graph实例。

现在是游戏时间了!在源文件中,我们为calcForce()中的E提供了许多不同的正弦变化函数(注释掉的代码),你可以试试。首先尝试以下功能:

E = new Vector2D(0, 50*Math.cos(1*t));

我们所做的是施加一个电场,其垂直分量随时间呈正弦变化,幅度(最大幅度)为 50,频率为每秒 1 个周期。当你运行代码时,你会看到粒子随着电场的变化上下振荡。

如果我们改变振荡的振幅或频率会发生什么?如果我们增加振幅,我们会施加更大的力,所以毫不奇怪,粒子会以更大的振幅振荡。但是增加外加电场频率的效果可能不那么明显。尝试将频率更改为 3,使E变成这样:

E = new Vector2D(0, 50*Math.cos(3*t));

如果你现在运行代码,你会发现粒子以小得多的振幅振荡。这里发生的是,粒子,因为它有惯性(质量),跟不上电场的变化速度。

下面的函数给出了一个更有趣的模式,通过施加一个电场,其水平分量为 1,垂直分量为正弦变化,振幅由2*t给出:

E = new Vector2D(1, 2*t*Math.cos(1*t));

这使得粒子在水平方向加速,并在垂直方向以增加的振幅振荡(见图 10-6 )。

我们将让您使用文件中给出的其他示例函数,以及尝试您自己的函数。当然,没有理由将自己局限于正弦变化的函数:可以随意试验任何你喜欢的函数。

A978-1-4302-6338-8_10_Fig6_HTML.jpg

图 10-6。

Example of the effect of a time-varying electric field on a charged particle

电磁力

电(静电)力只是带电粒子的一个方面。除了电力之外,移动的带电粒子还会受到另一种力的作用——磁力。因此,考虑磁力将改变带电粒子的运动方式。在本节中,您将看到如何计算磁力,以及磁力与电力的结合如何影响带电粒子的运动。

磁场和力

我们小时候都体验过磁铁的魅力。它们吸引人的部分原因是,你可以实际感受到两块磁铁之间的作用力,而无需它们实际接触。由于这种超距作用的特性,磁性恰好符合力场的概念:一块磁铁会在其周围产生磁场,然后对其他磁铁和磁性材料施加作用力。

一点都不明显的是,磁场其实和电场有着密切的联系。磁场是由移动的电荷或随时间变化的电场产生的。因此,导线中的电流会产生磁场。正弦变化的电场将产生正弦变化的磁场。电场和磁场之间的关系以及它们产生的精确方式是复杂的;它们在数学上由一组称为麦克斯韦方程组的矢量微分方程来描述。为了简单起见,我们将简单地假设给定的电场和磁场,而不用担心它们是如何产生的或者它们如何相互作用,并且专注于它们如何影响带电粒子的运动。

我们已经知道,电场强度为 E 时,电荷为 q 的粒子所受的力为 F = q E。磁场的等效力定律是什么?

首先我们需要引入一个类似于电场强度的磁场强度。没有必要深入实际的定义;只要说它存在并被赋予符号 B,它是一个像电场强度 e 一样的矢量就够了。

磁场 B 对电荷 q 的作用力由下式给出,其中 v 是带电粒子的速度矢量,x 表示叉(矢量)积(参见第三章):

A978-1-4302-6338-8_10_Fign_HTML.jpg

正如在第三章的中所解释的,叉积 v × B 给出了一个垂直于 v 和 B 的向量(它指向一个垂直于包含向量 v 和 B 的平面的方向)。乘以 q 得到一个方向相同的矢量,但是 q 乘以它的大小。从第三章,磁力也可以这样写,其中θ是 v 和 B 矢量之间的角度,n 是垂直于 v 和 B 矢量的单位矢量,方向根据右手定则(参见第三章):

A978-1-4302-6338-8_10_Figo_HTML.jpg

注意,因为磁力总是垂直于粒子速度,所以它充当向心力,引起旋转(参见第九章)。因此,我们可以把磁力定律等同于向心力公式:

A978-1-4302-6338-8_10_Figp_HTML.jpg

重新排列这个等式得出:

A978-1-4302-6338-8_10_Figq_HTML.jpg

这给出了带电粒子所遵循的轨迹的半径。这个公式告诉我们,如果质量或速度增加,圆的半径就会增加,如果电荷 q 或磁场强度 B 增加,圆的半径就会减小。

洛伦兹力定律

您可以将带电粒子上的电力和磁力结合起来,给出粒子上电磁力的单一公式:

A978-1-4302-6338-8_10_Figr_HTML.jpg

这被称为洛伦兹力方程。

我们现在将在Forces对象中创建一个名为lorentz()的静态力函数来实现这个力定律。唯一的问题是矢量积只在 3D 中定义,而我们的部队目前只在 2D。但是,如果我们假设磁场 B 总是指向屏幕内或屏幕外,我们可以用一种受限制的方式来实现磁力 qv × B。磁力由下式给出:qv × B = qvB sin (θ) n(参考上一节),其中θ是 v 和 B 之间的角度,在 2D 中,如果 B 指向屏幕内或屏幕外,v 总是垂直于 B。因为 sin (90 ) = 1,所以在这种特殊情况下,n 垂直于 v 时,得出以下公式:

A978-1-4302-6338-8_10_Figs_HTML.jpg

这意味着在 JavaScript 中磁力由下式给出,其中vel是速度矢量 v,B是磁场的大小:

vel.perp(q*B*vel.length())

完整的洛伦兹力函数如下:

Forces.lorentz = function(q,E,B,vel) {

return E.multiply(q).add(vel.perp(q*B*vel.length()));

}

现在让我们来玩洛仑兹力函数。文件lorentz-force.js设置一个粒子,让它在洛伦兹力的作用下运动。它与前面的electric-field-examples.js中的代码非常相似,主要区别在于 calcForce()方法:

function calcForce(){

E = new Vector2D(0,0);

B = 0.2;

force = Forces.lorentz(charge,E,B,particle.velo2D);

}

我们在init()中把质点在 x 方向的速度初始化为 40px/s;选择不同的初速度会得到不同的结果。

让我们尝试对EB使用不同的值和数学函数,看看我们会得到什么。首先,让E是一个零矢量,让B = 0.2,就像前面的代码片段一样——换句话说,我们有一个恒定的磁场。运行代码,你会看到粒子描绘出一个圆形的路径,正如上一节所讨论的。你可以通过将B的值增加到 0.5 并注意到圆形路径的半径减小来验证我们在上一节末尾所说的。

您可以通过在calcForce()updateVelo()中添加以下行来降低每个时间步长上粒子的速度:

particle.velo2D = particle.velo2D.multiply(0.999);

这将给出如图 10-7 所示的螺旋图案。

A978-1-4302-6338-8_10_Fig7_HTML.jpg

图 10-7。

A spiral traced by a particle with decreasing velocity in a constant magnetic field

现在加上一个非零电场。例如:

E = new Vector2D(1,0);

B = 0.5;

这将产生一个螺旋轨迹。你也可以使电场和/或磁场随时间变化;例如:

E = new Vector2D(0,50*Math.cos(1*time));

B = 0.5;

这将产生不总是你所期望的轨迹。体验愉快!

其他力定律

本章到目前为止,我们已经讨论了自然界中实际存在的物理力。但是我们没有理由不能发明自己的。因为我们的主要目的是摆弄力和它们的效果,所以我们不必受现实的限制。在接下来的几节中,我们将介绍不同的力定律,看看它们能做什么。请随意创建您自己的!

中央部队

中心力是满足以下两个条件的力:

  • 力的大小只取决于离给定点的距离。
  • 力的方向总是朝着那个点。

牛顿引力、静电力和弹簧力都是中心力的例子。数学上,上述两个条件可以组合起来给出任意点 P 的力矢量如下,其中 r u 是点 P 距力心的单位矢量,r 是其距该中心的距离,f(r)是 r 的函数:

A978-1-4302-6338-8_10_Figt_HTML.jpg

你已经知道,对于重力和静电力,f (r)正比于 1/r 2 :

A978-1-4302-6338-8_10_Figu_HTML.jpg

这通常被称为平方反比定律。有没有想过如果重力遵循不同的定律会是什么样子——例如,1/r 反比定律或者 1/r 立方反比定律?我们可以很容易地找到答案,通过创建一个修正的重力函数。但是,与其为这些不同的情况创建特殊的函数,不如让我们创建一个通用的中心力函数,该函数根据以下各项变化,其中 k 和 n 可以是正的或负的:

A978-1-4302-6338-8_10_Figv_HTML.jpg

这给出了以下向量形式:

A978-1-4302-6338-8_10_Figw_HTML.jpg

或者相当于:

A978-1-4302-6338-8_10_Figx_HTML.jpg

如果 k 为正,则力是排斥的(因为是 r 方向的);如果 k 为负,则力是吸引的(因为它与 r 方向相反)。如果 n 为正,力随距离增加;如果 n 为负,力随着距离的增加而减小。

例子包括弹簧力定律,其中 k 为负,n 为 1;重力,k 为负,n 为-2。常数 k 通常与粒子的某些性质有关(例如,质量或电荷)。

让我们给Forces对象添加一个中心力函数:

Forces.central = function(k,n,r) {

return r.multiply(k*Math.pow(r.length(),n-1));

}

以下来自文件central-forces.js的代码片段设置了一个受到朝向固定中心的中心力的粒子:

function init() {

center = new Ball(2,'#000000');

center.pos2D = new Vector2D(350,250);

center.draw(context_bg);

particle = new Ball(5,'#ff0000',mass,0,true);

particle.pos2D = new Vector2D(150,250);

particle.velo2D = new Vector2D(0,-20);

particle.draw(context);

setupGraph();

t0 = new Date().getTime();

t = 0;

animFrame();

}

function calcForce(){

var r = particle.pos2D.subtract(center.pos2D);

force = Forces.central(k,n,r);

}

代码的其余部分与前面的例子相似,包括像前面一样绘制粒子的最终轨迹。您可以试验不同的 k 和 n 值,以及不同的粒子初始位置和速度。

能不能总让一个粒子遵循一个有引力中心力(负 k)的闭合轨道?我们已经知道这对于 f (r) = k/r 2 定律(例如重力)和 f (r) = kr 定律(弹簧)是可能的。但是其他的力定律呢,比如 f (r) = k/r 或者 f (r) = r 3 呢?自己试试看吧!

作为一个例子,在以下初始条件下,以及(k =–1,n = 1),一个类似弹簧的力,你得到一个拉长的闭合轨道:

center.pos2D = new Vector2D(350,250);

particle.pos2D = new Vector2D(150,250);

particle.velo2D = new Vector2D(0,-20);

用(k =–100000,n =–2),一个类似重力的定律,你也会得到一个封闭的椭圆轨道。但是用(k =–1000,n =–1),一个 1/r 定律,你会得到一个不自我闭合的花状轨迹(见图 10-8);这是一个束缚轨道,但不是一个封闭的轨道。根据 1/r 3 定律,你会得到螺旋进入或螺旋离开的轨迹,但不闭合:它们既不闭合也不束缚。请注意,k 的大小需要根据 n 的值进行调整,以产生大小合适的位移来配合载物台。

A978-1-4302-6338-8_10_Fig8_HTML.jpg

图 10-8。

Trajectory traced by a particle in a 1/r force field

实际上有一个数学定理叫做 Bertrand 定理,它说唯一给出闭合轨道的中心力定律是 f (r) = k/r 2 和 f (r) = kr 定律。幸运的地球(和我们!)引力是 1/r 2 定律!

尽管有最后一个陈述,如果你有正确的切向速度,作为一个特例,对于所有吸引中心力,闭合圆轨道是存在的。很容易算出所需的速度。只要将力定律等同于向心力的公式,其中 m 是粒子的质量:

A978-1-4302-6338-8_10_Figy_HTML.jpg

求解 v 得出如下结果:

A978-1-4302-6338-8_10_Figz_HTML.jpg

你可以用模拟来测试这个公式。只要 k 为负,它对任何 n 值都有效。

重力与弹簧力定律?

你能想象如果重力遵循不同的力定律,例如弹簧力定律,宇宙会是什么样子吗?好吧,让我们来看看!

代码在文件spring-gravity.js中,同样改编自本章前面的例子。所以我们在这里只列出来,不做什么解释:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_bg = document.getElementById('canvas_bg');

var context_bg = canvas_bg.getContext('2d');

var vmax = 100;

var m = 1; // particles' mass

var M = 1; // center's mass

var G = 1;

var k = -G*M*m;

var n = 1;

var center;

var particles;

var t;

var t0;

var dt;

var force;

var acc;

var numParticles = 50;

window.onload = init;

function init() {

// create a stationary center

center = new Ball(20,'#ff0000',M,0,true);

center.pos2D = new Vector2D(400,300);

center.draw(context_bg);

// create particles

particles = new Array();

for (var i=0; i<numParticles; i++){

var particle = new Ball(4,'#000000',m,0,false);

particle.pos2D = new Vector2D(Math.random()*canvas.width,Math.random()*canvas.height);

particle.velo2D = new Vector2D((Math.random()-0.5)*vmax,(Math.random()-0.5)*vmax);

particle.draw(context);

particles.push(particle);

}

t0 = new Date().getTime();

t = 0;

animFrame();

};

function animFrame(){

requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

dt = 0.001*(new Date().getTime() - t0);

t0 = new Date().getTime();

t += dt;

move();

}

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

for (var i=0; i<numParticles; i++){

var particle = particles[i];

moveObject(particle);

calcForce(particle);

updateAccel(particle.mass);

updateVelo(particle);

}

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

obj.draw(context);

}

function updateAccel(mass){

acc = force.multiply(1/mass);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

function calcForce(particle){

var r = particle.pos2D.subtract(center.pos2D);

force = Forces.central(k,n,r);

}

这里的主要思想是,我们创造 50 个粒子,给它们随机的位置和速度,并使它们受到朝向固定中心的中心力。calcForce()方法基本上是计算一个中心力,用 k =–GMm,其中引力常数 G = 1,M 是中心的质量;而 m 是相关粒子的质量(这里都是 1)。因为 k 是负的,n = 1,我们有一个弹簧力。我们创造了弹力重力!

运行代码,获得一个有趣的洞察,如果重力遵守弹簧力定律,生活会是什么样子。你会看到所有的粒子都被拉向中心,经历某种振荡(见图 10-9 )。如果你追踪任何一个粒子,你会发现它描绘了一个围绕中心的闭合轨道(通常是拉长的)。如果你增加速度系数vmax的值,比如说增加到 1000,粒子会移动更多,但是仍然会被拉进这种振荡运动。根本没有办法摆脱弹簧重力!

A978-1-4302-6338-8_10_Fig9_HTML.jpg

图 10-9。

Modeling gravity with a spring force

具有不同引力定律的多重吸引子

在我们的最后一个例子中,force-fields.js,我们将结合不同 k 和 n 值的中心力来产生一个复杂的力场。这是一个不同的吸引子根据不同的力定律施加引力的宇宙。

让我们先来看看init()法:

function init() {

centers = new Array();

// k/r force

var center1 = new Ball(20,'#ff0000',1000,-1,true);

center1.pos2D = new Vector2D(200,500);

center1.velo2D = new Vector2D(10,-10);

center1.draw(context);

centers.push(center1);

// k/r2 force

var center2 = new Ball(20,'#00ff00',100000,-2,true);

center2.pos2D = new Vector2D(500,100);

center2.draw(context_bg);

centers.push(center2);

// k/r3 force

var center3 = new Ball(20,'#0000ff',10000000,-3,true);

center3.pos2D = new Vector2D(600,300);

center3.draw(context_bg);

centers.push(center3);

// create particles

particles = new Array();

for (var i=0; i<numParticles; i++){

var particle = new Ball(4,'#000000',1,0,false);

particle.pos2D = new Vector2D(Math.random()*canvas.width,Math.random()*canvas.height);

particle.velo2D = new Vector2D((Math.random()-0.5)*vmax,(Math.random()-0.5)*vmax);

particle.draw(context);

particles.push(particle);

}

t0 = new Date().getTime();

t = 0;

animFrame();

}

就像在spring-gravity.js中一样,我们设置了一堆粒子(实际上是 50 个)并给它们随机的位置和速度,最大速度值vmax设置为 20。然后我们设置三个吸引子,称为center1center2center3,将对它们的引用放在一个名为centers的数组中,并赋予它们 1000、100000 和 1000000 的质量;以及分别为–1、–2 和–3 的费用。电荷的值用于存储中心力定律指数 n 的值。没有理由我们不能这样做,因为我们不会在这里使用电荷。这意味着吸引子将分别施加 1/r、1/r 2 和 1/r 3 力定律。

我们给center1一个非零的速度,并通过在move()方法中包含以下加粗的代码行使其移动:

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

moveObject(centers[0]);

for (var i=0; i<numParticles; i++){

var particle = particles[i];

moveObject(particle);

calcForce(particle);

updateAccel(particle.mass);

updateVelo(particle);

}

}

calcForce()方法如下所示:

function calcForce(particle){

var central;

force = Forces.zeroForce();

for (var i=0; i<centers.length; i++){

var center = centers[i];

var k = -G*center.mass*particle.mass;

var n = center.charge;

var r = particle.pos2D.subtract(center.pos2D);

if (r.length() > center.radius){

central = Forces.central(k,n,r);

}else{

central = Forces.zeroForce();

}

force = Forces.add([force, central]);

}

}

这与前一个例子类似,除了我们现在有一系列吸引中心(见图 10-10 ),所以我们在calcForce()中对每个施加在for环中的中心力求和。注意,如果一个粒子恰好在中心,我们将力设置为零。这是为了提高视觉效果。毕竟这是我们自己创造的宇宙,所以我们可以为所欲为,不是吗?

A978-1-4302-6338-8_10_Fig10_HTML.jpg

图 10-10。

Particles moving in a field composed of different central forces

如果你运行代码,你会看到大多数粒子都被施加 1/r 力的中心吸引。随着它的移动,它会捕获更多的粒子。这个吸引子的重力占主导地位,因为 1/r 力是一个比 1/r 2 或 1/r 3 力更长范围的力:它随距离衰减得更慢。施加 1/r 3 力的吸引子抓住很少的粒子:它的影响力随着距离迅速衰减。

你可以尝试很多其他的事情;例如,使吸引器以更复杂的方式移动(例如,环绕一个中心或来回移动和弹跳)。你可以添加更多的吸引子或包括反重力。你只是被你的想象力所限制。

摘要

你现在有足够多的工具来创造有趣的运动类型。本书的第二部分到此结束。在第三部分中,你将应用你在第二部分中学到的知识来构建由相互作用的粒子或扩展物体组成的更复杂的系统。

十一、碰撞

这一章讨论了一个在任何动画或模拟中都必然会出现的重要主题:碰撞。更准确地说,它主要关注冲突解决——如何响应冲突事件——而不是冲突检测。冲突检测和冲突解决本身就是一个庞大的课题。一般物体碰撞检测的复杂性超出了本书的范围(因为我们专注于控制运动的物理学),但粒子之间的碰撞情况要简单得多,这是本章的主要主题。同样,不可能在一章中涵盖冲突解决的所有方面。因此,我们将集中讨论一些最常见的基础知识。具体地说,我们将把注意力限制在粒子与固定壁的碰撞(弹跳)和与其他粒子的碰撞上。

本章涵盖的主题包括以下内容:

  • 碰撞及其建模:这个简短的介绍性部分解释了什么是碰撞,并概述了本章将采用的建模方法。
  • 从水平或垂直的墙壁反弹:所谓墙壁,我们指的是粒子可以反弹的任何固定的平面。我们从一个球从水平或垂直的墙上反弹的例子开始。这种情况下的冲突解决很简单。反弹或壁摩擦造成的能量损失的影响也可以直接实现。
  • 从倾斜的墙壁上反弹:如果墙壁是倾斜的,情况就复杂多了。你将会看到解决与任何倾斜的墙壁碰撞的一般方法。
  • 1D 粒子之间的碰撞:当粒子沿着连接它们的线运动时,碰撞是一维的,可以通过应用动量和能量守恒定律以一种直接的方式解决。
  • 2D 粒子间的碰撞:最后一节讨论解决更复杂的二维粒子间碰撞的一般方法,这些粒子间的碰撞与连接它们的线成一定角度。

碰撞及其建模

我们将从定义本书上下文中的碰撞开始。两个物体之间的碰撞是一种短暂的相互作用,在此期间,它们接触并交换力,然后改变它们的运动。碰撞解决的任务是在碰撞发生后立即确定两个碰撞物体的运动。

在本书的第二部分,你已经习惯了用力来解决运动问题。碰撞通常不是这样处理的。很难对碰撞过程中施加的强大而短暂的力进行建模。通常处理碰撞的方式是通过它们的效果。根据牛顿第二运动定律,我们知道力会引起动量的变化。在第五章的中,这种关系通过脉冲δt 的概念以如下方式表达,其中δp 是脉冲产生的动量变化:

A978-1-4302-6338-8_11_Figa_HTML.jpg

对于具有恒定质量 m 的粒子:

A978-1-4302-6338-8_11_Figb_HTML.jpg

因此:

A978-1-4302-6338-8_11_Figc_HTML.jpg

因此,一个大而短暂的碰撞力,换句话说,一个冲量 fδt,会产生一个速度的变化,由前面的公式给出。

处理碰撞的方法是直接计算出速度的变化。如果你回头看第五章,你会回忆起这就是动量守恒原理的全部内容。但是动量守恒本身不足以解决碰撞问题。我们还必须假设碰撞物体的动能会发生什么变化。因此,碰撞所涉及的物理学包括动量和能量两个守恒定律。我们将在下文中或明或暗地应用它们。

概括地说,我们将模拟两种类型的碰撞:粒子-粒子碰撞和粒子-壁碰撞。这里的墙意味着一个不可移动的物体,它的运动本身没有被模拟。具体来说,我们将使用“墙”这个词来指代任何粒子可以从其反弹的固定平面。所以,我们的墙可以是水平的,也可以是垂直的,也可以是倾斜的;它们也可以是边界障碍或放置在模拟空间中任何其他位置的障碍。

从水平或垂直墙壁反弹

在碰撞物理学的第一个应用中,我们将看看如何使一个球从 2D 模拟空间中水平或垂直的直墙上反弹,如地板、天花板或房间墙壁。为了便于说明,我们将在这里使用垂直墙,但类似的原则也适用于水平墙。

我们将依次研究弹性和非弹性反弹(球的动能守恒或不守恒的反弹)。

弹性弹跳

如果球从墙上弹性反弹,它反弹后的动能必须等于反弹前的动能。因为 k.e. = mv 2 ,这暗示着它的速度大小并没有因为碰撞而改变。

首先考虑球以速度 v 垂直撞击墙壁的特殊情况(见图 11-1 )。在这种情况下,球也会以直角反弹回来。因此,因为它的速度大小不变,它在反弹后的速度将是-v。

A978-1-4302-6338-8_11_Fig1_HTML.jpg

图 11-1。

A ball bouncing off a wall at a right angle

这在代码中很容易实现:在碰撞检测之后,你只需反转速度方向。然而,还有其他的事情要考虑。当检测到碰撞时,球可能已经通过了墙的边缘,稍微穿透了墙。因此,在逆转球的速度之前,你需要将球重新定位在墙的边缘(见图 11-2 )。这不仅是为了避免球穿透墙壁的视觉效果,也是为了避免球“卡”在墙壁中的潜在问题。如果球的反向速度不足以使其穿出墙壁,则可能出现后一种情况(特别是如果速度也因能量损失而降低,见下一节)。在这种情况下,球的速度将在下一个时间步再次逆转,将其送回墙壁。

A978-1-4302-6338-8_11_Fig2_HTML.jpg

图 11-2。

Repositioning the ball after collision detection

以下代码说明了图 11-2 中所示场景的这些步骤,针对名为ballBall实例和名为wall的墙对象:

if (ball.x > wall.x - ball.radius){  // collision detection

ball.x = wall.x - ball.radius;  // reposition ball at wall edge

ball.vx *= -1; // reverse ball’s velocity

}

这简单的三条线概括了处理球与水平或垂直墙壁垂直碰撞的反弹的整个过程:

Collision detection: simple in this case.   Reposition the particle at the point of collision: in this case, shift it horizontally or vertically so that it just touches the wall.   Calculate the particle’s new velocity just after the collision: here, for elastic collisions normal (perpendicular) to the wall, just reverse the velocity.

请注意,在步骤 1 和 2 中,前面的代码假设球的位置是其中心位置。例如,如果球的位置被取为其边界框的左上角,那么它的直径应该在相应的代码行中减去。此外,这段代码只涵盖了球从左向右移动的情况,但不难看出如何修改它来将球从右向左移动。

这一切都很好也很简单,但是如果球以一个倾斜的角度击中墙壁(见图 11-3 )而不是以一个直角击中墙壁呢?在这种情况下,在执行步骤 3 时,我们需要分别考虑垂直和平行于壁面的速度分量。在图 11-3 所示的垂直墙的情况下,垂直分量为ball.vx,平行分量为ball.vy。我们需要像以前一样反转垂直分量,保持平行分量不变。这正是前一段代码所做的,所以它应该也能很好地处理间接影响。在这种情况下,将速度矢量分解为相对于壁面的法向(垂直)和平行(切向)分量的技术似乎是完全自然的。但它的应用更普遍;在下一节中,你会看到它的一个变体适用于两个粒子之间的碰撞。

A978-1-4302-6338-8_11_Fig3_HTML.jpg

图 11-3。

Ball bouncing off a wall at an oblique angle

如果你需要额外的精确,还有一些你需要担心的事情,这次是关于第二步。再看一下图 11-3 。在前面的代码中,我们通过沿 x 方向移动球来重新定位它,使它刚好碰到墙。但是当碰撞是斜的时候,球在碰撞点的真实位置是沿着碰撞线的。所以球也需要沿着 y 方向移动一点。在下一节中,我们将设计一个通用的方法来做到这一点。对于这个简单的例子,让我们只调整 x 位置。在大多数情况下,这应该可以很好地工作并产生平滑的反弹效果(除非您的机器速度很慢,在这种情况下,您可能会发现球偶尔会粘在墙上)。

可下载的文件wall-bouncing.js演示了这个简单的例子。代码如下:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_bg = document.getElementById('canvas_bg');

var context_bg = canvas_bg.getContext('2d');

var ball;

var wallX = 400;

var t0, dt;

window.onload = init;

function init() {

// create a ball

ball = new Ball(15,'#000000',1,0,false);

ball.pos2D = new Vector2D(100,100);

ball.velo2D = new Vector2D(200,50);

ball.draw(context);

// create a wall

context_bg.strokeStyle = '#333333';

context_bg.beginPath();

context_bg.moveTo(wallX,50);

context_bg.lineTo(wallX,350);

context_bg.closePath();

context_bg.stroke();

// make the ball move

t0 = new Date().getTime();

animFrame();

};

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

moveObject(ball);

checkBounce(ball);

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

context.clearRect(0, 0, canvas.width, canvas.height);

obj.draw(context);

}

function checkBounce(obj){

if (obj.x > wallX - obj.radius){

obj.x = wallX - obj.radius;

obj.vx *= -1;

}

}

如你所见,init()中的代码简单地创建了一个球作为Ball对象,并绘制了一条直线来表示一面墙。后续动画代码的大部分是熟悉的。在这里,我们不强加任何力量。所以,球的速度是恒定的,除了和墙碰撞的时候。关键的功能是由checkBounce()方法提供的,它包含一段条件代码,与前面给出的一个球与墙壁垂直碰撞的条件代码相同。请注意,如果墙在左侧或水平方向,则必须稍微修改此代码。如果墙既不是水平的也不是垂直的,而是成某个角度,您将需要比checkBounce()方法中的那三行代码多得多的代码,您很快就会发现这一点!

非弹性反弹

在前面的例子中,我们假设没有动能损失,换句话说,碰撞是弹性的。这样做意味着球以相同的速度反弹回来。在现实世界的碰撞中,总会有一些动能的损失。由于弹跳,很容易包括动能损失的影响。你所要做的就是在反转球的时候,把球垂直于墙的速度乘以一个小于 1 的因子。

因此,在前面的示例中,以下行:

ball.vx *= -1;

将被替换为类似这样的内容,其中vfac是一个介于 0 和 1 之间的数字:

ball.vx *= -vfac; // energy loss on bouncing

值为 1 对应于弹性碰撞,而值为 0 对应于完全非弹性碰撞(稍后会更详细地讨论),在这种碰撞中,球只是粘在墙上。您可以尝试不同的vfac值,看看会得到什么。

当我们讨论一维碰撞时,将速度乘以这个常数因子的原因将变得很清楚。

类似地,您可以通过将球的切向速度(平行于墙壁的速度分量)乘以 0 到 1 之间的系数,以简单的方式模拟墙壁摩擦的影响:

ball.vy *= vfac2; // wall friction

请注意,在这种情况下没有负号,因为摩擦力减小了,但没有使切向速度反向。

从倾斜的墙上反弹回来

如果球反弹的墙壁与水平面或垂直面成一定角度,情况就会变得复杂得多。同样的步骤也适用于水平墙或垂直墙的情况,但现在实施起来稍微复杂一些。这里可以采取不同的方法。Billy Lamberta 和 Keith Peters (Apress,2011)在《使用 JavaScript 的基础 HTML5 动画》一书中描述了一种常见的方法,即执行坐标旋转以使墙壁水平,进行反弹,然后将坐标系旋转回来。这是一个非常好的方法,我们建议你参考那本书来了解程序的细节。对于我们目前的目的,因为我们使用向量,我们已经设计了一种替代方法,利用向量公式并避免两次坐标旋转。在举例说明如何实施之前,我们详细解释了新方法的步骤。

具有倾斜壁的碰撞检测

当从倾斜的墙壁上反弹时,碰撞检测要简单得多,因为这不仅仅是检查球相对于墙壁的 x 或 y 位置的情况。但是从向量的角度思考很有帮助。看一下图 11-4 ,它显示了一个粒子接近与一面墙的碰撞。发生碰撞基本上必须满足两个条件:

A978-1-4302-6338-8_11_Fig4_HTML.jpg

图 11-4。

A ball approaching a wall

  • 粒子离壁的垂直距离必须小于它的半径。
  • 粒子必须位于墙的端点之间。

有许多方法可以实现这两种情况。我们在这里描述一种可能的方法。图 11-5 显示了与图 11-4 中的设置相对应的位置和位移矢量图。在该图中,p1 和 p2 是壁的端点相对于原点 O 的位置向量;w 是从标记为 A 的端点(具有位置向量 p1)到另一个端点的向量。类似地,p 是球相对于原点的位置向量,b1 和 b2 是从球到墙的端点的向量,d 是从球到墙的垂直向量(因此 d 等于球到墙的距离 PQ)。注意,我们把一个质点的位置定在它的中心。从Ball对象创建的任何Ball实例都是如此。

A978-1-4302-6338-8_11_Fig5_HTML.jpg

图 11-5。

Vector diagram for analyzing the collision of a ball with an inclined wall

根据这些向量,第一个条件简单如下,其中 r 是球的半径:

A978-1-4302-6338-8_11_Figd_HTML.jpg

第二个条件可以用矢量 b1 和 b2 在墙壁矢量 w 的方向上的投影 QA 和 QB 来表示。如果这两个投影的长度都小于 w 的长度,这意味着球在墙壁的端点之间。否则,如果任何一个投影超过 w 的长度,球一定在那个范围之外,不会碰到墙。

现在仍然需要计算投影的长度以及与已知向量 p1、p2 和 p 的距离 d。通过使用向量加法的规则,我们得到以下结果:

A978-1-4302-6338-8_11_Fige_HTML.jpg

在伪代码中,这是:

wallVec = wall.p2.subtract(wall.p1);

通过类似的推理,向量 b1 和 b2 由这些等式给出:

A978-1-4302-6338-8_11_Figf_HTML.jpg

A978-1-4302-6338-8_11_Figg_HTML.jpg

在伪代码中,它们可以写成如下形式:

ballToWall1 = wall.p1.subtract(ball.pos2D);

ballToWall2 = wall.p2.subtract(ball.pos2D);

那么向量ballToWall1ballToWall2在 w 上的投影由下式给出:

proj1 = ballToWall1.projection(wallVec);

proj2 = ballToWall2.projection(wallVec);

向量 d,在伪代码中我们称之为dist,可以通过从 b1 中减去 b1 在 w 上的投影向量来获得。在伪代码中,它可以写成这样:

dist = ballToWall1.subtract(ballToWall1.project(wallVec));

本章稍后将介绍project()功能。你也许可以用许多不同但等价的方式来表达这些量。我们把它留给你作为练习!

重新定位粒子

您在上一节中看到的用于在碰撞检测时重新定位粒子的公式特定于相对于球的位置位于右侧的垂直墙。对于左侧、顶部或底部的墙壁,其形状看起来略有不同。而且如果墙是斜的就不好了。我们需要更好的东西。这就是载体再次出手相救的地方。用矢量方法,有可能得出一个适用于任何粒子速度和任何壁面方向的公式。

图 11-6 显示了一个球撞击一面倾斜的墙壁,当球撞击墙壁一小段距离后,碰撞被检测到。如图所示,球必须沿其接近路线向后移动δs = h 的距离(与其速度矢量相反)。这里我们假设速度矢量的方向在这个小区间内是恒定的。如果存在重力这样的力,严格来说这是不正确的,但是因为时间间隔很小,所以速度方向可以作为一个非常好的近似常数。

A978-1-4302-6338-8_11_Fig6_HTML.jpg

图 11-6。

Repositioning a particle after collision detection with an inclined wall

然后,简单的三角学表明,δs 的大小由以下表达式给出,其中 d 是从碰撞检测点到壁的粒子中心的垂直矢量(因此其大小 d 是粒子到壁的距离),n 是垂直于壁并指向粒子通常所在侧的单位矢量,θ是粒子的接近线(以及碰撞前的速度)和壁之间的角度:

A978-1-4302-6338-8_11_Figh_HTML.jpg

请注意,如果球在碰撞检测点的中心位于墙的前面,则 d 与 n 相反,因此 d∙n =–d;如果它位于墙后,则 d 指向 n,因此 d ∙ n = d。使用点积可以自动处理这两种情况。

在伪代码中,重新定位粒子的位移向量由下式给出:

displ = particle.velo2D.para(deltaS);

因此,粒子的位置需要更新如下:

particle.pos2D = particle.pos2D.subtract(displ);

与为每面墙使用特殊的非矢量代码相比,这一切看起来既漂亮又简单。

计算新的速度

就像水平或垂直墙一样,粒子在反弹后的新速度是通过首先将反弹前的速度分解为平行和垂直于墙的分量,然后反转法线分量来计算的。但是因为墙现在是倾斜的,那些分量不仅仅是粒子的vxvy属性。相反,我们必须把它们指定为拥有自身权利的Vector2D物体。

参见图 11-7 了解如何操作。如果 d 是从质点中心到碰撞点垂直于壁面的矢量,则法向速度是一个矢量,其大小等于质点速度在 d 上的投影,方向与 d 相同。

A978-1-4302-6338-8_11_Fig7_HTML.jpg

图 11-7。

Decomposing the velocity vector

在伪代码中,如果我们将粒子到墙壁的垂直矢量计算为dist,并将粒子表示为particle,则使用Vector2D对象的projection()para()方法,法向速度如下所示:

normalVelo = dist.para(particle.velo2D.projection(dist));

回想一下,vec1.projection(vec2)给出了矢量vec1vec2方向上的投影长度,而vec.para(mag)给出了一个平行于矢量vec且长度为mag的矢量。

我们可以通过在Vector2D对象中创建下面的project()方法来使代码更加简单:

function project(vec) {

return vec.para(this.projection(vec));

}

因此,vec1.project(vec2)给出了矢量vec2方向上的矢量,其大小等于vec1vec2方向上的投影。用这个新定义的方法,我们可以简单地写出法向速度如下:

normalVelo = particle.velo2D.project(dist);

切向速度可以简单地通过从粒子速度中减去法向速度得到(见图 11-7 ):

tangentVelo = particle.velo2D.subtract(normalVelo);

这些是预碰撞速度。假设没有能量损失,碰撞将反转法向速度,同时保持切向速度不变。因此,作为碰撞的结果,粒子的新速度由下式给出:

particle.velo2D = tangentVelo.addScaled(normalVelo,-1);

如果反弹导致能量损失,只需用上一节中讨论的分数 v fac替换之前代码中的–1。

碰撞前的速度修正

如果您正在创建一个包含力(如重力)的模拟,那么还有其他事情需要担心。只要有一个力参与,就意味着粒子在加速(它的速度一直在变)。在这种情况下,当粒子在墙的边缘重新定位时,其速度也必须调整到该点的速度。虽然这看起来像是一个小的修正,但在某些情况下,不这样做可能会导致严重的准确性损失。如果速度随时间变化,这同样适用于水平或垂直墙壁。

让我们考虑一个简单的例子:一个球从一定高度落到水平的地板上。假设当球稍微越过地板时,碰撞被检测到。如果你简单地按照到目前为止给出的程序,你将把球移动到地板的水平,然后逆转它的速度(如果你假设没有因反弹而损失能量)。问题是,由于球在重力作用下加速,它在碰撞检测点的速度比实际碰撞点的速度要高。通过简单地逆转这个速度,你就把它带走了比它应该有的更高的动能,这是因为它从最初的高度下落,失去了势能。结果是球最终会到达比它落下的地方更高的地方!在几次反弹之后,动画可能会变得完全不稳定。当然,如果你在弹跳时实现能量损失,你甚至可能不会注意到这个问题——如果精度不重要,这可能是解决问题的一个简单技巧。但是,如果你想尽可能精确,或者如果模拟的性质不允许你包括能量损失,那么这里是这样做的方法。

目的是在粒子加速的情况下,计算出粒子在碰撞点的速度。我们知道碰撞检测点的速度、加速度和位移。接下来,让我们从加速度的微积分定义开始:

A978-1-4302-6338-8_11_Figi_HTML.jpg

由于一个很快就会变得明显的原因,我们可以把它写成如下形式(你可以把两个 ds 因子看作是“抵消”):

A978-1-4302-6338-8_11_Figj_HTML.jpg

现在,因为 v = ds/dt,我们可以写为:

A978-1-4302-6338-8_11_Figk_HTML.jpg

这给了我们一个微分方程,它将我们刚刚讨论的量联系起来。因为我们正在进行离散模拟,所以让我们这样写:

A978-1-4302-6338-8_11_Figl_HTML.jpg

代入前面的等式得出:

A978-1-4302-6338-8_11_Figm_HTML.jpg

所以:

A978-1-4302-6338-8_11_Fign_HTML.jpg

该公式给出了加速度 a 下位移δs 引起的速度变化δv。设 v 为碰撞检测时的速度,v’为碰撞时的速度,因此:

A978-1-4302-6338-8_11_Figo_HTML.jpg

简单代数给出了这个:

A978-1-4302-6338-8_11_Figp_HTML.jpg

该公式根据加速度(在重力情况下是常数)、碰撞检测点的速度以及粒子从碰撞点到检测到碰撞的位置的位移,给出了碰撞点的速度与碰撞检测点的速度的比值。后一个量在“重新定位粒子”一节中计算出来因此,速度的调整系数可以根据前面公式给出的这些已知量来计算。该公式是针对 1D 情况推导的,因为我们没有使用矢量,但当加速度 a 和位移δs 是可能不在同一方向的矢量时(例如,对于在重力作用下运动的球),该公式同样适用于 2D。在这种情况下,我们将加速度分解为位移方向,因此实际上我们需要用点积 a .δs 代替乘积 aδs;v 是速度矢量的大小。

让我们将速度校正因子 v'/v 对应的代码变量表示为vcor。然后,假设速度的方向在碰撞点和碰撞检测点之间没有太大变化,我们可以用伪代码来写:

veloAtCollision = veloAtCollisionDetection.multiply(vcor);

例子:一个球从一面倾斜的墙上反弹回来

我们一直在讨论的向量代数可能开始听起来有点沉重了。因此,现在是时候将您在本节中学到的知识整合起来,构建一个完整的示例了。因为我们要在墙壁上做很多弹跳,所以创建一个可重用的Wall对象是有意义的。让我们先做那件事。

创建墙对象

Wall对象是一个简单的对象,它在两个指定的端点之间画一条线来代表一面墙。端点是Vector2D对象。下面是Wall对象的完整代码:

function Wall(p1,p2){

this.p1 = p1;

this.p2 = p2;

this.side = 1;

}

Wall.prototype = {

get dir (){

return this.p2.subtract(this.p1);

},

get normal (){

return this.dir.perp(1);

},

draw: function (context) {

context.save();

context.strokeStyle = '#000000';

context.lineWidth = 1;

context.beginPath();

context.moveTo(this.p1.x,this.p1.y);

context.lineTo(this.p2.x,this.p2.y);

context.closePath();

context.stroke();

context.restore();

}

}

p1p2表示的端点在Wall的构造函数中作为参数提供。属性wall.dirwall.normal通过 getters 定义,getters 分别返回从p1p2的向量(沿着墙壁的向量)和垂直于墙壁的单位向量。这些特性的用处将很快变得显而易见。属性将在“处理潜在的‘隧道’问题”一节中讨论

创建墙实例

在文件wall-object.js中,我们通过下面这段代码演示了如何创建一个与水平面成斜角的Wall实例:

var p1 = new Vector2D(100,200);

var p2 = new Vector2D(250,400);

var wall = new Wall(p1,p2);

wall.draw(context);

所以端点向量p1p2分别是【100,200】和【250,400】。

让一个球从倾斜的墙上反弹回来

bouncing-off-inclined-wall.js文件中,我们首先创建一个球和一面倾斜的墙,然后使用calcForce()中带有重力Forces.constantGravity的普通动画代码,使球在重力作用下移动到墙上(见本章后面的图 11-9 )。move()方法添加了一个新方法checkBounce(),如下所示:

function checkBounce(obj){

// vector along wall

var wdir = wall.dir;

// vectors from ball to endpoints of wall

var ballp1 = wall.p1.subtract(obj.pos2D);

var ballp2 = wall.p2.subtract(obj.pos2D);

// projection of above vectors onto wall vector

var proj1 = ballp1.projection(wdir);

var proj2 = ballp2.projection(wdir);

// perpendicular distance vector from the object to the wall

var dist = ballp1.addScaled(wdir.unit(), proj1*(-1));

// collision detection

var test = ((Math.abs(proj1) < wdir.length()) && (Math.abs(proj2) < wdir.length()));

if ((dist.length() < obj.radius) &&  test){

// angle between velocity and wall

var angle = Vector2D.angleBetween(obj.velo2D, wdir);

// reposition object

var normal = wall.normal;

if (normal.dotProduct(obj.velo2D) > 0){

normal.scaleBy(-1);

}

var deltaS = (obj.radius+dist.dotProduct(normal))/Math.sin(angle);

var displ = obj.velo2D.para(deltaS);

obj.pos2D = obj.pos2D.subtract(displ);

// velocity correction factor

var vcor = 1-acc.dotProduct(displ)/obj.velo2D.lengthSquared();

// corrected velocity vector just before impact

var Velo = obj.velo2D.multiply(vcor);

// velocity vector component perpendicular to wall just before impact

var normalVelo = dist.para(Velo.projection(dist));

// velocity vector component parallel to wall; unchanged by impact

var tangentVelo = Velo.subtract(normalVelo);

// velocity vector component perpendicular to wall just after impact

obj.velo2D = tangentVelo.addScaled(normalVelo,-vfac);

}

// collision at the wall boundaries

else if (Math.abs(ballp1.length()) < obj.radius){

bounceOffEndpoint(obj,wall.p1);

}

else if (Math.abs(ballp2.length()) < obj.radius){

bounceOffEndpoint(obj,wall.p2);

}

}

这段代码很好地反映了前面几节中给出的描述。因此,大部分内容应该是简单明了的(我们保留了注释,这样您可以更容易地理解逻辑)。因此,让我们只关注前面讨论中没有涉及的几个特性。

首先,在碰撞检测后重新定位球的代码中,注意我们检查墙法向量是否与球的速度向量有正的点积;如果是这样,我们反转墙的法向量。要理解这样做的原因,请参考“重新定位粒子”一节,其中我们说过法线必须指向粒子所在的一侧。后一个条件相当于说法向量应该指向,这样它就有一个与球的速度方向相反的法向量分量,因此应该有一个负的点积(见图 11-8 )。代码强制执行那个条件。但是为什么我们需要在每一个时间点都检查它呢?我们不能一劳永逸地恢复正常吗?原因是这样做,我们考虑到了球可能在墙的底面和顶面反弹的可能性。

A978-1-4302-6338-8_11_Fig8_HTML.jpg

图 11-8。

The unit normal vector to the wall should be oriented opposite to the velocity vector

要指出的第二件事是在checkBounce()函数的末尾出现了两个else if块。他们在那里处理球在墙的任一端点的半径内的情况。在那种情况下,他们称之为一种新方法:bounceOffEndpoint()。这个想法是,从墙壁边缘反弹应该引起不同类型的运动,因此应该以不同的方式处理。我们采用的方法是以类似于两个粒子之间碰撞的方式来处理这种碰撞,另外一个特点是,这里的一个“粒子”,即壁端点,是固定的,半径为零。在bounceOffEndpoint()方法中的代码现在可能没有太多意义,但是一旦你在本章后面讲述了粒子间碰撞的内容,它就有意义了。

为了使模拟更具交互性和趣味性,还有一些代码允许您在画布上的任意位置单击和拖动来改变球的位置和大小。释放鼠标后,新球将继续模拟。

花些时间进行模拟实验。单击并拖动以创建不同大小的球,并查看它们如何以自然的方式下落并反弹到倾斜的墙上。拖动时避免球与墙重叠,因为这将导致不稳定的行为。将球释放到墙的端点上,注意它反弹方式的不同。改变重力g和能量损失系数vfac。仿真截图见图 11-9 。

A978-1-4302-6338-8_11_Fig9_HTML.jpg

图 11-9。

A ball bouncing off an inclined wall

接下来,将球释放到线的正上方,这样球就开始沿着墙滑下。你可能会发现,如果球只是滑动而没有反弹,它往往会被“卡住”。问题是我们的代码处理冲突解决,而不是联系解决。要伪造接触分辨率,您可以在球的位置向量pos2D更新后,通过在checkBounce()中引入以下行来欺骗:

obj.y -= 0.1;

每次球接触墙壁时,它都会将球稍稍抬高(仅 0.1 像素)。结果是球再次落回墙上,当它沿着墙滑下时,经历了一系列连续的碰撞。用这条额外的线再次运行模拟,看看球现在是如何滑下墙壁的,看起来很平稳。这很容易让人想起我们在第七章中使用法向接触力模拟球沿斜面下滑的场景。事实上,这是处理接触分辨率的一种常见方法:使用由“微碰撞”产生的脉冲,而不是通过显式包含法向接触力。

处理潜在的“隧道”问题

我们在上一节中描述并在本模拟中实现的碰撞检测方法在大多数正常情况下应该工作良好。但在某些情况下,特别是如果模拟在慢速机器上运行,并且球很小且移动很快,球可能会在单个时间步长内完全穿过墙壁。在这种情况下,碰撞检测将失败。这就是所谓的隧道效应。如果你把球做得很小(1 px ),然后把它放在离墙很远的地方,你可能会看到这种效果,这样当它碰到墙的时候就达到了很高的速度。球可能会直接穿过墙。

前一节中概述的碰撞检测算法失败,因为第一个条件(粒子比其半径更接近墙壁)从未满足。我们需要修改算法以包含新的可能场景。一个简单的解决方法是测试球的中心是否已经从墙的一边移动到另一边。如果是这样,这意味着它已经“隧道化”,这本身就提供了冲突检测。

我们已经用源文件bouncing-off-inclined-wall2.js在模拟的修改版本中实现了这种碰撞检测机制。如果将修改后的代码与原始代码进行比较,您会发现一个关键的变化是在init()onUp()方法中加入了新的checkSide()setSide()方法:

function checkSide(){

var wdir = wall.dir;

var ballp1 = wall.p1.subtract(ball.pos2D);

var proj1 = ballp1.projection(wdir);

var dist = ballp1.addScaled(wdir.unit(), proj1*(-1));

setSide(dist);

}

function setSide(dist){

if (dist.dotProduct(wall.normal) > 0){

wall.side = 1;

}else{

wall.side = -1;

}

}

checkBounce()中也调用了setSide()方法。这个代码提供了一种方法来跟踪球在任何时候在每面墙的哪一边。为此,它检查从粒子到墙壁的垂直矢量dist和墙壁法向量之间的点积的符号。如果符号为负,它将墙的side属性设置为–1;否则,它将其设置为 1。Wall对象的side属性就是专门为此目的而创建的。

checkBounce()中另一段重要的新代码检查点积的符号是否反转(这意味着球已经隧穿);如果是,它将变量testTunneling Boolean设置为真:

var testTunneling;

if (wall.side*dist.dotProduct(wall.normal) < 0){

testTunneling = true;

}else{

testTunneling = false;

}

testTunneling的值随后被用作碰撞检测测试的一部分:

if (( (dist.length() < obj.radius) || (testTunneling) ) &&  test){

...

}

这就建立了隧道情况下的基本冲突检测。代码中的其余更改应该很容易理解。

如果你测试bouncing-off-inclined-wall2.js,你会发现隧道问题已经解决了。

示例:球从多面倾斜的墙壁上反弹

bouncing-off-multiple-inclined-walls.js中,我们将前面的模拟归纳为包括几面墙(见图 11-10 )。看一看源文件;该代码以一种简单明了的方式扩展了前面的示例。首先,你用同样的方法在init()中添加更多的墙——四面倾斜的墙,加上四面包围墙,形成一个包围其他墙和球的盒子。checkBounce()方法现在在每面墙上循环。它还包括一个新的hasHitAWall Boolean变量,用于在检测到与其中一面墙发生碰撞时停止循环。

A978-1-4302-6338-8_11_Fig10_HTML.jpg

图 11-10。

Ball bouncing off multiple walls

尝试模拟,记住您可以单击并拖动来重新定位和调整球的大小。如前所述,避免定位球,使其与任何墙壁重叠。这样做将引入非物理的初始条件(球不能与墙物理重叠),这将导致一些方程给出无意义的结果,导致不稳定和不可预测的行为。

查看模拟如何正确处理不同的场景。例如,尝试不同大小的球,并在不同的位置释放它们:在墙壁上方,以便它们弹开;就在墙壁上,让它们滑下来;或者刚好在对他们来说太小的墙壁之间的缝隙上方,这样他们就会被困住。你会看到墙下的碰撞也得到妥善处理。请注意,四面围壁的处理方式与其他围壁完全相同。你也可以在顶壁上方点击拖动,让球从上面弹开!

与前面的模拟一样,您可能会在较慢的机器上遇到隧道问题。为了解决这个问题,我们按照与上一小节中描述的完全相同的方式修改了模拟。修改后的源代码在文件bouncing-off-multiple-inclined-walls2.js中。您可能会发现,隧道效应有时仍然会以非常高的速度发生(例如,尝试用超过 1000 px/s 的幅度初始化球的速度)。这里的问题是,我们处于模拟的时间分辨率的极限,这是由你的机器运行它的速度所限制的。

1D 粒子间的碰撞

在本节中,我们将向您展示当粒子的接近方向位于连接它们的直线上时,如何处理粒子之间的碰撞。这种情况可以被描述为 1D 碰撞,因为在碰撞前后,粒子的运动都是沿直线进行的。一般来说,两个粒子可能以任何角度碰撞,并在碰撞时改变方向。因此,本节中讨论的案例可能看起来是一个非常特殊的案例。那么为什么要研究它呢?原因是,在这种特殊情况下成立的方法和公式可以很容易地推广到粒子可以以任何角度碰撞的更一般的情况。事实上,我们将在下一节中介绍这一点。

处理 1D 粒子碰撞的步骤如下(与解决垂直于墙壁移动的粒子的反弹的步骤类似):

Collision detection   Repositioning the particles at the moment of collision   Calculating the new velocities just after the collision

对于球形粒子(在 2D 是圆形),第一步碰撞检测非常简单。正如在第二章中所讨论的,你只需查看两个粒子中心之间的距离 d 是否小于它们的半径之和,其中 d 是使用勾股定理从位置坐标(x 1 ,y 1 )和(x 2 ,y 2 )计算出来的:

A978-1-4302-6338-8_11_Figq_HTML.jpg

在 2D:

A978-1-4302-6338-8_11_Figr_HTML.jpg

现在让我们看看当检测到碰撞时如何重新定位粒子。

重新定位粒子

就像当侦测到碰撞时,一个粒子可能会沉入墙中一段距离,两个粒子也可能会重叠。这种情况的复杂性在于,一般来说,两个粒子都可能在运动,所以两个粒子都需要回到它们实际接触点的位置。但是我们怎么知道每个粒子要移动多少呢?

让我们首先考虑两个粒子相向运动的情况,如图 11-11 所示。

A978-1-4302-6338-8_11_Fig11_HTML.jpg

图 11-11。

Separating overlapping particles moving toward each other

重叠量 L 由下式给出,其中 r 1 和 r 2 是两个粒子的半径,d 是它们中心之间的距离,由上一节讨论的毕达哥拉斯公式给出:

A978-1-4302-6338-8_11_Figs_HTML.jpg

设 s 1 和 s 2 为质点必须沿相反方向移动的位移量,以使它们刚好接触。这些位移必须相加得出重叠距离 L:

A978-1-4302-6338-8_11_Figt_HTML.jpg

这给出了一个关于已知变量 l 的 s 1 和 s 2 的方程。为了能够计算出 s 1 和 s 2 ,我们需要一个连接它们的第二个方程。我们可以通过回忆得出这样一个等式,在一个小的时间间隔 t 内:

A978-1-4302-6338-8_11_Figu_HTML.jpg

A978-1-4302-6338-8_11_Figv_HTML.jpg

其中 u 1 和 u 2 是碰撞前各自粒子的速度。那只是利用了位移=速度×时间这个事实(见第四章)。

将第一个等式除以第二个等式得出:

A978-1-4302-6338-8_11_Figw_HTML.jpg

这是我们的第二个方程。现在这是一个简单的代数问题,求解 s 1 和 s 2 给出:

A978-1-4302-6338-8_11_Figx_HTML.jpg

A978-1-4302-6338-8_11_Figy_HTML.jpg

这个结果告诉我们的是,在撞击之前,粒子的位移必须与它们的速度成正比。

这很好,但如果我们有其他可能的情况,其中两个粒子在同一个方向上移动(而不是彼此相向),后面的一个移动得更快(因此实际上发生了碰撞),如图 11-12 所示。

A978-1-4302-6338-8_11_Fig12_HTML.jpg

图 11-12。

Overlapping particles moving in the same direction

在这种情况下,连接位移量 s 1 和 s 2 的第一个方程变成如下,而第二个方程不变:

A978-1-4302-6338-8_11_Figz_HTML.jpg

同时求解它们就得到这个:

A978-1-4302-6338-8_11_Figaa_HTML.jpg

还有这个:

A978-1-4302-6338-8_11_Figbb_HTML.jpg

这有点混乱,因为我们有不同的方程,取决于粒子是否相向运动。幸运的是,通过考虑向量位移,可以将两种情况合并成一个解决方案。推理稍微不太直观,但这是最终结果,其中符号|v|表示矢量 v 的大小:

A978-1-4302-6338-8_11_Figcc_HTML.jpg

A978-1-4302-6338-8_11_Figdd_HTML.jpg

注意,我们总是除以相对速度的大小(u1–u2)。当粒子相互靠近时,这是(u1+u2);否则为(u1–u2)。因此,在前面讨论的两种情况下,这些方程实际上简化为相应的位移量方程。另一件好事是,这里位移的方向在公式中也是明确的:负号告诉我们 s 1 与 u 1 相反,s 2 与 u 2 相反。

现在,您知道了一旦检测到碰撞,如何重新定位粒子。最后要做的是计算碰撞后碰撞粒子的新速度。我们将对弹性和非弹性碰撞分别做这个。

弹性碰撞

回想一下第四章,弹性碰撞是一种动能守恒的碰撞。这意味着除了动量守恒,我们还可以应用动能守恒(在任何碰撞中,无论是否有弹性,动量总是守恒的)。例如,台球之间的碰撞通常被建模为弹性碰撞。

设两个碰撞的粒子(我们可以称之为 particle1 和 particle2)分别有质量 m 1 和 m 2 ,初速度 u 1 和 u 2 ,终速度 v 1 和 v 2 。注意,我们只需要速度的一个分量,因为运动是 1D。那么我们可以把动量守恒和能量守恒写成:

A978-1-4302-6338-8_11_Figee_HTML.jpg

A978-1-4302-6338-8_11_Figff_HTML.jpg

这里粒子的质量 m 1 和 m 2 已知,初速度 u 1 和 u 2 也已知。目的是根据这些已知变量计算最终速度 v 1 和 v 2 。这是一个同时求解这两个方程的问题。最简单的方法是得出一个重要的中间结果,包括碰撞前后粒子的相对速度。所以让我们现在就开始吧。

碰撞粒子的相对速度

推导碰撞粒子的相对速度的第一步是通过组合方程每一侧每个粒子的项来重新排列前面的两个方程(将所有下标为 1 的项放在一侧,下标为 2 的项放在另一侧):

A978-1-4302-6338-8_11_Figgg_HTML.jpg

A978-1-4302-6338-8_11_Fighh_HTML.jpg

现在回想一下你的学校代数中的下列等式:

A978-1-4302-6338-8_11_Figii_HTML.jpg

这允许您将第二个等式写成如下:

A978-1-4302-6338-8_11_Figjj_HTML.jpg

你现在可以看到,这个方程的每一边都包含第一个方程的相应一边作为因子。所以你可以用这个方程除以第一个方程,去掉那些因素,留给我们这个:

A978-1-4302-6338-8_11_Figkk_HTML.jpg

这可以通过结合等式一侧的初始速度(u 变量)和另一侧的最终速度(v 变量)来重新排列,得到:

A978-1-4302-6338-8_11_Figll_HTML.jpg

或者,相当于,这个:

A978-1-4302-6338-8_11_Figmm_HTML.jpg

这是我们的结果。注意(u1–u2)是从粒子 2 的角度来看粒子 1 的初始相对速度。同样,(v1–v2)是从粒子 2 的角度来看粒子 1 的最终相对速度。因此,前面的结果告诉我们,两个粒子分离的相对速度是它们相对接近速度的负值。这个结果不取决于粒子的质量,因为 m 1 和 m 2 被抵消了。但是请注意,它只适用于弹性碰撞。在下一节中,我们将看到它是如何被修正用于非弹性碰撞的。

作为这个结果的直接应用,考虑其中一个粒子,比如粒子 2,不可移动的情况(比如一堵墙)。这和说 u 2 = v 2 = 0 是一样的。将这些值代入最后一个等式,我们得到了这个结果:

A978-1-4302-6338-8_11_Fignn_HTML.jpg

这是一个不可移动的物体的弹性反弹,这是我们在第一个例子中应用的一个球从墙上弹性反弹的条件。

计算弹性碰撞后的速度

利用上一节的结果,现在很容易计算出弹性碰撞后粒子的速度。逻辑是能量守恒被结合到相对速度的方程中:

A978-1-4302-6338-8_11_Figoo_HTML.jpg

所以我们需要同时求解这个方程和动量守恒方程:

A978-1-4302-6338-8_11_Figpp_HTML.jpg

目标是根据其他变量(初始速度和质量)找到最终速度 v 1 和 v 2 。这可以通过消除两个等式之间的 v 1 或 v 2 来完成,然后重新排列得到的等式以找到另一个。例如,我们首先将第一个等式乘以 m 2 得出:

A978-1-4302-6338-8_11_Figqq_HTML.jpg

将此加到第二个等式中,然后去掉 v 2 ,得到:

A978-1-4302-6338-8_11_Figrr_HTML.jpg

除以(m 1 +m 2 )得到粒子速度 1 的最终结果:

A978-1-4302-6338-8_11_Figss_HTML.jpg

您可以通过类似的代数运算来计算 v 2 ,但没有必要这样做:您可以交换最后一个等式中的 1 和 2 索引,得到如下结果:

A978-1-4302-6338-8_11_Figtt_HTML.jpg

这就是你想要的结果:你现在可以从碰撞前的速度(和粒子的质量)计算出碰撞后两个粒子的速度。

最后注意,你不必从第二个等式计算 v 2 。一旦你知道了 v 1 ,你就可以通过重新排列相对速度的方程来计算出 v 2 ,给出如下公式,这样可以节省 CPU 时间:

A978-1-4302-6338-8_11_Figuu_HTML.jpg

一个特例:质量相等的粒子

在具有相同质量的粒子的特殊情况下,它们碰撞后的速度公式具有特别简单的形式。要了解这一点,只需将 m 1 = m 2 = m 代入前面的等式即可得出:

A978-1-4302-6338-8_11_Figvv_HTML.jpg

还有这个:

A978-1-4302-6338-8_11_Figww_HTML.jpg

换句话说,粒子只是在碰撞后交换速度!只要粒子具有相同的质量,并且碰撞是弹性的,这一点与粒子的初速度无关。这是一个非常有用的结果。如果您的模拟将只涉及相同质量的粒子之间的弹性碰撞,则没有必要在前面给出的复杂通用公式中编码,从而节省宝贵的 CPU 时间。你可能记得我们在第五章的简单碰撞例子中利用了这个结果。

作为这个特例的特例,如果其中一个粒子初始静止(u 2 = 0),我们得到 v 1 = 0,v2= u1;另一个粒子在碰撞后突然停止在它的轨道上,而最初静止的粒子以另一个粒子在碰撞前的速度离开。

另一个特例:与质量大得多的粒子碰撞

另一方面,如果一个粒子的质量比另一个大得多(例如,m2>m1),最终速度的公式简化为:

A978-1-4302-6338-8_11_Figxx_HTML.jpg

还有这个:

A978-1-4302-6338-8_11_Figyy_HTML.jpg

特别是,如果质量大的粒子(particle2)最初是静止的,u 2 = 0,那么

A978-1-4302-6338-8_11_Figzz_HTML.jpg

A978-1-4302-6338-8_11_Figaaa_HTML.jpg

因此,较轻的粒子被较重的粒子反弹,几乎逆转了它的速度。注意到(m 1 /m 2 )是一个远小于 1 的小数字,我们得出 v2<<u1;因此,较重的粒子以比第一个粒子撞击它的速度小得多的速度离开。这都是常识。

非弹性碰撞

虽然通常将碰撞建模为弹性的,但现实生活中的大多数碰撞很少是弹性的——它们通常涉及动能的损失,因此是非弹性的。

为了模拟非弹性碰撞,一个有用的起点是碰撞粒子的相对速度——通过引入恢复系数的概念。

恢复系数

恢复系数(用符号 C R 表示)定义为碰撞后的相对速度与碰撞前的相对速度之比。

A978-1-4302-6338-8_11_Figbbb_HTML.jpg

这个等式可以改写如下:

A978-1-4302-6338-8_11_Figccc_HTML.jpg

对于弹性碰撞,碰撞前后的相对速度由等式A978-1-4302-6338-8_11_Figddd_HTML.jpg表示,因此前面的等式告诉我们,对于弹性碰撞,C R = 1。

从概念上讲,恢复系数是偏离完全弹性碰撞的一个量度。一般 C R 的值会小于 1。CR1 的碰撞也是可能的;它们对应于产生爆炸力的碰撞,使得粒子碰撞后的总动能大于其初始动能。这种碰撞被称为超弹性碰撞。

C R = 0 的特例是指所谓的完全非弹性碰撞。在这种情况下,相对速度方程简化为:

A978-1-4302-6338-8_11_Figeee_HTML.jpg

因此:

A978-1-4302-6338-8_11_Figfff_HTML.jpg

换句话说,粒子的最终速度是相同的:它们粘在一起。在完全非弹性碰撞中,动能的损失是最大的,但不是完全的(如果它是完全的,粒子会刚好停止;这将违反动量守恒)。

作为另一个特例,假设其中一个粒子是不可移动的(比如一面墙)。例如,如果粒子 2 是不可移动的,则 u 2 = v 2 = 0,因此相对速度方程给出:

A978-1-4302-6338-8_11_Figggg_HTML.jpg

这正是我们在本章前面对涉及能量损失的壁碰撞建模的方式,C R 代表“反弹”因子。

计算非弹性碰撞后的速度

从非弹性碰撞的相对速度方程出发,使用类似于推导相应的弹性碰撞方程的方法,很容易证明非弹性碰撞的最终速度由下式给出:

A978-1-4302-6338-8_11_Fighhh_HTML.jpg

还有这个:

A978-1-4302-6338-8_11_Figiii_HTML.jpg

这是最通用的碰撞公式,因为它还包括作为特例的弹性公式(在上述公式中设 C R = 1,并验证它们简化为弹性碰撞的相应公式)。

完全非弹性碰撞

如果两个粒子的碰撞是完全非弹性的(C R = 0),最后的方程简化为:

A978-1-4302-6338-8_11_Figjjj_HTML.jpg

所以两个粒子粘在一起,在碰撞后以相同的速度运动。您可能想要对完全非弹性碰撞建模的示例包括用雪球击中角色,或者发射一颗刺入目标的子弹。

这就完成了我们对 1D 粒子碰撞的讨论。毫无疑问,您渴望看到演示中应用的所有数学知识。您将很快看到一个代码示例,但在此之前,让我们快速讨论一下这如何扩展到 2D。一旦我们解决了这个问题,我们就能够建立模拟来处理 1D 和 2D 碰撞。

2D 粒子间的碰撞

我们现在准备研究一般的 2D 碰撞,其中粒子的接近方向不一定是沿着它们中心的连线。处理这种碰撞的方法是求助于一个类似于球与墙碰撞的技巧:在碰撞点(当粒子刚刚接触时),将每个粒子的速度分解为沿着连接它们的线的法向分量和垂直于该线的切向分量(见图 11-13 )。启示是,正如在与墙壁的碰撞中,只有法向速度分量受到碰撞的影响;碰撞后切向分量保持不变(假设无摩擦)。此外,法向速度分量的变化方式与 1D 碰撞完全相同(就好像切向分量为零)。那太好了;这意味着,我们可以根据法向速度分量,把任何 2D 碰撞当作 1D 碰撞。

A978-1-4302-6338-8_11_Fig13_HTML.jpg

图 11-13。

Resolving velocity normal and parallel to line joining particles

所以处理 2D 粒子碰撞的步骤如下:

Detecting the collision   Decomposing the initial velocities in normal and tangential components   Repositioning the particles at the moment of collision   Calculating the new normal velocities just after the collision   Adding the new normal velocity components back to the tangential components

与处理 1D 碰撞的过程相比,额外的步骤是步骤 2 和 5。还要注意,在步骤 3 中,使用我们为 1D 碰撞导出的相同公式(参见“重新定位粒子”一节)重新定位粒子,但是使用法线速度。

正如在“从倾斜的墙壁反弹”一节中提到的,我们发现人们用来处理 2D 碰撞的通常方法是旋转坐标系,执行 1D 碰撞,然后旋转回坐标系。但是有了向量,我们建议的方法实际上证明更简单:代替坐标旋转,我们简单地解析向量,然后再把它们加起来。让我们来看看实际使用的方法!

例子:两个粒子之间的 2D 碰撞

我们将创建一个模拟,可以处理两个粒子之间的一般 2D 碰撞。显然,只要选择适当的粒子初始位置和速度,它也能处理 1D 碰撞。这个例子将把我们已经讨论过的粒子间碰撞的所有内容汇集在一起。

创建两粒子碰撞模拟器

创建这个两粒子碰撞模拟器示例的代码在文件ball-collision.js中。init()方法创建两个粒子作为Ball实例,并在调用动画代码之前设置它们的初始大小、质量、位置和速度:

function init() {

// create a ball

ball1 = new Ball(15,'#ff0000',1,0,true);

ball1.pos2D = new Vector2D(0,200);

ball1.velo2D = new Vector2D(250,0);

ball1.draw(context);

ball2 = new Ball(75,'#0000ff',125,0,true);

ball2.pos2D = new Vector2D(300,200);

ball2.velo2D = new Vector2D(50,0);

ball2.draw(context);

// make the ball move

t0 = new Date().getTime();

animFrame();

}

我们正在创建一个半径为 15 像素和 1 质量单位的小球和一个半径为 75 像素和 125 质量单位的大球。请注意,我们已经根据 3D 几何缩放了半径立方的质量(回想一下,球体的体积= 4πr 3 /3),并假设两个球具有相同的密度(回想一下,质量=密度×体积)。在此基础上,由于大球的半径是小球的 5 倍,其质量应该是小球的 125 倍。

在代码的动画部分,move()方法依次移动每个球,然后在每个时间步检查碰撞:

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

moveObject(ball1);

moveObject(ball2);

checkCollision();

}

checkCollision()方法如下所示:

function checkCollision(){

var dist = ball1.pos2D.subtract(ball2.pos2D);

if (dist.length() < (ball1.radius + ball2.radius) ) {

// normal velocity vectors just before the impact

var normalVelo1 = ball1.velo2D.project(dist);

var normalVelo2 = ball2.velo2D.project(dist);

// tangential velocity vectors

var tangentVelo1 = ball1.velo2D.subtract(normalVelo1);

var tangentVelo2 = ball2.velo2D.subtract(normalVelo2);

// move particles so that they just touch

var L = ball1.radius + ball2.radius-dist.length();

var vrel = normalVelo1.subtract(normalVelo2).length();

ball1.pos2D = ball1.pos2D.addScaled(normalVelo1,-L/vrel);

ball2.pos2D = ball2.pos2D.addScaled(normalVelo2,-L/vrel);

// normal velocity components after the impact

var m1 = ball1.mass;

var m2 = ball2.mass;

var u1 = normalVelo1.projection(dist);

var u2 = normalVelo2.projection(dist);

var v1 = ((m1-m2)*u1+2*m2*u2)/(m1+m2);

var v2 = ((m2-m1)*u2+2*m1*u1)/(m1+m2);

// normal velocity vectors after collision

normalVelo1 = dist.para(v1);

normalVelo2 = dist.para(v2);

// final velocity vectors after collision

ball1.velo2D = normalVelo1.add(tangentVelo1);

ball2.velo2D = normalVelo2.add(tangentVelo2);

}

}

代码严格遵循前面小节中给出的解释,代码中的注释应该清楚地说明每一步发生了什么。事实上,这种代码比斜壁反射的相应代码简单得多。还要注意两个球的对称性。代码的简单性和相对简洁性,提供了一种我们认为有吸引力的替代方法,可以替代旋转坐标系来解决倾斜碰撞。

最后,计算最终速度的公式假设了弹性碰撞,但是用非弹性碰撞的另一个(更一般的)公式来代替它是一件简单的事情。我们把这个留给你做练习。

粒子碰撞模拟实验

如果你运行粒子碰撞模拟,你会看到小球与大球发生 1D 碰撞(见图 11-14 )。让我们开始实验,验证我们在讨论 1D 弹性碰撞的特殊情况时所做的一些观察。

A978-1-4302-6338-8_11_Fig14_HTML.jpg

图 11-14。

A two-particle collision simulator

首先,简单地通过注释掉ball-collision.js中设置其速度的行,将大的那个的初始速度设置为零。你会看到小球在碰撞后几乎逆转了速度,而大球以非常小的速度离开,就像我们之前说的那样。

接下来,通过用以下修改后的行替换实例化第二个球的行,使第二个球的大小和质量与第一个球相同:

var ball2 = new Ball(15,'#0000ff',1,0,true);

保持第二个球的速度为零并运行代码。你会看到,在碰撞时,第一个球正好停在它的轨道上,而第二个球以第一个球的初速度离开。这再一次证实了我们先前根据支配碰撞方程式所做的推论。

这是一个完整的 2D 碰撞模拟器,为什么不让球以一个角度碰撞呢?例如,在保持两个粒子的质量和大小相同的同时,尝试以下方法:

ball1.velo2D = new Vector2D(100,-30);

ball2.velo2D = new Vector2D(-50,-30);

这表明我们的基本 2D 粒子碰撞模拟器可以正确处理 1D 和 2D 碰撞。现在让我们增加一点复杂性。

示例:多个粒子碰撞

扩展前面的模拟以处理大量粒子只是创建更多的球实例,然后检查每对粒子之间的碰撞。在multiple-ball-collision.js中,我们创建了九个相同大小和质量的粒子,并用move()方法制作了每个粒子的动画,如下所示:

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

for (var i=0; i<numBalls; i++){

var ball = balls[i];

moveObject(ball);

}

checkCollision();

}

checkCollision()方法非常类似于ball-collision.js中的两粒子版本,主要区别在于当前版本中的双for循环:

function checkCollision(){

for (var i=0; i<balls.length; i++){

var ball1 = balls[i];

for(var j=i+1; j<balls.length; j++){

var ball2 = balls[j];

// code in here is similar to ball-collision.js

}

}

}

循环的关键任务是挑选所有可能的粒子对,而不用重复计算。所以第一个循环遍历了balls数组的所有元素,从中挑选出第一个球ball1。然后,第二个循环再次遍历balls的元素来选择ball2,但是这次是从ball1的下一个元素开始。这避免了检查ball1ball2之间的碰撞,然后再次检查ball2ball1之间的碰撞。

九个球最初被随机放置在画布上。给定它们朝向画布中心的速度,速度大小与它们离中心的距离成比例。当运行模拟时,球移动到中心,在那里它们快速连续碰撞多次,结果它们向外反弹。随意试验不同的配置和初始速度。

示例:具有反弹的多个粒子碰撞

前面的例子很有趣,但是粒子在分离并消失在画布区域之外之前只发生了短暂的碰撞。限制它们,让它们一次又一次地碰撞,会有趣得多。为此,我们需要引入限制墙。所以,让我们结合本章所学的一切,建立最后一个涉及多个粒子相互碰撞以及与墙壁碰撞的例子。粒子也将具有不同的大小和质量。

我们称这个新文件为molecules.js,因为结果看起来会像气体分子四处跳动。下面是molecules.js中的init()方法:

function init() {

balls = new Array();

for(var i = 0; i < numBalls; i++){

var radius = Math.random()*20 + 5;

var mass = 0.01*Math.pow(radius,3);

var ball = new Ball(radius,'#666600',mass,0,true);

ball.pos2D = new Vector2D(Math.random()*(canvas.width-2*radius)+radius, Math.random()*(canvas.height-2*radius)+radius);

ball.velo2D = new Vector2D(((Math.random()-0.5)*100),((Math.random()-0.5)*100));

balls.push(ball);

ball.draw(context);

}

walls = new Array();

var wall1 = new Wall(new Vector2D(canvas.width,0),new Vector2D(0,0));

wall1.draw(context_bg);

walls.push(wall1);

var wall2 = new Wall(new Vector2D(canvas.width,canvas.height),new Vector2D(canvas.width,0));

wall2.draw(context_bg);

walls.push(wall2);

var wall3 = new Wall(new Vector2D(0,canvas.height), new Vector2D(canvas.width,canvas.height));

wall3.draw(context_bg);

walls.push(wall3);

var wall4 = new Wall(new Vector2D(0,0),new Vector2D(0,canvas.height));

wall4.draw(context_bg);

walls.push(wall4);

t0 = new Date().getTime();

animFrame();

}

我们首先创建一组随机半径从 5 px 到 25 px 的球,其质量与半径的立方成比例。球被随机放置在画布上,并被赋予最大 50 px/s 的随机速度。然后在画布的边缘创建四面墙。然后调用动画代码。

代码的其余部分基本上是用于多个碰撞粒子(前面的例子)和从多个墙壁反弹的代码的混合。因此,除了它们的组合方式之外,没有什么实质性的新东西。move()方法与前面多个粒子碰撞的例子相同。除了在外部的for循环中增加了一行代码之外,checkCollision()方法与前面的例子几乎相同(在下面的清单中以粗体显示):

function checkCollision(){

for (var i=0; i<balls.length; i++){

var ball1 = balls[i];

for(var j=i+1; j<balls.length; j++){

var ball2 = balls[j];

var dist = ball1.pos2D.subtract(ball2.pos2D);

if (dist.length() < (ball1.radius + ball2.radius) ) {

// normal velocity vectors just before the impact

var normalVelo1 = ball1.velo2D.project(dist);

var normalVelo2 = ball2.velo2D.project(dist);

// tangential velocity vectors

var tangentVelo1 = ball1.velo2D.subtract(normalVelo1);

var tangentVelo2 = ball2.velo2D.subtract(normalVelo2);

// move particles so that they just touch

var L = ball1.radius + ball2.radius-dist.length();

var vrel = normalVelo1.subtract(normalVelo2).length();

ball1.pos2D = ball1.pos2D.addScaled(normalVelo1,-L/vrel);

ball2.pos2D = ball2.pos2D.addScaled(normalVelo2,-L/vrel);

// normal velocity components after the impact

var m1 = ball1.mass;

var m2 = ball2.mass;

var u1 = normalVelo1.projection(dist);

var u2 = normalVelo2.projection(dist);

var v1 = ((m1-m2)*u1+2*m2*u2)/(m1+m2);

var v2 = ((m2-m1)*u2+2*m1*u1)/(m1+m2);

// normal velocity vectors after collision

normalVelo1 = dist.para(v1);

normalVelo2 = dist.para(v2);

// final velocity vectors after collision

ball1.velo2D = normalVelo1.add(tangentVelo1);

ball2.velo2D = normalVelo2.add(tangentVelo2);

}

}

checkWallBounce(ball1);

}

}

额外的一行告诉代码查看ball1是否与任何墙壁发生碰撞。这里给出的checkWallBounce()方法是为了完整性,但它本质上与bouncing-off-multiple-inclined-walls.js中的相同,除了我们去掉了处理墙端点反弹的代码,因为在这个例子中没有空闲的墙端点可以反弹:

function checkWallBounce(obj){

var hasHitAWall = false;

for (var i=0; (i<walls.length && hasHitAWall==false); i++){

var wall = walls[i];

var wdir = wall.dir;

var ballp1 = wall.p1.subtract(obj.pos2D);

var ballp2 = wall.p2.subtract(obj.pos2D);

var proj1 = ballp1.projection(wdir);

var proj2 = ballp2.projection(wdir);

var dist = ballp1.addScaled(wdir.unit(), proj1*(-1));

var test = ((Math.abs(proj1) < wdir.length()) && (Math.abs(proj2) < wdir.length()));

if ((dist.length() < obj.radius) &&  test){

var angle = Vector2D.angleBetween(obj.velo2D, wdir);

var normal = wall.normal;

if (normal.dotProduct(obj.velo2D) > 0){

normal.scaleBy(-1);

}

var deltaS = (obj.radius+dist.dotProduct(normal))/Math.sin(angle);

var displ = obj.velo2D.para(deltaS);

obj.pos2D = obj.pos2D.subtract(displ);

var normalVelo = obj.velo2D.project(dist);

var tangentVelo = obj.velo2D.subtract(normalVelo);

obj.velo2D = tangentVelo.addScaled(normalVelo,-vfac);

hasHitAWall = true;

}

}

}

图 11-15 显示了运行代码时你会看到的截图:气体分子四处跳动!请注意,小分子通常比大分子移动得快,因为当大分子与它们碰撞时,它们可以获得大量动量。玩这个模拟或者只是简单地看一下,会浪费很多时间!为了让它更有趣,在源代码中我们还包括了一个模拟的修改版本(molecules2.js),允许你通过点击和拖动来添加你自己的粒子。对于大量的粒子,你可能会发现它们中的一些,尤其是较小的粒子,偶尔会从限制壁中穿出。您可以使用“从倾斜的墙壁反弹”一节中描述的方法来解决这个问题

A978-1-4302-6338-8_11_Fig15_HTML.jpg

图 11-15。

“Molecules” bouncing off each other and off walls

您可能还会发现,由于随机定位,一些粒子在最初创建时会重叠。这将导致一些最初的不稳定行为,这些行为应该会在几个时间步长内自行解决。大量粒子也可能出现其他问题(除了你的计算机无法处理的明显问题),特别是如果它们被塞进一个小空间或者高速移动。例如,你可以让一个粒子与另外两个粒子在同一时间步内发生碰撞。或者,您可以让一个粒子重新定位,并最终与附近的另一个粒子重叠。我们可以详细讨论处理这些情况的方法,但是我们在这一章已经讲了很多,需要在某个地方停下来!

摘要

在这一章中,我们已经从一个简单的演示一个球从一个垂直的墙壁上反弹到创建复杂的模拟,包括粒子从多个倾斜的墙壁上反弹以及粒子与其他粒子碰撞。我们已经相当详细地讨论了基础物理学,讨论了 1D 和 2D 中控制不同类型碰撞的方程式。在实现壁和粒子之间的碰撞解决时,我们引入了一种使用矢量代数的新方法,避免了通常需要执行坐标旋转来解决 2D 中的碰撞。我们看到的一系列例子在一个有趣的模拟中达到高潮,多个粒子碰撞,表现得像气体分子。在下一章中,我们将继续探索一系列视觉和动画效果,这些效果可以通过包含大量粒子的系统来实现。

十二、粒子系统

简而言之,本章将向你展示如何使用多粒子系统创建动画效果和模拟。粒子系统非常受欢迎,多年来在动画社区中使用它们做了很多创造性的工作。在一章中,我们只能真正触及可能的表面。因此,我们将满足于为您提供一些想法和示例,供您参考和借鉴。

本章涵盖的主题包括以下内容:

  • 粒子系统建模简介:这个简短的部分将解释什么是粒子系统,以及建立一个粒子系统需要什么。
  • 使用粒子创建动画效果:使用粒子和一些简单的物理可以产生一些有趣的动画效果,如烟和火。在本节中,我们将带您浏览一些示例。
  • 具有远程力的粒子动画:粒子动画不必看起来很逼真;他们可以只是为了好玩!我们看几个古怪的例子,用的是长程作用力,比如重力。
  • 相互作用的粒子系统:更复杂的粒子系统包括粒子之间的相互作用。但是对于大量的粒子来说,这在计算上会变得非常昂贵。我们给出几个例子,讨论解决这个问题的技巧。

本章的方法是创建使用一些物理的动画,但不一定是精确的模拟。最重要的是,这是一个需要实验和创造力的主题。因此,不要害怕修补和发明,即使这意味着打破一些物理定律!

粒子系统建模简介

虽然一个粒子系统的精确定义可能并不存在,但是尝试定义它将有助于在我们做任何事情之前解释我们的想法。在我们使用这个术语的意义上,粒子系统由下列元素组成:

  • 一组粒子
  • 它们移动和改变的规则
  • 他们互动的规则(如果有的话)

这个定义非常笼统:所说的“规则”可以基于物理学、人工智能、生物学或任何类似的系统。不足为奇的是,我们将把自己限制在物理定律下的粒子运动和相互作用(也许这里和那里有一些奇怪的调整)。因此,我们将考虑的粒子系统将在我们在本书第二部分中讨论的力的子集的作用下,根据牛顿运动定律激活粒子。粒子之间的不同种类的相互作用可以包括如下:

  • 没有相互作用:在这种情况下,粒子是彼此不知道的,在全局规定的力(如外部引力)的作用下独立运动。即使这是最简单的情况,使用非相互作用粒子也能产生令人印象深刻的效果。在接下来的两节中,我们将看几个例子。
  • 碰撞产生的相互作用:这里粒子除了碰撞时非常短暂的相互作用外,不发生相互作用。使用第十一章中给出的方法可以解决冲突。事实上,第十一章中的最后一个例子展示了这样一个通过碰撞相互作用的粒子系统。
  • 短程相互作用:在这种情况下,粒子只有在靠近但不一定接触时才会相互作用。它们可以用存在于分子间的短程力来模拟。使用这种力可以模拟流体效应,例如液滴的形成和下落。但是这些模拟需要更高级的方法,不在本书讨论范围之内。
  • 长程相互作用:这一类包括任何距离的粒子之间的相互作用,例如粒子之间的重力或电力。一般来说,每个粒子都会受到其他粒子的作用力。显然,随着系统中粒子数量的增加,需要执行的计算量会很快变得非常大。我们在本章的最后一节看一些例子。
  • 局部相互作用:这些相互作用介于短程和长程之间;某个局部邻域内的粒子是相互联系和相互作用的。这种相互作用可以产生有组织的系统和连接的结构。例子包括质量弹簧系统,它可以用来模拟像绳子和衣服这样的可变形物体。这些粒子系统将在第十三章中探讨。

那么,就物理和编码而言,我们需要什么来创建一个粒子系统呢?答案可能会让你大吃一惊:并没有超出我们在前面章节中已经介绍过的内容太多。所以这一章不会有太多的理论,但更多的是关于已经讨论过的原理的应用,并增加了一些额外的技巧。这意味着您可以更快地看到代码!

使用粒子创建动画效果

粒子系统最常见的用途之一是创建动画视觉效果,如烟和火。在这一节中,我们将向您展示使用基于物理的动画制作这些效果的简单方法。我们从一个非常简单的例子开始,然后创建一个粒子发射器,这将允许我们创建一些更复杂的效果。这部分的方法是我们想要制作看起来真实的动画,但是不需要完全精确。

一个简单的例子:粒子飞溅效果

在这个例子中,我们将物体放入水中,然后使用粒子创建飞溅效果。这些物体将是不同大小的球,从同一高度一次扔出一个。水花将由一组尺寸更小的Ball物体组成。让我们直接进入创建这些对象的代码。这个文件可以从 www.apress.com 下载,叫做splash.js。以下是完整的代码清单:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_bg = document.getElementById('canvas_bg');

var context_bg = canvas_bg.getContext('2d');

var drop;

var droplets;

var numDroplets = 20;

var m = 1;

var g = 20;

var vx = 20;

var vy = -15;

var wlevel = 510;

var fac = 1;

var t0,dt;

var acc, force;

window.onload = init;

function init() {

makeBackground();

makeDrop();

makeDroplets();

t0 = new Date().getTime();

animFrame();

}

function makeBackground(){

var horizon = 500;

// the sea

context_bg.fillStyle = '#7fffd4';

context_bg.fillRect(0,horizon,canvas_bg.width,canvas_bg.height-horizon);

// the sky

gradient = context_bg.createLinearGradient(0,0,0,horizon);

gradient.addColorStop(0,'#87ceeb');

gradient.addColorStop(1,'#ffffff');

context_bg.fillStyle = gradient;

context_bg.fillRect(0,0,canvas_bg.width,horizon);

}

function makeDrop(){

drop = new Ball(8,'#3399ff',1,0,true);

drop.pos2D = new Vector2D(400,100);

drop.velo2D = new Vector2D(0,100);

drop.draw(context);

}

function makeDroplets(){

droplets = new Array();

for (var i=0; i<numDroplets; i++){

var radius = Math.random()*2+1;

var droplet = new Ball(radius,'#3399ff',m,0,true);

droplets.push(droplet);

}

}

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

moveObject(drop);

checkDrop();

for (var i=0; i<numDroplets; i++){

var droplet = droplets[i];

moveObject(droplet);

calcForce(droplet);

updateAccel();

updateVelo(droplet);

}

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

if (obj.y < wlevel){// only show drops that are above water level

obj.draw(context);

}

}

function calcForce(obj){

force = Forces.constantGravity(m,g);

}

function updateAccel(){

acc = force.multiply(1/m);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

function checkDrop(){

if (drop.y > wlevel){

for (var i=0; i<droplets.length; i++){

var droplet = droplets[i];

var posx = drop.x+(Math.random()-0.5)*drop.radius;

var posy = wlevel-10+Math.random()*drop.radius;

var velx = (Math.random()-0.5)*vx*fac;

var vely = (Math.random()+0.5)*vy*fac;

droplet.pos2D = new Vector2D(posx,posy);

droplet.velo2D = new Vector2D(velx,vely);

}

drop.x = Math.random()*600+100;

drop.y = 100;

drop.radius = 4 + 8*Math.random();

fac = Math.pow(drop.radius/8,1.5);

}

}

先来看init()方法,我们看到初始设置代码已经被组织成三个独立的方法makeBackground()makeDrop()makeDroplets(),它们分别创建可视背景,即将被放下的Ball对象(名为drop;想象雨滴)和一组 20 个随机半径在 1 到 3 个像素之间的更小的Ball物体。这些将是飞溅的水滴,它们最初是不可见的,因为它们还没有被画出来。它们被放入名为droplets的数组中。液滴最初位于水面上方,y = 100,垂直向下的速度为 100 px/s。

像往常一样,通过从init()调用animFrame()方法来调用动画代码。在move()方法中,首先通过调用moveObject()方法将drop作为参数来激活拖放。这使得它以代码前面指定的 100 px/s 的恒定向下速度移动。你可能会奇怪,为什么我们让物体匀速下落,而不是让它在重力作用下加速。答案是,如果它是一滴雨滴,它很可能已经达到了极限速度。

接下来在move()中,调用checkDrop()方法。该方法检查水滴是否低于水位(设置为比代表水体的蓝色矩形的上边缘低 10 px,以产生假 3D 效果)。如果发生这种情况,飞溅的液滴会在撞击区域周围重新定位,并被赋予具有向上垂直分量的随机速度。速度的大小取决于最初在代码中设置的参数vxvy以及因子fac,该因子在每次飞溅发生时更新,这将在稍后描述。下落的水滴一旦到达水平面,就重新定位在初始高度和新的随机水平位置。然后,它的半径会变为 4 到 12 个像素之间的随机值。

速度因子fac根据以下公式更新,其中 r m 是液滴的平均半径(8 个像素):

A978-1-4302-6338-8_12_Figa_HTML.jpg

这里发生了什么事?这个想法是,水滴越大,它的质量就越重(它的质量与它的体积成正比,或者是它的半径的立方,r 3 )。因为水滴总是具有相同的速度,所以它的动能(E k = mv 2 )将与其质量成正比,因此也与 r 3 成正比。该能量的一部分将被传递给飞溅的液滴,因此可以预期这些液滴的平均动能与 r 3 成比例。因为液滴的质量是恒定的,所以它们的动能与速度的平方成正比。因此,我们有以下内容:

A978-1-4302-6338-8_12_Figb_HTML.jpg

因此,我们可以这样写:

A978-1-4302-6338-8_12_Figc_HTML.jpg

这表明我们期望液滴的平均速度与下落液滴半径的 1.5 次方成正比。前面关于fac的公式实现了这个假设,当半径等于 8 px 的平均半径时,fac的值被设置为 1。

回到move()方法,每个水滴在for循环中被激活。通过使用calcForce()中的Forces.constantGravity()方法,它们在重力作用下下落。在moveObject()中,有一个if条件,只有当他们低于水面时,才把他们拉到画布上;否则,它们将保持不可见。

如果您运行模拟,您会发现液滴越大,液滴飞溅越高,因为它们的初始速度更大。图 12-1 为截图。像往常一样,随意试验不同的参数。当然,动画的视觉效果可以根据你的想象力和艺术技巧以多种方式增强。我们已经关注了物理和编码方面;请随意根据口味调整外观。

A978-1-4302-6338-8_12_Fig1_HTML.jpg

图 12-1。

Producing a splash!

创建粒子发射器

粒子系统的许多应用需要连续的粒子源。这可以通过使用粒子发射器来实现。创建一个粒子发射器并不难;本质上,你需要的是随着时间的推移创造新的粒子。但是限制粒子总数也很重要,这样可以避免你的电脑崩溃!这可能意味着固定颗粒总数或回收/去除它们。

有几种方法可以实现这些结果。这里我们只能举一个例子,并不试图涵盖所有不同的方法。

您将在particle-emitter.js文件中找到我们方法的示例,完整列表如下:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var particles;

var maxParticles = 120;

var m = 1;

var g = 20;

var k = 0.003;

var vx = 60;

var vy = -80;

var i = 0;    // only needed for fountain effect

var fps = 30; // controls rate of creation of particles

var t0,dt;

var acc, force;

var posEmitter;

window.onload = init;

function init() {

particles = new Array();

posEmitter = new Vector2D(0.5*canvas.width,0.5*canvas.height);

addEventListener('mousemove',onMouseMove,false);

t0 = new Date().getTime();

animFrame();

}

function onMouseMove(evt){

posEmitter = new Vector2D(evt.clientX,evt.clientY);

}

function animFrame(){

setTimeout(function() {

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}, 1000/fps);

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

if (particles.length < maxParticles){

createNewParticles(posEmitter);

}else{

recycleParticles(posEmitter);

}

for (var i=0; i<particles.length; i++){

var particle = particles[i];

moveObject(particle);

calcForce(particle);

updateAccel();

updateVelo(particle);

}

}

function createNewParticles(ppos){

var newParticle = new Ball(2);

setPosVelo(newParticle,ppos);

particles.push(newParticle);

}

function recycleParticles(ppos){

var firstParticle = particles[0];

firstParticle.color = '#ff0000';

setPosVelo(firstParticle,ppos);

particles.shift();

particles.push(firstParticle);

}

function setPosVelo(obj,pos){

obj.pos2D = pos;

obj.velo2D = new Vector2D((Math.random()-0.5)*vx,(Math.random()+0.5)*vy);

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

obj.draw(context);

}

function calcForce(obj){

var gravity = Forces.constantGravity(m,g);

var drag = Forces.drag(k,obj.velo2D);

force = Forces.add([gravity, drag]);

}

function updateAccel(){

acc = force.multiply(1/m);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

粒子存储在一个名为particles的数组中。在这个例子中,calcForce()方法包括重力和阻力。真正的新特性出现在两个恰当命名的方法createNewParticles()recycleParticles()中,这两个方法都是从move()方法中调用的,这取决于指定的最大数量的粒子maxParticles是否已经被创建。

createNewParticles()方法创建一个新的粒子作为Ball对象,然后通过调用setPosVelo()函数初始化其位置和速度,并将位置指定为Vector2D输入参数。指定的位置在这里被称为posEmitter,最初在init()中被设置在画布的中间,但是随后通过使用mousemove事件监听器/处理器被更新到鼠标光标的位置。粒子被赋予一个随机向上的初速度。在createNewParticles()中,粒子被推入particles阵列。

这在每个时间步长发生一次,因此在每个时间步长都会创建一个新粒子。注意,我们已经将requestAnimationFrame()调用嵌套到了animFrame()中的setTimeout()函数中。正如在第二章中所讨论的,这允许你控制动画的时间步长。在所列的例子中,参数fps被设置为 30。这意味着动画将以(大约)每秒 30 帧的速度运行,每帧产生一个粒子(时间步长)。因此,fps间接地指明了每秒钟产生新粒子的速率,在这个例子中是 30。当然,你可以在每个时间步产生多个粒子,比如说N。在这种情况下,新粒子产生的速率将是N*fps

如果在每个时间步都无条件调用createNewParticles()方法,你的动画将很快充满粒子,直到你的计算机无法再处理。因此,如果粒子数超过最大允许数maxParticles,我们就称之为recycleParticles()。在recycleParticles()中,第一个(也是最老的)粒子被识别。它的位置被重置为posEmitter的值,其速度被重置为创建时的随机垂直速度。在这里,我们也改变它的颜色为红色,但这是可选的。最后,我们通过连续应用particles.shift()particles.push()方法,从第一个到最后一个移除particles数组中第一个粒子的参考位置。

你不要把maxParticles设得太高。在示例文件中,选择了适中的值 120。虽然我们已经成功地用数千个粒子测试了动画,但是在你自己的机器上的性能将取决于你的硬件和浏览器的能力,以及在机器上运行的其他进程的数量。

运行模拟,你将有一个粒子发射器!小球向上投射,在重力和阻力的作用下落回,看起来有点像水花。发射器的位置随着鼠标光标的位置而变化,这样你就可以四处移动鼠标,看热闹了。

通过改变参数和初始条件,可以产生一些有趣的效果。例如,你可以在setPosVelo()中引入以下代码行,而不是给粒子一个随机的速度:

var n = i%7;

var angle = -(60+10*n)*Math.PI/180;

var mag = 100;

newBall.velo2D = Vector2D.vector2D(mag,angle);

i++;

这里,变量i最初被赋予值 0。这就产生了喷泉般的图案,如图 12-2 所示。您可能需要调整粒子数量或帧速率来重现截图中显示的图案。

A978-1-4302-6338-8_12_Fig2_HTML.jpg

图 12-2。

Creating a particle emitter

制造烟雾效果

现在让我们使用粒子发射器来创建一个烟雾效果。这比你想象的要容易;事实上,你已经完成了大部分工作。修改后的文件smoke-effect.js中的代码与particle-emitter.js中的代码基本相同,除了一些相对较小的改动。

function Spark(radius,r,g,b,alpha,mass){

if(typeof(radius)==='undefined') radius = 2;

if(typeof(r)==='undefined') r = 255;

if(typeof(g)==='undefined') g = 255;

if(typeof(b)==='undefined') b = 255;

if(typeof(alpha)==='undefined') alpha = 1;

if(typeof(mass)==='undefined') mass = 1;

this.radius = radius;

this.r = r;

this.g = g;

this.b = b;

this.alpha = alpha;

this.mass = mass;

this.x = 0;

this.y = 0;

this.vx = 0;

this.vy = 0;

}

Spark.prototype = {

get pos2D (){

return new Vector2D(this.x,this.y);

},

set pos2D (pos){

this.x = pos.x;

this.y = pos.y;

},

get velo2D (){

return new Vector2D(this.vx,this.vy);

},

set velo2D (velo){

this.vx = velo.x;

this.vy = velo.y;

},

draw: function (context) {

context.fillStyle = "rgba("+ this.r +","+ this.g +","+ this.b +","+ this.alpha +")";

context.beginPath();

context.arc(this.x, this.y, this.radius, 0, 2*Math.PI, true);

context.closePath();

context.fill();

}

}

第一个修改可能看起来有点奇怪:我们给重力常数g一个负值(–5)!这是怎么回事?事情是这样的,烟倾向于上升而不是下降,因为浮力(上升推力)将克服重力。但是,更简单的方法不是模拟浮力,而是修改重力,使其向上作用,因为我们只对近似再现烟雾的视觉效果感兴趣。

第二个修改是我们使用了一个新的对象Spark,它是专门为接下来的几个动画创建的。Spark对象在许多方面看起来与Ball相似,但是它去掉了额外的属性,如chargegradient,这里不再需要。更重要的是,它引入了四个新属性rgbalpha来代替Ball. Spark的单一属性color还有一个radius和一个mass属性。

rgbalpha的值被输入到构造函数中(连同radiusmass),分别有默认值 255、255、255 和 1。它们在draw()功能中用于在绘制指定半径的实心圆时设置fillStyle,其中 alpha 代表透明度,rgb代表 RGB 格式的颜色。我们分别指定单独的颜色和 alpha 通道,以便我们可以独立地操纵它们来产生视觉效果(正如您将在接下来的几个示例中看到的)。

有了新的对象Spark,然后我们通过创建新的粒子作为Spark而不是Ball的实例来修改createNewParticles()方法,给它们 1 到 4 个像素值之间的半径。在下面的代码片段中,修改过的行用粗体表示。

function createNewParticles(ppos){

var newParticle = new Spark(1+3*Math.random(),255,255,255,1,m);

setPosVelo(newParticle,ppos);

particles.push(newParticle);

}

下一个修改是引入一个新的方法modifyObject(),它是从move()中的for循环中调用的,因此在每个时间步对每个粒子执行。

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

if (particles.length < maxParticles){

createNewParticles(posEmitter);

}else{

recycleParticles(posEmitter);

}

for (var i=0; i<particles.length; i++){

var particle = particles[i];

modifyObject(particle);

moveObject(particle);

calcForce(particle);

updateAccel();

updateVelo(particle);

}

}

modifyObject()方法如下所示:

function modifyObject(obj){

obj.radius *= 1.01;

obj.alpha += -0.01;

}

我们在这里所做的就是在每个时间步长,以一个常数因子增加每个粒子的大小,并以一个固定的量减少其透明度。

前面的添加和修改的结果是粒子随着上升而增长和消失,然后完全消失并被回收。还必须进行一项更改,以确保回收工作正常进行。因为我们一直在修改粒子的半径和透明度,所以我们需要在回收它们时将这些属性重置为初始值。这是通过在recycleParticles()方法中引入一个新方法resetObject()来实现的:

function recycleParticles(ppos){

var firstParticle = particles[0];

resetObject(firstParticle);

setPosVelo(firstParticle,ppos);

particles.shift();

particles.push(firstParticle);

}

function resetObject(obj){

obj.radius = 1+3*Math.random();

obj.alpha = 1;

}

如果你像这里显示的那样运行代码,你会得到一个有趣的效果,但是你不能真的称它为烟雾——见图 12-3 。

A978-1-4302-6338-8_12_Fig3_HTML.jpg

图 12-3。

An interesting effect, but not quite smoke yet!

是时候介绍过滤器了!如果你不熟悉图像过滤器,我们鼓励你花一些时间阅读它们(你可以在网上找到许多教程)并试用它们。我们想要使用的特定滤镜是模糊滤镜,它给图像或 HTML 元素的边缘一个模糊的外观。有几种方法可以实现这一点。因为我们在这里的兴趣是物理学而不是过滤器本身,我们将使用可能是最简单的方法:使用 CSS 过滤器。不幸的是,这种技术的规范还没有稳定下来,所以在撰写本文时,浏览器的支持还不完整。添加到 CSS 文件中的以下代码行可以在 Chrome 中使用(据说在 Safari 中也可以使用):

-webkit-filter: blur(3px);

这也可以在 JavaScript 文件中设置:

canvas.style.webkitFilter = "blur(3px)";

显然,您可以通过改变括号中的像素数来改变模糊程度。随意尝试不同的价值观。

如果你现在运行代码,你会看到一些看起来更像烟雾的东西,如图 12-4 中的屏幕所示。

A978-1-4302-6338-8_12_Fig4_HTML.jpg

图 12-4。

The smoke effect at last

这是一个基本的烟雾效果,可以无限地调整和增强。把它当作进一步实验的起点,而不是最终结果。此外,还有很多其他的方法来制造这样的烟雾效果。前面的方法改编自 Seb Lee-Delisle 在《计算机艺术》杂志(2008 年 3 月)上发表的 ActionScript 教程。它很好,因为它很简单,而且只需要纯代码就可以完成。

创造火焰效果

修改烟雾动画以产生看起来像火的东西是非常容易的。主要的变化与粒子的外观有关;首先,我们通过将createNewParticles()中的第一行修改为以下内容,使它们变得稍微大一点,呈橙色:

var newParticle = new Spark(3+3*Math.random(),255,100,0,1,m);

这使得粒子的半径在 3 到 6 个像素之间。随后,粒子的修改与modifyObject()中的烟雾模拟略有不同:

function modifyObject(obj){

obj.radius *= 1.01;

obj.alpha += -0.04;

obj.g = Math.floor(100+Math.random()*150);

}

我们现在在每个时间步长改变绿色通道,范围在 100 到 250 之间。我们还增加了阿尔法通道的增量。自然,我们需要修改resetObject()方法来恢复初始颜色和半径:

function resetObject(obj){

obj.radius = 3+3*Math.random();

obj.r = 255;

obj.g = 100;

obj.b = 0;

obj.alpha = 1;

}

我们还调整了gk的值,分别改为–2 和 0.003。您可以随意更改这些参数值。你也可以看看改变帧速率的效果——也许动态地改变fps,例如随机化它或使它响应用户事件。

我们所做的最后一项更改是通过在文件的第 3 行使用以下代码修改画布样式来增加亮度:

canvas.style.webkitFilter = "blur(3px) brightness(2)";

如果你现在运行代码,你会看到类似图 12-5 的东西。当然,你需要看到实际的动画来欣赏效果和颜色。

A978-1-4302-6338-8_12_Fig5_HTML.jpg

图 12-5。

Creating a fire effect

现在您已经有了基本的工具,您可以通过将这些方法应用到不同的设置和组合不同的技术来创建更精细的效果。例如,通过将发射器附加到对象,可以创建对象着火的幻觉。您还可以添加风,将烟与火结合,添加火花,等等。谈到火花,让我们现在创造一些。

创造焰火

我们在这一部分想要实现的最终结果是一个看起来像烟花的动画:一系列的小爆炸,每个爆炸都产生五颜六色的火花,这些火花在重力的牵引下落下。为了达到这个目的,我们将首先向我们的Spark实例添加几个属性,然后创建一个火花动画,最后添加额外的特性来制作焰火。

向火花添加寿命和年龄属性

像火花一样的东西只会存在一段时间,之后就需要被清除或回收。因此,引入几个新属性来表示一个Spark实例的生命周期和年龄是有意义的。我们可以选择将属性添加到Spark对象本身,或者将它们添加到Spark的实例。在接下来的几个例子中,我们选择后者。

正如您在第二章中回忆的那样,向对象实例添加属性很容易,如下所示:

var spark = new Spark();

spark.lifetime = 10;

spark.age = 0;

这创建了两个属性,lifetimeage,,并分别给它们赋值 10 和 0。这些以秒为单位代表了spark的寿命和年龄。在随后的代码中,您可以随着时间的推移更新其年龄,并在年龄超过其生存期时进行检查并采取适当的措施。

制造火花

现在让我们应用新创建的lifetimeage属性来创建在指定持续时间内存在的火花。代码在一个叫做sparks.js的文件里。我们现在有 200 个粒子,分别给它们的值gkvxvy为 10、0.005、100 和–100(如果需要,可以更改)。我们还将fps的值更改为 60,并在相应的 CSS 文件中指定模糊值为 1px,亮度因子为 2。此处复制了包含前一示例(fire-effect.js)中的更改的方法,修改后的行以粗体突出显示:

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

if (particles.length < maxParticles){

createNewParticles(posEmitter);

}else{

recycleParticles(posEmitter);

}

for (var i=0; i<particles.length; i++){

var particle = particles[i];

modifyObject(particle,i);

moveObject(particle);

calcForce(particle);

updateAccel();

updateVelo(particle);

}

}

function createNewParticles(ppos){

var newParticle = new Spark(2,255,255,0,1,m);

setProperties(newParticle,ppos);

particles.push(newParticle);

}

function recycleParticles(ppos){

var firstParticle = particles[0];

resetObject(firstParticle);

setProperties(firstParticle,ppos);

particles.shift();

particles.push(firstParticle);

}

function setProperties(obj,ppos){

obj.pos2D = ppos;

obj.velo2D = new Vector2D((Math.random()-0.5)*vx,(Math.random()-0.5)*vy);

obj.lifetime = 6 + 2*Math.random();

obj.age = 0;

}

function resetObject(obj){

obj.alpha = 1;

}

function modifyObject(obj,i){

obj.alpha += -0.01;

obj.age += dt;

if (obj.age > obj.lifetime){

removeObject(i);

}

}

function removeObject(num){

particles.splice(num,1);

}

createNewParticles()中,每个粒子都被赋予了相同的半径 2 个像素和一个微黄色。setPosVelo()方法已经被一种setProperties()方法所取代,这种方法现在做得更多一点——除了它的位置和速度之外,它还设置每个粒子的寿命和年龄。因为火花可以向任何方向飞去,所以每个粒子在任何方向上都有一个随机的速度。那么它的寿命被设置为 6 到 8 秒之间的随机值。然后将其年龄初始化为零。

resetObject()方法只需要将每个回收粒子的透明度重置为 1,因为其他属性(半径和颜色)在动画过程中不会改变。

modifyObject()方法像以前一样减少每个火花的 alpha 值,但是另外在每个时间步长更新它的年龄。然后根据粒子的寿命检查粒子的年龄:如果超过寿命,就调用removeParticle()方法,从particles数组中删除粒子。

运行模拟,你会看到一些跟随鼠标的漂亮火花(见图 12-6 )。

A978-1-4302-6338-8_12_Fig6_HTML.jpg

图 12-6。

Making sparks

制作烟花动画

既然我们有了火花,我们可以用它们来制造焰火。我们想通过一系列的小爆炸来实现,而不是连续不断地产生火花。我们会有一个初始爆炸,它会产生一束随机速度的火花;这些火花随后会在重力的作用下被拖拽,随着时间的推移而消失。当每个火花达到其寿命的终点时,它将爆炸,产生进一步的火花。显然,我们希望在某个时候停止这个过程。最简单的方法是通过设置全局截止时间来限制动画的持续时间。

以下是文件fireworks.js中的完整源代码,修改过的部分用粗体突出显示。请注意,与前面的 sparks 示例相比,还删除了一些代码,包括事件监听器/处理程序代码。

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var particles;

var m = 1;

var g = 10;

var k = 0.005;

var vx =``150

var vy = -100;

var numSparks = 10;

var minLife = 2;

var maxLife = 4;

var duration = 6;

var fps = 30;

var t0, t, dt;

var acc, force;

var posEmitter;

window.onload = init;

function init() {

particles = new Array();

posEmitter = new Vector2D(0.5*canvas.width,200);

createNewParticles(posEmitter,255,255,0);

t0 = new Date().getTime();

t = 0;

animFrame();

}

function animFrame(){

setTimeout(function() {

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}, 1000/fps);

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

t += dt;

move();

}

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

for (var i=0; i<particles.length; i++){

var particle = particles[i];

modifyObject(particle,i);

moveObject(particle);

calcForce(particle);

updateAccel();

updateVelo(particle);

}

}

function createNewParticles(ppos,``r,g,b

for (var i=0; i<numSparks; i++){

var newParticle = new Spark(2,r,g,b,1,m);

setProperties(newParticle,ppos);

particles.push(newParticle);

}

}

function setProperties(obj,ppos){

obj.pos2D = ppos;

obj.velo2D = new Vector2D((Math.random()-0.5)*vx,(Math.random()-0.5)*vy);

obj.lifetime = minLife + (maxLife-minLife)*Math.random();

obj.age = 0;

}

function modifyObject(obj,i){

obj.alpha += -0.01;

obj.age += dt;

if (obj.age > obj.lifetime){

if (t < duration){

explode(obj);

}

removeObject(i);

}

}

function explode(obj){

createNewParticles(obj.pos2D,0,255,0);

}

function removeObject(num){

particles.splice(num,1);

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

obj.draw(context);

}

function calcForce(obj){

var gravity = Forces.constantGravity(m,g);

var drag = Forces.drag(k,obj.velo2D);

force = Forces.add([gravity, drag]);

}

function updateAccel(){

acc = force.multiply(1/m);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

首先请注意,我们已经将vx的值从 100 更改为 150。这只是为了让火花水平传播多于垂直传播。我们还添加了四个新变量:numSparksminLifemaxLifeduration,分别存储火花的数量、它们的最短和最长寿命以及动画的持续时间。下一个变化是现在从init()方法中调用createNewParticles()方法,而不是从moveObject()方法中调用。这意味着粒子是最初创建的,而不是在每个时间步创建的。对createNewParticles()方法进行了修改,以接受指定火花的 RGB 颜色的新参数,在每个时间步长创建多个numSparks粒子,而不仅仅是一个,并为这些粒子分配指定的 RGB 颜色。从createNewParticles()调用的setProperties()方法然后给每个粒子分配一个在minLifemaxLife之间的随机寿命。

modifyObject()方法进行了修改,只要没有超过duration,任何超过其寿命的粒子都会在被移除之前爆炸。爆炸由explode()方法处理,该方法简单地调用createNewParticles()方法,并将垂死粒子的位置指定为爆炸的位置,并为下一代火花赋予新的颜色。

运行代码,您将看到一场焰火表演(见图 12-7 )。不用说,您可以不断调整动画来改善外观,并添加更多的效果,如尾随火花,烟雾,甚至声音效果。我们已经完成了向您展示如何实现基础物理的工作。现在去争取吧!

A978-1-4302-6338-8_12_Fig7_HTML.jpg

图 12-7。

Fireworks!

在源代码中,我们还在名为fireworks2.js的文件中包含了模拟的修改版本。这是一个交互式版本,您可以在画布上的任何地方单击并按住鼠标。火花数numSparks将被设置为你按住鼠标时间的两倍,所以你按下鼠标的时间越长,每次爆炸产生的火花数就越多。当你放开鼠标,最初的爆炸将发生在鼠标的位置。火花的颜色在这个版本中也是随机的。试试看!

具有远程力的粒子动画

现在是时候超越现实,开始摆弄粒子和物理了。在这一节中,我们将在远程力的作用下移动大量的粒子来创建一些有趣的图案和动画。

在这一节和下一节,我们通常会处理大量的粒子和大量的计算。为了减少任何不必要的开销,我们将使用一个稍微轻量级的Ball对象,我们称之为StarStar对象类似于Ball对象,但是它不绘制渐变填充,因此不需要额外的代码。它还免除了chargegradientangVelo属性。本章剩余的例子将利用Star对象。

力场中的粒子路径

粒子轨迹通常用于创成式艺术项目。我们现在将建立一个使用多个粒子的简单例子,作为进一步探索的基础。

这个动画的思路很简单,原理上和第十章的重力场例子差不多。我们在一个中心吸引子周围随机设置一些恒星,并给它们小的随机速度。恒星受到朝向吸引子的重力作用,绕着吸引子运动,追踪轨迹。

源文件名为long-range.js,包含以下代码:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_bg = document.getElementById('canvas_bg');

var context_bg = canvas_bg.getContext('2d');

var G = 10;

var attractors;

var orbiters;

var t0;

var dt;

var force;

var acc;

var numOrbiters = 20;

var numAttractors = 5;

var graph;

window.onload = init;

function init() {

// create attractors

attractors = new Array();

for (var i=0; i<numAttractors; i++){

var attractor = new Ball(20,'#333333',10000,0,false);

attractor.pos2D = new Vector2D(Math.random()*canvas.width,Math.random()*canvas.height);

attractor.draw(context_bg);

attractors.push(attractor);

}

// create orbiters

orbiters = new Array();

for (var i=0; i<numOrbiters; i++){

var orbiter = new Star(5,'ffff00',1);

orbiter.pos2D = new Vector2D(Math.random()*canvas.width,Math.random()*canvas.height);

orbiter.velo2D = new Vector2D((Math.random()-0.5)*50,(Math.random()-0.5)*50);

orbiter.draw(context);

orbiters.push(orbiter);

}

setupGraph();

t0 = new Date().getTime();

animFrame();

}

function animFrame(){

requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

for (var i=0; i<numOrbiters; i++){

var orbiter = orbiters[i];

plotGraph(orbiter);

moveObject(orbiter);

calcForce(orbiter);

updateAccel(orbiter.mass);

updateVelo(orbiter);

}

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

if (obj.x < 0 || obj.x > canvas.width || obj.y < 0 || obj.y > canvas.height){

recycleOrbiter(obj);

}

obj.draw(context);

}

function updateAccel(mass){

acc = force.multiply(1/mass);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

function calcForce(obj){

var gravity;

force = Forces.zeroForce();

for (var i=0; i<numAttractors; i++){

var attractor = attractors[i];

var dist = obj.pos2D.subtract(attractor.pos2D);

if (dist.length() > attractor.radius+obj.radius){

gravity = Forces.gravity(G,attractor.mass,obj.mass,dist);

force = Forces.add([force, gravity]);

}

}

}

function recycleOrbiter(obj){

obj.pos2D = new Vector2D(Math.random()*canvas.width,Math.random()*canvas.height);

obj.velo2D = new Vector2D((Math.random()-0.5)*100,(Math.random()-0.5)*100);

}

function setupGraph(){

graph = new Graph(context_bg,0,canvas.width,0,canvas.height,0,0,canvas.width,canvas.height);

}

function plotGraph(obj){

graph.plot([obj.x], [-obj.y], '#cccccc', false, true);

}

这段代码中几乎没有您以前没有见过的内容,我们将把重点放在几个新元素上。在init()中,我们创建多个吸引子作为Ball实例,多个轨道器作为Star实例。在calcForce()中,我们使用Forces.gravity()函数使每颗恒星受到吸引子的引力,但是请注意,如果恒星碰巧在一个特定的吸引子内,我们不包括它施加的力,实际上将该力设置为零。正如第十章中的重力场例子,我们使用一个graph物体来绘制每个轨道飞行器的轨迹。

运行模拟,并通过改变参数和以任何你能想到的方式修改它。例如,您可以添加一个有限的粒子寿命。如果你有一个运行缓慢的机器,你可能想要减少粒子的数量。你可以想出一些有趣的模式(例如,见图 12-8 )。

A978-1-4302-6338-8_12_Fig8_HTML.jpg

图 12-8。

An example of particle trajectories under a central gravitational force

建造虫洞

在这个古怪的例子中,我们将创造一些不反映任何真实物理现象,但看起来却很有趣的东西!

虫洞是一个假设的物体,你进入一个黑洞,然后在空间的其他地方出现。在我们的动画中,我们将有一个由黑洞和白洞组成的虫洞,黑洞吸入恒星,白洞再次将它们喷出。为了使动画在视觉上更有趣,从黑洞出来的恒星将会增大尺寸,并以随机的速度返回黑洞,从而创建一个循环系统。你期望最终会发生什么?让我们找出答案。首先,我们将显示文件wormhole.js中的模拟变量和参数值以及init()函数:

var stars;

var numStars = 1000;

var massStar = 1;

var massAttractor = 10000;

var radiusAttractor = 20;

var posAttractor = new Vector2D(400,400);

var posEmitter = new Vector2D(400,100);

var G = 10;

var t0, dt;

var acc, force;

window.onload = init;

function init() {

// create a stationary black hole

var blackHole = new Star(radiusAttractor,'#222222',massAttractor);

blackHole.pos2D = posAttractor;

blackHole.draw(context_bg);

// create a stationary white hole

var whiteHole = new Star(10,'#ffffff');

whiteHole.pos2D = posEmitter;

whiteHole.draw(context_bg);

// create stars

stars = new Array();

for (var i=0; i<numStars; i++){

var star = new Star(2,'#ffff00',massStar);

star.pos2D = new Vector2D(Math.random()*canvas.width,Math.random()*canvas.height);

star.velo2D = new Vector2D((Math.random()-0.5)*50,(Math.random()-0.5)*50);

star.draw(context);

stars.push(star);

}

t0 = new Date().getTime();

animFrame();

}

我们创建了一个黑洞和一个白洞,并把它们放置在彼此相隔一定距离的位置,分别用posAttractorposEmitter表示。接下来,我们创建 1000 颗星星,并将它们随机放置在画布上。星星像往常一样通过调用animFrame()被动画化。随后的代码是标准的,唯一的新特性出现在calcForce()recycleObject()方法中:

function calcForce(obj){

var dist = obj.pos2D.subtract(posAttractor);

if (dist.length() < radiusAttractor){

recycleObject(obj);

}else{

var gravity;

force = Forces.zeroForce();

if (dist.length() > radiusAttractor+obj.radius){

gravity = Forces.gravity(G,massAttractor,massStar,dist);

force = Forces.add([force, gravity]);

}

}

}

function recycleObject(obj){

obj.pos2D = posEmitter;

obj.velo2D = new Vector2D((Math.random()-0.5)*50,Math.random()*10);

obj.radius *= 1.5;

}

calcForce()方法与前面的例子几乎相同。新奇的是我们有了一个新的recycleObject()方法,如果恒星进入黑洞就调用这个方法。recycleObject()方法立即将进入黑洞的粒子移动到白洞的位置,给它们一个随机的向下速度,并将其半径增加 50%。

如果你运行代码,你会发现恒星被吸进黑洞,从白洞出来变大。因为它们再次被喷向黑洞周围,一些恒星被吸引回黑洞,而另一些则停留在黑洞周围的轨道上。一些恒星可能会逃脱,这取决于你设置的最大速度。少数恒星穿过虫洞几次,变得越来越大。最终你会发现不同大小的恒星被困在黑洞周围的永久轨道上,如图 12-9 所示。因为那些恒星是在黑洞周围的封闭轨道上,并且起源于白洞,所以它们的轨道靠近白洞,给人的感觉是后者也在吸引它们!

A978-1-4302-6338-8_12_Fig9_HTML.jpg

图 12-9。

Stars circulating through a wormhole!

相互作用粒子系统

到目前为止,我们所研究的粒子系统还没有涉及到粒子之间的任何相互作用。例如,你可以让粒子互相施加引力。这会产生一些有趣的效果。问题是,随着粒子数量的增加,计算的次数会变得非常多。对于 N 个粒子,大约有 N 个 2 个相互作用,因为每个粒子都与其他粒子相互作用。即使只有 100 个粒子,那也是每时间步 10,000 次额外的重力计算!接下来的两个例子说明了我们处理这个问题的不同方法。

相互引力下的多个粒子

在这个例子中,我们将修改前两个例子中的重力动画,包括恒星之间的相互引力。用物理学术语来说,这将是一个直接的 N 体计算。

同样,让我们从查看相关文件multigravity.js中的变量声明/初始化和init()函数开始:

var stars;

var numStars = 200;

var massStar = 100;

var vmag = 10;

var massNucleus = 1;

var radiusNucleus = 20;

var posNucleus = new Vector2D(0.5*canvas.width,0.5*canvas.height);

var G = 1;

var eps = 1;

var rmin = 100;

var t0, dt;

var acc, force;

window.onload = init;

function init() {

// create a stationary attracting nucleus

var nucleus = new Star(radiusNucleus,'#333333',massNucleus);

nucleus.pos2D = posNucleus;

nucleus.draw(context_bg);

// create stars

stars = new Array();

for (var i=0; i<numStars; i++){

var star = new Star(2,'#ffff00',massStar*(Math.random()+0.1));

star.pos2D = new Vector2D(Math.random()*canvas.width,Math.random()*canvas.height);

star.velo2D = new Vector2D((Math.random()-0.5)*vmag,(Math.random()-0.5)*vmag);

star.draw(context);

stars.push(star);

}

t0 = new Date().getTime();

animFrame();

}

在这里,我们创造了一个中央吸引核以及 200 颗随机分布在它周围的恒星,它们的随机速度很小。原子核的质量是恒星质量的 100 倍,但是你可以改变这些参数来观察它们的效果。与之前示例的唯一实质性区别在于calcForce()方法:

function calcForce(obj,num){

var dist = obj.pos2D.subtract(posNucleus);

var gravityCentral;

if (dist.length() < radiusNucleus) {

gravityCentral = new Vector2D(0,0);

}else{

gravityCentral = Forces.gravity(G,massNucleus,obj.mass,dist);

}

var gravityMutual = new Vector2D(0,0);

for (var i=0; i<stars.length; i++){

if (i != num){

var star = stars[i];

var distP = obj.pos2D.subtract(star.pos2D);

if (distP.length() < rmin){

var gravityP = Forces.gravityModified(G,star.mass,obj.mass,distP,eps);

gravityMutual.incrementBy(gravityP);

}

}

}

force = Forces.add([gravityCentral, gravityMutual]);

}

你已经熟悉了calcForce()中代码的前半部分,它计算了恒星由于中央原子核而受到的力gravityCentral。代码的后半部分计算所有其他恒星(不包括它自己,因此有了if (i != pnum){}条件)对一颗恒星施加的合力gravityMutual,并将其与gravityCentral力相加,得到每颗恒星上的合力。

这段代码中有两个新特性,嵌套在第一个语句中的另一个if语句中:

if (distP.length() < rmin){

var gravityP = Forces.gravityModified(G,star.mass,obj.mass,distP,eps);

gravityMutual.incrementBy(gravityP);

}

第一个新特性是if条件,它检查当前恒星与任何其他恒星的距离是否小于某个最小距离rmin。只有当条件满足时,后一颗星产生的重力才包括在内。这叫做局域相互作用近似。这个想法是因为重力是平方反比定律,它的大小随着距离的增加而迅速下降。因此,如果另一颗恒星非常远,包括它施加的力对总力不会有太大影响,只是浪费资源。所以你只包括最近邻居的影响。在精确的模拟中,您必须小心地正确实现这种近似,因为有一些微妙之处。但是因为我们只是随便玩玩,可以这么说,没有必要去深究那些细枝末节。代码中选择的rmin的值是 100 像素,但是您可以尝试其他值。给rmin0 的值将有效地排除恒星之间的所有相互作用。给rmin一个大于 800 的值将包括所有 200 颗恒星之间的相互作用:这大约是每时间步 400,000 次额外的重力计算,并且肯定会减慢你的动画速度!

第二个新特征是,我们正在使用一个名为Forces.gravityModified()的引力函数的修改版本来计算恒星之间的相互作用。新功能定义如下:

Forces.gravityModified = function(G,m1,m2,r,eps){

return r.multiply(-G*m1*m2/((r.lengthSquared()+eps*eps)*r.length()));

}

将此与Forces.gravity()函数进行比较,你会发现我们引入了一个新的参数eps,它的平方被加到函数分母中的 r 2 项上。相应的数学公式是:

A978-1-4302-6338-8_12_Figd_HTML.jpg

ε符号(希腊字母 epsilon)代表eps参数。为什么我们要这样修改引力函数?当两个相互施加引力的物体靠得很近时,力变得非常大,甚至无穷大(因为 r 趋于零,我们最后除以一个很小的数或零)。正如你在《??》第六章中回忆的那样,这导致两个物体极大地加速,并被送往任意方向。ε因子是控制问题的一个常见的“借口”。因为ε 2 永远是正的(是实数的平方),分母永远不会为零。只要ε很小,除了非常小的值外,对于大多数 r 值,它不会改变力的计算。在本例中,eps的值为 1 个像素。您也可以尝试不同的值,看看它会产生什么效果。

运行仿真并用不同的参数值进行实验。例如,你可以改变中心原子核和恒星的质量,从而改变中心力相对于恒星间相互作用力的相对重要性。使用参数的默认值运行代码会使星星在不同的位置聚集在一起,有几颗在中心吸引子周围徘徊。截图如图 12-10 所示。同样,如果模拟在您的计算机上运行缓慢,您可能希望减少粒子的数量。

A978-1-4302-6338-8_12_Fig10_HTML.jpg

图 12-10。

Lots of stars exerting gravitational forces on one another

一个简单的星系模拟

你需要做什么来模拟星系中恒星的运动?嗯,这不是一件简单的工作,通常需要一些重要的硬件和大量的物理和代码。但是有可能作弊,想出一个非常简化的办法。虽然在更复杂的方法中结果肯定不会是这样,但它会给你一些有趣的东西来进一步发挥和试验。

当我们试图模拟这样一个系统时,最明显的问题是如何处理你需要的大量粒子。我们在这里谈论的至少有数千人,可能有数万人。对于如此大量的粒子,直接的 N-body 计算是不切实际的,尤其是对于一个将要在网络浏览器中运行的动画!

另一种方法是考虑每颗恒星在有效引力场中的运动,因为所有其他恒星合在一起。这就是所谓的平均场方法。恒星系统实际上被认为是一个非相互作用无碰撞系统,每个恒星在平均场中独立运动。随着恒星位置的改变,平均场当然会随着时间而演变,并且这种演变需要被包含在这样的模拟中。

有一些数值方法可以用来计算点质量的一般分布所产生的平均场,但是它们超出了本书的范围。然而,如果我们假设恒星以球对称的方式围绕银河系中心分布,由于一个简洁的数学原理,这个任务就变得简单多了。这个原理说,对于任何球对称的质量分布,在离中心任意距离 r 处的重力与半径为 r 的球内的总质量施加的力相同,该质量被认为是在中心。半径之外的任何质量都不会有任何影响。如果你开始一个恒星球对称分布的模拟,它应该一直保持球对称。所以你可以在每个时间点应用这个原则。

星系中的恒星并不是唯一的质量来源。许多星系都有一个可能是超大质量黑洞的中央核心。所以这个星系核的质量需要考虑进去。但那很容易;我们只是对原子核使用指定质量的Forces.gravity()函数。

还有一种更奇特的东西潜伏在星系中:暗物质。事实上,据估计,一个星系中的大部分质量存在于星系周围的一个暗物质晕中。同样,假设球对称,这种暗物质对引力的贡献可以用与恒星相同的方法计算出来。

有了这些基本的成分,让我们把一个快速和肮脏的星系模拟放在一起。相关文件galaxy.js中的大部分代码看起来与前面的例子相似,但是我们在这里列出了完整的源代码:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var stars;

var numStars = 5000;

var massStar = 10;

var veloMag = 20;

var maxRadius = 300;

var nucleus;

var massNucleus = 100000;

var radiusNucleus = 20;

var posNucleus = new Vector2D(0.5*canvas.width,0.5*canvas.height);

var G = 1;

var rmax = 500;

var constDark = 1000;

var A = 1;

var alpha = 0.5;

var t0, dt;

var acc, force;

var massStars = new Array(); // total mass of stars within radius r

var massDark = new Array();  // total mass of dark matter within radius r

window.onload = init;

function init() {

// create a stationary attracting nucleus

nucleus = new Star(radiusNucleus,'#333333',massNucleus);

nucleus.pos2D = posNucleus;

nucleus.draw(context);

// initial distribution of stars

stars = new Array();

for (var i=0; i<numStars; i++){

var star = new Star(1,'#ffff00',massStar);

var radius = radiusNucleus + (maxRadius-radiusNucleus)*Math.random();

var angle = 2*Math.PI*Math.random();

star.pos2D = new Vector2D(radius*Math.cos(angle),radius*Math.sin(angle)).add(posNucleus);

var rvec = posNucleus.subtract(star.pos2D);

star.velo2D = rvec.perp(veloMag);

star.draw(context);

stars.push(star);

}

t0 = new Date().getTime();

animFrame();

}

function animFrame(){

requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

nucleus.draw(context);

calcMass();

for (var i=0; i<numStars; i++){

var star = stars[i];

moveObject(star);

calcForce(star,i);

updateAccel(massStar);

updateVelo(star);

}

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

obj.draw(context);

}

function updateAccel(mass){

acc = force.multiply(1/mass);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

function calcForce(obj){

var dist = obj.pos2D.subtract(posNucleus);

if (dist.length() < radiusNucleus) {

force = new Vector2D(0,0);

}else{

force = Forces.gravity(G, massNucleus + massStars[Math.ceil(dist.length())] + massDark[Math.ceil(dist.length())], massStar, dist);

}

}

function calcMass(){

var distanceToCenter;

var star;

var massStarRing = new Array();

var massDarkRing = new Array();

for (var l=0; l<rmax; l++){

massStarRing[l] = 0;

massDarkRing[l] = constDark*l*l*Math.exp(-A*Math.pow(l,alpha));

}

for (var k=0; k<stars.length-1; k++){

star = stars[k];

distanceToCenter = star.pos2D.subtract(posNucleus).length();

massStarRing[Math.ceil(distanceToCenter)] += star.mass;

}

massStars[0] = massStarRing[0];

massDark[0] = massDarkRing[0];

for(var j=1; j<stars.length-1; j++){

massStars[j] = massStars[j-1] + massStarRing[j];

massDark[j] = massDark[j-1] + massDarkRing[j];

}

//console.log(massNucleus,massStars[rmax-1],massDark[rmax-1]);

}

我们首先创建一个质量为 100,000 单位、半径为 20 像素的星系核,并将其放在画布的中间。然后我们创建 5000 颗恒星(取决于你的计算机速度,你可能想减少这个数字),每颗恒星的质量为 10 个单位,并将它们放置在银河系中心周围的随机圆形分布中(这就是init()中三角学代码的线条所实现的)。请注意,这里的每个“星星”实际上代表许多星星;一个星系中通常有数十亿颗恒星,而不是数千颗!然后给恒星一个绕银河系中心的初始切向速度。每颗恒星的速度大小相同veloMag;这是因为观察到的星系旋转曲线表明,星系中的速度几乎与距星系中心的距离无关。这里已经有很多参数可以玩了,比如银河系中心的质量,每颗恒星的质量,旋转速度等等。你也可以尝试不同的恒星初始位置和速度。在文件galaxy.js中,有一些注释行,对这些初始条件有不同的选项。尝试它们,看看它们如何导致星系的不同演化。

Note

如果您的机器运行缓慢,您可能需要删除包含以下代码的行:if (dt>0.2) {dt=0;}。该模拟中的大量粒子意味着必须在每个时间步长执行大量计算,这在慢速机器上可能需要 0.2 秒以上的时间。在这种情况下,任何运动都不会发生!

接下来我们来看看calcForce()方法。正如你从代码中看到的,我们正在通过使用Forces.gravity()函数计算每颗恒星在距中心(dist)的距离处的总引力,质量是星系核质量和该半径内恒星和暗物质质量的总和,存储在数组massStarsmassDark中。通过在calcMass()方法中对距中心不同距离处的质量求和,在每个时间步更新这些阵列。在calcMass()末端的注释行将三个总质量输出到控制台。

恒星的质量可以简单地通过定位恒星来计算。对于暗物质来说,情况就不一样了,因为它不是作为一个物体包含在模拟中的。因此,我们基于所谓的爱因斯坦轮廓(Einasto profile)规定了一种质量分布,这种轮廓通常用于暗物质的计算机模拟。该分布的数学形式如下:

A978-1-4302-6338-8_12_Fige_HTML.jpg

这里ρ 0 ,A 和α是必须指定的常数。这个公式给出了质量密度(每单位体积的质量)。为了获得距离 r 处的质量,必须将该质量密度乘以厚度为 dr 的薄球壳的体积。这引入了额外的因子 4πr 2 dr(球体的表面积乘以球壳的厚度)。这给出了编码在calcMass()中的公式。如果这种暗物质材料看起来有点难以理解,也不用担心。我们把它包括在内,是因为它能以一种显著的方式改变相互作用的恒星的动力学,产生一种“更丰富”的模拟。但是,即使您没有完全理解这里的所有内容,您仍然可以尝试使用相关的参数来探索使用这些附加的物理特性可以获得的可能效果。

如果您使用默认的参数值运行代码,您应该会发现在连续的初始收缩和膨胀之后,星形分布稳定在一个准稳定状态。图 12-11 显示了你可能会看到的截图。人们有时甚至可以分辨出一些环状结构,尽管这需要更仔细的模拟才能正确地再现真实的物理结构。同样,你可以改变galaxy.js中的参数,尤其是暗物质分布的参数。当你改变星系核质量、恒星总质量和暗物质总质量的相对量级时,看看模拟是如何变化的将是有益的。玩得开心!

A978-1-4302-6338-8_12_Fig11_HTML.jpg

图 12-11。

A simple galaxy simulation with 5000 particles

摘要

这一章已经提供了用粒子和一些简单的物理学可以达到的效果。在短短的一章中,不可能完全涵盖这个主题。因此,我们鼓励你搜索网络,探索动画社区内外已经取得的成就。才华横溢的个人创作的例子不胜枚举,但我们在网站上提供了相关链接: www.physicscodes.com

在前几章中,你已经从粒子中获得了很多乐趣。在下一章,我们将看看如何模拟更复杂的扩展物体的运动。

十三、扩展对象

在本书的大部分内容中,我们集中讨论了粒子的运动。在这一章中,我们将探索支配延伸物体运动的物理学。这个主题很重要,原因显而易见:日常生活中的物体在空间中延伸,具有各种形状、大小和行为。为了模拟扩展对象的运动,我们需要涵盖一些额外的物理和方法。这是一个很大的主题,可以很容易地写满几章,甚至一整本书。因此,我们在报道中有所选择,集中于两个主题,我们可以充分深入地报道,以提供一个良好的介绍,而不是提供一个更广泛但更肤浅的介绍。

本章涵盖的主题包括以下内容:

  • 刚体:刚体是具有固定形状和大小的扩展对象。除了改变它的位置,这样的物体也可以旋转来改变它的方向。我们将涵盖刚体动力学、滚动和刚体碰撞,并用许多不同的例子来说明它们。
  • 可变形物体:物体如绳子和衣服可以改变它们的形状。我们可以通过质量弹簧系统来模拟这种可变形物体,建立在第八章讨论的原理上。

这一章的大部分内容是关于刚体的,关于变形体的讨论要短得多。

刚体

刚体被定义为当力施加到其上时保持其形状和大小的物体。换句话说,刚体在力的作用下不会变形。刚体的概念是一个理想化的概念,因为在现实中,几乎所有的物体在受到力的作用时都会发生一些变形,即使是微小的变形。然而,当对许多对象的运动进行建模时,通常可以忽略任何这样的变形,并且仍然可以获得良好的结果。这是我们将在本节中采用的方法。

刚体建模的基本概念

在我们能够模拟刚体的运动之前,我们需要回顾前面章节中的一些相关概念,并介绍一些新的概念。这些概念将在下一节集中在一起,形成支配刚体转动的定律。

刚体运动与粒子运动

与质点运动相比,刚体运动的关键区别在于,刚体既可以转动,也可以平移。刚体的平移运动遵守与质点相同的定律(如第五章所述)。我们现在必须讨论如何分析旋转运动。

旋转运动学

在第九章中,我们看了旋转运动的运动学,引入了角位移θ、角速度ω、角加速度α等概念。简单回顾一下,以下是这些量的定义公式:

角速度:

A978-1-4302-6338-8_13_Figa_HTML.jpg

角加速度:

A978-1-4302-6338-8_13_Figb_HTML.jpg

这些旋转运动变量与相应的线性变量相关,例如:

线性位移和角位移:

A978-1-4302-6338-8_13_Figc_HTML.jpg

线速度和角速度:

A978-1-4302-6338-8_13_Figd_HTML.jpg

线性(切向)和角加速度:

A978-1-4302-6338-8_13_Fige_HTML.jpg

在刚体旋转的情况下,这些公式给出了在没有平移运动的情况下,或者除了平移运动之外,刚体上距离旋转中心距离为 r 的点的位移、速度和切向加速度。

除了切向加速度,还有一个向心(径向)加速度,由以下公式给出:

A978-1-4302-6338-8_13_Figf_HTML.jpg

请注意,刚体上的所有点必须具有相同的角位移、角速度和角加速度,而这些量的线性对应关系取决于距旋转中心的距离 r。

我们还将使用前面的量和公式的矢量等价物。例如,角速度实际上是一个矢量ω,其方向垂直于旋转平面,方向如图 13-1 所示。因此,作为角速度导数的角加速度也是矢量α。

A978-1-4302-6338-8_13_Fig1_HTML.jpg

图 13-1。

Angular velocity as a vector

在画布元素上旋转刚体对象

如何在画布元素上旋转对象?在第九章中,我们通过旋转整个画布来实现。回想一下,在进行旋转之前,我们首先必须移动画布,使其原点位于对象的中心。旋转画布后,在绘制对象之前,我们将画布平移回其原始位置。如果有许多对象正在被动画,这个过程必须在每个时间步为每个对象重复。肯定有更好的方法来做到这一点。

虽然在画布元素上绘制单个对象后不能旋转它们,但是您可以首先控制绘制它们的方向。在动画中,如果您跟踪对象的方向,那么您可以在每个时间步长以稍微不同的方向重新绘制它。

为了说明这个想法的应用,让我们从第九章中翻出wheel-demo.js模拟和Wheel对象,并对它们做一些小的修改。在wheel.js中,我们在构造函数中添加了一个rotation属性来跟踪轮子实例的方向。rotation的值将以弧度表示一个角度,初始值为 0:

this.rotation = 0;

然后,在Wheeldraw()方法中,我们修改了绘制轮辐的代码,如粗体所示:

for (var n=0; n<nums; n++){

context.moveTo(this.x,this.y);

context.lineTo(this.x + ir*Math.cos(2*Math.PI*n/nums``+ this.rotation``), this.y + ir*Math.sin(2*Math.PI*n/nums``+ this.rotation

}

通过这一修改,draw()方法将轮子实例的旋转添加到它绘制每个辐条的角度上。

接下来我们从第九章的修改wheel-demo.js,称新文件为wheel-rotate.js。在这个文件中,我们去掉了onTimer()中执行画布转换的代码行:

context.save();

context.translate(wheel.x,wheel.y);

context.rotate(angle);

context.translate(-wheel.x,-wheel.y);

context.restore();

我们用单行替换这五行:

wheel.rotation = angle;

运行代码,你会发现它产生的动画与第九章中的动画一模一样。但是这段代码更简单、更优雅、更高效。在这个简单的例子中,性能的提高可能并不明显,但是如果正在制作大量旋转对象的动画,性能的提高可能是相当可观的。

多边形对象和旋转多边形

我们在上一节中使用的方法可以适用于旋转除轮子以外的其他类型的对象。在这一章中,我们将会经常用到多边形,所以让我们创建一个多边形对象并应用这个方法:

function Polygon(vertices,color,mass){

if(typeof(color)==='undefined') color = '#0000ff';

if(typeof(mass)==='undefined') mass = 1;

this.vertices = vertices;

this.color = color;

this.mass = mass;

this.x = 0;

this.y = 0;

this.vx = 0;

this.vy = 0;

this.angVelo = 0;

}

Polygon.prototype = {

get pos2D (){

return new Vector2D(this.x,this.y);

},

set pos2D (pos){

this.x = pos.x;

this.y = pos.y;

},

get velo2D (){

return new Vector2D(this.vx,this.vy);

},

set velo2D (velo){

this.vx = velo.x;

this.vy = velo.y;

},

set rotation (angle){

for (var i=0; i<this.vertices.length; i++){

this.vertices[i] = this.vertices[i].rotate(angle);

}

},

draw: function (ctx) {

var v = new Array();

for (var i=0; i<this.vertices.length; i++){

v[i] = this.vertices[i].add(this.pos2D);

}

ctx.save();

ctx.fillStyle = this.color;

ctx.beginPath();

ctx.moveTo(v[0].x,v[0].y);

for (var i=1; i<v.length; i++){

ctx.lineTo(v[i].x,v[i].y);

}

ctx.lineTo(v[0].x,v[0].y);

ctx.closePath();

ctx.fill();

ctx.restore();

}

}

多边形对象有三个参数:vertices, colormass。这里感兴趣的是第一个,vertices。这是一个Vector2D对象的数组,指定要创建的多边形实例的顶点的位置向量。这些位置向量相对于多边形对象的位置,由xy值指定。对于正多边形,这将是其几何中心的位置。draw()方法通过连接这些顶点来绘制多边形。属性在这里的工作方式不同于在对象中的工作方式。这里使用一个 setter 来旋转多边形的顶点,旋转角度指定为rotation的值。这是通过使用新创建的Vector2D对象的rotate()方法完成的,该方法将一个向量旋转指定的角度作为它的参数(以弧度为单位)。其定义如下:

function rotate(angle){

return new Vector2D(this.x*Math.cos(angle) - this.y*Math.sin(angle), this.x*Math.sin(angle) + this.y*Math.cos(angle));

}

它用了一些三角学来做这个,你可以试着把它当成一个练习。

polygon-rotate.js文件给出了一个如何使用Polygon对象创建一个多边形并使其旋转的例子:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var v = 10;       // linear velocity in pixels per second

var w = 1;        // angular velocity in radians per second

var angDispl = 0; // initial angular displacement in radians

var dt = 30/1000; // time step in seconds = 1/FPS

// create a polygon

v1 = new Vector2D(-100,100);

v2 = new Vector2D(100,100);

v3 = new Vector2D(150,0);

v4 = new Vector2D(100,-100);

v5 = new Vector2D(-100,-100);

var vertices = new Array(v1,v2,v3,v4,v5);

var polygon = new Polygon(vertices);

polygon.x = 300;

polygon.y = 300;

polygon.draw(context);

setInterval(onTimer, 1/dt);

function onTimer(evt){

polygon.x += v*dt;

angDispl = w*dt;

polygon.rotation = angDispl;

context.clearRect(0, 0, canvas.width, canvas.height);

polygon.draw(context);

}

如您所见,此代码的工作方式与上一节中修改后的 wheel 示例相同,只需更新时间循环中多边形实例的 rotation 属性值,而无需调用 canvas 变换。

正如本节开始时指出的,您可以采用这种方法来处理其他类型的对象。当line属性被指定为true时,我们通过定义对应于在Ball实例上绘制的线的端点的“顶点”,在Ball对象中实现了它的变体。看看本章源代码中修改过的ball.js文件。Ball在绘制实例之前,只需改变它们的rotation属性,就可以使实例旋转,如ball-rotate.js示例文件所示。

力的转动效应:扭矩

假设一个刚体静止,我们想平移它(给它一点线速度)。我们如何做到这一点?我们需要施加一个力。但是如果我们想让它绕给定的轴旋转,我们必须做什么呢?我们再次需要施加一个力。但是力的作用线不能通过轴。力的转动效应被称为力矩或扭矩,定义为力与其作用线离轴的垂直距离的乘积(见图 13-2 ):

A978-1-4302-6338-8_13_Figg_HTML.jpg

这里,θ是力 F 和从旋转中心到力的作用点的矢量 r 之间的角度。

A978-1-4302-6338-8_13_Fig2_HTML.jpg

图 13-2。

Definition of torque due to a force

在矢量形式中,它如下:

A978-1-4302-6338-8_13_Figh_HTML.jpg

请注意,矢量积是不可交换的,因此乘积的执行顺序很重要。因为两个矢量的矢量积给出了一个垂直于两者的矢量,如果 r 和 F 在纸的平面上,转矩将指向纸的内部或外部(参见第三章中的“矢量相乘:矢量或叉积”一节,了解如何计算出合成矢量的方向)。

从这个定义可以清楚地看出,如果力的作用线通过轴,转矩为零。一般来说,一个力既会产生线性加速度,又会产生转向效应。

质心

从上一节我们所说的,你可以看到,如果一个物体可以自由移动(没有任何约束),你对它施加一个力,它将同时经历平移和旋转。如果力的作用线通过一个称为物体质心的特殊点,就会出现例外。在这种情况下,施加的力只产生平移,而不产生旋转。

质心是一个通用概念,适用于点粒子的集合,也适用于扩展对象。在这两种情况下,它都代表系统整体质量的作用位置。对于位于位置 r 1 ,r 2 ,…的质量为 m 1 ,m 2 ,…的点粒子集合,质心的位置矢量由以下公式给出:

A978-1-4302-6338-8_13_Figi_HTML.jpg

所以我们把粒子的乘积 m r 向量相加,然后除以它们的总质量。使用代表 sum 的符号σ,我们可以用下面的速记形式写出这个公式:

A978-1-4302-6338-8_13_Figj_HTML.jpg

对于刚体来说,这个公式的推广是通过把物体看作是由连续分布的质点组成的。然后可以使用微积分公式计算刚体的质心:

A978-1-4302-6338-8_13_Figk_HTML.jpg

这个公式的分母简单地等于刚体的总质量。质心的位置将取决于物体的形状及其质量的分布。对于对称且质量分布均匀的物体,质心位于其几何中心。例如,球体的质心位于其中心。其他物体如长方体、圆盘和矩形也是如此。

质心很重要,有两个原因。首先,它提供了粒子动力学和刚体动力学之间的联系:就其平移运动而言,刚体可以被认为是位于其质心的粒子。第二,刚体运动的分析,如果用以质心为原点的坐标系来表示,就简单多了。

转动惯量

我们说过转矩是旋转运动的力的模拟:转矩是角加速度的原因。质量(惯性)的旋转模拟是什么?这可能不明显,但是,正如下一节将要演示的,它是一个称为惯性矩的量。惯性矩定义为组成粒子系统或刚体的所有粒子的 mr 2 之和,其中 m 是粒子的质量,r 是其与旋转轴的距离。因此,对于离散的粒子系统,惯性矩(用符号 I 表示)的定义如下:

A978-1-4302-6338-8_13_Figl_HTML.jpg

这个公式告诉我们,粒子的质量越大,或者它们离轴的距离越大,转动惯量就越大。

对于质量的连续分布,如刚体,相应的微积分定义如下:

A978-1-4302-6338-8_13_Figm_HTML.jpg

这两个公式都告诉我们,转动惯量是一个量化刚体中质量分布的性质。使用微积分公式,可以计算出 2D 和三维中各种规则刚体的转动惯量。结果是一个关于刚体的总质量和一些线性尺寸的公式。

例如,质量为 m、半径为 r 的空心球体绕通过其中心的轴的惯性矩由以下公式给出:

A978-1-4302-6338-8_13_Fign_HTML.jpg

因为惯性矩取决于质量分布,相同质量 m 和半径 r 的实心球体具有不同的惯性矩,由以下公式给出:

A978-1-4302-6338-8_13_Figo_HTML.jpg

因此,与相同质量和半径的实心球相比,空心球的惯性矩更大(因此更难旋转)。

惯性矩也取决于旋转所围绕的轴。例如,图 13-3 所示的实心圆柱体绕 x 轴和 y 轴的惯性矩由以下公式给出(其中 m 为其质量,r 为其圆形横截面的半径,h 为其高度):

A978-1-4302-6338-8_13_Figp_HTML.jpg

A978-1-4302-6338-8_13_Fig3_HTML.jpg

图 13-3。

Rotation of a solid cylinder about different axes

相同圆柱体绕 z 轴的相应惯性矩由不同的公式给出:

A978-1-4302-6338-8_13_Figq_HTML.jpg

你可以在物理或工程教科书和网站上找到各种 2D 和 3D 物体的转动惯量。

角动量

另一个重要的概念是角动量,它是线性动量的旋转模拟。对于一个绕着一个距离它 r 的中心旋转的粒子,角动量(用符号 L 表示)被定义为它的线性动量和距离 r 的乘积:

A978-1-4302-6338-8_13_Figr_HTML.jpg

在矢量形式中,角动量矢量是位置矢量和动量矢量的矢量积:

A978-1-4302-6338-8_13_Figs_HTML.jpg

利用线速度和角速度之间的关系 v = rω,得出:

A978-1-4302-6338-8_13_Figt_HTML.jpg

因此,对于所有以相同角速度ω旋转的粒子的集合,总角动量由下式给出:

A978-1-4302-6338-8_13_Figu_HTML.jpg

使用惯性矩的定义,得出以下结果:

A978-1-4302-6338-8_13_Figv_HTML.jpg

在向量形式中,它是这样的:

A978-1-4302-6338-8_13_Figw_HTML.jpg

根据惯性矩的适当微积分定义,这个结果也适用于刚体。

建模刚体

有了刚体概念的知识,我们现在可以构建一些基本的 JavaScript 对象来帮助我们创建刚体。

创建刚体对象

我们将创建的第一个对象是RigidBody对象。因为一个刚体拥有粒子的所有属性以及更多属性,所以使用模拟第四章中描述的经典继承的方法来“扩展”对象Particle可能是有意义的。如果您要开发一个包含许多从其他对象继承属性的对象的大型库,这将是推荐的方法。但是为了清晰起见,我们将只修改particle.jsParticle对象的代码,并将其保存为rigidbody.js

事实上,我们的RigidBody对象只是将Particle对象的charge属性替换为im属性,表示惯性矩(默认值为 1)。所以这里是RigidBody的目标代码:

function RigidBody(mass,momentOfInertia){

if(typeof(mass)==='undefined') mass = 1;

if(typeof(momentOfInertia)==='undefined') momentOfInertia = 1;

this.mass = mass;

this.im = momentOfInertia;

this.x = 0;

this.y = 0;

this.vx = 0;

this.vy = 0;

}

RigidBody.prototype = {

get pos2D (){

return new Vector2D(this.x,this.y);

},

set pos2D (pos){

this.x = pos.x;

this.y = pos.y;

},

get velo2D (){

return new Vector2D(this.vx,this.vy);

},

set velo2D (velo){

this.vx = velo.x;

this.vy = velo.y;

}

}

刚体实例的位置将始终被认为是其质心的位置,并由xy属性指定,或者等效地由pos2D属性指定。

扩展刚体对象

Particle一样,RigidBody对象没有图形。要在实践中使用它,您需要“扩展”它并包含一些图形。让我们创建几个例子,我们将在本章的后面使用。

BallRB 对象

正如RigidBodyim(惯性矩)属性替换了Particle中的charge属性一样,我们对Ball对象做了同样的处理,并将新对象称为BallRB。所以BallRB的构造函数是这样的:

function BallRB(radius,color,mass,momentOfInertia,gradient,line){}

所有参数都是可选的,默认值分别为20, '#0000ff', 1, 1, falsefalse

多边形对象

接下来,我们以类似于BallRB类的方式创建一个PolygonRB对象,本质上就是给Polygon添加一个惯性矩属性。因此,该对象的构造函数中的参数如下:

function PolygonRB(vertices,color,mass,momentOfInertia){}

Polygon对象一样,vertices是一个由Vector2D值组成的数组,对应于顶点相对于PolygonRB实例位置的位置,也就是相对于多边形质心的位置。

Polygon一样,PolygonRB对象用指定的颜色绘制一个多边形。指定顶点的顺序很重要。该代码通过按指定顺序连接顶点来绘制多边形,因此不同的顶点顺序会产生不同的形状。

使用刚体对象

文件rigid-body-test.js包含演示这些刚体类使用的代码。这段代码产生了一个旋转了 45 度的BallRB对象和一个正方形的PolygonRB对象,并使它们以不同的速度和相反的方向旋转。正方形也随着旋转慢慢向右移动。将这个例子与polygon-rotate.js中早先的旋转多边形进行比较,可以看出我们还没有做任何实质性的新东西——我们没有利用BallRBPolygonRB的转动惯量属性。要做到这一点,我们需要在我们的模拟中实现旋转动力学。但在此之前,我们需要更多的理论。

刚体的转动动力学

在发展了与它们的线性对应物相似的相关旋转运动概念之后,我们现在能够阐明旋转动力学的定律了。先说最重要的一个:牛顿第二运动定律的转动等效。

牛顿旋转运动第二定律

如图 13-4 所示,考虑一个刚体在力 F(产生力矩 T)的作用下绕轴旋转。

A978-1-4302-6338-8_13_Fig4_HTML.jpg

图 13-4。

A rotating rigid body

让我们把刚体想象成由小粒子组成,每个粒子的质量为 m,每个粒子都承受切向加速度 a(忽略任何径向加速度,它对旋转没有贡献)。然后,将牛顿第二定律应用于这样的粒子,得到如下结果:

A978-1-4302-6338-8_13_Figx_HTML.jpg

回想一下将切向加速度与角加速度联系起来的公式 a = rα,我们可以将前面的公式写成:

A978-1-4302-6338-8_13_Figy_HTML.jpg

因此,颗粒上的扭矩 T = Fr 是通过将前面的公式乘以 r 得到的:

A978-1-4302-6338-8_13_Figz_HTML.jpg

为了得到刚体上的总扭矩,我们将每个粒子上的扭矩相加,得到以下公式:

A978-1-4302-6338-8_13_Figaa_HTML.jpg

严格来说,对于刚体,我们应该用积分而不是离散和,但推理和最终答案是一样的,因为积分不过是一个连续和。所以我们在这里使用更简单的离散求和符号。

因为刚体上的所有点都有相同的角加速度,α是一个常数,我们可以把它从和中拿出来。认识到σMr2为惯性矩 I,我们然后获得以下最终结果:

A978-1-4302-6338-8_13_Figbb_HTML.jpg

在向量形式中,它是这样的:

A978-1-4302-6338-8_13_Figcc_HTML.jpg

这个公式相当于旋转运动的牛顿第二定律 F = m a,用扭矩、惯性矩和角加速度分别代替力、质量和线加速度。这个公式使我们能够根据施加在刚体上的力矩来计算它的角加速度。

作为特例,如果转矩 T 为零,该公式意味着角加速度也为零。这是牛顿第一运动定律的类比。

正如在直线运动中,你可以有减速扭矩以及加速扭矩。例如,你施加一个加速扭矩来做旋转木马。然后,由于摩擦产生的减速扭矩的作用,它减速并停止。

平衡中的刚体

在第四章中,我们讨论了一个物体在多个力的作用下处于平衡的情况。例如,匀速运动的飞机在四个主要力(重力、推力、阻力和升力)的作用下处于平衡状态。平衡的条件是力平衡(合力为零)。但是对于一个延伸的物体,比如一个刚体,这个条件是不够的。合成扭矩也必须为零;否则,物体将经历旋转加速度。

旋转平衡的概念可以用天平的例子很好地说明(见图 13-5 )。

A978-1-4302-6338-8_13_Fig5_HTML.jpg

图 13-5。

Rotational equilibrium illustrated by a balance

假设天平处于平衡状态,由枢轴任一侧的组合重量施加的向下的力被枢轴施加的向上的接触力平衡。此外,一个重物产生的顺时针扭矩或力矩被另一个重物产生的逆时针扭矩平衡:这就是众所周知的力矩原理。在矢量项中,一个力矩指向纸外,另一个指向纸内。

合力可能为零而合力扭矩不为零的一个例子是当你转动旋钮时(见图 13-6 )。在这种情况下,有两个相反的力施加在旋钮的两侧。如果它们的大小相等,则合力为零。然而,它们的扭矩将在相同的方向上(进入纸面),因为它们都产生顺时针旋转。这是力偶的一个例子:一对大小相等方向相反的力,它们有平行不重合的作用线,因此产生合力。

A978-1-4302-6338-8_13_Fig6_HTML.jpg

图 13-6。

A couple

角动量守恒

正如牛顿第二定律有一个旋转类比,其他定律的旋转类比也存在。我们现在将从角动量守恒定律开始,不加证明地陈述这些定律。

这个原理表明,如果没有外力作用在刚体或质点系上,它的角动量是守恒的。例如,如果一个物体以恒定的角速度旋转,它将继续这样做,除非对它施加一个外部扭矩。这就是地球继续绕轴旋转的原因。另一方面,由于相反的摩擦力矩,旋转的陀螺很快停止旋转。

角动量定理

角动量守恒是角冲量定理的一个特例。回忆一下第五章中的线性冲量定理。表达该定理的公式如下所示:

A978-1-4302-6338-8_13_Figdd_HTML.jpg

这个公式告诉我们,作用在物体上的冲量等于产生的动量的变化。

前一个方程的旋转模拟是通过取 r 与方程各边的矢量积得到的(因为 T = r × F,L = r × p):

A978-1-4302-6338-8_13_Figee_HTML.jpg

换句话说,作用在物体上的角冲量等于它产生的角动量的变化。回想一下,对于一个刚体,角动量 L = I w,当我们考虑刚体之间的碰撞时,这个结果是有用的。

如果力矩 T = 0,定理给出δL = 0,角动量守恒。因此,角动量守恒原理是前面所说的角动量定理的一个特例。

旋转动能

因为刚体既能旋转又能平移,所以除了与直线运动有关的动能之外,还有与旋转运动有关的动能。回想一下,线性动能由以下公式给出:

A978-1-4302-6338-8_13_Figff_HTML.jpg

我们说过转动惯量类比质量,角速度类比线速度。因此,当你知道转动动能由以下公式给出时,你可能不会感到惊讶:

A978-1-4302-6338-8_13_Figgg_HTML.jpg

所以刚体的总动能就是这两个动能之和。

旋转运动的功能定理

与力所做的功类似,扭矩所做的功等于扭矩与其产生的角位移的乘积:

A978-1-4302-6338-8_13_Fighh_HTML.jpg

然后,我们可以将功能定理(见第四章)应用于旋转运动的情况,得出结论:扭矩所做的功等于其产生的旋转动能的变化:

A978-1-4302-6338-8_13_Figii_HTML.jpg

在那种情况下,动能由 E k = Iω 2 给出。

模拟刚体动力学

我们现在准备用代码实现刚体动力学。要做到这一点,我们需要修改我们在本书中一直使用的动画循环代码的相关部分。

修改动画循环以包含旋转动力学

动画代码需要以直接的方式修改,以包括扭矩和角加速度,与力和线性加速度完全相似。下面的清单显示了实现这一点的典型代码,增加的部分以粗体突出显示:

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

obj.rotation = obj.angVelo*dt;

context.clearRect(0, 0, canvas.width, canvas.height);

obj.draw(context);

}

function calcForce(obj){

force = Forces.zeroForce();

torque = 0;

}

function updateAccel(obj){

acc = force.multiply(1/obj.mass);

alp = torque/obj.im;

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

obj.angVelo += alp*dt;

}

在该代码中,torque表示扭矩的大小,alp表示角加速度的大小。因此,它们都是数字,而不是像相应的forceacc变量那样的Vector2D对象。我们之所以只表示扭矩和角加速度的大小,是因为在我们要考虑的 2D 例子中,旋转只能发生在 x–y 平面(只有一个平面),因此任何相关的角速度、角加速度和扭矩的方向总是在一个假想的第三维空间中,该空间要么伸入该平面,要么伸出该平面。相关量的符号将决定它指向哪个方向。

简单的测试

让我们用一个简单的例子来测试新的旋转动力学代码。在rigid-body-dynamics.js中,我们使用PolygonRB对象创建一个方形刚体,然后对其施加扭矩。init()方法中的设置代码如下所示:

function init() {

var v1 = new Vector2D(-100,100);

var v2 = new Vector2D(100,100);

var v3 = new Vector2D(100,-100);

var v4 = new Vector2D(-100,-100);

var vertices = new Array(v1,v2,v3,v4);

rigidBody = new PolygonRB(vertices);

rigidBody.mass = 1;

rigidBody.im = 5;

rigidBody.pos2D = new Vector2D(200,200);

rigidBody.velo2D = new Vector2D(10,0);

rigidBody.angVelo = 0;

rigidBody.draw(context);

t0 = new Date().getTime();

animFrame();

}

物体的质量和转动惯量被设定,其速度和角速度被初始化。后续的动画代码如下所示:

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

moveObject(rigidBody);

calcForce(rigidBody);

updateAccel(rigidBody);

updateVelo(rigidBody);

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

obj.rotation = obj.angVelo*dt;

context.clearRect(0, 0, canvas.width, canvas.height);

obj.draw(context);

}

function calcForce(obj){

force = Forces.zeroForce();

force = force.addScaled(obj.velo2D,-kLin); // linear damping

torque = 1;

torque += -kAng*obj.angVelo; // angular damping

}

function updateAccel(obj){

acc = force.multiply(1/obj.mass);

alp = torque/obj.im;

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

obj.angVelo += alp*dt;

}

正如你在calcForce()方法中看到的,在每个时间步都施加了 1 个单位的扭矩。如果这就是所做的一切,那么正方形将会经历连续的角加速度,旋转得越来越快。因此,还应用与角速度成比例的角阻尼项来限制其角速度。力中还加入了线性阻尼项。相应的阻尼因子kLinkAng的值分别设置为 0.05 和 0.5。

如果你运行模拟,你会发现广场平移到右边,而顺时针方向旋转。随着时间的推移,其线速度会降低,直到停止(因为没有驱动力,而是有一个与其速度成比例的减速力),而其角速度会增加到一个恒定值(您可以通过在每个时间步长跟踪角速度来检查这一点)。后者之所以发生,是因为施加的 1 单位扭矩会增加角速度,而角阻尼项会随着角速度的增加而增加。因此,在某些点上,阻尼项平衡了所施加的扭矩,并且实现了旋转平衡(以及线性平衡)。它相当于旋转运动的终端速度。你可以用下面的方法计算“终端角速度”。

从与扭矩和角加速度相关的角运动方程开始:

A978-1-4302-6338-8_13_Figjj_HTML.jpg

在本模拟中,因为存在 1 单位的驱动扭矩和与角速度成比例的减速扭矩,所以合成扭矩 T 由下式给出:

A978-1-4302-6338-8_13_Figkk_HTML.jpg

使用前面的运动方程,得到如下结果:

A978-1-4302-6338-8_13_Figll_HTML.jpg

平衡时,角加速度α为零。因此,这个等式的左边是零。重新排列会产生以下结果:

A978-1-4302-6338-8_13_Figmm_HTML.jpg

这就是“终极”角速度。在代码中使用 k = 0.5 的值给出了这个角速度的值 2。如果你在模拟中的每个时间步追踪物体的角速度,你会发现它确实收敛到这个值。

通过改变物体的质量和转动惯量进行实验。你会发现,它们分别影响达到线性和旋转平衡所需的时间,但不影响最终的线速度(0)或最终的角速度(2)。

我们现在可以模拟旋转动力学了!让我们建立一些更有趣的例子。

示例:简单的风力涡轮机模拟

在这个例子中,我们将模拟多个刚体的运动。我们要模拟的物体是风力涡轮机。所以我们首先创建一个turbine函数,它利用PolygonRB来绘制一个由六个顶点组成的填充多边形。其中三个顶点沿内圆圆周等距分布,另外三个位于外圆上。内圆和外圆上的顶点交替出现,这样产生的Polygon看起来像一个风力涡轮机。turbine函数有五个参数,分别对应于内外圆的半径(riro)、涡轮颜色(col)、质量(m)和转动惯量(im),如下所示:

function turbine(ri,ro,col,m,im){

var vertices = new Array();

for (var i=0; i<3; i++){

var vertex = getVertex(ro,i*120);

vertices.push(vertex);

vertex = getVertex(ri,i*120+60);

vertices.push(vertex);

}

return new PolygonRB(vertices,col,m,im);

}

函数getVertex()返回顶点位置,作为一个Vector2D对象,给出它与涡轮机中心的距离和它与水平面的角度:

function getVertex(r,a){

a *= Math.PI/180;

return new Vector2D(r*Math.cos(a),r*Math.sin(a));

}

在文件wind-turbines.js中,我们使用turbine功能创建了三个不同尺寸的涡轮机,如图 13-7 所示。

A978-1-4302-6338-8_13_Fig7_HTML.jpg

图 13-7。

Creating wind turbines!

这是在wind-turbines.js:init()方法中完成的

function init() {

turbines = new Array();

var turbine1 = turbine(4,50,'#000000',1,1);

turbine1.pos2D = new Vector2D(200,150);

turbines.push(turbine1);

var turbine2 = turbine(6,75,'#000000',2.25,5);

turbine2.pos2D = new Vector2D(150,400);

turbines.push(turbine2);

var turbine3 = turbine(12,150,'#000000',9,81);

turbine3.pos2D = new Vector2D(500,300);

turbines.push(turbine3);

addEventListener('mousedown',onDown,false);

t0 = new Date().getTime();

animFrame();

}

该规范为涡轮机分配了不同的质量和惯性矩。质量与涡轮机外半径的平方成比例分配(将其视为二维),而惯性矩与外半径的四次方成比例(因为 I 与 mr 2 成比例,m 与 r 2 成比例)。质量真的无关紧要,因为涡轮机只会旋转。将它们的值更改为其他值不会对旋转产生任何影响。但是我们为物理一致性设置了适当的值。

这里显示了部分动画代码:

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

for (var i=0; i<turbines.length; i++){

var windTurbine = turbines[i];

moveObject(windTurbine);

calcForce(windTurbine);

updateAccel(windTurbine);

updateVelo(windTurbine);

}

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

obj.rotation = obj.angVelo*dt;

obj.draw(context);

}

function calcForce(obj){

force = Forces.zeroForce();

torque = tq;

torque += -k*obj.angVelo; // angular damping

}

function updateAccel(obj){

acc = force.multiply(1/obj.mass);

alp = torque/obj.im;

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

obj.angVelo += alp*dt;

}

从前面的例子来看,这段代码应该很熟悉。在calcForce()方法中,我们指定了一个零合力和一个大小为tq的驱动扭矩,以及一个与角速度成比例的角阻尼项。扭矩大小tq的值由鼠标点击事件处理程序控制:如果按下鼠标,tq被赋予一个值tqMax(默认为 2);如果不是,它的值为 0。这意味着任何时候点击鼠标,一个恒定的驱动扭矩应用于每个涡轮;否则,驱动扭矩为零。

function onDown(evt){

tq = tqMax;

addEventListener('mouseup',onUp,false);

}

function onUp(evt){

tq = 0;

removeEventListener('mouseup',onUp,false);

}

运行模拟并单击画布上的任意位置。你看到了什么?最小的涡轮机几乎立即开始转动,而最大的涡轮机只是缓慢地开始转动。这说明了转动惯量的概念:对于相同的扭矩,转动惯量较大的刚体比转动惯量较小的刚体加速度小。当然,这只是 T = Iα的结果。同样,如果你停止按鼠标,最小的涡轮也会迅速停止;最大的一个要过很久才会停。

虽然这种模拟捕捉到了旋转涡轮机的基本动态,但还需要做更多的工作来更真实地模拟它在风力作用下的实际运行情况。例如,每个涡轮机的扭矩和阻尼系数将取决于其表面积等特性。风建模是另一个独立的主题。我们不会纠缠于这些复杂的因素;相反,我们将移动到另一个例子!

示例:滚下斜面

滚动涉及旋转动力学的一个有趣且特别有益的应用,但理解起来可能相当复杂。因此,我们将通过一个具体的例子来探讨这个问题。

在第七章中,我们模拟了一个球从斜面上滑下的运动。人们注意到,只有当斜面陡于某个取决于静摩擦系数的临界角时,球才会滑动。模拟效果很好,但有一个主要缺陷:如果角度不够高,球就不会移动。这几乎不现实;事实上,不管倾斜角度如何,球都会从斜面上滚下!

从粒子到刚体的模拟

我们现在可以将滚动融入到球沿斜面下滑的模拟中了。但是首先我们需要把球当作一个刚体,而不是一个质点。因此,让我们找出旧代码sliding.js,并修改它以使用我们的新刚体对象BallRB。文件的设置部分rolling.js,最初看起来是这样的:

var ball;

var r = 20; // radius of ball

var m = 1;  // mass of ball

var g = 10; // acceleration due to gravity

var ck = 0.2;  // coeff of kinetic friction

var cs = 0.25; // coeff of static friction

var vtol = 0.000001 // tolerance

// coordinates of end-points of inclined plane

var xtop = 50; var ytop = 150;

var xbot = 450; var ybot = 250;

var angle = Math.atan2(ybot-ytop,xbot-xtop); // angle of inclined plane

var acc, force;

var alp, torque;

var t0, dt;

var animId;

window.onload = init;

function init() {

// create a ball

ball = new BallRB(r,'#0000ff',m,0,true,true);

ball.im = 0.4*m*r*r; // for solid sphere

ball.pos2D = new Vector2D(50,130);

ball.velo2D = new Vector2D(0,0);

ball.draw(context);

// create an inclined plane

context_bg.strokeStyle = '#333333';

context_bg.beginPath();

context_bg.moveTo(xtop,ytop);

context_bg.lineTo(xbot,ybot);

context_bg.closePath();

context_bg.stroke();

// make the ball move

t0 = new Date().getTime();

t = 0;

animFrame();

}

这类似于第七章中的代码,它产生如图 13-8 所示的设置。此外,我们已经指定了球的质量和转动惯量。对于后者,我们使用的是实心球体的转动惯量公式(I = 2mr 2 /5),假设我们模拟的是实心球形球体。

A978-1-4302-6338-8_13_Fig8_HTML.jpg

图 13-8。

A ball rolling down an inclined plane

代码的动画部分是标准的,除了calcForce()方法,它最初看起来是这样的(我们很快会修改它):

function calcForce(){

var gravity = Forces.constantGravity(m,g);

var normal = Vector2D.vector2D(m*g*Math.cos(angle),0.5*Math.PI-angle,false);

var coeff;

if (ball.velo2D.length() < vtol){  // static friction

coeff = Math.min(cs*normal.length(),m*g*Math.sin(angle));

}else{  // kinetic friction

coeff = ck*normal.length();

}

var friction = normal.perp(coeff);

force = Forces.add([gravity, normal, friction]);

}

如果您将这段代码与第七章的中的sliding.js示例的相应calcForce()方法进行比较,您会发现它做了同样的事情,具有相同的力和参数值。到目前为止,我们所做的就是用BallRB代替Ball,这样我们就可以引入刚体动力学。如果你以当前形式运行代码,球不会移动,就像第七章中的一样,除非你使倾斜度更陡(例如,通过将设置文件中的ybot的值改为 260 或更大)。我们知道,在现实生活中,即使球不能滑动,它也会滚下斜坡。那么少了什么呢?

模拟无滑动滚动

你可能想给calcForce()增加一个扭矩,看看是否能让球滚动。从最后一行代码中可以看出,模拟中包含了三种力:重力、法向力和摩擦力(参见图 13-9 中的力图)。因为重力和法向力都通过质心作用,所以它们没有任何关于质心的相关力矩。只有摩擦力有一个绕球质心的力矩,这个力矩的大小是 fr,其中 f 是摩擦力的大小,r 是球的半径。

A978-1-4302-6338-8_13_Fig9_HTML.jpg

图 13-9。

Force diagram for a ball rolling down an inclined plane

calcForce()的最后一行后添加以下代码行:

torque = r*friction.length();

现在运行代码。你发现了什么?肯定球在摩擦力产生的力矩作用下旋转越来越快,但是它哪儿也不去!我们仍然遗漏了一些东西。保留扭矩代码,因为它是正确的。但是我们需要做更多的思考。

让我们再来看看图 13-9 。除了考虑摩擦产生的扭矩,还需要改变什么?唯一的另一件事是,三个力是否仍然与滚动相同。现在重力是一个只取决于物体质量的固定力,所以会保持完全不变,量级 mg,垂直向下作用;法向力也会如此,因为它仍然必须与垂直于平面的重力分量大小相等,方向相反(在那个方向上没有运动)。因此,法向力的大小仍为 mg cos(θ),并垂直于平面作用,如规范中所述。

摩擦力需要改变。滚动摩擦通常小于静摩擦或动摩擦(这就是为什么轮子是如此有用的发明!).目前摩擦力普遍过大。因此,问题实际上可以归结为指定正确的摩擦力,以产生无滑动的滚动。

关键是,无滑动滚动的条件限制了线性和旋转运动“一起工作”,这反过来又限制了摩擦力的大小。通过从基本原理(即从基本物理定律开始)制定约束,并使用线性和旋转运动方程计算出其含义,就有可能推导出摩擦力必须是多少。让我们现在做那件事。

为了更容易形象化(但不失一般性),想象一个半径为 r 的球或轮子以角速度ω在平地上滚动,如图 13-10 所示。

A978-1-4302-6338-8_13_Fig10_HTML.jpg

图 13-10。

A ball or wheel rolling without slipping

如果球或轮子无滑动地滚动(让我们称之为纯滚动),当它完成一次旋转时,它向前移动了等于 2πr 的距离。它这样做的时间等于它的周期 T,它等于 2π/ω(见第九章)。其质心的速度由移动距离与所用时间的比值给出,因此由下式给出:

A978-1-4302-6338-8_13_Fignn_HTML.jpg

回想一下,这只是圆周上任意一点的线速度的公式;在纯滚动中,它也给出了整个物体向前运动的速度。这个公式在任何时候都成立,即使角速度(以及质心的线性速度)在变化。然后对时间求导,得到质量加速度的线性中心与角加速度的等效公式:

A978-1-4302-6338-8_13_Figoo_HTML.jpg

这是帮助我们计算纯滚动摩擦力(用 f 表示)的关键约束。力发挥作用的方式当然是通过运动方程。沿着斜坡应用线性运动方程 F = ma 给出(见图 13-9 ):

A978-1-4302-6338-8_13_Figpp_HTML.jpg

应用运动的角度方程 T = Iα给出如下:

A978-1-4302-6338-8_13_Figqq_HTML.jpg

我们需要求解摩擦力 f,但是我们不知道加速度 a 或者角加速度α。但是我们有约束条件 a = rα,所以我们可以用它和上一个等式来消去α,得到加速度 a 和摩擦力 f 之间的关系:

A978-1-4302-6338-8_13_Figrr_HTML.jpg

现在,我们可以将其代入由线性运动方程得出的前一个方程,重新排列后,得到以下最终结果:

A978-1-4302-6338-8_13_Figss_HTML.jpg

然后,我们还可以使用前面连接 a 和 f 的等式,推导出滚动物体加速度的以下公式:

A978-1-4302-6338-8_13_Figtt_HTML.jpg

将此与自由下落物体的加速度 g 进行比较,我们发现滚动物体的加速度降低了一个系数,该系数取决于物体的 I/mr 2 比,以及平面的倾角(后者只是因为我们看到的是沿斜坡向下的加速度分量,而不是垂直向下的)。

既然我们已经有了纯滚动摩擦力的公式,把它包含在代码中就很简单了。删除calcForce()中计算摩擦力coeff大小的if代码块,用下面一行代替,这就是 f 的公式:

coeff = m*g*Math.sin(angle)/(1+m*r*r/ball.im);

现在运行代码,你就可以让球沿着斜坡滚下去而不会打滑了!

允许运输和滚动

现在,通过将ybot的值更改为 850 来增加斜率,并重新运行模拟。现在坡度很陡,但是球还是会滚下来,不会打滑。现在我们有相反的问题:球不知道如何滑动!

为了使模拟更加真实,我们需要以一种简单的方式实现滚动和滑动的可能性,只需在计算coeff的行之后添加下面的if块:

if (coeff > cs*normal.length()){

coeff = ck*normal.length();

}

这就限制了滚动摩擦的最大值;如果计算出的摩擦力超过静摩擦力,它会将摩擦力的大小重置为动摩擦力。运行代码,你会看到球在滚下的时候滑动了。如果你把ybot变回 250,你会发现球像以前一样进行纯滚动。事实上,您可以在if块中添加一行console.log("slipping"),并针对不同的ybot值运行代码。这将明确地告诉你球何时滑落。根据给定的参数值,您应该会发现纯滚动发生在ybot = 500 的值,对应于大约 41.2°的倾斜角。

更多的模拟实验

一个经典的大学物理问题是这样的:假设你在一个斜坡上,在相同的高度释放两个实心圆柱体,一个轻,一个重。假设两人都不打滑的滚下斜坡,哪一个会先到达底部?好吧,你可以用你的模拟来找出答案!

首先把rolling.js中的ybot的值改回 250,这样你就有了纯滚动。然后将惯性矩公式改为如下:

ball.im = 0.5*m*r*r;

这与前面的球体公式的不同之处仅在于系数为 0.5;圆柱体的转动惯量是 I = mr 2 /2。

使用圆柱体的不同质量和半径值进行模拟实验,并记下圆柱体到达底部所需的时间。你注意到了什么?

好吧,尽管看起来有些违反直觉,你应该会发现圆柱体到达斜坡底部的时间总是一样的,不管它的质量和半径如何。要了解为什么会这样,请参考上一小节中推导出的加速度公式。因为圆柱体的惯性矩由 I = mr 2 /2 给出,所以该公式中出现的比率 I/mr 2 的值为 0.5,给出实心圆柱体沿斜坡向下的加速度为

A978-1-4302-6338-8_13_Figuu_HTML.jpg

这个有趣的结果告诉我们,加速度完全独立于圆柱体的性质,如质量、半径或惯性矩:它们都被抵消了!所以,只要两个圆柱体都是实心的,它们应该总是以相同的速度滚下斜坡!

然而,中空圆柱形管具有由近似 I = mr 2 给出的惯性矩。通过同样的计算,得出以下加速度:

A978-1-4302-6338-8_13_Figvv_HTML.jpg

因此,一个中空的圆柱形管道会加速得更慢,因此需要更长的时间才能到达斜坡的底部。

刚体碰撞和反弹

在第十一章中,我们花了相当多的时间讨论粒子碰撞和反弹。你可能会觉得当时的数学有点复杂。好吧,对于刚体,数学变得更加复杂!对于刚体,你必须考虑碰撞中物体的线性运动和旋转。粒子、球体和圆形物体不会因为碰撞而旋转(至少如果我们假设碰撞是无摩擦的)。这是因为对于这样的物体,碰撞线总是通过它们的质心(见图 13-11 ),因此碰撞产生的冲击力不会产生扭矩。

A978-1-4302-6338-8_13_Fig11_HTML.jpg

图 13-11。

Colliding spheres

对于一个形状更复杂的物体来说,这通常是不成立的。与球体一样,碰撞线垂直于物体表面,但它可能不会通过物体的质心(见图 13-12 )。因此,在这种情况下,会产生围绕每个碰撞物体中心的扭矩,导致它们旋转。

A978-1-4302-6338-8_13_Fig12_HTML.jpg

图 13-12。

Collision between two rigid bodies of arbitrary shape

因此,在这种更一般的情况下,碰撞解决的任务是计算两个物体的最终线速度 v 1 f 和 v 2 f 和角速度ω 1 f 和ω 2 f , 给定它们的初始线速度 v 1 i 和 v 2 i 和角速度ω 1 i 和ω 2 i (物体在碰撞之前可能已经在旋转)。 这就是我们在这一节要做的事情。

碰撞产生的线性冲量

线速度 v 1 i ,v 2 i ,v 1 f ,v 2 f 是两个碰撞刚体质心的初始和最终线速度(分别用下标 1 和 2 表示)。角速度ω 1

起点是应用冲量-动量定理:

A978-1-4302-6338-8_13_Figww_HTML.jpg

让我们用符号 j 来表示冲量 fδt。然后,将冲量-动量定理应用于每个刚体,并记住它们经历大小相等方向相反的冲量,我们可以写出以下等式:

A978-1-4302-6338-8_13_Figxx_HTML.jpg

A978-1-4302-6338-8_13_Figyy_HTML.jpg

我们顺便注意到,这两个方程合在一起意味着下列方程:

A978-1-4302-6338-8_13_Figzz_HTML.jpg

正如我们在第十一章中看到的,这就是动量守恒。我们将该等式与恢复系数 C R 的等式一起使用,以根据初始速度(由 u 1 和 u 2 表示)获得最终速度 v1f 和 v2ff(由 v 1 和 v 2 表示)。参考第十一章中的“计算非弹性碰撞后的速度”一节。这里的情况更复杂,因为我们还要考虑角速度,我们需要通过一条稍微不同的路线前进。

我们将采用的方法是,用冲量 J 来表示最终速度,然后算出 J 是多少。对于线速度而言,这只是重新排列前面涉及 J 的等式,以获得以下等式:

A978-1-4302-6338-8_13_Figaaa_HTML.jpg

A978-1-4302-6338-8_13_Figbbb_HTML.jpg

和粒子碰撞一样,我们可以用一个包含恢复系数的方程来补充这些方程。在粒子的情况下,恢复系数等于碰撞前后碰撞粒子的相对法向速度的负比率。在这里,人们可能会尝试用每个粒子质心的线速度来做同样的假设。但事实上,在这种情况下,相关的速度是每个物体上垂直于接触面的接触点的线速度。再次参考图 13-12 ,这些是两个刚体接触点 P 处的速度。让我们用 v p1 和 v p2 来表示这些速度。它们都是由质心速度和点 P 处速度的矢量和给出的,因为绕质心旋转。

因此,我们有以下等式:

A978-1-4302-6338-8_13_Figccc_HTML.jpg

A978-1-4302-6338-8_13_Figddd_HTML.jpg

这些方程在任何时候都是有效的,特别是在碰撞之前和之后。因此,它们可以用我们以前用过的上标“I”和“f”来写。向量 r p1 和 r p2 ,它们是点 P 相对于每个物体质心的位置向量,在那个短暂的间隔内是相同的,所以它们不需要这些上标。

碰撞前后的相对速度由下式给出:

A978-1-4302-6338-8_13_Figeee_HTML.jpg

A978-1-4302-6338-8_13_Figfff_HTML.jpg

恢复系数由这些相对速度的法向分量的负比率给出(其中 n 是单位法向量):

A978-1-4302-6338-8_13_Figggg_HTML.jpg

问题是,这也涉及到最终的角速度,我们不知道。所以我们需要同时求解角速度。这意味着我们需要更多的方程。额外的方程式来自应用角冲量定理。

碰撞产生的角冲量

现在让我们应用角冲量定理:

A978-1-4302-6338-8_13_Fighhh_HTML.jpg

因为 T = r × F,L = Iω,所以等价于:

A978-1-4302-6338-8_13_Figiii_HTML.jpg

将此应用于每个物体,并再次记住它们经历相等且相反的脉冲,我们得到以下等式:

A978-1-4302-6338-8_13_Figjjj_HTML.jpg

A978-1-4302-6338-8_13_Figkkk_HTML.jpg

这些方程可以重新排列,根据初始角速度和冲量给出最终角速度:

A978-1-4302-6338-8_13_Figlll_HTML.jpg

A978-1-4302-6338-8_13_Figmmm_HTML.jpg

我们现在唯一需要的是冲量 J,然后我们就可以得到线速度和角速度。

最后的结果

有可能将上述所有方程一起求解,以获得冲量 j 的表达式。我们将省去详细的代数运算,只提供最终答案。写出 J = J n,其中 J 是冲量的大小,n 是单位法向量,如前所述(因为冲量是沿着法线方向的),我们得到冲量大小的如下表达式:

A978-1-4302-6338-8_13_Fignnn_HTML.jpg

在前面的等式中,每个叉积项(矢量)的平方指的是取自身的点积。

在第二刚体是不可移动物体的情况下,比如一面墙,我们可以使前面方程中的 m 2 和 I 2 无穷大(去掉了涉及它们的项)。注意,那种情况下的相对速度 vrI 就是物体 1 上点 P 的速度 vIP1。然后我们得出以下结论:

A978-1-4302-6338-8_13_Figooo_HTML.jpg

为了便于参考,让我们重复一下等式,以便计算最终的线速度和角速度:

A978-1-4302-6338-8_13_Figppp_HTML.jpg

A978-1-4302-6338-8_13_Figqqq_HTML.jpg

A978-1-4302-6338-8_13_Figrrr_HTML.jpg

A978-1-4302-6338-8_13_Figsss_HTML.jpg

在第二个物体是不可移动的墙壁的情况下,v2T2f 和ω2f 为零。

完成了看起来复杂的数学运算后,你会很高兴地知道前面的公式实际上并不难编码。在编码方面,模拟刚体碰撞的更棘手的方面可能是碰撞检测和重新定位,它们同样重要,在创建真实模拟时需要仔细考虑。

注意,这些方程对任何形状的刚体之间的碰撞都有效。在我们不久将放在一起的例子中,我们将考虑多边形。在这种情况下,最常见的碰撞事件是一个多边形的顶点与另一个多边形的边发生碰撞。然后,碰撞线是相关边的法线。但是两个多边形的顶点之间的冲突也很常见,因此有一种方法来处理它们是很重要的。其他不太常见的碰撞场景包括一个对象的两个顶点同时撞击两个不同的对象。

为了避免与这些不同碰撞场景以及处理它们所需的碰撞检测和处理方法相关联的复杂性,我们将从一个简单的单个多边形从地板上反弹的例子开始。这将使我们能够专注于实现前面几节中开发的冲突解决方法。之后,我们将构建一个更复杂的模拟,涉及多个碰撞和反弹的对象,这将需要我们面对这里提到的一些更棘手的问题。

示例:模拟单个弹跳块

我们现在将构建一个简单的矩形块从地板上反弹的模拟,这样我们就有了一个可移动的对象。该模拟的设置代码rigid-body-bouncing.js如下所示:

var block;

var wall;

var m = 1;

var im = 5000;

var g = 20;

var cr = 0.4;

var k = 1;

var acc, force;

var alp, torque;

var t0, dt;

var animId;

window.onload = init;

function init() {

// create a block

block = makeBlock(100,50,'#0000ff',m,im);

block.rotation = Math.PI/4;

block.pos2D = new Vector2D(400,50);

block.draw(context);

// create a wall

wall = new Wall(new Vector2D(100,400),new Vector2D(700,400));

wall.draw(context_bg);

// make the block move

t0 = new Date().getTime();

animFrame();

}

function makeBlock(w,h,col,m,im){

var vertices = new Array();

var vertex = new Vector2D(-w/2,-h/2);

vertices.push(vertex);

vertex = new Vector2D(w/2,-h/2);

vertices.push(vertex);

vertex = new Vector2D(w/2,h/2);

vertices.push(vertex);

vertex = new Vector2D(-w/2,h/2);

vertices.push(vertex);

return new PolygonRB(vertices,col,m,im);

}

我们使用第十一章中的Wall类创建一个矩形块作为PolygonRB实例和地板对象。该块的惯性矩为 5000,初始方向为π/4 弧度(45°)。

代码的动画部分相当标准,有一个看起来不起眼的calcForce()方法:

function calcForce(obj){

force = Forces.constantGravity(m,g);

torque = 0; // no external torque since gravity is the only force

torque += -k*obj.angVelo; // damping

}

calcForce()方法规定重力是作用在木块上的唯一力,并规定外部扭矩为零,因为重力不会产生围绕其质心的扭矩。不过,我们确实包括一个阻尼扭矩,其大小由阻尼参数k控制(如果您愿意,可以将其设置为零)。

代码中真正新的物理特性包含在checkBounce()方法中,该方法在每个时间步长从move()方法调用,如下所示:

function checkBounce(obj){

// collision detection

var testCollision = false;

var j;

for (var i=0; i<obj.vertices.length;i++){

if (obj.pos2D.add(obj.vertices[i].rotate(obj.rotation)).y >= wall.p1.y){

if (testCollision==false){

testCollision = true;

j = i;

}else{ // that means one vertex is already touching

stop(); // block is lying flat on floor, so stop simulation

}

}

}

// collision resolution

if (testCollision == true){

obj.y += obj.pos2D.add(obj.vertices[j].rotate(obj.rotation)).y*(-1) + wall.p1.y;

var normal = wall.normal;

var rp1 = obj.vertices[j].rotate(obj.rotation);

var vp1 = obj.velo2D.add(rp1.perp(-obj.angVelo*rp1.length()));

var rp1Xnormal = rp1.crossProduct(normal);

var impulse = -(1+cr)*vp1.dotProduct(normal)/(1/obj.mass + rp1Xnormal*rp1Xnormal/obj.im);

obj.velo2D = obj.velo2D.add(normal.multiply(impulse/obj.mass));

obj.angVelo += rp1.crossProduct(normal)*impulse/obj.im;

testCollision = false;

}

}

正如您所看到的,考虑到前面理论讨论的复杂性,代码看起来出奇的短。代码分为两部分,一部分指定冲突检测,另一部分指定冲突解决。碰撞检测代码在块的顶点上循环,并使用以下条件测试是否有任何顶点低于地板:

if (obj.pos2D.add(obj.vertices[i].rotate(obj.rotation)).y >= wall.p1.y){}

这一行可能看起来有点复杂,所以让我们分解一下。首先简单的一点:wall.p1.y给出墙的y位置。接下来,obj.vertices[i]是当前正在测试的顶点相对于该块质心的位置向量。你已经在本章前面遇到了Vector2D对象的rotate()方法。

因此,我们将当前顶点的位置向量旋转一个角度,该角度等于块的角位移obj.rotation,以说明块的方向。然后我们把它加到物体的(重心)位置上。我们这样做是因为我们需要顶点在画布坐标系中的位置向量,而不是在质心坐标系中。最后,我们测试这个向量的 y 分量是否大于墙的 y 位置。如果是,则检测到碰撞,并且将Boolean参数testCollision设置为true,并且将顶点的索引存储在变量j中。但这仅在testCollision当前为false的情况下完成(在同一时间步中尚未检测到另一个冲突)。如果testCollision已经是true,这意味着另一个顶点已经与地板发生碰撞。从物理上来说,这意味着积木现在平放在地板上。那么合理的做法是停止模拟。我们使用stop()方法来停止动画循环:

function stop(){

cancelAnimationFrame(animId);

}

如果检测到碰撞(如果testCollision为真),则执行碰撞解决代码。第一行通过简单地将块向上移动它落到墙下的量来重新定位块。接下来的几行代码简单地实现了上一节末尾给出的等式,应该很容易理解。惟一的微妙之处是使用了crossProduct()方法,这是Vector2D类的另一个新创建的方法,定义如下:

function crossProduct(vec) {

return this.x*vec.y - this.y*vec.x;

}

如果你记得两个向量的叉积只存在于 3D 中,并且本身就是一个向量,这可能会让你感到困惑。因为我们的模拟是在 2D 进行的,我们已经定义了叉积的模拟,但只能给出它的大小,因为两个向量的叉积应该与两个向量都垂直,因此需要第三维度存在。底线是,这个技巧使我们能够使用上一节中涉及矢量积的公式,只要我们记住,它只会给我们叉积的大小(事实上,这就是我们在这些公式中所需要的)。

这个模拟就不多说了。运行它并享受吧!截图见图 13-13 。像往常一样,尝试改变转动惯量、恢复系数和角度阻尼系数等参数的影响。作为附加练习,尝试用另一个多边形(如三角形或五边形)替换矩形。

A978-1-4302-6338-8_13_Fig13_HTML.jpg

图 13-13。

A falling block bouncing off a floor

示例:碰撞块

如前所述,当多个块碰撞在一起时,需要注意检测对象之间的碰撞,然后将它们分开。因此,让我们花一些时间来讨论如何在一个具体的例子中应用它们——一组具有不同大小和方向的多边形从高处落下,以便它们落到地板上并从地板上弹回,同时彼此碰撞。

图 13-14 说明了一个块(对象 1)的顶点 P 与另一个块(对象 2)的边发生碰撞时最常见的情况。在碰撞检测的时候,P 在第二个多边形里面。如果考虑第二个多边形的顶点相对于 P 的位置向量,它们应该都具有正的点积,其法线从 P 到与它们相邻的边(按照惯例,我们将考虑从相关顶点逆时针方向的边)。所以如果你测试所有这些点积,任何一个都是负的,你就知道没有碰撞发生。你也可以先做一个较弱的测试,看看两个物体质心之间的距离是否大于它们最远顶点的距离。如果是这样,您就不必费心去做更详细的碰撞检测测试了。

A978-1-4302-6338-8_13_Fig14_HTML.jpg

图 13-14。

Vertex-edge collision between two polygons

要在顶点-边碰撞后重新定位对象,只需沿发生碰撞的边的法线将其向后移动一段距离,该距离等于从点 P 到相关边的垂直距离。通过检查哪一个相对于物体 1 的质心的位置矢量 P 的角度最小来确定正确的法线(参见图 13-14 )。

另一种情况是一个对象的顶点与另一个对象的顶点发生碰撞。你可能会认为这是一个罕见的事件,但它往往比你想象的更经常发生。例如,一个多边形的顶点可以沿着另一个多边形的边滑动,直到碰到顶点为止。在顶点-顶点碰撞的情况下,一个简单的碰撞检测方法是检查两个顶点之间的距离;如果它小于一定的量(比如说 1 个像素),那就算作碰撞。

我们在示例模拟中应用了这些方法。模拟的代码在文件rigid-body-collisions.js中。此代码生成许多不同大小和方向的多边形,并根据它们的质量和尺寸为它们分配与其面积成比例的质量和惯性矩。还生成一个Wall对象来表示楼层。我们不会在这里列出所有的代码,而只是展示修改后的move()方法,这样你就可以了解主要的逻辑:

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

for (var i=0; i<blocks.length; i++){

var block = blocks[i];

checkWallBounce(block);

for(var j=0;j<blocks.length;j++){

if(j!==i){

checkObjectCollision(block,blocks[j]);

}

}

moveObject(block);

checkWallBounce(block);

calcForce(block);

updateAccel(block);

updateVelo(block);

}

}

和前面的模拟一样,每个物体上只有重力作为外力,没有外力矩,这样calcForce()和前面一样。在每个时间步,checkWallBounce()方法检查当前对象是否与墙壁碰撞,并在必要时处理碰撞。这种方法本质上非常类似于我们在前面的例子中看到的用于检测和解决墙壁碰撞的代码。然后checkObjectCollision()方法查看当前对象是否与任何其他对象发生碰撞,如果发生碰撞,则解决碰撞。碰撞解决代码与壁碰撞的代码是相同的,但是当然使用两体碰撞的相应公式。checkObjectCollision()中的其余代码检测两个对象之间的任何顶点边或顶点-顶点碰撞,并根据前面描述的方法重新定位它们。具体实现见rigid-body-collisions.js文件中的源代码。

如果您运行代码,您会发现模拟通常运行良好,正确处理了它旨在检测的碰撞场景。但是因为我们保持了简单的模拟,你可能会不时地注意到一些问题,尤其是当物体变得拥挤并且涉及多个碰撞时。如果涉及更高的速度,问题会变得更糟;例如,如果g增加。您可以通过设计方法来处理更多可能的碰撞场景来改进模拟。你也可以引入摩擦力,让它看起来更真实,当砖块由于缺乏摩擦力而在地板上滑动时。图 13-15 显示了模拟的截图。

A978-1-4302-6338-8_13_Fig15_HTML.jpg

图 13-15。

The multiple colliding blocks simulation

可变形物体

你现在知道如何模拟刚体运动,但是你如何模拟可变形物体的运动,例如绳子和衣服,它们的形状可以改变?有各种方法可以做到这一点,但是我们将介绍一种基于弹簧物理学的特殊方法,我们在第八章中已经介绍过了。让我们首先简要回顾一下弹簧物理学的主要原理和公式,并讨论如何将这些原理应用于可变形物体的建模。

质量弹簧系统

基本的建模方法是将可变形的扩展对象表示为一系列通过虚拟弹簧连接在一起的具有质量的粒子。因此,我们通常把这种模型称为质量弹簧系统。作为该方法的说明,考虑图 13-16 中所示的简单一维粒子和弹簧链。

A978-1-4302-6338-8_13_Fig16_HTML.jpg

图 13-16。

A 1D chain of particles connected by springs

每个粒子都受到一个弹力,这个弹力取决于它与相邻粒子的距离。我们可以在粒子之间施加一定的平衡距离 L。如果两个粒子之间的距离小于距离 L,它们会相互排斥;如果它们之间的距离比 L 大,它们就会相互吸引。该长度可以被认为是连接两个粒子的虚拟弹簧的自然未拉伸长度。

如第八章所述,任意两个粒子之间的弹簧力由下式给出,其中 k 为弹簧常数,x 为延伸量:

A978-1-4302-6338-8_13_Figttt_HTML.jpg

延伸 x 的大小是弹簧被拉伸超过其未拉伸长度 L 的量。在两个相邻粒子由弹簧连接的情况下,它因此等于粒子之间的距离 d 减去 L:

A978-1-4302-6338-8_13_Figuuu_HTML.jpg

在向量形式中,它是这样的:

A978-1-4302-6338-8_13_Figvvv_HTML.jpg

这里 d 是两个粒子之间的距离矢量。例如,前面的 1D 链中的第 i 粒子受到由以下等式给出的第(i+1) 粒子产生的力,其中 r 是各个粒子的位置向量:

A978-1-4302-6338-8_13_Figwww_HTML.jpg

类似地,第 i 粒子受到第(i-1) 粒子的力,由下式给出:

A978-1-4302-6338-8_13_Figxxx_HTML.jpg

这个 1D 模型可以扩展到 2D 或 3D,根据前面的公式,由于更多的邻居,每个粒子都可以受到弹簧力。

你也想在你的质量弹簧系统中有阻尼;否则,粒子将永远振荡。阻尼项通常与相对速度成线性关系,系数为常数 c(可以选择它来最小化达到平衡所需的时间),正如我们在第八章中讨论的:

A978-1-4302-6338-8_13_Figyyy_HTML.jpg

在我们连接的质量-弹簧系统的特定上下文中,速度 v r 是相关粒子相对于对其施加力的粒子的速度。因此,第 I 个粒子相对于第(i+1) 粒子的阻尼力由下式给出:

A978-1-4302-6338-8_13_Figzzz_HTML.jpg

类似地,第 i 粒子相对于第(i+1) 粒子的阻尼力由下式给出:

A978-1-4302-6338-8_13_Figaaaa_HTML.jpg

为了使质量弹簧系统正常工作,经常需要调整质量、刚度和阻尼系数。一般来说,你会希望刚度 k 高,以减少你的对象的拉伸。问题是,由此产生的弹簧系统变得更加不稳定,看到模拟在你眼前爆炸是很正常的!让我们举个例子,你很快就会明白我们的意思了!

绳索模拟

这个例子建立在第八章的最后一个例子的基础上,在那里我们模拟了一个由弹簧连接在一起的粒子链(参见“耦合振荡”代码)。我们现在将对这个模拟做一些小而重要的改变,使它的行为更像一根绳子。修改后的代码在文件rope.j s 中,在此完整复制,最重要的更改以粗体突出显示:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var balls;

var support;

var center = new Vector2D(100,100);

var g = 10;

var kDamping = 20;

var kSpring = 500;

var springLength = 20;

var spacing = 20;

var numBalls = 15;

var drop = false;

var t0, dt;

var acc, force;

var animId;

window.onload = init;

function init() {

// create a support

support = new Ball(2,'#000000');

support.pos2D = center;

support.draw(context);

// create a bunch of balls

balls = new Array();

for (var i=0; i<numBalls; i++){

var ball = new Ball(2,'#000000',10,0,true);

ball.pos2D = new Vector2D(support.x+spacing*(i+1),support.y);

ball.draw(context);

balls.push(ball);

}

addEventListener('mousedown',onDown,false);

t0 = new Date().getTime();

animFrame();

}

function onDown(evt){

drop = true;

addEventListener('mouseup',onUp,false);

}

function onUp(evt){

drop = false;

removeEventListener('mouseup',onUp,false);

}

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

context.clearRect(0, 0, canvas.width, canvas.height);

drawSpring();

for (var i=0; i<numBalls; i++){

var ball = balls[i];

moveObject(ball);

calcForce(ball,i);

updateAccel(ball.mass);

updateVelo(ball);

}

}

function drawSpring(){

support.draw(context);

context.save();

context.lineStyle = '#009999';

context.lineWidth = 2;

context.moveTo(center.x,center.y);

for (var i=0; i<numBalls; i++){

var X = balls[i].x;

var Y = balls[i].y;

context.lineTo(X,Y);

}

context.stroke();

context.restore();

}

function moveObject(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

obj.draw(context);

}

function calcForce(obj,num){

var centerPrev;

var centerNext;

var veloPrev;

var veloNext;

if (num > 0){

centerPrev = balls[num-1].pos2D;

veloPrev = balls[num-1].velo2D;

}else{

centerPrev = center;

veloPrev = new Vector2D(0,0);

}

if (num < balls.length-1){

centerNext = balls[num+1].pos2D;

veloNext = balls[num+1].velo2D;

}else{

centerNext = obj.pos2D;

veloNext = obj.velo2D;

}

var gravity = Forces.constantGravity(obj.mass,g);

var velo = obj.velo2D.multiply(2).subtract(veloPrev).subtract(veloNext);

var damping = Forces.damping(kDamping,velo);

var displPrev = obj.pos2D.subtract(centerPrev);

var displNext = obj.pos2D.subtract(centerNext);

var extensionPrev = displPrev.subtract(displPrev.unit().multiply(springLength));

var extensionNext = displNext.subtract(displNext.unit().multiply(springLength));

var restoringPrev = Forces.spring(kSpring,extensionPrev);

var restoringNext = Forces.spring(kSpring,extensionNext);

force = Forces.add([gravity, damping, restoringPrev, restoringNext]);

if (num==balls.length-1``&&

force = new Vector2D(0,0);

obj.velo2D = new Vector2D(0,0);

}

}

function updateAccel(mass){

acc = force.multiply(1/mass);

}

function updateVelo(obj){

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

大部分代码与coupled-oscillations.js中的代码没有变化,因此,如果它不是完全显而易见,并且您需要复习它是如何工作的,我们建议您参考第八章中的相关讨论。相反,我们将关注关键的变化。

首先,注意我们在变量veloPrevveloNext中存储了当前粒子之前和之后的粒子速度。然后,它们与当前粒子的速度相结合,给出参数velo,该参数用于使用Forces.damping()函数计算阻尼力。你可能想知道为什么我们这样组合速度。参考上一小节给出的阻尼力公式,我们将当前粒子相对于两个相邻粒子的阻尼力相加,得出:

A978-1-4302-6338-8_13_Figbbbb_HTML.jpg

变量velo就是由该总和产生的组合有效速度。注意,在coupled-oscillations.js中,我们使用当前粒子的绝对速度(不是相对于其邻居的速度)来计算它的阻尼力。如果您在这里做同样的事情,您可能希望通过比较模拟的行为来进行实验。

另一个变化是,我们通过将链中最后一个粒子上的力和速度设置为零来固定它,除非Boolean参数drop为真:

if (num==balls.length-1 && drop==false){

force = new Vector2D(0,0);

obj.velo2D = new Vector2D(0,0);

}

参数drop,最初设置为假,通过分别响应mousedownmouseup事件的事件处理程序onDown()onUp()由用户交互控制。只要用户按住鼠标,drop为真,最后一个粒子可以像其余粒子一样在力的作用下运动(第一个除外)。如果放开鼠标,最后一个粒子会停在它所在的地方。源文件rope2.js中有一个稍微修改的模拟版本,以不同的方式响应鼠标点击:当鼠标被按住时,一个力被施加到鼠标上,其大小与鼠标到最后一个粒子的距离成比例;释放鼠标时,力消失。尽管我们在下面的讨论中引用了模拟的第一个版本,但是它的大部分同样适用于修改后的版本。

注意我们在rope.js中指定的参数值:重力是 10 个单位,弹簧长度是 20 个像素,粒子的质量是 10 个单位。最重要的是,注意用于弹簧阻尼系数(20)和弹簧常数(500)的高值,与它们在第八章的耦合振荡模拟中的值相比,这些参数的值分别为 0.5 和 10。当您试验此模拟时,请尝试这些参数的不同值,以查看它们如何改变其行为。

使用这些参数的默认值运行模拟。注意粒子是如何在重力的作用下从它们的初始位置落下,但是被弹簧力保持在一起的。在一些短暂的摆动和轻微的拉伸后,它们稳定下来,形成一个弯曲的形状,像一条绳子一样垂下来(见图 13-17 )。这是一条被称为悬链线的典型数学曲线:它是两端固定的链条在其自身重量的影响下自然呈现的形状。当它稳定下来后,点击并按住鼠标,让绳子的末端落下来:看看“绳子”是如何自我调整的。现在松开鼠标,注意绳子又变成了一条特征曲线:这是悬链线的不同部分。

A978-1-4302-6338-8_13_Fig17_HTML.jpg

图 13-17。

A rope simulation

通过改变参数值进行实验。首先,将弹簧常数kSpring的值增加到 1000。看看绳子现在怎么变得不那么紧了。你可能会尝试一个更大的值,看看你是否能得到一个更紧的绳子。继续将kSpring增加到 10000(别说我们没有警告你),然后重新运行模拟。哎呦!你的绳子爆炸了!欢迎来到数值不稳定性。不幸的是,这是质量弹簧系统的一个常见问题:它们容易爆炸,尤其是对于高值的弹簧常数。这是因为积分方案的一个潜在问题,称为数值不稳定性,我们将在下一章详细讨论。

你可以类似地使用阻尼系数kDamping,注意它的值会影响振动停止的速度。这里的问题也会变得棘手。例如,保持kSpring的初始值为 500,并将kDamping的值减少到 1。模拟最初似乎表现良好,显然像以前一样稳定下来。但是再等一会儿,你很快就会看到微小的波动在增长,把绳子扭曲成你再也不能称之为绳子的东西。

总结:质量弹簧系统很容易创建,观看起来也很有趣,但在某些参数范围内,它们可能非常不稳定。可以采取一些措施来提高它们的稳定性,例如使用改进的集成方案(见下一章),但应小心使用。

布料仿真

将我们的绳子模拟扩展到 2D 来创建一个简单的布料模拟是相当简单的。当然,要创建一个可以弯曲的布料的真实模拟,我们真的需要一个 3D 模拟,即使布料是 2D 的,也要允许布料在三维空间中运动和弯曲。但是我们可以很快做出一个 2D 版本,它将至少再现移动的布的行为的一些元素。所以让我们对绳子模拟做一些快速的修改,并创建一个 2D 版本。新文件名为cloth.js,其设置部分如下:

var balls;

var rows = 10;

var cols = 15;

var refPoint = new Vector2D(120,50);

var fixedPoints = new Array(0,70,140);

var g = 10;

var kDamping = 10;

var kSpring = 100;

var kWind = 10;

var windMag = 10;

var springLength = 20;

var spacing = 20;

var w = 0;

var t0, dt;

var acc, force;

var animId;

window.onload = init;

function init() {

// create the masses

balls = new Array();

for (var i=0; i<cols*rows; i++){

var ball = new Ball(2,'#000000',10,0,true);

var ncol = Math.floor(i/rows);

ball.pos2D = new Vector2D(refPoint.x+ncol*spacing,refPoint.y+(i-ncol*rows-1)*spacing);

ball.draw(context);

balls.push(ball);

}

addEventListener('mousedown',onDown,false);

t0 = new Date().getTime();

animFrame();

}

function onDown(evt){

w = windMag;

addEventListener('mouseup',onUp,false);

}

function onUp(evt){

w = 0;

removeEventListener('mouseup',onUp,false);

}

除了对 2D 设置的明显概括,请注意,我们还指定了布料上我们希望保持固定的点的数组。在清单中,我们将布料固定在三个点上,看起来像是挂在一条线上的一块布料。

驱动模拟的calcForce()方法相当长,但是它是rope.js代码中的一个明显的概括,所以我们不会在这里列出它。不过,请看一下源文件中的代码。可以说,该文件中的一些编码可以通过使用数组而不是在不同方向做相同事情的不同变量(如extensionUpextensionDown等)来更简洁优雅地完成。但是代码在当前不优雅的形式下可能更容易理解,注释也应该有所帮助。主要的想法是,现在每个粒子都受到力的作用,这是由于它的四个最近的邻居分别在左、右、上、下。如果一个粒子在布的边缘,它显然没有这些力中的一个或多个。所有的粒子都受到重力的影响。另一个变化是在用户交互方面;点击并按住鼠标现在应用一个稳定的风吹向右侧。

运行模拟,你会有一块悬挂的布,你可以通过点击鼠标让风吹过(见图 13-18 )。实验模拟的参数,看看他们做什么。例如,您可以尝试增加粒子的数量,减少它们的间距,等等。你可能还想增加弹簧常数,看看在你把布撕成碎片之前你能走多远!

A978-1-4302-6338-8_13_Fig18_HTML.jpg

图 13-18。

A simple cloth simulation

不用说,这是一个非常基本的布料模拟,可以通过无数种方式进行增强。在功能上,最重要的改进大概是让布料在 3D 中运动,但我们还没有谈到如何在 3D 中模拟物理。你也可以将每个粒子连接到更多的邻居;例如,四个最近的对角线邻居和上下左右次最近的邻居。这将改善布料的性能。视觉上,你可以填充粒子之间的区域,以及添加纹理,灯光等等。交给你了!

摘要

本章介绍了刚体动力学和碰撞,并向您展示了如何使用质量弹簧系统来建模可变形体。不用说,我们在这一章中所谈到的只是冰山一角。关于扩展系统的一般主题,还有许多我们在这里只能简要提及的主题:约束系统、正向和反向运动学以及 Verlet 系统。其中一些可以提供模拟刚体和变形体的替代方法,但是我们没有足够的空间在本书中讨论它们。

本章完成了第三部分。在第四册的第一章中,我们将着眼于先进的数值方案,以及在创建复杂模拟中所涉及的稳定性和精确性的考虑。

十四、数值积分方案、精度和比例

在这本书的前两部分中,我们已经介绍了很多物理知识。在这个过程中,我们尽量避免陷入复杂的数字问题。在这一章中,我们将集中讨论数值积分方案的重要课题以及数值稳定性和精度的考虑。因为不会出现新的物理现象,所以这一章不会有任何新的模拟,只是一些简单的代码示例来测试不同的方案。在这一章的最后,我们还包括了一小段关于建造比例模型的内容。

数值积分是数值分析的一个分支,处理微分方程的解。积分方案是一种特殊的数值积分方法,其形式易于编写成代码。我们需要一个积分方案来模拟运动,因为它涉及到求解牛顿第二运动定律(这是一个微分方程;参见第五章。

本章涵盖的主题包括以下内容:

  • 一般原则:我们首先陈述要解决的一般问题,并解释可用的不同类型的集成方案及其特征。
  • 欧拉积分:这是最简单的积分方法,也是我们在整本书中一直使用的方法。它很快并且非常容易编码,但是它不准确并且可能不稳定,这可能导致某些类型的模拟出现问题。
  • 龙格-库塔积分:这种方法比欧拉方案精确得多,但它涉及大量的计算,因此会降低模拟速度。它特别适合高度精确的模拟。
  • Verlet 积分:这种方法不如 Runge-Kutta 精确,但一般比 Euler 方案好。它非常适合许多游戏编程和动画应用。
  • 实现准确性的提示:如果您想要创建一个需要高度准确的模拟,本节回顾了一些需要考虑的因素。
  • 构建比例模型:当您构建真实的模拟时,数值精度不是唯一的考虑因素。本节讨论如何选择单位和参数值来创建您要模拟的系统的比例模型。

总则

在我们研究具体的积分方案之前,先以一般的方式提出数值模拟的问题,然后看看数值积分是如何接近其解的,这是很有用的。然后,我们讨论集成方案的一般特征,并回顾与不同类型的方案相关的一些术语。

问题陈述

数值积分是解决问题的一种方法。那么我们要解决的问题是什么呢?

作为初值问题的质点运动

为了简化讨论,考虑模拟单个质点在力的作用下的运动问题。假设粒子从相对于原点 O 的初始位置向量 x(0)给定的位置开始,初始速度为 v(0)(见图 14-1 )。问题是确定作为时间函数的轨迹。在数学上,这相当于确定作为时间函数的位置和速度矢量 x(t)和 v(t)。这被称为初值问题。

A978-1-4302-6338-8_14_Fig1_HTML.jpg

图 14-1。

The initial-value problem for particle motion simulation

在这个问题中,粒子 v 的速度本身可能是随时间变化的(换句话说,粒子可能在加速)。这就是为什么我们需要牛顿第二定律:

A978-1-4302-6338-8_14_Figa_HTML.jpg

知道任意时刻的加速度,我们就可以通过积分定义加速度的方程来计算速度 v:

A978-1-4302-6338-8_14_Figb_HTML.jpg

类似地,应用速度的定义,我们可以通过对速度进行时间积分来获得位置:

A978-1-4302-6338-8_14_Figc_HTML.jpg

正如在第五章中所讨论的,这些微分方程有时可以解析求解,以给出 x(t)和 v(t)作为时间函数的闭合表达式。在计算机模拟中,积分需要用数字来完成。

数值积分意味着连续变化的粒子的位置和速度只能在小的离散步骤中计算和更新。要做到这一点,我们必须首先离散化刚才所示的运动方程。

数值离散和差分格式

在第三章中,当我们用一般数学术语讨论梯度函数的数值计算时,你看到了一个离散化过程的例子。你可以参考“简单的微积分思想”一节来复习。

在当前的上下文中,您可以将数值离散化应用到前面的两个方程,以将它们转换为可以在计算机上输入代码和求解的形式。

这是通过用代数分数近似这些方程中的导数来实现的。例如,我们可以利用 v 和 t 的微小变化δv 和δt 来近似计算加速度 d v/dt,如下所示:

A978-1-4302-6338-8_14_Figd_HTML.jpg

下一步是用一定时间的值来表示离散阶跃变化δv 和δt,因为这是您知道的(或者说是您的计算机知道的)。这可以用不同的方法实现,称为差分格式。

例如,您可以将δt 作为下一个时间步长的时间减去当前时间,速度间隔δv 也是如此。这给出了一个向前差分方案,如第三章中所述(注意,这里的 n 表示时间步长编号):

A978-1-4302-6338-8_14_Fige_HTML.jpg

类似地,速度可以通过以下方式由前向差分方案近似:

A978-1-4302-6338-8_14_Figf_HTML.jpg

现在,您已经将微分方程转换成了易于理解和操作的代数方程。更重要的是,它是一种计算机可以计算的形式。这就是离散化的作用。

但是在您可以将任何东西放入代码之前,还有一个步骤:您需要将前面的差分模式转换为集成模式。

数值积分和积分方案

在第三章中也概括介绍了数值积分。关键的想法是,我们需要根据旧时间的值,找到新时间的速度和位置。因此,正如我们在第三章中所做的,我们操纵前面的等式给出 v(n+1)和 x(n+1)的以下表达式,其中我们将时间差 t(n+1)–t(n)写成δt(时间步长):

A978-1-4302-6338-8_14_Figg_HTML.jpg

A978-1-4302-6338-8_14_Figh_HTML.jpg

如你所知,这给出了一个前向积分方案,称为欧拉方案。有了这些方程,你可以根据前一时间步的速度和位置来获得新的速度和位置。现在,您可以将这些方程直接放入代码中,并通过在每个时间步重复计算来模拟粒子的运动。这将给你一个粒子真实轨迹的离散近似值,如图 14-2 所示。一个好的模拟意味着这个近似的轨迹尽可能的接近真实的轨迹。近似值的精度取决于许多因素,包括以下因素:

A978-1-4302-6338-8_14_Fig2_HTML.jpg

图 14-2。

Numerical integration gives an approximation of the true trajectory

  • 问题的物理学:实际上,这是通过加速度 a 随时间的变化而得到的。有些物理问题比其他问题更容易精确模拟,特别是当加速度不随时间变化很大时。
  • 模拟时间步长:一般来说,时间步长越小,近似值就越好。但是减少时间步长是有代价的,因为模拟可能会运行得更慢。参考图 14-2 ,较小的时间步长意味着点之间的距离减小,近似曲线更接近真实曲线。集成方案:集成方案的选择通常对模拟的准确性、稳定性和执行速度有重要影响。但是在这些期望的特征之间经常有一个折衷。

数值格式的特征

当您决定使用哪种集成方案时,有一组特性需要牢记在心,因为它们决定了该方案有多“好”以及它有多适合您心目中的应用。我们将分别简要讨论其中的一些特征,但请记住它们是相互联系的。

一致性

数值格式的一致性是指数值格式在逼近微分方程的意义上与原微分方程一致的性质。形式上,在无限小时间步长的限制下,数值解应该与微分方程的真实解相同。这可以看作是任何数值格式的必要条件。如果这不是真的,这个方案将给出错误的物理原理,并且基本上是无用的。

稳定性

在前一章中,你看到了一个数值不稳定的壮观例子,当弹簧刚度太高时,绳索模拟“爆炸”。从形式上讲,数值格式的稳定性是指它随着时间的推移减少误差(或通常所说的“扰动”)的能力。如果误差或扰动在任何时候随时间衰减,则方案是稳定的;如果误差随时间增长,它是不稳定的。一个方案可以是有条件稳定的(意味着它在某些条件下是稳定的)或无条件稳定的(总是稳定的,与条件无关)。

集合

收敛是指当我们减少时间步长时,得到的数值解应该接近真实解。一致性是收敛的必要条件,但不是充分条件。同样,如果数值格式不收敛,它显然是没有用的。为了收敛,一个方案还必须是稳定的。

准确

用有限差分代替微分方程不可避免地会引入误差(称为离散化误差)。不用说,希望这些误差尽可能小。数值方案的精度决定了这些误差的大小。时间步长对数值误差也有重要影响;时间步长越小,误差通常越小(如果方案是收敛的)。数值格式的顺序决定了误差随着时间步长的减小而减小的速度。一阶方案的误差与时间步长δt 成正比,二阶方案的误差与(δt)2成正比,依此类推。因此,随着时间步长δt 的减小,二阶格式将更快地收敛到真实解,使其更加精确。

效率

数值方案的效率量化了每单位模拟时间内计算解决方案的速度(以挂钟时间计)。对于相同的时间步长,数值格式越复杂,其效率越低,因为它必须执行大量的计算和相关操作,以产生每个时间步长的给定解。更精确的方案通常更复杂,因此效率更低。因此,通常需要在准确性和效率之间进行权衡。然而,在实际实施中,数值方法的整体效率取决于它在保持稳定性和准确性的同时可以允许的时间步长的大小。结果可能是,一个简单的方案比一个更复杂的方案需要大得多的时间步长来保持稳定,这使得它在每个模拟时间的总计算时间方面效率更低。

集成方案的类型

数值分析的术语听起来很混乱,因为有太多的方案,它们相关的名称,以及不同的分类方法。以下是与集成方案分类相关的一些术语的简要描述。请注意,这里提到的类别并不相互排斥;它们重叠。

  • 向前/向后方案:您可能会遇到诸如向前欧拉和向后欧拉这样的方案名称。这些名称表明该格式是从相应的向前或向后差分格式中派生出来的。
  • 隐式/显式方案:显式方案是仅根据前一时间步的变量来计算新时间步的状态变量的值;这使得显式方案易于应用。在隐式方案中,必须求解包含旧时间步长和新时间步长的变量的方程,以计算新时间步长的变量;这使得这种方案效率较低。
  • 单步/多步方法:单步方法使用单个先前时间步的信息来计算新时间步的变量。多步方法使用来自多个先前时间步的信息。
  • 预测-校正方法:顾名思义,预测-校正方法包括两个阶段:在新的时间步长预测值的步骤,然后是对估计值进行校正的另一个步骤。
  • 龙格-库塔方法:这是指一类高阶方法,通过采用中间阶段进行,可以是显式的,也可以是隐式的。我们将考虑这种方法的两个例子,包括四阶龙格-库塔(RK4)方法,通常简称为龙格-库塔方法。

演示不同集成方案的简单示例

在接下来的部分中,我们将讨论一些不同的方案,然后编写实现它们的代码。能够容易地在不同的方案之间切换将是有帮助的,这将便于比较不同的方案和为每个问题选择合适的方案。

启用这种切换在一个简单的例子中得到了最好的演示,这个例子修改了我们在本书到目前为止的大多数例子中使用的动画代码的结构。具体来说,我们将修改《??》第五章中的基本forces-test.js示例,该示例演示了球在重力和阻力下的运动,以处理切换。我们称修改后的文件为schemes-test.js。代码的开头如下所示:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var ball;

var m = 1;

var g = 10;

var k = 0.1;

var t, t0, dt;

var force, acc;

var animId;

var animTime = 10;

window.onload = init;

function init() {

ball = new Ball(15,'#0000ff',m,0,true);

ball.pos2D = new Vector2D(50,400);

ball.velo2D = new Vector2D(60,-60);

ball.draw(context);

t0 = new Date().getTime();

t = 0;

animFrame();

};

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

t += dt;

if (t < animTime){

move();

}else{

stop();

}

}

function stop(){

cancelAnimationFrame(animId);

}

function move(){

myFavoriteScheme(ball);

context.clearRect(0, 0, canvas.width, canvas.height);

ball.draw(context);

}

function calcForce(``pos,vel

var gravity = Forces.constantGravity(m,g);

var drag = Forces.linearDrag(k,vel);

force = Forces.add([gravity, drag]);

}

function getAcc(pos,vel){

calcForce(pos,vel);

return force.multiply(1/m);

}

function myFavoriteScheme(obj){

// scheme-specific code goes in here

}

代码的关键结构变化用粗体表示。calcForce()方法现在有两个参数,posvel,它们引用位置和速度向量。这是需要的,因为一些方案需要在不同的时间评估加速度(以及力)。在列出的示例中,没有使用pos参数,但在未来的示例中可能需要它,因此出于通用性考虑将其包括在内。这些方案将通过调用私有的getAcc()方法来进行评估,该方法计算加速度,也使用位置和速度向量作为参数。move()方法将调用实现集成方案的方法。所有特定于方案的代码都将在它自己的方法中,这里统称为myFavoriteScheme()。这样,实现新的方案和在方案之间切换将变得容易。请注意,我们只对线性运动应用这些变化,因为我们将只对粒子运动使用新的积分方案;作为进一步的练习,你也可以沿着同样的路线在旋转运动中实现它们。

欧拉积分

我们在本书中多次讨论和使用了欧拉积分方案,所以你现在已经非常熟悉了。但你可能不知道的是,欧拉方案有几种不同的版本。让我们来看看其中的一些及其属性。

显式欧拉

我们在本节前面从前向差分格式导出的欧拉积分格式被称为前向欧拉格式。又因为是显式格式,所以又称为显式欧拉格式。等式又来了:

A978-1-4302-6338-8_14_Figi_HTML.jpg

A978-1-4302-6338-8_14_Figj_HTML.jpg

因为简单,显式欧拉格式速度快,但只有一阶精度,而且往往不稳定。

注意,当在代码中实现该方案时,我们在基于当前加速度更新速度之前基于当前速度更新位置,以与前面的等式一致。

文件schemes-test.js中的相关代码在EulerExplicit()方法中,看起来是这样的:

function EulerExplicit(obj){

acc = getAcc(obj.pos2D,obj.velo2D);

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

隐式欧拉

显式欧拉格式是从导数的前向差分近似开始得到的。相反,您可以从向后差分方案开始:

A978-1-4302-6338-8_14_Figk_HTML.jpg

A978-1-4302-6338-8_14_Figl_HTML.jpg

重新排列这些差分方程得到如下结果:

A978-1-4302-6338-8_14_Figm_HTML.jpg

A978-1-4302-6338-8_14_Fign_HTML.jpg

这看起来类似于显式欧拉格式的相应方程,除了一个关键的区别:这些方程的右侧包含新时间步长下的加速度和速度。因此,在获得新时间步长的速度之前,我们需要先求解一个隐式方程。这涉及到进一步的计算,这使得隐式方案效率更低(而且编码起来也很痛苦!).隐式格式的主要优点是它是无条件稳定的。如果你想确保你的模拟永远不会爆炸,值得考虑使用隐式方案。但是因为我们在本书中没有使用隐式欧拉格式,所以我们不会费心编写代码。

半隐式欧拉

半隐式欧拉格式,顾名思义,介于显式和隐式欧拉格式之间。在一个常见的变型中,如在显式方案中,基于前一时间步的加速度来推进速度,但如在隐式方案中,基于新时间步的更新速度来推进位置:

A978-1-4302-6338-8_14_Figo_HTML.jpg

A978-1-4302-6338-8_14_Figp_HTML.jpg

半隐式方案的优点是它比显式方案更稳定,并且还节省能量。这种能量守恒特性使它比显式格式更精确,尽管它仍然是一阶的。

与显式方案的实现相比,半隐式方案实现简单。您只需颠倒更新位置和速度的顺序。下面是相应的函数EulerSemiImplicit():

function EulerSemiImplicit(obj){

acc = getAcc(obj.pos2D,obj.velo2D);

obj.velo2D = obj.velo2D.addScaled(acc,dt);

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

}

存在半隐式欧拉方案的另一个变体(具有与第一个版本类似的属性),其中基于当前速度而不是更新的速度来推进位置,并且基于更新的加速度(因此在更新的位置)来推进速度:

A978-1-4302-6338-8_14_Figq_HTML.jpg

A978-1-4302-6338-8_14_Figr_HTML.jpg

请注意,因为加速度可能取决于位置,并且必须在新的时间进行计算,所以必须首先在代码中更新位置。这个版本的半隐式欧拉方案是我们在本书的大部分内容中一直使用的方案,并且它基本上工作得相当好,至少对于创建各种类型的物理效果的简单演示来说是如此。

我们已经在函数EulerSemiImplicit2()中对此进行了编码:

function EulerSemiImplicit2(obj){

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt);

acc = getAcc(obj.pos2D,obj.velo2D);

obj.velo2D = obj.velo2D.addScaled(acc,dt);

}

显式和半隐式欧拉格式的比较

为了比较显式和半隐式欧拉方案,让我们对一个质点在弹簧力作用下的振动进行一个非常简单的模拟。文件spring.js设置了一个粒子和一个弹簧力作用的中心,并在理论上其轨迹应该结束的点上创建了另外两个粒子。然后,calcForce()方法施加弹簧力,模拟移动粒子的运动,描绘出它的轨迹。这些文件在物理或编码方面没有什么新内容,所以我们在这里不列出任何代码。但关键的一点是,你可以在move()方法中改变集成方案。

EulerExplicit()EulerSemiImplicit()EulerSemiImplicit2()方法依次运行模拟。您可以通过取消注释springs.js中的moveObject()方法中的相关行并注释掉其他行来选择集成方案(或者,您可能希望修改代码以同时运行多个方案)。你会发现粒子如预期的那样振荡,并且很好地保持在两个半隐式方案的端点边界内。半隐式欧拉方法几乎是能量守恒的;就是所谓的辛方法。然而,显式欧拉格式表现很差:振荡变得越来越大(见图 14-3 )。模拟炸了:显式欧拉创造能量!如果你引入一些可以消除能量的物理现象,比如阻力或摩擦力,稳定性就会提高。然而,该格式不能模拟纯周期振荡运动。

A978-1-4302-6338-8_14_Fig3_HTML.jpg

图 14-3。

The explicit Euler scheme is too unstable to simulate spring motion properly

作为这些方案稳定性的另一个测试,通过改变move()方法中位置和速度更新的顺序,尝试使用显式欧拉方案运行上一章的绳索模拟。这是在文件rope-explicit-euler.js里做的:不好看!即使在低得多的刚度下,显式欧拉格式也不能模拟绳索。有趣的是,即使半隐式方案的第一个版本也没有第二个版本做得好;对于小得多的刚度值,它变得不稳定。相比之下,半隐式方案的第二个版本相当健壮,尽管它最终也会失败,正如你在第十三章中看到的。

为什么用欧拉,为什么不用?

许多程序员只使用欧拉积分,而其他人则尽可能远离欧拉。那么到底该不该用欧拉呢?

为了明智地回答这个问题,区分不同版本的欧拉方法是很重要的。人们经常谈论欧拉方案,好像只有一个似的。但是我们已经看到,不同版本的 Euler 具有非常不同的属性,根据模拟的性质,这可能会导致非常不同的性能。有不好的欧拉,也有更好的欧拉。你一定要避免不好的,显式的欧拉。半隐式欧拉实际上没那么糟糕(毕竟,我们用它熬过了本书的大部分时间!).它和显式欧拉格式一样容易编码,但具有更好的特性。在代码中,显式和半隐式 Euler 格式的区别仅仅在于交换了计算变量的顺序(所以要小心!).所以真的没有理由使用显式欧拉格式。半隐式欧拉格式广泛应用于刚体物理引擎中。

如果你需要绝对的稳定性,全隐式欧拉格式是值得考虑的,尽管我们还没有真正向你展示如何求解一个隐式方程。但是现在你对数值模式有了更好的理解,从书上或网上找到它应该不难。求解所得隐式方程所涉及的额外计算成本使得隐式欧拉格式在效率方面看起来不太吸引人。但是您可以使用大的时间步长,而不用担心数值不稳定,这将提高效率。然而,大的时间步长会导致低精度,特别是考虑到欧拉仅仅是一阶的。如果与稳定性相比,准确性不重要,这可能没问题(例如,如果您只是希望您的模拟不会爆炸,而不担心其准确性)。

但是,如果你真的需要精度,我们讨论过的一阶欧拉格式不会很好地为你服务。你可能需要一个类似龙格-库塔的方案。

龙格-库塔积分

龙格-库塔方案比欧拉方案更复杂,但它们在相当大的精度方面是值得的。如果准确性对您的模拟至关重要,您应该认真考虑龙格-库塔。不过,这种精度是以性能价格为代价的,尤其是如果您选择高阶方案的话。我们将描述两种最流行的龙格-库塔方案。

二阶龙格-库塔格式(RK2)

二阶龙格-库塔方法(RK2)也称为 Heun 方法或改进的欧拉方法。这是一种两阶段预测-校正方法,使用显式欧拉方法作为预测器,使用所谓的梯形方法作为校正器。以下是定义该方法的等式:

A978-1-4302-6338-8_14_Figs_HTML.jpg

A978-1-4302-6338-8_14_Figt_HTML.jpg

A978-1-4302-6338-8_14_Figu_HTML.jpg

A978-1-4302-6338-8_14_Figv_HTML.jpg

A978-1-4302-6338-8_14_Figw_HTML.jpg

A978-1-4302-6338-8_14_Figx_HTML.jpg

A978-1-4302-6338-8_14_Figy_HTML.jpg

A978-1-4302-6338-8_14_Figz_HTML.jpg

临时变量 p 1 ,v 1 ,a 1 为初始位置、速度和加速度向量;类似地,p 2 、v 2 和 a 2 是由前向欧拉方案计算的时间步长结束时的相应变量。这是预测部分。最后两个等式通过(分别)使用时间步长间隔内的平均速度和平均加速度更新位置和速度来应用校正器。

RK2 比 Euler 更精确,因为 Euler 方案仅使用旧时间步的速度和加速度来计算粒子的新速度和位置。如果没有加速度,这没问题,所以速度在一个时间步长内保持不变,但速度和加速度都可能变化。例如,对于一个抛射体来说,速度的大小和方向都是变化的。欧拉方案只考虑时间步长开始时的速度,而 RK2 方案则考虑时间步长开始和结束时的平均速度。因此,RK2 预测的时间步长结束时的位置更接近实际位置。

这种方法的精度是二阶的,因此它比欧拉方法要精确得多。下面是相关的RK2()函数:

function RK2(obj){

var pos1 = obj.pos2D;

var vel1 = obj.velo2D;

var acc1 = getAcc(pos1,vel1);

var pos2 = pos1.addScaled(vel1,dt);

var vel2 = vel1.addScaled(acc1,dt);

var acc2 = getAcc(pos2,vel2);

obj.pos2D = pos1.addScaled(vel1.add(vel2),dt/2);

obj.velo2D = vel1.addScaled(acc1.add(acc2),dt/2);

}

RK2()函数比半隐式欧拉方案多包含几行代码。因此,对于相同的时间步长,精度的提高是以降低速度为代价的。

四阶龙格-库塔格式(RK4)

A978-1-4302-6338-8_14_Figaa_HTML.jpg

A978-1-4302-6338-8_14_Figbb_HTML.jpg

A978-1-4302-6338-8_14_Figcc_HTML.jpg

A978-1-4302-6338-8_14_Figdd_HTML.jpg

A978-1-4302-6338-8_14_Figee_HTML.jpg

A978-1-4302-6338-8_14_Figff_HTML.jpg

A978-1-4302-6338-8_14_Figgg_HTML.jpg

A978-1-4302-6338-8_14_Fighh_HTML.jpg

A978-1-4302-6338-8_14_Figii_HTML.jpg

A978-1-4302-6338-8_14_Figjj_HTML.jpg

A978-1-4302-6338-8_14_Figkk_HTML.jpg

A978-1-4302-6338-8_14_Figll_HTML.jpg

A978-1-4302-6338-8_14_Figmm_HTML.jpg

A978-1-4302-6338-8_14_Fignn_HTML.jpg

四阶龙格-库塔方案(RK4)是最著名的龙格-库塔方案,通常被称为龙格-库塔方案。顾名思义,它是四阶精度。因此,如果将时间步长减半,误差应该会减少十六分之一!这种想法与 RK2 类似,但中间步骤更多,中间变量及其平均值的计算方式也不同。下面是完整的方程组:

下面是相应的RK4()方法:

function RK4(obj){

var pos1 = obj.pos2D;

var vel1 = obj.velo2D;

var acc1 = getAcc(pos1,vel1);

var pos2 = pos1.addScaled(vel1,dt/2);

var vel2 = vel1.addScaled(acc1,dt/2);

var acc2 = getAcc(pos2,vel2);

var pos3 = pos1.addScaled(vel2,dt/2);

var vel3 = vel1.addScaled(acc2,dt/2);

var acc3 = getAcc(pos3,vel3);

var pos4 = pos1.addScaled(vel3,dt);

var vel4 = vel1.addScaled(acc3,dt);

var acc4 = getAcc(pos4,vel4);

var velsum = vel1.addScaled(vel2,2).addScaled(vel3,2).add(vel4);

var accsum = acc1.addScaled(acc2,2).addScaled(acc3,2).add(acc4);

obj.pos2D = pos1.addScaled(velsum,dt/6);

obj.velo2D = vel1.addScaled(accsum,dt/6);

}

在一个时间步长内要做大量的计算!所以让我们看看 RK4 能为我们做些什么。我们将针对三个不同的问题比较欧拉、RK2 和 RK4 方法。

与欧拉比较 RK2 和 RK4 的稳定性和精确度

为了检查 RK2 和 RK4 的稳定性,您可以使用RK2()RK4()方法重复弹簧测试。你会发现振荡运动被两者完美地模拟了:在肉眼所能看到的范围内,振荡的振幅(以及能量)甚至没有一点点的减少或增加(如果你愿意,你可以通过绘制图表来进行更多的定量测试)。

为了测试这些方案的准确性,并以一种简单的方式与以前的方案进行比较,让我们做一个简单的轨道模拟。文件名为orbits.js。同样,没有什么是你在以前的文件中没有见过的,我们不会在这里列出任何代码。测试是轨道应该自己关闭;如果他们不这样做,这个计划就会引入重大错误。

像以前一样用不同的集成方案运行代码。有了显式欧拉积分器,轨道将到处结束:它永远不会自我关闭(见图 14-4 )。这应该足以说服你永远不要使用显式欧拉格式!

A978-1-4302-6338-8_14_Fig4_HTML.jpg

图 14-4。

The explicit Euler scheme is too inaccurate to produce a closed orbit

所有其他计划都做得相当不错。但是如果你长时间运行模拟,你会发现即使是半隐式欧拉格式也开始失去精度,并跟踪稍微不重叠的圆,因为它们只有一阶精度(见图 14-5 )。相比之下,我们运行 RK4 方案的时间要长得多,但是没有发现任何准确性的损失。RK2 格式的精度介于半隐式欧拉格式和 RK4 格式之间。对于这个简单的例子,你几乎不会注意到 RK2 和 RK4 之间的任何区别。

A978-1-4302-6338-8_14_Fig5_HTML.jpg

图 14-5。

The semi-implicit Euler scheme accumulates errors after a few orbits

Verlet 集成

我们将讨论的第三类也是最后一类集成方案称为 Verlet 集成。由于其稳定性,该方法最初是为处理复杂和可变的分子动力学模拟而开发的,现在广泛用于游戏编程和动画。

在所谓的 Verlet 系统中使用 Verlet 积分来模拟受约束的粒子的运动。这项技术可以应用于连接结构,如在布娃娃物理学,正向和反向运动学,甚至刚体系统。我们没有空间来探索这种方法。我们提到 Verlet 系统仅仅是为了将它们与 Verlet 集成方案区分开来,因为它们有时会被混淆:Verlet 系统如此命名是因为它们使用了 Verlet 集成方案。但是方案本身完全独立于建模方法;事实上,它可以与任何其他方法一起使用,例如在第十三章中讨论的刚体动力学。

Verlet 积分方案通常不如 RK4 精确,但另一方面,相比之下非常有效。让我们来看看该方法的两个常见变体:位置 Verlet 和速度 Verlet。

位置 Verlet 方法

在位置微元法中,也称为标准微元法,粒子的速度是不存储的。相反,存储粒子的最后位置,然后可以根据需要通过从当前位置减去最后位置并除以时间步长来计算其速度。

正如显式欧拉格式可以从一阶导数的前向差分近似导出一样,标准的 Verlet 格式可以从二阶导数的中心差分近似导出(见第三章)。我们不会在这里显示推导过程,而只引用最终结果:

A978-1-4302-6338-8_14_Figoo_HTML.jpg

该等式根据前两个时间步长的位置给出新时间步长的位置,而不使用速度。

如果需要,可以从位置计算速度,例如,使用后向差分:

A978-1-4302-6338-8_14_Figpp_HTML.jpg

或者,您可以使用中心差分方案,因为先前的位置也是已知的,以给出更精确的速度估计:

A978-1-4302-6338-8_14_Figqq_HTML.jpg

先前给出的位置 Verlet 方程的一个问题是它假设时间步长是恒定的。但一般来说,模拟中的时间步长是可变的。事实上,在您的 JavaScript 模拟中,时间步长通常会略有不同。考虑到可变的时间步长,使用该公式的修改版本:

A978-1-4302-6338-8_14_Figrr_HTML.jpg

在该等式中,δt(n)表示当前时间步长,δt(n-1)表示前一时间步长。这种形式可以称为时间校正或时间调整的位置 Verlet 方案。

在 Position Verlet 方案的实现中出现的另一个潜在问题是初始条件的指定。因为该方案不使用速度,而是使用前一时间步的位置,所以不能像通常那样直接指定初始速度和初始位置。相反,您需要指定初始位置 x(0)以及初始位置之前的前一个位置 x(–1)!这听起来可能毫无意义,但实际上,通过应用前面给出的速度向后差分公式,可以从初始速度推断出假设位置的值。在该公式中设置 n =–1 给出如下结果:

A978-1-4302-6338-8_14_Figss_HTML.jpg

解出 x(–1)的方程式,得出:

A978-1-4302-6338-8_14_Figtt_HTML.jpg

我们现在可以如下编写一个PositionVerlet()函数:

function PositionVerlet(obj){

var temp = obj.pos2D;

if (n==0){

acc = getAcc(obj.pos2D,obj.velo2D);

oldpos = obj.pos2D.addScaled(obj.velo2D,-dt).addScaled(acc,dt*dt/2);

olddt = dt;

}

acc = getAcc(obj.pos2D,obj.velo2D);

obj.pos2D = obj.pos2D.addScaled(obj.pos2D.subtract(oldpos),dt/olddt).addScaled(acc,dt*dt);

obj.velo2D = (obj.pos2D.subtract(oldpos)).multiply(0.5/dt);

oldpos = temp;

olddt = dt;

n++;

}

注意,我们有新的变量oldposolddt用于存储先前的位置和先前的时间步长。还有一个变量n,表示自上次调用该函数以来已经过去的时间步数。从代码中可以看出,第一次调用该函数时,位置 x(–1)被设置。

速度 Verlet 方法

位置 Verlet 方法的一个问题是速度估计不是非常精确。Velocity Verlet 方法解决了这个问题,尽管在效率上付出了一些代价。Velocity Verlet 是两者中更常用的版本。方案方程如下:

A978-1-4302-6338-8_14_Figuu_HTML.jpg

A978-1-4302-6338-8_14_Figvv_HTML.jpg

将第一个方程与欧拉方案中的相应方程进行比较,可以看出我们现在有了一个包含加速度的附加项。因此,欧拉方案假设加速度在一个时间步长的间隔内为零,而速度 Verlet 方法考虑了加速度,但假设加速度在该间隔内是恒定的。也许你还记得《??》第四章中的等式 s = u t + a t 2 。速度 Verlet 中的位置更新方程正是这种形式(位移为 s = x(n+1)-x(n))。

还要注意,速度更新方程使用旧时间步长和新时间步长的加速度平均值,而不是仅使用旧时间步长的加速度。因此,它是速度的一个更好的近似值。由于这些原因,速度 Verlet 方案比欧拉方案更精确,并且具有其他良好的特性,如更大的稳定性和能量守恒。

下面是VelocityVerlet()方法:

function VelocityVerlet(obj){

acc = getAcc(obj.pos2D,obj.velo2D);

var accPrev = acc;

obj.pos2D = obj.pos2D.addScaled(obj.velo2D,dt).addScaled(acc,dt*dt/2);

acc = getAcc(obj.pos2D,obj.velo2D);

obj.velo2D = obj.velo2D.addScaled(acc.add(accPrev),dt/2);

}

测试 Verlet 方案的稳定性和准确性

现在,您可以使用 Verlet 方案运行弹簧和轨道模拟。你应该会发现他们在这两种情况下都做得相当不错。Verlet 方案是稳定的,但不如 RK4 精确。

通过用已知的解析解对一个问题进行模拟,可以进行一个简单的测试来说明所有不同方案的精度差异,然后可以将数值解与之进行比较。你可能还记得,在第四章中,我们为抛射体模拟的欧拉方案做过这样的测试。现在让我们对所有不同的方案做一些类似的事情。

文件projectile.js包含代码。同样,我们不会显示任何代码清单,因为到目前为止,您已经多次看到过类似的代码。要点是它创建一个球,并初始化它的位置和速度,使球以一定角度向上抛,然后使球受重力作用。

使用不同的积分器运行模拟。该代码绘制了分析轨迹以及数值计算轨迹(当然,球会跟随该轨迹)。你会发现时间修正位置 Verlet 和所有欧拉积分器给出的轨迹与解析轨迹略有偏差(见图 14-6 )。另一方面,使用速度 Verlet、RK2 和 RK4 方案预测的轨迹与解析轨迹非常接近,以至于无法区分。和 RK2 一样,Velocity Verlet 格式也是二阶格式。请注意,这些比较是针对特定的问题,包括恒定加速度。对于不同的问题,不同的方案可能或多或少是准确的。

A978-1-4302-6338-8_14_Fig6_HTML.jpg

图 14-6。

Projectile trajectory simulated with Position Verlet scheme (slightly lower curve)

最后,您可能想要检查标准 Position Verlet 方案在没有时间校正的情况下表现如何。为此,用以下代码替换更新PositionVerlet()中位置的代码行:

obj.pos2D = obj.pos2D.add(obj.pos2D).subtract(oldpos).addScaled(acc,dt*dt);

如果你运行代码,你会发现模拟完全错了!消息是,如果在模拟中有可变的时间步长,时间校正是必不可少的。

实现准确性的技巧

如果你想建立非常精确的模拟,你需要仔细考虑几个方面。我们在这里简要回顾一下你在这一章以及前几章中学到的知识。

选择合适的集成方案

在这一章中,我们花了很多时间讨论数值积分方案及其特点,如精度和稳定性。所以,这里就不多说了。简单重申一下,通常的欧拉格式不是很精确,只是一阶格式;四阶龙格-库塔方案 RK4 通常是精度非常重要的仿真的好选择,尽管这是以降低效率为代价的。RK2 和 Velocity Verlet 方案在精度和速度之间提供了很好的折衷,是二阶的(因此比 Euler 方案更精确),同时比 RK4 简单得多。

使用合适的时间步长

结合积分方案,你需要为你的模拟选择一个合适的时间步长。时间步长越小,数值格式越精确。RK4 等高阶格式比欧拉等低阶格式收敛更快。这意味着对于给定的时间步长,RK4 更精确。你可以在一定程度上控制时间步长。然而,时间步长可以有一个限制,因为timer事件之间的实际时间间隔包括执行事件处理程序中所有代码的时间。因此,如果您的模拟很复杂,并且涉及大量计算或动画,则实际时间步长可能会远远大于您在计时器中指定的时间步长。

有些模拟非常复杂,无法在合理的小时间步长内完成计算,因此可能无法实时执行。一个可能的解决方案是预先计算,然后制作动画。通过这种方式,可以将模拟时间步长与动画时间步长“分离”。这种方法的一个例子将在第十六章的太阳系模拟中应用。

使用精确的初始条件

有些模拟对开始时模拟对象的位置和速度值很敏感。这些初始条件的一个小变化可能会在随后的运动中产生大的变化。例如,在涉及轨道的模拟中,卫星的轨道可能会发生实质性的变化,甚至可能在初始条件选择错误的情况下撞上一颗行星。初始条件是模拟规范的一部分:它们越精确,结果轨迹就越接近预期轨迹。

小心处理界限

涉及边界的模拟特别容易不准确。这种相互作用必须使用特殊的方法来处理,以检测和处理特定时刻的碰撞,它们会引入额外的误差源,随着时间的推移,这些误差源甚至会变得比与数值积分相关的误差源更重要。在第十一章中,我们描述了如何处理一些特殊情况下的问题,包括边界的重新定位和速度修正。不准确的另一个来源是碰撞时间的不确定性。在下一节中,你将看到一个例子,一个球在“穿过”地面后被检测到并停止。因此,它落地的时间被稍微高估了。速度越高,该误差越大。为了获得更高的精度,需要考虑并校正这些误差。

建筑比例模型

让我们从澄清标题的含义开始这一部分。我们所说的比例模型并不是指像飞机那样的物理比例模型。我们指的是一个计算机模型,其中所有相关的物理参数都与它要模拟的真实系统成适当的比例。从这个意义上说,比例模型可能包括按比例表示物理对象,但它远远超出了简单的视觉方面;从某种意义上来说,我们很快会做得更精确,正确地衡量物理学。

缩放以获得真实感

为什么比例建模很重要?正确的比例建模对于模拟真实地复制被建模系统的行为是必要的。拥有正确的物理方程和适当的视觉表现以及像 3D 这样的附加效果对于游戏和模拟中的真实感来说显然是非常重要的。也许不太明显的是,需要一致地选择适当的参数值,以便模拟的所有方面与正确的长度和时间比例一起工作,以产生真实世界的逼真模型。

我们如何做到这一点?首先,我们将用一个非常简单的例子来说明比例建模的过程;然后在这一节的最后,我们会给你一个处理更复杂模拟的通用形式方法。

简单的例子

假设你想创建一个 2D 的排球游戏:角色将把球扔来扔去,因此大部分时间球将在重力作用下移动,就像一个抛射体。周围会有风景和建筑,你希望你的游戏在视觉上和功能上都是一个比例模型。为了继续,让我们考虑一个简单的假设场景,其中球在建筑物的高度,并在重力作用下下落。你希望球下落的速度与周围场景中的长度比例一致;在这种情况下,建筑高度。假设唯一的力是没有阻力或摩擦或风效应的纯重力,并且为了简单起见(但不失一般性),仅考虑垂直运动,底层物理可以由单个方程描述。应用牛顿第二定律 F = ma,用 mg 给出的力 F,我们得到这个方程:

A978-1-4302-6338-8_14_Figww_HTML.jpg

当然,这个方程仅仅表达了这样一个事实,即所有物体都以相同的垂直加速度 g 下落,不管它们的质量或其他特性如何。如你所知,你的模拟将对这个方程进行两次积分:第一次获得球的速度,第二次获得它的位移。整个问题由一个参数控制:重力加速度 g。因此,问题归结为:你必须在你的模拟中使用什么样的 g 值,才能使球以真实的速度下落,与游戏中的大小和距离一致?g 的实际值为 9.8 米/秒 2 ,或约为 10 米/秒 2 。但这不是您可以在模拟中使用的值,因为您在模拟中没有使用相同的单位。见图 14-7 。

A978-1-4302-6338-8_14_Fig7_HTML.jpg

图 14-7。

Simulating a falling ball to scale

选择单位

首先,您需要为模拟中的相关物理量选择单位。你需要的最重要的单位是时间、长度和质量。它们在物理学中被称为基本量。其他量,如速度和加速度,取决于这些基本量,它们的单位可以通过使用它们的定义方程从基本量的单位导出;因此,它们被称为导出量。

在这种情况下,因为质量不在方程中,所以用什么单位或者什么值来表示球的质量是完全不相关的。所以我们需要担心两个基本量:时间和长度(或距离)。

让我们假设您想要实时模拟:换句话说,模拟应该以与真实“挂钟”时间相同的速率进行。(有时您可能不希望这样:例如,如果您正在模拟太阳系,您可能不希望等待一年地球绕太阳一周!)在图 14-7 中,你的模拟中的球从真实建筑的高度落下所用的时间完全相同。因此,你选择以秒来衡量时间,就像在现实生活中一样。不仅如此,您还希望 1 秒的模拟时间等于 1 秒的真实时间:您的时间缩放因子是 1。

用于基于屏幕的视觉模拟的自然距离单位是像素(px)。您还需要决定一个合适的比例因子来将实际距离转换为像素。

比例因子和参数值

假设你场景中的建筑在现实生活中有 5 米高,你用一个 200 像素高的矩形来表示它。那意味着你的缩放因子是 0.025m/px;每个像素代表 0.025 米或 2.5 厘米。您可以使用该长度比例因子相应地缩放其他对象。因此,如果你的球是一个半径为 10 厘米的排球,它在模拟中的半径将是 4 个像素。

回到我们最初的问题:你应该给 g 什么值?你解决这个问题的方法如下。作为加速度,g 的测量单位是米/秒 2 。现在,每一秒在模拟中都是实际的一秒,但每米在模拟中都是 40 px。因此,在模拟中,缩放 g = 10 米/秒 2 的近似值将得到 g = 400!

文件scale-model.js在这个简单的模拟中实现了这些值。在代码中,当球落到地面上 200 px(相当于现实中的 5 m)时,球被停止。然后跟踪自模拟开始以来的时间。运行模拟,你会发现球落地大约需要 1 秒钟。这现实吗?使用你在第四章中遇到的旧公式很容易找到答案:

A978-1-4302-6338-8_14_Figxx_HTML.jpg

这里位移 s 是下落的高度(5 m),u 是初速度大小(零),a 是重力加速度 g (10 m/s 2 )。代入这些值确实给出了 t = 1 s,我们有了实时比例模型!模拟中跟踪的值不完全是 1,但已经足够接近了。它不精确为 1 的原因之一是,球被检测到越过地面的时间稍微晚了一点,因为它移动得太快了。当您运行模拟时,您会看到它在“地面”下方稍停

假设你在模拟中天真地给了 g 值 10。会有什么后果?现在这样做,你应该会发现球到达地面需要 6 秒以上的时间——几乎不现实!

当然,在确定模拟的参数时,您可能不需要总是像这样小心。你可能根本不在乎有没有缩尺模型;也许你只是对创建一个看起来大致正确的动画感兴趣。但是当现实主义真的很重要的时候,知道如何做到这一点是很有用的。在更复杂的模拟中,有一个系统的方法来计算参数值是有帮助的。

重新调整方程

重定方程式比例的方法是一种正式的程序,有助于为模拟中的参数计算出合适的值。当您需要计算多个参数的值或者存在多个比例因子时,这种方法特别有用,但是我们将针对刚刚看到的简单示例来演示这种方法。

重新标度涉及到对系统中的变量进行标度,根据新的标度变量重新表达方程,然后将标度方程与原始方程进行比较。程序如下。

首先,写下控制方程(代数的或微分的),确定所有的变量。在我们的例子中,方程是 a = g,但这实际上是下面的微分方程:

A978-1-4302-6338-8_14_Figyy_HTML.jpg

接下来,为每个变量定义比例因子,如下所示:

A978-1-4302-6338-8_14_Figzz_HTML.jpg

这里,带帽子的变量是新的缩放变量,希腊字母是相应的缩放因子。

然后把旧变量代入原方程。在前面的微分方程中,注意二阶导数意味着我们将位移增量除以时间增量两次。进行替换,我们因此得到这个:

A978-1-4302-6338-8_14_Figaaa_HTML.jpg

我们可以将这个转换后的等式改写如下:

A978-1-4302-6338-8_14_Figbbb_HTML.jpg

将其与原始方程进行比较,我们推断出,如果右侧等于 g 的重新标度值,则根据标度变量的方程将与原始方程具有完全相同的形式:

A978-1-4302-6338-8_14_Figccc_HTML.jpg

该等式给出了比例因子和参数 g 的重定标值之间的约束。因此,如果我们选择其中两个,就可以获得第三个。在本例中,我们选择了距离和时间的比例因子。因此,我们可以通过替换比例因子τ和λ的值来获得 g 的重新标度值。如前所述,时间 t 的比例因子τ为 1,而距离 s 的比例因子λ为 5/200 或 0.025 m/px,如前一小节所示。因此,模拟所需的 g 的比例值由下式给出:

A978-1-4302-6338-8_14_Figddd_HTML.jpg

这正如我们先前的推理。

注意,我们也可以将 g 的重新标度值与其中一个变量(时间或长度)的标度因子固定在一起。先前的约束条件将为我们提供剩余变量的比例因子。

对于这个简单的例子,刚刚概述的正式程序肯定是多余的,但是添加更多的变量和更复杂的方程组,它确实会派上用场。

摘要

如果你打算认真进入物理模拟,迟早你需要担心你的集成方案的准确性和稳定性。本章介绍了一些最常见的选项供您选择。特定方案的选择取决于问题以及其他实际因素。在本书的前几章中,我们只使用了欧拉方案。在第十六章中,你会看到一个需要更精确集成方案的例子。

十五、3D 物理

到目前为止,在本书中,你已经建立了二维模拟。但在现实世界中有三个维度:如何将你已经完成的艰苦工作扩展到 3D 领域?在倒数第二章,我们将向你介绍基于 3D 物理的动画的精彩世界。

本章涵盖的主题包括以下内容:

  • 3D 物理和数学:这一节讨论如何扩展我们现有的代码框架来创建 3D 模拟。在这个过程中,我们将探索一些额外的数学概念,这将有助于处理三维平移和旋转运动,这将在本章后面的例子中使用。
  • 3D 渲染:介绍 WebGL 和 three.js:本节讨论 3D 渲染和 WebGL API,并介绍了three.js JavaScript 库,它简化了使用 WebGL 创建 3D 模拟的过程。
  • 模拟 3D 中的粒子运动:这部分给出了使用three.js库来创建 3D 中粒子运动的物理模拟的例子。这里使用的方法将在第十六章中应用于开发一个精确的太阳系模拟。
  • 在 3D 中模拟刚体运动:以一个旋转立方体为例,本节展示如何在 3D 中模拟刚体运动。在第十六章中,我们将在这个例子的基础上创建一个飞行模拟器。

请注意,这一章的重点将是 3D 物理,而不是一般的 3D 编程。因此,尽管我们不可避免地要讨论使用 JavaScript 库进行 3D 编码的一些方面,这一章并不是关于最新的 3D 技术。

3D 物理和数学

在我们可以创建 3D 模拟之前,我们需要概括一些迄今为止我们一直在使用的数学和物理概念,并在此过程中引入新的概念。

3D 对 2D 物理学

到目前为止,我们在本书中讨论的大多数物理学在从 2D 到 3D 的过程中基本上没有变化。当用矢量形式表示时,直线运动和旋转运动的基本定律保持不变。粒子运动以类似的方式处理,不同的是在 3D 中粒子可以在额外的维度中运动(这很明显,不是吗?).因此,数学和代码必须一般化,以包括额外的空间维度。因此,我们将在 3D 中查看向量和向量代数,并构建一个Vector3D JavaScript 对象。我们还将创建一个Forces3D对象,它与Vector3D对象一起,将使我们能够编写代码来处理 3D 空间中各种力下的粒子运动。

刚体动力学更复杂,因为 3D 中的旋转很复杂,需要一些更高级的数学知识。因此,我们还将介绍处理 3D 旋转所需的数学背景,介绍新的数学概念,如矩阵和四元数。

三维向量

我们在本书中广泛使用了向量。作为对其有用性的提醒,向量使代表物理定律的方程能够以一种经济的方式来书写。根据 vector 对象编写的代码也更简洁、更直观、更不容易出错。好消息是,3D 矢量与 2D 矢量没有太大区别。

三维向量代数

3D 矢量是 2D 矢量的直接扩展。在第三章中,我们并排复习了 2D 版和 3D 版的向量代数。如果你需要复习,你可以再读一遍那一节。2D 的向量代数和 3D 的最大区别,除了有三个分量而不是两个之外,就是你可以在 3D 中做叉积。

Vector3D 对象

回到第三章,我们构建了一个Vector2D对象,我们一直在用新方法稳步增强它。修改它来创建一个Vector3D对象是很简单的。代码在vector3D.js文件中,可从 www.apress.com 下载,我们在此列出完整代码:

function Vector3D(x,y,z) {

this.x = x;

this.y = y;

this.z = z;

}

// PUBLIC METHODS

Vector3D.prototype = {

lengthSquared: function(){

return this.x*this.x + this.y*this.y + this.z*this.z;

},

length: function(){

return Math.sqrt(this.lengthSquared());

},

clone: function() {

return new Vector3D(this.x,this.y,this.z);

},

negate: function() {

this.x = - this.x;

this.y = - this.y;

this.z = - this.z;

},

unit: function() {

var length = this.length();

if (length > 0) {

return new Vector3D(this.x/length,this.y/length,this.z/length);

}else{

return new Vector3D(0,0,0);

}

},

normalize: function() {

var length = this.length();

if (length > 0) {

this.x /=length;

this.y /=length;

this.z /=length;

}

return this.length();

},

add: function(vec) {

return new Vector3D(this.x + vec.x,this.y + vec.y,this.z + vec.z);

},

incrementBy: function(vec) {

this.x += vec.x;

this.y += vec.y;

this.z += vec.z;

},

subtract: function(vec) {

return new Vector3D(this.x - vec.x,this.y - vec.y,this.z - vec.z);

},

decrementBy: function(vec) {

this.x -= vec.x;

this.y -= vec.y;

this.z -= vec.z;

},

multiply: function(k) {

return new Vector3D(k*this.x,k*this.y,k*this.z);

},

addScaled: function(vec,k) {

return new Vector3D(this.x + k*vec.x, this.y + k*vec.y, this.z + k*vec.z);

},

scaleBy: function(k) {

this.x *= k;

this.y *= k;

this.z *= k;

},

dotProduct: function(vec) {

return this.x*vec.x + this.y*vec.y + this.z*vec.z;

},

crossProduct: function(vec) {

return new Vector3D(this.y*vec.z-this.z*vec.y,this.z*vec.x-this.x*vec.z,this.x*vec.y-this.y*vec.x);

}

};

// STATIC METHODS

Vector3D.distance =  function(vec1,vec2){

return (vec1.subtract(vec2)).length();

}

Vector3D.angleBetween = function(vec1,vec2){

return Math.acos(vec1.dotProduct(vec2)/(vec1.length()*vec2.length()));

}

Vector3D.scale = function(vec,sca){

vec.x *= sca;

vec.y *= sca;

vec.z *= sca;

}

Vector3D方法是它们的Vector2D对应物的简单概括。注意,crossProduct()方法返回了一个Vector3D对象,这是应该的。

3D 中的力

因为力是矢量,所以当你得知新创建的Vector3D对象使得在 3D 模拟中包含力变得简单时,你不会感到惊讶。

三维空间中的牛顿运动定律

在第五章中,我们介绍了支配运动的定律,包括牛顿运动定律。概括地说,这些定律允许我们计算物体在力的作用下的运动。如果你回头看看第五章,你会发现这些定律是用矢量形式表达的。至关重要的是,我们没有具体说明我们是在 2D 还是在 3D 中谈论矢量:规律是一样的。我们在第四章中介绍的运动概念也是如此,比如速度和加速度。所以我们写 3D 代码要做的就是把这些物理量定义为Vector3D对象,而不是Vector2D对象。我们必须应用的定律保持不变,因此产生的物理代码实际上和它的 2D 对应物是一样的!

Forces3D 对象

在我们可以为 3D 粒子运动编写类似的代码之前,我们需要更新另外一个关键的代码:对象。同样,这非常简单。我们称这个新对象为Forces3D,并在这里列出相应文件forces3D.js中的代码:

function Forces3D(){

}

// STATIC METHODS

Forces3D.zeroForce = function() {

return (new Vector3D(0,0,0));

}

Forces3D.constantGravity = function(m,g){

return new Vector3D(0,m*g,0);

}

Forces3D.gravity = function(G,m1,m2,r){

return r.multiply(-G*m1*m2/(r.lengthSquared()*r.length()));

}

Forces3D.gravityModified = function(G,m1,m2,r,eps){

return r.multiply(-G*m1*m2/((r.lengthSquared()+eps*eps)*r.length()));

}

Forces3D.electric = function(k,q1,q2,r){

return r.multiply(k*q1*q2/(r.lengthSquared()*r.length()));

}

Forces3D.forceField = function(q,E) {

return E.multiply(q);

}

Forces3D.lorentz = function(q,E,B,vel) {

return E.multiply(q).add(vel.perp(q*B*vel.length()));

}

Forces3D.central = function(k,n,r) {

return r.multiply(k*Math.pow(r.length(),n-1));

}

Forces3D.linearDrag = function(k,vel){

var force;

var velMag = vel.length();

if (velMag > 0) {

force = vel.multiply(-k);

}else {

force = new Vector3D(0,0,0);

}

return force;

}

Forces3D.drag = function(k,vel) {

var force;

var velMag = vel.length();

if (velMag > 0) {

force = vel.multiply(-k*velMag);

} else {

force = new Vector3D(0,0,0);

}

return force;

}

Forces3D.upthrust = function(rho,V,g) {

return new Vector3D(0,-rho*V*g,0);

}

Forces3D.spring = function(k,r){

return r.multiply(-k);

}

Forces3D.damping = function(c,vel){

var force;

var velMag = vel.length();

if (velMag>0) {

force = vel.multiply(-c);

}

else {

force = new Vector3D(0,0,0);

}

return force;

}

Forces3D.add = function(arr){

var forceSum = new Vector3D(0,0,0);

for (var i=0; i<arr.length; i++){

var force = arr[i];

forceSum.incrementBy(force);

}

return forceSum;

}

矩阵和旋转

向量帮助我们处理平移运动,从而改变物体在空间中的位置。你需要一个更复杂的数学实体来处理旋转,由此扩展的对象改变其在空间中的方向——你需要一个矩阵。

矩阵介绍

到目前为止,我们在避免矩阵方面做得很好,甚至在第十三章讨论刚体转动时也是如此。虽然在 2D 这样做没问题,但在 3D 中忽略矩阵就比较困难了。但是在我们讨论 3D 中的矩阵代数和旋转之前,先在更简单的 2D 上下文中做这些会有很大帮助。

矩阵本质上是一组数字,很像向量。但是尽管向量是一维数组,你也可以有更高维的矩阵。我们只需要担心二维矩阵,它看起来像这样:

A978-1-4302-6338-8_15_Figa_HTML.jpg

该矩阵有 3 行 4 列;这是一个 3 × 4 的矩阵。我们称它为 a,然后我们可以通过行号和列号来识别特定的矩阵元素。例如,A(3,2)=–2。

就像数组一样,当您必须处理一组相似的量,并且必须对它们执行相同的操作时,矩阵非常有用。它们提供了一种执行计算的简洁方法;例如,当你必须做旋转和其他变换时。但是正如数学向量不仅仅是一个数组,因为它有明确的向量代数规则(如向量加法或乘法),矩阵也是如此。我们来看看矩阵代数的规则。

矩阵代数

可以通过加减相应的元素来加减矩阵。这意味着您只能添加或减去相同大小的矩阵,例如 2 × 2 矩阵:

A978-1-4302-6338-8_15_Figb_HTML.jpg

矩阵加法可用于表示平移或位移,其中物体的位置在空间中移动;例如,下面的代码分别沿 x 和 y 方向将一个点的位置移动 dx 和 dy。这可以用 2 × 1 矩阵表示,如下所示:

A978-1-4302-6338-8_15_Figc_HTML.jpg

矩阵乘法有点复杂。当你把两个矩阵 A 和 B 相乘时,你把 A 的每一行乘以 B 的每一列,一个元素接一个元素,然后把结果相加。下面是一个 2 × 2 矩阵的例子:

A978-1-4302-6338-8_15_Figd_HTML.jpg

这意味着,如果矩阵 A 的列数与 B 的行数相同,则只能将矩阵 A 乘以矩阵 B。换句话说,一个 M × N 矩阵只能乘以一个 N × P 矩阵。

注意,将第 m 乘以第 n 得到乘积中的(m,n)矩阵元素。同样,M × N 矩阵乘以 N × P 矩阵的结果是 M × P 矩阵。

矩阵乘法可以以各种方式变换对象,这取决于我们要乘的矩阵的形式。我们对旋转矩阵特别感兴趣。

2D 的旋转矩阵

矩阵在做旋转时特别有用。为了看到这一点,假设你有一个物体,你想围绕一个垂直于 xy 平面的轴旋转一个角度θ(见图 15-1 )。让我们使用数学坐标系,这样 y 轴指向上,角度以逆时针方向测量。

A978-1-4302-6338-8_15_Fig1_HTML.jpg

图 15-1。

Rotating an object in 2D

使用三角学,您可以显示坐标为(x,y)的对象上的每个点将被移动到坐标为(x ',y ')的新位置,由以下等式给出:

A978-1-4302-6338-8_15_Fige_HTML.jpg

顺便说一下,这是我们在第十三章中介绍的Vector2D对象的rotate()方法背后的数学。您可以用一个矩阵方程来表示新旧坐标之间的关系:

A978-1-4302-6338-8_15_Figf_HTML.jpg

我们在这里所做的是将点(x,y)和(x’,y’)的每个位置向量 r 和 r’表示为一个具有单列的矩阵(称为列矩阵或列向量)。如果您将右侧的矩阵相乘,并使所得列向量的元素与等式左侧的元素相等,您将恢复前两个等式。

我们可以用方便的简写形式写出这个矩阵方程如下:

A978-1-4302-6338-8_15_Figg_HTML.jpg

矩阵 R 被称为旋转矩阵,由下式给出:

A978-1-4302-6338-8_15_Figh_HTML.jpg

除了旋转对象,您还可以旋转坐标系(轴):

A978-1-4302-6338-8_15_Figi_HTML.jpg

这里的情况是,将坐标轴旋转θ角,与将物体旋转-θ角是一样的。因此,如果将原始矩阵中的θ替换为–θ,并利用 sin(–θ)=–sin(θ),cos(–θ)= cos(θ这一事实,就可以得到第二个矩阵。因此,在物体和坐标系旋转之间交换的规则是“反转正弦的符号”

三维旋转矩阵

在 3D 中,情况很复杂,因为您有三个组件和三个独立的旋转方向。旋转矩阵是一个 3 × 3 矩阵,对于绕 z 轴的旋转,它由下式给出:

A978-1-4302-6338-8_15_Figj_HTML.jpg

绕 x 轴的旋转矩阵由下式给出:

A978-1-4302-6338-8_15_Figk_HTML.jpg

绕 y 轴的旋转由以下矩阵给出:

A978-1-4302-6338-8_15_Figl_HTML.jpg

与 2D 的情况一样,如果坐标轴旋转,正弦的符号就会颠倒。

旋转矩阵可以通过相乘来组合。因此,矩阵 R 1 的旋转后跟矩阵 R 2 的旋转等价于矩阵 R 2 R 1 的旋转。请注意,旋转的顺序在 3D 中很重要。例如,将长方体绕 x 轴顺时针旋转 90 度,然后绕 z 轴顺时针旋转 90 度,将使长方体以不同的方向结束,而不是先绕 z 轴旋转,然后再绕 x 轴旋转。用书试试吧!数学上,这对应于矩阵乘法不可交换的事实。换句话说:

A978-1-4302-6338-8_15_Figm_HTML.jpg

另一方面,非常小的增量(无穷小)旋转是可交换的,所以关于不同轴的无穷小旋转以什么顺序进行并不重要。这对于我们如何在 3D 中表示角度方向和角速度有着重要的意义。绕 x 轴、y 轴和 z 轴旋转的不可交换性意味着,作为一个具有三个分量的矢量,角度方向没有明确的意义。所以你不能从 3D 的角度旋转θ形成一个真正的矢量。然而,对于小的角度增量δθ,可以形成一个矢量,因此角速度ω = d θ/dt。

如前面的讨论所示,旋转矩阵是非常有用的数学对象,可以用代码实现,以允许对象在 3D 中旋转。然而,在实践中,从计算的角度来看,处理矩阵运算通常不是很有效。不赘述,只说一个 3 × 3 的矩阵有 9 个分量就够了,乘法等矩阵运算涉及大量计算,有些是冗余的。幸运的是,有一种更有效的方法来执行 3D 旋转——使用更奇特的数学对象,称为四元数。

四元数

处理 3D 旋转最方便的方法是使用四元数,它属于一个抽象的数字系统。这些相当神秘的数学实体并不经常出现在学院或大学的数学课程中——所以你可能以前没有遇到过它们。因此,让我们花一点时间非正式地介绍一下四元数。

四元数简介

四元数是由一位名叫威廉·哈密顿的爱尔兰数学家在十九世纪发明的,是所谓复数(或虚数)的延伸。正如你可能想象的那样,这使得它们成为相当抽象的构造。就我们的目的而言,将四元数想象成一个标量和一个矢量的组合会更有用(关于标量和矢量的复习见第三章)。我们可以把四元数象征性地写成

A978-1-4302-6338-8_15_Fign_HTML.jpg

其中 s 是标量部分,v 是矢量部分。我们也可以显式地写出矢量分量,就像这样:

A978-1-4302-6338-8_15_Figo_HTML.jpg

还有其他的符号,但这可能是最简单的。在这种符号中,很容易把四元数想象成一个有四个分量的向量。对于许多目的来说,这是很有帮助的,只要你记住操作和组合四元数(即四元数代数)的规则与向量的规则有些不同。现在我们来看看四元数代数。

四元数代数

处理 3D 旋转需要知道的操作是两个四元数的加法,一个四元数乘以一个标量,以及两个四元数的乘法。此外,您还需要了解四元数的大小、四元数的共轭和逆以及恒等四元数。

要添加或减去两个四元数,只需添加或减去它们各自的分量:

A978-1-4302-6338-8_15_Figp_HTML.jpg

要将四元数乘以或除以标量,可以将四元数的每个分量乘以或除以标量:

A978-1-4302-6338-8_15_Figq_HTML.jpg

前面的规则类似于组合向量的规则。将一个四元数乘以另一个四元数要复杂得多,可以结合使用点积和叉积来完成。用下面的符号来写最方便:

A978-1-4302-6338-8_15_Figr_HTML.jpg

请注意,四元数乘法是不可交换的,乘法的顺序很重要。

恒等四元数是一种特殊的四元数,标量部分等于 1,矢量部分等于 0:1 =(1,0,0,0)。之所以这么叫,是因为任何一个四元数乘以恒等四元数都保持不变(我们把证明留给练习!).

四元数 q = (s,v 1 ,v 2 ,v 3 )的大小或范数的定义方式与向量类似:

A978-1-4302-6338-8_15_Figs_HTML.jpg

将一个四元数除以其大小,得到一个单位大小的四元数,也称为单位四元数。这个过程称为规范化。

对于任意四元数 q = (s,v 1 ,v 2 ,v 3 )存在一个由 q* = (s,–v1,–v2,–v3定义的共轭四元数。很容易证明,四元数和它的共轭的乘积给出了一个四元数,其矢量部分为零,标量部分等于四元数的范数平方(同样,我们把这作为练习):

A978-1-4302-6338-8_15_Figt_HTML.jpg

四元数 q 的逆 q–1等于其共轭除以其范数的平方:

A978-1-4302-6338-8_15_Figu_HTML.jpg

四元数与其倒数相乘得到单位四元数:

A978-1-4302-6338-8_15_Figv_HTML.jpg

四元数和旋转

像矩阵一样,四元数可以用于旋转矢量。在上一节中,我们看到旋转矩阵 R 根据公式 r' = R r 将向量 R 转换为向量 R ',也就是说,我们只需将向量与旋转矩阵相乘,就可以得到旋转后的向量。用四元数 q 旋转向量 r 的等价公式稍微复杂一些:

A978-1-4302-6338-8_15_Figw_HTML.jpg

这里 r 是一个四元数,标量部分为零,矢量部分等于 r: r = (0,r)。

如果 q 是单位四元数,那么它与下面的等式相同:

A978-1-4302-6338-8_15_Figx_HTML.jpg

在这种情况下,旋转四元数总是可以写成以下形式,以表示围绕由单位向量 u 表示的轴转过角度θ的旋转:

A978-1-4302-6338-8_15_Figy_HTML.jpg

像旋转矩阵一样,两个旋转四元数 q 1 和 q 2 可以通过将它们相乘(或合成)来组合,以给出表示组合旋转的四元数 q ’:

A978-1-4302-6338-8_15_Figz_HTML.jpg

这表示由 q 1 表示的旋转,随后是由 q 2 表示的旋转。回想一下,四元数乘法是不可交换的,所以乘积的阶数会给出 3D 空间中不同的旋转。

三维旋转动力学

你需要的最后一个背景理论是关于如何在 3D 中实现刚体动力学。要做到这一点,我们首先需要确定一些关键概念,如角度方向、角速度、扭矩等如何推广到 3D。

3D 方向

在 2D 中,物体的方向可以用 x-y 平面上的角度直接描述,这可以解释为绕垂直于该平面的虚拟 z 轴的旋转。通过将方向指定为绕 x、y 和 z 轴旋转的组合,很容易将这种方法推广到 3D。这些角度被称为欧拉角。不幸的是,这种方法存在严重的问题。首先,正如在“3D 中的旋转矩阵”一节中所讨论的,以这种方式定义的对象的方向将取决于围绕三个轴旋转的顺序。这很难令人满意。第二,某些配置可能导致可怕的“万向节锁”问题,从而不可能围绕其中一个轴旋转(在网上搜索“万向节锁”以了解更多详细信息);这被证明是阿波罗 11 号任务中的一个问题。

更好的解决方案是使用旋转矩阵来表征方向——因为旋转矩阵告诉我们如何相对于一组固定的轴旋转对象,所以也可以认为它保存了旋转对象的方向信息。然后,问题变成计算旋转矩阵随时间的演变。这当然是可行的,也是制作旋转动画的常用方法。但是它有一个小小的缺点,那就是必须处理矩阵代数,这通常在计算上效率不高。

我们的首选方法是使用单位四元数来表示方向。这是可能的,原因与旋转矩阵可以用来表示方向相同。为了说明这是如何工作的,假设刚体的初始方向由单位四元数 q 描述。然后,我们使刚体进行由单位四元数 p 描述的旋转。然后,刚体的新方向 q '可通过简单地将 q 乘以 p 获得:

A978-1-4302-6338-8_15_Figaa_HTML.jpg

我们在一个物理动画中面临的问题是:我们如何在每个时间步找到需要乘以 q 的四元数 p?在离散形式中,我们寻找 p 使得:

A978-1-4302-6338-8_15_Figbb_HTML.jpg

是时候回到角速度了。

角速度和方向

在“3D 中的旋转矩阵”一节中,我们指出一个无穷小的角位移是一个矢量,因此角速度ω = d θ/dt 也是一个矢量。事实证明,有一个特别简单的演化方程,将方向四元数与角速度联系起来。在附着于旋转物体的坐标框架(所谓的体框架)中,可以写成:

A978-1-4302-6338-8_15_Figcc_HTML.jpg

这里ω是角速度四元数,通过设置四元数的矢量部分等于角速度矢量,标量部分为零来构造。这个方程告诉我们一个物体的方位四元数 q 是如何随时间变化的:它的变化率等于 q 和角速度ω乘积的一半。

要在代码中应用这个等式,我们首先必须将其离散化。使用欧拉方法,我们可以写出:

A978-1-4302-6338-8_15_Figdd_HTML.jpg

重新排列,我们可以把这个等式写成:

A978-1-4302-6338-8_15_Figee_HTML.jpg

该等式将新时间步长 q(n+1)处的方向四元数与旧时间步长 q(n)处的方向四元数相关联。方括号中的量就是我们要找的 p(n)的表达式。这是旋转四元数,我们需要在每个时间步长乘以 q 来更新方向。注意,方括号中的第一个 1 表示单位四元数。

一个关键要求是 q 必须始终是单位四元数;否则,随着动画的进行,对象将被缩放或扭曲。因为不可避免地会有数值误差,这些误差会随着时间的推移而累积,并导致 q 的范数偏离 1,所以明智的做法是频繁地归一化 q,可能在每个时间步长进行归一化。

扭矩、角加速度和惯性矩矩阵

就像角速度一样,角加速度,α = d ω/dt,在 3D 中也是矢量。如果有合成力矩 T 作用在一个刚体上,它将受到角加速度。在 3D 中,运动的角度方程可以在主体框架中写成以下矩阵方程:

A978-1-4302-6338-8_15_Figff_HTML.jpg

回想一下,扭矩是由旋转中心的力作用点的位置矢量与力矢量的叉积给出的:T = r × F,可以用数值方法求解该方程,得到每个时间步长的角速度。额外的复杂性在于,在 3D 中,转动惯量 I 是一个 3 × 3 的矩阵。在 I 是对角线且对角线元素都相等的特殊情况下,Ixx= IYY= IZZ= I,前面的等式简化为 T = I α,类似于第十三章中的 2D 情况。

3D 渲染:介绍 WebGL 和 three.js

在这篇相当冗长的 3D 数学和物理介绍之后,你无疑已经迫不及待地想看到它全部投入使用了。但是如何在浏览器中制作 3D 图形和动画呢?是时候介绍一下 WebGL 和three.js了。

Canvas、WebGL 和 WebGL 框架

HTML5 canvas 元素是一个固有的 2D 环境。那么我们如何扩展它的 3D 渲染能力呢?可以通过应用透视变形在 2D 画布上模拟 3D,这有时被称为 2.5D。这是 Billy Lamberta 和 Keith Peters 在 book Foundation HTML5 动画和 JavaScript 中采用的方法。然而,这是一个相当费力的过程,并且存在性能问题。幸运的是,现在有了一个更简单、更强大的替代方案——web GL。

WebGL:将画布扩展到 3D

WebGL 起源于 Mozilla 为 canvas 元素添加 3D 功能的尝试。它本质上是一个 JavaScript API,用于在 web 浏览器中渲染 2D 和 3D 图形。WebGL 基于 OpenGL ES 2.0,并允许在兼容的浏览器中进行 GPU(图形处理单元)加速。在撰写本文时,大多数主流 web 浏览器都支持 WebGL。要检查您的浏览器或设备是否支持 WebGL,您可以访问以下网站之一:

http://webglreport.com/

http://get.webgl.org/

WebGL 程序由 JavaScript 控制代码和在计算机的 GPU 上执行的着色器代码组成(着色器是一种为深度感知和视觉效果执行 3D 模型着色的程序)。用纯 WebGL 编码可能是一个相当费力的过程,超出了本书的范围。相反,我们将利用一个框架来避免编写原始着色器代码的需要。

WebGL 框架

到目前为止,我们在不需要使用外部库或框架的情况下做得非常好。但是我们将为 3D 破例,因为我们并不特别热衷于花时间使用低级 WebGL 命令!有许多基于 WebGL 构建的 JavaScript 框架和库可以简化 3D 编码过程。Three.jsbabylon.js只是其中比较受欢迎的两个。在本书中,我们将使用three.js库。

快速三. js 入门

本节的目的是介绍使用three.js开始运行时您需要了解的最基本知识。如果您想获得更深入的知识,我们鼓励您查阅three.js上的许多书籍和在线资源。当然,首先要去的地方是位于 http://three.org 的官方项目网页,在那里你可以找到文档和例子,并且可以下载最新版本的库。为了方便起见,我们在本章的可下载源代码中包含了一个包含完整库的three.min.js文件的副本。不用说,在本章的所有例子中,你必须将这个文件包含在你的 HTML 文件的一个<script>标签中。

渲染器

首先你需要选择一个渲染器。您可以选择渲染器,包括使用 GPU 的CanvasRenderer和性能更好的WebGLRenderer。您可以用下面几行代码创建一个WebGLRenderer并将其添加到 DOM 中:

var renderer = new THREE.WebGLRenderer();

renderer.setSize(window.innerWidth, window.innerHeight);

document.body.appendChild(renderer.domElement);

最后一行创建了一个显示场景的 canvas 元素。

现场

场景设置了一个可以放置视觉元素、摄像机、灯光等的区域。创造一个场景再简单不过了:

var scene = new THREE.Scene();

照相机

接下来你需要一台相机。有多种不同的摄像机可供选择,我们建议您参考文档以了解详细信息。这里我们只需选择一个PerspectiveCamera,设置它的位置,并将其添加到场景中,如下所示:

var camera = new THREE.PerspectiveCamera(45, window.innerWidth/window.innerHeight, 0.1, 10000);

camera.position.set(100,0,400);

scene.add(camera);

您需要在PerspectiveCamera : fov(视野)、aspectnearfar中指定四个参数。这里的视野设置为 45 度,长宽比是浏览器窗口的宽度和高度之比。nearfar参数分别是摄像机渲染对象的最近距离和最远距离(这里设置为 0.1 和 10000)。

前面清单中的第二行代码使用(x,y,z)坐标设置摄像机的位置。最后一行将摄像机添加到场景中。

照明设备

你需要一盏灯来看场景中的物体。有几种不同的选择(参见文档),并且可以使用多种灯光。让我们挑一个DirectionalLight:

var light = new THREE.DirectionalLight();

light.position.set(-10,0,20);

scene.add(light);

对象:网格、几何体和材质

接下来,您需要用一个或多个对象填充场景。在three.js中,创建一个带有Mesh的对象,它带有两个参数GeometryMaterial。同样,根据你想要创建的物体的形状和外观,有许多GeometryMaterial的选择。让我们创建一个球体,给它一个红色的MeshLambertMaterial:

var sphereGeometry = new THREE.SphereGeometry(50,20,20);

var sphereMaterial = new THREE.MeshLambertMaterial({color: 0xff0000});

var ball = new THREE.Mesh(sphereGeometry,sphereMaterial);

ball.position.set(100,0,0);

scene.add(ball);

SphereGeometry有三个参数:半径、水平线段数(类似于纬度线)和垂直线段数(类似于经度线)。

3D 坐标系统

在 WebGL 和three.js中,坐标系如图 15-2 所示。x 轴从左向右,y 轴从下向上,z 轴指向屏幕外。原点位于显示区域的中心。原点的位置和 y 轴的方向不同于 2D 画布坐标系。在您的代码中必须考虑到这些差异。

A978-1-4302-6338-8_15_Fig2_HTML.jpg

图 15-2。

The 3D coordinate system

three.js 动画

使用three.js制作 3D 动画和在 2D 制作基本相同。你首先使用第二章中描述的任何一种方法创建一个动画循环。在动画循环中,您可以在每个时间步长更新对象的位置和/或方向。

你已经看到了如何使用obj.position.set()方法设置一个对象的位置。您也可以单独设置或更新单个坐标,如下例所示:

obj.position.x = 100;

three.js中,对象的方向可以由 r otation属性指定,rotation属性是具有三个分量 x、y 和 z 的欧拉角表示。例如:

obj.rotation.y = Math.PI/2;

也可以使用对象的四元数属性来指定对象的方向。您将在“模拟 3D 刚体运动”一节中看到一个示例

模拟三维粒子运动

好了,在所有这些背景材料之后,终于到了一些代码示例的时候了。让我们从我们的“hello world”物理模拟开始——一个弹跳球!

三维弹跳球模拟

因为这是我们的第一个 3D 示例,所以查看完整的代码是很有用的。代码在bouncing-ball.js中,在此列出:

var width = window.innerWidth, height = window.innerHeight;

var scene, camera, renderer;

var ball;

var t0, dt;

var g = -20;

var fac = 0.9;

var radius = 20;

var x0 = -100, y0 = -100, z0 = -100;

window.onload = init;

function init() {

renderer = new THREE.WebGLRenderer();

renderer.setSize(width, height);

document.body.appendChild(renderer.domElement);

scene = new THREE.Scene();

var angle = 45, aspect = width/height, near = 0.1, far = 10000;

camera = new THREE.PerspectiveCamera(angle, aspect, near, far);

camera.position.set(100,0,400);

scene.add(camera);

var light = new THREE.DirectionalLight();

light.position.set(-10,0,20);

scene.add(light);

var sphereGeometry = new THREE.SphereGeometry(radius,20,20);

var sphereMaterial = new THREE.MeshLambertMaterial({color: 0x006666});

ball = new THREE.Mesh(sphereGeometry,sphereMaterial);

scene.add(ball);

ball.pos = new Vector3D(100,0,0);

ball.velo = new Vector3D(-20,0,-20);

positionObject(ball);

var plane1 = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), new THREE.MeshNormalMaterial());

plane1.rotation.x = -Math.PI/2;

plane1.position.set(0,y0,0);

scene.add(plane1);

var plane2 = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), new THREE.MeshNormalMaterial());

plane2.position.set(0,0,z0);

scene.add(plane2);

var plane3 = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), new THREE.MeshNormalMaterial());

plane3.rotation.y = Math.PI/2;

plane3.position.set(x0,0,0);

scene.add(plane3);

t0 = new Date().getTime();

animFrame();

}

function animFrame(){

requestAnimationFrame(animFrame);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

moveObject(ball);

}

function positionObject(obj){

obj.position.set(obj.pos.x,obj.pos.y,obj.pos.z);

}

function moveObject(obj){

obj.velo.y += g*dt;

obj.pos = obj.pos.addScaled(obj.velo,dt);

if (obj.pos.x < x0 + radius){

obj.pos.x = x0 + radius;

obj.velo.x *= -fac;

}

if (obj.pos.y < y0 + radius){

obj.pos.y = y0 + radius;

obj.velo.y *= -fac;

}

if (obj.pos.z < z0 + radius){

obj.pos.z = z0 + radius;

obj.velo.z *= -fac;

}

positionObject(obj);

renderer.render(scene, camera);

}

我们首先将浏览器窗口的宽度和高度存储在变量widthheight中,我们将在几个地方使用它们。然后我们为scenecamerarenderer以及一个ball对象创建变量。球的半径存储在变量radius中,我们会在很多地方用到它。请注意,代表重力加速度的变量g被赋予了负值。原因是 WebGL 的垂直(y)轴指向上方而不是下方。

init()方法中,我们设置了渲染器、场景、摄像机和灯光,如前一节所述。然后我们使用SphereGeometry创建一个球。接下来,我们为球对象创建新的属性posvelo,并赋予它们Vector3D值以保持其初始位置和速度。注意,您必须在 HTML 文件中包含vector3D.js文件。ball.posball.velo变量将用于执行物理计算。但是我们还需要告诉 WebGL 在每次更新这些变量时设置球在画布上的位置。这是通过调用positionObject()方法完成的,该方法使用pos值更新球的位置属性:

function positionObject(obj){

obj.position.set(obj.pos.x,obj.pos.y,obj.pos.z);

}

接下来在init()中,我们使用PlaneGeometry对象创建三面墙,并使用实例的rotationposition属性适当地定向和定位它们。

动画代码看起来非常类似于你已经用来创建 2D 画布模拟。新的代码片段在moveObject()方法中。在前两行中,我们更新了球的垂直速度和位置向量。然后我们用和第一章的第一个例子相似的方法检查与三面墙的碰撞。然后我们调用positionObject()在渲染场景之前更新球的position属性。

运行代码,您应该会看到类似于图 15-3 中的截图。和以往一样,您可以通过更改参数和视觉元素来随意进行实验。

A978-1-4302-6338-8_15_Fig3_HTML.jpg

图 15-3。

Bouncing ball in 3D

旋转地球动画

使用three.js可以轻松创建旋转动画。为了说明如何做到这一点,让我们制作一个旋转的地球动画。这个例子的代码实际上比前一个例子简单,您可以在文件earth.js中找到它。以下是完整列表:

var width = window.innerWidth, height = window.innerHeight;

var renderer = new THREE.WebGLRenderer();

renderer.setSize(width, height);

document.body.appendChild(renderer.domElement);

var scene = new THREE.Scene();

var angle = 45, aspect = width/height, near = 0.1, far = 10000;

var camera = new THREE.PerspectiveCamera(angle, aspect, near, far);

camera.position.set(100,0,500);

scene.add(camera);

var light = new THREE.DirectionalLight();

light.position.set(-10,0,20);

scene.add(light);

var radius = 100, segments = 20, rings = 20;

var sphereGeometry = new THREE.SphereGeometry(radius,segments,rings);

var sphereMaterial = new THREE.MeshLambertMaterial();

sphereMaterial.map = THREE.ImageUtils.loadTexture('img/earth.jpg');

var sphere = new THREE.Mesh(sphereGeometry,sphereMaterial);

scene.add(sphere);

function animFrame(){

requestAnimationFrame(animFrame);

onEachStep();

}

function onEachStep() {

sphere.rotation.y += 0.01;

camera.position.z -= 0.1;

renderer.render(scene, camera);

}

animFrame();

正如你所看到的,我们已经大大简化了动画代码。除此之外,新颖之处仅在于三行代码,以粗体显示。第一行加载了一个图像,它用作创建的SphereGeometry对象的纹理贴图,给出了一个 3D 地球的外观,如图 15-4 所示。请注意,如果您只是在 web 浏览器中打开 HTML 文件,您的代码可能无法正常工作,因为 JavaScript 中的安全特性会阻止外部托管的文件加载。如果发生这种情况,在 https://github.com/mrdoob/three.js/wiki/How-to-run-things-locally 的一个有用的three.js页面会解释如何规避这个问题。

A978-1-4302-6338-8_15_Fig4_HTML.jpg

图 15-4。

Rotating Earth in 3D

在函数onEachStep()中,前两行增加地球自转属性的 y 分量,减少相机位置的 z 分量。运行代码以查看结果。

力:重力和 3D 中的轨道

接下来,我们将添加一个月球和一些重力物理,使其绕地球运行!我们将需要精确的计时,所以将恢复我们在书中大多数例子中使用的完整动画循环。您还需要在 HTML 文件中添加forces3D.js文件。

结果代码在earth-moon.js中,在此复制:

var width = window.innerWidth, height = window.innerHeight;

var acc, force;

var t0, dt;

var animId;

var G = 1;

var M = 50000;

var m = 1;

var scene, camera, renderer;

var earth, moon;

window.onload = init;

function init() {

renderer = new THREE.WebGLRenderer();

renderer.setSize(width, height);

document.body.appendChild(renderer.domElement);

scene = new THREE.Scene();

var angle = 45, aspect = width/height, near = 0.1, far = 10000;

camera = new THREE.PerspectiveCamera(angle, aspect, near, far);

camera.position.set(0,100,1000);

scene.add(camera);

var light = new THREE.DirectionalLight();

light.position.set(-10,0,20);

scene.add(light);

var radius = 100, segments = 20, rings = 20;

var sphereGeometry = new THREE.SphereGeometry(radius,segments,rings);

var sphereMaterial = new THREE.MeshLambertMaterial({color: 0x0099ff});

sphereMaterial.map = THREE.ImageUtils.loadTexture('img/Earth.jpg');

earth = new THREE.Mesh(sphereGeometry,sphereMaterial);

scene.add(earth);

earth.mass = M;

earth.pos = new Vector3D(0,0,0);

earth.velo = new Vector3D(0,0,0);

positionObject(earth);

moon = new THREE.Mesh(new THREE.SphereGeometry(radius/4,segments,rings),new THREE.MeshLambertMaterial());

scene.add(moon);

moon.mass = m;

moon.pos = new Vector3D(300,0,0);

moon.velo = new Vector3D(0,0,-12);

positionObject(moon);

t0 = new Date().getTime();

animFrame();

}

function animFrame(){

requestAnimationFrame(animFrame);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

positionObject(moon);

moveObject(moon);

calcForce(moon);

updateAccel(moon);

updateVelo(moon);

}

function positionObject(obj){

obj.position.set(obj.pos.x,obj.pos.y,obj.pos.z);

}

function moveObject(obj){

obj.pos = obj.pos.addScaled(obj.velo,dt);

positionObject(obj);

earth.rotation.y += 0.001;

renderer.render(scene, camera);

}

function calcForce(obj){

var r = obj.pos.subtract(earth.pos);

force = Forces3D.gravity(G,M,m,r);

}

function updateAccel(obj){

acc = force.multiply(1/obj.mass);

}

function updateVelo(obj){

obj.velo = obj.velo.addScaled(acc,dt);

}

你应该可以毫无困难地理解这些代码。init()方法设置场景和 3D 对象,动画代码像 2D 的例子一样工作,根据需要用Vector3D变量替换Vector2D变量。

运行代码,你将看到一个视觉上吸引人的月球绕地球运行的 3D 模拟,如图 15-5 所示。您可以改变摄像机的位置,从不同的角度观察系统。例如,在init()中添加下面一行,从地球的位置观看月球:

camera.position = earth.position;

A978-1-4302-6338-8_15_Fig5_HTML.jpg

图 15-5。

Earth-Moon simulation

或者在moveObject()里加上下面一行,从月球上看地球:

camera.position = moon.position;

我们让您进一步开发代码并添加交互性,例如更改摄像机位置或添加缩放效果等等。

不用说,模拟不是按比例的,但它确实包含了基本的物理学。在第十六章中,你将建立一个精确且更加真实的太阳系模拟。

模拟三维刚体运动

我们现在准备模拟三维刚体运动。让我们选择一个简单的物体——一个旋转的立方体——它的惯性矩矩阵的对角线元素相等,非对角线元素为零。正如我们在“3D 旋转动力学”一节中看到的,扭矩和角加速度之间的关系简化为 T = I α,其中 I 是惯性矩矩阵的每个对角元素的值。

旋转立方体动画

作为起点,让我们回到第十三章的rigid-body-dynamics.js模拟,并做一些修改,用旋转立方体替换旋转正方形。修改后的代码在cube-rotation.js中,在此全文转载:

var width = window.innerWidth, height = window.innerHeight;

var t0, dt;

var cube;

var scene, camera, renderer;

var alp, torque;

var t0, dt;

var animId;

var k = 0.5; // angular damping factor

var tqMax = 2;

var tq = 0;

window.onload = init;

function init() {

renderer = new THREE.WebGLRenderer();

renderer.setSize(width, height);

document.body.appendChild(renderer.domElement);

scene = new THREE.Scene();

var angle = 45, aspect = width/height, near = 0.1, far = 10000;

camera = new THREE.PerspectiveCamera(angle, aspect, near, far);

camera.position.set(100,0,500);

scene.add(camera);

var light = new THREE.DirectionalLight();

light.position.set(30,0,30);

scene.add(light);

cube = new THREE.Mesh(new THREE.CubeGeometry(100, 100, 100), new THREE.MeshNormalMaterial());

cube.overdraw = true;

scene.add(cube);

cube.im = 1;

cube.angVelo = 0;

addEventListener('mousedown',onDown,false);

t0 = new Date().getTime();

animFrame();

}

function onDown(evt){

tq = tqMax;

addEventListener('mouseup',onUp,false);

}

function onUp(evt){

tq = 0;

removeEventListener('mouseup',onUp,false);

}

function animFrame(){

animId = requestAnimationFrame(animFrame);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

moveObject(cube);

calcForce(cube);

updateAccel(cube);

updateVelo(cube);

}

function moveObject(obj){

obj.rotation.x += obj.angVelo*dt;

renderer.render(scene, camera);

}

function calcForce(obj){

torque = tq;

torque += -k*obj.angVelo; // angular damping

}

function updateAccel(obj){

alp = torque/obj.im;

}

function updateVelo(obj){

obj.angVelo += alp*dt;

}

init()方法中,我们添加了新的代码,在three.js命令的帮助下设置场景和相关对象。然后我们用CubeGeometry创建一个立方体,并赋予它新的属性im(惯性矩)和angVelo(角速度),值分别设置为 1 和 0。代码的动画部分紧跟第十三章的代码中的动画部分。这里我们已经删除了一些与力相关的代码,因为我们只施加了一个扭矩而没有施加力。为了简单起见,转矩和角加速度都被视为标量,就像 2D 的情况一样。现在这很好,因为我们将围绕 x、y 或 z 轴旋转立方体。在代码清单中,我们有以下代码行,它使立方体围绕 x 轴旋转:

obj.rotation.x += obj.angVelo*dt;

您可以将这条线改为绕其他两个轴之一旋转。有一些事件侦听器和处理程序,当您单击鼠标时,它们会应用一个恒定的扭矩使立方体旋转,否则将扭矩设置为零。在 2D 的例子中,已经包括了一个角阻尼项。运行代码并单击鼠标进行模拟实验。截图如图 15-6 所示。

A978-1-4302-6338-8_15_Fig6_HTML.jpg

图 15-6。

A rotating cube in 3D

向旋转的立方体施加力

现在,让我们通过将扭矩和角加速度视为矢量而不是标量来进一步开发旋转立方体模拟。在这个例子中,我们将使用欧拉角来处理旋转。我们还将包含处理力的代码。为了补偿增加的代码长度,我们删除了事件处理程序和侦听器,从而失去了交互性。新代码(cube-euler-angles.js)现在看起来像这样:

var width = window.innerWidth, height = window.innerHeight;

var t0, dt;

var cube;

var scene, camera, renderer;

var acc, force;

var alp, torque;

var t0, dt;

var animId;

var k = 0.5;

var kSpring = 0.2;

var tq = new Vector3D(1,0,0);

var center = new Vector3D(0,0,-500);

window.onload = init;

function init() {

renderer = new THREE.WebGLRenderer();

renderer.setSize(width, height);

document.body.appendChild(renderer.domElement);

scene = new THREE.Scene();

var angle = 45, aspect = width/height, near = 0.1, far = 10000;

camera = new THREE.PerspectiveCamera(angle, aspect, near, far);

camera.position.set(100,0,500);

scene.add(camera);

var light = new THREE.DirectionalLight();

light.position.set(30,0,30);

scene.add(light);

cube = new THREE.Mesh(new THREE.CubeGeometry(100, 100, 100), new THREE.MeshNormalMaterial());

cube.overdraw = true;

scene.add(cube);

cube.mass = 1;

cube.im = new Vector3D(1,1,1);

cube.pos = new Vector3D(0,0,0);

cube.velo = new Vector3D(0,0,0);

cube.eulerAngles = new Vector3D(0,0,0);

cube.angVelo = new Vector3D(0,0,0);

t0 = new Date().getTime();

animFrame();

}

function animFrame(){

animId = requestAnimationFrame(animFrame);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

positionObject(cube);

moveObject(cube);

calcForce(cube);

updateAccel(cube);

updateVelo(cube);

}

function positionObject(obj){

obj.position.set(obj.pos.x,obj.pos.y,obj.pos.z);

obj.rotation.set(obj.eulerAngles.x,obj.eulerAngles.y,obj.eulerAngles.z);

}

function moveObject(obj){

obj.pos = obj.pos.addScaled(obj.velo,dt);

obj.eulerAngles = obj.eulerAngles.addScaled(obj.angVelo,dt);

positionObject(obj);

renderer.render(scene, camera);

}

function calcForce(obj){

var r = obj.pos.subtract(center);

force = Forces3D.spring(kSpring,r);

torque = tq;

torque = torque.addScaled(obj.angVelo,-k);

}

function updateAccel(obj){

acc = force.multiply(1/obj.mass);

alp = torque.div(obj.im);

}

function updateVelo(obj){

obj.velo = obj.velo.addScaled(acc,dt);

obj.angVelo = obj.angVelo.addScaled(alp,dt);

}

该代码中有许多新功能。首先,我们使用Forces3D.spring()方法在calcForce()中包含一个弹簧力。扭矩现在是一个矢量,并在整个模拟过程中被赋予一个恒定的Vector3D值,该值在变量tq中设置。在init()中,我们创建并初始化立方体的各种物理属性:质量属性、惯性矩向量(而不是矩阵,假设矩阵是对角的)、位置和速度向量、角速度向量以及存储描述立方体方向的欧拉角的向量。

updateAccel()中,我们通过将扭矩的每个分量除以相应的惯性矩分量来计算角加速度;这是一种数学上的简化,只有当惯性矩矩阵是对角线时才有可能,就像在这种情况下一样。我们使用新创建的Vector3Ddiv()方法来执行这种划分。div()方法允许你通过划分一个向量和另一个向量各自的分量来划分它们(不是标准的数学运算!):

div: function(vec) {

return new Vector3D(this.x/vec.x,this.y/vec.y,this.z/vec.z);

}

最后,我们对待eulerAngles变量就像对待moveObject()positionObject()中的位置变量pos一样。这对于绕 x 轴、y 轴或 z 轴的旋转是没问题的,但对于你将要看到的任意斜轴就不一样了。

如果您照原样运行代码,您将看到一个立方体绕着它的 x 轴旋转,同时沿着 z 轴振荡。您可以简单地通过改变Vector3D变量tqcenter的值来改变旋转轴和摆动中心。先将tq的值改为Vector3D(0,1,0),再改为Vector3D(0,0,1);这将分别在 y 和 z 方向施加一个扭矩,并使立方体绕各自的轴旋转。现在试着通过将tq的值改为Vector3D(1,1,1)来使立方体绕一个斜轴旋转。这沿着一条对角线轴施加了一个扭矩,从物理上来说,应该使立方体围绕该轴旋转。但是如果你运行代码,你会发现立方体的行为完全不同。我们对欧拉角的简单尝试在处理斜轴旋转时有困难。让我们看看如何用四元数来处理这个问题。

绕任意轴旋转立方体

我们这里的重点是如何处理绕任意轴的旋转。因此,让我们去掉之前处理力的代码,这样我们就可以专注于处理旋转的部分。此外,我们没有使用我们创建的eulerAngles属性,而是使用了quaternion属性,这是一个内置的three.js属性,它将对象的方向存储为一个四元数。新代码名为cube-rotation-quaternion.js,如下所示:

var width = window.innerWidth, height = window.innerHeight;

var t0, dt;

var cube;

var scene, camera, renderer;

var alp, torque;

var t0, dt;

var animId;

var k = 0.5;

var tqMax = new Vector3D(1,1,1);

var tq = new Vector3D(0,0,0);

window.onload = init;

function init() {

renderer = new THREE.WebGLRenderer();

renderer.setSize(width, height);

document.body.appendChild(renderer.domElement);

scene = new THREE.Scene();

var angle = 45, aspect = width/height, near = 0.1, far = 10000;

camera = new THREE.PerspectiveCamera(angle, aspect, near, far);

camera.position.set(100,0,500);

scene.add(camera);

var light = new THREE.DirectionalLight();

light.position.set(30,0,30);

scene.add(light);

cube = new THREE.Mesh(new THREE.CubeGeometry(100, 100, 100), new THREE.MeshNormalMaterial());

cube.overdraw = true;

scene.add(cube);

cube.im = new Vector3D(1,1,1);

cube.angVelo = new Vector3D(0,0,0);

addEventListener('mousedown',onDown,false);

t0 = new Date().getTime();

animFrame();

}

function onDown(evt){

tq = tqMax;

addEventListener('mouseup',onUp,false);

}

function onUp(evt){

tq = new Vector3D(0,0,0);

removeEventListener('mouseup',onUp,false);

}

function animFrame(){

animId = requestAnimationFrame(animFrame);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

moveObject(cube);

calcForce(cube);

updateAccel(cube);

updateVelo(cube);

}

function moveObject(obj){

var p = new THREE.Quaternion;

p.set(obj.angVelo.x*dt/2,obj.angVelo.y*dt/2,obj.angVelo.z*dt/2,1);

obj.quaternion.multiply(p);

obj.quaternion.normalize();

renderer.render(scene,camera);

}

function calcForce(obj){

torque = tq;

torque = torque.addScaled(obj.angVelo,-k);

}

function updateAccel(obj){

alp = torque.div(obj.im);

}

function updateVelo(obj){

obj.angVelo = obj.angVelo.addScaled(alp,dt);

}

请注意,我们重新引入了事件侦听器和处理程序,因此只有在单击鼠标时才会施加扭矩。默认情况下,施加的扭矩值为Vector3D(1,1,1);所以它是沿着对角线轴应用的。关键的新代码是moveObject()中的前四行;这就是实现我们在“3D 中的旋转动力学”一节中讨论的四元数旋转技术所需的全部内容第一行只是通过实例化THREE.Quaternion对象来创建一个新的四元数 p。下一行使用Quaternionset()方法给 p 一个值,该值等于出现在以下等式的方括号中的四元数:

A978-1-4302-6338-8_15_Figgg_HTML.jpg

回想一下,在这个等式中,1 表示单位四元数,标量分量为 1,矢量分量为 0;同样,ω是角速度的四元数形式,标量分量等于 0,矢量分量等于角速度矢量。moveObject()中的第三行代码使用Quaternionmultiply()方法将对象的方向四元数(上一个等式中的obj.quaternion,q)乘以 p 并更新。最后一行将对象的方向四元数归一化,以避免由于数值舍入误差导致的失真。

运行代码,你会发现立方体表现完美,围绕对角线轴旋转。通过改变所施加的扭矩值tqMax进行试验,使其绕任意轴旋转。

摘要

干得好,这本书写了这么多!你现在知道如何创建 3D 物理模拟了——原则上没有什么能阻止你将书中的任何 2D 例子转换成 3D。在最后一章,你将应用你在这里学到的知识创建两个 3D 模拟——一个飞行模拟器和一个太阳系模拟。

十六、模拟项目

这最后一章由三个模拟项目组成,它们将说明你在本书中学到的物理和方法如何应用于不同类型的应用。这些例子彼此大相径庭,不仅反映了基础物理的不同,也反映了它们建造的目的的不同。

这三个例子包括:

  • 建造一艘潜艇:这里的目的是展示你可以多么容易地修改一些你已经遇到的现有代码来创建一艘你可以合并到 2D 游戏中的潜艇。
  • 构建一个飞行模拟器:在这个例子中,我们将创建一个 3D 飞行模拟器,可以用许多有趣的方式进一步开发,纯粹是为了好玩!
  • 创建一个精确的太阳系模型:这个项目是创建一个精确的太阳系三维模型,可以作为一个电子学习工具。然后,我们将把模拟结果与美国国家航空航天局模拟产生的数据进行比较。

这个想法是,这些是你的项目,所以我们将描述相关的物理,并帮助你建立一个基本的版本,但然后让你以任何你可能想要的方式开发和增强它们。玩得开心!

建造潜艇

在第七章中,我们有一个简单而有趣的例子,一个球漂浮在水中。修改这个例子并把它变成一个可以在简单的 2D 游戏中使用的交互式潜水艇实际上是很容易的。我们先快速回顾一下涉及的主要物理。

物理学的简要回顾

在浮球的例子中,我们确定了作用在球上的三个主要力:重力、上推力和阻力,它们分别由以下公式给出:

A978-1-4302-6338-8_16_Figa_HTML.jpg

A978-1-4302-6338-8_16_Figb_HTML.jpg

A978-1-4302-6338-8_16_Figc_HTML.jpg

请注意,我们已经以矢量形式编写了上推力公式:需要负号,因为上推力向上作用,与重力矢量 g 相反。提醒一下,在该公式中,ρ是水的密度,V 是物体排开的水的体积。

对于潜艇,你有第四种力量:推力 T 推动潜艇,让它在水中水平移动。当通电时,这将被模拟为恒定大小的水平力。

另一个相关的物理学原理(如第七章所讨论的)是,如果一个物体的密度(单位体积的质量)小于或等于水的密度,它就会漂浮,否则它就会下沉。潜水艇的工作原理是,它的有效密度可以通过向压载舱充入海水或压缩空气来改变:它的密度(质量/体积比)是变化的,因为它的质量因此而变化,而它的体积保持不变。

视觉设置

视觉背景非常简单,由一个用渐变填充表示天空的矩形和另一个表示大海的矩形组成,这两个矩形都是使用绘图 API 创建的。仿真截图如图 16-1 所示。屏幕上还有一些文字显示潜艇的密度与水的密度之比。

A978-1-4302-6338-8_16_Fig1_HTML.jpg

图 16-1。

Screenshot of the submarine simulation

使用绘图 API 将潜艇创建为 JavaScript 对象Sub——代码在文件sub.js中,在 www.apress.com 中可以找到所有源代码和相关文件。图 16-2 仔细看看。潜艇内的蓝色矩形代表压载舱内的水。通过控制它的高度,我们创造了改变水位的假象,潜艇的质量也随之调整。

A978-1-4302-6338-8_16_Fig2_HTML.jpg

图 16-2。

A closer look at the submarine

设置代码

设置代码在仿真文件submarine.jsinit()方法中。这里列出了init()中的代码:

function init() {

// create the sky

gradient = context_bg.createLinearGradient(0,0,0,yLevel);

gradient.addColorStop(0,'#0066ff');

gradient.addColorStop(1,'#ffffff');

context_bg.fillStyle = gradient;

context_bg.fillRect(0,0,canvas_bg.width,yLevel);

// create the sea

context_fg.fillStyle = "rgba(0,255,255,0.5)";

context_fg.fillRect(0,yLevel,canvas.width,canvas.height);

// create a sub

sub = new Sub(160,60,'#0000ff',emptySubMass);

sub.pos2D = new Vector2D(250,300);

sub.velo2D = new Vector2D(40,-20);

sub.draw(context);

// set water height in sub

setWaterHeight();

// set up text formatting

setupText();

// set up event listener

window.addEventListener('keydown',keydownListener,false);

// initialise time and animate

initAnim();

}

注意,我们使用了三个 canvas 实例(参见相关 HTML 文件中的标记,submarine.html)。天空绘制在背景画布上,大海绘制在前景画布上。sub 被放置在中间的画布实例上。自然,最后两个画布元素是透明的(参见相关的样式文件,style7.css)。sub 中的水位在setWaterHeight()方法中设置,文本格式在setupText()方法中设置。然后设置一个keydown事件监听器,最后调用初始化动画代码的initAnim()方法。

创建的Sub对象有四个参数,分别指定其宽度、高度、颜色和质量。除了这些属性之外,它还有一个tankWidth和一个tankHeight属性,默认情况下,这两个属性分别被设置为其宽度和高度的一半。它还有一个waterHeight属性,默认值为 0。它具有位置(xy)和速度(vxvy)属性,以及相关的pos2Dvelo2D获取器和设置器。最后,它有一个draw()方法,绘制潜艇的身体、坦克和水位。参见sub.js中的代码。

基本运动代码

剩下的代码是我们在《??》第七章中创建的浮动模拟floating-ball.js的修改。主要的变化与视觉效果和控制有关(它们将在下一节描述)。在calcForce()方法中实现的基本运动代码实际上与floating-ball.js中的代码没有太大不同。下面是calcForce()的方法:

function calcForce(){

var rheight = 0.5*sub.height;

var ysub = sub.y + rheight;

var dr = (ysub-yLevel)/rheight;

var ratio; // volume fraction of object that is submerged

if (dr <= -1){ // object completely out of water

ratio = 0;

}else if (dr < 1){ // object partially in water

ratio = 0.5+0.5*dr; // for cuboid

}else{ // object completely in water

ratio = 1;

}

var gravity = Forces.constantGravity(sub.mass,g);

var upthrust = Forces.upthrust(rho,V*ratio,g);

var drag = Forces.drag(k*ratio,sub.velo2D);

force = Forces.add([gravity, upthrust, drag, thrust]);

}

变量dr是淹没在水面下的接头高度的分数。变量ratio是被淹没的体积分数(它决定了接头上的上推力和阻力),并以复杂的方式取决于接头的形状。因为这个模拟是为了一个简单的游戏,所以用一种近似的方式来处理它是有意义的。所以我们简单地把接头当成一个长方体,在这种情况下,只要dr的值在 0 到 1 之间,体积比就等于0.5 + 0.5*dr。如果dr为负(在这种情况下,接头完全在水面之外),ratio设置为零。如果dr大于 1(在这种情况下,接头完全浸入水中),ratio设置为 1。

变量ratio用于调整上推力和阻力,然后将它们添加到接头的重量中。还有第四个力,thrust,这是增加的,显然没有出现在第七章的的浮球例子中。这是一个Vector2D变量,其值根据用户交互设置。所以让我们来看看用户是如何控制模拟的。

添加控件和视觉效果

目的是通过键盘上的箭头键来控制潜艇。右箭头将施加向前的推力;向左箭头将施加向后的推力;向下箭头将把水引入压载舱;向上箭头将从压载舱中移除水,并向压载舱中充入空气。这是通过以下两个事件处理程序完成的,它们分别响应keydownkeyup键盘事件:

function keydownListener(evt){

if (evt.keyCode == 39) { // right arrow

thrust = new Vector2D(thrustMag,0);

} else if (evt.keyCode == 37) { // left arrow

thrust = new Vector2D(-thrustMag,0);

}

if (evt.keyCode == 40) { // down arrow

ballastInc = incMag;

} else if (evt.keyCode == 38) { // up arrow

ballastInc = -incMag;

}

window.addEventListener('keyup',keyupListener,false);

}

function keyupListener(evt){

thrust = new Vector2D(0,0);

ballastInc = 0;

window.removeEventListener('keyup',keyupListener,false);

}

变量thrustMagincMag控制推力的大小和每个时间步长内水位的增减量。代码中的默认值分别是 20 和 0.01。推力和水位增量的当前值分别存储在变量thrustballastInc中。当按键再次按下时,推力和水位增量都被重置为零。

必要的视觉变化由单独的方法控制,这些方法在每个时间步从move()方法中的updateSub()方法调用:

function updateSub(){

adjustBallast();

updateInfo();

}

adjustBallast()方法增加了waterFraction变量,该变量跟踪压载舱中的水量,作为压载舱装满时水量的一部分。显然,该分数不能小于 0 或大于 1;因此,代码也确保这种情况永远不会发生:

function adjustBallast(){

if (ballastInc != 0){

waterFraction += ballastInc;

if (waterFraction < 0){

waterFraction = 0;

}

if (waterFraction > 1){

waterFraction = 1;

}

setWaterHeight();

}

}

adjustBallast()中的最后一行代码调用了一个setWater()方法,该方法的任务是根据水箱中水质量的变化来调整水位的高度和接头的质量:

function setWaterHeight(){

sub.waterHeight = sub.tankHeight*waterFraction;

sub.mass = emptySubMass + waterMass*waterFraction;

}

updateSub()调用的最后一个方法是updateInfo()方法,它计算并显示潜艇密度与水密度的更新比率:

function updateInfo(){

var ratio = sub.mass/V/rho; // ratio of submarine density to water density

ratio = Math.round(ratio*100)/100; // round to 2 d.p.

var txt = "[sub density] / [water density] = ";

txt = txt.concat(ratio.toString());

context_fg.clearRect(0,0,700,100);

context_fg.fillText(txt,20,20);

}

完整的代码

为了展示一切是如何组合在一起的,我们在这里列出了submarine.js的完整代码:

var canvas = document.getElementById('canvas');

var context = canvas.getContext('2d');

var canvas_bg = document.getElementById('canvas_bg');

var context_bg = canvas_bg.getContext('2d');

var canvas_fg = document.getElementById('canvas_fg');

var context_fg = canvas_fg.getContext('2d');

var sub;

var g = 10;

var rho = 1;

var V = 1;

var k = 0.05;

var yLevel = 200;

var thrustMag = 20;

var thrust = new Vector2D(0,0);

var waterMass = 1;

var emptySubMass = 0.5;

var waterFraction = 0.4; // must be between 0 and 1

var ballastInc = 0; // ballast increment

var incMag = 0.01; // magnitude of the ballast increment

var t0, dt

var force, acc;

var animId;

window.onload = init;

function init() {

// create the sky

gradient = context_bg.createLinearGradient(0,0,0,yLevel);

gradient.addColorStop(0,'#0066ff');

gradient.addColorStop(1,'#ffffff');

context_bg.fillStyle = gradient;

context_bg.fillRect(0,0,canvas_bg.width,yLevel);

// create the sea

context_fg.fillStyle = "rgba(0,255,255,0.5)";

context_fg.fillRect(0,yLevel,canvas.width,canvas.height);

// create a sub

sub = new Sub(160,60,'#0000ff',emptySubMass);

sub.pos2D = new Vector2D(250,300);

sub.velo2D = new Vector2D(40,-20);

sub.draw(context);

// set water height in sub

setWaterHeight();

// set up text formatting

setupText();

// set up event listener

window.addEventListener('keydown',keydownListener,false);

// initialize time and animate

initAnim();

};

function setupText(){

context_fg.font = "12pt Arial";

context_fg.textAlign = "left";

context_fg.textBaseline = "top";

}

function keydownListener(evt){

if (evt.keyCode == 39) { // right arrow

thrust = new Vector2D(thrustMag,0);

} else if (evt.keyCode == 37) { // left arrow

thrust = new Vector2D(-thrustMag,0);

}

if (evt.keyCode == 40) { // down arrow

ballastInc = incMag;

} else if (evt.keyCode == 38) { // up arrow

ballastInc = -incMag;

}

window.addEventListener('keyup',keyupListener,false);

}

function keyupListener(evt){

thrust = new Vector2D(0,0);

ballastInc = 0;

window.removeEventListener('keyup',keyupListener,false);

}

function initAnim(){

t0 = new Date().getTime();

animFrame();

}

function animFrame(){

animId = requestAnimationFrame(animFrame,canvas);

onTimer();

}

function onTimer(){

var t1 = new Date().getTime();

dt = 0.001*(t1-t0);

t0 = t1;

if (dt>0.2) {dt=0;};

move();

}

function move(){

updateSub();

moveObject();

calcForce();

updateAccel();

updateVelo();

}

function stop(){

cancelAnimationFrame(animId);

}

function updateSub(){

adjustBallast();

updateInfo();

}

function adjustBallast(){

if (ballastInc != 0){

waterFraction += ballastInc;

if (waterFraction < 0){

waterFraction = 0;

}

if (waterFraction > 1){

waterFraction = 1;

}

setWaterHeight();

}

}

function setWaterHeight(){

sub.waterHeight = sub.tankHeight*waterFraction;

sub.mass = emptySubMass + waterMass*waterFraction;

}

function updateInfo(){

var ratio = sub.mass/V/rho; // ratio of submarine density to water density

ratio = Math.round(ratio*100)/100; // round to 2 d.p.

var txt = "[sub density] / [water density] = ";

txt = txt.concat(ratio.toString());

context_fg.clearRect(0,0,700,100);

context_fg.fillText(txt,20,20);

}

function moveObject(){

sub.pos2D = sub.pos2D.addScaled(sub.velo2D,dt);

context.clearRect(0, 0, canvas.width, canvas.height);

sub.draw(context);

}

function calcForce(){

var rheight = 0.5*sub.height;

var ysub = sub.y + rheight;

var dr = (ysub-yLevel)/rheight;

var ratio; // volume fraction of object that is submerged

if (dr <= -1){ // object completely out of water

ratio = 0;

}else if (dr < 1){ // object partially in water

ratio = 0.5+0.5*dr; // for cuboid

}else{ // object completely in water

ratio = 1;

}

var gravity = Forces.constantGravity(sub.mass,g);

var upthrust = Forces.upthrust(rho,V*ratio,g);

var drag = Forces.drag(k*ratio,sub.velo2D);

force = Forces.add([gravity, upthrust, drag, thrust]);

}

function updateAccel(){

acc = force.multiply(1/sub.mass);

}

function updateVelo(){

sub.velo2D = sub.velo2D.addScaled(acc,dt);

}

潜艇现在准备试驾了!像您在整本书中对示例所做的那样,享受试验参数值的乐趣。

轮到你了

那还不算太糟,是吗?你现在有了一个基本但功能齐全的潜艇,你可以在游戏中添加它。现在它在你的手中,你可以进一步发展它。您可以增强潜艇的视觉外观,添加风景,如带有水生生物和植物的漂亮背景,添加气泡,等等。你可以添加一个海底,用正常的力来处理与它的接触。将这种模拟转换成 3D 应该也不是很困难。如果你这样做,你当然也需要包含代码来处理旋转。

建造一个飞行模拟器

这个项目比前一个例子要复杂得多,因为我们将在 3D 中工作,也因为飞机的物理和操作相当复杂。因此,在我们开始编码之前,我们需要在理论上多花一点时间。

飞机的物理学和控制机制

为了模拟飞机飞行,需要理解两个方面的理论。首先是基础物理学,包括对作用在飞机上的力和力矩以及它们如何影响飞机运动的理解。第二个方面是飞机控制这些力和力矩的机制;这需要了解飞机的相关部件及其相关的机动动作。

飞机上的力

我们在第七章中讨论了作用在飞机上的力:重力、推力、阻力和升力。图 16-3 显示了与水平面成一定角度的平面的力图。这是图 7-15 所示力图的概括,它是针对飞机水平飞行的特殊情况。因此,推力和阻力的方向不一定是水平的,升力也不一定是垂直的。因此,在这种情况下,力的平衡稍微复杂一些。在任何时候(在上升和下降过程中,而不仅仅是在水平飞行过程中)获得这些不同力的大小和方向显然是很重要的,所以我们需要稳健的方程来计算它们。

A978-1-4302-6338-8_16_Fig3_HTML.jpg

图 16-3。

The four forces acting on an airplane during flight

重力 W = m g 仍然向下作用,当然是常数;所以这个很简单。推力 T,无论是由螺旋桨还是喷气发动机产生的,通常都是沿着飞机的轴线向前作用的(除非你考虑的是一种飞机模型,如“鹞”式飞机,其中推力的方向是可变的)。推力的大小取决于许多因素,如发动机类型、发动机效率和海拔影响。在我们的模拟中,我们将忽略所有这些影响,只规定一个用户可以更改的值。

阻力和升力更加微妙。严格地说,它们是相对于相关飞机部件(如机翼)上的气流方向来定义的。假设没有风,我们可以把它近似为飞机速度的方向。阻力可以定义为与飞机速度方向相反的气动力(由气流产生的力)的分量。升力是垂直于飞机速度的分量。

阻力公式与第七章中给出的公式相同,其中 v 现在被解释为飞机的速度:

A978-1-4302-6338-8_16_Figd_HTML.jpg

我们在第七章中给出的升力公式被推广到下面的公式,其中 k 是垂直于飞机速度的单位矢量,指向飞机机翼的上方:

A978-1-4302-6338-8_16_Fige_HTML.jpg

这些是通用公式,当我们更详细地研究飞机部件时,需要对它们进行修改。特别是,应定义这些等式中使用的相关区域。我们还需要指定阻力系数和升力系数,这些系数会根据几个因素而变化。

飞机上的扭矩:旋转

飞机上的力不足以计算它的运动。如你所知,合力决定了平移运动,但如果这些力的作用线不通过质心,它们也会产生力矩。物体的重量通过一个称为重心的点起作用。在均匀的重力场中,例如在地球表面附近(为此目的,飞机离地球表面足够近),重心与质心重合。因此,飞机的重量不会产生扭矩。然而,阻力和升力通过一个称为压力中心的点起作用。这是一个类似于重心的概念,但是它取决于飞机的形状和倾角,并且通常与飞机的质心不一致。所以如果不平衡的话,它会产生一个能让飞机旋转的扭矩。因此,飞机需要有稳定机制来平衡这种扭矩。

当然,有时你确实想让飞机旋转。图 16-4 显示了飞机绕质心和轴旋转的三种方式。它可以通过上下移动鼻子和尾巴来俯仰;它可以通过上下移动翼尖来滚动;它可以通过向侧面移动鼻子和尾巴来偏转。这些轴是相对于飞机的形状定义的,它们都通过飞机的重心。俯仰轴的方向平行于翼展,滚转轴沿着飞机的长度方向,偏航轴垂直于其他两个轴。一架飞机被设计成具有特殊的部件,可以通过控制升力和阻力来产生这些类型的运动。我们现在来看看这些。

A978-1-4302-6338-8_16_Fig4_HTML.jpg

图 16-4。

Pitch, roll, and yaw

飞机零件和控制

飞机可以通过改变作用在它们身上的推力、阻力和升力来控制它们的运动。本节简要描述飞机上做这些工作的部件以及它们是如何做的。推力由发动机(可以是不同类型的,如螺旋桨或喷气式)控制,将推动飞机前进。图 16-5 显示了一架飞机的示意图,强调了那些与阻力、升力及其控制相关的部件。

A978-1-4302-6338-8_16_Fig5_HTML.jpg

图 16-5。

Aircraft parts

固定的“主要”部件包括:

  • 机身:这是飞机的主体。这是造成阻力的主要原因。它还会产生升力和俯仰力矩,但只是在大倾角时,通常可以忽略不计。它还可以为大的横向倾斜产生偏航扭矩;同样,这通常可以被忽略。
  • 机翼:飞机的两个机翼提供了大部分的升力和阻力。没有翅膀,飞机就飞不起来。主翼上升力和阻力的压力中心靠近质心,所以可以假定它们不产生扭矩,就像机身一样。然而,它们有称为襟翼和副翼的可移动部件,可以操纵这些部件来改变升力和阻力,并产生滚动扭矩。
  • 尾翼:尾翼由两部分组成:水平尾翼和垂直尾翼,它们有助于保持飞机的水平和垂直稳定性。它们也有称为升降舵和方向舵的可移动部件,产生俯仰和偏航扭矩。

可移动的“控制”部件包括:

  • 襟翼:襟翼安装在机翼后部,在起飞和着陆时向下旋转,以增加升力(也增加着陆时的阻力,使快速移动的飞机减速)。
  • 副翼:副翼也附在机翼的后部,但是更靠近翼尖。向下转动副翼增加升力,向上转动副翼减小升力。两个副翼反方向旋转,产生一个滚转力矩(力偶)。由于副翼远离质心,机翼两侧相对较小的升力变化就能产生较大的力矩(回想一下力矩=力×距离)。副翼通常由操纵杆控制;向左或向右移动驾驶杆会使飞机向左或向右滚动。
  • 升降舵:升降舵铰接在水平尾翼的后侧。与副翼不同,升降舵一起运动(都向上或都向下),从而产生俯仰力矩。升降舵是通过向前移动操纵杆来升高机头,向后移动操纵杆来降低机头来控制的。
  • 方向舵:方向舵附着在垂直尾翼的后部。它可以向任意一侧旋转,产生偏航力矩。方向舵由两个脚踏板控制,踩下左踏板或右踏板,飞机的机头分别向左或向右移动。

翼型几何形状和攻角

飞机机翼的形状和倾角是决定升力大小的重要因素。图 16-6 显示了机翼横截面的形状,称为翼型。翼型的形状通常是前圆后尖。连接前后缘曲率中心的线叫做弦线。

A978-1-4302-6338-8_16_Fig6_HTML.jpg

图 16-6。

Flow over an aircraft wing (airfoil)

弦线和入射气流之间的角度(通常沿飞机速度)称为迎角,通常用希腊字母α表示。翼型的阻力和升力系数取决于攻角,飞行模型通常使用实验数据或简单函数来评估这些系数作为攻角的函数。

起飞和着陆

飞机只有在速度超过一定限度时才能起飞。该阈值对应于升力平衡飞机重量的点。在这种情况下,您可以使升力和重量的表达式相等:

A978-1-4302-6338-8_16_Figf_HTML.jpg

然后你可以重新整理这个公式,得到一个速度公式,即起飞的临界速度:

A978-1-4302-6338-8_16_Figg_HTML.jpg

在着陆过程中,你会遇到相反的问题。你不希望飞机接触地面的速度太高;否则,飞机会坠毁。您可以在游戏或模拟中设置合理的阈值速度。着陆时的另一个考虑是,俯仰角必须为零或略正;否则,飞机的机头会撞到地面。

旋转

转弯是通过倾斜产生升力的水平不平衡分量来实现的,因为它总是垂直于速度,所以产生一个向心力,使飞机做圆周运动(见图 16-7 )。倾斜是通过副翼使飞机滚动来实现的。方向舵产生的偏航运动不是用来转弯,而是用来调整飞机的速度方向。

A978-1-4302-6338-8_16_Fig7_HTML.jpg

图 16-7。

Force diagram for a banked plane

我们将创造什么

我们将创建的模拟将包括使飞机飞行所需的基本物理知识,以及经历所讨论的不同类型的运动(直线飞行、俯仰、滚转和偏航)和机动(起飞、着陆和转弯)的能力。用户将能够使用键盘控制一个非常简单的飞机模型,就像飞行员控制飞机一样。将模拟升降舵、副翼和方向舵在产生俯仰、滚转和偏航力矩时的操作。用户也可以调整推力的大小。为了简单起见,我们不包括副翼——但是,它们很容易添加。

创建视觉设置

视觉设置是极简主义的,只包括地面和一个非常简单的长方体飞机模型。代码在文件airplane.js中,init()方法如下所示:

function init() {

setupObjects();

setupOrientation();

setupText();

renderer.render(scene,camera);

window.addEventListener('keydown',startControl,false);

t0 = new Date().getTime();

animFrame();

}

前两个方法setupObjects()setupOrientation()分别负责创建显示对象和初始化飞机的方位。我们先来看setupObjects():

function setupObjects(){

renderer = new THREE.WebGLRenderer({clearColor: 0xff0000, clearAlpha: 1});

renderer.setClearColor( 0x82caff, 1);

renderer.setSize(width, height);

document.body.appendChild(renderer.domElement);

scene = new THREE.Scene();

var angle = 45, aspect = width/height, near = 0.1, far = 10000;

camera = new THREE.PerspectiveCamera(angle, aspect, near, far);

camera.position.set(100,0,1000);

scene.add(camera);

var light = new THREE.DirectionalLight();

light.position.set(30,0,30);

scene.add(light);

var geom = new THREE.PlaneGeometry(5000, 100000, 50, 500);

var wireframeMat = new THREE.MeshBasicMaterial();

wireframeMat.wireframe = true;

var ground = new THREE.Mesh(geom, wireframeMat);

ground.rotation.x = -Math.PI/2;

ground.position.set(0,groundLevel,0);

scene.add(ground);

airplane = new THREE.Mesh(new THREE.CubeGeometry(400, 100, 100), new THREE.MeshNormalMaterial());

airplane.overdraw = true;

scene.add(airplane);

airplane.mass = massAirplane;

airplane.pos = new Vector3D(0,200,0);

airplane.velo = new Vector3D(0,0,0);

airplane.angVelo = new Vector3D(0,0,0);

}

使用带有MeshBasicMaterial和一个wireframe选项的PlaneGeometry创建“地面”,并围绕 x 轴旋转 90 度。使用CubeGeometry创建飞机,并给定质量、位置向量、速度向量和角速度。飞机的惯性矩在变量I中指定,该变量在代码开头的变量列表中初始化。如前一章所述,转动惯量是一个 3 × 3 的 3D 矩阵。这里,我们认为惯性矩阵仅由三个对角分量组成:IxxIyyIzz,矩阵的其他分量为零。为了实现所需的矩阵代数(参见下一节“编写物理代码”),我们编写了一个非常简单的Matrix3D对象,通过将矩阵的每一行视为一个Vector3D对象来创建一个 3 × 3 矩阵。Matrix3D因此在其构造函数中接受三个Vector3D参数。我们赋予了Matrix3D两个方法,multiply()scaleBy(),分别将其乘以一个Vector3D对象和一个标量。具体实现见文件matrix3D.js中的代码。

请注意,创建飞机时,飞机的初始方向是其长度沿着 x 轴。在模拟中,我们希望它指向–z 轴,这样它就可以飞入屏幕。为此,setupOrientation()方法将平面绕 y 轴旋转 90 度:

function setupOrientation(){

// initialize airplane orientation

var qRotate = new THREE.Quaternion();

qRotate.setFromAxisAngle(new THREE.Vector3( 0, 1, 0 ), Math.PI/2);

airplane.quaternion.multiply(qRotate);

// airplane local axes

ix = new Vector3D(1,0,0);

iy = new Vector3D(0,1,0);

iz = new Vector3D(0,0,1);

}

我们还定义了三个向量ixiyiz来表示飞机在它自己的参考系中的三个轴。在calcForce()中,计算飞机上的升力和扭矩需要用到这些。参见图 16-4 ,这些轴分别对应横滚、偏航和俯仰轴。它们是相对于飞机定义的,因此会在所谓的“世界坐标系”中相对于地面变化

编写物理代码

动画循环是通过调用init()中的animFrame()方法建立的。这产生了对熟悉的方法的调用,如moveObject()calcForce()updateAccel()updateVelo(),这些方法像前面的例子一样处理飞机平移和旋转运动的更新。此外,还有一种controlRotation()方法,应用一点黑客技术来稳定飞机的旋转运动,防止它失去控制。实际上,稳定机制相当复杂,要正确实施相当困难。所以我们在controlRotation()中稍微作弊了一下,简单的对飞机的角速度应用一个阻尼因子。

代码的核心是计算飞机上的力和扭矩,这是在calcForce()方法中完成的。这个乍一看挺复杂的,但是一步步走过去就有道理了。我们还在可下载的源文件中包含了大量的注释;其中一些包含在下面的清单中。在讨论之前,让我们先列出calcForce()的全部:

function calcForce(obj){

// *** rotate airplane velocity vector to airplane's frame ***

var q = new THREE.Quaternion;

q.copy(airplane.quaternion);

var rvelo = rotateVector(obj.velo,q);

// *** forces on whole plane ***

force = new Vector3D(0,0,0);

var drag = Forces3D.drag(kDrag,rvelo);

force = force.add(drag);

var thrust = new Vector3D(-thrustMag,0,0); // thrust is assumed along roll axis

force = force.add(thrust);

// *** torques on whole plane ***

torque = new Vector3D(0,0,0); // gravity, drag and thrust don't have torques

// *** lift forces and torques on wings and control surfaces ***

if (rvelo.length() > 0){ // no lift if velocity is zero

var viXY = new Vector3D(rvelo.x,rvelo.y,0); // velocity in the airplane xy plane

var viZX = new Vector3D(rvelo.x,0,rvelo.z); // velocity in the airplane xz plane

// *** calculate angle of attack and lateral angle ***

calcAlphaBeta(rvelo);

// *** Wing ***

// force: lift on the Wing; no overall torque

var liftW = liftforce(viXY,iz,areaWing,clift(alphaWing+alpha));

force = force.add(liftW);

// *** Ailerons ***

// force: ailerons; form a couple, so no net force

var liftAl = liftforce(viXY,iz,areaAileron,clift(alphaAl));

var torqueAl = (iz.multiply(distAlToCM*2)).crossProduct(liftAl); // T = r x F

torque = torque.add(torqueAl);

// *** Elevators ***

// force: horizontal tail (elevators)

var liftEl = liftforce(viXY,iz,areaElevator,clift(alphaEl));

torqueEl = (ix.multiply(-distTlToCM)).crossProduct(liftEl);  // T = r x liftHt;

force = force.add(liftEl);

torque = torque.add(torqueEl);

// *** Rudder ***

// force: vertical tail (rudder)

var liftRd = liftforce(viZX,iy.multiply(-1),areaRudder,clift(alphaRd+beta));

torqueRd = (ix.multiply(-distTlToCM)).crossProduct(liftRd); // T = r x liftVt

force = force.add(liftRd);

torque = torque.add(torqueRd);

}

// *** rotate force back to world frame ***

force = rotateVector(force,q.conjugate());

// *** add gravity ***

var gravity = Forces3D.constantGravity(massAirplane,g);

force = force.add(gravity);

}

这种模拟的一个主要特点是,大部分力和力矩的计算都是在飞机的参考系中进行的。所以我们开始通过应用rotateVector()方法将飞机的速度向量旋转到飞机的框架中,这利用了three.js中的applyQuaternion()方法。然后,计算与该旋转速度矢量相反的阻力。阻力是在假定阻力系数不变的情况下计算的。这是一种简化;一般来说,阻力系数将取决于迎角,但为了简单起见,这里不模拟这种相关性。推力施加在飞机的ix(滚转)轴方向,其大小由用户通过更新thrustMag变量来控制(将在后面描述)。由于这些力产生的净扭矩假定为零,因此飞机保持旋转平衡。

下一节代码计算飞机各种表面(机翼、副翼、升降舵和方向舵)的升力以及这些力产生的力矩。因为如果飞机不动,升力为零,所以整个计算只有在飞机速度不为零时才进行。升力包括机翼、升降舵和副翼上的垂直升力和方向舵上的水平升力。它们取决于 xy 和 xz 平面相对于飞机的速度。因此,首先计算这些速度。然后在calcAlphaBeta()函数中用它们来计算迎角α和侧向角β(飞机速度的 x 和 z 分量之间的角度)。请注意,我们在这里假设没有风。加入风的影响很简单,但这只会使代码更加复杂。

接下来,计算机翼上的升力。使用叉积是因为机翼上的升力垂直于 xy 平面和机翼轴(俯仰轴)上的速度。计算升力系数时,假设线性依赖于迎角,梯度 dC L /dα不变:

A978-1-4302-6338-8_16_Figh_HTML.jpg

注意,我们将机翼相对于飞机滚转轴的倾角(alphWing)加到先前计算的α值上,以获得实际迎角。我们还对升力施加了一个极限值,因为实际上升力不会随着迎角的增加而无限增加,而是达到一个最大值。升力系数在clift()函数中计算,升力在liftforce()中计算。

假设当飞机处于平衡状态时,主翼上的升力不产生扭矩,就像阻力和推力一样(或者,更准确地说,实际上这些力的组合扭矩被抵消了)。

接下来的代码块计算活动操纵面(升降舵、副翼和方向舵)上的升力及其产生的力矩。升力的计算方法与主翼相同。扭矩通常通过计算升力与质心的距离矢量和升力的叉积来计算:

A978-1-4302-6338-8_16_Figi_HTML.jpg

需要为每个控制表面指定适当的距离矢量。

在所有的升力被添加到force向量后,后者旋转回到世界框架,重力被添加为一个恒定的向下的力。加速度和速度更新的后续计算将照常在世界坐标系中执行。

请注意,在整个代码中,角运动计算是在飞机的框架中进行的。在这里,我们必须小心应用运动的角度方程(即扭矩和角加速度之间的关系)。因为飞机在一个旋转参考系中,后者有一个附加项,看起来像这样(见第十五章中的“力矩、角加速度和惯性矩矩阵”一节):

A978-1-4302-6338-8_16_Figj_HTML.jpg

回想一下,这是一个矩阵方程,惯性矩 I 是一个 3 × 3 的矩阵,转矩 T,角速度ω,角加速度α = d ω/dt 是矢量。这个方程可以很容易地用来给出角加速度的矩阵方程:

A978-1-4302-6338-8_16_Figk_HTML.jpg

这里A978-1-4302-6338-8_16_Figl_HTML.jpg是转动惯量矩阵的逆矩阵。该等式可使用正向方案离散化,并在代码中实现,如下面列出的updateAccel()方法中的粗体行所示:

function updateAccel(obj){

acc = force.multiply(1/obj.mass);

var omega = obj.angVelo;

alp = Iinv.multiply(torque.subtract(omega.crossProduct(I.multiply(omega))));

}

逆惯性矩矩阵Iinv在代码中根据惯性矩矩阵I的指定分量预先计算。

最后,飞机的方位和位置在moveObject()中更新:

function moveObject(obj){

var p = new THREE.Quaternion;

p.set(obj.angVelo.x*dt/2,obj.angVelo.y*dt/2,obj.angVelo.z*dt/2,1);

obj.quaternion.multiply(p);

obj.quaternion.normalize();

obj.pos = obj.pos.addScaled(obj.velo,dt);

positionObject(obj);

positionCamera(obj);

renderer.render(scene,camera);

}

还要注意的是在moveObject()中增加了新的positionCamera()方法,它控制摄像机的位置和移动,欢迎您定制。

实施控制

init()方法中,为keydown事件设置了一个事件监听器。通过使用箭头、X 和 Z 键控制升降舵、副翼和方向舵来控制飞机;空格键和回车键来控制推力。这是在startControl()事件监听器中完成的。(我们不会在这里列出代码,因为它相当简单。)向下箭头键增加俯仰,向上抬起飞机机头,向上键降低机头。左箭头键和右箭头键分别使飞机向左和向右滚动。这些运动类似于飞行员使用操纵杆控制升降舵和副翼的方式。使用 X 和 Z 键控制方向舵。这些可移动部件的最大角度和增量在程序开始时设置。分别按空格键和回车键可以增加和减少推力的大小。同样,推力的最大值及其增量设置在代码的开头。

通过按 A 键跟随飞机或按 W 键保持在世界帧中的固定点来控制相机位置。最后,按 Esc 键停止模拟。

显示航班信息

在每个时间步长从onTimer()方法调用的updateInfo()方法包含代码,用于在模拟过程中在单独的画布元素上写文本。显示的信息包括飞机的高度、垂直速度、水平速度以及副翼、升降舵和方向舵的角度。当你试图让飞机保持飞行而不坠毁时,记住前两条信息是很重要的。正的垂直速度意味着飞机在上升;如果垂直速度是负的,它正在失去高度,所以你最好小心。

您可以轻松地添加附加信息,如飞机的当前位置(如果您想去某个地方,这很有用)。

试飞模拟器

飞机现在可以飞行了!使用默认参数值运行它。当模拟开始时,飞机的初始速度为零,但有一个施加的推力。如果你什么都不做,飞机最初会在重力作用下下降,然后随着施加的推力增加其水平速度并因此提供升力而开始上升。按住空格键增加推力。然后按下向下箭头键增加电梯的角度。你会看到飞机在前进的过程中逐渐上升。然后通过按向上箭头键来减小俯仰角,这将减小升力并降低飞机的上升,甚至使其下降。看看你能否让飞机上升到一个给定的高度并保持在那里。要做到这一点,你需要调整推力和/或俯仰角,你需要在你想要的高度使垂直速度接近于零。一旦你做到了这一点,你就可以高枕无忧了;飞机会自己照顾自己的。

重新开始模拟,并尝试副翼和方向舵控制。注意,飞机翻滚时飞机也在转弯,就像“转弯”一节所描述的那样。然而,过多的滚动或偏航可能会使飞机的行为有些不可预测。另请参见源代码中包含的详细注释。图 16-8 显示了飞机飞行时的模拟截图。

A978-1-4302-6338-8_16_Fig8_HTML.jpg

图 16-8。

The airplane in flight

轮到你了

关于如何改进这个模拟器,你可能已经有了一些想法。创造一个更令人印象深刻的 3D 模型飞机可能是其中之一,也可能是更具视觉吸引力的风景。你可以在 3D 效果上走得更远,还可以加入更广阔的地形,这可以是生成的,也可以是基于真实的空中信息。您还可以通过添加襟翼来改进飞机的控制,在模型中包含视觉元素来表示控制表面,添加坠毁场景,等等。你可能有自己更好的想法!

创建一个精确的太阳系模型

这个项目与书中的大多数例子有些不同。这里的目标是创建一个准确的太阳系计算机模型,可以用作学生的电子学习工具。这里的关键词是准确:视觉和动画效果不如物理的正确性和模拟的准确性重要。因此,我们将偏离我们通常的动画框架,从一开始就采用一种稍微不同的编码方法,这种方法将反映项目的模拟而不是动画方面。

我们将创造什么

我们的目标是创建一个包括太阳和行星的太阳系比例模型。在提供的源代码中,我们包括了最里面的四颗行星(水星、金星、地球和火星)——所谓的类地行星。我们把它作为一个练习,让你添加其他四个(木星、土星、天王星和海王星)——所谓的气态巨行星。这应该很容易做到,并且会给你宝贵的代码实践经验。

请注意,冥王星不再被归类为行星。在模拟中,我们将假设像这样的小天体不存在,包括小行星和卫星。我们可以忽略它们,因为它们对行星运动的影响可以忽略不计。

该项目将分不同阶段进行:

  • 第一步:首先,我们将使用四阶龙格-库塔方案(RK4,在第十四章的中介绍)在 3D 中建立一个模拟循环,我们将用一个简单的例子来测试它。这个的源代码在文件rk4test3d.js中。
  • 第二步:第二,我们将使用模拟循环以一种简单理想化的方式来模拟单个行星的运动。该步骤的源代码在文件single-planet.js中。
  • 第三步:第三,我们将包括四个内行星,并根据平均天文数据设置它们的属性和初始条件,这样我们将最终得到一个相当现实的太阳系模型(或者说是它的一半)。我们还需要仔细设置合适的秤。对应的文件命名为solar-system-basic.js
  • 第四步:第四步,在文件solar-system.js中,我们将包含精确的初始条件,并运行模拟一年。
  • 第五步:第五步,在solar-system-nasa.js中,我们将把结果与美国宇航局相应的模拟数据进行比较,看看我们的模拟有多好。
  • 第六步:最后,在solar-system-animated.js中,我们将引入一些基本的动画来使模拟更具视觉吸引力。

物理学

模拟将包括模拟每颗行星在太阳和其他行星施加的综合引力作用下的运动。当然,太阳的影响要大得多,因为与其他行星相比,它的质量非常大。我们将采用一个太阳固定的坐标系,这样我们就不必模拟太阳本身的运动。在这些力的作用下,行星将能够在三维空间中移动。

设 m 为行星的质量,r i 为其相对于其他行星和太阳的位置矢量(其中 rIu 为相应的单位矢量),m i 为其他行星和太阳的质量。那么每个行星上的合力由下面的公式给出:

A978-1-4302-6338-8_16_Figm_HTML.jpg

除以质量,我们得到每个行星的加速度:

A978-1-4302-6338-8_16_Fign_HTML.jpg

这是数值积分代码将求解的方程。就这样。这个问题的物理原理非常简单!但是还有其他问题使得模拟不简单:实现精确的数值积分方案,适当地缩放模型,以及加入精确的初始条件。我们将一步一步地处理这些需求。

编写合适的集成方案

因为精度是我们首要关注的问题,所以使用 RK4 积分方法是完全有意义的。为了实现 RK4,我们将首先创建一个简单的“模拟循环”来进行时间步进,但是我们将独立于迄今为止使用的“动画循环”来进行。做这件事比解释它要容易得多,所以我们就这样做吧,然后再解释。

我们从第十四章的中的示例中借用 RK4 代码,并对其进行修改,得出以下代码,该代码在文件rk4test3d.js中:

var dt = 0.05;

var numSteps = 20;

var t = 0;

var g = 10;

var v = new Vector3D(0,0,0);

var s = new Vector3D(0,0,0);

window.onload = init;

function init(){

simulate();

}

function simulate(){

console.log(t,s.y);

for (var i=0; i<numSteps; i++){

t += dt;

RK4();

console.log(t,s.y);

}

}

function RK4(){

// step 1

var pos1 = s;

var vel1 = v;

var acc1 = getAcc(pos1,vel1);

// step 2

var pos2 = pos1.addScaled(vel1,dt/2);

var vel2 = vel1.addScaled(acc1,dt/2);

var acc2 = getAcc(pos2,vel2);

// step 3

var pos3 = pos1.addScaled(vel2,dt/2);

var vel3 = vel1.addScaled(acc2,dt/2);

var acc3 = getAcc(pos3,vel3);

// step 4

var pos4 = pos1.addScaled(vel3,dt);

var vel4 = vel1.addScaled(acc3,dt);

var acc4 = getAcc(pos4,vel4);

// sum vel and acc

var velsum = vel1.addScaled(vel2,2).addScaled(vel3,2).addScaled(vel4,1);

var accsum = acc1.addScaled(acc2,2).addScaled(acc3,2).addScaled(acc4,1);

// update pos and velo

s = pos1.addScaled(velsum,dt/6);

v = vel1.addScaled(accsum,dt/6);

//acc = accsum.multiply(1/6);

}

function getAcc(ppos,pvel){

return new Vector3D(0,g,0);

}

好吧,我们从熟悉的开始。如前所述,RK4()方法是我们在第十四章中第一次看到的RK4()方法的改进。如果你比较这两个版本,你会注意到一个关键的区别:这段代码通过抽象变量sv处理当前的位置和速度,而不是像以前一样处理实际的粒子位置和速度属性。这是动画方面从模拟中分离出来的第一个迹象。

之前调用calcForce()getAcc()方法在这里被简化为简单地返回一个指向 y 方向下方的恒定加速度向量。这意味着在这里我们将代码专门化到一个简单的重力问题。剩下的另一段代码是simulate()方法,它在初始化时被调用一次。代码的核心非常简单。下面的for循环执行所有的时间步长,递增当前时间并调用RK4()方法,该方法在每个时间步长更新位置和速度向量sv:

for (var i=0; i<numSteps; i++){

t += dt;

RK4();

}

从动画的角度来看,这段代码和相关的时间步长将一次性执行:实际上,我们将在对现有显示对象进行任何动画之前预先计算运动。这段代码中甚至没有显示对象。这样做的好处是,它将模拟循环从动画循环中“解耦”(如果有动画循环的话),这样任何一个都可以不受另一个引入的任何时间延迟的影响而完成自己的工作。然而,如果您的模拟需要交互,这种方法就不起作用。

看看变量是如何初始化的,我们可以看到,我们是在模拟一个时间单位的重力作用下,一个物体从静止(v初始为零)下落的过程(因为dt * numSteps = 0.05 × 20 = 1)。例如,为dt选择秒的单位,为g选择 m/s 2 ,这相当于让一个物体在重力作用下从静止状态下落 1 秒,g 等于 10 m/s 2 。使用公式 s = ut + at 2 然后告诉我们,物体在这段时间内下落了 5 m 的距离。在代码中,我们在每个时间步向控制台输出一对值ts.y,这告诉我们物体每次下落了多远。运行代码,你会发现最终的距离确实是 5。你可以尝试不同的时间步长(相应地调整步数),你会发现无论时间步长是多少,RK4 都做得很好。例如,即使时间步长为 0.5 并且有 2 个步骤(因此持续时间仍然是 1 个时间单位),它仍然给出正好 5!

现在我们已经有了一个好的积分器和一个基本的模拟回路,我们准备继续前进,创建一个更接近行星系统的东西。

建立一个理想化的单行星模拟

修改我们刚刚创建的基本 RK4 模拟器来模拟行星轨道是很简单的:你必须根据牛顿的 1/r 2 引力公式修改getAcc()方法,并为sv选择适当的初始条件。但是我们也想在画布上看到一些东西!因此,在three.js的一点帮助下,我们还使用sphereGeometry添加了一个太阳和一个行星,并包含一些代码,向我们展示它们的位置如何随时间变化。结果是下面的代码,保存为single-planet.js:

var width = window.innerWidth, height = window.innerHeight;

var dt = 0.05;

var numSteps = 2500;

var animFreq = 40;

var t = 0;

var v = new Vector3D(5,0,14);

var s = new Vector3D(0,250,0);

var center = new Vector3D(0,0,0);

var G = 10;

var massSun = 5000;

var scene, camera, renderer;

var sun, planet;

window.onload = init;

function init(){

setupObjects();

simulate();

renderer.render(scene, camera);

}

function setupObjects(){

renderer = new THREE.WebGLRenderer();

renderer.setSize(width, height);

document.body.appendChild(renderer.domElement);

scene = new THREE.Scene();

var angle = 45, aspect = width/height, near = 0.1, far = 10000;

camera = new THREE.PerspectiveCamera(angle, aspect, near, far);

camera.position.set(0,0,1000);

scene.add(camera);

var light = new THREE.DirectionalLight();

light.position.set(-10,0,20);

scene.add(light);

var radius = 80, segments = 20, rings = 20;

var sphereGeometry = new THREE.SphereGeometry(radius,segments,rings);

var sphereMaterial = new THREE.MeshLambertMaterial({color: 0xffff00});

sun = new THREE.Mesh(sphereGeometry,sphereMaterial);

scene.add(sun);

sun.mass = massSun;

sun.pos = center;

positionObject(sun);

var sphereGeometry = new THREE.SphereGeometry(radius/10,segments,rings);

var sphereMaterial = new THREE.MeshLambertMaterial({color: 0x0099ff});

planet = new THREE.Mesh(sphereGeometry,sphereMaterial);

scene.add(planet);

planet.mass = 1;

planet.pos = s;

positionObject(planet);

}

function positionObject(obj){

obj.position.set(obj.pos.x,obj.pos.y,obj.pos.z);

}

function simulate(){

for (var i=0; i<numSteps; i++){

t += dt;

RK4();

if (i%animFreq==0){

clonePlanet(planet);

}

}

}

function clonePlanet(){

var p = planet.clone();

scene.add(p);

p.pos = s;

positionObject(p);

}

function getAcc(ppos,pvel){

var r = ppos.subtract(center);

return r.multiply(-G*massSun/(r.lengthSquared()*r.length()));

}

function RK4(){

// code as in previous example

}

第一段新颖的代码是在setupObjects()方法中,它实现了three.js功能并创建了太阳和行星。注意渲染只做一次,在init()方法的最后,在setupObjects()simulate()执行之后。我们在simulate()函数中添加了一个if代码块,它以animFreq的间隔调用一个clonePlanet()方法。方法clonePlanet()创建了一个行星的副本,并把它放在新的位置。最终结果是一系列克隆的行星出现在行星经过相同时间后所在的位置(见图 16-9 )。不完全是“动画”,因为如果您运行代码,您将立刻看到所有显示的内容,但它确实传达了某种穿越时间的感觉。这是 3D 的行星轨迹。您可以看到,当 z 坐标较大时,透视效果会导致行星看起来较小。就物理学而言,最重要的修改是getAcc()方法现在应用了由于太阳对行星的吸引力而产生的加速度。这产生了我们在图 16-9 中看到的轨道。

A978-1-4302-6338-8_16_Fig9_HTML.jpg

图 16-9。

A planetary trajectory in 3D

选择合适的比例因子

既然我们已经有了一个精确的轨道模拟器,现在是时候代表真实的太阳系进行缩放,作为现实模拟的下一步。要做到这一点,我们需要回到控制方程。为了选择合适的比例因子,重要的是方程的形式,而不是各项的细节。因此,只考虑方程中反映太阳影响的部分是没问题的。此外,重要的是相关量的大小,而不是它们的方向。因此,最后一个等式导致每个行星加速度的以下近似等式,其中 M s 是太阳的质量,r 是行星与太阳的距离:

A978-1-4302-6338-8_16_Figo_HTML.jpg

我们现在以与上一章中的示例类似的方式定义时间、距离和质量的比例因子:

A978-1-4302-6338-8_16_Figp_HTML.jpg

将这些因子代入前面的等式并重新排列,根据重新调整后的变量得出以下相应的等式:

A978-1-4302-6338-8_16_Figq_HTML.jpg

比较这两个方程告诉我们,在重新标度的系统中,重力常数 G 的值由下式给出:

A978-1-4302-6338-8_16_Figr_HTML.jpg

我们现在需要为基本单位选择适当的比例因子μ、τ和λ,以便将量从常用的 SI 单位转换为相应的模拟单位。在任何给定的问题中,比例因子的合理选择应该反映与物理学相关的比例。例如,在这种情况下,用千克来表示质量值是没有意义的,而是用太阳或地球的质量来表示。我们选择后者吧。同样,坚持以秒为时间单位也没有什么意义,因为我们将有兴趣看到行星至少绕太阳完成一周(模拟的持续时间将在几年的量级)。因此,我们将选择 1 天作为时间单位。最后,我们可以选择地球到太阳的平均距离(称为天文单位或 au)作为距离单位。这大约是 1.5 亿公里,或 150 × 10 9 米。根据定义,这将使地球和太阳之间的距离在模拟中为 1 个单位。然而,因为我们正在建立一个视觉模拟,我们将考虑像素方面的距离,并希望看到地球离太阳有相当数量的像素。在这种情况下,更合适的比例因子选择是每像素 100 万公里(109米),这将使地球与太阳的距离约为 150 像素。表 16-1 总结了这些比例因子的选择。

表 16-1。

Scaling Factors for Base Units

| 换算系数 | 价值 | | --- | --- | | μ(质量) | 地球质量= 5.9736 x 10 24 千克 | | τ(时间) | 地球日= 86400 秒 | | λ(长度) | 100 万公里= 10 9 米 |

速度等导出量的比例因子可以从基本量的比例因子中计算出来。例如,速度的比例因子由λ/τ给出。

获取行星数据和初始条件

接下来,我们需要获得一些行星数据,如行星的质量和初始条件(行星的位置和速度)的一些合适的值。这个来自美国宇航局网站的网页上有所有需要的信息: http://nssdc.gsfc.nasa.gov/planetary/planetfact.html

为了方便访问,我们保存了一些数据,我们将在Astro对象中使用这些数据作为静态常量。这包括,例如,常数EARTH_MASSEARTH_RADIUSEARTH_ORBITAL_RADIUSEARTH_ORBITAL_VELOCITY以及其他行星的类似常数。看一下文件astro.js。在文件phys.js中有一个相关的类叫做Phys,,里面包含了几个物理常量的值,比如引力常数 G ( GRAVITATIONAL_CONSTANT)和地球上由于引力产生的加速度的标准值(STANDARD_GRAVITY)。这些值是从 NIST 网站上的以下网页获得的: http://physics.nist.gov/cuu/Constants/

Astro物体中行星的平均轨道半径和轨道速度的值将在我们下一步构建的太阳系模型的基本版本中用作近似初始条件。

创建一个基本的太阳系模型

我们现在准备建立一个基本的太阳系模型,它将包括模拟的所有重要方面。相关文件solar-system-basic.js包含了相当多的代码。所以我们会稍微分解一下。让我们首先列出变量和init()方法:

// rendering

var width = window.innerWidth, height = window.innerHeight;

var scene, camera, renderer;

// time-keeping variables

var dt = 1/24; // simulation time unit is 1 day; time-step is 1 hr

var numSteps = 8760; // 1 year; 365*24

var animFreq = 168; // once per week; 24*7

var t = 0;

// gravitational constant

var G;

// sun variables

var center;

var massSun;

var radiusSun = 30;

// arrays to hold velocity and position vectors for all planets

var v;

var s;

// visual objects

var sun;

var planets;

var numPlanets = 4;

// planets' properties

var colors;

var radiuses;

var masses;

var distances;

var velos;

// scaling factors

var scaleTime;

var scaleDist;

var scaleMass;

var scaleVelo;

window.onload = init;

function init(){

setupScaling();

setupPlanetData();

setInitialConditions();

setupObjects();

simulate();

renderer.render(scene, camera);

}

首先,看一下变量的定义。请特别注意,sv现在是保存行星的Vector3D位置和速度的数组。还要注意数组colorsradiuses等等,它们将保存行星的属性。接下来看看init()功能。最终渲染前依次调用以下方法:setupScaling()setupPlanetData()setInitialConditions()setupObjects()simulate()。让我们依次简单讨论一下每一个。

setupScaling()方法正如其名所示:它定义比例因子,然后使用它们将太阳质量和引力常数重新调整到模拟值:

function setupScaling(){

scaleMass = Astro.EARTH_MASS;

scaleTime = Astro.EARTH_DAY;

scaleDist = 1e9; // 1 million km or 1 billion meters

scaleVelo = scaleDist/scaleTime; // million km per day

massSun = Astro.SUN_MASS/scaleMass;

G = Phys.GRAVITATIONAL_CONSTANT;

G *= scaleMass*scaleTime*scaleTime/(scaleDist*scaleDist*scaleDist);

}

setupPlanetData()方法将行星属性的适当值放入五个数组radiusescolorsmassesdistancesvelos。我们为radiuses选择的值与四颗行星的真实半径成比例。但是请注意,它们与太阳的半径不成比例(实际上,太阳的半径比行星的半径大得多)。此外,行星的半径和太阳的半径都与它们之间的距离不成比例。如果我们这样做了,这些行星将会在浏览器窗口的可用空间中变成微小的点。质量、距离和速度是从每个行星的Astro物体读取的缩放值。

function setupPlanetData(){

radiuses = [1.9, 4.7, 5, 2.7];

colors = [0xffffcc, 0xffcc00, 0x0099ff, 0xff6600];

masses = new Array();

distances = new Array();

velos = new Array();

masses[0] = Astro.MERCURY_MASS/scaleMass;

masses[1] = Astro.VENUS_MASS/scaleMass;

masses[2] = Astro.EARTH_MASS/scaleMass;

masses[3] = Astro.MARS_MASS/scaleMass;

distances[0] = Astro.MERCURY_ORBITAL_RADIUS/scaleDist;

distances[1] = Astro.VENUS_ORBITAL_RADIUS/scaleDist;

distances[2] = Astro.EARTH_ORBITAL_RADIUS/scaleDist;

distances[3] = Astro.MARS_ORBITAL_RADIUS/scaleDist;

velos[0] = Astro.MERCURY_ORBITAL_VELOCITY/scaleVelo;

velos[1] = Astro.VENUS_ORBITAL_VELOCITY/scaleVelo;

velos[2] = Astro.EARTH_ORBITAL_VELOCITY/scaleVelo;

velos[3] = Astro.MARS_ORBITAL_VELOCITY/scaleVelo;

}

setInitialConditions()方法使用setupPlanetData()中设置的距离和速度来设置sv中每个行星相对于太阳的位置和速度矢量的初始值。

function setInitialConditions(){

center = new Vector3D(0,0,0);

s = new Array();

s[0] = new Vector3D(distances[0],0,0);

s[1] = new Vector3D(distances[1],0,0);

s[2] = new Vector3D(distances[2],0,0);

s[3] = new Vector3D(distances[3],0,0);

v = new Array();

v[0] = new Vector3D(0,velos[0],0);

v[1] = new Vector3D(0,velos[1],0);

v[2] = new Vector3D(0,velos[2],0);

v[3] = new Vector3D(0,velos[3],0);

}

就像在单个行星的例子中一样,setupObjects()方法然后利用three.js来创建太阳和行星,给每个行星适当的半径、颜色和质量,并在positionObject()方法的帮助下设置初始位置和速度。

function setupObjects(){

renderer = new THREE.WebGLRenderer();

renderer.setSize(width, height);

document.body.appendChild(renderer.domElement);

scene = new THREE.Scene();

var angle = 45, aspect = width/height, near = 0.1, far = 10000;

camera = new THREE.PerspectiveCamera(angle, aspect, near, far);

camera.position.set(0,0,1000);

scene.add(camera);

var light = new THREE.DirectionalLight();

light.position.set(-10,0,20);

scene.add(light);

var sphereGeometry = new THREE.SphereGeometry(radiusSun,10,10);

var sphereMaterial = new THREE.MeshLambertMaterial({color: 0xffff00});

sun = new THREE.Mesh(sphereGeometry,sphereMaterial);

scene.add(sun);

sun.mass = massSun;

sun.pos = center;

positionObject(sun);

planets = new Array();

for (var n=0; n<numPlanets; n++){

sphereGeometry = new THREE.SphereGeometry(radiuses[n],10,10);

sphereMaterial = new THREE.MeshLambertMaterial({color: colors[n]});

var planet = new THREE.Mesh(sphereGeometry,sphereMaterial);

planets.push(planet);

scene.add(planet);

planet.mass = masses[n];

planet.pos = s[n];

planet.velo = v[n];

positionObject(planet);

}

}

function positionObject(obj){

obj.position.set(obj.pos.x,obj.pos.y,obj.pos.z);

}

最后,我们有了simulate()方法,以及相关的clonePlanet()方法,我们在代码的前一个版本中已经有了。这些都是它们之前对应的多个行星的自然推广,加上一个参数n,代表相关行星的阵列索引。RK4()getAcc()方法也被更新以包括这个行星指数参数n,这样他们就知道他们在计算哪个行星。注意getAcc()现在在计算每个行星的合力和加速度时,除了太阳施加的引力之外,还包括了其他每个行星施加的引力。

function simulate(){

for (var i=0; i<numSteps; i++){

t += dt;

for (var n=0; n<numPlanets; n++){

RK4(n);

if (i%animFreq==0){

clonePlanet(n);

}

}

}

}

function clonePlanet(n){

var planet = planets[n];

var p = planet.clone();

scene.add(p);

p.pos = s[n];

positionObject(p);

}

function getAcc(ppos,pvel,pn){

var massPlanet = planets[pn].mass;

var r = ppos.subtract(center);

// force exerted by sun

var force = Forces3D.gravity(G,massSun,massPlanet,r);

// forces exerted by other planets

for (var n=0; n<numPlanets; n++){

if (n!=pn){ // exclude the current planet itself!

r = ppos.subtract(s[n]);

var gravity = Forces3D.gravity(G,masses[n],massPlanet,r);;

force = Forces3D.add([force, gravity]);

}

}

// acceleration

return force.multiply(1/massPlanet);

}

function RK4(n){

// step 1

var pos1 = s[n];

var vel1 = v[n];

var acc1 = getAcc(pos1,vel1,n);

// step 2

var pos2 = pos1.addScaled(vel1,dt/2);

var vel2 = vel1.addScaled(acc1,dt/2);

var acc2 = getAcc(pos2,vel2,n);

// step 3

var pos3 = pos1.addScaled(vel2,dt/2);

var vel3 = vel1.addScaled(acc2,dt/2);

var acc3 = getAcc(pos3,vel3,n);

// step 4

var pos4 = pos1.addScaled(vel3,dt);

var vel4 = vel1.addScaled(acc3,dt);

var acc4 = getAcc(pos4,vel4,n);

// sum vel and acc

var velsum = vel1.addScaled(vel2,2).addScaled(vel3,2).addScaled(vel4,1);

var accsum = acc1.addScaled(acc2,2).addScaled(acc3,2).addScaled(acc4,1);

// update pos and velo

s[n] = pos1.addScaled(velsum,dt/6);

v[n] = vel1.addScaled(accsum,dt/6);

}

运行模拟。请注意,这可能需要几秒钟的时间来运行,这取决于您的计算机的速度以及当前正在运行的其他进程。当它在计算时,似乎什么也不会发生。这是因为在进行任何渲染之前,它会经历整个时间步长循环。计时变量的代码默认值为dt = 1/24、numSteps = 8760、animFreq = 168。回想一下,在模拟中,时间单位是 1 天,dt = 1/24 对应于 1 小时的时间步长。加倍它将意味着模拟可以在相同的挂钟时间内运行两倍的时间,或者可以在挂钟时间的一半内运行相同的模拟时间。

您可以试验一下时间步长,看看在不显著降低精确度的情况下,可以将时间步长调整到多大。numSteps的值 8760 等于 365×24;所以我们正在做一个地球年的模拟。168 的值等于 7 × 24,因此行星的位置每周可视地更新一次。用默认值运行模拟将产生如图 16-10 所示的轨迹。地球当然是从太阳算起的第三颗行星,它在 1 年的模拟时间内完成了一个完整的轨道,这是令人放心的!金星和水星完成不止一个轨道,因此你可以看到克隆体之间的一些重叠。另一方面,火星只完成略多于一半的轨道。如果你把模拟时间减少到 8500 个时间步(约 354 天),你会发现地球只是未能完成一个轨道,正如预期的那样。这是一个很好的迹象,表明我们得到的模拟至少大致正确。

A978-1-4302-6338-8_16_Fig10_HTML.jpg

图 16-10。

A basic solar system model that looks a bit artificial

这是一个功能齐全、物理上相当真实的太阳系模型。除了行星和太阳的大小与它们之间的距离不成比例这一次要事实外,该模型的真正限制是为行星选择的初始条件不是实际的瞬时值;它们是基于它们离太阳距离的平均值和它们的平均轨道速度。此外,行星一开始都排成一条直线,这有点人为。现在让我们解决这些限制。

包含精确的初始条件

我们快成功了,但是要创建一个真正真实的太阳系模拟,你需要使用精确的数据作为初始条件。唯一需要修改的代码实际上是setInitialConditions()方法。你需要在里面放一些真实的数据;你可以从美国宇航局的地平线系统这里得到数据: http://ssd.jpl.nasa.gov/horizons.cgi

该系统为太阳系中的大量物体生成高度精确的轨迹数据(称为星历表)。这些数据是由 NASA 自己模拟生成的,精确度极高(通常是 16 位有效数字!).您可以选择数据类型、坐标原点、时间跨度和其他设置。这个例子的源代码中包含的文件initial_conditions.txt,包含了我们下载的 2012 年 1 月 1 日 00:00 时八颗行星的位置和速度的一些数据。在每个行星的名字旁边有六个字段,分别包含值xyzvxvyvz。位置以千米为单位,速度以千米/秒为单位,都是相对于太阳而言的。在文件名为solar-system.js的代码版本中,我们使用这些数据作为我们正在模拟的四颗行星的初始条件。注意,setupPlanetData()方法也被简化了,去掉了不再需要的distancesvelos数组。

如果你运行模拟,你会发现在适当的初始条件下,模拟的轨迹现在看起来更真实(见图 16-11 )。请特别注意水星轨道特有的偏心率。因为我们可以访问 NASA 的模拟数据,为什么不把我们的模拟和他们的进行比较呢?

A978-1-4302-6338-8_16_Fig11_HTML.jpg

图 16-11。

Planets’ trajectories in a 1-year simulation with our code

将模型结果与 NASA 数据进行比较

我们还从 HORIZONS 网站下载并保存了从 2012 年 1 月 1 日到 2013 年 1 月 1 日一整年的模拟头寸数据。数据以 CSV 格式保存在每个行星的单独文件中,并位于该模拟的源代码文件夹的子目录中。请注意,HORIZONS 系统会随着新测量数据的引入而频繁更新。这意味着,如果您在不同的时间下载数据,您可能无法获得精确到 16 位的相同数字。但是,在大多数情况下,您甚至不会注意到其中的区别!

为了使我们能够将我们的模拟结果与 NASA 的数据进行比较,我们对solar-system.js中之前的代码做了一些小的修改。新代码是文件solar-system-nasa.js。第一个变化是运行模拟 366 天,将 numSteps 的值改为 8784。接下来我们将animFreq的值改为numSteps,这样新行星的位置只在 366 天结束时显示。在渲染之前的init()方法中,我们引入了一个对新方法compareNASA()的调用,该方法创建了行星的克隆体,并将它们放置在第 366 天 NASA 数据给出的位置。剩下的变化是在simulate()方法中(见下面清单中的粗体行),用一个movePlanet()方法替换了clonePlanet()方法,将原来的行星移动到新的位置,而不是克隆它们。还要注意的是,在if语句中,索引i被替换为i+1——这确保了movePlanet()不是在最初被调用(当i = 0 时),而是在最后一个时间步长被调用。

function simulate(){

for (var i=0; i<numSteps; i++){

t += dt;

for (var n=0; n<numPlanets; n++){

RK4(n);

if ((i+1)%animFreq==0){

movePlanet(n);

}

}

}

}

function movePlanet(n){

var planet = planets[n];

planet.pos = s[n];

positionObject(planet);

}

这些变化的结果是,在模拟的第 366 天,每个行星的两个副本被放置在画布上。一组行星是根据我们的模拟计算定位的,另一组位于从 NASA 的数据中读取的位置,每一组都是在初始条件下一年后。那么他们如何比较呢?

运行模拟,你会在图 16-12 中的截图所示的位置看到行星。但是为什么只有一组行星呢?答案是,实际上有两组——它们在彼此之上!为了说服自己,在compareNASA()方法的最后一行之前添加以下代码行:

p.pos = sN[i].multiply(2);

A978-1-4302-6338-8_16_Fig12_HTML.jpg

图 16-12。

Planet positions after one year of simulated time compared with NASA data

这样做的目的是根据美国宇航局的数据,将克隆的一组行星与太阳的距离增加一倍。重新运行代码,你会发现确实有两组行星,其中一组距离太阳的距离是另一组的两倍。这表明 NASA 的克隆体在第一次运行时位于原始行星之上,在这个分辨率下你根本无法区分这两组行星。不错吧,嗯?

尽管我们忽略了一大堆效应,如气态巨行星(尤其是最大的行星,也是离四大类地行星最近的木星)、小行星和相对论效应,但结果看起来相当不错。当然,美国宇航局的模拟有所有这些和更多,加上更先进的数值方法,这使得它更加准确。这将是有趣的运行模拟时间更长或减少时间步长,看看它在什么时候开始显示与美国宇航局数据的明显差异。当然,有一些小的差异,记住屏幕上的 1 px 在现实中是 100 万 km,它们在绝对值上可能是显著的。然而,虽然你不一定能利用这个模拟把你自制的太空探测器送上火星,但你肯定可以用它来教小学生或大学生太阳系是如何工作的。

制作太阳系模拟动画

修改solar-system.js模拟引入一些基础动画并不难。这是在修改后的文件solar-system-animated.js中完成的。首先,我们将对init()simulate()方法的调用替换为对animFrame()方法的调用。然后,我们引入以下修改后的代码,其中大部分应该看起来非常熟悉:

function animFrame(){

animId = requestAnimationFrame(animFrame);

onTimer();

}

function onTimer(){

if (nSteps < numSteps){

simulate();

moveCamera();

renderer.render(scene, camera);

}else{

stop();

}

nSteps++;

}

function simulate(){

t += dt;

for (var n=0; n<numPlanets; n++){

RK4(n);

movePlanet(n);

}

}

function movePlanet(n){

var planet = planets[n];

planet.pos = s[n];

positionObject(planet);

}

function moveCamera(){

camera.position.x += -0.1;

camera.position.z += -0.5;

}

function stop(){

cancelAnimationFrame(animId);

}

请注意,调用渲染器的行现在包含在onTimer()中的时间循环中,因此行星在每个时间步的新位置被重新渲染,产生动画。我们还引入了一个新的moveCamera()方法,它会随着动画的进展移动摄像机。运行模拟来看看这产生的有趣的视觉效果。你可以通过让相机以不同的方式移动来进行实验。例如,您可以从固定地球的角度查看模拟,将moveCamera()中的两行替换为以下行:

camera.position.set(planets[2].position.x,planets[2].position.y,planets[2].position.z+200);

图 16-13 显示了修改后的动画截图。

A978-1-4302-6338-8_16_Fig13_HTML.jpg

图 16-13。

Animated version of the solar system simulation

轮到你了

有许多方法可以开发或扩展这种模拟。最明显(也是最直接)的改进是增加四颗外行星。您还可以添加交互性、动态缩放、增强的动画效果等等。

这里还有大量的 3D 功能等待开发。也许你可以使用包裹在上面的行星的真实图像地图。然后,您可以添加旋转和缩放视图的功能。也许你甚至可以展示行星靠近时的旋转。我们不打扰你了。

摘要

随着这本书的完成,你现在有了一套强大的工具供你使用,这将帮助你构建更真实、更吸引人的游戏和动画,以及一些非常强大的模拟。从第一章中的简单弹跳球模拟开始,你已经走了很长一段路,真的!

让我们快速回顾一下这本书涉及的所有内容。在第一部分中,您浏览了大量 JavaScript、数学和物理学的背景材料,以建立与基于物理学的运动和模拟相关的关键概念和技术。

第二部分讲述了运动的基本定律,你会遇到各种各样的力,构建了许多例子来展示这些力产生的有趣的运动类型。

在第三部分中,您模拟了由多个粒子或扩展对象组成的系统,包括粒子碰撞、刚体和可变形体。

最后,在第四部分中,您研究了如何创建更复杂的模拟,这些模拟需要更多地关注数值精度,或者涉及 3D 或比例建模。

那是很多材料。

这最后一章只是提供了一个微小的机会,让我们看到将所有的物理学结合在一起并投入使用的可能性。我们希望您将继续从这些项目以及您将从头构建的许多其他项目中获得一些真正的乐趣。我们邀请您在本书的网站 www.physicscodes.com 上分享您的创作!

第一部分:基础

第二部分:粒子、力和运动

第三部分:多粒子和扩展系统

第四部分:构建更复杂的模拟