原文: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 所示。
图 1-1。
Steps in programming physics
第一步是确定适用于您正在建模的情况的物理原理。如果你没有物理学背景,这可能会很棘手。这本书会对你有所帮助:它不仅仅是一本操作指南,也是为了教你一些物理知识。第二步是回忆、研究或推导相关方程。显然,这一步涉及到一些数学。不用担心;我们会给你所有你需要的帮助!第三步是开发求解方程的算法。有时方程可以解析求解(我们将在后面的章节中解释这意味着什么),在这种情况下算法非常简单。更常见的情况是,人们需要使用数值方法,这些方法可能简单,也可能不那么简单,这取决于问题和所需的精度水平。虽然前两步似乎显而易见,但第三步往往被忽视。事实上,许多开发人员甚至没有意识到它的存在或必要性。同样,我们会在这方面花些时间,尤其是在本书的第四部分。第四步,也是最后一步,用你最喜欢的编程语言编写代码。你已经很擅长这个了,不是吗?
一个简单的物理模拟例子
为了了解图 1-1 中描述的过程在实践中是如何工作的,我们现在来看一个简单的例子。我们将用几行代码模拟一个球被抛向地面的运动。
首先,让我们描绘一下我们试图建模的场景,它在现实中的行为方式。假设你把一个排球抛向空中。它是如何移动的?你可能已经注意到这样一个球不是直线运动,而是沿着一条曲线运动。此外,球似乎在曲线的顶部移动缓慢,而在底部靠近地面的地方移动迅速。当它落地时,通常会反弹,但总是比它落下的高度低。在我们试图重现这种运动之前,让我们更仔细地研究一下引起这种运动的物理学。
弹跳球的物理学
正如你现在已经知道的,力是导致物体运动的原因。因此,理解排球为什么以这种方式运动的第一条线索是找出是什么力量在作用于它。稍后你会了解到,在日常情况下,通常有许多力一起作用在物体上。但在这种情况下,有一种力量比其他任何力量都重要得多。这是地球施加在球上的重力。
所以让我们假设一旦球被抛向空中,重力是唯一作用在球上的力。谢天谢地,重力以一种简单的方式起作用。靠近地球表面,如本例所示,它是一个垂直向下的恒力。因此,它的作用是将物体向下拉,使它们加速。加速?是的,这意味着它增加了物体的速度。正如我们将在后面的章节中更详细地讨论的那样,重力每秒钟以恒定的量增加一个物体的垂直速度。但是因为重力向下作用,所以不影响物体的水平速度。
球每落地一次,后者就对其施加一个接触力(接触力是两个固体物体直接接触时相互施加的力)。这个力向上作用的时间很短。与重力不同,直接对这种接触力建模并不容易。因此,我们将简化事情并对其效果建模。它的作用是在降低球的速度的同时,将球的运动从向下逆转到向上。
在 2D 编码一个弹跳球
为了简化场景和生成的代码,我们将假设我们生活在一个 2D 世界中。2D 中的物体可以沿着两个独立的方向移动:水平和垂直。我们将用两个数字来表示球在任意给定时间的位置,x
和y
,其中x
表示水平位置,y
表示垂直位置。我们将球沿这两个方向运动的速度记为vx
和vy
。
根据我们所说的,时钟每滴答一次,重力就会导致vy
增加一个恒定的量,但是vx
会保持不变。
因为vx
和vy
是速度,它们告诉我们时钟每次滴答时物体移动了多少。换句话说,在时钟的每一个滴答声中,x
增加了一个量vx
,而y
增加了一个量vy
。
这实现了重力的效果。要实现地面的效果,我们要做的就是把vy
的符号反过来,在每次球落地的时候减小它的大小。信不信由你,差不多就是这样了。
终于有代码了!
图 1-2 中所示示例的 JavaScript 代码包含在bouncing-ball.js
文件中,该文件可以在apress.com
与书中的所有其他源代码一起下载。
图 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
,因为重力只垂直作用。然后我们通过将x
和y
分别增加vx
和vy
来更新球的位置。后续代码负责球的反弹,并在球离开画布时回收球。函数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 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 )。
图 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 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, Date
和Math
对象(我们将在本章后面讨论)。要创建一个新的对象,可以使用两种不同形式的语法
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.PI
和Math.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, Undefined
和Null
(后两者有时称为特殊数据类型);非原始数据类型包括Object, Array
和Function
(都是对象类型)。表 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
只能有两个值之一:true
或false
。例如:
var bln = false;
请注意,值true
或false
没有用引号括起来;它不是一个字符串。必须特别小心,因为 JavaScript 变量是动态类型的。因此,如果bln
后来被赋值如下:
bln = "true";
因为有引号,它将变成一个字符串变量!
未定义和空
Undefined
数据类型只有一个值:undefined
。不存在的属性或已声明但未赋值的变量假定值为 undefined。没有 return 语句的函数返回 undefined。函数的未提供的参数也假定一个未定义的值。
Null
数据类型也只有一个值:null
。例如,null
和undefined
的一个重要区别是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 ... in
、for each ... in
和while
循环。我们不会描述前两个,因为我们在本书中不会用到它们。
在一个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);
}
clientX
和clientY
属性提供了一种简单的方法来跟踪鼠标的位置。必须在代码开头声明isDragging Boolean
变量并将其设置为false
:
var isDragging = false;
顾名思义,isDragging
告诉我们对象是否被拖动。这需要在拖动发生时停止代码的物理部分的执行。因此,我们将物理代码包装在函数onEachStep
的下面的if
块中:
if (isDragging==false){
execute physics code
}
您还需要将vx
和vy
的初始值设置为零,ball.y
的初始值设置为canvas.height – radius
(这样它最初在地面上是静止的),并将ball.x
设置为任何合适的值,以便它在舞台上可见。修改后的代码在bouncing-ball-drag-drop.js
中。试试看。您会注意到几个奇怪的地方——首先,即使您在球外单击,球也会移动到鼠标位置;第二,球的中心跳到鼠标位置。当你在下一章学习如何计算两点之间的距离时,你将能够解决这些问题。
画布坐标系
在现实世界中,事物存在于空间中。在 HTML5 世界中,等价的是对象存在于 canvas 元素上。要知道如何在画布上定位对象,有必要了解画布坐标系。
画布坐标系与数学中常见的笛卡尔坐标系有些不同。在普通坐标几何中,x 坐标从左到右,y 坐标从下到上(见图 2-2b )。然而,在 canvas 中,y 坐标以相反的方式运行,从上到下(见图 2-2a )。原点在可视舞台的左上角。
图 2-2。
2D coordinate systems compared: (a) in canvas and (b) math
通常的笛卡尔系统被称为右手坐标系,因为如果你握住右手,手指部分闭合,拇指伸出纸面,手指将从正 x 轴指向正 y 轴。言下之意,canvas 中的坐标系是左手坐标系。
画布坐标系中的另一个奇怪之处是,角度是从正 x 轴方向顺时针测量的(见图 2-3a )。数学上的通常惯例是从正 x 轴逆时针测量角度(见图 2-3b )。
图 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 )。
图 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);
};
预计运动
正如你现在所知道的,我们使用的用代码制作对象动画的方法是通过“动态”计算对象位置的更新来实现的。也有可能预先计算一个物体的运动,然后制作动画。这可以通过使用for
或while
循环来表示时间步进,计算每一步的粒子位置,并将位置坐标保存在一个数组中来实现。
你为什么要这么做?例如,如果计算需要很长时间才能完成,并且无法在合理的帧速率内完成,那么它就非常有用。
这种方法的缺点是它不支持交互性。因为用户交互通常是基于物理的应用的一个重要方面,所以我们一般不会使用这种方法。
摘要
哇哦!这一章是对 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
对象的实例。接下来的四个参数(xmin
、xmax
、ymin
、ymax
)表示 x 和 y 的最小和最大期望值。接下来的两个参数(x0
、y0
)表示原点在画布坐标系中的坐标,以像素为单位。最后两个参数以像素为单位指定图形对象的宽度和高度。
函数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 ( xmin
、xmax
、ymin
和ymax
)中的范围设置为(–4,4,0,20)。(xmajor
、xminor
、ymajor
和yminor
)的合理选择是(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)
x
和y
的值在前两个参数中被指定为单独的数组。第三个可选参数color
是一个表示绘图颜色的字符串。它的默认值是“#0000ff”,代表蓝色。最后两个参数dots
和line
,是可选的布尔参数。如果dots
是true
(默认),在每个点用指定的颜色画一个小圆(半径 1 像素)。如果line
是true
(默认),这些点由一条相同颜色的线连接。如果您查看代码,您会看到点是使用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
类为不同类型的数学函数生成图表。
图 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;
图 3-2。
A polynomial curve y = –0.5 x5 + 3x3 + x2 – 2x – 3
增长和衰退的事物:指数函数和对数函数
还有许多其他类型的函数可以显示有趣的行为。一个有趣的例子是指数函数,它在物理学中随处可见。它的数学定义如下
你可能想知道 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。
图 3-3。
Exponential growth function exp(x) (solid curve) and decay function exp(–x) (dotted curve)
当然,没有什么能阻止我们组合功能,你可以通过这样做得到有趣的形状。例如,尝试绘制–x2的指数:
这给出了一条钟形曲线(技术上称之为高斯曲线),如图 3-4 所示。它在中间有一个最大值,在两边迅速下降到零。在图 3-4 中,由于绘制图形的分辨率有限,曲线似乎下降到零。实际上,它永远不会精确到零,因为对于 x 的大的正值和负值,exp(–x2)变得小到几乎为零,但永远不会精确到零。
图 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,看看相反的效果。
图 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()
函数更直观、更详细。它可以帮助您理解代码中的逻辑或物理,并识别任何潜在的问题。把它想象成汽车或飞机上的仪表板或仪器面板,很快你就会比你想象的更多地使用它!
圆圈的麻烦在于
现在,让我们尝试使用前两节中使用的相同方法围绕一个圆移动一个对象。这应该够简单了吧?首先,我们需要一个圆的方程。你可以在任何一本初等几何教科书中找到这个。
以(a,b)为原点,半径为 r 的圆的方程式如下:
如果您尝试遵循与前两节完全相同的步骤,您会希望将 y 作为 x 的函数。回想一下您的代数,您应该能够操作前面的等式来得到以下结果:
为了使任务简单,选择 a = b = 0,r = 1(圆心在原点,半径为 1):
现在我们来绘制图表。你需要在两个轴上使用相同的缩放比例,这样你的圆看起来就不会扭曲。如果您这样做并运行代码,您将得到看起来部分正确的东西,但是有几个奇怪的问题。第一个是,你最终只能得到一个半圆,而不是整个圆。第二个问题是,球需要一点时间出现,然后在结束时消失。图 3-6 显示了结果图。
图 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]);
}
第一个问题更棘手。只创建一个半圆是因为当我们用平方根来得到前面的等式时,我们也应该包括负数:
问题是你不能让一个函数同时返回两个不同的值。还有一个问题,这次是关于动画的。你可能已经注意到,球似乎沿着圆圈的某些部分快速移动,而在其他地方缓慢移动。这通常不是我们想看到的。它的出现是因为我们一直在等量增加 x。但是在曲线“陡峭”的地方,与曲线更“平坦”的地方相比,x 的相同增量导致 y 的更大增量
通常我们想让一个物体以匀速圆周运动。有没有办法知道球的位置是如何依赖于时间和转速的?有。是时候学习一下参数方程了。
使用参数方程
我们在上一节中遇到的基本问题是,我们知道 y 是 x 的函数,但不知道 x 或 y 如何依赖于时间。我们所追求的是这种形式的一对方程,其中 f (t)和 g (t)是时间的函数:
它们被称为参数方程,因为 x 和 y 是用另一个参数 t 来表示的,在这种情况下,t 代表时间。从这些参数方程中,应该可以恢复出连接 x 和 y 的方程,但反过来就不一定了。参数方程不是唯一的;可能有不同的参数方程对。
我们在这里给你一个答案。虽然在你学过一些三角学和我们讲过角速度的概念(下一节)之前不会有完全的意义,但在这里(其中 r 是圆的半径,ω是所谓的角速度,基本上是绕圆旋转的速率):
请注意,我们在这里使用的概念将在本章的后面部分详细解释。因此,尽管这些概念现在可能还不完全清楚,但它们应该很快就会变得清晰。
让我们选择 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。但是很高兴知道,除了动态计算对象的位置之外,还有另一种方法可以用代码来制作对象的动画。这种方法可以证明是有用的,例如,如果计算太耗时;然后可以在“初始化”期间完成这些操作,并将位置存储在一个数组中(如前所述),随后以通常的方式制作动画。
图 3-7。
Moving an object around a circle using parametric equations
求两点之间的距离
一个常见的问题是在给定两个物体位置的情况下找出它们之间的距离,这在例如碰撞检测中是需要的。一些物理定律也涉及两个物体之间的距离。比如牛顿万有引力定律(在第六章中涉及)涉及两个物体之间距离的平方。
幸运的是,有一个简单的公式,基于勾股定理,允许我们计算两点之间的距离。勾股定理实际上是一个关于三角形边长的定理。具体来说,它适用于一种特殊类型的三角形,称为直角三角形,其中一个角为 90 度。三角形的最长边称为斜边,总是与 90 度角相对(见图 3-8 )。
图 3-8。
A right triangle
毕达哥拉斯定理指出,如果我们取其他每条边,计算它们的长度的平方,并将结果相加,我们将得到最长边的长度的平方。用公式表示,如下,其中 c 是斜边的长度,a 和 b 是其他两条边的长度:
图 3-9 展示了我们如何使用这个公式计算两点之间的距离:通过画一个直角三角形。如果两点的坐标分别为(x1,y1)和(x2,y2),则两个较短边的长度分别为(x2–x1)和(y2–y1)。
图 3-9。
Using the Pythagorean Theorem to calculate the distance between two points
因此,斜边的长度,实际上是两点之间的距离 d,由下式给出:
距离 d 然后通过取前一个表达式的平方根来获得。就这样。此外,该公式可以很容易地推广到 3D,给出如下公式:
请注意,如果我们用(x1–x2)2代替(x2–x1)2并不重要,其他项也是如此,因为负数的平方与对应的正数的平方相同。
基础三角学
三角学是数学的一个分支,专门研究三角形的性质以及它们的边长和角之间的关系。换句话说,三角学听起来可能不是很特别,但事实上,它是动画或游戏程序员工具集不可或缺的一部分。例如,我们以前在产生圆周匀速运动的参数方程中使用三角函数。
你可能还记得学生时代的基础知识,比如三角形内角之和是 180 度的定理。但也许你不记得什么是正弦函数。这一节将回顾这门学科的要点。
角度和弧度
大家都知道一次完整的旋转有 360 度。很少有人知道 360 度等于 2π弧度(或者可能从未听说过弧度)。所以我们先来解释一下什么是弧度。
简单的解释是:弧度是角度的度量单位(就像度一样)。以下是这两种方法的关系:
- 2π弧度等于 360 度
- 所以π弧度等于 180 度
- 所以 1 弧度等于 180/π度,大约是 57.3 度
现在你可能对弧度的概念感到不舒服。为什么我们需要这样一个奇怪的角度单位?难道我们都不知道并热爱学位吗?好吧,重点是度数也是任意的——为什么一个圆有 360 度?为什么不是 100?
事实上,弧度在很多方面是一个更“自然”的角度单位。这是根据它的定义得出的:长度等于半径的弧在圆心所对的角。参见图 3-10 。
图 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)有关。相对于另一个角度,对立和相邻的角色颠倒了。另一方面,斜边总是最长的一边。
图 3-11。
Definition of hypotenuse, adjacent, and opposite sides of a right triangle
正弦函数被简单地定义为对边的长度与斜边的长度之比:
现在你可以为不同的角度 x 画许多直角三角形,测量 opp 和 hyp,计算它们的比率,并制表和绘制 sin (x)来看看它是什么样子。但是你肯定更喜欢挖出那个Graph
物体并绘制Math.sin()
。这就是我们在trig-functions.js
所做的。
图 3-12 显示了我们得到的结果。我们在–720 度(–4π弧度)和 720 度(4π弧度)之间绘制了图表,以显示函数的周期性质。周期(它重复的间隔)是 360 度,或 2π弧度。曲线就像平滑的波浪。
图 3-12。
The graph of a sine function
请注意,sin (x)始终介于–1 和 1 之间。在量级上永远不可能大于 1,因为 opp 永远不可能大于 hyp。我们说正弦波的峰值振幅为 1。sin (x)的值在 0 度以及此后和之前每隔 180 度为零。
余弦函数
与正弦类似,余弦函数定义为邻边长度与斜边长度之比:
图 3-13 所示的曲线与 sin (x)相似,除了 cos (x)看起来相对于 sin (x)移动了 90 度。其实结果是 cos(x–π/2)= sin(x)。继续,通过绘制这个函数来证明它。这表明 cos 和 sin 仅相差一个常数。我们说它们有 90 度的相位差。
图 3-13。
The graph of a cosine function
正切函数
第三种常见的 trig 函数是正切函数,定义如下:
这个定义相当于:
如果绘制 tan x 的曲线图,乍一看可能会有点奇怪(见图 3-14 )。忽略 90 度的垂直线,以此类推。他们不应该真的在那里。该图仍然是周期性的,但它由每隔 180 度的不连续分支组成。此外,tan (x)可以取任何值,而不仅仅是–1 和 1 之间的值。90 度的情况是 tan (x)变得无穷大。回到 tan (x)的定义,这是因为对于 90 度,adj 为零,所以我们最后除以零。
图 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 度一样。
图 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 表示)。
不难看出,一般来说,频率是由周期的倒数给出的:
现在,事实证明,你总是可以把球的上下运动想象成另一个在圆周上匀速运动的假想球的位置的投影(见图 3-16 )。一个周期的振荡(类似波浪的运动)相当于围绕圆周的一次完整的旋转。想想旋转的球移动的角度,那是 2π弧度。因为球每秒移动 f 个周期,每个周期是 2π弧度,这意味着球每秒通过 2πf 弧度。这被称为运动的角频率或角速度(通常用希腊字母ω,omega 表示)。它告诉你旋转的球每秒移动的角度,单位是弧度。
图 3-16。
Relationship between oscillation, circular motion, and a sine wave
最后,因为ω是假想球每秒移动的角度,所以在 t 秒内,球将移动ω t 弧度。因此,如果它以 0 弧度的角度开始,在 t 秒时,它的投影位移等于 sin (ω t):
如果我们知道振荡的角频率,我们可以计算出球在任何时候的位置,如果它的初始位置是已知的。当然,如果我们知道周期或频率,我们可以通过前面的公式计算出角频率。
让我们看一些例子。对于这些例子,我们使用的是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 显示了您可能会看到的典型模式。
图 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 )。
图 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 时,曲线几乎是完美的方波。
图 3-19。
Fourier series pattern for a square wave with N = 10
向量和基本向量代数
向量是有用的数学构造,它简化了我们处理速度和力等物理量的方式,这将在下一章详细讨论。向量代数由操作和组合向量的规则组成。当你们讨论速度分量的时候,你们已经非正式地接触过向量了。现在让我们更正式地介绍矢量。
什么是矢量?
用位移的例子可以最直观地说明矢量。位移的概念将在第四章中详细介绍,但本质上它指的是给定距离和给定方向的运动。这里有一个例子。
假设那只虫子,瓢虫,正在一张图表纸上爬行。Bug 从原点开始,爬行 10 个单位的距离;然后他停下来。Bug 接着继续爬行 10 个单位,然后再次停止。Bug 离原点有多远?有人可能会说 20 个单位。但是如果 Bug 第一次向上爬,第二次向右爬呢?(参见图 3-20 )。显然 20 个单位是错误的答案。通过使用毕达哥拉斯定理,答案实际上是,或者大致是 14.1 个单位。
图 3-20。
The direction of displacement matters!
不能简单的把两个距离相加,因为还需要考虑到 Bug 移动的方向。为了分析位移,你需要一个大小和一个方向。
在数学中,我们将这个概念抽象为向量的概念,向量是一个有大小和方向的量。
位移不是唯一的向量。许多运动的基本概念,如速度、加速度和力,都是矢量(它们将在下一章讨论)。这一节概述了作为抽象数学对象的矢量代数,不管它们是位移、速度还是力。掌握一点向量代数,你可以通过减少你必须处理的方程的数量来简化计算。这节省了时间,减少了出错的可能性,简化了生活。
向量与标量
向量通常与标量形成对比,标量是只有大小的量。距离或长度是一个标量,你只需要一个数字来指定它。但是位移是一个向量,因为你需要指定位移的方向。形象地说,向量通常被表示为有方向的直线——一条上面有箭头的线。
注意,向量的唯一特征是它的大小和方向。这意味着两个具有相同大小和方向的矢量被认为是相等的,不管它们在空间的什么位置(见图 3-21a)。换句话说,矢量的位置无关紧要。然而,这个规则有一个例外:位置向量。物体的位置矢量是连接固定原点和物体的矢量,所以它是相对于固定原点的位移矢量(见图 3-21b)。
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 )。合成位移是多少?这就更棘手了:你不能直接应用勾股定理,因为你不再有直角三角形了。你可以使用一些更复杂的三角学,或者精确地画出矢量,然后拿一把尺子测量合成的距离和角度。
但实际上,你不会这么做。使用矢量分量来加减矢量要容易得多。现在我们来看看矢量分量。
图 3-22。
Addition of vectors
解析向量:向量分量
如前所述,矢量有大小和方向。这在 2D 意味着你需要两个数字来指定一个向量。看一下图 3-23 。这两个数字是向量的长度 r 和它与 x 轴的夹角θ。但是我们也可以将这两个数字指定为向量在 x 和 y 方向上的范围,分别用 x 和 y 表示。这些被称为矢量分量。
图 3-23。
Vector components
(r,θ)和(x,y)之间的关系可以通过上一节讨论的简单三角学得到。再次查看图 3-23 ,我们知道:
这给出了以下内容:
以这种方式从矢量的大小和角度计算出矢量分量被称为沿 x 轴和 y 轴“分解矢量”。实际上,你可以沿着任意两个相互垂直的方向分解一个矢量。但大多数时候,我们会沿着 x 轴和 y 轴来做。
向量有许多不同的符号,很容易陷入符号的困境。但是在 JavaScript 中,你只需要记住向量是有分量的。所以任何有助于记忆的符号都是一样好的。为了简洁起见,我们将使用下面的符号,用粗体字母表示向量,用方括号括起它们来表示它们的组成部分:
在 3D 中,你不需要两个,而是三个组件来指定一个向量,所以我们这样写:
位置向量
物体的位置向量是从原点指向该物体的向量。因此,我们可以用物体的坐标来表示位置向量。在 2D:
在 3D 中:
使用组件添加向量
下面是如何使用组件添加两个向量:
很简单,不是吗?为什么会这样,从图 3-24 中应该很清楚。将两个矢量的水平分量和垂直分量分别相加。这就给出了合成矢量的分量。
图 3-24。
Adding vectors using components
作为一个例子,将此应用于图 3-22 所示的 Bug 位移问题,可以得到以下位移,您可以很容易地在图上验证:
随意用纸笔练习做向量加法;在经历了一些之后,这个过程应该变得明显,并且帮助你建立你对向量和向量分量的直觉。
类似地,使用分量的矢量减法也很简单:
将一个向量乘以一个数
将一个向量乘以一个数,就是将它的每个分量乘以那个数:
特别是,如果我们将一个向量乘以–1,我们会得到这个向量的负值:
将一个向量除以一个数 N 就等于将这个向量乘以这个数的倒数 1/N。
矢量幅度
矢量的大小(长度)是通过将勾股定理应用于其分量而得到的:
- 在 2D,[x,y]的量级是;或者用 JavaScript 代码:
Math.sqrt(x*x+y*y)
。 - 在 3D 中,[x,y,z]的大小为;或者用 JavaScript 代码:
Math.sqrt(x*x+y*y+z*z)
。
注意,如果我们把一个向量除以它的大小,我们会得到一个长度为一个单位的向量,它与原始向量的方向相同。这叫做单位向量。
向量角度
从矢量的分量计算矢量的角度并不比计算其大小更困难。在 JavaScript 中,使用Math.atan2()
函数最容易做到这一点。
向量[x,y]的角度由Math.atan2(y, x)
给出。注意——您需要首先将参数指定为 y,然后是 x。
乘法向量:标量或点积
两个向量相乘可能吗?答案是肯定的。数学家定义了一种叫做标量积的“乘法”运算,其中两个向量产生一个标量。回想一下,标量只有大小,没有方向;换句话说,它基本上只是一个数字。规则是将每个向量的相应分量相乘,然后将结果相加:
标量积用一个点表示,所以也叫点积。
用矢量幅度和方向表示,点积由下式给出,其中θ是两个矢量之间的角度,r1 和 r2 是它们的长度:
标量积的几何解释是,它是一个向量的长度与另一个向量的投影长度的乘积(见图 3-25 )。
图 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,其点积如下所示:
标量积出现在物理学的几个地方。下一章我们会碰到一个例子,关于功的概念,它被定义为力和位移的点积。因此,尽管点积作为一种数学构造可能显得有些抽象和神秘,但当你在第四章的应用上下文中再次遇到它时,它将有望变得更直观。
乘法向量:向量或叉积
在 3D 中,还有另一种类型的乘积,称为矢量乘积,因为它给出的是矢量而不是标量。这条规则要复杂得多。
如果有两个向量 a = [x1,y1,z1]和 b = [x2,y2,z2],并且它们的矢量积由向量 c = a ×b = [x,y,z]给出,则 c 的分量由下式给出:
这不太直观,对吧?就像数学中的一切一样,这肯定是有一些逻辑的。但试图解释这一点会转移太多的注意力。所以我们就接受这个公式吧。
矢量积也称为叉积,因为它用“叉”符号来表示。两个矢量 a 和 b 的矢量积给出了第三个矢量,它垂直于 a 和 b。
就矢量幅度和方向而言,叉积由下式给出:
其中 a 和 b 分别是 a 和 b 的大小,θ是 a 和 b 之间的较小角度,n 是垂直于 a 和 b 的单位向量,并且根据右手法则定向(参见图 3-26 ):握住右手,食指指向第一个向量 a,中指指向 b 的方向,然后 n 将指向拇指的方向,保持垂直于 a 和 b。
图 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 或其他量)。为此,我们将使用图表来帮助我们更容易地可视化关系。
让我们从两个相互之间有简单线性关系的量开始;也就是说,它们之间的关系是
正如你在坐标几何部分看到的,这意味着 y 对 x 的图形是一条直线。常数 b 是 y 轴截距;直线与 y 轴相交的点。我们现在将证明常数 a 给出了 y 随 x 的变化率的度量。
看图 3-27 。两条线中,哪一条 y 随 x 变化更快?
图 3-27。
The gradient (slope) of a line
很明显,它是 A,因为它“更陡”,所以 x 的相同增加导致 y 的更大增加。因此,直线斜率的陡度给出了 y 相对于 x 的变化率的度量。我们可以通过如下定义变化率来使这一想法更精确:
变化率=(y 的变化)/(x 的变化)
习惯上使用符号δ来表示“变化”,因此我们可以这样写:
让我们给变化率取一个较短的名字;姑且称之为梯度。想法是这样的:在坐标为(x1,y1)和(x2,y2)的直线上取任意两点,计算 y 的差值和 x 的差值,并将前者除以后者:
有了一条直线,你会发现无论你取哪一对点,你都会得到相同的梯度。此外,该梯度等于 a,即公式 y = ax + b 中 x 的倍数。
这是有意义的,因为一条直线有一个恒定的斜率,所以你在哪里测量它并不重要。这是一个非常好的开始。我们有一个计算梯度或变化率的公式。唯一的问题是,它仅限于彼此线性相关的量。那么我们如何将这个结果推广到更一般的关系(比如当 y 是 x 的非线性函数时?)
变化率:衍生品
你已经知道非线性函数的图形是曲线而不是直线。直观上,曲线的斜率不是恒定的,而是沿着曲线变化的。因此,无论非线性函数的变化率或梯度如何,它都不是常数,而是取决于 x。换句话说,它也是 x 的函数。
我们现在的目标是找到这个梯度函数。在数学课程中,你会做一些代数运算,然后得出梯度函数的公式。比如你可以从函数 y = x 2 开始,说明它的梯度函数是 2x。但是因为你没有上过数学课,所以让我们用代码来代替。但是首先,我们需要定义曲线的梯度是什么意思。
因为一条曲线的坡度是沿着曲线变化的,所以我们把重点放在曲线上的一个固定点 P 上(图 3-28 )。
图 3-28。
The gradient of a curve
然后让我们在 P 点附近选取任何其他点 Q,如果我们画一条连接 P 和 Q 的线段,那条线的斜率就近似于曲线的斜率。如果我们想象 Q 接近 P,我们可以期望线段 PQ 的梯度越来越接近曲线在 P 处的梯度。
实际上,我们所做的是使用之前的直线梯度公式,并减少δx 和δy 的间隔:
然后,我们可以沿着曲线在不同的位置对点 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 的一个极限值,我们这样写:
这是函数 y 相对于 x 的导数的标准符号。另一个符号是 y’(“y 素数”);或者我们写 y = f(x),导数也可以用 f’(“f 素数”)来表示。
没有什么可以阻止你计算导数的导数。这被称为二阶导数,写作如下:
事实上,你会在下一章发现,速度是位置(相对于时间)的导数,加速度是速度的导数。所以加速度是位置对时间的二阶导数。记数法中,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]:
离散微积分:差分方程
我们在前面的代码中计算梯度函数时所做的是离散微积分的一个例子:使用数值方法计算导数。数值方法基本上是一种使用代码以近似方式执行数学计算的算法。如果我们没有计算数量的精确公式,这是需要的。
在前面的例子中,我们有 y = x 2 并且需要使用导数的离散形式来计算 y ’:
为此,我们使用了以下形式的差分方程:
这被称为向前差分方案,因为我们在第 n 步使用下一步 n+1 的函数值计算导数。
还有很多其他的差分方案。让我们试试中心差分格式:
它被称为中心差分格式,因为我们在点 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 的梯度函数,也就是我们的原始函数——所以中心差分格式做得非常好。使用前向差分方案计算的另一行有一点偏移,这意味着它将有一个相关的误差。这被称为数值积分误差。当然,所有的数值方法都会有误差。但是在这种情况下,由于中心差分方案产生的误差非常小,以至于在图上看不到。我们将在第十四章的中更详细地讨论数值精度。
图 3-29。
Derivatives computed by the forward (thin line) and central (thick line) difference schemes
做加法:积分
现在我们问这样一个问题:反过来有可能吗?假设我们知道一个函数的导数;我们能找到函数吗?答案是肯定的。这就叫融合。同样,通常在数学中,你会用分析的方法来做。但这里我们将通过代码进行数值积分。
作为数值积分的一个例子,让我们反转前面的向前差分方案以找到 y(n+1),它给出如下结果:
现在 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.js
和bouncing-ball.js
)中使用的正向方案是最简单的集成方案。在第十四章,我们将详细讨论数值积分和不同的积分方案。
摘要
哇哦!很好地幸存了这一章。你现在有了一套出色的工具供你使用。希望你能开始看到应用这些想法和方法的潜力。毫无疑问,你有许多自己的想法。
如果你对本章的一些内容有困难,不要担心。当你在后面的章节中看到实践中应用的概念时,事情会变得清楚得多。你可能想要再次回到这一章来刷新你的记忆和加强你的理解。
你现在准备好上物理课了。所以,好好休息一下,当你觉得准备好了,让我们进入下一章。