代码的本质-全-
代码的本质(全)
原文:
zh.annas-archive.org/md5/91f83195a1fa4705075725b8059f2978译者:飞龙
前言
十多年前,我自费出版了The Nature of Code,一本在线资源和印刷书籍,通过创意编程框架 Processing 探讨软件中自然界的不可预测的进化和涌现特性。从那时起,技术和创意媒体的世界发生了翻天覆地的变化,简直可以说是本世纪的轻描淡写。现在,我再次带着这本书的新版本和重启版出现,这次围绕 JavaScript 和 p5.js 库构建。书中有一些新的编程技巧,但本质上还是那个熟悉的自然——鸟儿仍然拍打翅膀,苹果依旧会掉到我们的头上。
这本书是什么?
在 ITP/IMA(纽约大学 Tisch 艺术学院),我自 2004 年起教授名为《计算媒体导论》的课程。这个课程的起源可以追溯到 1987 年,源自 Mike Mills 和 John Henry Thompson(Lingo 编程语言的发明者)的工作。在这门课程中,学生们学习编程基础(变量、条件语句、循环、对象、数组),以及与制作互动媒体项目相关的概念(像素、数据、声音、网络、3D 等)。2008 年,我将这些课程材料汇编成一本入门书籍《Learning Processing》,2015 年,我制作了一系列视频教程,内容与这本书相似,不过这次是在 JavaScript 和 p5.js 库下进行的。
一旦学生掌握了基础知识,并见识了各种应用,他们的下一步可能是深入研究某个特定领域。也许他们想专注于计算机视觉、数据可视化或生成诗歌。我的《Nature of Code》课程(自 2008 年起在 ITP/IMA 教授)代表了另一个可能的下一步。该课程正好接续了我的入门课程,展示了一系列集中于算法和仿真的编程技巧。你现在阅读的这本书正是从那门课程演变而来。
本书的目标很简单:我想探讨物理世界中自然发生的现象,并找出如何编写代码来模拟它们。
那么,这本书到底是什么?它是一本科学书籍吗?答案是否定的。确实,我可能会研究一些源自物理学或生物学的主题,但我不会以特别高的学术严谨度来探讨这些主题。相反,这本书是“受到实际事件的启发”。我会挑选科学和数学中需要的部分来构建自然的计算机软件解释,并根据需要偏离主题或跳过一些细节。
这是一本艺术或设计书吗?我也会说不是。尽管我的方式可能比较非正式,但我依然专注于算法及其相关的编程技术。没错,结果展示是可视的(以 p5.js 动画示例的形式呈现),但它们仅仅是算法和编程技术的字面可视化,全部用基本的图形和灰度色彩绘制。然而,我的希望是,你,亲爱的读者,能通过你的创意和视觉构思,利用这些示例创造出新的、引人入胜的作品。(如果你把每个示例都变成彩虹,我不会抱怨。)
归根结底,如果这本书有什么特点,那就是它是一本文法较为传统的编程教材。尽管某些科学话题(如牛顿物理学、细胞生长、进化)可能会为某一章节提供灵感,并且结果可能会启发艺术项目,但内容本身始终会归结为代码实现,特别侧重于面向对象编程。
关于 p5.js 的说明
p5.js 库是对 Processing 创意编码环境在现代 Web 上的重新构想。我在这本书中使用它有几个原因。首先,它是我非常熟悉的环境。虽然最初基于 Java 的 Processing 是我的初恋,仍然是我尝试新想法时常用的工具,但 p5.js 是我现在用来教授许多编程课程的工具。它是免费的、开源的,并且非常适合初学者,且由于它是 JavaScript 编写的,所有内容都直接在 Web 浏览器中运行——无需安装。
然而,对我来说,Processing 和 p5.js 首先是一个由人组成的社区,而非编码库或框架。那些人无私地奉献了无数小时来制作和共享软件。我为这个社区,以及所有热衷于通过代码探索好奇心和玩乐的朋友们写了这本书。
话虽如此,本书的内容并不严格依赖于 p5.js——或者说 Processing。其实,这本书也可以用“原生” JavaScript 或 Java 编写,或者用任何其他开源创意编码环境,如 openFrameworks、Cinder 等。我的希望是,在完成这本书后,我能发布可以在其他环境中运行的示例版本。如果有人有兴趣帮助移植这些示例,欢迎通过电子邮件联系我,邮箱:daniel@natureofcode.com。来吧,你知道你想把The Nature of Code 移植到 PHP!
本书中的所有示例都已使用 p5.js 版本 1.9.0 进行过测试,但大部分示例应该也能在早期版本中运行。我会保持它们与最新版本同步更新。最新的代码始终可以在本书的网站上找到(* natureofcode.com )以及其关联的 GitHub 仓库( github.com/nature-of-code *)。
你需要了解什么?
理解本书内容的前提条件可以表述为:“学习过一学期的 p5.js、Processing 或任何其他创意编程环境的编程课程。” 也就是说,即便你用其他语言或开发环境学过编程,也完全可以阅读这本书。
如果你以前从未写过代码,虽然你可以通过这本书来学习概念和获得灵感,但你可能会在理解代码时遇到困难,因为我假设你已经掌握了基础知识:变量、条件语句、循环、函数、对象和数组。如果这些概念对你来说是新的,我的“Code! 使用 p5.js 编程”(* thecodingtrain.com/p5js )和“学习 Processing”( thecodingtrain.com/processing *)视频课程提供了你需要了解的基础知识。
如果你是经验丰富的程序员,但没有使用过 p5.js,你可以通过查阅 p5.js 的文档(* p5js.org ),浏览示例,并阅读库的“入门”页面( p5js.org/get-started *)来学习它。
我还应该指出,面向对象编程的经验非常关键。我将在第零章中回顾一些基础知识,但如果你对类和对象不熟悉,我建议观看我的 p5.js 和 Processing 面向对象编程的视频教程,这些教程也可以在 Coding Train 网站找到(* thecodingtrain.com/oop *)。
你是怎么阅读这本书的?
你是在 Kindle 上阅读这本书吗?打印版纸质书?在笔记本电脑上以 PDF 格式阅读?在平板电脑上观看 HTML5 动画版本?还是你被绑在椅子上,通过一系列电极、电缆和卡带将内容直接传输到大脑中?
我的梦想一直是将这本书写成一个统一的格式(在本例中是 Notion 文档集合),然后,在按下一个神奇的按钮(npm run build)后,书就能以你可能需要的任何格式输出——PDF、HTML5、打印版、Kindle 版等等。这在很大程度上得益于 Magic Book 项目(* github.com/magicbookproject *),这是一个最初由 Rune Madsen 和 Steve Klise 在 ITP 开发的开源自出版框架。所有内容都使用 CSS 进行设计和排版——没有手动排版或布局。
编写这本书的过程并不像那么简单,背后的故事也很漫长。如果你有兴趣了解更多,记得阅读书中的致谢部分,然后去雇佣我感谢过的人,帮助你出版一本书!我还会在相关的 GitHub 仓库中提供更多细节(* github.com/nature-of-code *)。
底线是,无论你以何种形式阅读,内容都是相同的。唯一的区别在于你体验代码示例的方式——更多内容请参见“如何阅读代码”一节,见第 xxxiii 页。
The Coding Train 连接
就个人而言,我仍然喜欢由纸浆精心拼装而成、坚韧脊背牢牢装订在一起的书籍,上面以色素化合物艺术性地传达文字和思想。然而,自 2012 年以来,当时我在 ITP 办公室里冲动地录制了我的第一节编程视频课后,我发现通过动态图像传递思想和课程具有巨大的价值和乐趣。
总之,我有一个名为 The Coding Train 的 YouTube 频道(www.youtube.com/thecodingtrain)。我在前面提到过它,用来讨论学习本书先决条件的选项,如果你继续阅读,你会发现我会不断引用相关的视频。我可能会提到我制作的关于某个相关算法或特定编程示例的替代技术的视频,或者推荐一个系列讲解与我正在探索的主题相关的旁支概念,提供额外的上下文。
如果你喜欢通过视频学习,我也在制作一套与本书内容完全相同的视频教程。我十年前用 Processing 制作了很多视频,最近我开始发布使用 p5.js 更新的系列教程。在撰写本文时,我大约完成了第五章。
附加资源
还有大量优秀的教育材料,讲解模拟和生成算法,这些我并没有编写或录制。我总是推荐你在尝试学习新知识时,探索不同的视角和声音。可能我所写的内容与你不太契合,甚至看到我在视频中重复同样的信息,也许无论我如何在镜头前做表情,效果都不大。有时最好的方式是找一个你能产生共鸣的人,用不同的语言、不同的风格来阐述或演示相同的概念。为此,我在本书网站上加入了一个“附加资源”部分。如果你创作了自己的材料或有任何推荐内容,希望能够纳入其中,请与我联系!
我现在有两个快速推荐,分别是由 Gary William Flake(麻省理工学院出版社,1998 年)所著的《自然的计算美》——这本书是我最初学习这本书中许多概念的来源——以及 Taru Muhonen 和 Raphaël de Courville 精心组织的在线资源That Creative Code Page。
这本书的“故事”
如果你浏览一下本书的目录,你会注意到有 12 章(第 0 到第十一章!),每一章都涵盖了不同的主题。从某种意义上来说,这本书就是这样的——对一打概念和相关代码示例的概览。尽管如此,在编排这些材料时,我总是想象出一个线性的叙事结构。在你开始阅读之前,我想带你走一遍这个故事。
第一部分:无生命物体
一颗足球躺在草地上。一记踢击将它踢向空中。重力将它拉回地面。一阵强风让它在空中停留了片刻,直到它落下并弹跳到一个跳跃球员的头上。足球并不是活的;它不会自行选择如何在世界中运动。相反,它是一个没有生命的物体,等待着被环境的力量推拉。
你如何在数字画布上建模一个移动的足球?如果你曾编写过让圆形在屏幕上移动的程序,你可能写过以下这行代码:
x = x + 1;
你在位置x处绘制一个形状。在每一帧动画中,你增加x的值,重新绘制形状,哇——就创造出了运动的假象!也许你更进一步,增加了y位置,并为沿 x 轴和 y 轴的速度设置了变量:
x = x + xspeed;
y = y + yspeed;
本故事的第一部分将进一步拓展这个想法。在探索如何使用不同类型的随机性来驱动物体的运动(第零章)后,我将展示如何将这些xspeed和yspeed变量结合起来,并说明它们如何共同形成一个向量(第一章)。你不会从中获得任何新的功能,但它将为本书其余部分的运动编程奠定坚实的基础。
一旦你了解了有关向量的基本知识,你会很快意识到,力(第二章)是一个向量。踢足球时,你就在施加力。力会导致物体做什么呢?根据艾萨克·牛顿爵士的说法,力等于质量乘以加速度,因此力会使物体加速。建模力将使你能够创建具有动态运动的系统,其中物体根据多种规则移动。
现在,你施加了力的那颗足球可能也在旋转。如果一个物体根据其线性加速度运动,它也可以根据其角加速度旋转(第三章)。理解角度和三角学的基础知识将帮助你建模旋转物体,并理解诸如摆动的钟摆或弹跳的弹簧等振荡运动的原理。
一旦你掌握了单个无生命物体的运动和力学基础,我将向你展示如何制作成千上万的这些物体,并将它们作为一个单元进行管理,称之为粒子系统(第四章)。粒子系统也是一个很好的借口来研究面向对象编程的额外特性——即继承和多态。
第二部分:它活了!
模拟生命是什么意思?这个问题不容易回答,但我将从构建能够感知环境的物体开始。我们先想一想。一块从桌子上掉下来的木块是根据力的作用运动的,就像一只在水中游泳的海豚一样。但是有一个关键的区别:那块木块不能决定从桌子上跳下,而海豚可以决定跃出水面。海豚有梦想和欲望。它感到饥饿和恐惧,这些情感会影响它的运动。通过研究建模自主体的技术(第五章),你将学会为无生命的物体注入生命,让它们根据对环境的理解做出运动决策。
在第一章到第五章中,所有的示例将“从头开始”编写——意味着驱动物体运动的算法代码将直接用 p5.js 编写。我当然不是第一个考虑在动画中模拟物理和生命的程序员,因此接下来我将探讨如何使用物理库(第六章)来模拟更复杂的行为。我将介绍两个库的特点:Matter.js 和 Toxiclibs.js。
第五章的结尾将探讨表现复杂性特征的群体行为。复杂系统通常被定义为一个整体大于其部分之和的系统。虽然系统的各个元素可能非常简单并且容易理解,但整个系统的行为可能非常复杂、智能且难以预测。追求复杂性会使你不再仅仅考虑模拟运动,而是进入基于规则的系统领域。你可以用细胞自动机(第七章)来模拟什么?这些是生活在网格上的细胞系统。你可以用分形(第八章)——自然几何——生成什么样的模式?
第三部分:智能
你让事物开始运动。然后,你赋予这些事物希望、梦想和恐惧,并制定了生活规则。本书的最后一步将把智能决策引入到你的创作中。你能否将生物进化过程应用到计算系统中(第九章),从而让自主代理的行为进化?受到人类大脑启发,你能否编写人工神经网络(第十章)?代理如何做出决策、从错误中学习并适应环境(第十一章)?
使用本书作为大纲
尽管本书的内容无疑会带来紧张且高度压缩的学期,但我已将其设计为适应 14 周的课程。我发现,有些章节在多周展开时效果更好,而其他章节可以合并在一周内一起探讨。这里是一个可能的大纲:
| 第 1 周 | 随机性与向量(第零章–1 章) |
|---|---|
| 第 2 周 | 力学(第二章) |
| 第 3 周 | 振荡(第三章) |
| 第 4 周 | 粒子系统(第四章) |
| 第 5 周 | 自主代理(第五章) |
| 第 6 周 | 物理学库(第六章) |
| 第 7 周 | 学期中期运动项目 |
| 第 8 周 | 复杂系统:二维元胞自动机与分形(第七章–8 章) |
| 第 9 周 | 遗传算法(第九章) |
| 第 10 周 | 神经网络与神经进化(第十章–11 章) |
| 第 11 周 | 最终项目讨论 |
| 第 12–13 周 | 最终项目研讨会 |
| 第 14 周 | 最终项目展示 |
如果你打算将此书用作课程或研讨会教材,欢迎随时与我联系。我希望最终能完成伴随视频集,并将有用的幻灯片作为补充教材。如果你自己制作了相关材料,也希望能听到你的反馈!
如何阅读代码
代码是本书的主要媒介,它贯穿整个叙述并被剖析和检视。有时它以完整的独立示例出现,其他时候它作为一两行代码出现,往往它会通过许多简短的片段分布在整个章节中,解释也夹在其中。不管它以什么形式出现,代码总是以等宽字体展示。下面是关于如何阅读本书中不同类型代码的简要指南。
完整示例
每一章都包含了使用 p5.js 库编写的完整功能代码示例。它们的样子如下:

示例在每一章中按顺序编号,帮助你在线查找对应的代码。在书籍的打印版中,你会看到一个截图,位于示例标题正下方。在线版则直接在页面上嵌入了运行中的草图。对于动画示例(几乎所有示例都是),截图通常会展示“运动轨迹”。这一效果是通过在background(255, 10)函数中添加透明度来实现的,尽管随附的代码并未包括这一增强功能。
在示例下方,你会看到代码,但它不一定是完整的代码。由于许多示例相当长,并且涉及多个文件,我会尽量提供一个突出了示例的主要部分或本节中没有早先讨论过的新组件的片段。
你可以在本书的网站上找到完整版本的代码。在那里,你可以与代码互动、修改并在 p5.js Web 编辑器中进行实验 (editor.p5js.org)。此外,所有内容都包含在本书的 GitHub 仓库中。以下是所有材料的链接:
-
本书的网站 (
natureofcode.com) 包含了完整的书籍文本、额外的阅读材料和参考资料,以及所有代码示例。 -
GitHub 仓库 (
github.com/nature-of-code) 包含了本书网站的原始源代码、本书的构建过程以及所有代码示例。 -
除了网站和 GitHub 仓库,你还可以通过查看 p5.js 网页编辑器中的草图列表来访问代码 (
editor.p5js.org/natureofcode/sketches).
注意,我在示例中使用了注释来解释代码的作用。这些注释会漂浮在代码旁边(具体显示效果可能会因你阅读的方式不同而有所变化)。背景阴影将注释与其对应的代码行分组。
完整代码片段
尽管很少见,偶尔会有“完整”的代码部分与正文混合在一起。有时候,像前一节中的示例 Example #.#那样,我可能会列出与完整的 p5.js 草图相关的所有代码。然而,在大多数情况下,我认为“完整”的代码片段是指一个完整函数或类的代码——一个完整的代码块,包括了开头和结尾的花括号以及其中的所有内容。就像这样:

这个代码片段展示了整个draw()函数,但它仍然不是一个完整的草图。它假定存在一个名为spacing的全局变量,以及一个调用createCanvas()的setup()函数。
无上下文代码
偶尔,你会看到一些代码行出现在页面上,而没有被包含在任何函数或上下文中。这些代码片段是用来阐明某个观点的,不一定是要直接运行的。它们可能代表一个概念、一小段算法,或者一种编程技巧。

请注意,这个没有上下文的代码片段与前一个“完整”代码片段中的fill(255)保持了相同的缩进。当代码被确认为之前演示的一部分时,我会这样做。虽然这并不总是能做到如此干净或完美,但我尽力而为!
代码片段
留意剪刀图标!这个设计元素表示代码片段是前一部分的继续,或者会在一些解释性文字之后继续。有时它实际上并没有继续,而只是被截断,因为所有代码并不适合当前讨论。剪刀图标的作用是:“嘿,可能上面或下面还有更多代码,或者至少,这是某个更大部分的一部分!”这可能会在一些上下文的配合下体现出来。
构建 p5.js 草图的第一步是创建一个画布:

然后是时候绘制背景了:

我还喜欢在画布的中心添加一个圆圈:

在draw()中,我可能想要开始将方形放置在背景和固定圆圈上方的随机位置。其余的代码可以是任何你想要的内容!

请注意,我在保持缩进一致性,以帮助建立上下文,而且,我再次使用剪刀图标来标示代码是继续的部分或已被截断的部分。
使用代码片段的一个特定副作用是,你会经常发现某个代码片段中的左花括号,直到几个代码片段后才会有对应的右花括号(如果有的话)。如果你习惯了查看 JavaScript 代码,刚开始可能会让你有些惊慌,但希望你能逐渐习惯。
练习
每一章都包含编号的练习,作为你的练习场地,让你应用、实验并超越本章中提供的概念和代码。以下是一个练习的样子:
练习 #.#
尝试调整示例 #.#,使得每个圆圈具有随机大小:
function draw() {
fill(0, 25);
stroke(0, 50);
circle(random(width), random(height), random(16, 64));
}
为了让你保持警觉,练习题有多种格式。有些提出了技术挑战,要求你编写特定算法的变体或解决某个特定问题。其他则是开放性问题,促使你进行创作和实验,按照自己的想法进行探索。有些包括代码片段,其中有空白部分,邀请你直接填写。不要犹豫,在这本书中写下、涂鸦或随意勾画!
解决方案
练习的解答可以在本书网站上找到。或者我应该说,我希望能够在本书网站上提供所有练习的解答。截至目前,只有少数解答可用,但希望等你读到这时,解答会有更多。如果你愿意贡献一个练习的解答,我很希望你能通过本书的 GitHub 仓库来提交!
生态系统项目
尽管我很想假装你可以通过蜷缩在舒适的椅子上阅读一些散文来学习一切,但要学会编程,你实际上必须动手编程。每章中散布的练习是一个起点,但你可能会发现,牢记一个更为实际的项目想法(或两个),并在每一章的学习过程中逐步开发它,会对你有所帮助。事实上,当我在 ITP 教授《代码的本质》课程时,我发现学生们喜欢在一个学期中,逐步地、每周地构建一个项目。
在每一章的末尾,你会看到一系列针对这样一个项目的提示——这些练习是相互关联的,每次只涉及一个主题。这个项目基于以下情境:你受到了一个科学博物馆的委托,开发一个新的展览的软件,数字生态系统,这是一个充满动画的、程序生成的生物世界,它们生活在一个计算机仿真中,供游客在进入博物馆时欣赏。我并不是要暗示这是一个特别创新或有创意的概念。相反,我将使用这个生态系统项目的例子,作为本书内容的字面表现,展示这些元素如何在一个程序中结合在一起。我鼓励你开发自己的想法,可能是更抽象、更非传统的。
获取帮助与提交反馈
编程可能既艰难又令人沮丧,本书中的一些想法也不总是直接明了的。你不必独自一人走这条路。现在可能有人正在阅读本书,他们也希望共同组织一个学习小组或书友会,在那里你们可以见面、聊天,并分享各自的困难与成功。如果你找不到一个本地社区一起走这段旅程,如何考虑一个在线社区呢?我建议的两个地方是官方的 Processing 论坛 (discourse.processing.org) 和 Coding Train 的 Discord 服务器 (thecodingtrain.com/discord)。
我认为本书的在线版本是一本活文档,欢迎您的反馈。有关本书的所有信息,请访问《代码的本质》网站 (natureofcode.com)。本书的原始文本和所有插图都可以在 GitHub 上找到 (github.com/nature-of-code)。请通过 GitHub issues (github.com/nature-of-code/noc-book-2/issues) 提交反馈和修改意见。
更重要的是,我想看到你创造的东西!你可以通过将你的想法提交到 Coding Train 网站上的乘客展示区 (thecodingtrain.com/showcase) 或者在前面提到的 Discord 频道中分享。YouTube 评论中的问候也始终欢迎(尽管说实话,最好还是不要读 YouTube 的评论),而且随时可以在任何社交媒体平台上@我——无论哪个平台最友好、最少毒性!我想享受你们生态系统中所有的小失误。无论它们是自豪地跃过创意的波浪,还是在学习的池塘中溅起一丝涟漪,让我们一起享受它们在编程本质中激起的涟漪!
第一章:0 随机性
随机数的生成太重要了,不能交给运气。
—罗伯特·R·科维尤

《百万随机数字与 100,000 个正态偏差》中的随机数字表,由兰德公司提供
1947 年,兰德公司出版了一本名为《百万随机数字与 100,000 个正态偏差》的奇特书籍。这本书不是一部文学作品,也不是关于随机性的哲学论文。而是一本包含随机数字的表格,这些数字是通过电子模拟的轮盘赌产生的。这本书是从 20 世纪 20 年代中期到 50 年代,兰德公司出版的最后一部随机数字表之一。随着高速计算机的发展,生成伪随机数字比从表格中读取它们更快,因此这时代的打印随机数字表最终也走向了终结。
现在我们在这里:起点。如果你有一段时间没有编写 JavaScript 代码(或者做任何数学运算),这一章将帮助你重新熟悉计算思维。为了开始你的自然编程之旅,我将向你介绍一些编程模拟的基础工具:随机数、随机分布和噪声。把这看作是组成本书的数组中的第一个(零)元素——一个复习和通往未来可能性的门户。

在第一章中,我将讨论向量的概念,以及它如何成为本书中模拟运动的构建块。但在我迈出这一步之前,让我们先思考一下让某物在数字画布上移动意味着什么。我将从最著名且最简单的运动模拟之一开始:随机漫步。
随机漫步
想象一下,你站在一根平衡木的中央。每隔 10 秒钟,你就抛一次硬币。正面,向前迈一步。反面,向后退一步。这就是随机漫步,一条由一系列随机步骤定义的路径。小心地从平衡木上走到地面上,你可以将你的随机漫步从一维(仅向前和向后移动)扩展到二维(向前、向后、向左、向右移动)。现在有了四种可能性,你需要抛两次硬币来确定每一步。
| 投掷 1 | 投掷 2 | 结果 |
|---|---|---|
| 正面 | 正面 | 向前迈步。 |
| 正面 | 反面 | 向右迈步。 |
| 反面 | 正面 | 向左迈步。 |
| 反面 | 反面 | 向后退步。 |
这可能看起来是一个简单的算法,但你可以使用随机漫步来模拟现实世界中发生的各种现象,从气体中分子运动,到动物觅食,再到赌徒在赌场度过的一天。对于我们的目的来说,随机漫步是一个完美的起点,原因有三:
-
我想回顾一下本书的核心编程概念:面向对象编程(OOP)。我即将创建的随机行走者将作为使用面向对象设计在计算机图形画布上制作移动对象的模板。
-
随机游走引出了我将在本书中反复提问的两个问题:“如何定义支配对象行为的规则?”然后是,“如何在代码中实现这些规则?”
-
本书的项目中,您将定期需要了解基本的随机性、概率和 Perlin 噪声。随机游走将帮助我展示一些关键点,这些点稍后会派上用场。
我将通过编写一个Walker类来回顾一下面向对象编程(OOP),创建可以进行随机行走的Walker对象。这将只是一个简要回顾。如果你以前没有使用过 OOP,你可能需要更全面的讲解;我建议你停下来,查看我的“Code! Programming with p5.js”视频课程中的“对象”章节,网址是(thecodingtrain.com/objects)。
随机行走者类
在 JavaScript 中,对象是一个具有数据和功能的实体。在这个例子中,Walker对象应该包含关于其在画布上位置的数据和一些功能,比如能够绘制自己或执行一步。
类是构建实际对象实例的模板。可以把类想象成饼干模具,而对象则是实际的饼干。为了创建一个Walker对象,我将首先定义Walker类——即什么是一个行走者。
一个行走者只需要两块数据:一个是它的 x 坐标,另一个是它的 y 坐标。我将把它们初始化为画布的中心,设置对象的起始位置。我可以在类的构造函数中完成这项工作,构造函数的名称是constructor()。你可以将构造函数看作对象的setup()函数。它负责定义对象的初始属性,类似于setup()函数为整个草图所做的工作:

注意使用关键字this来将属性附加到新创建的对象本身:this.x和this.y。
除了数据,类还可以定义功能。在这个例子中,Walker对象有两个函数,在面向对象编程(OOP)中称为方法。虽然方法本质上是函数,但区别在于方法是在类内部定义的,因此与对象或类相关联,而函数则不是。function关键字是一个很好的线索:当定义独立的函数时,你会看到它,但在类内部不会出现它。我会尽力在本书中始终如一地使用术语,但程序员常常将函数和方法交替使用。
第一个方法,show(),包含了绘制对象(作为一个黑点)的代码。再次强调,引用该对象的属性(变量)时,切记使用this.:

接下来的方法,step(),指示 Walker 对象迈出一步。这时事情就变得更加有趣了。记得在地板上随机走步吗?现在我会使用 p5.js 画布来表示这个地板。有四个可能的步伐。向右走可以通过增加 x 来模拟,即 x++;向左走通过减少 x 来模拟,即 x--;向前走是通过上移一个像素(y--);向后走是通过下移一个像素(y++)。但是,代码如何从这四个选择中做出选择呢?
之前我提到过,你可以抛两枚硬币。然而,在 p5.js 中,当你想从一组选项中随机选择时,你可以简单地使用random()函数生成一个随机数。它会在你指定的任何范围内生成一个随机的浮动小数值。这里,我使用 4 来表示从 0 到 4 的范围:
let choice = floor(random(4));
我声明了一个变量 choice,并通过使用 floor() 函数去除随机浮动小数中的小数部分,来为它赋一个随机整数(整数)。从技术上讲,random(4) 生成的数值在 0(包含)到 4(不包含)之间,这意味着它永远不可能是 4.0。它生成的最大值为接近 4 的数——3.999999999(后面跟着尽可能多的 9,直到 JavaScript 所允许的精度),然后 floor() 将其舍去小数部分,变成 3。因此,我实际上已经将 choice 赋值为 0、1、2 或 3。
编码约定
在 JavaScript 中,变量可以使用 let 或 const 来声明。一种典型的方法是,首先使用 const 声明所有变量,并在需要时切换为 let。在这个例子中,使用 const 声明 choice 是合适的,因为它在每次调用 step() 时从未被重新赋值。虽然这种区分很重要,但我选择遵循 p5.js 的示例约定,所有变量都使用 let 来声明。
我认识到 JavaScript 中有 const 和 let 这两个关键字是有重要原因的。然而,这种区别可能会让初学者感到困惑并分散注意力。我鼓励你深入探讨这个话题,并自行决定如何在自己的草图中声明变量。更多信息,请阅读 p5.js GitHub 仓库中关于问题 #3877 的讨论 (github.com/processing/p5.js/issues/3877)。
我还选择使用 JavaScript 的严格相等(===)运算符(以及其不等于的对立运算符!==)。这个布尔运算符会测试值和类型的相等性。例如,3 === '3'将返回false,因为两者类型不同(数字与字符串),即使它们看起来相似。另一方面,使用宽松相等(==)运算符时,3 == '3'会返回true,因为这两种不同类型会被转换成可比较的类型。虽然宽松比较通常能正常工作,但有时会导致意外的结果,因此===可能是更安全的选择。
接下来,行走者根据选择的随机数,采取适当的步伐(向左、向右、向上或向下)。下面是完整的step()方法,结束了Walker类的定义:

现在我已经编写了类,接下来是时候在草图中实际创建一个Walker对象了。假设你想要模拟一个单一的随机行走,从一个全局变量开始:

然后在setup()中通过new操作符引用类名来创建对象:

最后,在每次通过draw()时,行走者会迈出一步并画出一个点。

由于背景只在setup()中绘制,而不是在每次通过draw()时都清除,所以随机行走的轨迹在画布上可见。
我可以对随机行走者做几个调整。首先,这个Walker对象的步伐被限制为四个选项:上、下、左、右。但画布上的任何给定像素都有八个可能的邻居,包括对角线(见图 0.1)。第九个可能性是保持在同一位置。

图 0.1:随机行走者的步伐,包含与不包含对角线的情况
为了实现一个可以步进到任何邻近像素(或保持原地)的Walker对象,我可以从 0 到 8 中随机选择一个数字(九个可能的选择)。不过,另一种编写代码的方式是,从 x 轴的三个可能步伐(–1、0 或 1)和 y 轴的三个可能步伐中选择一个:

更进一步,我可以去掉floor(),使用random()函数的原始浮点数,创建从 –1 到 1 的连续范围的可能步长,如下所示。

所有这些对传统随机行走的变种有一个共同点:在任何时刻,行走者采取某一方向的步伐的概率与行走者采取任何其他方向的步伐的概率是相等的。换句话说,如果有四个可能的步伐,那么行走者采取某一特定步伐的概率是 1/4(即 25%)。如果有九个可能的步伐,那么概率是 1/9(大约 11.1%)。
方便的是,这就是random()函数的工作方式。p5.js 的随机数生成器(在后台运行)产生均匀分布的数字。你可以通过统计每次随机数被选中时的次数并将这些值绘制成图来测试这种分布。

请注意,图表的每个柱子在高度上略有不同。样本量(随机数的选择次数)较小,因此偶尔会出现差异,因为某些数字被选中的频率高于其他数字。随着时间的推移,使用良好的随机数生成器,这种分布会趋于平衡。
伪随机数
random()函数生成的随机数并不是真正的随机数;相反,它们是伪随机的,因为它们是数学函数的结果,模拟了随机性。这个函数随着时间推移会产生一个模式,从而不再看起来是随机的。然而,这段时间非常长,以至于random()对于本书中的示例来说足够随机。
练习 0.1
创建一个随机行走者,它更倾向于向下和向右移动。(解决方案将在下一节提供。)
概率与非均匀分布
均匀的随机性往往不是设计问题的最周到解决方案——特别是那些涉及构建有机或自然外观模拟的问题。然而,使用一些技巧,random()函数可以生成非均匀分布的随机数,其中某些结果比其他结果更有可能出现。这种类型的分布能够产生更有趣、更自然的结果。
想想你第一次使用 p5.js 编程时。也许你想在屏幕上画很多圆形,于是你对自己说:“哦,我知道了!我会随机选择位置、大小和颜色来画这些圆形。”将随机性引入系统是学习计算机图形学基础时一个完全合理的起点,但在本书中,我希望建立模拟自然界的系统,均匀的随机性并不总是足够的。有时候,你得稍微“加点偏”。
创建一个非均匀分布的随机数在全书中都会非常有用。例如,在第九章的遗传算法中,我需要一种执行选择的方法——哪些个体应该被选中,将它们的 DNA 传递给下一代?这类似于达尔文的“适者生存”概念。假设你有一个不断演化的猴子种群,并不是每只猴子都有相等的繁殖机会。为了模拟达尔文的自然选择,你不能仅仅随机挑选两只猴子作为父母。更“适应”的个体应该更有可能被选中。这可以视为最适者的概率。
让我在这里暂停一下,看看概率的基本原理,这样我就能对接下来的编码示例使用更准确的词汇。我将从单事件概率开始——某个事件发生的可能性。在概率论中,结果指的是随机过程的所有可能结果,而事件是指被考虑的特定结果或结果组合。
如果你有一个每个结果都和其他结果一样可能发生的场景,那么某个事件发生的概率等于匹配该事件的结果数除以所有潜在结果的总数。硬币投掷是一个简单的例子:它只有两个可能的结果,正面或反面。正面只有一个,因此硬币正面朝上的概率是 1 除以 2:1/2,或者 50%。
拿一副 52 张牌的牌堆。从这副牌中抽到一张王牌的概率如下:
王牌的数量 / 牌的数量 = 4/52 = 0.077 ≈ 8%
抽到方块的概率在这里显示:
方块的数量 / 牌的数量 = 13/52 = 0.25 = 25%
你还可以通过将每个事件的个别概率相乘来计算多个事件按顺序发生的概率。例如,以下是硬币连续三次正面朝上的概率:
(1/2) × (1/2) × (1/2) = 1/8 = 0.125 = 12.5%
这表明硬币连续三次正面朝上的平均概率是八分之一。如果你连续三次投掷硬币 500 次,你会预期大约八分之一的时间,或者大约 63 次,出现三次连续正面。
练习 0.2
如果你从一副 52 张牌的牌堆中连续抽两张王牌,而在第二次抽牌前将第一次抽到的牌放回去,抽到两张王牌的概率是多少?如果你在第一次抽牌后不将牌放回去,那么这个概率是多少?
你可以通过几种方式使用random()函数,在代码中应用概率的概念来实现不均匀分布。一种方法是用数字填充一个数组,其中一些数字是重复的,然后从这个数组中选择随机元素,并根据这些选择生成事件:

这个五元素数组有两个 1,所以运行这段代码将产生五分之二的机会,或者 40%的机会打印值为 1。同样,打印 2 的概率为 20%,打印 3 的概率为 40%。
你也可以请求一个随机数(我们简单起见,只考虑 0 到 1 之间的随机浮点值),并仅当随机数在某个范围内时才允许事件发生。例如:

从 0 到 1 的浮点数中有十分之一小于 0.1,所以这段代码只会有 10%的概率会唱歌。
你可以使用相同的方法为多个结果应用不等的权重。假设你想让唱歌的概率是 60%;跳舞的概率是 10%;睡觉的概率是 30%。同样,你可以从 0 到 1 之间选择一个随机数,看看它落在哪个区间:
-
从 0.0 到 0.6(60%)→ 唱歌
-
从 0.6 到 0.7(10%)→ 跳舞
-
从 0.7 到 1.0(30%)→ 睡觉

现在让我们将这种方法应用到随机漫步者上,让它倾向于朝某个特定方向移动。下面是一个Walker对象的示例,它具有以下概率:
-
向上移动的概率:20%
-
向下移动的概率:20%
-
向左移动的概率:20%
-
向右移动的概率:40%

这种技术的另一个常见用途是控制你希望在代码中偶尔发生的事件的概率。例如,假设你创建了一个草图,每隔固定的时间间隔(每 100 帧)启动一个新的随机漫步者。使用random(),你可以将一个新的漫步者的启动概率设为 1%。最终结果是相同的(平均每 100 帧启动一个新的漫步者),但后者引入了随机性,感觉更动态和不可预测。
练习 0.3
创建一个具有动态概率的随机漫步者。例如,你能否让它有 50%的机会朝鼠标的方向移动?记住,你可以使用mouseX和mouseY来获取当前鼠标的位置,p5.js 中就是这样!
随机数的正态分布
创建非均匀分布的随机数的另一种方式是使用正态分布,其中数字会围绕平均值聚集。为了理解为什么这有用,让我们回到模拟猴子的人群,并假设你的草图生成了 1000 个Monkey对象,每个对象都有一个随机的身高值在 200 到 300 之间(因为这是一个身高为 200 到 300 像素的猴子世界):
let h = random(200, 300);
这是创建猴子身高人群的准确算法吗?想象一下纽约市繁忙的街道。随便挑一个路人,他们的身高可能看起来是随机的。然而,这并不是random()默认生成的那种随机。人的身高分布并不是均匀的;大约身高平均的人远多于非常高或非常矮的人。为了准确反映这种人群,接近均值(即平均值)的身高应该更有可能被选择,而极端身高(非常矮或非常高)则应更少见。
这正是正态分布(有时称为高斯分布,源自数学家卡尔·弗里德里希·高斯)的工作原理。该分布的图形非正式地被称为钟形曲线。该曲线由一个数学函数生成,该函数定义了某个给定值出现的概率,这个概率是均值(通常写作µ,希腊字母 mu)和标准差(σ,希腊字母 sigma)的函数。
如果考虑身高值在 200 到 300 之间,你可能已经直观地知道均值(平均值)大约是 250。但是,如果我告诉你标准差是 3?或者是 15?这对数据意味着什么?图 0.2 给出的图表应该能给你一些提示。

图 0.2:正态分布的两个示例钟形曲线,分别具有低(左)和高(右)标准差
左侧是一个标准差非常低的分布,绝大多数值都集中在均值附近(它们与标准差的偏差很小)。右侧的版本有更高的标准差,因此值更加均匀地分布在均值周围(它们的偏差较大)。
数字的计算结果如下:假设一个群体,其中 68%的成员的值位于均值的一个标准差范围内,95%的值位于两个标准差范围内,99.7%的值位于三个标准差范围内。假设标准差为 5 像素,那么只有 0.3%的猴子身高会低于 235 像素(低于均值 250 的三个标准差)或高于 265 像素(高于均值 250 的三个标准差)。同时,68%的猴子身高会在 245 到 255 像素之间。
幸运的是,在 p5.js 草图中使用正态分布的随机数时,你无需手动进行这些计算。相反,randomGaussian() 函数会处理这些数学运算,并返回具有正态分布的随机数:

计算均值和标准差
假设有一个包含 10 个学生的班级,他们在一次考试中的成绩(满分 100)如下:85、82、88、86、85、93、98、40、73 和 83。
均值是平均数:81.3。
标准差的计算方法是对偏差平方的平均值开方。换句话说,取均值与每个人成绩之间的差值,并对其进行平方,得到该人的平方偏差。接下来,计算所有这些值的平均值以获得平均方差。然后,对平均方差开方,你就得到了标准差。
| 分数 | 与均值的差异 | 方差 |
|---|---|---|
| 85 | 85 − 81.3 = 3.7 | (3.7)² = 13.69 |
| 40 | 40 - 81.3 = -41.3 | (-41.3)² = 1,705.69 |
| . . . | ||
| 平均方差: | 228.21 |
标准差是方差的平方根:15.13。
接下来该做什么?例如,目标是为绘制的形状分配 x 位置,应该怎么办?
默认情况下,randomGaussian()函数返回一个正态分布的随机正负数,均值为 0,标准差为 1。这也被称为标准正态分布。然而,通常这些默认参数无法满足需求。例如,假设你希望使用均值为 320(宽度为 640 的窗口中的中心水平像素)和标准差为 60 像素的正态分布来随机分配一个形状的 x 位置。在这种情况下,你可以通过传递randomGaussian()函数两个参数来调整参数:均值和标准差。

这里我使用了参数来定制对randomGaussian()的调用,但请注意,实现这一定制的数学原理非常简单:你所需要做的就是将标准正态分布的值乘以标准差,然后加上均值。换句话说,将x赋值为randomGaussian(320, 60)与以下操作是等价的:
let x = 60 * randomGaussian() + 320;
通过将圆圈以透明度叠加在一起,你可以开始看到分布情况。最暗的区域位于中心,那里大多数值聚集,但偶尔会有圆圈绘制在中心的左右更远位置。
练习 0.4
考虑一个模拟的油漆飞溅效果,表现为一系列彩色的点。大多数油漆聚集在中心位置,但有些点会向边缘飞溅。你能使用正态分布的随机数来生成这些点的位置吗?你能否也使用正态分布的随机数来生成颜色调色板?尝试创建一个滑块来调整标准差。
练习 0.5
高斯随机漫步是指其中步长(对象在给定方向上移动的距离)是通过正态分布生成的。实现这种Walker类的变体。
自定义的随机数分布
在你的人生中,会有某个时刻你不希望使用均匀分布的随机值,甚至也不希望使用高斯分布。假设一下,你是一个在寻找食物的随机漫步者。在空间中随机移动似乎是寻找食物的合理策略。毕竟,你并不知道食物在哪,所以你不如随机寻找,直到找到它。然而,问题是,正如你可能在观察你的Walker对象的行动时注意到的,随机漫步者会多次返回之前访问过的位置,这种现象被称为过度采样。这可能导致你的寻找食物的过程毫无成果,或者至少低效。
避免此类问题的一种策略是每隔一段时间迈出一个非常大的步伐。这样,步态者可以在特定位置附近随机游走,同时偶尔跳得很远,以减少过度采样。这种随机漫步的变体,称为Lévy 飞行,需要一组自定义的概率。虽然这并不是 Lévy 飞行的精确实现,但你可以这样表示概率分布:步伐越长,被选中的可能性越小;步伐越短,被选中的可能性越大。
之前我写过,你可以通过填充一个数组来生成自定义概率分布(其中一些值会重复,以便更频繁地被选中),或者通过测试random()的结果来实现。一种实现 Lévy 飞行的方法可能是指定 1%的机会让步态者迈出一大步:

然而,这会将概率限制为固定的选项数量:99%的时间,小步前进;1%的时间,大步前进。如果你想制定一个更通用的规则呢?比如数字越大,被选中的可能性越大。例如,0.8791 比 0.8532 更可能被选中,即使这种可能性只是略微更高。换句话说,如果x是随机数,它被选中的可能性可以通过函数y = x映射到 y 轴上(见图 0.3)。

图 0.3:y = x的图形,其中y是值x被选中的概率
如果可以根据图 0.3 中的图形生成随机数分布,那么你也应该能够生成一个符合任何你用公式定义的曲线的随机分布。
自定义分布的一种解决方案是选择两个随机数而不是一个。第一个随机数就是一个随机数。而第二个随机数,我称之为合格随机值。该值由程序用来决定是否使用第一个数,还是丢弃它并选择另一个。那些更容易合格的数字会更频繁地被选中,而那些很少合格的数字则会很少被选中。以下是步骤(暂时我只考虑从 0 到 1 的随机值):
-
选择一个随机数:
r1。 -
计算概率
p,让r1符合的概率。我们试试:p = r1。 -
再挑一个随机数:
r2。 -
如果
r2小于p,那么你已经找到了你的数字:r1! -
如果
r2不小于p,返回第 1 步重新开始。
在这里,一个随机值符合的概率等于该随机数本身,就像你在图 0.3 中看到的那样。例如,如果r1等于 0.1,那么r1有 10%的机会符合。如果r1等于 0.83,它有 83%的机会符合。数字越大,被选中的概率越高。
这个过程被称为接受-拒绝算法,是一种蒙特卡洛方法(以蒙特卡洛赌场命名)。以下示例展示了一个实现接受-拒绝算法的函数,返回一个介于 0 和 1 之间的随机值。

尽管接受-拒绝算法确实能用来生成自定义的随机数分布,但这种技术效率不高。当大量随机值被拒绝时,尤其是在合格概率非常低的情况下,可能会浪费大量计算资源。到我在第九章讲解遗传算法时,我将采取一种不同且更优的方式。
练习 0.6
使用自定义的概率分布来改变随机游走者步伐的大小。步伐的大小可以通过影响所选随机值的范围来确定。你能通过将选中某个值的可能性设为该值的平方,来将概率映射到一个二次函数吗?

(在第一章中,我将展示如何使用向量更高效地改变步伐大小。)
使用 Perlin 噪声的更平滑方法
一个好的随机数生成器应当生成彼此之间没有关系且无明显模式的数字。然而,正如我所暗示的,虽然在编程模拟自然、有生命的行为时,适度的随机性是有益的,但单纯以均匀随机作为唯一指导原则并不一定符合自然规律。一个叫做Perlin 噪声的算法,正是考虑到这一点,它生成一系列自然排序的伪随机数,其中每个数值都与前一个数值非常接近。这种方式在随机数之间创建了“平滑”的过渡,且比纯噪声呈现出更自然的外观,因此,Perlin 噪声非常适合用于生成具有自然特性的各种效果,如云彩、景观和大理石等纹理图案。
为了说明 Perlin 噪声与均匀随机之间的差异,考虑图 0.4。左侧的图展示了 Perlin 噪声随时间变化的情况,x 轴表示时间;注意曲线的平滑性。右侧的图则展示了纯随机数形式的噪声随时间变化的情况,结果明显更加崎岖不平。(生成这些图的代码可以在本书网站上找到。)

图 0.4:Perlin 噪声值随时间变化的图(左)和随机噪声值随时间变化的图(右)
Ken Perlin 在 1980 年代初期为电影 Tron 工作时开发了最初的 Perlin 噪声算法;他因这项工作获得了技术成就的奥斯卡奖。该算法旨在为计算机生成的特效创建程序化纹理。(程序化指的是通过算法生成视觉元素,而不是艺术家手工设计它们。)多年来,许多不同的噪声变种由不同的作者开发出来。一些著名的变种包括值噪声、Worley 噪声和 simplex 噪声(由 Perlin 本人于 2001 年开发)。您可以在 Ken Perlin 的网站上了解更多关于 Perlin 噪声的历史 (mrl.nyu.edu/~perlin/doc/oscar.html),并在我在 Coding Train 网站上的视频“什么是 OpenSimplex 噪声?”中了解它在这些年中的变种 (thecodingtrain.com/opensimplexnoise).
p5.js 库包含了经典 1983 年 Perlin 噪声算法的实现,该实现位于名为 noise() 的函数中。它可以接受一个、两个或三个参数,因为噪声是计算在一维、二维或三维空间中的。我将从展示一维(1D)噪声开始。
假设您想在画布上绘制一个位于随机 x 位置的圆。出于习惯,您可能会使用 random() 函数:

现在,您不再需要一个随机的 x 位置,而是需要一个更平滑的 Perlin 噪声 x 位置。您可能会认为,只需要将 random() 替换为一个相同的 noise() 调用,如下所示:

从概念上讲,这正是您想要做的——根据 Perlin 噪声计算一个从 0 到宽度范围的 x 值——但这不是正确的实现。虽然 random() 函数的参数指定了一个最小值和最大值之间的范围,但 noise() 并不是这样工作的。相反,它的输出范围是固定的:它总是返回一个 0 到 1 之间的值。稍后您将看到,可以通过 p5.js 的 map() 函数轻松解决这个问题,但首先我们来看看 noise() 期望您传入的参数到底是什么。
一维 Perlin 噪声可以被视为随时间变化的线性数值序列。例如:
| 时间 | 噪声值 |
|---|---|
| 0 | 0.365 |
| 1 | 0.363 |
| 2 | 0.363 |
| 3 | 0.364 |
| 4 | 0.366 |
要访问特定的噪声值,您必须选择一个“时间点”,并将其传递给 noise() 函数。例如:
let n = noise(3);
根据上表,noise(3) 返回 0.364。下一步是使用一个时间变量,并在 draw() 中持续请求噪声值:

接近,但还不完全正确。这段代码只是重复输出相同的值,因为它不断请求 noise() 函数在相同的时间点 3 的结果。然而,如果时间变量 t 递增,你每次调用该函数时都会得到不同的噪声值:

我选择将 t 增量为 0.01,但使用不同的增量值会影响噪声的平滑度。通过噪声空间跳跃较大的时间间隔会产生不那么平滑、更加随机的值(图 0.5)。

图 0.5:演示 Perlin 噪声中的短跳跃和长跳跃
在即将到来的代码示例中,使用了 Perlin 噪声,请注意随着 t 值变化,动画是如何变化的。
噪声范围
一旦你获得了从 0 到 1 的噪声值,你就可以将该范围映射到任何适合你目的的大小。最简单的方法是使用 p5.js 的 map() 函数(图 0.6)。它需要五个参数。第一个是你要映射的值——在这个例子中是 n。接下来是该值的当前范围(最小值和最大值),然后是目标范围。

图 0.6:将一个值从一个范围映射到另一个范围
在这个例子中,虽然噪声的范围是从 0 到 1,我想画一个 x 位置范围从 0 到画布宽度的圆:

相同的逻辑也可以应用于随机行走者,将其 x 和 y 值都根据 Perlin 噪声分配。这会创造一个更加平滑、更加自然的随机漫步。

请注意,这个例子需要一对新变量:tx 和 ty。这是因为我需要跟踪两个时间变量,一个用于 Walker 对象的 x 位置,另一个用于 y 位置。但这些变量有点奇怪。为什么 tx 从 0 开始,而 ty 从 10,000 开始?
尽管这些数字是随意选择的,但我故意将这两个时间变量初始化为这种方式,因为噪声函数是确定性的:它始终在特定时间 t 给出相同的结果。如果我要求在相同时间 t 获取 x 和 y 的噪声值,那么 x 和 y 将始终相等,这意味着 Walker 对象将仅沿对角线移动。相反,我使用噪声空间的两个不同部分,x 从 0 开始,y 从 10,000 开始,这样 x 和 y 看起来彼此独立地行动(图 0.7)。

图 0.7:使用不同的 x 轴偏移量来变化 Perlin 噪声值
事实上,这里并没有真正的时间概念在起作用。它是一个有用的隐喻,用来帮助描述噪声函数是如何工作的,但实际上,你所拥有的是空间,而不是时间。图表 Figure 0.7 描绘了一个在一维空间中排列的噪声值的线性序列——也就是说,这些值沿着一条直线排列。值是在特定的 x 位置被提取的,这就是为什么你在示例中经常会看到一个变量名为 xoff,用来表示噪声图中的 x 偏移量,而不是 t 来表示时间。
练习 0.7
在 Perlin 噪声随机行走器中,noise() 函数的结果直接映射到行走器的位置。创建一个随机行走器,但将 noise() 函数的结果映射到行走器的步长上。
二维噪声
在探索了单维噪声值的概念之后,让我们考虑它们如何在二维(2D)空间中存在。对于一维噪声来说,有一系列的值,其中任何给定的值与其邻居相似。想象一张图纸(或电子表格!),在一行中写着一维噪声的值,每个单元格一个值。由于这些值存在于一维空间中,每个值只有两个邻居:一个在它之前的值(左边)和一个在它之后的值(右边),如图 Figure 0.8 左侧所示。

图 0.8:比较邻近的 Perlin 噪声值,在一维(左)和二维(右)中。单元格根据它们的 Perlin 噪声值进行着色。
二维噪声的工作方式在概念上与一维噪声完全相同。不同之处在于,值并不是沿着图纸的单一行线性排列,而是填充整个网格。给定的值将与它所有的邻居相似:上、下、右、左以及任何对角线方向,如 Figure 0.8 右侧所示。
如果你将这个图纸可视化,每个值映射为一种颜色的亮度,你会看到类似云彩的效果。白色旁边是浅灰色,浅灰色旁边是灰色,灰色旁边是深灰色,深灰色旁边是黑色,然后是深灰色,以此类推(见 Figure 0.9)。

图 0.9:在这个 p5.js 草图的输出中,视觉化了二维噪声,每个像素表示一个噪声值,以灰度色显示。
这种效果正是噪声最初发明的原因。如果你调整参数并玩弄颜色,得到的图像看起来更像大理石、木材或任何其他有机纹理。
噪声细节
p5.js 的噪声参考解释了噪声是通过多个八度(octaves)计算的(p5js.org/reference/#/p5/noise)。调用noiseDetail()函数(p5js.org/reference/#/p5/noiseDetail)可以改变八度的数量及其相对重要性。这会改变生成的噪声值的质量。
如果你想用random()函数为画布上的每个像素随机上色,你需要一个嵌套循环来遍历像素的行列,并为每个像素选择一个随机亮度。请注意,在 p5.js 中,像素以数组的形式排列,每个像素包含四个值:红色、绿色、蓝色和透明度。详情请见“像素”教程中的像素数组视频,网址是(thecodingtrain.com/pixels)。

为了根据noise()函数更平滑地给每个像素着色,你可以做同样的事情,只不过不是调用random(),而是调用noise():

从概念上来说,这是一个不错的起点——代码为 2D 空间中的每个(x,y)位置计算噪声值。问题在于,这不会有你想要的平滑、云雾状的效果。从一个像素到下一个像素在噪声空间中增加 1 的跨度太大了。记住,在 1D 噪声中,我每帧将时间变量增加了 0.01,而不是增加 1!
解决这个问题的一个不错的办法是,使用与访问画布上像素的变量不同的变量来表示噪声参数。例如,每次x在水平方向上增加 1 时,可以将一个名为xoff的变量增加 0.01,每次y在垂直方向上增加 1 时,可以将yoff变量增加 0.01,具体实现见下方的嵌套循环。

我得承认,我做了一些相当令人困惑的事情。我用1D噪声来设置控制 2D 行走者运动的两个变量(this.x和this.y)。然后,我立即转而使用2D噪声来设置控制画布上每个像素亮度的一个变量(bright)。
这里的关键区别是,对于行走者,我的目标是拥有两个独立的1D噪声值;我使用它们来移动一个物体通过2D空间,完全是巧合。实现这一点的方法是使用两个偏移量(this.tx 和 this.ty),从同一个 1D 噪声空间的不同部分提取值。同时,在 2D 噪声示例中,xoff和yoff都从 0 开始,因为我正在寻找一个特定点在 2D 噪声空间中的单一值(像素亮度)。行走者实际上是在导航两个独立的 1D 噪声路径,而像素则是 2D 空间中的单一值。
练习 0.8
调整颜色、noiseDetail(),以及 xoff 和 yoff 增量的速率,以实现不同的视觉效果。
练习 0.9
为 noise 添加第三个参数,在 draw() 每次循环时递增,以便对 2D 噪声进行动画处理。
练习 0.10
使用噪声值作为景观的高度。

我在本节中建议了几种 Perlin 噪声的传统用途。我将 1D 噪声的平滑值分配给物体的位置,呈现出一种漂移的效果。使用 2D 噪声,我通过对像素平面上的平滑值进行处理,生成了云雾状的图案。然而,重要的是要记住,Perlin 噪声值只是值而已——它们并不直接与像素位置或颜色相关联。
本书中的任何例子,只要有变量,都可以通过 Perlin 噪声进行控制。例如,在模拟风力时,其强度可以通过 Perlin 噪声来控制。同样,分形树形图中枝条之间的角度,或者在流场模拟中沿网格移动的物体的速度和方向,也可以使用 Perlin 噪声来控制(见 图 0.10)。

图 0.10:左侧为带有 Perlin 噪声的树,右侧为带有 Perlin 噪声的流场
然而,正如你可以过度使用随机性一样,也很容易陷入过度使用 Perlin 噪声的陷阱。物体应该如何移动?Perlin 噪声!物体应该是什么颜色?Perlin 噪声!物体应该如何生长?Perlin 噪声!如果这成了你对每个问题的答案,继续阅读下去吧。我的目标是向你介绍一个全新的可能性宇宙,用来定义你系统的规则。毕竟,这些规则由你来定义,拥有更多的可能性,你将能够做出更深思熟虑、有根据的选择。随机性和 Perlin 噪声只是我在本书中将要探索的广阔创意宇宙中的第一颗星星。
正如在介绍中提到的,使用本书的一种方式是在阅读过程中构建一个单一的项目,将每一章中的元素逐步融入其中。一个这样的项目可能是生态系统的模拟。假设有一群计算生物生活在一个数字池塘及其周围,按照各种规则相互作用。在每一章的结尾,你都会看到相同的提示。我的目标是提供如何利用本章所探讨的概念,逐步扩展你自己模拟生态系统的思路。但也可以尽情发挥自己的创意!
生态系统项目
在你的第一步中,制定一套规则来模拟生物的现实行为,基于随机游走或其他噪声驱动运动的原则。你能模拟一个飞行轨迹不规则、颤动的昆虫吗?或者模拟一片被不稳定微风吹动的漂浮叶子?从探索如何通过行为完全表达一个生物的个性开始。然后,你可以再考虑它的视觉特征。
这里有一张插图,帮助你根据本书中涵盖的主题构建一个生态系统。观察插图在每一章中如何随着新概念和技术的引入而逐步演变。

本书的目标是展示算法和行为,因此我的示例几乎总是只包含一个简单的基本形状,比如圆形。然而,我完全相信你内心充满创造力,我鼓励你通过挑战自己设计画布上的元素。如果将设计转化为代码对你来说是新事物,本书的插画师 Zannah Marsh 已经编写了一本关于如何为代码绘图的实用指南,你可以在附录中找到它。
第二章:1 向量
我正在犯下既有方向又有大小的罪行。
—向量,卑鄙的我

马绍尔群岛的航海图(由 Jim Heaphy 拍摄,展出于伯克利艺术博物馆)
这张航海图是马绍尔群岛土著人民制作的导航工具,马绍尔群岛位于中太平洋。这种古老的工具是通过精心绑扎椰子叶的中脉制成的。图表上的贝壳标记表示该地区岛屿的位置。叶脉和贝壳的布局作为地理指引,提供了一种抽象的向量表示,捕捉了海洋波动模式及其方向流动。
本书的核心内容是观察我们周围的世界,并开发用代码模拟它的方法。在本书的第一部分,我将从基础物理学开始:例如一个苹果从树上掉下来,一个摆钟在空气中摆动,地球围绕太阳转动,等等。书中的前五章涵盖的所有内容,都需要使用编程运动的最基本构建块——向量。因此,我将从这里开始讲解。
“向量”这个词可以有很多含义。它是 1980 年代初在加利福尼亚州萨克拉门托成立的一支新浪潮摇滚乐队的名字,也是加拿大凯洛格公司生产的一种早餐谷物的名字。在流行病学领域,向量是指传递感染的有机体。在 C++编程语言中,向量(std::vector)是动态可调整大小的数组数据结构的实现。
尽管所有这些定义都值得探讨,但它们并不是本章的重点。相反,本章深入探讨了欧几里得向量(以希腊数学家欧几里得命名),也被称为几何向量。当你在本书中看到“向量”这个词时,你可以假设它指的是欧几里得向量,即具有大小和方向的实体。
向量通常绘制为箭头,如图 1.1 所示。箭头的指向表示向量的方向,箭头的长度则表示向量的大小。
图 1.1 中的向量被绘制为从 A 点指向 B 点的箭头。它表示从 A 到 B 的旅行方向。

图 1.1:一个作为箭头从 A 点指向 B 点的向量
向量的意义
在深入了解向量的更多细节之前,我想创建一个 p5.js 的示例,演示为什么你应该首先关心向量。如果你曾看过任何初学者 p5.js 教程,阅读过任何介绍性的 p5.js 教材,或参加过创意编码入门课程(希望你在准备这本书时做过其中一项!),你可能在某个时刻学过如何编写一个弹跳球的草图。

在这个例子中,有一个平面二维世界——一块空白画布——其中一个圆形物体(“球”)在四处移动。这个球有位置、速度等属性,这些属性在代码中以变量的形式表示:
| 属性 | 变量名 |
|---|---|
| 位置 | x 和 y |
| 速度 | xspeed 和 yspeed |
在一个更复杂的草图中,你可能会有更多的变量来表示球和它的环境的其他属性:
| 属性 | 变量名 |
|---|---|
| 加速度 | xacceleration 和 yacceleration |
| 目标位置 | xtarget 和 ytarget |
| 风速 | xwind 和 ywind |
| 摩擦力 | xfriction 和 yfriction |
你可能会注意到,在这个世界中的每个概念(风速、位置、加速度等)都有两个变量。这只是一个二维世界。在三维(3D)世界中,每个属性都需要三个变量:位置用 x、y 和 z 表示;速度用 xspeed、yspeed 和 zspeed 表示;依此类推。是不是很想简化代码,减少使用变量的数量?而不是像这样开始程序
let x;
let y;
let xspeed;
let yspeed;
你将能够像这样开始它:
let position;
let speed;
将球的属性视为向量,而不是将它们作为一组分离的值,将帮助你做到这一点。
迈出使用向量的第一步,并不会让你做出什么新奇的事物,也不会将 p5.js 草图 magically 转换为完整的物理仿真。然而,使用向量将帮助你组织代码,并为你在编程运动时需要反复使用的常见数学操作提供一套方法。
作为向量的介绍,我将长期坚持使用二维(至少前几章)。所有这些例子都可以相对容易地扩展到三维(并且我将使用的类 p5.Vector 支持三维)。然而,为了学习基础,第三维度带来的额外复杂性会分散注意力。
p5.js 中的向量
将向量视为两点之间的差异,或者将其视为从一个点走到另一个点的指令。例如,图 1.2 展示了一些向量及其可能的解释。

图 1.2:三条示例向量以箭头的形式绘制,附带的指令指示向北、南、东或西方向行走。
这些向量可以按以下方式理解:
| 向量 | 说明 |
|---|---|
| (–15, 3) | 向西走 15 步;转身并向北走 3 步。 |
| (3, 4) | 向东走 3 步;转身并向北走 4 步。 |
| (2, –1) | 向东走 2 步;转身并向南走 1 步。 |
你可能已经在编程运动时考虑过这种方式。对于每一帧动画(即每次通过 p5.js 的draw()循环),你会指示每个对象将自己重新定位到一个新的位置,水平和垂直方向分别移动一定的像素数。这个指令本质上就是一个向量,如图 1.3 所示;它有大小(你移动了多远?)和方向(你朝哪个方向移动?)。

图 1.3:一个向量,表示从一个位置到新位置的水平和垂直步数
向量设置了对象的速度,定义为对象位置随时间变化的速率。换句话说,速度向量决定了对象在每一帧动画中的新位置,根据这个基本的运动算法:新位置等于将速度应用到当前的位置的结果。
如果速度是一个向量(表示两个点之间的差异),那么位置呢?位置也是向量吗?从技术上讲,你可以辩称位置不是向量,因为它并不描述从一个点到另一个点的移动;它描述的是空间中的一个单一位置。然而,另一种描述位置的方式是将其视为从原点(0, 0)到当前点所经过的路径。当你以这种方式考虑位置时,它就变成了一个向量,就像速度一样,正如图 1.4 所示。

图 1.4:一个计算机图形窗口,左上角是(0,0),显示了位置向量和速度向量
在图 1.4 中,向量被放置在计算机图形画布上。不同于图 1.2,原点(0, 0)不在中心,而是在左上角。而且,不再是北、南、东、西方向,而是沿着 x 轴和 y 轴有正负方向(y 轴的正方向指向下方)。
让我们来看看位置和速度的底层数据。在弹跳球的例子中,我最初有以下变量:
| 属性 | 变量名 |
|---|---|
| 位置 | x,y |
| 速度 | xspeed,yspeed |
现在,我将位置和速度视为向量,每个向量由具有x和y属性的对象表示。如果我自己编写一个Vector类,我会从类似这样的代码开始:
class Vector {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
请注意,这个类设计用于存储与之前相同的数据——每个向量有两个浮动小数值,一个是x值,一个是y值。从根本上讲,Vector对象只是一个方便的方式,通过一个名称存储两个值(或在 3D 示例中存储三个值)。
恰好,p5.js 已经有一个内建的p5.Vector类,因此我不需要自己编写。于是,这
let x = 100;
let y = 100;
let xspeed = 1;
let yspeed = 3.3;
变成这样:
let position = createVector(100, 100);
let velocity = createVector(1, 3.3);
请注意,position 和 velocity 向量对象并没有像你可能预期的那样,通过调用构造函数来创建。与其写 new p5.Vector(x, y),我使用了 createVector(x, y)。createVector() 函数作为 p5.js 中的辅助函数,在创建向量时处理幕后细节。除特殊情况外,你应该始终通过 createVector() 来创建 p5.Vector 对象。我需要指出,像 createVector() 这样的 p5.js 函数不能在 setup() 或 draw() 之外执行,因为库还未加载。我将在示例 1.2 中演示如何处理这个问题。
现在我有了两个向量对象(position 和 velocity),我准备实现基于向量的运动算法:position = position + velocity。在示例 1.1 中,没有使用向量时,代码如下:

在理想情况下,我将能够像这样重新编写:

然而,在 JavaScript 中,加法运算符 + 是为原始值(整数、浮点数等)保留的。JavaScript 不知道如何将两个 p5.Vector 对象相加,就像它不知道如何将两个 p5.Font 对象或 p5.Image 对象相加一样。幸运的是,p5.Vector 类包含了用于常见数学运算的方法。
向量加法
在继续使用 p5.Vector 类和 add() 方法之前,让我们先用数学和物理课本中常见的符号来研究向量加法。向量通常以粗体字或上面加箭头的形式书写。为了本书的目的,为了区分向量(具有大小和方向)和标量(单一值,例如整数或浮动小数),我将使用箭头符号表示:
-
向量:
![Image]()
-
标量:x
假设我有图 1.5 中显示的两个向量。

图 1.5:两个向量
和
以三角形形式表示
每个向量有两个分量,x 和 y。为了将两个向量相加,将 x 分量和 y 分量分别相加,得到一个新的向量,如图 1.6 所示。

图 1.6:通过组合 x 和 y 分量来加法向量
换句话说,
可以写成如下形式:
w[x] = u[x] + v[x]
w[y] = u[y] + v[y]
然后,将
和
替换为来自图 1.6 的值,你将得到:
w[x] = 5 + 3 = 8
w[y] = 2 + 4 = 6
最后,将结果写成一个向量:

向量加法性质
向量加法遵循与实数相同的代数规则。
可交换性规则:
结合性规则:
抛开复杂的术语和符号,这些规则归结为一个非常简单的概念:无论向量加法的顺序如何,结果是相同的。将向量替换为常规的数字(标量),这些规则就很容易理解:
可交换性:3 + 2 = 2 + 3
结合性:(3 + 2) + 1 = 3 + (2 + 1)
现在我已经讲解了将两个向量相加的理论,我可以开始讲解如何在 p5.js 中添加向量对象。再假设一次,我正在创建我自己的Vector类。我可以为它定义一个名为add()的函数,接收另一个Vector对象作为参数:

这个函数查找两个向量的 x 和 y 分量,并分别相加。这正是内建的p5.Vector类的add()方法的写法。了解了它的工作原理后,我现在可以回到反弹球的例子,使用位置 + 速度算法并实现向量加法:

现在,你已经具备了重写反弹球示例并使用向量的必要条件。

在这个阶段,你可能会感到有些失望。毕竟,这些变化看起来让代码变得比原来更复杂了。虽然这是一个完全合理且有效的批评,但理解向量编程的强大功能尚未完全展现是非常重要的。仅仅实现一个反弹球并进行向量加法只是第一步。当我进入一个更复杂的世界,多个物体和多个力(我将在第二章中介绍)作用于这些物体时,向量的优势会变得更加明显。
然而,我需要注意到,过渡到使用向量编程时的一个重要方面。即使我使用p5.Vector对象来封装两个值——球的位置的x和y,或球的速度的x和y——在一个单一的变量名下,我仍然常常需要单独引用每个向量的 x 和 y 分量。
circle()函数不允许将p5.Vector对象作为参数传递。一个圆只能用两个标量值,x 坐标和 y 坐标来绘制。因此,我必须深入到p5.Vector对象中,通过面向对象的点语法提取出 x 和 y 分量:
circle(position, 48);
circle(position.x, position.y, 48);
在测试圆形是否已经到达窗口边缘时,也会出现相同的问题。在这种情况下,我需要访问position和velocity两个向量的各个分量:
if ((position.x > width) || (position.x < 0)) {
velocity.x = velocity.x * -1;
}
有时直接访问对象的属性与将对象作为整体引用或使用其方法之间的区别并不总是显而易见。本章(以及本书的大部分内容)的目标是通过提供各种示例和使用案例,帮助你区分这些场景。
练习 1.1
从第零章中取一个行走者示例,并将其转换为使用向量。
练习 1.2
找一个你之前在 p5.js 中使用单独的 x 和 y 变量制作的东西,并用向量代替它。
练习 1.3
将示例 1.2 扩展为 3D。你能让一个球体在盒子中反弹吗?
更多向量数学
加法实际上只是第一步。许多数学运算通常与向量一起使用。以下是 p5.Vector 类中可用的操作的完整表格。请记住,这些不是独立的函数,而是与 p5.Vector 类关联的方法。当你看到以下表格中的 this 时,它指的是该方法正在操作的特定向量。
| 方法 | 任务 |
|---|---|
| add() | 将一个向量加到此向量上 |
| sub() | 从此向量中减去另一个向量 |
| mult() | 通过乘法缩放此向量 |
| div() | 通过除法缩放此向量 |
| mag() | 返回此向量的大小 |
| setMag() | 设置此向量的大小 |
| normalize() | 将此向量归一化为单位长度 1 |
| limit() | 限制此向量的大小 |
| heading() | 返回此向量的 2D 方向角度 |
| rotate() | 按角度旋转此 2D 向量 |
| lerp() | 线性插值到另一个向量 |
| dist() | 返回两个向量(视为点)之间的欧几里得距离 |
| angleBetween() | 计算两个向量之间的角度 |
| dot() | 返回两个向量的点积 |
| cross() | 返回两个向量的叉积(仅在三维中相关) |
| random2D() | 返回一个随机的 2D 向量 |
| random3D() | 返回一个随机的 3D 向量 |
我现在将介绍一些关键方法。随着例子的逐步复杂化,在后面的章节中,我会继续揭示更多的细节。
向量减法
在已经讲解了加法之后,我现在来讲解减法。其实不难;只需将加号替换为减号即可!不过,在处理减法之前,先考虑一下向量
变成–
的意义。标量 3 的负数是–3,而负向量也是类似的:每个向量分量的极性都会被反转。所以,如果
有分量(x, y),那么–
就是(–x, –y)。从视觉上来看,这会生成一个长度与原始向量相同的箭头,指向相反的方向,如图 1.7 所示。

图 1.7:
与–
之间的关系
因此,减法和加法是相同的,只不过方程中的第二个向量被视为其负数版本:

就像向量相加时,将它们“头尾相接”——也就是将一个向量的头部(或终点)与下一个向量的尾部(或起点)对齐——向量相减时,第二个向量的方向会反转,并将它放置在第一个向量的末尾,如图 1.8 所示。

图 1.8:向量减法将一个向量放置在另一个向量的末端,但方向相反。
要实际解决减法问题,计算向量分量的差值。即,
可以写成如下所示:
w[x] = u[x] – v[x]
w[y] = u[y] – v[y]
在p5.Vector中,代码如下所示:
sub(v) {
this.x = this.x - v.x;
this.y = this.y - v.y;
}
以下示例通过计算两个点之间的差来演示向量减法(这两个点被视为向量):鼠标位置和窗口的中心。

注意使用translate()来将结果向量可视化为从中心 (width / 2, height / 2) 到鼠标的线。向量减法是一种特殊的平移操作,它移动位置向量的“原点”。在这里,通过将中心向量从鼠标向量中减去,我实际上是在将结果向量的起点移动到画布的中心。因此,我还需要通过使用translate()来移动原点。如果没有这个操作,线条将从左上角绘制,视觉上的连接就不那么清晰了。
向量乘法与除法
进入乘法部分,你需要从不同的角度思考。乘法向量通常指的是缩放向量的过程。如果我想将一个向量缩放为其大小的两倍或三分之一,同时保持其方向不变,我会说:“将向量乘以 2”或“将向量乘以 1/3。”与加法和减法不同,我是在将向量乘以一个标量(一个数),而不是另一个向量。图 1.9 说明了如何通过 3 的倍数缩放一个向量。

图 1.9:通过乘法缩放一个向量
要缩放一个向量,可以将每个分量(x 和 y)乘以一个标量。也就是说,
可以写成如下形式:
w[x] = u[x] × n
w[y] = u[y] × n
作为一个例子,假设
和 n = 3。你可以按照以下方式计算
:
w[x] = −3 × 3
w[y] = 7 × 3
= (− 9, 21)
这正是 p5.Vector 类中的 mult() 函数的工作原理:

在代码中实现乘法非常简单,代码如下:

示例 1.4 通过在鼠标和画布中心之间画一条线来说明向量乘法,正如前一个示例所示,然后将该线缩放为 0.5。

得到的向量是原始大小的一半。与其将向量乘以 0.5,我也可以通过将向量除以 2 来达到相同的效果,就像在图 1.10 中所示。

图 1.10:通过除法缩放一个向量
向量除法的运作方式和向量乘法一样——只需将乘法符号(*)替换为除法符号(/)。以下是 p5.Vector 类如何实现 div() 函数:
div(n) {
this.x = this.x / n;
this.y = this.y / n;
}
以下是如何在草图中使用 div() 函数:

这将向量 u 除以 2。
更多与向量相关的数值属性
与加法一样,乘法的基本代数规则也适用于向量。
结合律:
两个标量和一个向量的分配律:
两个向量和一个标量的分配律:
向量大小
如前所述,乘法和除法可以改变向量的长度,而不影响其方向。你可能会想:“好吧,那我怎么知道一个向量的长度是多少呢?我知道这个向量的分量(x 和 y),但是这个实际的箭头有多长(单位:像素)?”理解如何计算向量的长度,也就是它的大小,是非常有用且重要的。

图 1.11:向量的长度或大小!Image 通常表示为
。
请注意在图 1.11 中,向量以箭头和两个分量(x 和 y)表示,形成了一个直角三角形。三角形的边是分量,而斜边是箭头。我们很幸运有这个直角三角形,因为曾经有位名叫毕达哥拉斯的希腊数学家发现了一个优美的公式,描述了直角三角形的边和斜边之间的关系。这个公式就是勾股定理,即a² + b² = c²(见图 1.12)。

图 1.12:勾股定理通过使用向量的分量来计算向量的长度。
有了这个公式,我们现在可以计算
的大小,如下所示:

在p5.Vector类中,mag()函数使用相同的公式定义:
mag() {
return sqrt(this.x * this.x + this.y * this.y);
}
下一个示例中的草图计算了鼠标和画布中心之间的向量大小,并将其可视化为一条穿过窗口顶部的矩形。

请注意,向量的大小(长度)始终为正,即使向量的分量是负数。
归一化向量
计算向量的大小只是开始。它打开了许多可能性的大门,其中第一个是归一化(图 1.13)。这是将某物标准化或称为“正常化”的过程。对于向量来说,约定是标准向量的长度为 1。因此,归一化一个向量就是将任何长度的向量改为长度为 1,而不改变其方向。归一化后的向量被称为单位向量。

图 1.13:当一个向量被归一化时,它仍然指向相同的方向,但其长度已经调整为单位长度 1。
单位向量描述了向量的方向,而不考虑其长度。一旦你开始处理第二章中的力时,你会发现它特别有用。
对于任何给定的向量
,其单位向量(写作û)的计算方式如下:

换句话说,要归一化一个向量,将每个分量除以向量的大小。为了理解为什么这样做有效,考虑一个向量(4, 3),其大小为 5(见图 1.14)。一旦归一化,向量的大小将为 1。将向量视为直角三角形,归一化的过程通过除以 5 来缩小斜边(因为 5/5 = 1)。在这个过程中,每一边也按 5 的比例缩小。边长从 4 和 3 分别变为 4/5 和 3/5。

图 1.14:为了标准化一个向量,其分量将被其大小除以。
在p5.Vector类中,标准化方法写作如下:
normalize() {
let m = this.mag();
this.div(m);
}
当然,这里有一个小问题。如果向量的大小是 0 怎么办?你不能除以 0!一些快速的错误检查(如下所示)可以解决这个问题。
normalize() {
let m = this.mag();
if (m > 0) {
this.div(m);
}
}
本示例使用标准化来使鼠标和画布中心之间的向量长度固定,不管原始向量的实际大小如何。

注意,在将mouse向量标准化为 1 后,我将其乘以了 50。标准化通常是创建具有特定长度的向量的第一步,即使目标长度不是 1。你将在本章稍后看到更多相关内容。
所有这些向量数学的内容听起来像是你应该了解的东西,但为什么呢?它如何帮助你编写代码?耐心点,使用p5.Vector的强大功能将在一段时间后完全显现出来。这在学习新数据结构时是很常见的现象。例如,当你第一次学习数组时,可能会觉得使用数组比用几个变量表示多个东西要麻烦。但当你需要处理 100、1000 或 10000 个元素时,这种做法就会迅速崩溃。
向量的情况也一样。现在看起来像是更多的工作,稍后会得到回报,而且回报会非常不错。而且你不必等太久,因为你的奖励将在下一章到来。不过现在,我将专注于向量是如何工作的,以及如何通过使用它们提供一种不同的思维方式来处理运动。
使用向量进行运动
使用向量编程运动是什么意思?你在示例 1.2 中体验过了,那个是弹跳球。屏幕上的圆圈有一个位置(它在任何给定时刻的位置),以及一个速度(指示它如何从一个时刻移动到下一个时刻)。速度被加到位置上:
position.add(velocity);
然后,物体会绘制在新的位置:
circle(position.x, position.y, 48);
这些步骤合起来构成了运动基础 101:
-
将速度添加到位置。
-
在该位置绘制物体。
在弹跳球的示例中,所有这些代码都在setup()和draw()中完成。现在我想做的是将一个物体的运动逻辑封装到一个类中。这样,我就可以为编程运动物体创建一个基础,便于我反复使用。(有关面向对象编程的简要回顾,请参见第 3 页的《随机行走类》一节。)
首先,我将创建一个通用的Mover类,用于描述一个形状在画布上的移动。为此,我必须考虑以下两个问题:
-
一个移动器包含哪些数据?
-
一个移动器具备什么功能?
《运动 101》算法回答了这两个问题。首先,一个Mover对象有两部分数据,position和velocity,它们都是p5.Vector对象。这些数据在对象的构造函数中初始化。在这种情况下,我随意决定通过给Mover对象一个随机的位置和速度来初始化它。注意所有属于Mover对象的变量都使用了this关键字:

功能也跟着变化。Mover对象需要移动(通过将速度应用到位置)并且需要可见。我将把这些需求实现为名为update()和show()的函数。我会把所有运动逻辑的代码放在update()中,并在show()中绘制对象:

Mover类还需要一个函数,用来决定当对象到达画布边缘时应该做什么。目前,我做了一件简单的事情,让它在边缘处绕回来:

现在Mover类已经完成,但该类本身不是一个对象;它是用来创建对象实例的模板。要实际创建一个Mover对象,我首先需要声明一个变量来存储它:
let mover;
然后,在setup()函数中,我通过调用类名和new关键字来创建对象。这会触发类的构造函数,从而创建该对象的实例:
mover = new Mover();
现在剩下的就是在draw()中调用适当的方法:
mover.update();
mover.checkEdges();
mover.show();
这是完整的示例,供参考。

如果面向对象编程(OOP)对你来说是全新的,这里有一点可能看起来有些奇怪。我在本章开头讨论了p5.Vector类,而这个类是用来创建position对象和velocity对象的模板。那么,这些对象为什么会出现在另一个对象——Mover对象里呢?
事实上,这几乎是最正常不过的事了。一个对象就是用来存储数据(和功能)的。这些数据可以是数字,也可以是其他对象(包括数组)!你会在本书中反复看到这一点。例如,在第四章中,我将编写一个类来描述一个粒子系统。那个ParticleSystem对象将包含一个Particle对象的列表……而每个Particle对象的数据将包含几个p5.Vector对象!
你可能还注意到,在Mover类中,我直接在构造函数内设置了初始位置和速度,而没有使用任何参数。虽然这种做法目前保持了代码的简洁,但我将在第二章中探讨在构造函数中添加参数的好处。
到此为止,希望你已经熟悉了两个概念:(1)什么是向量,以及(2)如何在对象内使用向量来跟踪其位置和运动。这是一个很好的第一步,值得小小鼓掌。然而,在真正的热烈掌声到来之前,你还需要再迈出一步,这一步有些大。毕竟,观看“运动 101”示例相当无聊。圆形从未加速,从未减速,也从未转动。为了实现更复杂的运动——那种在我们周围的世界中出现的运动——需要向类中添加一个额外的向量:加速度。
加速度
加速度是速度变化的速率。稍微思考一下这个定义。这是一个全新的概念吗?不完全是。之前我将速度定义为位置变化的速率,因此,本质上,我正在开发一个渐变效应。加速度影响速度,而速度反过来影响位置。(为了稍作铺垫,这一点将在下一章变得更加关键,当我展示摩擦力等力量如何影响加速度,从而影响速度,再影响位置时。)在代码中,这个渐变效应是这样的:
velocity.add(acceleration);
position.add(velocity);
作为一个练习,从现在开始,我给自己定个规则:我会尽量在本书余下的示例中,避免直接接触速度和位置的值(除了初始化它们)。换句话说,编程运动的目标是提出一个计算加速度的算法,然后让它的渐变效应发挥作用。(事实上,会有许多理由打破这个规则,而我也会打破它。尽管如此,作为一个起点,这个限制还是很有用的,它有助于通过加速度的运动算法原理进行说明。)
接下来的步骤是想出一种计算加速度的方法。以下是几种可能的算法:
-
恒定加速度
-
随机加速度
-
朝向鼠标的加速度
我将用本章的剩余部分来向你展示如何实现这些算法。
算法 1:恒定加速度
加速度算法 1,恒定加速度,虽然并不特别有趣,但它是最简单的,因此是将加速度引入代码的绝佳起点。第一步是向Mover类添加另一个变量:

接下来,将加速度整合到update()函数中:

我快完成了。唯一缺少的部分就是让那个移动器动起来!在构造函数中,初始速度被设置为 0,而不是之前做的随机向量。因此,当草图开始时,物体是静止的。为了让它动起来,而不是直接改变速度,我将通过物体的加速度来更新它。根据算法 1,加速度应该是恒定的,因此我现在选择一个值:
this.acceleration = createVector(-0.001, 0.01);
这意味着动画的每一帧,物体的速度在 x 方向应该增加 -0.001 像素,在 y 方向增加 0.01 像素。也许你会想,“天哪,这些值看起来太小了!”确实,它们非常微小,但这是有意设计的。加速度值随着时间在速度中累积,每秒约 30 次,这取决于草图的帧速率。为了防止速度矢量的大小增长过快并失控,加速度值应保持相当小。
通过使用 p5.Vector 函数 limit(),我还可以帮助保持速度在合理范围内,它限制了向量的大小:

这转化为以下内容:
速度的大小是多少?如果小于 10,没问题;保持原样。如果大于 10,则将其减小到 10!
练习 1.4
为 p5.Vector 类编写 limit() 函数:
limit(max) {
if (this.mag() > max) {
this.normalize();
this.mult(max);
}
}
让我们来看看 Mover 类的更改,包括 acceleration 和 limit()。

结果是物体向下和向左移动,逐渐加速,直到达到最大速度。
练习 1.5
创建一个物体的模拟(想象一辆车),按向上箭头加速,按向下箭头刹车。
算法 2:随机加速
现在进入加速度算法 2,随机加速。在这种情况下,我不想在对象的构造函数中初始化 acceleration,而是希望在 update() 方法内随机设置其值。这样,每帧动画对象将获得一个不同的加速度向量:

random2D() 方法生成一个归一化向量,意味着它具有随机的方向,但其大小始终为 1。为了增加趣味性,我可以尝试将随机向量按照一个常数值进行缩放:

或者,为了更大的变化,我可以将加速度缩放到一个随机值。在 示例 1.9 中,acceleration 向量既具有随机方向又具有 0 到 2 的随机大小。

很重要的一点是要理解,加速度不仅仅指加速或减速。正如这个例子所显示的,它指的是速度的任何变化——大小或方向。加速度用于控制物体的运动,你将在未来的章节中再次看到这一点,当我开始编写能够决定如何移动的对象时。
你可能还会注意到,这个例子是另一种类型的随机游走。然而,和上一章的例子不同,关键在于 随机化的内容。在传统的随机游走中,我是直接操作速度,也就是说每一步都与上一部完全独立。而在 例子 1.9 中,是加速度(速度的变化率)在随机化,而不是速度本身。这使得物体的运动依赖于其之前的状态:速度根据随机加速度逐步变化。由此产生的物体运动具有连续性和流畅性,而原始的随机游走缺乏这种特性。这个差异可能看起来很微妙,但它从根本上改变了物体在画布上的运动方式。
练习 1.6
回到 练习 0.6,实现一个基于 Perlin 噪声计算的加速度。
静态方法与非静态方法
你可能注意到在上一个例子中有些奇怪而陌生的地方。用于创建随机单位向量的 random2D() 方法是通过类名调用的,像这样 p5.Vector.random2D(),而不是通过类的当前实例调用,像这样 this.random2D()。这是因为 random2D() 是一个 静态方法,意味着它是与整个类相关的,而不是与个别对象(即该类的实例)相关的。
当你编写自己的类(如 Walker 或 Mover)时,通常不需要静态方法,所以你可能没有接触过它们。然而,在一些预写的类中,如 p5.Vector,静态方法有时是非常重要的一部分。事实上,加速算法 3(朝向鼠标加速)需要进一步使用这个概念,所以让我们退一步,考虑一下静态方法和非静态方法之间的区别。
暂时抛开向量,看看以下代码:
let x = 0;
let y = 5;
x = x + y;
这可能是你习惯的方式,对吗?我给 x 赋值为 0,将 y 加到它上面,现在 x 等于 5。我可以写类似的代码来添加两个向量:
let v = createVector(0, 0);
let u = createVector(4, 5);
v.add(u);
向量 v 的值为 (0, 0),我将向量 u 加到它上面,现在 v 的值变为 (4, 5)。有道理,对吧?
现在来看这个例子:
let x = 0;
let y = 5;
let z = x + y;
我给 x 赋值为 0,将 y 加到它上面,并将结果存储到一个新变量 z 中。这里 x 的值没有改变(y 也没有改变)!这看起来可能是一个微不足道的点,对于简单数字的数学运算来说,它非常直观。然而,当我们使用 p5.Vector 对象进行数学运算时,这就不那么显而易见了。让我们尝试基于我到目前为止介绍的 p5.Vector 类,重写这个例子:

这看起来可能是一个不错的猜测,但这不是 p5.Vector 类的工作方式。如果你查看 add() 方法的定义,你就能明白原因:
add(v) {
this.x = this.x + v.x;
this.y = this.y + v.y;
}
这段代码有两个问题。首先,add() 方法不会返回一个新的 p5.Vector 对象;其次,add() 会改变它调用时所在向量的值。为了将两个向量对象相加并返回结果作为一个新的向量,我必须使用 add() 方法的静态版本,调用时是类名而非特定对象实例的非静态版本。
如果我自己声明这个类,下面是如何编写 add() 的静态版本:

这里的关键区别是,方法返回一个新的向量(v3),它是通过 v1 和 v2 的分量之和创建的。因此,方法不会对任何原始向量做出修改。
调用静态方法时,你不是引用对象实例,而是引用类的名称。下面是实现向量加法示例的正确方式:
let v = createVector(0, 0);
let u = createVector(4, 5);
let w = v.add(u);
let w = p5.Vector.add(v, u);
p5.Vector 类有 add()、sub()、mult() 和 div() 的静态版本。这些静态方法允许你对向量执行通用的数学运算,而不会在过程中改变任何输入向量的值。
练习 1.7
将以下伪代码转化为代码,根据需要使用静态或非静态函数:
-
向量
v等于 (1, 5)。 -
向量
u等于v乘以 2。 -
向量
w等于v减去u。 -
将向量
w除以 3。
let v = createVector(1, 5);
let u = p5.Vector.mult(v, 2);
let w = p5.Vector.sub(v, u);
w.div(3);
算法 3:交互式运动
为了完成本章内容,让我们尝试一些更复杂、也更有用的内容。我将根据加速度算法 3 中所述的规则动态计算一个对象的加速度:对象朝向鼠标加速。
每当你需要根据规则或公式计算一个向量时,你需要计算两个属性:大小和方向。我将从方向开始。我知道加速度向量应该从对象的位置指向鼠标位置(图 1.15)。假设对象位于位置向量 (x, y),而鼠标位于 (mouseX, mouseY)。

图 1.15:从对象到鼠标位置的向量
在图 1.16 中,你可以看到加速度向量(dx,dy)可以通过从对象位置中减去鼠标位置来计算:
-
dx = mouseX - x
-
dy = mouseY - y

图 1.16:通过取鼠标和位置向量的差来计算初始加速度向量
让我们通过使用 p5.Vector 语法来实现这个。假设代码位于 Mover 类内,因此可以访问对象的 position,我可以这样写:

我使用了sub()的静态版本来创建一个新的向量direction,它指向从物体的位置到鼠标的位置。然而,如果物体实际使用该向量加速,它会瞬间出现在鼠标位置,因为direction的大小等于物体与鼠标之间的距离。当然,这样做并不会产生平滑的动画。因此,下一步是决定物体应该以多快的速度向鼠标加速,通过改变向量的大小。
要设置加速度向量的大小(无论它是多少),我必须先 ______ 该向量。没错,你说对了:归一化!如果我能将向量缩小到单位向量(长度为 1),我就可以轻松地将它缩放到任何其他值,因为 1 乘以任何东西都等于任何东西:

总结一下,按照以下步骤让物体向鼠标加速:
-
计算一个指向物体到目标位置(鼠标)的向量。
-
归一化该向量(将其长度缩小到 1)。
-
将那个向量缩放到适当的值(通过将其乘以某个值)。
-
将该向量分配给加速度。
我得承认,归一化然后缩放是如此常见的向量操作,以至于p5.Vector包含了一个可以同时完成这两个操作的函数,用一个函数调用即可将向量的大小设置为给定值。那个函数就是setMag():
let anything = ?????
dir.setMag(anything);
在下一个示例中,为了强调数学部分,我将使用normalize()和mult()来编写代码,但这可能是我最后一次这样做了。你会在之后的示例中看到setMag()。

你可能会想,为什么圆圈在到达目标时不会停止。需要注意的是,运动物体并不了解试图停在目的地这一点;它只知道目的地的位置。物体会以固定速率向该位置加速,无论距离有多远。这意味着它不可避免地会超越目标,然后必须转过头来,再次加速朝向目的地,再次超越目标,如此反复。敬请期待;在后续章节中,我会向你展示如何编程让物体到达目标(在接近时减速)。
练习 1.8
示例 1.10 非常接近引力吸引的概念,物体被吸引到鼠标位置。然而,在这个示例中,吸引的强度是恒定的,而在现实中的引力中,强度与距离成反比:物体越接近吸引点,它加速的速度越快。我将在下一章详细讲解引力吸引的内容,但现在,请尝试实现你自己的版本,示例 1.10,并使用可变的加速度强度,物体离得越近或越远,吸引力越强。
生态系统项目
将向量引入,以进一步发展和精炼你生态系统中元素的运动。探索如何通过单纯操控物体的加速度向量来引导运动。
你如何计算加速度来模拟某些行为——一只紧张的苍蝇不规则的嗡嗡声、一只兔子轻盈的跳跃,或者一条蛇的滑行?加速度在自然界中扮演着什么角色?考虑一下鸟类起飞时如何加速,或者鱼类游泳时如何突然改变方向。再次思考,生物的个性能有多少是通过其行为塑造的?通过引入更多视觉设计元素,除了简单的形状外,还能添加(或去除)什么?

第三章:2 力
不要低估力量。
—达斯·维达

卡尔德 装置,位于麻省理工学院查尔斯·海登纪念图书馆新画廊,1950 年(照片由埃兹拉·斯托勒拍摄)
亚历山大·卡尔德是 20 世纪美国艺术家,以其平衡形态与运动的动感雕塑而闻名。他的“星座”系列是由互相连接的形状和金属丝构成的雕塑,展示了张力、平衡和无处不在的重力吸引力。
在第一章的最后一个例子中,我演示了如何根据从画布上的一个圆形到鼠标位置的向量计算动态加速度。结果的运动类似于形状和鼠标之间的磁力吸引,好像有一个力正在将圆形拉向鼠标。在这一章中,我将详细阐述力的概念及其与加速度的关系。通过这一章的学习,目标是构建一个简单的物理引擎,并理解物体如何在画布上移动,响应各种环境力。
物理引擎是一个计算机程序(或代码库),用于模拟物体在物理环境中的行为。在 p5.js 草图中,物体是二维形状,环境是一个矩形画布。物理引擎可以开发得非常精确(需要高性能计算)或实时(使用简单快速的算法)。本章的重点是构建一个基础的物理引擎,强调速度和易于理解。
力与牛顿的运动定律
让我们首先从概念上探讨什么是现实世界中的力。就像“向量”这个词一样,“力”这个词也可以有多种含义。它可以指强大的物理强度,像是“他们以极大的力量推动巨石”,也可以指强大的影响力,像是“他们是不可忽视的力量!”我在本章中感兴趣的力的定义更加正式,源自艾萨克·牛顿的三大运动定律:
力是一个向量,它使具有质量的物体加速。
希望你能认出定义的第一部分:力是一个向量。谢天谢地,你刚刚花了一整章学习向量是什么以及如何使用它们编程!我将从这里开始,解释牛顿的三大运动定律与向量之间的关系;然后,我会在接下来的讲解中展示力的其余定义。
牛顿的第一定律
牛顿的第一定律通常是这样表述的:
一个静止的物体保持静止,运动中的物体保持运动。
然而,这里缺少一个与力相关的重要元素。我可以通过以下方式扩展定义:
一个静止的物体保持静止,运动中的物体保持运动,以恒定的速度和方向,除非受到不平衡力的作用。
当牛顿提出理论时,流行的运动理论——由亚里士多德提出——已经有近 2000 年的历史。它认为,如果一个物体在运动,就需要某种力来保持它的运动。除非这个物体正在被推动或拉动,否则它会减速或停止。这个理论通过对世界的观察得到了验证。例如,如果你扔一个球,它会掉到地上并最终停止运动,似乎是因为投掷的力不再作用于它。
当然,这个旧理论并不正确。正如牛顿所确立的,在没有任何外力作用的情况下,不需要任何力来保持物体的运动。当物体(比如前面提到的球)在地球大气中被投掷时,它的速度会因为空气阻力和重力等看不见的力而发生变化。只有在没有任何力作用的情况下,物体的速度才会保持恒定,或者只有当作用于物体的各个力相互抵消时,物体的速度才会保持不变,这意味着净力的合力为零。这通常被称为平衡(见图 2.1)。当空气阻力与重力的作用力相等时,掉落的球将达到终端速度(并保持恒定)。

图 2.1:玩具鼠标不动,因为所有的力相互抵消(即它们的合力为零)。
考虑到 p5.js 画布,我可以这样重新表述牛顿的第一定律:
如果物体处于平衡状态,它的速度向量将保持恒定。
换句话说,在Mover类中,update()函数不应该对速度向量进行任何数学运算,除非存在非零的净力。
牛顿的第三定律
让我暂时搁置牛顿的第二定律(可以说是本书中最重要的定律),转而讨论他的第三定律。这个定律通常表述如下:
每一个作用力,都会有一个大小相等、方向相反的反作用力。
这种定律的表述经常引起混淆。首先,它听起来像是一个力引起另一个力。是的,如果你推一个人,这个人可能会主动决定反推你。但这并不是牛顿第三定律所指的作用和反作用。
假设你推墙。墙并不会主动决定反推你,但它仍然会以相等的反向力提供抵抗。没有“原始”力。你的推动同时包含了这两个力,这被称为作用/反作用对。因此,更准确地表述牛顿的第三定律可能是以下内容:
力总是成对出现。两个力的大小相等,但方向相反。
这仍然会引起混淆,因为它听起来像这些力总是会相互抵消。但事实并非如此。记住,这些力作用在不同的物体上。而且仅仅因为这两个力相等,并不意味着物体的运动是相等的(或物体会停止运动)。
想象一下推一个静止的卡车。尽管卡车的质量远大于你,但静止的卡车(与运动中的卡车不同)永远不会把你压倒并把你甩到后面。你双手对卡车施加的力与卡车对你双手施加的力大小相等、方向相反。结果取决于许多其他因素。如果卡车小且停在冰冻的街道上,你可能能够推动它。如果卡车很大,而且停在一条土路上,即使你用尽全力(甚至可能需要助跑),你也可能会伤到手。
如果你像图 2.2 那样穿着滑轮鞋推卡车呢?

图 2.2:通过穿着滑轮鞋推重卡车来演示牛顿的第三定律
你会从卡车上加速滑开,沿着路面滑行,而卡车则保持不动。为什么你会滑动而卡车不会?首先,卡车的质量要大得多(我将在牛顿的第二定律中进一步解释)。还有其他的力在起作用——即卡车轮胎与你的滑轮鞋与路面之间的摩擦力。
再次考虑 p5.js,我可以这样重新表述牛顿的第三定律:
如果你计算了一个名为f的p5.Vector,表示物体 A 对物体 B 的力,你也必须施加物体 B 对物体 A 的反向力。你可以通过p5.Vector.mult(f, -1)来计算这个反向力。
你很快会发现,在编码仿真世界中,往往不需要严格遵循牛顿的第三定律。有时,例如在描述物体之间的引力时(参见示例 2.8),我会在示例代码中建模等大且反向的力。其他时候,比如说,“嘿,环境中有风”,我就不会去模拟物体对空气施加的反作用力。事实上,我甚至不会模拟空气!记住,本书中的示例从自然界的物理学中汲取灵感,目的是为了创意和互动性。它们不要求完全的精确性。
牛顿的第二定律
现在是时候为你,p5.js 编程者,介绍最重要的定律了:牛顿的第二定律。它的表述如下:
力等于质量乘以加速度。
或者:

为什么这是本书中最重要的定律?嗯,我们换一种方式写出来:

加速度与力成正比,与质量成反比。考虑一下如果你被推了会意味着什么。你越用力推,你的加速(加快或减速)就越快。另一方面,你越大,力对你的加速效果就越小!
重量与质量
质量不能与重量混淆。质量是物体中物质的数量的度量(以千克为单位)。在地球上质量为 1 千克的物体,在月球上的质量仍然是 1 千克。
重量虽然常常被误认为是质量,但从技术上讲,它是物体所受的重力。根据牛顿第二定律,你可以通过质量乘以重力加速度(w = m × g)来计算重量。重量的单位是牛顿,它表示重力的大小。因为重量与重力相关,月球上的物体重量是地球的六分之一。
与质量相关的是密度,它被定义为单位体积内的质量(例如,克/立方厘米)。
在 p5.js 的世界里,质量到底是什么呢?我们不是在处理像素吗?让我们从简单的开始,假设在一个虚拟的像素世界里,所有物体的质量都等于 1。任何东西除以 1 都等于它本身,因此,在这个简单的世界里,我们就得到了这个:

我实际上是将质量从方程中移除了,使得物体的加速度等于力。这是个好消息。毕竟,第一章描述了加速度是控制画布上物体运动的关键。我说过位置会根据速度变化,速度会根据加速度变化。加速度似乎是一切的起点。现在你可以看到,力才是真正的一切起点。
让我们看一下 Mover 类,其中包含位置、速度和加速度:
class Mover {
constructor() {
this.position = createVector();
this.velocity = createVector();
this.acceleration = createVector();
}
}
现在的目标是能够将力应用到这个物体上,代码类似于这样:
mover.applyForce(wind);
或者像这样:
mover.applyForce(gravity);
这里,wind 和 gravity 是 p5.Vector 对象。根据牛顿的第二定律,我可以按如下方式实现 applyForce() 方法:

这个看起来挺不错的。毕竟,加速度 = 力 是牛顿第二定律的字面翻译(在没有质量的世界里)。不过,这段代码有一个相当大的问题,我将在回到我的原始目标时遇到:创建一个能够响应风力和重力的物体。考虑这段代码:
mover.applyForce(wind);
mover.applyForce(gravity);
mover.update();
假设你是计算机,先调用 applyForce() 并传入 wind,这样 Mover 对象的加速度就被赋值为 wind 向量。然后,调用 applyForce() 并传入 gravity,此时 Mover 对象的加速度就被赋值为 gravity 向量。最后,调用 update()。在 update() 中会发生什么?加速度被加到速度上:
this.velocity.add(this.acceleration);
如果你运行这段代码,你不会在控制台看到错误,但是哎呀!出现了一个大问题。当 acceleration 被加到 velocity 上时,它的值是多少?它等于 gravity 向量,意味着 wind 被忽略了!每次调用 applyForce() 时,acceleration 都会被覆盖。那我该如何处理多个力呢?
力的累积
答案是这些力必须累积,或加在一起。这是牛顿第二定律完整定义中的内容,我现在承认自己简化了它。这里有一种更准确的表述方式:
净力等于质量乘以加速度。
换句话说,加速度等于所有力的总和除以质量。在任何给定时刻,可能有 1 个、2 个、6 个、12 个或 303 个力作用在物体上。只要物体知道如何将它们加在一起(累积它们),那么不管有多少个力都无所谓。总和会给出物体的加速度(再次忽略质量)。这完全合情合理。毕竟,正如你在牛顿第一定律中看到的那样,如果作用在物体上的所有力加起来为零,那么物体就处于平衡状态(即没有加速度)。
现在,我可以修改applyForce()方法来考虑力的累积:

不过,我还没有完成。力的累积还有最后一部分。由于我在任何给定时刻都在将所有力加在一起,我必须确保在每次调用update()之前清除acceleration(将其设为0)。想象一下风的作用力。有时候风很强,有时候风很弱,有时候根本没有风。例如,你可能会写一段代码,当按住鼠标时会产生一阵风:
if (mouseIsPressed) {
let wind = createVector(0.5, 0);
mover.applyForce(wind);
}
当鼠标释放时,风应该停止,并且根据牛顿的第一定律,物体应该以恒定速度继续运动。然而,如果我忘记将acceleration重置为0,风的作用力仍然会继续生效。更糟的是,它会从前一帧叠加上去!在基于时间的物理模拟中,加速度是没有记忆的;它是根据任何给定时刻(帧)存在的环境力来计算的。这与位置不同。物体必须记住它的上一个位置,才能正确地移动到下一个位置。
清除每帧加速度的一种方法是在update()的末尾将acceleration向量乘以0:

能够累积并应用力让我更接近一个可工作的物理引擎,但此时我应该指出我一直忽略的另一个细节,除了质量之外。那就是时间步长,即模拟更新的速率。时间步长的大小会影响模拟的准确性和行为,这也是为什么许多物理引擎将时间步长作为一个变量(通常表示为dt,即delta time,或时间的变化量)。为了简化,我选择假设每次通过draw()的循环代表一个时间步长。这个假设可能不是最准确的,但它让我能集中精力关注模拟的关键原理。
我会保持这个假设,直到第六章,那时我会探讨不同时间步长的影响,并介绍第三方物理库。不过,现在,我可以并且应该解决目前一直忽视的一个重大问题:质量。
练习 2.1
使用力学模拟一个充气的氦气球向上浮动并碰到窗户顶端反弹的过程。你能否加入一个随时间变化的风力,可能是按照 Perlin 噪声的方式变化?
考虑质量
牛顿的第二定律实际上是
,而不是
。那么,如何将质量纳入模拟中呢?首先,通过向 Mover 类添加一个 this.mass 实例变量就能轻松实现,但我需要花更多时间探讨,因为有另一个即将出现的复杂问题。
但首先,我将加入质量:

测量单位
现在我在介绍质量时,重要的是要快速说明一下测量单位。在现实世界中,事物是用特定的单位来衡量的:两个物体相距 3 米,棒球的速度是每小时 90 英里,或者这个保龄球的质量是 6 千克。有时候,你确实需要考虑现实世界的单位。然而,在本章中,我将坚持使用像素作为测量单位(“这两个圆形相距 100 像素”)和动画帧数(“这个圆形每帧移动 2 像素”,即前面提到的时间步长)。
在质量的情况下,p5.js 没有可以使用的测量单位。那么每个像素的质量是多少呢?你或许可以发明一个属于你自己的 p5.js 质量单位,比如“10 像素体”或“10 尤克尔”。
为了演示,我将质量与像素挂钩(一个圆形的直径越大,其质量也越大)。这将让我能够可视化物体的质量,尽管这种做法并不准确。在现实世界中,大小并不意味着质量。一个小金属球的质量可能比一个大气球的质量要大得多,因为它的密度更高。对于两个具有相等密度的圆形物体,我还会指出,质量应该与圆的面积公式相关:π*r²。(这将在练习 2.11 中讨论,我会在第三章中详细讲解π和圆形的内容。)
质量是一个标量,而不是一个向量,它只是一个数字,用来描述物体中物质的多少。我可以使用复杂的方式,计算形状的面积作为它的质量,但更简单的方法是直接说:“嘿,这个物体的质量是……嗯,我不知道……那就定为 10 吧。”
constructor() {
this.position = createVector(random(width), random(height));
this.velocity = createVector(0, 0);
this.acceleration = createVector(0, 0);
this.mass = 10;
}
这样并不完美,因为只有当我拥有具有不同质量的物体时,事物才会变得有趣,但这足以让我们开始。质量在何处起作用呢?我需要将力除以质量,以便将牛顿的第二定律应用到物体上:

再次强调,尽管代码看起来相当合理,但它仍然存在一个重大问题。考虑以下情形,两个Mover对象都被风力吹走:
let moverA = new Mover();
let moverB = new Mover();
let wind = createVector(1, 0);
moverA.applyForce(wind);
moverB.applyForce(wind);
再次假设你是计算机。物体moverA接收到风力—(1, 0)—将其除以mass(10),然后将其添加到加速度中:
| 动作 | 向量分量 |
|---|---|
moverA接收到风力。 |
(1, 0) |
moverA将风力除以 10 的质量。 |
(0.1, 0) |
现在你继续到物体moverB。它也会收到风力—(1, 0)。等等,稍等一下。风力的值是多少?仔细看看,它实际上现在是(0.1, 0)! 记住,当你把一个物体(在这个案例中是p5.Vector)传入函数时,你传递的是该对象的引用,而不是它的副本!因此,如果一个函数对该对象做了修改(在这种情况下,它通过除以质量来修改),那么这个对象会被永久改变。但我不想让moverB收到由物体moverA的质量所除的力。我希望它收到的力是原始状态的—(1, 0)。因此,我必须保护原始向量,并在除以质量之前先复制它。
幸运的是,p5.Vector类有一个方便的方法来制作副本:copy()。它返回一个包含相同数据的新p5.Vector对象。所以,我可以按如下方式修改applyForce():

让我花点时间回顾一下到目前为止的内容。我已经定义了力是什么(一个向量),并且展示了如何将力应用到物体上(将其除以质量并加到物体的加速度向量中)。还缺少什么呢?嗯,我还没有弄清楚如何计算一个力。力到底来自哪里?
练习 2.2
你可以用另一种方式编写applyForce(),使用静态方法div()而不是copy()。通过使用静态方法重写applyForce()。有关此练习的帮助,请参考第 64 页的“静态方法与非静态方法”部分。
applyForce(force) {
let f = p5.Vector.div(force, this.mass);
this.acceleration.add(f);
}
创建力
本节介绍了两种在 p5.js 世界中创建力的方法:
-
制造一个力! 毕竟,你是程序员,你是你世界的创造者。没有理由你不能直接编造一个力并应用它。
-
模拟一个力! 力在物理世界中存在,物理学教科书中通常包含这些力的公式。你可以把这些公式转化成源代码,在 JavaScript 中模拟现实世界的力。
首先,我将专注于第一种方法。制造一个力的最简单方法就是选择一个数字(或者两个数字)。我们从模拟风力开始。假设一个风力向右并且相当弱,怎么样?假设有一个物体mover,那么代码如下所示:
let wind = createVector(0.01, 0);
mover.applyForce(wind);
结果并不特别有趣,但这是一个很好的起点。我创建了一个p5.Vector对象,初始化它,并将其传递给Mover对象(后者将其应用到自己的加速度中)。为了完成这个示例,我会再添加一个力——重力(指向下方),并且只有在按下鼠标时才激活风力。

现在,我有两个力,分别指向不同的方向,并且大小不同,这两个力都作用在mover对象上。我已经有所进展了。我创建了一个世界,一个充满力的环境,这些力作用于物体!
让我们来看一下,当我添加一个具有可变质量的第二个对象时会发生什么。为了做到这一点,你可能需要快速回顾一下面向对象编程(OOP)。再说一遍,我不会在这里覆盖所有编程基础(如果需要了解这些,可以参考《The Coding Train Connection》中的任何 p5.js 入门书籍或视频教程,详情见页面 xxx)。然而,由于创建一个充满物体的世界是本书所有示例的基础,因此值得花点时间走一遍从一个对象到多个对象的步骤。
这是我在Mover类中的进展。注意,它与在第一章中创建的Mover类完全相同,只是增加了两个内容:mass和一个新的applyForce()方法:

既然类已经写好,我可以创建多个Mover对象了:
let moverA = new Mover();
let moverB = new Mover();
但是有一个问题。再看看Mover对象的构造函数:

现在,每个Mover对象都是完全相同的。我想要的是具有可变质量并且从可变位置开始的Mover对象。一种实现这个目标的好方法是使用构造函数参数:

注意,质量和位置不再设置为硬编码的数字,而是通过传递给构造函数的x、y和mass参数来初始化。这意味着我可以创建各种各样的Mover对象——大号的、小号的、从画布左侧开始的、从右侧开始的,以及介于两者之间的各种对象:

我可以选择以各种方式初始化值(随机、Perlin 噪声、网格等)。在这里,我只是选择了一些数字用于演示目的。接下来我会在整本书中介绍其他初始化模拟的方法。
一旦对象被声明并初始化,剩下的代码就像以前一样继续。对于每个对象,将环境中的力传递给applyForce()方法,享受过程吧!

请注意,代码中的每个操作都写了两遍,一次用于moverA,一次用于moverB。实际上,使用数组管理多个Mover对象比使用单独的变量更合适,尤其是当这些对象的数量增加时。这样,我只需写一次操作,并使用循环将其应用于数组中的每个Mover。我将在本章稍后演示这一点,并在第四章中更详细地讲解数组。
练习 2.3
不再让物体从墙的边缘弹回,而是创建一个示例,加入一个看不见的力,推动物体保持在窗口内。你能根据物体距离边缘的远近来调整这个力的大小吗?也就是说,物体越靠近边缘,受到的力越大?
练习 2.4
修复画布边缘的反弹问题,使得当圆的边缘碰到边界时改变方向,而不是它的中心。
练习 2.5
创建一个可变的风力。你能使它具有交互性吗?例如,可以考虑鼠标所在位置的风扇,并让风扇朝着圆形物体吹。
当你运行示例 2.2 中的代码时,会注意到小圆对施加的力的反应比大圆更为剧烈。这是因为公式 加速度 = 力除以质量。质量在分母中,因此质量越大,加速度越小。这对于风力来说是合理的——物体的质量越大,风推它的难度越大——但这种解释对模拟地球引力是否准确呢?
如果你爬到比萨斜塔的顶部,扔下两个质量不同的球,哪个会先落地?根据传说,伽利略在 1589 年进行了这项实验,发现它们以相同的加速度下落,同时撞击地面。为什么会这样?我稍后会深入探讨这个问题,简短的答案是,虽然重力的大小是根据物体的质量来计算的——物体越大,重力越强——但当你通过质量来确定加速度时,这个力会被质量抵消。因此,不同物体的重力加速度是相等的。
对草图的一个快速修正——它更接近于真实地模拟一个力,而不仅仅是编造一个力——就是通过将重力力与质量相乘来实现这种缩放。

现在这些物体以相同的速度下落。我仍然基本上是通过任意将重力设定为 0.1 来构造重力力,但通过根据物体的质量来调整力,我让它的表现更接近地球实际的引力。与此同时,由于风力的强度与质量无关,当按下鼠标时,较小的圆仍然加速向右移动得更快。(这个示例的在线代码也包含了练习 2.4 的解决方案,添加了一个radius变量到Mover类中。)
建模一个力
构造力实际上可以让你走得很远——毕竟,我刚刚构造了一个相当好的地球重力近似。最终,p5.js 的世界是一个像素的交响乐,而你是指挥,所以无论你认为应该是什么样的力,嗯,那就应该是那种力!然而,可能会有这么一刻,你会想,“但这一切究竟是如何运作的?”那时,建模力而不是单纯构造它们就变得至关重要了。
解析公式
稍后,我将写出摩擦力的公式。这不是你第一次在本书中看到公式;我刚刚完成了对牛顿第二定律的讨论,
(或者说力等于质量乘以加速度)。希望你没有花太多时间担心那个公式,因为它只是几个字符和符号。然而,外面的世界是可怕的。只要看看正态分布的方程式,我在“随机数的正态分布”一节中讲解过(没有给出公式),请见第 13 页:

公式通常由许多符号表示(通常包括希腊字母)。这是摩擦力的公式(如
所示):

如果你有一段时间没有查看过数学或物理课本中的公式,那么在继续之前,有三个关键点需要讲清楚:
-
评估右侧;赋值给左侧。 这就像在编程中一样!在前面的例子中,左侧表示我要计算的内容——摩擦力——右侧则详细说明了如何计算它。
-
我是在谈论一个向量还是标量? 重要的是要意识到,在某些情况下,你将计算一个向量;在其他情况下,计算的是标量。例如,在这种情况下,摩擦力是一个向量。它由f上方的箭头表示。它有一个大小和方向。方程式的右侧也有一个向量,如符号
所示,这在此情况下代表速度单位向量。 -
当符号放在一起时,通常表示它们相乘。 摩擦力公式的右侧有四个元素:–、µ、N 和
。它们应该相乘,公式可以读作
。
打开任何一本高中物理教科书,你会看到描述各种力的图示和公式——重力、电磁力、摩擦力、拉力、弹性力等等。在本章的剩余部分,我将考虑三种力——摩擦力、阻力和重力吸引力,并展示如何使用 p5.js 对它们建模。我要强调的不是这些力是你在模拟中总是需要的基本力,而是将这些力作为案例研究,展示以下过程:
-
理解力背后的概念
-
将力的公式分解成两部分:
-
如何计算力的方向?
-
如何计算力的大小?
-
-
将这个公式翻译成 p5.js 代码,计算一个向量并传递给
Mover对象的applyForce()方法
如果你能跟随我提供的这些示例力的步骤,那么希望当你在凌晨三点谷歌查询原子核弱核力时,你能够具备将找到的信息转换并应用于 p5.js 的技能。
摩擦力
我们从摩擦力开始,遵循前面的步骤。每当两个表面接触时,它们都会经历摩擦力。摩擦力是一种耗散力,意味着它会将物体的动能转化为另一种形式,给人以损失或耗散的印象。
假设你正在开车。当你踩下刹车踏板时,汽车的刹车系统通过摩擦力减慢轮胎的运动。动能(运动)被转化为热能(热量)。一个完整的摩擦力模型会包括静摩擦力(物体静止在表面上)和动摩擦力(物体在表面上运动)的不同情况,但为了简化起见,我这里只会处理动摩擦力的情况。图 2.3 展示了摩擦力的公式。
由于摩擦力是一个向量,让我将这个公式分成两部分,来确定摩擦力的方向和大小。图 2.3 表明,摩擦力与速度的方向相反。实际上,这正是公式中说的!图片,或者说是速度单位向量的负一倍。在 p5.js 中,这意味着将一个物体的速度向量乘以-1:

注意这里有两个额外的步骤。首先,重要的是要复制速度向量,因为我不希望不小心反转物体的运动方向。其次,向量被标准化。这是因为摩擦力的大小与物体的速度无关,我希望从长度为 1 的向量开始,以便可以轻松缩放。

图 2.3:摩擦力是指在雪橇与山坡接触滑行时,摩擦力与雪橇的速度方向相反。
根据公式,大小为µ × N。这里使用希腊字母mu(µ,发音为mew)来表示摩擦系数。摩擦系数决定了特定表面的摩擦力强度。它越高,摩擦力越强;越低,摩擦力越弱。例如,一块冰的摩擦系数远低于砂纸。由于这是一个假想的 p5.js 世界,我可以任意设置摩擦系数来调整摩擦力的强度:
let c = 0.01;
现在进入第二部分。N表示法向力,是与物体沿表面运动方向垂直的力。可以把它想象成一辆车沿道路行驶的情形。车子在重力作用下压向道路,牛顿的第三定律告诉我们,路面会反过来对车辆施加一个力,这就是法向力。重力越大,法向力也越大。
如你将在下一部分看到的,重力吸引与质量相关,因此一辆轻量级的跑车将比一辆重型拖车卡车遭遇更少的摩擦力。然而,在图 2.3 中,由于物体沿斜面运动,计算法向力的大小和方向会更加复杂,因为它并不指向与重力相反的方向。你需要了解一些角度和三角学的知识。
所有这些细节都很重要;然而,若不考虑这些,也能实现一个“足够好的”模拟。例如,我可以假设法向力的大小始终为 1,从而让摩擦力起作用。当我在下一章学习三角学时,你可以回到这个问题,将摩擦力的例子做得更复杂。因此:
let normal = 1;
现在,我已经得到了摩擦力的大小和方向,可以将它们整合到代码中:

这段代码计算了摩擦力,但没有回答何时应用摩擦力的问题。这个问题没有答案,当然,因为这一切都只是一个在 2D p5.js 画布上可视化的虚构世界!我会做出一个任意但合乎逻辑的决定:当圆形与画布底部接触时应用摩擦力,我可以通过向Mover类中添加一个名为contactEdge()的函数来检测这一点:

现在正是我要提到的时候,这里的实际边缘反弹模拟了理想化的弹性碰撞,这意味着当圆圈和边缘碰撞时不会损失动能。在真实世界中很少见;拿起一个网球并将其抛到任何表面,它反弹的高度将逐渐降低直到靠在地面上。这里有许多因素在起作用(包括下一节将涵盖的空气阻力),但模拟非弹性碰撞的快速方法是通过每次反弹减少速度的百分比来减少速度大小:

最后,我可以将所有这些部分添加到示例 2.3 的代码中,并模拟物体经历三种力:风(当点击鼠标时)、重力(始终)、以及现在的摩擦力(当接触到画布底部时)。

运行此示例,您会注意到圆圈最终会停止。通过调整摩擦系数以及在bounceEdges()方法中失速速度的百分比,可以使此过程更快或更慢。
练习 2.6
向示例 2.4 添加第二个对象。如何处理具有不同质量的两个对象?如果每个对象相对于底部表面都有自己的摩擦系数,那么怎么处理?将摩擦力计算封装到Mover方法中是否有意义?
练习 2.7
而不是风,您是否可以添加功能以通过鼠标交互抛掷圆圈?
空气和流体阻力
当物体穿过液体或气体时,也会产生摩擦。所产生的力有许多名称,实际上都是指同一件事情:粘性力、阻力、空气阻力或流体阻力(参见图 2.4)。
阻力的效果与我们以前摩擦示例中的效果最终相同:物体减速。然而,阻力的确切行为和计算略有不同。以下是公式:


图 2.4:阻力(空气或流体阻力)与物体速度成比例,以及其表面积方向相反于物体速度的矢量。
让我解释一下,看看在 p5.js 中实现有效模拟的真正必要条件,同时简化公式:
-
指的是阻力,这个向量用于计算并传递给applyForce()方法。 -
–1/2 是一个常数:–0.5。虽然它是一个缩放力的重要因子,但在这里并不特别相关,因为我将为其他缩放常数指定数值。然而,它是负数这一点很重要,因为它表示力指向与速度相反的方向(就像摩擦力一样)。
-
ρ是希腊字母rho,另一个常数,表示液体的密度。现在我决定忽略它,假设它的值为 1。
-
v指的是运动物体的速度。好吧,这个你应该明白!物体的速度是速度向量的大小:
velocity.mag()。v²则表示v的平方,或v × v。(我会注意到,这假设液体或气体是静止的,如果你把物体投入流动的河水中,你还必须考虑水流的相对速度。) -
A指的是物体在液体或气体中推动的正面表面积。想象一张平纸从空中掉下,和一支尖锐的铅笔直线向下比较。铅笔会遇到较少的拖曳力,因为它的正面表面积在运动方向上较小。同样,这也是一个常数,为了保持实现的简单性,我将假设所有物体都是球形的,并忽略这个因素。
-
C[d]是拖曳系数,和摩擦系数(µ)完全相同。这个常数将决定拖曳力的相对强度。
-
看起来应该很熟悉。这是速度单位向量,通过velocity.normalize()得到。就像摩擦力一样,拖曳力是一个指向速度相反方向的力。
现在,我已经分析了这些部分并确定了我的仿真所需的内容,我可以简化公式,如图 2.5 所示。

图 2.5:我的简化拖曳力公式
虽然我已经把简化公式写成只有C[d]作为唯一的常数,代表拖曳系数,但我也可以把它看作是所有常数的组合(−1/2,ρ,A)。一个更复杂的仿真可能会分别处理这些常数;你可以尝试将它们分别考虑作为练习。
下面是简化拖曳公式的 p5.js 版本:

让我们在Mover示例中实现这个力。但我应该什么时候应用它呢?之前,我启用了摩擦力,以便当物体与画布底边接触时减速。现在,我将为环境引入一个新的元素:一个Liquid对象,当物体穿过它时,它会施加一个阻力。这个“液体”将作为一个矩形绘制,具有位置、宽度和高度,并且会有一个阻力系数,决定物体是否容易穿过它(像空气一样)或难以穿过它(像糖浆一样)。此外,Liquid将包含一个show()方法,这样我们就可以在画布上看到液体:

现在,草图需要一个liquid变量,在setup()中初始化。我将在画布的下半部分放置液体:

现在来了一个有趣的问题:Mover对象如何与Liquid对象进行交互呢?我想实现以下功能:
当一个移动物体穿过液体时,那个物体会经历一个阻力。
用面向对象的术语来翻译:

这段代码是我需要添加到Liquid类中的指令:(1)一个contains()方法,判断一个Mover对象是否位于Liquid对象的区域内,和(2)一个drag()方法,计算并返回应施加于Mover的适当阻力。
第一个很简单;我可以使用布尔表达式来判断position向量是否位于液体定义的矩形内部:

calculateDrag()方法也很简单:当我实现简化的阻力公式时,实际上我已经为它写好了代码!阻力等于阻力系数乘以物体速度的平方,并且方向与速度相反:

添加了这两个方法到Liquid类之后,我准备好将所有代码整合在一起了!在下面的示例中,我将扩展代码,使用一个均匀间隔的Mover对象数组,来展示不同质量物体在阻力作用下的行为。这也展示了除随机初始化外的另一种模拟初始化方法。请在代码中查找40 + i * 70。40的初始偏移提供了从画布边缘的小间隔,而i * 70则使用对象的索引来均匀地间隔这些移动物体。间隔和乘数是任意的;你可以尝试其他值,或者考虑根据画布尺寸计算间隔的其他方法。

运行示例时,你可能会注意到它似乎模拟了物体掉入水中的过程。物体只有在穿越窗口底部的灰色区域(代表液体)时才会减速。你还会注意到,较小的物体比较大的物体减速得更多。记得牛顿的第二定律吗?加速度等于力除以质量
,所以质量大的物体加速较慢,而较小的物体加速较快。在这种情况下,加速度是由于阻力导致的减速。较小的物体比较大的物体减速得更快。
练习 2.8
你可能会注意到,如果你在示例 2.5 中将阻力系数设置得太高,圆圈可能会从液体中反弹出来!这是由于我在本章前面提到的大时间步长的不准确性。阻力会让物体停止,但永远不会改变方向。你如何使用limit()方法来修正这个问题?你也可以尝试从不同高度掉落物体。这会如何影响它们碰到液体时的阻力?
练习 2.9
原始的阻力公式包括了表面积。你能创建一个模拟让箱子掉入水中,并且其阻力与碰撞水面的边长有关的效果吗?
练习 2.10
除了阻力是与速度矢量方向相反的力外,阻力还可以是垂直的。这个被称为升力引起的阻力,它会导致机翼倾斜的飞机升高。试着创建一个升力的模拟。
引力
可能最著名的力就是引力。我们地球上的人类把重力看作是物体掉下去,比如苹果砸在艾萨克·牛顿爵士的头上。但这只是我们对重力的经验。现实要复杂得多。

图 2.6:两物体之间的引力与这些物体的质量成正比,与它们之间距离的平方成反比。
事实上,就像地球由于引力将苹果拉向它一样,苹果也会拉地球(这是牛顿第三定律)。地球太庞大了,以至于它压倒了所有其他的重力作用。事实上,每一个有质量的物体都会对每个其他物体施加引力。计算这些力的强度的公式如图 2.6 所示。
让我们更仔细地检查一下这个公式:
-
指的是引力力,计算该力并传递给applyForce()方法。 -
G 是万有引力常数,在我们的世界中等于 6.67428 × 10^(–11) 立方米每千克每秒平方。如果你是人类,这个数字非常重要,但如果你只是一个在 p5.js 画布上游荡的形状,那它就不那么重要了。它仍然是一个常数,可以用来缩放世界中的引力,使其变强或变弱。将其设置为 1 并忽略它也不是一个糟糕的选择。
-
m[1] 和 m[2] 分别是物体 1 和物体 2 的质量。正如我在最初使用牛顿第二定律时所做的那样
,质量也是我可以选择忽略的东西。毕竟,屏幕上绘制的形状没有物理质量。然而,如果你跟踪这个值,你可以创建更有趣的模拟,其中“更大”的物体比“更小”的物体施加更强的引力。 -
指的是从物体 1 指向物体 2 的单位向量。正如你马上会看到的,这个方向向量可以通过将一个物体的位置减去另一个物体的位置来计算。 -
r² 是两个物体之间的距离的平方。
花点时间思考这个公式。公式顶部的所有内容——G、m[1]、m[2]——其值越大,力就越强。大质量,大力。大 G,大力。然而,底部的 r² 则相反:其值越大(物体越远),力就越弱。从数学上讲,引力的强度与距离的平方成反比。
现在是时候弄清楚如何将这个公式转换成 p5.js 代码了。为此,我做出以下假设:
-
这里有两个物体。
-
每个物体都有一个位置:
position1和position2。 -
每个物体都有一个质量:
mass1和mass2。 -
变量
G代表万有引力常数。

图 2.7:指向鼠标位置的加速度向量
在这些假设下,我想计算一个向量,即引力。我会分两部分来计算。首先,我将计算力的方向 (
在公式中)。其次,我将根据质量和距离计算力的大小。
记得在第一章中,我创建了一个朝向鼠标加速的物体(见图 2.7)吗?正如我当时所展示的,一个向量可以被看作是两个点之间的差异,所以要计算一个从圆圈指向鼠标的向量,我将一个点减去另一个点:
let direction = p5.Vector.sub(mouse, position);
现在我可以做同样的事情来计算
。物体 1 对物体 2 施加的引力方向等于以下内容:
let direction = p5.Vector.sub(position1, position2);
direction.normalize();
别忘了,因为我需要一个单位向量,它只表示方向,所以在减去位置后,标准化向量是很重要的。(稍后,我可能会跳过这一步,直接使用 setMag()。)
现在我已经有了力的方向,接下来需要计算它的大小,并相应地缩放这个向量:
let magnitude = (G * mass1 * mass2) / (distance * distance);
dir.mult(magnitude);
唯一的问题是我不知道距离。G、mass1 和 mass2 的值都是已知的,但我需要计算 distance,才能让前面的代码生效。但是等等,我不是刚才创建了一个从一个对象位置指向另一个对象的向量吗?这个向量的长度应该就是这两个对象之间的距离(见图 2.8)。

图 2.8:一个从一个位置指向另一个位置的向量是通过计算两个位置之间的差值得出的。
的确,如果我再加一行代码,在归一化向量之前获取该向量的大小,就能得到距离。这个时候,我将跳过 normalize() 步骤,改用 setMag():

请注意,我还将 direction 向量的名称更改为 force。毕竟,当计算完成后,我最初创建的向量最终成为了我一直想要的实际力向量。
现在,我已经完成了计算吸引力的数学公式和代码(模拟引力作用),让我们将注意力转向在实际的 p5.js 草图中应用这一技巧。我将继续使用 Mover 类作为起点——一个模板,用来创建具有位置、速度和加速度向量的对象,以及一个 applyForce() 方法。我将把这个类放到草图中,并添加以下内容:
-
一个单一的
Mover对象 -
一个单一的
Attractor对象(一个具有固定位置的新类)
Mover 对象将会受到朝向 Attractor 对象的引力作用,如图 2.9 所示。

图 2.9:一个 mover 和一个 attractor。mover 受到朝向 attractor 的引力作用。
我将从创建一个基本的 Attractor 类开始,赋予它一个位置和质量,并添加一个绘制自身的方法(将质量与大小挂钩):

在草图中,我将添加一个变量来保存 Attractor 对象的实例:

这是一个不错的开始:一个包含 Mover 对象和 Attractor 对象的草图,它们是通过类来管理 movers 和 attractors 的变量和行为。最后的难题是如何让一个对象吸引另一个对象。这两个对象如何进行通信呢?这可以通过多种方式实现。以下是其中的一些可能性:
| 任务 | 功能 |
|---|
|
- 一个全局函数,接收
Attractor和Mover两个对象。
|
attraction(attractor, mover);
|
|
Attractor类中的一个方法,接收一个Mover。
|
attractor.attract(mover);
|
|
Mover类中的一个方法,接收一个Attractor。
|
mover.attractedTo(attractor);
|
|
Attractor类中的一个方法,它接收一个Mover对象并返回一个p5.Vector,也就是引力。然后,这个引力会传入Mover对象的applyForce()方法。
|
let force = attractor.attract(mover);
mover.applyForce(force);
|
考虑各种选项是有益的,你或许能为每种方法提出论据。我至少想排除第一种方法,因为我倾向于选择面向对象的方法,而不是一个与 Mover 或 Attractor 类都没有关联的任意函数。选择第二种还是第三种方法,区别在于说,“引力源吸引了移动物体”和“移动物体被引力源吸引”之间的不同。然而,第四种方法才是我最喜欢的。我花了大量时间设计 applyForce() 方法,我认为继续使用这种方法来应用力会让例子更加清晰。
换句话说,我曾经写过

现在我有了这个:

所以 draw() 函数可以按如下所示编写:

我差不多完成了。既然我决定将 attract() 方法放入 Attractor 类中,我还需要实际编写这个方法。它应该接收一个 Mover 对象并返回一个 p5.Vector:

方法内部包含什么?所有那些关于引力的美妙数学!

好了,我完成了。差不多吧,几乎完成了。我还需要解决一个小问题。再看看 attract() 方法的代码。看到那个斜杠符号表示除法吗?每当你遇到这样的符号时,你应该问自己一个问题:如果距离是一个非常非常小的数字,或者(更糟糕的是!)是 0 会发生什么呢?你不能将一个数字除以 0,如果你将一个数字除以像 0.0001 这样的小数,这等同于将该数字乘以 10,000!这或许是现实世界中引力公式的一个合理结果,但 p5.js 不是现实世界。在 p5.js 的世界里,移动物体可能会非常接近引力源,结果力可能会变得非常强,导致物体飞出画布。
相反,如果移动物体距离引力源,比如说,500 像素(在 p5.js 中并不算不合理)呢?你在平方距离,这将导致将力除以 250,000。这个力可能会变得非常弱,几乎就像根本没有施加任何力一样。
为了避免这两种极端情况,实际操作中可以在将 distance 输入公式之前,先约束其范围。也许无论 Mover 实际 位于何处,在计算引力时都不应考虑它距离引力源少于 5 像素或超过 25 像素:

最终,选择你希望模拟的行为由你决定。但如果你决定要一个合理的引力效果,既不显得过弱也不显得过强,限制距离是一个不错的技术。
Mover类没有变化,所以我们只需查看主草图和Attractor类,整体上添加一个变量G表示万有引力常数。(在本书网站上,你会发现这个示例也有允许你用鼠标移动Attractor对象的代码。)

在这段代码中,mover 和 attractor 的直径是根据每个物体的质量来缩放的。然而,这并不准确地反映质量和大小在我们物理世界中的关系。圆的面积是用公式πr²计算的,其中r*代表半径(直径的一半)。(关于π的更多内容将在第三章中讨论!) 因此,为了更准确地通过圆的面积来表示物体的质量,我实际上应该取质量的平方根,并将其作为圆的直径来缩放。
练习 2.11
适配示例 2.6,将Attractor和Mover的质量映射到它们各自圆的面积上:
circle(this.position.x, this.position.y, sqrt(this.mass) * 2);
当然,你可以扩展代码,加入一个Attractor和多个Mover对象的数组,就像我之前在示例 2.5 中加入了Mover对象的数组一样。

这只是使用对象数组的一个小小展示。敬请期待在第四章中对从画布上添加和移除多个对象的更深入探讨,该章涵盖了粒子系统。
练习 2.12
在示例 2.7 中,有一个Mover对象的系统(一个数组)和一个Attractor对象。构建一个同时包含多个 mover 和 attractor 的系统。如果你让 attractors 变得不可见呢?你能从围绕 attractors 移动的物体轨迹中创造出某种模式或设计吗?
练习 2.13
本章并没有建议每一个好的 p5.js 模拟都必须涉及引力吸引。相反,你应该创造性地思考如何设计你自己的规则来驱动物体的行为,将我模拟引力吸引的方法作为一个模型。例如,如果你设计了一种吸引力,物体靠近时变弱,远离时变强,会发生什么?或者,如果你设计一个 attractor,能够吸引远离的物体但排斥靠近的物体呢?
n 体问题
我开始用一个简单的场景探索引力吸引,一个物体吸引另一个物体,然后转向稍微复杂一点的一个物体吸引多个物体。接下来的逻辑步骤是探索当多个物体吸引多个物体时会发生什么!
开始时,尽管分开使用Mover和Attractor类在目前为止很有帮助,但这种区分有点误导。毕竟,根据牛顿第三定律,所有的力都是成对出现的:如果一个吸引者吸引了一个移动物体,那么那个移动物体也应该吸引吸引者。在这里,我真正想要的不是两个类,而是一个单一的物体类型——例如,称之为Body——每个物体都吸引其他所有物体。
我描述的场景通常被称为n体问题。它涉及通过引力相互作用的一组物体的运动求解。二体问题是一个著名的已解问题,意味着当只有两个物体时,可以通过数学方程精确计算出它们的运动。然而,再加入一个物体后,二体问题变成了三体问题,并且突然间没有正式的解存在(参见图 2.10)。

图 2.10:二体(可预测)与三体(复杂)问题的示例路径
尽管比使用精确的运动方程更不准确,但本章中构建的示例可以模拟二体和三体问题。首先,我将把Attractor类中的attract()方法移到Mover类(现在我将其称为Body)中:

现在,只需要创建两个Body对象(我们称它们为bodyA和bodyB),并确保它们相互吸引:

对于任何* n *体问题,结果的运动和模式完全依赖于初始条件。例如,如果我在setup()中为每个物体分配特定的速度向量,一个指向右边,一个指向左边,结果是一个圆形轨道。

示例 2.8 可以通过重构代码来改进,加入构造函数参数以分配物体速度。然而,暂时来说,这种方法作为一种快速实验基于不同初始位置和速度的模式的方式是可行的。
练习 2.14
詹姆斯·蒙塔尔迪(James Montaldi)和卡特里娜·斯特克尔斯(Katrina Steckles)发表的论文《平面 n 体编舞对称群的分类》(Classification of Symmetry Groups for Planar n-Body Choreographies)探讨了 n 体问题的 编舞 解(定义为物体以规则间隔相互跟随的周期性运动)。教育者和艺术家丹·格里斯(Dan Gries)创建了这些编舞的互动演示(dangries.com/rectangleworld/demos/nBody)。尝试在示例 2.8 中添加第三个(或更多)物体,并尝试设置初始位置和速度。你能实现什么样的编舞?
我现在准备通过引入一个数组来继续进行 n 体的例子:

draw() 函数是我需要发挥魔法的地方,让每个物体对每个其他物体施加引力。目前,代码是“对于每个物体i,更新并绘制。”为了让每个物体i吸引每个其他物体j,我需要嵌套第二个循环并调整代码为“对于每个物体i,吸引每个其他物体j(并更新和绘制)。”

代码有一个小问题。当每个物体i吸引每个物体j时,当i等于j时会发生什么?物体索引 3 应该吸引物体索引 3 吗?答案当然是否定的。如果有五个物体,你希望物体索引 3 只吸引物体 0、1、2 和 4,而跳过自己。我会通过添加一个条件语句来跳过当i等于j时应用力的情况。

示例 2.9 中的嵌套循环解决方案导致了所谓的 n 平方算法,这意味着计算的数量等于物体数量的平方。如果我增加物体的数量,模拟将因为所需的计算量而开始显著变慢。
在第五章中,我将探讨优化像这样的草图的策略,特别关注空间细分算法。空间细分结合四叉树的概念和一个名为 Barnes-Hut 的算法,对于提高像这里讨论的 n 体模拟的效率特别有效。
习题 2.15
将示例 2.9 中的引力改为排斥力。你能否创建一个例子,其中所有的Body对象都被鼠标吸引,但彼此之间相互排斥?思考一下如何平衡力的相对强度,以及如何在力的计算中最有效地利用距离。
习题 2.16
你能否将n-体模拟中的物体排列成一种类似螺旋星系的轨迹,使其围绕画布的中心旋转?你可能需要在中心加入一个额外的大型物体以保持整体的稳定。在我的“相互吸引”视频中提供了解决方案,视频属于《编码之美》系列,发布在 Coding Train 网站上(thecodingtrain.com/nbody)。

生态系统项目
将力的概念融入到你的生态系统中。其他环境因素(例如水与泥土,或河流的水流)会如何影响角色在生态系统中的移动方式?
尝试在环境中引入其他元素(食物、捕食者等)供生物互动。生物是否对其世界中的事物产生吸引或排斥的反应?你能否更加抽象地思考,并根据生物的欲望或目标设计力量?

第四章:3 振荡
三角学是时代的象征。
—匿名

Gala by Bridget Riley, 1974; 丙烯画布,159.7 × 159.7 cm
布里奇特·赖利(Bridget Riley),一位著名的英国艺术家,是 1960 年代光学艺术(Op Art)运动的推动力之一。她的作品以几何图案为特色,挑战观众的感知,并唤起运动或震动的感觉。她 1974 年的作品《Gala》展示了一系列曲线形态,像波纹一样在画布上扩散,唤起了正弦波的自然节奏。
在第一章和第二章中,我仔细制定了一个面向对象的结构,用于在 p5.js 画布中动画化形状,使用一个向量来表示位置、速度和加速度,这些都由环境中的力驱动。从这里我可以直接进入粒子系统、引导力、群体行为等主题。然而,若这样做就意味着跳过了自然界运动的一个基本方面:振荡,即物体围绕一个中心点或位置的往返运动。
要模拟振荡,你需要了解一些三角学,即三角形的数学。学习一些三角学将为你提供生成图案和创建新的运动行为的工具。在 p5.js 草图中,你将学会利用角速度和加速度来旋转物体,同时物体在移动。你将能够使用正弦和余弦函数来模拟平滑的加速、减速波动模式。你还将学会计算涉及角度的更复杂的力,例如钟摆的摆动或箱子沿斜面下滑的情况。
我将从在 p5.js 中使用角度的基础知识开始,然后介绍三角学的几个方面。最后,我会将三角学与在第二章中学到的关于力的内容联系起来。本章的内容将为书中后续需要使用三角学的更复杂示例铺路。
角度
在继续之前,我需要确保你理解角度这一概念是如何融入到 p5.js 创意编码中的。如果你有使用 p5.js 的经验,在使用rotate()函数来旋转和旋转物体时,你无疑已经遇到过这个问题。你很可能熟悉角度作为度数的概念(见图 3.1)。

图 3.1:以度数为单位的角度
一个完整的旋转从 0 度到 360 度,而 90 度(直角)是 360 的四分之一,如图 3.1 所示,图中有两条垂直线。
角度在计算机图形学中常用于指定形状的旋转。例如,图 3.2 中的正方形绕其中心旋转了 45 度。

图 3.2:旋转了 45 度的正方形
问题是,默认情况下,p5.js 测量角度时使用的是弧度而不是度数。这个替代的计量单位由圆的弧长(圆周的一部分)与圆的半径的比值定义。一个弧度是当这个比值等于 1 时的角度(见图 3.3)。一个完整的圆(360 度)相当于 2π弧度,180 度相当于π弧度,90 度相当于π/2 弧度。

图 3.3:1 弧度角的弧长等于半径。
从度数转换为弧度的公式如下:

令人庆幸的是,如果你更倾向于以度数表示角度,你可以调用angleMode(DEGREES),或者使用便捷函数radians()将度数转换为弧度。常量PI、TWO_PI和HALF_PI也可以使用(分别等同于 180 度、360 度和 90 度)。例如,在 p5.js 中,以下是旋转一个形状 60 度的两种方式:
let angle = 60;
rotate(radians(angle));
angleMode(DEGREES);
rotate(angle);
什么是π?
数学常数π(或希腊字母π)是一个实数,定义为圆的周长(圆周的外侧距离)与直径(通过圆心的直线段)的比值。它大约等于 3.14159,并且可以通过 p5.js 中的内建PI变量访问。
虽然度数可能很有用,但在本书中,我将使用弧度,因为它们是许多编程语言和图形环境中的标准计量单位。如果它们对你来说是新的,这是一个很好的练习机会!此外,如果你不熟悉 p5.js 中旋转的实现方式,我建议观看我关于 p5.js 变换的 Coding Train 视频系列(thecodingtrain.com/transformations)。
练习 3.1
使用translate()和rotate()围绕物体的中心旋转一个类似指挥棒的物体。

角动量
旋转的另一个术语是角动量——即围绕一个角度的运动。正如线性运动可以用速度来描述——即物体位置随时间变化的速率——角动量可以用角速度来描述——即物体角度随时间变化的速率。通过扩展,角加速度描述物体的角速度变化。
幸运的是,你已经具备了理解角动量所需的所有数学知识。记得我在第一章和第二章中几乎全部讲解的内容吗?

你可以将相同的逻辑应用于旋转物体:
角速度 = 角速度 + 角加速度
角度 = 角度 + 角速度
事实上,这些角运动的公式比线性运动的公式更简单,因为这里的角度是一个标量量(单一数值),而不是向量!这是因为在二维空间中,只有一个旋转轴;在三维空间中,角度会变成一个向量。(请注意,在大多数情况下,这些公式会包括时间变化的乘法,通常称为增量时间。我假设增量时间为 1,对应于 p5.js 中的一帧动画。)
使用练习 3.1 中的答案,假设你想要在 p5.js 中让指挥棒旋转一定角度。最初,代码可能是这样的:
translate(width / 2, height / 2);
rotate(angle);
line(-60, 0, 60, 0);
circle(60, 0, 16);
circle(-60, 0, 16, 16);
angle = angle + 0.1;
融入角运动的原理后,我可以改写以下示例(这是练习 3.1 的解决方案)。

不像通过固定增量逐步旋转指挥棒,我在每一帧中将angleAcceleration加到angleVelocity,然后将angleVelocity加到angle。结果是,指挥棒一开始没有旋转,随着角速度加速,旋转越来越快。
练习 3.2
给旋转的指挥棒添加互动。如何通过鼠标控制加速度?你能引入拖拽的概念,通过减少角速度来让指挥棒最终停下来吗?
合理的下一步是将这个角运动的概念融入到Mover类中。首先,我需要在类的构造函数中添加一些变量:

然后,在update()中,根据我刚刚演示的算法更新移动者的位置和角度:

当然,所有这些要生效,我还需要在show()方法中绘制物体时进行旋转。(我会在圆心到圆边添加一条线,使旋转可见。你也可以将物体绘制成除圆形以外的其他形状。)

到目前为止,如果你真的创建了一个Mover对象,你不会看到它有任何不同的表现。这是因为角加速度被初始化为零(this.angleAcceleration = 0;)。为了让物体旋转,它需要一个非零的加速度!当然,一种选择是在构造函数中硬编码一个数字:
this.angleAcceleration = 0.01;
然而,你可以通过在update()方法中根据环境中的力动态地分配角加速度,从而得到更有趣的结果。这可能是我开始研究基于力矩(en.wikipedia.org/wiki/Torque)和转动惯量(en.wikipedia.org/wiki/Moment_of_inertia)的角加速度物理学的信号,但在这个阶段,这种模拟的复杂度可能会有些“无底洞”。(我将在《摆锤》章节中更详细地介绍如何使用摆锤建模角加速度,见第 154 页,并在第六章中讨论第三方物理库如何真实地模拟旋转运动。)
相反,一个快速且粗糙的解决方案,能够产生创造性结果,就足够了。一个合理的做法是将角加速度计算为物体的线性加速度的函数,即沿路径向量的速度变化率,而不是它的旋转。下面是一个例子:

是的,这个是任意的,但它确实能产生一些效果。如果物体向右加速,它的角旋转会朝顺时针方向加速;向左加速会导致逆时针旋转。当然,在这种情况下,考虑比例是非常重要的。加速度向量的x分量可能过大,导致物体旋转看起来非常荒谬或不现实。你甚至可能会注意到一种视觉错觉,叫做马车轮效应:由于每一帧动画之间的变化较大,物体看起来旋转得更慢,甚至是朝相反方向旋转。
将x分量除以一个值,或者将角速度限制在一个合理的范围内,可能真的会有所帮助。以下是整个update()函数,已添加这些调整。

请注意,我使用了多种策略来防止物体旋转失控。首先,我将acceleration.x除以10,然后再赋值给angleAcceleration。接着,为了确保安全,我还使用了constrain()将angleVelocity限制在(-0.1, 0.1)范围内。
练习 3.3
第 1 步:创建一个物体从大炮中发射的模拟。每个物体在发射时应该经历一次突然的力(仅一次),以及重力(始终存在)。
第 2 步:给物体添加旋转,模拟它从大炮中发射时的旋转。你能让它看起来有多真实?
三角函数
我想我准备好揭示三角学的秘密了。我已经讨论过角度,已经转过棒子。现在是时候...等等... sohcahtoa 了。是的,sohcahtoa!这个看似毫无意义的词实际上是计算机图形学工作的重要基础。基本理解三角学是计算角度、测量点之间距离,以及处理圆形、弧线或直线的关键。如果你想要计算角度,弄清楚点之间的距离,或者处理圆、弧线或直线,三角学是必不可少的。而 sohcahtoa 是一个记忆法(尽管有些荒谬)用来记住三角函数正弦、余弦和正切的含义。它指的是直角三角形的各边,如图 3.4 所示。

图 3.4:一个直角三角形,展示了邻边、对边和斜边
取三角形中的一个非直角。邻边 是与该角相接的那条边,对边 是不与该角相接的那条边,斜边 是与直角相对的那条边。Sohcahtoa 告诉你如何根据这些边的长度来计算角度的三角函数:
-
soh: sine(角度) = opposite/hypotenuse
-
cah: cosine(角度) = adjacent/hypotenuse
-
toa: tangent(角度) = opposite/adjacent
再看一下图 3.4。你不需要背诵它,但可以看看自己是否感觉舒适。试着自己重新画一遍。接下来,让我们以稍微不同的方式来看它(见图 3.5)。

图 3.5:一个向量
,包含分量 x、y 和 角度
看看一个直角三角形是如何从这个向量
中生成的?向量箭头是斜边,向量的分量(x 和 y)是三角形的两条边。角度是确定向量方向(或 航向)的附加手段。以这种方式来看,三角函数建立了向量的分量与其方向 + 大小之间的关系。因此,三角学将在本书中非常有用。为了说明这一点,我们来看看一个需要用到正切函数的例子。
指向运动的方向
想回例子 1.10,那是一个Mover对象朝鼠标加速的例子(图 3.6)。

图 3.6:一个朝鼠标加速的移动物体(来自例子 1.10)
你可能会注意到,到目前为止我画的大部分形状都是圆形的。这是很方便的,原因有几个,其中之一就是使用圆形可以避免旋转的问题。旋转一个圆形,嗯,它看起来完全一样。然而,所有运动程序员的生活中总会有那么一刻,他们想要在屏幕上移动一些不是圆形的物体。也许是蚂蚁,或者是汽车,或者是飞船。为了看起来更真实,这个物体应该指向它的运动方向。
当我说“指向它的运动方向”时,我的意思是“根据它的速度向量进行旋转。”速度是一个向量,具有 x 和 y 分量,但要在 p5.js 中进行旋转,你只需要一个数字,一个角度。让我们再看一遍三角函数图,这次聚焦在物体的速度向量上(见图 3.7)。

图 3.7:速度向量的角度的正切是 y 除以 x。
向量的 x 和 y 分量与其角度通过正切函数相关联。使用 toa 在 sohcahtoa 中,我可以将关系写为:

这里的问题是,虽然我知道速度向量的 x 和 y 分量,但我不知道它的方向角度。我必须求解这个角度。这就是另一个函数——反正切,或称为 反正切函数(简称 arctan 或 atan)派上用场的地方。(还有 反正弦 和 反余弦 函数,分别称为 arcsine 和 arccosine。)
如果 a 的正切值等于 b,那么 b 的反正切值等于 a。例如:
| 如果 | tan(a) = b |
|---|---|
| 然后 | a = arctan(b) |
看到一个是另一个的反向吗?这使我能够求解向量的角度:
| 如果 | ![]() |
|---|---|
| 然后 | ![]() |
现在我已经有了公式,让我们看看它应该放在哪里,在 Mover 类的 show() 方法中,以便让运动物体(现在绘制为一个矩形)指向它的运动方向。请注意,在 p5.js 中,反正切函数是 atan():

这段代码非常接近,几乎可以正常工作。不过,存在一个大问题。考虑图 3.8 中展示的两个速度向量。

图 3.8:这两个向量
和
,它们的分量分别是 (4, –3) 和 (–4, 3),指向相反的方向。
虽然表面上看起来相似,但这两个向量指向的方向完全不同——实际上是相反的方向!尽管如此,看看如果我应用反正切公式来求解每个向量的角度,会发生什么:

我得到了相同的角度!不过,这样不对,因为这些向量指向的方向是相反的。事实证明,这是计算机图形学中一个相当常见的问题。我本可以使用atan()结合条件语句来处理正负情况,但 p5.js(以及大多数编程环境)有一个很有用的函数叫做atan2(),它可以为我解决这个问题。

为了进一步简化,p5.Vector 类提供了一个名为 heading() 的方法,该方法负责调用 atan2() 并返回任何 p5.Vector 的二维方向角,以弧度表示:

使用 heading() 后,实际上你并不需要在代码中实现三角学函数,但理解它们是如何工作的仍然是有帮助的。
练习 3.4
创建一个可以通过箭头键控制移动的车辆模拟:左箭头加速车辆向左,右箭头加速车辆向右。车辆应该朝着当前移动的方向指向。
极坐标与笛卡尔坐标
每当你在 p5.js 中绘制形状时,你都需要指定一个像素位置,即一组 x 和 y 坐标。这些被称为笛卡尔坐标,得名于法国数学家勒内·笛卡尔,他发展了笛卡尔空间的相关思想。
另一个有用的坐标系,称为极坐标系,通过描述空间中一个点到原点的距离(如圆的半径)和围绕原点旋转的角度(通常叫做θ,希腊字母θ)来定义一个点的位置。从向量的角度来看,笛卡尔坐标描述了一个向量的 x 和 y 分量,而极坐标描述了一个向量的大小(长度)和方向(角度)。
在 p5.js 中工作时,你可能会发现思考极坐标更为方便,特别是在创建涉及旋转或圆形运动的图形时。然而,p5.js 的绘图函数只理解(x,y)笛卡尔坐标。幸运的是,三角学为你提供了从极坐标和笛卡尔坐标之间相互转换的关键(见图 3.9)。这使得你可以根据自己的需求设计坐标系,同时始终使用笛卡尔坐标进行绘图。

图 3.9:希腊字母 θ(theta)通常用来表示角度。由于极坐标通常表示为(r,θ),因此在提到 p5.js 中的角度时,我将使用 theta 作为变量名。
例如,给定一个半径为 75 像素,角度为 45 度(或π/4 弧度)的极坐标,可以按以下方式计算笛卡尔坐标的 x 和 y:
cos(θ) = x/r ⇒ x = r × cos(θ)
sin(θ) = y/r ⇒ y = r × sin(θ)
在 p5.js 中,正弦和余弦的函数分别是 sin() 和 cos()。每个函数接受一个参数,表示一个以弧度为单位的角度。因此,这些公式可以像下面这样编码:

这种类型的转换在某些应用中可能非常有用。例如,使用笛卡尔坐标系移动一个形状沿圆形路径并不容易。然而,使用极坐标就简单多了:只需增加角度即可!以下是使用全局 r 和 theta 变量来实现的方式。

极坐标到笛卡尔坐标的转换已经足够常见,以至于 p5.js 提供了一个便捷的函数来处理这个问题。它作为 p5.Vector 类的一个静态方法 fromAngle() 被包含进来。它接受一个弧度表示的角度,并在笛卡尔空间中创建一个单位向量,指向由该角度指定的方向。这就是 示例 3.4 中的实现:

你已经惊讶了吗?我展示了一些切线(用于找出向量的角度)以及正弦和余弦(用于从极坐标转换为笛卡尔坐标系)的非常棒的应用。我可以在这里停下来并感到满足。但我不会这么做。这仅仅是个开始。正如我接下来将展示的,正弦和余弦能够为你做的远不止数学公式和直角三角形。
练习 3.5
以 示例 3.4 为基础,绘制一个螺旋路径。从中心开始,向外移动。请注意,这只需更改一行代码并添加一行代码!

练习 3.6
模拟游戏 Asteroids 中的飞船。如果你不熟悉 Asteroids,这里有一个简短的介绍:飞船(表示为一个三角形)漂浮在二维空间中。左箭头键将飞船逆时针旋转;右箭头键将其顺时针旋转。Z 键在飞船指向的方向上施加推力。

振荡的性质
查看 图 3.10 中的正弦函数图,其中 y = sin(x)。

图 3.10:y = sin(x) 的图像
正弦函数的输出是一个平滑的曲线,在 -1 和 1 之间交替变化,也称为 正弦波。这种行为是周期性地在两个点之间运动,这就是我在章节开始时提到的 振荡。拨动吉他弦、摆动钟摆、在弹跳棒上跳跃——这些都是可以通过正弦函数模拟的振荡运动的例子。
在 p5.js 草图中,你可以通过将正弦函数的输出赋值给一个物体的位置来模拟振荡。我将从一个基本的场景开始:我希望一个圆在画布的左侧和右侧之间振荡(图 3.11)。

图 3.11:一个振荡的圆形
这种围绕中心点来回振荡的模式被称为简谐运动(或者,准确一点,物体的周期性正弦振荡)。实现这一点的代码非常简单,但在进入之前,我想介绍一些与振荡(和波动)相关的关键术语。
当一个物体表现出简谐运动时,它的位置(在此情况下是 x 位置)可以表示为时间的函数,包含以下两个要素:
-
振幅: 从运动中心到任一极端的距离
-
周期: 完整运动周期所需的时间
要理解这些术语,请再看一眼图 3.10 中的正弦函数图。曲线在 y 轴上从不超过 1 或低于 -1,因此正弦函数的振幅为 1。同时,曲线的波形在 x 轴上每 2π 单位重复一次,因此正弦函数的周期为 2π。(按照惯例,这里的单位是弧度,因为正弦函数的输入值通常是以弧度衡量的角度。)
以上是抽象正弦函数的振幅和周期,那么在 p5.js 中,一个振荡圆的振幅和周期是什么呢?嗯,振幅可以相对容易地用像素来衡量。例如,如果画布的宽度是 200 像素,我可能会选择围绕画布中心进行振荡,从中心向右 100 像素到向左 100 像素。换句话说,振幅是 100 像素。

周期是完成一个振荡周期所需的时间。然而,在 p5.js 草图中,时间到底意味着什么呢?理论上,我可以说我希望圆形每三秒振荡一次,然后根据现实世界的时间设计一个复杂的算法来移动物体,使用 millis() 来跟踪毫秒的流逝。然而,对于我在这里要完成的任务,现实世界的时间并不必要。在 p5.js 中,更有用的时间衡量方式是已过去的帧数,这可以通过内置的 frameCount 变量获得。我希望振荡运动每 30 帧重复一次?每 50 帧重复一次?目前,假设周期为 120 帧:

一旦我有了振幅和周期,就该写一个公式来计算圆的 x 位置,作为时间(当前帧数)的函数:

想想这里发生了什么。首先,无论sin()函数返回什么值,都将乘以amplitude。正如你在图 3.10 中看到的,正弦函数的输出在–1 和 1 之间振荡。将这个值乘以我选择的振幅——我们称之为a——就得到了所需的结果:一个在–a和a之间振荡的值。(这里也是你可以使用 p5.js 的map()函数,将sin()的输出映射到自定义范围的地方。)
现在,想一想sin()函数内部发生了什么:
TWO_PI * frameCount / period
这里发生了什么?从你已知的开始。我已经解释过,正弦的周期是 2π,这意味着它将在 0 处开始,并在 2π、4π、6π等位置重复。如果我希望振荡的周期为 120 帧,那么当frameCount达到 120 帧、240 帧、360 帧等时,圆形应处于相同位置。在这里,frameCount是唯一随着时间变化的值;它从 0 开始并递增。让我们看看当frameCount增加时公式会产生什么结果。
frameCount |
frameCount / period |
TWO_PI * frameCount / period |
|---|---|---|
| 0 | 0 | 0 |
| 60 | 0.5 | π |
| 120 | 1 | 2π |
| 240 | 2 | 4π |
| . . . | . . . | . . . |
将frameCount除以period可以告诉我已完成的周期数。(波形是否已完成第一个周期的一半?是否已完成两个周期?)将这个数值乘以TWO_PI,我就得到了所需的结果,适合输入到sin()函数中,因为TWO_PI是正弦(或余弦)函数完成一个完整周期所需的值。
将它们结合起来,下面是一个示例,它通过振荡x坐标,产生一个振幅为 100 像素、周期为 120 帧的圆形运动。

在继续之前,我不得不提一下频率,即每单位时间内振荡的周期数。频率是周期的倒数——也就是说,1 除以周期。例如,如果周期是 120 帧,那么在 1 帧中完成的是周期的 1/120,因此频率是 1/120。在示例 3.5 中,我选择了通过周期来定义振荡速率,因此不需要为频率定义变量。然而,有时候,考虑频率而不是周期更有帮助。
练习 3.7
使用正弦函数,创建一个挂在窗口顶部的弹簧上的物体(有时称为摆锤)的模拟。使用map()函数计算摆锤的垂直位置。在第 147 页的《弹簧力》一节中,我将展示如何根据胡克定律创建这种模拟。
角速度振荡
理解振动、振幅和周期(或频率)在模拟现实世界行为时可能至关重要。然而,有一种稍微简单的方法来实现示例 3.5 中的简谐运动,这种方法通过更少的变量达成相同的结果。再看看振动公式:
let x = amplitude * sin(TWO_PI * frameCount / period);
现在我将以稍微不同的方式重写它:
let x = amplitude * sin( some value that increments slowly );
如果你关心精确定义振荡周期(以动画帧为单位),你可能需要我最初写的公式。然而,如果你不关心准确的周期——例如,如果你将随机选择它——那么你真正需要的只是sin()函数内部的一个值,该值增加得足够慢,以便物体的运动在每一帧之间看起来是平滑的。每当这个值越过 2π的倍数时,物体就完成了一个振荡周期。
这种技术与我在第零章中使用 Perlin 噪声时做的类似。在那种情况下,我增加了一个偏移量变量(我称它为t或xoff)来从noise()函数中采样不同的输出,创建平滑的值过渡。现在,我将增加一个值(我称它为angle),并将其输入到sin()函数中。不同之处在于,sin()的输出是平滑重复的正弦波,没有任何随机性。
你可能会想,为什么我把增量值称为angle,因为物体没有明显的旋转。之所以使用角度这个术语,是因为这个值被传递到sin()函数中,而角度是三角函数的传统输入值。考虑到这一点,我可以重新引入角速度(和加速度)的概念,将示例重写为根据变化的角度计算x位置。我假设这些全局变量:
let angle = 0;
let angleVelocity = 0.05;
然后我可以写出如下代码:
function draw() {
angle += angleVelocity;
let x = amplitude * sin(angle);
}
在这里,angle是我“缓慢增加的值”,它缓慢增加的量是angleVelocity。

仅仅因为我没有直接引用周期,并不意味着我已经消除了这个概念。毕竟,角速度越大,圆形的振动越快(因此周期越短)。事实上,周期是angle增量达到 2π所需的帧数。由于angle的增量由角速度控制,我可以通过以下方式计算周期:
period = 2π/角速度
为了说明将振动视为角速度的强大力量,我将通过创建一个Oscillator类来进一步扩展示例,其对象可以沿 x 轴(如前所述)和 y 轴独立振荡。该类需要两个角度、两个角速度和两个振幅(每个轴一个)。
这是一个绝佳的机会,可以使用createVector()将每对值打包在一起!与之前的向量不同,这些向量中的值将不再是笛卡尔坐标集。然而,p5.Vector类提供了一种方便的方式来管理值对——在这种情况下,是一对对的角度(以及它们的相关速度、加速度等)。

为了更好地理解Oscillator类,集中注意力观察动画中单个振荡器的运动可能会很有帮助。首先,观察其水平运动。你会注意到它沿 x 轴规律地前后振荡。将注意力转向其垂直运动,你会看到它沿 y 轴上下振荡。每个振荡器都有自己独特的节奏,因为它的角度、角速度和振幅是随机初始化的。
关键在于认识到p5.Vector对象this.angle、this.angleVelocity和this.amplitude的x和y属性不再与空间向量相关联。相反,它们用于存储两个独立振荡的相应属性(一个沿 x 轴,另一个沿 y 轴)。最终,当x和y在show()方法中计算时,这些振荡在空间上得以体现,将振荡映射到物体的位置上。
练习 3.8
尝试用非随机的速度和振幅初始化每个Oscillator对象,以创建某种规律性的图案。你能让振荡器看起来像昆虫的腿吗?
练习 3.9
将角加速度加入到Oscillator对象中。
波动
想象一个单独的圆圈根据正弦函数上下振荡。这相当于模拟波的 x 轴上的一个点。稍微加点花样,配合一个for循环,你可以通过将一系列振荡圆圈排成一行来动画化整个波形(见图 3.12)。

图 3.12:通过振荡圆圈来动画化正弦波
你可以使用这种波动图案来设计生物的身体或附肢,或者模拟一个柔软的表面(如水面)。让我们深入了解这个草图代码的工作原理。
在这里,振幅(波的高度)和周期(波的持续时间)这两个概念同样适用。然而,当绘制整个波时,术语周期的含义发生了变化,它不再表示时间,而是描述一个完整波动周期的宽度(以像素为单位)。波的空间周期(与时间周期相对)称为波长——即波完成一个完整振荡周期所经过的距离。就像前面振荡的例子一样,你可以选择根据精确的波长来计算波形,或者通过为波上的每个点随意递增角度值(delta angle)来进行计算。
我将选择更简单的情况,递增角度。我知道我需要三个变量:角度、增量角度(类似于之前的角速度),和振幅:
let angle = 0;
let deltaAngle = 0.2;
let amplitude = 100;
接下来,我将循环遍历波上每个点的x值。目前,我将相邻的x值之间间隔 24 像素。对于每个x,我将执行以下三个步骤:
-
根据振幅和角度的正弦值计算 y 坐标。
-
在(x, y)位置画一个圆。
-
按
deltaAngle递增角度。
以下示例将这些步骤转化为代码。

如果你尝试不同的deltaAngle值会发生什么呢?图 3.13 展示了几种选择。

图 3.13:三个不同deltaAngle值的正弦波(从左到右分别是 0.05,0.2 和 0.6)
尽管我没有精确计算波长,但你可以看到,角度变化越大,波长越短。还值得注意的是,随着波长的减小,波变得越来越难以辨认,因为各个点之间的垂直距离增加了。
请注意,示例 3.8 中的所有内容都发生在setup()内部,因此结果是静态的。波动永远不会改变或起伏。添加运动会有点棘手。你第一反应可能是说:“嘿,没问题,我只需将for循环放入draw()函数中,让angle从一个周期递增到下一个。”
这是一个不错的想法,但它行不通。如果你尝试一下,结果会显得极为不稳定和故障频发。要理解为什么,回顾一下示例 3.8。波的右边缘与左边缘的高度不匹配,因此波在一次draw()循环中的结束位置不能是下一个循环的起始位置。相反,你需要一个专门用于追踪每一帧动画中起始angle值的变量。这个变量(我将其称为startAngle)按自己的节奏递增,控制波从一帧到下一帧的进展。

在这个代码示例中,startAngle的增量被硬编码为0.02,但你可能想考虑重用deltaAngle或创建一个第二个变量。通过重用deltaAngle,波形的空间进展将与时间进展紧密关联,可能会创造出更同步的运动。如果引入一个单独的变量,或许叫做startAngleVelocity,则可以独立控制波形的速度。这里使用速度一词是合适的,因为起始角度是随着时间变化的。
练习 3.10
尝试使用 Perlin 噪声函数代替正弦或余弦函数来设置示例 3.9 中的y值。
练习 3.11
将波形生成代码封装到一个Wave类中,并创建一个显示两个波形(具有不同振幅/周期)的草图,如下图所示。尝试超越简单的圆形和线条,以更具创意的方式可视化波形。如何通过使用beginShape()、endShape()和vertex()连接这些点呢?

练习 3.12
为了创建更复杂的波形,你可以将多个波形叠加在一起。计算多个波形的高度(或y)值,并将这些值加在一起,得到一个单一的y值。结果是一个新的波形,它融合了每个单独波形的特征。

弹簧力
探索三角形和波的数学非常有趣,但也许你开始怀念牛顿的运动定律和向量了。毕竟,本书的核心内容是模拟运动物体的物理。在《振荡性质》一章中(见第 134 页),我通过将正弦波映射到画布上的像素范围来模拟简谐运动。练习 3.7 要求你使用这种技术来创建一个由sin()函数驱动的悬挂在弹簧上的摆。然而,如果你真正想要的是一个响应环境中其他力(如风力、重力等)的弹簧摆,单靠这种快速简洁的代码解决方案就不够了。要实现这样的模拟,你需要使用向量来模拟弹簧的力。

图 3.14:带有锚点和摆的弹簧
我将弹簧视为一个连接可移动的摆(或重物)和固定的锚点之间的连接(见图 3.14)。
弹簧的力是根据胡克定律计算的,这一定律以英国物理学家罗伯特·胡克的名字命名,他于 1660 年提出了这个公式。胡克最初用拉丁语表述了这个定律:“Ut tensio, sic vis”,意思是“伸长多少,力就有多少”。可以这样理解:

图 3.15:弹簧的伸长(x)是其当前长度与静止长度之间的差值。
弹簧的力与弹簧的伸长量成正比。
伸长量是衡量弹簧被拉伸或压缩的程度:如图 3.15 所示,它是弹簧当前长度与静止长度(其平衡状态)之间的差值。因此,胡克定律表明,如果你用力拉动摆锤,弹簧的力会很强,而如果拉动摆锤的力较小,弹簧的力就会较弱。
数学上,这一定律可以表述如下:
F[spring] = −kx
这里的k是弹簧常数。它的值决定了力的大小,设置了弹簧的弹性或刚性。同时,x是伸长量,即当前长度减去静止长度。
现在记住,力是一个向量,所以你需要计算其大小和方向。对于代码,我将从以下三个变量开始——两个表示锚点和摆锤位置的向量,以及一个静止长度:

然后我将使用胡克定律来计算力的大小。为此,我需要k和x。计算k很简单,它只是一个常数,所以我将虚构一个值:
let k = 0.1;
求解x或许有些困难。我需要知道当前长度和静止长度之间的差异。静止长度被定义为变量restLength。当前长度是多少?是锚点和摆锤之间的距离。那么我该如何计算这个距离呢?如何通过从锚点到摆锤的向量大小来计算呢?(请注意,这与我在第二章中用来计算物体之间的引力的过程完全相同。)

现在我已经整理出了计算力的大小(–kx)所需的元素,接下来我需要搞清楚方向,即指向力方向的单位向量。好消息是,我已经有了这个向量。对吧?就在刚才,我问过一个问题,“如何计算那个距离?”然后我回答,“如何通过从锚点到摆锤的向量大小来计算?”嗯,那个向量描述了力的方向!
图 3.16 显示,如果你将弹簧拉伸到超过其静止长度,就会有一个力将其拉回锚点。如果弹簧收缩到低于其静止长度,就会有一个力将其推离锚点。胡克定律公式通过–1 来考虑这种方向的反转。

图 3.16:弹簧的力指向位移的相反方向。
现在我所需要做的就是设置用于计算距离的向量的大小。让我们来看一下代码,并将该向量变量重命名为force:

现在我已经有了计算弹簧力的算法,问题依然存在:我应该使用什么 OOP 结构?这是那种没有唯一正确答案的情况。存在多种可能性,我选择的方案取决于我的目标和个人编码风格。
由于我一直在使用Mover类,所以我将继续使用这个框架。我会把Mover类看作是弹簧的摆锤。摆锤需要position、velocity和acceleration向量来在画布上移动。完美——我已经有了这些!而且也许摆锤通过applyForce()方法经历了一个重力力。这只剩下最后一步了,施加弹簧力:

一个选项是将所有弹簧力的代码写在主draw()循环中。但考虑到未来可能有多个摆锤和弹簧连接,明智的做法是创建一个额外的类,Spring类。正如图 3.17 所示,Bob类跟踪摆锤的运动;Spring类跟踪弹簧的锚点位置及其静止长度,并计算施加在摆锤上的弹簧力。

图 3.17:Spring类有锚点和静止长度;Bob类有位置、速度和加速度。
这使我能够写出如下漂亮的草图:

想想这和我在示例 2.6 中第一次尝试重力吸引的情况有何异同,那时我有单独的Mover和Attractor类。在那里,我写了类似这样的代码:
let force = attractor.attract(mover);
mover.applyForce(force);
弹簧的类似情况可能如下所示:
let force = spring.connect(bob);
bob.applyForce(force);
相反,在这个示例中,我有以下内容:
spring.connect(bob);
怎么回事?为什么我不需要在摆锤上调用applyForce()?答案当然是,我确实需要在摆锤上调用applyForce()。只不过,我不是在draw()中调用它,而是展示了一个完全合理(有时更可取)的替代方案,那就是让connect()方法在内部调用applyForce():

为什么在Attractor类中用一种方式,而在Spring类中用另一种方式?当我第一次讨论力时,在draw()循环中显示所有施加的力是一种更清晰的方式,帮助你了解力的累积。现在你已经更熟悉了,或许将一些细节嵌入到对象内部更简单一些。
让我们看看Spring类中的其他元素。

这个示例的完整代码可以在本书的网站上找到,并加入了两个附加功能:(1)Bob类包括鼠标交互方法,允许你在窗口中拖动摆锤,(2)Spring类包括一个方法,用于将连接的长度限制在最小值和最大值之间。
练习 3.13
在去在线查看示例之前,先看一下这个constrainLength方法,看看你是否能填补其中的空白:

练习 3.14
创建一个包含多个钟摆球和弹簧连接的系统。怎么样,把一个钟摆球连接到另一个没有固定锚点的钟摆球上?
钟摆
你可能已经注意到,在示例 3.10 的弹簧代码中,我从未使用过正弦或余弦。然而,在你把所有这些三角学内容当作旁枝末节时,请允许我展示一个例子,说明这一切是如何结合在一起的。想象一个吊坠从一个锚点悬挂,锚点通过弹簧与其相连,且连接是完全刚性的,既不能压缩也不能伸展。这个理想化的情景描述了一个钟摆,并提供了一个绝佳的机会,让你练习将所学的力学和三角学知识结合起来。

图 3.18:一个带有支点、摆臂和钟摆球的钟摆
一个钟摆是一个通过摆臂悬挂在支点上的钟摆球(在弹簧中曾被称为锚点)。当钟摆静止时,它会垂直向下悬挂,如图 3.18 所示。然而,如果你将钟摆从静止状态抬起到一个角度,然后释放它,它就会开始来回摆动,划出一个弧形轨迹。现实中的钟摆会生活在三维空间中,但我将研究一个更简单的情景:一个位于 p5.js 画布二维空间中的钟摆。图 3.19 显示了一个非静止位置的钟摆,并添加了作用力:重力和张力。
当钟摆摆动时,它的摆臂和钟摆球基本上是绕着固定的支点旋转的。如果没有摆臂连接钟摆球和支点,钟摆球会在重力的作用下直接掉到地面上。显然,这并不是发生的事情。相反,摆臂的固定长度产生了第二个力——张力。然而,我不会按照这些力来处理这个情景,至少不像我处理弹簧情景那样。
我将不使用线性加速度和速度,而是用角加速度和角速度来描述钟摆的运动,这指的是摆臂相对于钟摆静止位置的角度θ的变化。我应该先警告你,尤其是如果你是一个经验丰富的物理学家,我将方便地忽略几个重要的概念:能量守恒、动量、向心力等等。这并不是对钟摆物理的全面描述。我的目标是给你一个机会,练习你新学的三角学技巧,并通过一个具体的例子进一步探索力和角度之间的关系。

图 3.19:一个钟摆,显示θ为相对于其静止位置的角度
为了计算摆的角加速度,我将使用牛顿第二定律,但会加上一些三角学的技巧。看看图 3.19,并将头倾斜,使得摆的臂变成垂直轴。重力的方向突然发生偏移,稍微向左—it’s at an angle with respect to your tilted head。如果这开始让你的脖子有点疼,别担心。我会重新绘制倾斜后的图形,并重新标注力F[g]代表重力,T代表张力(图 3.20,左侧)。
现在让我们将重力分解为 x 轴和 y 轴的分量,以摆臂为新的 y 轴。这些分量形成一个直角三角形,重力作为斜边(图 3.20,右侧)。我将它们称为F[gx]和F[gy],但这些分量是什么意思呢?嗯,F[gy]分量代表与F[gy]反向的张力力。记住,张力力就是使摆锤不会掉下来的力。
另一个分量F[gx]垂直于摆臂,它正是我一直在寻找的力!它使摆锤旋转。当摆锤摆动时,y 轴(摆臂)将始终垂直于运动方向。因此,我可以忽略张力和F[gy]力,专注于F[gx],它是沿运动方向的合力。而且,因为这个力是直角三角形的一部分,所以我可以用……你猜对了,三角学来计算它!

图 3.20:左侧是摆的图形被旋转,使得摆臂为 y 轴。右侧显示了F[g]的放大图,并将其分解为F[gx]和F[gy]的分量。
这里的关键是,右三角形的顶角与摆的臂和其静止位置之间的角度θ相同。正如我在讨论极坐标时所示,正弦和余弦函数允许我根据这个角度将重力的分量(斜边)分解出来。对于F[gx],我需要使用正弦:
sin(θ) = F[gx] / F[g]
解出F[gx],我得到了这个结果:
F[gx] = F[g] × sin(θ)
现在,我将这个力重新命名为F[p],代表摆的力。在图 3.21 中,我已将图形恢复到原始方向并重新标注了分量。我还将F[p]的起点从右三角形的底部移到摆锤的中心,以便更清楚地表示这个力如何使摆锤运动。
就是这样。导致摆锤旋转的合力计算如下:
F[p] = F[g] × sin(θ)

图 3.21:F[gx]现在被标为F[p],是沿运动方向的合力。
然而,免得你忘记,我的目标是确定摆的角加速度。一旦我得到了这个,我就能应用运动规则,为每一帧动画找到新的角度θ:
角速度 = 角速度 + 角加速度
角度 = 角度 + 角速度
好消息是,牛顿的第二定律建立了力与加速度之间的关系——即,F = M × A,或者 A = F / M。因此,如果摆的力等于重力与角度的正弦的乘积,那么我就得到了这个公式:
摆的角加速度 = 重力加速度 × sin(θ)
现在是提醒大家一下的时候了,这里讨论的背景是创造性编码,而不是纯粹的物理学。是的,地球上的重力加速度是 9.8 米每秒平方。但这个数字在我们像素的世界中并不相关。相反,我将使用一个任意常数(称为gravity)作为一个变量来调整加速度(顺便提一下,角加速度通常写作α,以便与线性加速度A区分):
α = 重力 × sin(θ)
在我把所有内容整合之前,还有一个细节我没有提到。或者说,实际上有很多小细节。想一下摆臂。它是金属杆吗?一根绳子?橡皮筋?它是如何固定在支点上的?它的长度是多少?它的质量是多少?今天是风大的日子吗?我可以继续提出很多会影响模拟的问题。然而,我选择生活在一个幻想世界里,在这个世界里,摆臂是一个理想化的杆,永远不会弯曲,而摆锤的质量集中在一个微小的点上。
即使我不太愿意过多担心这些问题,但有一个关键部分仍然缺失,和角加速度的计算有关。为了简化摆的角加速度的推导,我假设摆臂的长度是 1。然而,实际上,摆臂的长度会影响摆的加速度,因为力矩和转动惯量的概念。
力矩(或 τ)是作用在物体上的旋转力的度量。在摆的情况下,力矩与摆锤的质量和摆臂的长度成正比(M × r)。摆的转动惯量(或 I)是衡量绕支点旋转摆的难度的度量。它与摆锤的质量和摆臂长度的平方成正比(Mr²)。
记得牛顿的第二定律,F = M × A 吗?它有一个旋转的对应公式,τ = I × α。通过重新排列这个方程来解角加速度 α,我得到了 α = τ /I。进一步简化,这变成了 Mr/Mr² 或者 1/r。角加速度与摆锤的质量无关!
这就像是伽利略的比萨斜塔实验,演示了线性加速度,在这个实验中,不同的物体以相同的速度下落,与它们的质量无关。在这里,摆锤的质量同样不影响其角加速度——只有摆臂的长度会影响。所以,最终的公式是这样的:

太棒了!最后,这个公式是如此简单,你可能会想知道我为什么要详细讲解这些。我的意思是,学习是好事,但我本来可以简单地说:“嘿,摆锤的角加速度是一个常数乘以角度的正弦值,再除以臂长。”那样就错过了重点。本书的目的不是学习摆锤如何摆动或重力如何运作。重点是要富有创意地思考如何在基于计算的图形系统中让形状在屏幕上移动。摆锤只是一个案例研究。如果你能理解编程摆锤的方法,你可以将相同的技巧应用到其他场景中,无论你选择如何设计你的 p5.js 画布世界。
现在,我还没有完成。也许我对我简单优雅的角加速度公式感到满意,但我仍然需要在代码中应用它。这是一个很好的机会,练习一些面向对象编程(OOP)技巧,创建一个Pendulum类。首先,考虑一下我提到过的所有摆锤属性:
-
臂长
-
角度
-
角速度
-
角加速度
Pendulum类也需要这些属性:

接下来,我需要编写一个update()方法,根据公式更新摆锤的角度:

请注意,当前的加速度计算已经包含了乘以 –1 的操作。当摆锤位于其静止位置的右侧时,角度为正,因此角度的正弦值也是正的。然而,重力应该将摆锤拉回到静止位置。相反,当摆锤位于静止位置的左侧时,角度为负,因此其正弦值也为负。在这种情况下,拉力应该是正的。在这两种情况下,乘以 –1 是必要的。
接下来,我需要一个show()方法来在画布上绘制摆锤。但究竟应该在哪里绘制它呢?如何计算摆锤支点(我们称之为pivot)和摆锤位置(我们称之为bob)的 x 和 y 坐标(笛卡尔坐标!)?这可能有点让人感到乏味,但答案还是三角学,如图 3.22 所示。

图 3.22:摆锤相对于支点的位置,在极坐标和笛卡尔坐标中的表示
首先,我需要在构造函数中添加一个this.pivot属性,以指定在画布上绘制摆锤的位置:
this.pivot = createVector(100, 10);
我知道摆锤应该与支点保持一个固定的距离,这个距离由臂长决定。这就是我的变量r,我现在将其设置为:
this.r = 125;
我还知道摆锤相对于支点的当前角度:它存储在变量angle中。通过臂长和角度,我得到了摆锤的极坐标(r,θ)。我真正需要的是笛卡尔坐标,但幸运的是,我已经知道如何使用正弦和余弦将极坐标转换为笛卡尔坐标。因此:
this.bob = createVector(r * sin(this.angle), r * cos(this.angle));
请注意,我正在使用 sin(this.angle) 来计算 x 值,使用 cos(this.angle) 来计算 y 值。这与我在《极坐标与笛卡尔坐标》一节中 第 130 页 所展示的内容相反。原因是我现在要寻找的是指向下方的直角三角形的顶角,如 图 3.21 所示。这个角位于 y 轴和斜边之间,而不是像你在 图 3.9 中看到的那样位于 x 轴和斜边之间。
现在,this.bob 的值假设支点在点 (0, 0) 处。为了得到摆球相对于支点 实际 位置的坐标,我只需要把 pivot 加到 bob 向量中:
this.bob.add(this.pivot);
现在剩下的小问题就是画一条线和一个圆圈(当然,你应该更有创意):
stroke(0);
fill(127);
line(this.pivot.x, this.pivot.y, this.bob.x, this.bob.y);
circle(this.bob.x, this.bob.y, 16);
最后,现实中的摆锤会遇到一定量的摩擦(在支点处)和空气阻力。按照当前的代码,摆锤将永远摆动下去。为了使其更加真实,我可以通过阻尼技巧来减慢摆锤的运动。我说是 技巧,因为与其通过某种方式精确地模拟阻力(就像我在 第二章 中做的那样),我可以通过在每个循环中随意减少角速度的方式来实现类似的效果。以下代码会将速度减少 1%(或将其乘以 0.99),并在每一帧动画中应用:
this.angleVelocity *= 0.99;
把所有的部分结合在一起,我得到了以下示例(摆锤从 45 度角开始)。

在书籍的官方网站上,这个例子有额外的代码,允许用户用鼠标抓取摆锤并让它摆动。
练习 3.15
将一系列摆锤串联起来,使得一个摆锤的摆球成为另一个摆锤的支点。请注意,尽管这样做可能会产生有趣的结果,但在物理上会极其不准确。模拟实际的双摆需要复杂的方程。你可以在 Wolfram Research 的双摆文章中阅读相关内容 (scienceworld.wolfram.com/physics/DoublePendulum.html) 或观看我关于编码双摆的视频 (thecodingtrain.com/doublependulum)。

练习 3.16

使用三角学,你如何计算这里所示的 法向力(垂直于雪橇所处斜面的力)?你可以认为 F[gravity] 的大小是已知的常数。寻找一个直角三角形来帮助你开始计算。毕竟,法向力等于并且与重力的一个分量方向相反。如果有帮助的话,你可以在图上画更多的直角三角形,尽管这样做!
练习 3.17
创建一个带有摩擦力的箱子滑下斜面的模拟。请注意,摩擦力的大小与法向力成正比,如前一个练习中所讨论的那样。
生态系统项目
选取你其中一个生物并将振荡融入其运动中。你可以使用示例 3.7 中的Oscillator类作为模型。然而,Oscillator对象是围绕一个固定点(窗口的中间)进行振荡的。试着围绕一个移动的点进行振荡。
换句话说,设计一种根据位置、速度和加速度在屏幕上移动的生物。但这个生物不仅仅是一个静态的形状;它是一个振荡的物体。考虑将振荡的速度与运动速度联系起来。想想蝴蝶的翅膀扑动或者昆虫的腿。你能否让这个生物的内部机制(振荡)推动它的运动?参见本书网站,了解结合了来自第二章的吸引力和振荡的额外示例。

第五章:4 粒子系统
这是明智的。然而,如果我引用逻辑,逻辑显然表明,众人的需求超过少数人的需求。
—斯波克

正电子(照片由卡尔·D·安德森提供)
这张来自云室的 20 世纪初照片提供了对亚原子粒子世界的一个 glimpse,捕捉到了第一次观察到的正电子。云室是一种装置,能够使带电粒子在超饱和蒸气中移动时的路径变得可见。
1982 年,卢卡斯影业的研究员威廉·T·里维斯正在为《星际迷航 II:可汗的愤怒》工作。电影的大部分情节围绕基因装置展开,这是一种鱼雷,当它被发射到一个荒芜、无生命的星球时,能够重新组织物质,创造一个适合殖民的宜居世界。在这个情节中,随着星球的“地球化”,一堵火墙波动覆盖了星球。粒子系统这一在计算机图形学中极其常见且有用的技术,正是在制作这个特效时诞生的。正如里维斯所说:
粒子系统是许许多多微小粒子的集合,这些粒子共同代表一个模糊的物体。在一段时间内,粒子被生成到系统中,在系统内部移动和变化,并从系统中消亡。
自 1980 年代初以来,粒子系统已被广泛应用于无数视频游戏、动画、数字艺术作品和装置中,用于模拟各种不规则类型的自然现象,如火焰、烟雾、瀑布、雾气、草地、气泡等等。
本章旨在探讨编码粒子系统和管理相关数据的策略。你如何组织代码?你将与单个粒子相关的信息和与整个系统相关的信息存储在哪里?我将介绍的示例将使用简单的点表示粒子,并只应用最基本的行为。然而,这些特性不应限制你的想象力。粒子系统通常看起来闪闪发光、向前飞行或在重力作用下下落,并不意味着你的系统也必须具备这些特征。通过在本章的框架基础上构建,并添加更多创意的方法来渲染粒子并计算它们的行为,你可以实现各种效果。
换句话说,本章的重点是如何跟踪一个包含许多元素的系统。这些元素的行为和外观完全由你决定。
粒子系统的重要性
粒子系统是一组独立的对象,通常通过点或其他简单的形状来表示。但这有什么重要的呢?当然,模拟一些列出的现象(比如瀑布!)的前景非常吸引人,也可能有用。但更广泛地说,当你开始开发更复杂的模拟时,你很可能会遇到处理许多事物的情况——弹跳的球、聚集的鸟群、进化的生态系统,所有这些都是以复数形式出现的事物。这里讨论的粒子系统策略将帮助你应对所有这些情况。
事实上,从这一章开始的每一章几乎都会包含使用对象列表的草图,而这基本上就是粒子系统的形式。是的,我已经在前面的一些章节示例中稍微接触过数组的使用。但现在是时候去探索那些数组未曾涉足的领域了(至少在本书中是如此)。
首先,我想适应灵活数量的元素。有些示例可能没有任何物体,有时是一个物体,有时是十个物体,有时是万千物体。其次,我想采用一种更复杂的面向对象的方法。除了编写一个描述单个粒子的类,我还想编写一个描述整个粒子集合——即粒子系统本身——的类。这里的目标是能够写出如下的草图:

代码中没有引用单个粒子,但结果是画布上到处飞舞着粒子。这之所以能实现,是因为细节被隐藏在ParticleSystem类中,它持有对许多Particle类实例的引用。习惯于使用多类编写草图的这种技巧,包括那些保存其他类实例列表的类,在你进入本书后面的章节时将非常有用。
最后,处理粒子系统也是一个解决其他两种面向对象编程技术的机会:继承和多态。到目前为止,你所看到的示例中,我总是使用单一类型对象的数组,比如mover数组或oscillator数组。通过继承和多态,我将展示一种方便的方法,使用单一列表来存储不同类型的对象。这样,粒子系统就不必是只有一种粒子的系统了。
单个粒子
在我开始编写粒子系统的代码之前,我需要写一个类来描述单个粒子。好消息是:我已经做过了!第二章中的Mover类就是一个完美的模板。粒子是一个独立的物体,在画布上移动,因此就像一个mover一样,它有position、velocity和acceleration变量;一个构造函数来初始化这些变量;还有方法来show()自己和update()位置。

这就是粒子最简单的形式。从这里开始,我可以将粒子引导到多个方向。我可以添加applyForce()方法来影响粒子的行为(我将在未来的示例中做这个)。我还可以添加描述颜色和形状的变量,或者加载p5.Image来以更有趣的方式绘制粒子。不过,目前我会专注于添加一个额外的细节:生命周期。
一些粒子系统包含一个发射器,它作为粒子的源。发射器控制粒子的初始设置:位置、速度等。它可能会发射一批粒子、连续的粒子流,或者其他某种变化。这里的新特性是,发射器出生的粒子不能永远活着。如果它们能永生,那么 p5.js 的草图最终会因为粒子数目随时间增加而停滞不前。随着新粒子的诞生,旧粒子需要被移除,从而创造出一个粒子无限流动的假象,而不影响草图的性能。
决定粒子何时准备被移除有很多方法。例如,它可以在与另一个物体接触时“死亡”,或者当它离开画布的边框时“死亡”。目前,我选择给粒子一个lifespan变量,像一个计时器一样。它将从 255 开始,在草图进程中逐渐减小到 0,到那时粒子就会被视为死亡。以下是Particle类中的新增代码:

由于lifespan的范围从 255 到 0,它还可以方便地充当表示粒子的圆形的透明度。这样,当粒子死亡时,它就会字面上消失。
随着lifespan属性的加入,我还需要一个方法,这个方法可以查询(返回真或假)以确定粒子是活着还是死了。当我编写一个单独的类来管理粒子列表时,这将派上用场。编写这个方法其实很简单:我只需要检查lifespan的值是否小于 0。如果是,返回true;否则,返回false:

更简单地说,我可以直接返回布尔表达式的结果!

在进入下一步制作许多粒子之前,值得花点时间确认粒子是否能正确工作。为此,我将创建一个只包含单个Particle对象的草图。以下是完整代码,做了一些小改动:给粒子一个随机的初始速度,并添加applyForce()来模拟重力。

这个示例为了简化和测试,每次只创建一个粒子。每当粒子达到其生命周期的末尾时,particle 变量将被一个新的 Particle 类实例覆盖。这实际上是替换了先前的 Particle 对象。需要理解的是,先前的 Particle 对象并没有被真正删除,而是变得无法访问或不再在代码中使用。这个草图实际上是忘记了旧的粒子,从新创建的粒子开始。
练习 4.1
在 Particle 类中创建一个 run() 方法,用于处理 update()、show() 和 applyForce()。这种方法的优缺点是什么?
练习 4.2
为粒子添加角速度(旋转),并设计一个不是圆形的粒子,使其旋转可见。
粒子数组
现在我已经有了描述单个粒子的类,接下来是下一个大步骤:如何在不事先知道具体有多少粒子的情况下跟踪多个粒子?答案是 JavaScript 数组,这是一种存储任意长度值列表的数据结构。在 JavaScript 中,数组实际上是从 Array 类创建的对象,因此它具有许多内建的方法。这些方法提供了我需要的所有功能,用于维护一个 Particle 对象的列表,包括添加粒子、删除粒子或以其他方式操作它们。如需复习数组,请参阅 MDN Web Docs 网站上的 JavaScript 数组文档 (developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array).
当我引入数组时,我将使用解决方案来处理 练习 4.1,并假设有一个 Particle.run() 方法来管理每个粒子所有的功能。虽然这种方法也有一些缺点,但它将使后续的代码示例更加简洁。首先,我将在 setup() 中使用 for 循环来填充一个粒子数组,然后在 draw() 中使用另一个 for 循环来运行每个粒子:

draw() 中的 for 循环演示了如何通过访问每个索引来调用数组中每个元素的方法。我将变量 i 初始化为 0,并将其递增 1,访问数组的每个元素,直到 i 达到 particles.length 并到达数组的末尾。实际上,还有其他几种方法可以实现相同的操作。这也是我既喜欢又讨厌使用 JavaScript 编程的地方——它有如此多的风格和选项需要考虑。一方面,这使得 JavaScript 成为一种高度灵活且适应性强的语言,另一方面,选择的多样性可能令人不知所措,学习时可能会感到困惑。
让我们来体验一下用于遍历数组的循环选择过山车:
-
传统的
for循环,如刚才所示。这可能是你最熟悉的,它的语法与其他编程语言(如 Java 和 C)类似。 -
for...in循环。这种循环允许你遍历对象的所有属性。它对于数组并不是特别有用,所以在这里我不会详细介绍。 -
forEach()循环。这是一个很棒的循环,我鼓励你去探索它!它是一个高阶函数的例子,稍后我会在本章中解释高阶函数的概念。 -
for...of循环。这是我接下来将要详细扩展的技术。与传统的for循环相比,它在处理对象数组时提供了更简洁、清晰的语法。
for...of循环的样子如下:
function draw() {
for (let particle of particles) {
particle.run();
}
}
要翻译这段代码,应该用each代替let,用in代替of。合起来就是:“对于每个粒子在粒子数组中,更新并显示该粒子。”
简单、优雅、简洁、可爱。但是在你对for...of循环感到过于兴奋之前,请先稍作停顿,深呼吸,因为我有一个坏消息要告诉你:它们并不适用于所有情况。是的,我喜欢for...of循环,在接下来的示例中,我将使用它们来遍历数组中的项目,但现在还不行。最终,我希望创建一个连续的粒子流,每个循环通过draw()向数组中添加一个新粒子,并在粒子死亡时从数组中移除旧粒子。正如你很快会看到的,这正是for...of循环让我失望的地方。
在每一帧创建一个新粒子非常简单:我只需在draw()中调用Array类的push()方法,将一个新的Particle对象添加到数组的末尾。这也避免了在setup()中创建任何粒子:

运行这段代码几分钟,你会发现帧率越来越慢,直到程序完全停滞。(我的测试在 15 分钟后表现出可怕的性能问题。)问题当然是,我没有移除任何粒子,只是在不断添加更多的粒子。
为了解决这个问题,我可以使用splice()方法,在粒子死亡时将其从数组中移除。该方法从给定的索引开始,移除一个或多个元素。这就是我不能在这里使用for...of循环的原因;splice()需要一个指向正在被移除粒子索引的引用,而for...of循环并没有提供这种引用。所以,我只能改用常规的for循环:

虽然这段代码能够正常运行,并且永远不会停止,但是我通过尝试在遍历数组时操作数组内容,打开了一个中等大小的“麻烦盒子”。这无疑是在自找麻烦。例如,看看以下代码:

这是一个有些极端的场景(逻辑存在缺陷),但它证明了这个问题。对于列表中的每个粒子,这段代码都会向列表中添加一个新的粒子,从而导致数组的 length 增加。这将导致一个无限循环,因为我永远无法超过数组的大小!
虽然在循环中从数组中移除元素并不会导致程序崩溃(就像添加元素时会崩溃一样),但问题可能更为隐蔽,因为它没有留下任何证据。为了发现这个缺陷,我必须先确定一个重要事实:当通过 splice() 从数组中删除一个元素时,所有后续元素都会向左移动。图 4.1 展示了当粒子 C(索引 2)被移除时发生的情况。粒子 A 和 B 保持相同的索引,而粒子 D 和 E 则分别从位置 3 和 4 移动到位置 2 和 3。

图 4.1:当从数组中移除一个元素时,后续元素会向左移动以填补空缺。
考虑一下当计数器 i 遍历数组中的元素时会发生什么:
| i | 粒子 | 操作 |
|---|---|---|
| 0 | 粒子 A | 不删除! |
| 1 | 粒子 B | 不删除! |
| 2 | 粒子 C | 删除!将粒子 D 和 E 从位置 3 和 4 移动到位置 2 和 3。 |
| 3 | 粒子 E | 不删除! |
注意到问题了吗?粒子 D 从未被检查!当 C 从位置 2 被删除时,D 移动到位置 2 代替它,但 i 已经移动到位置 3。实际上,这可能不会导致灾难性后果,因为粒子 D 会在下一次通过 draw() 时被检查。不过,期望是代码应该遍历数组中的每一个元素,跳过一个元素是不可接受的!
这个问题有两种解决方案。第一种是反向遍历数组。由于当其他元素被移除时,元素会从右到左滑动,因此跳过元素变得不可能。以下是代码的写法:

第二种解决方案是使用高阶函数。这是一种接受另一个函数作为参数(或将一个函数作为返回值)的函数。JavaScript 数组使用了许多高阶函数。例如,一个常见的高阶函数是 sort(),它接受一个定义如何比较数组中两个元素的函数作为参数,然后根据该比较对数组进行排序。
对于粒子数组,我可以使用 filter()。这个高阶函数接受一个指定某种条件的函数作为参数,检查数组中的每个项目是否符合该条件,并返回符合条件的项目(排除那些返回 false 的项目):

这个通常使用 JavaScript 的箭头函数语法来书写。要了解更多,你可以观看我关于高阶函数和箭头函数语法的 Coding Train 教程(thecodingtrain.com/hof)。
particles = particles.filter(particle => !particle.isDead());
就本书而言,我将继续使用splice()方法,但我鼓励你探索使用高阶函数和箭头表示法编写代码。

你可能会想,为什么我不在检查每个粒子时单独处理,而是在一定时间后(通过检查frameCount或数组长度来确定)直接移除最旧的粒子。在这个示例中,粒子以与出生顺序相同的顺序死亡,这种方法实际上是有效的。我甚至可以使用一个名为shift()的数组方法,它会自动移除数组中的第一个元素。然而,在许多粒子系统中,其他条件或相互作用可能导致“年轻”的粒子比“老”的粒子更早死亡。结合使用isDead()和splice()是一个很好的综合解决方案,它在各种场景下提供了管理粒子的灵活性。
粒子发射器
我已经征服了数组,并用它来管理一个Particle对象的列表,能够随意地添加和删除粒子。我本可以在这里停下,满足于现状,但我可以并且应该采取进一步的步骤:编写一个类来描述Particle对象的列表。在本章开始时,我使用了一个假设性的类名ParticleSystem来表示粒子的总体集合。然而,描述粒子发射功能的更合适术语是Emitter,从现在起我将使用这个名称。
Emitter类将使我能够简化draw()函数,移除循环遍历所有粒子的庞大逻辑。作为额外的好处,它还将为拥有多个粒子发射器提供可能性。
回想一下,我在本章开始时设定的目标之一是编写setup()和draw()函数时不引用任何单独的粒子。在设定这个目标时,我曾暗示了主草图文件可能变得简单美观的可能性。现在,它已经变成了这个样子,只不过现在采用了Emitter的命名惯例。

要达到这一点,查看示例 4.2 中setup()和draw()的每个部分,思考它们如何更适合放入Emitter类中。代码行为不应该发生任何变化——唯一的区别是它的组织方式:
| setup() 和 draw() 中的数组 | Emitter 类中的数组 |
|---|
|
let particles = [];
function setup() {
createCanvas(640, 240);
}
function draw() {
particles.push(new Particle());
let length = particles.length - 1;
for (let i = length; i >= 0; i--) {
let particles = particles[i];
particle.run();
if (particle.isDead()) {
particles.splice(i, 1);
}
}
}
|
class Emitter {
constructor() {
this.particles = [];
}
addParticle() {
this.particles.push(new Particle());
}
run() {
let length = this.particles.length - 1;
for (let i = length; i >= 0; i--) {
let particle = this.particles[i];
particle.run();
if (particle.isDead()) {
this.particles.splice(i, 1);
}
}
}
}
|
我还可以为粒子系统本身添加新特性。例如,Emitter类可能会跟踪一个粒子出生的原点。这个原点可以在构造函数中初始化。

示例发射器是一个静态的粒子源,这是开始处理粒子系统的一个不错的方式。然而,事情不必总是这样。发射器可以有自己的行为:经历物理变化、振荡、响应用户输入,或者表现出前几章中展示的任何其他类型的运动。然后,粒子可以从不同的位置发射,随着时间的推移形成轨迹或其他更复杂、更有趣的图案。
练习 4.3
如果发射器移动怎么办?你能从鼠标位置发射粒子,或者利用速度和加速度的概念让系统自主移动吗?
练习 4.4
基于第三章的小行星示例,使用粒子系统从飞船的推进器中发射粒子,每当施加推力时。粒子的初始速度应与飞船的当前方向相关。
发射器系统
到目前为止,我描述了一个单独的粒子,并将其代码组织到一个Particle类中。我还描述了一个粒子系统,并将其代码组织到一个Emitter类中。这个粒子系统不过是一个独立的Particle对象的集合。但作为Emitter类的实例,粒子系统不也是一个对象吗?如果是这样(而且确实如此),那就没有理由我不能再构建一个包含多个粒子发射器的集合:一个系统的系统!
我可以进一步思考,甚至把自己锁在地下室里几天,画出一个系统的系统的系统的系统的系统的系统的图……直到我把这整个系统的概念从我的系统中“清除”出去。毕竟,我可以用类似的方式描述这个世界:一个器官是由细胞组成的系统,一个人体是由器官组成的系统,一个社区是由人体组成的系统,一个城市是由社区组成的系统,依此类推。虽然我还没准备好做得那么深入,但仍然有必要看看如何编写一个可以追踪多个粒子系统的草图,而每个粒子系统又可以追踪多个粒子。
考虑以下场景:你从一个空白的屏幕开始(图 4.2)。

图 4.2:从空白屏幕开始
你点击鼠标,并在鼠标位置生成一个粒子系统(图 4.3)。

图 4.3:添加粒子系统
你不断点击鼠标。每次点击,都会在你点击的位置弹出另一个粒子系统(图 4.4)。

图 4.4:添加更多粒子系统
如何做到这一点?在示例 4.3 中,我将一个Emitter对象的引用存储在变量emitter中:
let emitter;
function setup() {
createCanvas(640, 240);
emitter = new Emitter(width / 2, 20);
}
function draw() {
background(255);
emitter.addParticle();
emitter.run();
}
现在,我将变量命名为emitters(复数),并将其设为数组,以便跟踪多个Emitter对象。草图开始时,数组为空。

每当鼠标点击时,一个新的Emitter对象被创建并放入数组中:
function mousePressed() {
emitters.push(new Emitter(mouseX, mouseY));
}
然后,在draw()中,我不再引用单个Emitter对象,而是遍历所有的发射器,并对每个发射器调用run():

注意,我又回到使用for...of循环,因为没有元素从emitters数组中被移除。
练习 4.5
重写示例 4.4,使每个粒子系统不会永远存在。为每个粒子系统生成的粒子数量设置限制。然后,当一个粒子系统为空(没有剩余粒子)时,从emitters数组中移除它。
练习 4.6
创建一个物体破碎成许多碎片的模拟。你如何将一个大形状转化为许多小粒子?你能否在屏幕上创建几个大形状,每个形状在被点击时都会破碎?
继承和多态
到目前为止,我的系统中的所有粒子都是相同的,具有相同的基本外观和行为。谁说一定要这样呢?通过利用两个基本的面向对象编程(OOP)原则——继承和多态,我可以创建具有更多种类和趣味的粒子系统。
也许在你编程的过程中,已经遇到过这两个术语。例如,我的初学者书籍《学习处理》(Learning Processing)中有近一整章(第二十二章)专门讲解这两个概念。不过,也许你只是抽象地了解过继承和多态,但从未有机会真正应用它们。如果是这样,你来对地方了。没有这些技术,你编程多样粒子和粒子系统的能力会非常有限。(在第六章中,我还会演示如何理解这些概念能够帮助你使用物理库。)
想象一下,这是一个周六早晨。你刚刚去跑步,享受了一碗美味的麦片,正在电脑前安静地坐着,手里拿着一杯温暖的洋甘菊茶。今天是你老朋友某某的生日,你决定用 p5.js 做一张贺卡。怎么样,模拟一下五彩纸屑?紫色纸屑、粉色纸屑、星形纸屑、方形纸屑、快速飘落的纸屑、轻盈飘动的纸屑——各种各样的纸屑,各有不同的外观和行为,全部在屏幕上同时爆发出来。
你现在看到的显然是一个粒子系统:由多个单独的片段(粒子)组成的彩纸。你可能会聪明地重新设计Particle类,使用变量来存储颜色、形状、行为等。为了创建各种粒子,你可能会用随机值初始化这些变量。但如果某些粒子差异非常大呢?如果把各种不同的粒子行为都放在同一个类中,代码会变得非常凌乱。另一种选择可能是这样做:
class HappyConfetti {
}
class FunConfetti {
}
class WackyConfetti {
}
这是一个不错的解决方案:创建三个类来描述你的粒子系统中不同种类的彩纸。然后,Emitter构造函数可以包含一些代码,在填充数组时从这三个类中随机选择(注意,这种概率方法与我在第零章中的随机漫步示例中使用的相同):

让我稍作停顿。你没有做错什么。你只不过是想祝朋友生日快乐,并享受编写代码的乐趣。但尽管这种方法的思路是合理的,还是存在一个问题:你不是要在各个彩纸类之间复制和粘贴大量代码吗?
是的,你可能会这样想。即使粒子的种类足够不同,值得将它们分开成不同的类,它们很可能会共享大量的代码。例如,它们都会有向量来跟踪位置、速度和加速度;一个实现运动算法的update()函数;等等。
这就是继承的作用。继承让你能够编写一个类,从另一个类中(继承)变量和方法,同时也实现自己的定制功能。你可能还会想知道,把所有这些类型的彩纸放进一个particles数组中是否真的可行。毕竟,我通常不会把不同种类的对象放在同一个数组中,因为这可能会让人感到困惑。Emitter类中的代码如何知道哪个粒子是哪种彩纸呢?分开数组不更容易管理吗?
constructor() {
this.happyParticles = [];
this.funParticles = [];
this.wackyParticles = [];
}
事实上,将粒子分成不同的数组并不方便;一个包含所有粒子的单一数组要实用得多。幸运的是,JavaScript 具有将不同类型的对象混合在一个数组中的能力,而多态性使得这些混合的对象可以像同一种类型的对象一样进行操作。我可以用不同类型的粒子填充一个数组,每个粒子仍然会保持它在各自类中定义的独特行为和特征。
在这一节中,我将更详细地说明继承和多态的概念,然后我会创建一个结合这些概念的粒子系统。
继承基础
为了展示继承是如何工作的,我将以动物世界中的一个例子为例:狗、猫、猴子、熊猫、袋熊、海蜇等等。我将从编写一个Dog类开始。一个Dog对象将拥有一个age变量(一个整数),以及eat()、sleep()和bark()方法:
class Dog {
constructor() {
this.age = 0;
}
eat() {
print("Yum!");
}
sleep() {
print("Zzzzzz");
}
bark() {
print("WOOF");
}
}
现在我来创建一只猫:

当我继续为鱼、马、考拉和狐猴编写相同的代码时,这个过程会变得相当繁琐。一个更好的解决方案是开发一个通用的Animal类,可以描述任何类型的动物。毕竟,所有动物都会吃和睡。我可以这样写:
-
一只狗是动物,并且拥有所有动物的属性,可以做所有动物能做的事情。此外,狗还可以叫。
-
一只猫是动物,并且拥有所有动物的属性,可以做所有动物能做的事情。此外,猫还可以喵喵叫。
继承使这一切成为可能,允许Dog和Cat被指定为Animal类的子类(子类)。子类自动继承父类(超类)的所有变量和方法,但它们也可以包含父类中没有的方法和变量。像生命的系统发育树一样,继承遵循树状结构(见图 4.5):狗继承自哺乳动物,哺乳动物继承自动物,依此类推。

图 4.5:继承树
下面是继承语法如何工作的:

这段代码使用了两个新的 JavaScript 特性。首先,注意到extends关键字,它指定了类的父类。一个子类只能继承一个父类。然而,类可以继承继承其他类的类;例如,Dog extends Animal,Terrier extends Dog。从Animal到Terrier,一切都被继承下来。
其次,注意在Dog和Cat构造函数中调用了super()。这会调用父类中的构造函数。换句话说,无论你在父类构造函数中做了什么,都要在子类构造函数中做同样的事。在这种情况下,这并不是必要的,但如果父类构造函数定义了需要匹配参数的构造函数,super()也可以接收参数。
你可以扩展一个子类,包含超类中没有的额外方法。在这里,我为Dog类添加了bark()方法,为Cat类添加了meow()方法。你还可以在子类的构造函数中,除了调用super(),添加额外的代码,以给该子类添加额外的变量。例如,假设除了age之外,一个Dog对象还应该有一个haircolor变量。这个类现在会像这样:

注意,父类构造函数首先通过super()调用,这会将age设置为0,然后在Dog构造函数中设置haircolor。
如果Dog对象的吃饭方式与普通Animal对象不同,可以通过在子类中创建不同的定义来覆盖父类方法:

但如果一只狗吃东西的方式与普通动物差不多,只是多了一些额外的功能怎么办?子类可以既执行父类方法中的代码,也可以加入自定义代码:

类似于在构造函数中调用super(),在Dog类的eat()方法中调用super.eat()将会调用Animal类的eat()方法。然后,子类的方法定义可以继续执行任何额外的自定义代码。
多态基础
你已经使用继承创建了一堆动物子类。现在试着想象一下,如何管理这个包含狗、猫、乌龟和奇异鸟等各种动物的庞大动物王国:

当一天开始时,动物们都很饿,准备进食。现在是(增强版!)循环时间:

这样做是可行的,但随着世界扩展到更多动物物种,你将不得不写很多独立的循环。这真的是必要的吗?毕竟,这些生物都是动物,而且它们都喜欢吃东西。为什么不只用一个数组,填充所有种类的动物呢?

这就是多态(来自希腊语polymorphos,意为“多种形式”)的实际应用。虽然所有动物都被放在一个数组中,并在一个for循环中处理,JavaScript 仍然能够识别它们的真实类型,并为每个对象调用适当的eat()方法。就是这么简单!
继承与多态中的粒子
现在我已经介绍了继承和多态的理论和语法,准备在 p5.js 中写出一个基于Particle类的实际示例。首先,再看一眼一个基本的Particle实现,改编自示例 4.1:
class Particle {
constructor(x, y) {
this.acceleration = createVector(0, 0);
this.velocity = createVector(random(-1, 1), random(-2, 0));
this.position = createVector(x, y);
this.lifespan = 255.0;
}
run() {
this.update();
this.show();
}
update() {
this.velocity.add(this.acceleration);
this.position.add(this.velocity);
this.lifespan -= 2.0;
this.acceleration.mult(0);
}
applyForce(force) {
this.acceleration.add(force);
}
isDead() {
return (this.lifespan < 0);
}
show() {
fill(0, this.lifespan);
circle(this.position.x, this.position.y, 8);
}
}
这个类包含了粒子系统中任何参与者应该具备的变量和方法。接下来,我将创建一个继承自Particle的Confetti子类。它将使用super()执行父类构造函数中的代码,并继承大部分Particle类的方法。然而,我将为Confetti定义一个自己的show()方法,覆盖父类的方法,这样Confetti对象将被绘制为方形,而不是圆形:

让我们把这个做得更复杂一些。假设我想让每个Confetti粒子在飞行时旋转。一种选择是模拟角速度和加速度,如第三章所述。然而,为了简便,我将实现一种不那么正式的方式。
我知道一个粒子的 x 坐标在 0 到画布宽度之间。那么,如果我说,当粒子的 x 坐标为 0 时,它的旋转角度应该是 0;当它的 x 坐标等于宽度时,它的旋转角度应该是 4π呢?这听起来熟悉吗?正如在第零章中讨论的那样,当你需要将一个范围的值映射到另一个范围时,可以使用map()函数:
let angle = map(this.position.x, 0, width, 0, TWO_PI * 2);
以下是这段代码如何融入到show()方法中的:

选择 4π可能看起来有些任意,但其实是故意为之——两次完整的旋转相较于一次旋转能给粒子带来更强的旋转效果。
练习 4.7
不要使用map()来计算angle,尝试建模角速度和加速度。
现在,我已经有了一个扩展自基础Particle类的Confetti子类,接下来的步骤是将Confetti对象添加到Emitter类中定义的粒子数组里。

你能发现这个例子是如何利用多态性的吗?正是它让Particle和Confetti对象可以在Emitter类中的同一个particles数组里混合。由于继承关系,它们都可以被视为同一类型Particle,因此可以安全地遍历数组并对每个对象调用像run()和isDead()这样的函数。继承和多态性一起使得可以在一个数组中管理各种粒子类型,而不管它们最初属于哪个类。
练习 4.8
创建一个包含两种以上粒子的粒子系统。除了设计外,尝试改变粒子的行为。
带有力的粒子系统
到目前为止,本章的重点是以面向对象的方式构建代码,以管理一组粒子。虽然我在Particle类中保留了applyForce()函数,但我为了简化代码采取了一些捷径。现在,我将重新添加一个mass属性,并在此过程中更改constructor()和applyForce()方法(类的其他部分保持不变):

既然Particle类已经完成,我有一个重要的问题要问:我应该在哪里调用applyForce()方法?在代码中的哪一部分应用力到粒子上才合适?在我看来,没有对错之分,这真的取决于特定 p5.js 草图的功能和目标。我在前面的示例中的简单解决方案是,在每个粒子的run()方法中创建并应用一个gravity力:

现在,我想考虑一个更广泛、更通用的解决方案,允许对系统中的单个粒子应用不同的力。例如,如果我在每次通过draw()时全球性地对所有粒子应用一个力,会怎样?
function draw() {
background(255);
/* Apply a force to all particles? */
emitter.addParticle();
emitter.run();
}
嗯,似乎有个小问题。applyForce()方法是在Particle类内部写的,但这里并没有对单个粒子本身的引用,只有对emitter(Emitter对象)的引用。然而,我希望所有粒子都能接受这个力,所以我可以把力传递给发射器,让它来管理所有粒子:

当然,如果我在draw()方法中调用了Emitter对象的applyForce()方法,那么我就必须在Emitter类中定义该方法。该方法需要能够接收一个p5.Vector类型的力,并将这个力应用到所有粒子上。以下是代码实现:
applyForce(force) {
for (let particle of this.particles) {
particle.applyForce(force);
}
}
写这个方法几乎有点傻。代码本质上是在说:“将力施加到粒子系统中,这样系统就能把力施加到所有单个粒子上。”虽然这听起来有点绕,但这种方法是相当合理的。毕竟,发射器负责管理粒子,所以如果你想与粒子交互,就必须通过它们的管理者来交互。(另外,这也是使用for...of循环的好机会,因为没有粒子被删除!)
这是完整的示例,包括了这个更改。(代码假设之前已存在Particle类;由于没有变化,因此无需再次展示。)

虽然这个例子展示了一个硬编码的重力力场,但值得考虑如何将前几章中的其他力(如风力或阻力)引入到粒子系统中。你还可以尝试不同的方式和时机来应用这些力。如果不是每一帧都持续作用于粒子,而是在特定条件下或者某些特定时刻才作用力会怎样呢?在粒子系统的设计中,这里还有很大的创意和互动空间!
带有排斥器的粒子系统
如果我想进一步提升我的代码,加入一个Repeller对象——它是第二章中讲到的Attractor对象的反向——它将推开任何靠得太近的粒子,这该怎么做呢?这比简单地应用重力力场要复杂一些,因为排斥器对特定粒子施加的力是独特的,必须为每个粒子单独计算(见图 4.6)。

图 4.6:重力力场,其中所有向量相同(左),排斥力场,其中所有向量指向不同方向(右)
要在粒子系统草图中加入一个新的Repeller对象,我需要对代码进行两个主要的扩展:
-
一个
Repeller对象(已声明、初始化并显示) -
一个方法,用于将
Repeller对象传递给粒子发射器,以便排斥器可以对每个粒子施加力

创建Repeller对象很简单;它是示例 2.6 中Attractor类的复制版。由于这一章不涉及mass概念,我将为Repeller类添加一个名为power的属性。这个属性可用于调整排斥力的强度:

更困难的任务是编写applyRepeller()方法。与applyForce()不同,我不是将p5.Vector对象作为参数传递,而是需要将Repeller对象传递给applyRepeller(),并要求该方法计算排斥器与每个粒子之间的力。请看一下这两个方法的对比:
|
applyForce(force) {
for (let particle of this.particles) {
particle.applyForce(force);
}
}
|
applyRepeller(repeller) {
for (let particle of this.particles) {
let force = repeller.repel(particle);
particle.applyForce(force);
}
}
|
这两个几乎相同的方法只有两个区别。我之前提到过其中一个:applyRepeller()的参数是一个Repeller对象,而不是p5.Vector对象。第二个区别是更重要的:我必须为每一个粒子计算一个自定义的p5.Vector力并施加这个力。这个力是如何计算的?在Repeller类的方法repel()中,它是通过取Attractor类的attract()方法的反向来计算的:

注意,在整个将排斥器添加到环境的过程中,我从未考虑编辑Particle类本身。粒子不需要了解其环境的任何细节;它只需管理其位置、速度和加速度,并能接收外部力并对其进行作用。
我现在准备将这个示例完整地写出,再次省略了Particle类,因为它没有变化。



注意到在Repeller类中添加了power变量,它控制施加的排斥力的强度。当你有多个吸引器和排斥器,每个都有不同的力量值时,这个属性变得尤其有趣。例如,强吸引器和弱排斥器可能导致粒子聚集在吸引器周围,而更强的排斥器可能会展现出类似路径或通道的图案。这些都是第五章中将要探讨的复杂系统概念的暗示。
练习 4.9
扩展示例 4.7,包括多个排斥器和吸引器。你如何使用继承和多态来创建独立的Repeller和Attractor类,而不重复代码?
练习 4.10
创建一个粒子系统,使每个粒子都能响应其他每个粒子。(我将在第五章中详细解释如何实现这一点。)
图像纹理和加法混合
尽管本书几乎专注于行为和算法,而非计算机图形学和设计,但如果我在讨论粒子系统时没有提供一个用图像为每个粒子贴图的示例,我觉得自己一定会不甘心。毕竟,渲染粒子的方式在设计某些类型的视觉效果时是一个关键因素。例如,比较图 4.7 中展示的两种烟雾模拟。

图 4.7:白色圆圈(左)和带有透明度的模糊图像(右)
这两张图像是通过相同的算法生成的。唯一的区别是,左图中的每个粒子都绘制为一个普通的白色圆圈,而右图中的每个粒子则绘制为一个模糊的斑点。图 4.8 展示了这两种粒子纹理。

图 4.8:两种图像纹理:全白圆圈(左)和向边缘渐隐的模糊圆圈(右)
使用图像为粒子贴图,在视觉效果的真实感上能以极少的成本获得很大的回报。然而,在编写任何代码之前,你必须先制作好你的图像纹理。我推荐使用 PNG 格式,因为 p5.js 会保留绘制图像时的 alpha 通道(透明度),这对于在粒子相互叠加时进行纹理混合是必需的。你可以通过多种方式制作这些纹理:你确实可以在 p5.js 中通过编程生成它们(我在本书网站上提供了一个示例),但你也可以使用其他开源或商业的图形编辑工具。
一旦你制作了一个 PNG 文件并将其放入草图的data文件夹中,你只需要几行额外的代码。

首先,声明一个变量来存储图像:
let img;
然后,在preload()中加载图像:

接下来,在绘制粒子时,使用img变量,而不是绘制圆圈或矩形:

这个烟雾示例也是一个很好的借口,让我们重新审视一下《随机数的正态分布》中关于高斯分布的内容,见第 13 页。与直接将粒子发射到完全随机的方向(会产生类似喷泉的效果)不同,如果初始速度向量大多集中在一个均值附近,且远离该均值的速度出现概率较低,最终效果看起来更像烟雾。
使用randomGaussian()函数,可以如下初始化粒子速度:
let vx = randomGaussian(0, 0.3);
let vy = randomGaussian(-1, 0.3);
this.velocity = createVector(vx, vy);
最后,在这个示例中,我对烟雾应用了一个风力,风力映射自鼠标的水平位置:

除了设计纹理之外,你还应考虑其分辨率。渲染大量高分辨率纹理可能会显著影响性能,尤其是当代码需要动态调整它们的大小时。为了获得最佳速度的理想情况,是将纹理的大小精确调整为你打算在画布上绘制粒子的分辨率。如果你希望粒子的大小不同,那么理想的做法是将纹理的大小设置为粒子最大尺寸的大小。
练习 4.11
尝试为不同类型的效果创建其他纹理。你能让你的粒子系统看起来像火焰而不是烟雾吗?
练习 4.12
使用一组图像,并为每个Particle对象分配不同的图像。多个粒子将绘制相同的图像,因此确保你不要重复调用loadImage(),每个图像文件调用一次就足够了!
最后,值得注意的是,许多算法可以用来在计算机图形中混合颜色,这些算法通常被称为混合模式。通常,当你在 p5.js 中绘制某物时,它会覆盖在另一个物体上,你只能看到最上面的一层——这是默认的“混合”行为,即根本不进行混合。同时,当像素具有透明度值(就像在烟雾示例中一样)时,p5.js 会自动使用一个 alpha 合成算法,将背景像素的一部分与前景像素的比例混合,这个比例由 alpha 值决定。
然而,也可以使用其他混合模式进行绘制。例如,粒子系统中非常受欢迎的一种技术是加法混合。这种模式由 Robert Hodgin 在他的经典粒子系统中首次提出,并在探索项目《Magnetosphere》(roberthodgin.com/project/magnetosphere)中得到了应用,该项目后来成为早期版本 iTunes 的可视化工具,展示了与声音响应的动态视觉效果。
加法混合是一种简单的混合算法,它将一个层的像素值加到另一个层中,所有值都限制在 255 以内。这样会产生一种未来感的光晕效果,随着更多层的叠加,颜色会变得越来越亮。

在开始绘制任何内容之前,使用blendMode()设置混合模式:

加法混合和粒子系统提供了一个讨论计算机图形渲染器的机会。渲染器是代码中负责在屏幕上绘制内容的部分。你一直在使用的 p5.js 库的默认渲染器,实际上是建立在现代网页浏览器中包含的标准 2D 绘图和动画渲染器之上的。然而,还有一个名为WEBGL的额外渲染选项可供选择。WebGL,即Web 图形库,是一种基于浏览器的高性能渲染器,支持 2D 和 3D 图形。它利用你计算机显卡的附加功能。要启用它,可以在createCanvas()中添加第三个参数:

通常,只有在你在 p5.js 草图中绘制 3D 图形时,才需要 WebGL 渲染器。然而,即使是 2D 草图,在某些情况下 WebGL 渲染器也很有用—根据你电脑的硬件和草图的具体细节,它可以显著提高绘图性能。粒子系统(尤其是启用了加法混合的粒子系统)正是这种可以在WEBGL模式下绘制更多粒子而不会减慢草图的场景之一。请记住,WEBGL模式改变了绘图的原点,使得(0,0)成为画布的中心,而不是左上角。WEBGL模式还改变了某些函数的行为,可能会影响渲染质量。此外,一些旧设备或浏览器不支持 WebGL,尽管这种情况很少见。你可以在 Coding Train 网站上的 WebGL 视频中了解更多内容(thecodingtrain.com/webgl)。
练习 4.13
在示例 4.9 中,每次通过draw()中的for循环添加三个粒子,从而创建更具层次感的效果。更好的解决方案是修改addParticle()方法,接受一个参数—例如,addParticle(3),以确定要添加的粒子数量。请在此处填写新方法的定义。如果未提供值,如何使其默认为添加一个粒子呢?
addParticle(amount = 1) {
for (let i = 0; i < amount; i++) {
this.particles.push(new Particle(this.origin.x, this.origin.y));
}
}
练习 4.14
使用 tint()结合加法混合来创建彩虹效果。如果你尝试使用其他模式进行混合,例如SUBTRACT、LIGHTEST、DARKEST、DIFFERENCE、EXCLUSION或MULTIPLY,会发生什么?
生态系统项目
从第三章中取出你的生物,并构建一个生物系统。它们是如何相互作用的?你能否使用继承和多态来创建从相同代码库派生的各种生物?制定一种方法来模拟它们如何竞争资源(例如,食物)。你能像追踪粒子生命周期一样追踪生物的健康状态,适时移除生物吗?你可以加入什么规则来控制生物如何进入系统?
此外,你可能会考虑在生物的设计中使用粒子系统。如果将发射器与生物的位置绑定,会发生什么情况?

第六章:5 自主代理
生活是一场旅程,而非目的地。
—拉尔夫·沃尔多·爱默生

莫伊鱼(图片由美国国家海洋和大气管理局提供)
六指丝鳞鱼(Polydactylus sexfilis),也叫“王鱼”,或在夏威夷语中称为mo’i,它们在群体中游动。莫伊鱼在夏威夷王室中享有特殊地位,专门在养殖池中培育,以确保它们的种群增长并防止灭绝。这些鱼在集体运动中展示出精妙而协调的舞蹈,每条莫伊鱼都在微妙地影响并被邻近的鱼所影响。
到目前为止,我一直在展示没有生命的物体、静止的形状,它们在外界力量的作用下摇摆不定。但这就是代码的本质。如果我能为这些形状注入生命呢?如果这些形状能够按照自己的规则生存呢?形状能有希望、梦想和恐惧吗?这些问题是本章的主题。它们是将无意识的物体与更有趣的事物区分开来的关键:自主代理。
来自内部的力量
自主代理是一个能够在其环境中做出自己行动决定的实体,而不受任何领导者或全球计划的影响。在本书中,行动通常指的是移动。例如,我不再模拟一个被重力等力吸引或排斥的粒子,而是希望设计一个具有能力——甚至是“愿望”——做出自己移动决策的实体。它可以决定朝着某个目标移动或远离目标,就像一只飞蛾被火焰吸引,或一条小鱼躲避捕食者一样。
从无生命物体到自主代理的转变是一个重要的概念飞跃,但代码本身几乎没有变化。自主代理想要移动的欲望只是另一种力,就像重力或风力一样。只不过现在这种力是来自内部的。
在我构建本章示例时,有三个自主代理的关键组成部分需要牢记:
-
自主智能体对其环境的感知能力是有限的。 生活和呼吸的生物应该能够意识到其环境的存在是合理的。然而,这种意识不仅仅指外部环境,还包括智能体的内部状态——如位置、速度,甚至可能包括其他属性或模拟的情感。在本章中,我将探讨智能体在做出决策时如何考虑自身状态。我还将介绍一些编程技术,使对象能够存储对其他对象的引用,从而“感知”其周围环境。这里的有限一词很重要。你是在设计一个全知的圆形对象,它可以在画布上飞行,意识到画布中的一切吗?还是你在创建一个只能观察到自己 15 像素范围内其他形状的形状?当然,这个问题没有唯一的正确答案;一切取决于你的需求。在本章中,我将探讨几种可能性,但总的来说,局限性有助于创建一个更“自然”的模拟。例如,一只昆虫可能只会感知到立即围绕它的景象和气味。要模拟一种真实的生物,你可以研究这些局限性的确切科学。幸运的是,我只需凭空想象并进行尝试。
-
自主智能体处理来自环境的信息并计算出一个行动。 这一部分相对简单,因为行动本身就是一种力。环境可能会告诉智能体,有一只巨大的、看起来很可怕的鲨鱼正向它游来,那么它的行动将是朝相反方向施加的强大力量。
-
自主智能体没有领导者。 这个第三条原则根据上下文,我可能不太关心。例如,如果你在设计一个系统,在这个系统中让领导者向各个实体发号施令是合适的,那么你就可以这样实现。然而,本章中的许多示例将没有领导者,这是有重要原因的:在本章的最后,我将研究群体行为,并探讨如何设计表现出复杂系统特性的自主智能体集合。这些是智能的、有结构的群体动态,它们并非来自领导者,而是来自元素之间局部互动的结果。
我可以从许多地方开始探索自主智能体。例如,人工模拟的蚂蚁和白蚁群体是智能体系统的极好示范。关于这个话题,我鼓励你阅读 Mitchel Resnick 的《乌龟、白蚁与交通堵塞》(Bradford Books,1997)。然而,我想从本书前四章的工作基础上出发,首先探讨建立在向量和力的运动建模上的智能体行为。因此,我将回到本书那本不断变化的英雄类——最初是Walker,然后是Mover,再到Particle——并赋予它全新的形式。
车辆与转向
在 1980 年代末,计算机科学家 Craig Reynolds (www.red3d.com/cwr) 为动画角色开发了算法化的 转向 行为。这些行为允许个体元素以生动的方式在其数字环境中导航,拥有逃跑、徘徊、到达、追踪、躲避等策略。后来,在他 1999 年的论文《自主角色的转向行为》中,Reynolds 使用 车辆 一词来描述他的自主代理。我将效仿他,称我的自主代理类为 Vehicle:

和之前的 Mover 类和 Particle 类一样,Vehicle 类的运动是通过其位置、速度和加速度向量来控制的。这将使单个自主代理的转向行为变得易于实现。然而,通过构建一个由多个车辆组成的系统,这些车辆根据简单的局部规则自行转向,会产生惊人的复杂性。最著名的例子是 Reynolds 的鸟群模型,它展示了群体或集群行为,我将在 示例 5.11 中展示。
为什么是车辆?
在他的书《车辆:合成心理学实验》(Bradford Books,1986 年)中,意大利神经科学家和控制论学者 Valentino Braitenberg 描述了一系列假设的车辆,具有简单的内部结构,他写道:“这是一个虚构科学的练习,或者如果你更喜欢,可以称之为科幻小说。”Braitenberg 认为,他那些极其简单的机械车辆展现了诸如恐惧、攻击、爱情、预见和乐观等行为。Reynolds 从 Braitenberg 中汲取了灵感,而我将从 Reynolds 中获得我的灵感。
Reynolds 描述了 理想化 车辆的运动——理想化是因为他不关心它们的实际工程,而是从它们能够正常工作并响应所定义规则的假设出发。这些车辆有三个层次:
-
动作选择: 一辆车有一个目标(或多个目标),并可以根据该目标选择一个动作(或多个动作的组合)。这本质上是我在讨论自主代理时的停顿点。车辆观察环境并基于一种欲望来选择一个动作:“我看到一个僵尸朝我走来。因为我不想让我的大脑被吃掉,我要从僵尸那里逃跑。”目标是保住自己的大脑,动作是逃跑。Reynolds 的论文描述了许多目标和相关的动作,比如寻找目标、避开障碍物和跟踪路径。稍后,我将用 p5.js 代码展开这些示例。
-
转向: 一旦选择了一个动作,车辆就必须计算其下一步行动。下一步将是一个力——更具体地说,是一个转向力。幸运的是,Reynolds 提出了一个简单的转向力公式,我将在本章的例子中使用:转向力 = 期望速度 - 当前速度。我将在下一节详细讨论这个公式以及它为何如此有效。
-
运动方式: 大多数情况下,我将忽略这一第三层。在逃避僵尸的情况下,运动方式可以描述为“左脚,右脚,左脚,右脚,尽可能快。”然而,在画布中,一个矩形、圆形或三角形在窗口中的实际运动并不重要,因为运动本身就是一种幻觉。但这并不是说你应该完全忽视运动方式。你会发现,思考你车辆的运动设计以及如何选择动画呈现它会非常有价值。本章中的示例将保持视觉上的简洁;一个不错的练习是展开动画风格。例如,你能为它添加旋转的轮子、摆动的桨板,或者是走动的腿吗?
最终,最重要的层次是你需要考虑的第一层,动作选择。你的系统元素是什么,它们的目标是什么?在本章中,我将涵盖一系列转向行为(即动作):寻找目标、逃避、跟随路径、跟随流场、与邻居集群等等。然而,正如我在其他章节中所说,重点不是你在所有项目中都使用这些具体的行为。而是,重点是教你如何在代码中建模转向行为——任何转向行为——并为你设计和开发具有新目标和行为的车辆提供基础。
更重要的是,尽管本章中的示例非常直观(跟着那个像素走!),你应该允许自己更加抽象地思考(就像 Braitenberg 一样)。如果你的车辆的目标是“爱”或者其驱动力是“恐惧”,那会意味着什么呢?最后(我将在第 265 页的“结合行为”部分讨论这个问题),仅仅通过开发一个单一动作的模拟,你是走不远的。是的,第一个示例的动作将是寻找目标。但通过发挥创意——通过让这些转向行为成为你自己的——最终的关键是将多个动作结合在同一辆车中。将接下来的示例视为一个更大拼图中的一部分,而不是单一的行为模仿。
转向力
什么是转向力?为了回答这个问题,考虑以下情境:一辆具有当前速度的车辆正在寻找目标。为了趣味,我们可以把这辆车想象成一只像虫子一样的生物,想要品尝一颗美味的草莓,如图 5.1 所示。

图 5.1:具有速度和目标的车辆
该车辆的目标和后续行动是寻找目标。回想一下第二章,你可能会从将目标设为吸引物并应用引力将车辆拉向目标开始。这是一个完全合理的解决方案,但从概念上讲,这并不是我在这里想要的。
我不想简单地计算一个推动车辆向目标移动的力;相反,我希望要求车辆根据它对自身状态(其速度和当前运动的方向)以及环境(目标的位置)的感知做出智能决策,朝向目标行驶。车辆应考虑它希望如何移动(指向目标的向量),将这个目标与当前的运动状态(其速度)进行比较,并相应地施加力。这正是 Reynolds 的转向力公式所表达的:
转向力 = 期望速度 − 当前速度
或者,你可以在 p5.js 中这样写:
let steer = p5.Vector.sub(desired, velocity);
当前速度并不是问题:Vehicle类已经有一个变量来表示这一点。然而,期望速度需要计算。看看图 5.2。如果车辆的目标定义为寻找到目标,那么它的期望速度就是一个从当前位置指向目标位置的向量。

图 5.2:车辆期望的速度从其当前位置指向目标。(期望的向量应该从车辆的中心指向目标的中心,但为了说明简化了长度。)
假设有一个p5.Vector变量叫做target,定义了目标的位置,那么我就有了:
let desired = p5.Vector.sub(target, position);
然而,故事还有更多的内容。如果这是一个高分辨率的画布,而目标距离数千个像素远呢?当然,车辆可能希望以极大的速度瞬间传送到目标位置,但这不会产生有效的动画。我将愿望重新表述如下:
车辆希望以最大可能的速度朝向目标移动。
换句话说,期望向量应该从车辆的当前位置指向目标位置,大小等于车辆的最大速度,如图 5.3 所示。

图 5.3:车辆期望速度的大小是最大速度。
最大速度的概念在第一章中被引入,用以确保移动者的速度保持在合理范围内。然而,在随后的章节中我并未始终使用这一概念。在第二章中,摩擦力和阻力等其他力量限制了速度,而在第三章中,相反的力量导致了振荡,从而保持了速度限制。在本章中,最大速度是控制转向代理行为的关键参数,因此我将在所有示例中包含它。
虽然我鼓励你思考如何将摩擦力和阻力等其他力与转向行为结合起来,但目前我将只关注转向力。因此,我可以将最大速度的概念作为力计算中的限制因素。首先,我需要在Vehicle类中添加一个属性,设置最大速度:

然后,在期望速度的计算中,我将根据最大速度进行缩放:
let desired = p5.Vector.sub(target, this.position);
desired.setMag(this.maxspeed);
综合这些内容,我现在可以编写一个名为seek()的方法,该方法接收一个p5.Vector目标并计算指向该目标的转向力:

请注意,我在方法结束时将转向力传递给applyForce()。这假设代码是建立在我在第二章中开发的基础之上的。
要了解为什么 Reynolds 的转向公式如此有效,看看图 5.4。它展示了转向力相对于车辆和目标位置的表现。

图 5.4:车辆施加的转向力等于期望速度减去当前速度。
这种力与引力的吸引力看起来有很大的不同。记住自主代理的一个原则:自主代理有有限的感知环境的能力,包括对自身状态的感知。这个能力被巧妙而有力地嵌入到 Reynolds 的转向公式中。在引力吸引的情况下,吸引物体的力是相同的,无论物体如何运动。但在这里,车辆是主动感知自己速度的,并且其转向力会做出相应的补偿。这为模拟增加了一种逼真的质量,因为车辆向目标移动的方式依赖于它对当前运动状态的理解。
在这一切兴奋之中,我错过了最后一步。这个车辆是什么类型的?是拥有惊人操控性能的超级跑车吗?还是需要大量预警才能转弯的大型城市公交车?是一只优雅的熊猫,还是一只笨拙的大象?目前的示例代码并没有考虑到这一转向能力的差异。为此,我需要限制转向力的大小。我将这个限制称为最大转向力(或简称maxforce):

现在,我只需要在施加转向力之前设定这个限制:

限制转向力提出了一个重要的观点:目标不是尽可能快地将车辆送到目标位置。如果是这样,我只需要说,“将位置设置为目标”,车辆就会立刻传送到那个位置!相反,正如雷诺兹所说,目标是让车辆以一种“逼真且即兴的方式”移动。
我试图让车辆看起来像是在朝目标方向转向,所以我需要调整系统中的力和变量来模拟给定的行为。例如,较大的最大转向力会导致与较小转向力截然不同的路径(见图 5.5)。这两者并没有哪一个天生更好或更差;它取决于所需的效果。(当然,这些值不必是固定的,可以根据其他条件变化。也许车辆有一个能量属性:能量越高,转向性能越好。)

图 5.5:更强的最大力(左)与较弱的最大力(右)路径对比
这是完整的Vehicle类,包含了来自第二章 Mover类的其余部分。

请注意,与之前章节中用来表示移动器和粒子的圆形不同,Vehicle对象被绘制为一个三角形,定义为使用beginShape()和endShape()设置的三个自定义顶点。这允许车辆以一种能够表示其方向的方式呈现,方向是通过heading()方法确定的,正如在第三章中展示的那样。
练习 5.1
实现一个逃避转向行为(期望的速度与寻求相同,但指向相反的方向)。
练习 5.2
创建一个草图,在其中,车辆的最大力和最大速度不是保持不变的,而是根据环境因素发生变化。
练习 5.3
实现一个带有移动目标的追寻行为,通常称为追逐。在这种情况下,你的目标向量不会指向物体的当前位置,而是指向从其当前速度推算出的未来位置。你将在后续的示例中看到车辆如何“预测未来”。解决方案在《追逐与逃避》视频中有介绍,视频链接为(thecodingtrain.com/pursuit).

到达行为
在使用寻路行为工作了一段时间后,你可能会问自己:“如果我希望车辆在接近目标时减速呢?”在我开始回答这个问题之前,我应该先解释为什么寻路行为会导致车辆飞越目标,迫使它转身回去。考虑一下寻路车辆的大脑。在每一帧动画中,它在想什么?
-
我想尽可能快地朝目标前进。
-
我想尽可能快地朝目标前进。
-
我想尽可能快地朝目标前进。
-
我想尽可能快地朝目标前进。
-
我想尽可能快地朝目标前进。
-
依此类推……
这辆车对到达目标感到非常兴奋,以至于它不去做任何关于速度的智能决策。不管离目标多远,它总是想尽可能快地行驶。当车辆非常接近目标时,它最终会超越目标(见图 5.6,顶部)。

图 5.6:上方的车辆目标速度设为最大速度,并且会超越目标。下方的车辆示范了根据与目标的距离来缩放目标速度。(虽然我鼓励你继续将车辆想象成一只可爱的虫子,但从这一点开始,它被画成三角形,以简化问题。)
在某些情况下,这是期望的行为。(想想小狗去追它最喜欢的玩具:无论离玩具多近,它都不会减速!)然而,在许多其他情况下(如汽车驶入停车位、蜜蜂停在花上),车辆的思维过程需要考虑与目标之间的距离相对的速度(见图 5.6,底部)。例如:
-
我离得很远。我想尽可能快地朝目标前进。
-
我离目标还有一段距离。我仍然希望尽可能快地朝目标前进。
-
我快接近了。我想更慢地朝目标前进。
-
我快到了。我想很慢地朝目标前进。
-
我到了。我想停下来!
如何在代码中实现这种到达行为?回想一下seek()方法。代码中的哪部分设置了目标速度的大小?
let desired = p5.Vector.sub(target, this.position);
desired.setMag(this.maxspeed);
这总是将desired向量的大小设置为maxspeed,如图 5.7 所示。

图 5.7:车辆的目标速度的大小设置为最大速度,无论它们与目标的相对距离如何。
如果相反,目标速度的大小等于距离的一半呢?
let desired = p5.Vector.sub(target, this.position);
desired.mult(0.5);
我仍然希望将desired的大小限制在最大速度之内,以防止那些远距离的车辆行驶得过快(见图 5.8)。

图 5.8:每个车辆的期望速度大小等于到目标的距离的一半。在最左侧的车辆的情况下,速度被限制为最大速度。
尽管这一变化很好地展示了将期望速度与目标距离相联系的目标,但它并不是一个特别好的解决方案。毕竟,10 像素的距离已经相当近,而期望速度为 5 则显得太大。像期望速度的大小等于距离的 5% 这样的方式可能效果更好:
let desired = p5.Vector.sub(target, this.position);
desired.mult(0.05);
Reynolds 描述了一种更为复杂的方法。设想在目标周围画一个半径为 r 的圆。如果车辆在该圆内,它会逐渐减速——从圆的最边缘的最大速度到目标处的零速度(图 5.9)。

图 5.9:在圆外,车辆的期望速度大小设置为最大速度。随着车辆进入圆内并接近目标,其期望速度的大小逐渐减小。
换句话说,如果目标的距离小于r,期望速度的范围从 0 到根据该距离映射的最大速度。

到达行为是一个很好的示范,展示了自主体对环境的感知——包括自身的状态。这个模型不同于第二章中的无生命力:一个天体被另一个天体吸引时并不知道自己正在经历引力,而一只猎豹追逐它的猎物时则知道自己正在追赶。
关键在于力的计算方式。例如,在引力吸引示意图中(示例 2.6),力总是直接从物体指向目标——也就是期望速度的确切方向。与此相反,这里车辆感知到与目标的距离,并根据该距离调整期望速度,随着接近目标而减速。因此,车辆所受的力不仅基于期望速度,还基于相对于当前速度的期望速度。车辆在评估环境时考虑到自身的状态。
换句话说,Reynolds 的 期望速度减去当前速度 方程的神奇之处在于,它本质上将转向力表现为当前速度的误差:“我应该朝这个方向以这个速度前进,但实际上我在朝另一个方向以这个速度前进。我的误差就是我想去的地方和我目前去的地方之间的差距。”有时这会导致看似意外的结果,如图 5.10 所示。

图 5.10:一个朝目标移动的车辆,如果速度快于其期望速度,将导致一个指向远离目标的转向力。
在这个“到达”行为的示例中,车辆朝目标移动的速度过快。转向力或误差告诉车辆通过实际上朝相反方向(远离目标)来减速。相比之下,利用引力吸引时,无论目标多么接近,力都不会指向远离目标的方向。通过将误差应用为转向力,结果是更加动态、逼真的模拟。
你自己的行为
我所讨论的前两个示例——“寻路”和“到达”——归结为为每个行为计算一个单一的矢量:期望速度。事实上,雷诺兹的每一个转向行为都遵循相同的模式。在本章中,我将介绍更多雷诺兹的行为——流场跟随、路径跟随和集群行为。然而,首先,我想再次强调,这些是示例——展示了常见的转向行为,这些行为在程序化动画中非常有用。它们并不是你能做的事情的全部。只要你能够提出一个描述车辆期望速度的矢量,你就创造了自己的转向行为。
例如,看看雷诺兹是如何定义游荡行为的期望速度的:
游荡是一种具有一定长期规律的随机转向:一帧中的转向方向与下一帧中的转向方向相关。这比例如每帧都生成一个随机转向方向更能产生有趣的运动。
对雷诺兹来说,游荡的目标不是随机运动,而是希望车辆先朝一个方向移动一段时间,然后再朝下一个方向游荡一段时间,如此循环。图 5.11 展示了雷诺兹如何计算一个目标来实现这种效果。

图 5.11:游荡转向行为被计算为寻求一个目标,该目标在车辆前方的圆形周围随机移动。
首先,车辆预测其未来位置,距离其当前速度方向固定的距离。然后,它在该位置画一个半径为 r 的圆,并从圆的周长上随机选择一个点。这个点会在每一帧动画中沿着圆周随机移动,它就是车辆的目标,因此它的期望速度指向那个方向。
听起来荒谬,对吧?或者至少有些任意。事实上,这是一种巧妙且深思熟虑的解决方案——它利用随机性来驱动车辆的转向,但将这种随机性限制在一个圆形路径上,以防止车辆的运动看起来颤抖或完全随机。
这个看似随机和任意的解决方案应该强调我想表达的观点:这些是虚构的行为,尽管它们受现实世界运动的启发。你完全可以编造另一个复杂的场景来计算期望速度。而且你应该这样做!
练习 5.4
编写 Reynolds 的漫游行为代码。使用极坐标来计算车辆沿圆形路径的目标。

另一个例子是,假设我想创建一个叫做保持在墙内的转向行为。为了定义期望的速度,我将制定一个规则:如果车辆接近墙壁的距离为d,那么该车辆希望以最大速度朝向墙壁的反方向移动(参见图 5.12)。

图 5.12:如果车辆靠得太近,期望的速度会远离墙壁。
如果我将空间的墙壁定义为画布的边缘,并且将offset距离设置为 25,我可以通过一系列的if语句编写这段代码。

在这个boundaries()方法中,你可能会想知道为什么我一开始将desired速度设置为null。为什么不直接将desired设置为一个零向量呢?记住,转向力等于期望速度减去当前速度!如果车辆希望以 0 速度移动,产生的力将使车辆减速至停止。通过将desired初始化为null,并在应用转向力之前检查它是否为非null,车辆在远离画布边缘时就不会受到任何影响。
练习 5.5
想出你自己任意的方案来计算期望的速度。
流场
Reynolds 的另一个转向行为是流场跟随。但什么是流场呢?将画布看作是一个网格(图 5.13)。在网格的每个单元格里,都有一个指向某个方向的箭头——你知道,就是一个向量。当车辆在画布上移动时,它会问:“嘿,下面的箭头是什么?那就是我期望的速度!”

图 5.13:一个充满指向随机方向的单位向量的二维网格
Reynolds 的流场示例中,车辆会查看其未来位置并跟随该位置的向量。然而,为了简单起见,我将让车辆跟随当前所在位置的向量。
在我编写Vehicle类的附加代码,使其跟随流场之前,我首先需要一个描述流场的类。由于流场本质上是一个向量网格,2D 数组是一个方便的数据结构,可以用来表示流场,因为我可以通过两个索引来引用每个元素,即网格中单元格的列和行。如果你不熟悉 2D 数组,建议你查看我关于“JavaScript 中的 2D 数组”的视频教程(thecodingtrain.com/2d-array)。

我应该如何填充缺失的值呢?假设我有一个宽度为 200 像素,高度为 200 像素的画布。理论上,我可以创建一个流场,每个像素都有一个向量,也就是说,总共有 40,000 个向量(200 × 200)。这个数字并不是特别不合理,但在这个上下文中,每个像素一个向量有些过头了。我完全可以通过每隔 10 个像素设置一个向量来解决问题(20 × 20 = 400)。我的resolution变量设置了每个单元格的像素大小。然后,我可以根据画布的大小除以分辨率来计算列和行的数量:

现在,我已经为流场设置了数据结构,是时候计算流场的向量了。我该怎么做呢?随我怎么想吧!也许我希望流场中的每个向量都指向右侧(图 5.14)。

图 5.14:一个所有向量指向右侧的流场
对此,我可以直接将每个向量设置为(1, 0)。

也许我更喜欢让向量朝随机方向指向(图 5.15)。

图 5.15:一个向量指向随机方向的流场
很简单。只需使用p5.Vector类的random2D()方法为每个向量分配值:

使用 2D Perlin 噪声怎么样呢?(图 5.16)

图 5.16:一个使用 Perlin 噪声计算的流场
只需将每个噪声值映射到从 0 到 2π的角度,然后根据该角度创建一个向量:

现在我有了一些进展。通过使用 Perlin 噪声计算向量的方向是一种很好的方法来模拟各种自然效果,比如不规则的阵风或河流的蜿蜒路径。然而,我要指出的是,这种噪声映射生成了一个偏好向左流动的场。由于 Perlin 噪声具有类似高斯分布的特性,靠近π的角度更容易被选择。对于图 5.16,我使用了 0 到 4π的范围来抵消这种倾向,类似于我在第四章中应用 4π来表示旋转纸屑粒子的角度范围。当然,最终并没有一种计算流场向量的正确方法;这取决于你希望模拟的内容。
练习 5.6
编写代码以计算流场,使得向量围绕画布中心旋转。

let x = i * width / cols;
let y = j * height / rows;
flowfield[i][j] = createVector(width / 2 - x, height / 2 - y);
flowfield[i][j].rotate(PI / 2);
现在我有一个存储流场向量的二维数组,我需要一种方法让车辆查找它所需的速度。为此,我简单地将车辆的 x 和 y 坐标除以网格的分辨率。这将给我二维数组中所需向量的索引。例如,如果分辨率为 10,车辆位于(100, 50)位置,我想查找第 10 列和第 5 行:
let column = floor(this.position.x / this.resolution);
let row = floor(this.position.y / this.resolution);
因为车辆理论上可能会偏离 p5.js 画布,使用constrain()函数有助于确保我不会在流场数组的边界之外查找。下面是一个名为lookup()的方法,我将把它添加到FlowField类中,该方法接收一个向量(车辆的位置)并返回该位置对应的流场向量:

在继续讨论Vehicle类之前,让我们将FlowField类的代码全部放在一起,这次使用 Perlin 噪声来计算向量的方向:

现在假设有一个名为flow的FlowField对象。通过该对象的lookup()方法,车辆可以从流场中检索所需的速度,并使用雷诺兹的转向公式计算一个力。

请注意,lookup()是FlowField类的方法,而不是Vehicle类的方法。虽然你完全可以将lookup()放到Vehicle类中,但从我的角度来看,将它放在FlowField中最符合面向对象编程的封装原则。查找任务是基于流场中的位置检索向量的,它本质上与FlowField对象的数据密切相关。
你也许会注意到一些来自第四章的熟悉元素,比如使用车辆数组。尽管这里的车辆独立运行,但这是朝着思考群体行为迈出的重要第一步,这些群体行为我将在本章稍后介绍。
练习 5.7
修改流场示例,使向量随时间变化。(提示:试着使用 Perlin 噪声的第三维!)
练习 5.8
你能从一张图像中创建一个流场吗?例如,试着让向量从暗色到亮色(或反之)指向。
路径跟随
接下来我想探讨的 Reynolds 提出的另一个引导行为是路径跟随。但让我先澄清一件事:这里的行为是路径 跟随,而不是路径 寻找。路径寻找指的是一种算法,用于解决两点之间最短距离的问题,通常在迷宫中使用。而 路径跟随,则是指预定义的路线或路径已经存在,车辆只需要尝试跟随它。
在这一部分,我将讲解算法,包括相关的数学和代码。然而,在此之前,重要的是要介绍一个我在第一章中跳过的向量数学关键概念:点积。到目前为止我还没有用到它,但在这里它是必要的,并且很可能不仅在这个示例中对你非常有用。
点积
还记得第一章中讲过的所有向量数学吗?加法、减法、乘法和除法?图 5.17 回顾了其中的一些操作。

图 5.17:向量相加和将向量与标量相乘
注意,乘法涉及将向量与标量值相乘。这是有道理的;当你想让一个向量变成原来两倍大(但方向不变)时,将它乘以 2;当你想让它变为原来的一半大小时,将它乘以 0.5。然而,还有一些其他类似乘法的操作,涉及一对向量,在某些情况下非常有用——点积、叉积和一种叫做 Hadamard 乘积的运算。目前,我将专注于点积。
假设有向量
和
:

点积的公式(用 · 符号表示)如下:

至关重要的是,点积的结果是一个标量值(一个单一的数字),而不是一个向量,即使输入的是两个向量。例如,假设你有这两个向量:

它们的点积如图所示:

在 p5.js 中,这转换为以下内容:

如果你查看 p5.Vector 源代码的核心部分,你会发现一个相当简单的 dot() 方法实现:

这个公式足够简单,但点积为什么是必要的,什么时候在编码中有用呢?好吧,点积的一个常见用途是找出两个向量之间的角度。事实上,点积也可以表达为如下形式:

换句话说,
和
的点积等于
的大小乘以
的大小,再乘以 theta 的余弦值(其中 theta 是两个向量
和
之间的角度)。
这两个点积公式可以通过三角学(mathworld.wolfram.com/DotProduct.html)相互推导出来,但我很高兴不走这条路,而是基于以下假设进行操作:

这样是可行的,因为方程的两边都等于
。这个假设对我有什么帮助呢?假设我有两个向量
和
:

在这种情况下,我知道向量的分量,但不知道它们之间的角度θ(见图 5.18)。使用点积公式,我可以解出θ的余弦值:


图 5.18:两个向量
和
之间的角度
为了解出θ,我可以对方程右侧取反余弦,或者说反余弦(p5.js 中的acos):

现在我用实际的数字来做一下数学计算:

这是 p5.js 的版本:
let a = createVector(10, 2);
let b = createVector(4, -3);
let angle = acos(a.dot(b) / (a.mag() * b.mag()));
结果证明,如果你再次深入查看 p5.js 的源代码,你会找到一个叫做angleBetween的方法,它实现了这个精确的算法。
angleBetween(v) {
let dot = this.dot(v);
let angle = Math.acos(dot / (this.mag() * v.mag()));
return angle;
}
当然,我本可以一开始就告诉你这个angleBetween()方法,但详细理解点积会更好地为你接下来的路径跟踪示例做准备,并帮助你理解点积如何融入一个叫做标量投影的概念中。
练习 5.9
创建一个草图,展示两个向量之间的角度。

关于点积有几点需要注意:
-
如果两个向量(
和
)是正交的(即垂直的),它们的点积(
)等于 0。 -
如果两个向量是单位向量,它们的点积等于它们之间夹角的余弦值。换句话说,
,如果
和
的长度都是 1。
现在我已经讲解了点积的基础知识,可以回到雷诺兹的路径跟踪算法。
简单的路径跟踪
图 5.19 描述了路径跟踪行为的所有组成部分。这里有很多组件不仅仅是车辆和目标,因此请花些时间查看完整的图表。然后,我将慢慢地逐步解释算法。

图 5.19:路径跟踪需要路径、车辆、未来位置、法线和目标。
首先,路径是什么意思?可以使用多种技术来实现路径,但一种简单的方法是将路径定义为一系列连接的点,如图 5.20 所示。

图 5.20:路径是由连接点组成的序列。
这条路径的最简单版本将是两点之间的直线(图 5.21)。

图 5.21:一条具有起点、终点和半径的路径
我还将考虑路径具有半径。如果路径是一条道路,半径就是道路的宽度。半径越小,车辆必须更紧密地跟随路径;较大的半径则允许它们稍微偏离路径的两侧。
现在我将把它放入一个类中。

现在,假设一辆车在路径的半径之外,以一定速度行驶,如图 5.22 所示。

图 5.22:添加一辆偏离路径并远离路径行驶的车辆
第一步是预测(假设恒定速度)那个车辆未来将会在哪里:

一旦我得到那个位置,就该确定从预测位置到路径的距离。如果它远离路径,说明车辆已经偏离,需要重新调整方向。如果车辆在路径上,一切正常,车辆可以继续行驶。
本质上,我需要计算一个点(未来位置)和一条线(路径)之间的距离。这个距离定义为法线的长度,法线是一个从点到线并垂直于线的向量(图 5.23)。

图 5.23:法线是一个从未来位置到路径并垂直于路径的向量。
如何找到法线?首先,我可以定义一个向量(称之为
),它从路径的起点延伸到车辆的未来位置:
let a = p5.Vector.sub(future, path.start);
接下来,我可以定义一个向量(称之为
),它指向路径的起点到终点:
let b = p5.Vector.sub(path.end, path.start);
现在,通过一点三角学(cah 在 sohcahtoa 中),我可以计算从路径的起点到法线点的距离。如图 5.24 所示,距离是
。

图 5.24:从路径起点到法线的距离是
。
如果我只知道θ,我可以通过下面显示的代码找到那个法线点。

幸运的是,如果点积教会了我什么,那就是给定两个向量,我可以计算这两个向量之间的角度!

虽然这段代码能工作,但我可以再做一个简化。再次查看,你会看到向量
的大小被设置为a.mag() * cos(theta),这是下面代码的翻译:

并且,回想一下这个:

现在,假设
是长度为 1 的单位向量?那么你将得到以下结果:

或者,更简单地:

当
是单位向量时,
等于
和
的点积。将b转换为单位向量只需调用normalize()。因此,我可以绕过使用angleBetween()计算theta,并将代码简化如下:

根据法线点缩放
的过程通常被称为标量投影。我们说
是
在
上的标量投影,如图 5.25 所示。

图 5.25:
在
上的标量投影等于
。
一旦我沿着路径找到了法线点,下一步就是决定车辆是否应该朝路径方向转向以及如何转向。雷诺兹算法指出,只有当车辆有可能偏离路径时——即法线点与预测的未来位置之间的距离大于路径的半径时——车辆才应朝路径转向。如图 5.26 所示。

图 5.26:一辆在路径上的未来位置的车辆(上图)和一辆在路径外的车辆(下图)
我可以通过一个简单的if语句编码这个逻辑,并在必要时使用我之前的seek()方法来引导车辆。

但路径跟随者寻找到的目标是什么呢?Reynolds 的算法包括在路径的法线前方选择一个点。由于我知道定义路径的向量 (
),我可以通过将指向
方向的向量添加到表示法线点的向量,来实现这个前方点,如图 5.27 所示。

图 5.27:目标点在路径的法线点前方 25 像素(一个任意选择的值)。
我随便说目标点应该在法线前方 25 像素:

把这一切整合起来,下面是Vehicle类中的路径跟随方法。

请注意,和以前使用所有点积和标量投影代码来找到法线点不同,我现在调用了getNormalPoint()函数。在这种情况下,将执行特定任务(如寻找法线点)的代码拆分成一个函数,并在需要时调用是很有用的。这个函数接受三个向量参数(见图 5.28):第一个定义了一个点p在笛卡尔空间中的位置(即车辆的未来位置),第二和第三个则定义了两个点a和b之间的线段(即路径)。

图 5.28:getNormalPoint()函数的元素:position、a和b

到目前为止我有什么?我有一个Path类,它将路径定义为两点之间的线。我还有一个Vehicle类,里面有一个方法可以沿路径行驶(通过转向来寻找沿路径的目标)。总的来说,这是一个不错的示例,但它仍然有些局限。缺少了什么?
深呼吸一下,你快完成了。
多个线段的路径跟随
如果我想让一辆车跟随比单一直线更复杂的路径呢?也许是一条曲线路径,沿着多个方向移动,像图 5.29 中那样?

图 5.29:更复杂的路径
也许我有点太雄心勃勃了。我可以研究跟随曲线路径的算法,但如果我坚持使用直线段,比如图 5.30 中的那些,我可能更不容易需要在额头上敷上冰袋。即使我仍然画出路径作为曲线,但最好在后台用简化的几何形状来近似它,以进行必要的计算。

图 5.30:相同的曲线路径,但近似为连接的线段
如果我让路径跟随只在一段线段上工作,那我怎么才能让它在一系列连接的线段上工作呢?关键在于我如何沿路径找到目标点。
为了仅通过一条线段找到目标,我需要计算该线段的法线。现在我有了一系列的线段,我也有了一系列需要计算的法线点——每个线段一个(见图 5.31)。车辆该选择哪一个呢?Reynolds 提出的解决方案是选择(a)最近的法线点,并且(b)在路径上的那个点。

图 5.31:在一系列连接的线段中找到最近的法线点
如果你有一个点和一条无限长的直线,你总能找到一个与直线相交的法线点。但如果你有一个点和一条有限的线段,你不一定能找到一个位于线段上的法线。如果在任何线段上发生这种情况,我可以排除这些法线。一旦只剩下那些位于路径上的法线(在图 5.31 中只有两个),我选择最短的那个。
为了编写这段代码,我将扩展Path类,使其拥有一个点的数组(而不仅仅是起点和终点)。

现在Path类已经更新,轮到车辆学习如何适应多个线段了。它之前只是在一条线上找到法线。通过使用循环,它可以为所有线段找到法线:

下一步是测试法线点是否确实位于点a和b之间。由于在这个示例中我知道路径是从左到右的,所以我可以测试normalPoint的x分量是否超出了a和b的x分量范围。

如果法线点不在线段内,我会假设该线段的终点就是法线。(你也可以尝试起始点,具体取决于你的路径特点。)这将确保车辆始终保持在路径上,即使它偏离了线段的边界。
练习 5.10
一种更通用的方式来测试法线点是否位于线段上,是将normalPoint与a和b之间的距离相加。如果结果大于线段的长度,则法线点在该线段之外。你能用 p5.js 编写这个算法吗?
最后,我需要找到离车辆最近的法线点。为此,我可以从一个非常高的“世界纪录”距离开始,逐个检查每个法线点,看它是否打破(小于)纪录。每当一个法线点打破纪录时,世界纪录会被更新,获胜的点会存储在名为target的变量中。在循环结束时,target将保存离车辆最近的法线点。

你可能已经注意到,Infinity 被用来初始化 worldRecord。在 JavaScript 中,Infinity 是一个特殊的数值,表示无限大。之所以这样做,是因为我需要一个起始值,这个值始终大于代码中计算出的任何可能的距离。第一个计算出的距离将总是设定新的世界纪录,所有其他的距离都会以此为基准进行比较。
我还想强调 25 的硬编码值,它设置了目标路径上正常位置前方的距离。Reynolds 指出,这个值应该是动态的,并且基于车辆到路径的距离和速度进行计算。试试看这个改动,看看它是如何改善路径跟随行为的准确性或响应性的!
练习 5.11
创建一个随着时间变化的路径。定义路径的各个点能否拥有各自的引导行为?
复杂系统
我曾说过,本章的目的是为在 p5.js 画布上移动的事物赋予生命。通过学习编写自主代理的代码并玩弄该代理的个体行为示例,你已经取得了很大的进展。但这还不是结束。是的,车辆是一个模拟存在,它会做出如何寻求、流动和跟随的决策。但一个独自生活的生命,能没有他人的爱与支持吗?
因此,作为一个逻辑上的下一步,我将把我在开发单个自主代理行为方面所做的工作,应用到涉及 多个 自主代理并行操作的仿真中——这些代理不仅能感知它们的物理环境,还能感知其他代理的行为,并相应地做出反应。换句话说,我想用 p5.js 创建复杂系统。
复杂系统 通常被定义为一个整体大于其部分之和的系统。虽然系统中的单个元素可能非常简单且易于理解,但作为一个整体,系统的行为可以是高度复杂的、智能的,且难以预测。
比如,想象一只微小的、爬行的蚂蚁——一只蚂蚁。蚂蚁是一个自主代理;它能感知环境(通过触角收集关于化学信号的方向和强度的信息),并根据这些信号做出如何移动的决策。但单独一只蚂蚁能建造巢穴、收集食物或保护它的女王吗?蚂蚁是一个只能感知其周围环境的简单单元。然而,一群蚂蚁(蚁群)是一个复杂的系统,是由多个组件组成的超有机体,这些组件协同工作以完成困难且复杂的目标。
以下是三个将指导我处理复杂系统的关键原则:
-
简单的单元具有短程关系。 这正是我一直在构建的内容:具有有限环境感知能力的车辆。
-
简单单元并行工作。 每次通过
draw()循环时,每个单元都会计算自己的驱动力。这将产生所有单元并行工作的效果。 -
系统整体表现出涌现现象。 复杂的行为、模式和智能可以通过简单单元之间的相互作用涌现出来。这一现象在自然界中普遍存在,例如蚁群、迁徙模式、地震和雪花。问题是,是否能够在 p5.js 草图中实现相同的结果。
除了这些核心原则外,复杂系统的三个额外特性将帮助框定讨论,并为软件模拟中应包括的功能提供指导。需要注意的是,这是一组模糊的特征,并非所有复杂系统都具备这些特性:
-
非线性: 复杂系统的这个特性通常被随意称为蝴蝶效应,该术语由数学家和气象学家爱德华·诺顿·洛伦兹(Edward Norton Lorenz)提出,洛伦兹是混沌理论研究的先驱。1961 年,洛伦兹第二次运行计算机天气模拟时,可能为了节省一点时间,他输入了 0.506 作为初始值,而不是 0.506127。最终结果与第一次模拟的结果完全不同。更具表现力地说,这一理论认为,在世界的另一端,一只蝴蝶扇动翅膀,可能引起巨大的天气变化,最终影响到你在海滩度过的周末。它被称为非线性,因为初始条件的变化与结果之间没有线性关系。初始条件的微小变化可能会对结果产生巨大的影响。非线性系统是混沌系统的超集。在第七章中,你将看到即便是在一个由许多 0 和 1 组成的系统中,只要改变一个比特,结果也会完全不同。
-
竞争与合作: 使复杂系统运转的一个重要因素是元素之间同时存在竞争和合作。即将实现的群体行为系统将有三个规则:对齐、凝聚和分离。对齐和凝聚将要求元素“合作”,尽量保持在一起并共同移动。然而,分离将要求元素“竞争”空间。当这个时刻来临时,可以尝试去除合作或竞争中的任何一个,你会看到系统失去了其复杂性。竞争与合作共同存在于生物的复杂系统中,但在非生物复杂系统(如天气)中并不存在。
-
反馈: 复杂系统通常包括一个反馈循环,将系统的输出反馈到系统中,以积极或消极的方向影响其行为。假设你决定每天乘坐公共交通工具上班,因为它是最可靠且成本效益最好的解决方案,而你又对交通拥堵和驾车的环境影响感到厌烦。你并不孤单,其他人也选择了公共交通。这个系统变得更高效、更具吸引力,能够用相同的资源服务更多的人,同时减少了车辆交通。然而,随着时间的推移,系统可能会难以适应日益增长的需求,导致过度拥挤、延误,以及提高票价以资助基础设施改善。因此,你和其他人开始重新选择开车,从而再次增加交通拥堵,减少公共交通的效率。随着交通状况的恶化,票价上涨的资金(希望)被用来改善公共交通基础设施,使其再次变得更具吸引力。通过这种方式,公共交通的成本和效率既是系统的输入(决定你是否选择使用它),也是输出(交通拥堵的程度以及随之而来的成本和效率)。经济模型只是人类复杂系统的一个例子,其他例子包括时尚潮流、选举、群众行为和交通流动。
复杂性将作为本书剩余部分的一个关键主题。在这一部分,我将开始为Vehicle类引入一个新特性:感知邻近车辆的能力。这个增强功能将为一个复杂系统的终极示例铺平道路,在这个系统中,简单的个体行为相互作用,产生了一个涌现行为:群体飞行。
实现群体行为(或者:我们不要互相撞上)
管理一组对象无疑不是一个新概念。你之前见过这个概念——在第四章中,我开发了Emitter类来表示一个整体粒子系统。在那里,我使用数组来存储单个粒子的列表。在这里,我也会用相同的技术,并将Vehicle对象存储在数组中:

现在,当需要在draw()中操作所有车辆时,我可以遍历数组并调用必要的方法:
function draw() {
for (let vehicle of vehicles) {
vehicle.update();
vehicle.show();
}
}
也许我想加入一个行为,即应用于所有车辆的力。这可能是寻求鼠标的位置:
vehicle.seek(mouseX, mouseY);
但那是个体行为,而我已经在本章的大部分内容中讨论了个体行为。你之所以在这里,是因为你想应用群体行为。我将从分离行为开始,这种行为的命令是:“避免与邻居发生碰撞!”
vehicle.separate();
看起来不错,但还不完全正确。缺少了什么呢?在seek()的情况下,我说,“寻求mouseX和mouseY。”在separate()的情况下,我说,“与其他所有人分开。”谁是其他所有人?就是所有其他车辆的列表:
vehicle.separate(vehicles);
这是超越之前粒子系统的重大进展。与每个元素(粒子或车辆)独立操作不同,我现在说,“嘿,你,那辆车!当你该操作的时候,你需要在意识到其他所有人的情况下操作。所以我将把所有其他人的列表传给你。”
把到目前为止做的整理一下,这里是展示群体行为的草图的setup()和draw()函数:


图 5.32:分离所需的速度(相当于逃离)是一个指向目标相反方向的向量。
当然,这只是开始。真正的工作发生在separate()方法内部。Reynolds 将分离行为定义为“调整方向以避免拥挤”。换句话说,如果某辆车离你太近,向那辆车的反方向转向。听起来熟悉吗?记得寻求行为吗,车辆向目标驶去?把那个力反转,你就得到了逃离行为,这就是在这里实现分离所需要的行为(见图 5.32)。
但是如果有多个车辆太近呢?在这种情况下,我会将分离定义为所有指向远离任何近距离车辆的向量的平均值(图 5.33)。

图 5.33:分离所需的速度是多个逃离速度的平均值。
我如何把它转化为代码呢?记住,我正在写一个叫做separate()的方法,它接收一个Vehicle对象数组作为参数:
separate(vehicles) {
}
在这个方法中,我将循环遍历所有车辆,看看是否有任何车辆离得太近:

请注意,我不仅在检查距离是否小于所需的分离距离,还在检查this是否不等于other。这是一个关键要素。记住,所有的车辆都在数组中;如果没有这个额外的检查,车辆将试图从自己身边逃离!
如果车辆太近,我会计算一个指向远离干扰车辆的向量:

这还不够。我现在有了一个逃离向量,但我真正需要的是所有离得太近的车辆的逃离向量的平均值。我该如何计算平均值呢?将所有向量加起来,然后除以总数:

一旦我获得了平均向量(存储在变量sum中),这个向量可以被缩放到最大速度,成为所需的速度——车辆希望以最大速度朝那个方向移动!(实际上,我真的不需要再除以count,因为大小是手动设置的。)一旦我得到所需的速度,就进入了典型的 Reynolds 模型——转向等于所需速度减去当前速度:

以下示例展示了方法的完整实现。

separate()方法包含了两个额外的改进。首先,所需的分离距离现在依赖于车辆的大小,而不是一个任意常数。这样,分离行为会根据车辆的个体特征动态适应。其次,指向邻近车辆的向量的大小被设置为与距离成反比。这意味着邻居越近,车辆越想逃离,反之亦然。
练习 5.12
创建一个cohere()方法,它遵循与separate()相反的逻辑:如果一辆车与其他车的距离超过某个值,则向该车方向转向。这将保持群体的整体性。(稍后,我将查看当凝聚力和分离力同时在同一模拟中发挥作用时会发生什么。)
练习 5.13
将分离力添加到路径跟随中,创建一个模拟 Reynolds 群体路径跟随的效果。

结合行为
最令人兴奋和有趣的群体行为来自于混合和匹配多种转向力。毕竟,如何通过只有一个规则的草图来模拟一个复杂系统中的涌现现象呢?
当多个转向力同时作用时,我需要一种机制来管理它们。你可能会想:“这没什么新鲜的。我们一直在处理多个力。”你说得对。事实上,这种技术早在第二章中就出现过:
let wind = createVector(0.001, 0);
let gravity = createVector(0, 0.1);
mover.applyForce(wind);
mover.applyForce(gravity);
在这里,一个Mover对象响应两种力。之所以能顺利工作,是因为Mover类的设计使得力向量能够汇聚到它的加速度向量中。然而,在本章中,这些力来源于mover(现在称为车辆)的内部需求。这些需求可以赋予不同的权重,使某些需求比其他需求更具影响力。例如,考虑一个草图,其中所有车辆都有两个需求:
-
寻找老鼠的位置。
-
与任何过于接近的车辆保持分离。
想象这些车辆代表着一群鱼。尽管鱼类希望避免彼此碰撞,但它们的首要任务是寻找食物来源(例如老鼠)。能够调整两种转向力的权重对实现这一效果至关重要。
首先,我将在Vehicle类中添加一个名为applyBehaviors()的方法来管理所有行为:
applyBehaviors(vehicles) {
this.separate(vehicles);
this.seek(createVector(mouseX, mouseY));
}
在这里,单一的方法负责调用其他应用力的方法——separate() 和 seek()。我本可以开始调整这些方法中计算的力的强度,但不如让这些方法简单地计算并返回这些力。然后,我可以在 applyBehaviors() 中调整这些力的强度,并将它们应用于车辆的加速度:

这就是新方法如何改变 seek() 方法的方式:

这个变化微妙但极其重要:它允许在一个地方对这些力的强度进行加权。

在这段代码中,我使用 mult() 来调整各个力的大小。通过将每个力向量乘以一个系数,它的大小会相应地缩放。这些系数(在这种情况下,separate 的系数是 1.5,seek 的系数是 0.5)代表了分配给每个力的权重。然而,权重并不一定是常数。你可以考虑它们如何根据环境条件或车辆的特性动态变化。例如,当车辆探测到附近有食物时,seek 的权重可能会增加(可以将车辆想象成一个拥有 hunger 属性的生物),或者当车辆进入拥挤区域时,separate 的权重可能会变大。这种动态调整权重的灵活性允许出现更加复杂和微妙的行为。
练习 5.14
修改 示例 5.10,使得行为权重随时间变化。例如,假设权重是根据正弦波或 Perlin 噪声计算的呢?或者假设某些车辆更关注寻求行为,而其他车辆则更关注分离行为?你能引入其他转向行为吗?
集群行为
集群行为是许多生物中都能观察到的一种群体行为,如鸟类、鱼类和昆虫等。1986 年,Reynolds 创建了一个关于集群行为的计算机模拟,并在他的论文《群体、兽群和鱼群:一种分布式行为模型》中详细记录了这个算法。在 p5.js 中重新创建这个模拟将结合本章中的所有概念:
-
我将使用转向力公式(steer = desired – velocity)来实现集群行为的规则。
-
这些转向力将形成群体行为,需要每个车辆感知到所有其他车辆。
-
我将结合并加权多个力。
-
结果将是一个复杂的系统——智能群体行为将从简单的集群规则中涌现出来,而无需集中系统或领导者的存在。
好消息是,我已经在本章中演示了第 1 到第 3 项内容,所以这一部分只需要将这些内容整合起来并查看结果。
在开始之前,我应该提一下,我将再次更改Vehicle类的名称。Reynolds 使用了boid这个术语(一个虚构的词,指代类似鸟的物体)来描述鸟群系统中的元素。我也将采用这个术语。
鸟群聚集有三条规则:
-
分离(又称避让):引导避免与邻居发生碰撞。
-
对齐(又称复制):朝着与邻居相同的方向引导。
-
凝聚(又称中心):朝着邻居的中心引导(保持与群体在一起)。
图 5.34 说明了这些规则。

图 5.34:鸟群聚集的三条规则:分离、对齐和凝聚。示例车辆和期望速度为粗体。
就像在示例 5.10 中,我将分离和寻求结合在一起一样,我希望Boid对象有一个方法来管理这三种行为。我将其命名为flock():

现在,问题只是如何实现这三条规则。我已经应用了分离规则;它与前面的示例相同。接下来,我将重点关注对齐,或者说是与相邻的鸟群保持相同的方向。和其他所有的引导行为一样,我需要将这个概念表达为一种愿望:鸟群的期望速度是其邻居的平均速度。因此,算法就是计算所有其他鸟群的平均速度,并将其设置为期望速度:

这已经相当不错,但缺少一个非常关键的细节。复杂系统(如鸟群聚集)的一个关键原理是,系统中的元素(在这里是鸟群)具有短程关系。再想想蚂蚁,想象蚂蚁能够感知它的直接环境是容易的,但要想象一只蚂蚁能够知道几百英尺外另一只蚂蚁的行为就不那么容易了。实际上,正是蚂蚁通过这些邻近关系展现出复杂的集体行为的能力,才使得它们如此令人兴奋。
在align()方法中,我目前正在计算所有鸟群的平均速度,而我实际上应该只考虑某个特定距离内的鸟群(见图 5.35)。当然,这个距离阈值是可以变化的。你可以设计只看到 20 像素远的鸟群,也可以设计看到 100 像素远的鸟群。

图 5.35:示例车辆(粗体)只与其邻域内的车辆互动(圆圈内)。
在实现分离时,我已经应用了类似的逻辑,只计算一定距离内其他车辆的作用力。现在,我希望对对齐做同样的处理(最终还包括凝聚):

与separate()方法一样,我加入了条件this !== other,以确保鸟群在计算平均速度时不会考虑自己。虽然不加这个条件可能也能工作,但如果每只鸟群不断受到自身速度的影响,可能会导致一个反馈循环,从而破坏整体行为。
练习 5.15
你能重新编写align()方法,使得鸟群只会看到那些在直接视线范围内的其他鸟群吗?

凝聚的代码与对齐的代码非常相似。唯一的区别是,我不再计算鸟群邻居的平均速度,而是计算鸟群邻居的平均位置(并将其作为目标进行寻求)。

你还可以花些时间编写一个名为Flock的类来管理整个鸟群。它将与第四章中的ParticleSystem类几乎完全相同,唯一的微小变化是:当我对每个Boid对象调用run()时(就像对每个Particle对象做的一样),我将传入整个鸟群数组的引用:

剩下的就是在setup()中初始化鸟群,并在draw()中运行它。

就像第四章中的粒子系统一样,你可以看到面向对象编程(OOP)在简化setup()和draw()函数方面的优雅。
练习 5.16
将鸟群行为与其他控制行为结合起来。
练习 5.17
在他的书《自然的计算美》(Bradford Books,2000 年)中,Gary Flake 描述了鸟群行为的第四条规则,视野:“从任何挡住视线的鸟群那里横向移动。”让你的鸟群遵循这一规则。

练习 5.18
创建一个鸟群模拟,其中所有参数(分离权重、凝聚权重、对齐权重、最大力、最大速度)随时间变化。它们可以通过 Perlin 噪声或用户交互来控制。(例如,你可以使用 p5.js 的createSlider()函数将这些值与可以实时调整的滑块位置绑定。)
练习 5.19
以完全不同的方式可视化鸟群。
算法效率(或:为什么我的草图运行这么慢?)
群体行为非常有趣,但我必须带着沉重的心情承认,它们也可能很慢。事实上,群体越大,草图的运行速度就越慢。我很想隐藏这个黑暗的真相,因为我希望你能快乐,过上充实而有意义的生活,不必为代码的效率而烦恼。但我也希望能在晚上安心入睡,而不是担心当你尝试运行包含太多 boid 的集群模拟时,你会感到无法避免的失望。
通常,当我谈到 p5.js 草图运行缓慢时,原因是绘制到画布可能会很慢——你绘制的越多,草图运行就越慢。正如你可能从第四章回忆到的那样,切换到 WebGL 这样的不同渲染器有时可以缓解这个问题,从而允许更快速地绘制更大的粒子系统。然而,对于像集群模拟这样的东西,慢速的根本原因来自于算法。计算机科学家用一种叫做大 Ο 记法的术语来描述这个问题,其中的O代表“阶”。这是描述算法效率的简写:算法完成所需的计算周期数是多少?
考虑一个简单的搜索问题。你有一个装有 100 个巧克力糖果的篮子,只有一个是纯黑巧克力。那是你想吃的。为了找到它,你一颗颗挑出巧克力糖果。你可能很幸运,一开始就找到它,但在最坏的情况下,你必须检查所有 100 个糖果才能找到黑巧克力。在 100 个物品中找到一个,你必须检查 100 个物品(或者在N个物品中找到一个,你必须检查N次)。这里的大 O 记法是 O(N)。顺便提一下,这也是描述一个简单粒子系统的大 O 记法。如果你有N个粒子,你就必须运行并显示这些粒子N次。
现在,让我们思考一种群体行为,比如集群。对于每一个Boid对象,你需要在计算其转向力之前,检查每一个其他Boid对象的速度和位置。假设你有 100 只 boid。对于 boid 1,你需要检查 100 只 boid;对于 boid 2,你需要检查 100 只 boid;以此类推。总之,对于 100 只 boid,你需要进行 10,000 次检查(100 × 100 = 10,000)。
你可能会想,“没问题,计算机很快。它们可以很轻松地处理 10,000 个任务。”但如果有 1,000 只 boid 呢?那你就得这样处理:
1,000 × 1,000 = 1,000,000 次循环
这开始变得相当慢,但仍然可以勉强应付。那么,10,000 个元素呢?
10,000 × 10,000 = 100,000,000 次循环
现在事情变得真的很慢,真的,真的,非常慢。
注意到一个规律了吗?随着元素数量增加 10 倍,所需的循环次数增加 100 倍。更广泛地说,当元素数量增加N倍时,循环次数增加N × N,即N²。在大 O 记法中,这被称为 O(N²)。
也许你在想,“没问题,使用群集时,我只需要考虑靠近当前个体的其他个体。所以即使我有 1000 个个体,我也可以只看每个个体的 5 个最近邻,这样我总共就只需要 5,000 个周期。”你停顿了一下,开始思考,“所以对于每个个体,我只需要检查所有个体,找出 5 个最近的就好了!”看到了问题所在了吗?即使你只想查看最近的个体,唯一知道哪些是最近的方式就是检查所有的个体。
或者有没有其他方法?
空间划分
在他 2000 年的论文《与自主角色群体的互动》(* www.red3d.com/cwr/papers/2000/pip.pdf )中,Reynolds(意料之中)提出了一种被称为bin-lattice 空间划分(通常简称为binning*)的技术,用于优化群集算法和其他群体行为。该技术的关键是将仿真空间划分为一个更小单元(或称“箱子”)的网格。
为了演示,假设画布被划分为 10 行 10 列的网格,总共有 100 个单元格(10 × 10 = 100)。假设你有 2,000 个个体——这个数量足够小,以便你实际希望它,但又足够大,以致运行得太慢(2,000 × 2,000 = 4,000,000 个周期)。在任何给定时刻,每个个体都会落在网格中的一个单元格内,如图 5.36 所示。对于 2,000 个个体和 100 个单元格,平均每个单元格大约会有 20 个个体(2,000 ÷ 100 = 20)。

图 5.36:一个充满车辆的方形画布,划分为一个方形单元格的网格
现在假设,为了将群集规则应用到给定的个体,你只需要查看该个体所在单元格中的其他个体。每个单元格平均包含 20 个个体,那么每个单元格将需要 400 个周期(20 × 20 = 400),而有 100 个单元格时,总共需要 40,000 个周期(400 × 100 = 40,000)。这将节省超过 4,000,000 个周期!
为了在 p5.js 中实现 bin-lattice 空间划分算法,我需要多个数组。第一个数组用于跟踪所有个体,就像原始的群集示例一样:
let boids = [];
第二个是一个二维数组(重新使用示例 5.4 中的代码),表示网格中的单元格:

该二维数组中的每个值本身是一个数组,里面包含当前在该单元格内的Boid对象的引用。如果你在记录的话,这就是一个嵌套数组:数组中的数组中的数组:

每次通过draw()时,每个网格单元的数组都会首先被清除。然后,每个个体根据其位置将自己注册到适当的单元格中。这样,随着个体的移动,其所在单元格的分配会被更新:

最后,当需要让群体检查它们的邻居时,它们只需查看自己所在单元格中的群体。

我这里只介绍了 bin-lattice 算法的基础知识。在实际应用中,每个群体还应该检查相邻单元格(上、下、左、右以及对角线方向)中的群体,以及它自己单元格中的群体。(如果想了解如何实现这一点,可以查看本书网站上的完整代码。)然而,即使增加了这种额外的检查,算法仍然比检查每一个群体更高效。
然而,这种方法仍然存在缺陷。例如,如果所有的群体聚集在角落并生活在同一个单元格中呢?这不就意味着我又回到了检查所有 2,000 个群体与所有 2,000 个群体吗?事实上,bin-lattice 空间细分在元素均匀分布于画布时最有效。然而,一种名为四叉树的数据结构可以处理不均匀分布的系统,避免了所有群体都挤进一个单元格的最坏情况。
四叉树通过根据群体(boids)的分布动态调整网格,扩展了空间细分策略。与固定网格不同,四叉树从一个大的单元格开始,这个单元格包含整个空间。如果这个单元格内的群体太多,它会分裂成四个更小的单元格。这个过程会对每个新的单元格进行重复,当它变得过于拥挤时,就会继续分裂,从而创建一个灵活的网格,在需要时提供更精细的分辨率。

四叉树数据结构是 Barnes-Hut 算法的关键,我在构建 n-body 模拟时简要提到过这个算法。该方法使用四叉树将一组物体近似为一个单独的物体,从而在计算引力时减少计算量。这大大减少了所需的计算次数,使得拥有大量物体的模拟运行得更加高效。你可以通过访问 Coding Train 网站的挑战 98了解更多关于构建四叉树并将其应用于群体系统的内容。
练习 5.20
扩展 bin-lattice 空间细分群体模拟,参考示例 5.12,使用四叉树。
更多优化技巧
在这个过程中,这里还有一些与保持代码高效、快速相关的额外提示:
-
使用平方的大小(magnitude squared)(有时也称为平方的距离)。
-
计算正弦和余弦查找表。
-
不要创建无数不必要的 p5.Vector 对象。
接下来将详细介绍这些技巧。
使用平方的大小
什么是平方的大小(magnitude squared),以及何时使用它?回想一下如何计算一个向量的大小。
function mag() {
return sqrt(x * x + y * y);
}
大小需要平方根运算。这是应该的!毕竟,如果你想要一个向量的大小,你得用到勾股定理(我们在第一章中做过)。然而,如果你能 somehow 跳过平方根的计算,你的代码将运行得更快。
假设你只想知道一个向量v的相对大小。例如,大小是否大于 10?
if (v.mag() > 10) {
/* Do something! */
}
好吧,这等价于以下说法:
if (v.magSq() > 100) {
/* Do something! */
}
那么,如何计算平方后的大小呢?
function magSq() {
return x * x + y * y;
}
它的计算方式与大小相同,但不包含平方根。对于单个向量而言,使用magSq()而不是mag()不会显著提高 p5.js 草图的性能。然而,如果你每次通过draw()计算成千上万个向量的大小,使用平方后的大小可能会让你的代码运行稍微更快一点。
计算正弦和余弦查找表
取平方根并不是唯一计算上慢的数学函数。正弦、余弦和正切等三角函数也很慢。如果你只是在代码中偶尔需要一个单独的正弦或余弦值,你永远不会遇到问题。但是,如果你有类似下面的情况呢?
function draw() {
for (let i = 0; i < 10000; i++) {
print(sin(PI));
}
}
当然,这是一个完全荒谬的代码片段,你永远不会写出来。但它说明了一个问题:如果你要计算π的正弦 10,000 次,为什么不一次性计算,保存该值,并在需要时引用它呢?
这就是正弦和余弦查找表的原理。你可以通过构建一个数组,存储从 0 到 2π之间的角度对应的正弦和余弦结果,而不是每次在代码中调用正弦和余弦函数。然后,在需要时,只需查找预计算的值。例如,以下是两个数组,分别存储从 0 到 359 度的每个整数角度对应的正弦和余弦值。我在这里使用angleMode(DEGREES)来简化讨论,但同样的技术也可以应用于弧度:
angleMode(DEGREES);
let sinvalues = [];
let cosvalues = [];
for (let i = 0; i < 360; i++) {
sinvalues[i] = sin(i);
cosvalues[i] = cos(i);
}
现在,假设你需要打印π的正弦值(或 180 度的正弦)呢?
let angle = 180;
for (let i = 0; i < 10000; i++) {
print(sinvalues[angle]);
}
关键在于,从数组中查找一个预计算的值比进行像正弦或余弦这样的复杂操作要快得多。

示例 5.14 中的代码通过引入查找表精度的变量,增强了初始代码片段,使其能够以小于 1 度的增量存储值。
不要创建成千上万不必要的 p5.Vector 对象
在任何草图中,你创建的每个对象都会占用计算机内存的空间。对于少量对象,这可能不会构成问题,但当草图生成大量对象时,尤其是在循环或时间推移中,可能会影响性能。有时,事实证明并非所有的对象都是必需的。
我必须承认,当涉及到创建过多对象时,我可能是最大的罪魁祸首。为了编写清晰易懂的示例,我经常在完全不需要的情况下创建额外的p5.Vector对象。大多数情况下,这根本不是问题。但有时它可能会成为问题。看看这个例子:
function draw() {
for (let v of vehicles) {
let mouse = createVector(mouseX, mouseY);
v.seek(mouse);
}
}
假设vehicles数组包含 1,000 辆车。这意味着每次通过draw()时,我都会为鼠标的位置创建 1,000 个新的p5.Vector对象。在最近购买的任何标准笔记本或台式电脑上,这个草图很可能不会出现任何问题,运行也不会变慢。毕竟,现代计算机有大量的内存,JavaScript 能够处理创建和销毁大约 1,000 个临时对象的问题。
然而,如果对象的数量变得更大(而且它很可能会变大),几乎肯定会出现问题。因此,你应该寻找减少创建p5.Vector对象数量的方法。在这种情况下,这里有一个简单的解决方法:
function draw() {
let mouse = createVector(mouseX, mouseY);
for (let v of vehicles) {
v.seek(mouse);
}
}
现在,我只创建了一个向量,而不是 1,000 个。更好的是,我可以将这个向量变成一个全局变量,然后在draw()中仅通过set()赋值x和y:
let mouse;
function setup() {
mouse = createVector();
}
function draw() {
mouse.set(mouseX, mouseY);
for (let v of vehicles) {
v.seek(mouse);
}
}
现在,在草图开始后,我再也不会创建新的p5.Vector对象;我只是使用同一个对象贯穿整个草图!
在本书的示例中,你会发现有很多机会可以减少临时对象的数量。(我告诉过你,我是个重大罪犯。)例如,以下是本章seek()方法中的一段代码:

看看我是如何创建两个向量对象的?首先,我计算出所需的速度向量,然后是转向力。为了提高效率,我可以重写代码,仅创建一个向量:

我其实不需要一个名为steer的第二个向量。我可以重用desired向量对象,并通过减去velocity将其转变为转向力。我在示例中没有这么做,因为这样会让代码变得更难阅读。但在某些情况下,像这样的改动可能会提高效率。
练习 5.21
尽可能减少在群体行为示例中创建临时的p5.Vector对象。并且尽可能使用magSq()。
生态系统项目
使用转向力来驱动你生态系统中生物的行为。以下是一些可能性:
-
创建生物的群体或鸟群。
-
使用寻求行为让生物寻找食物(对于追逐移动猎物,可以考虑追击)。
-
为生态系统环境使用流场。例如,如果生物生活在一条流动的河流中,你的系统将如何表现?
-
创建一个具有无数转向行为的生物(尽可能多地添加)。思考如何改变这些行为的权重,以便可以随时调节它们,灵活地混合和匹配。生物的初始权重是如何设置的?有哪些规则驱动权重随时间变化?
-
复杂系统可以是嵌套的。你能否设计出由一群鸟群组成的单个生物?然后你能否再让这些生物组成一群?
-
复杂系统可以具有记忆(并且具有适应性)。你的生态系统的历史是否会影响其当前状态下的行为?(这可能是生物如何调整其转向力权重的驱动力。)

第七章:6 个物理学库
图书馆意味着一种信念的行为,几代人依然在黑暗中隐藏,签署他们的夜晚,以见证黎明的到来。
—维克多·雨果

活根桥(照片由 Arshiya Urveeja Bose 提供)
在印度梅加拉亚邦,卡西族和贾因提亚族生活在世界上降雨量最高的地区之一。在季风季节,洪水常常使得村庄之间的交通变得不可能。因此,建造活根桥的古老传统应运而生。这些桥梁,如这里展示的东卡西的双层活根桥,是通过引导和生长树根穿过竹子、棕榈树干或钢架搭建而成的。随着树根与环境的互动,它们不断生长并变得更强,形成适应性强、如弹簧般的连接。
想一想你在这本书中迄今为止所取得的成就。你已经完成了以下内容:
-
学习了物理学中的一些概念(什么是向量?什么是力?什么是波动?)
-
理解了这些概念背后的数学和算法
-
使用面向对象的方法在 p5.js 中实现了这些算法,最终构建了自动导航代理的模拟
这些活动已经产生了一套运动模拟,允许你创造性地定义你所构建世界中的物理规律(无论是现实的还是幻想的)。但当然,你和我并不是第一个或唯一这样做的人。计算机图形学和编程的世界充满了为物理模拟提供的预编写代码库。
只要搜索一下 开源物理引擎,你可能会花上整天的时间去研究一堆丰富而复杂的代码库。这引出了一个问题:如果现有的代码库已经处理了物理模拟,为什么你还要费心学习如何自己编写这些算法呢?这就是本书背后哲学思想的体现。虽然许多库提供了现成的物理效果供你实验(而且这些物理效果超级棒、复杂且强大),但在深入使用这些库之前,学习基础知识有几个充分的理由。
首先,如果不理解向量、力和三角学的知识,仅仅阅读库的文档就容易迷失方向,更不用说使用它了。其次,尽管库可能会处理幕后的一些数学运算,但它并不一定会简化你的代码。在理解库如何工作以及它对你的代码有何期望方面,可能需要大量的额外努力。最后,尽管物理引擎可能非常棒,但如果你深入内心,你可能会发现你希望创造的是突破想象力极限的世界和可视化。库可能很棒,但它只提供有限的功能集。重要的是,要知道何时在追求创意编程项目的过程中,接受这些限制,何时这些限制将变得束缚你。
本章将重点介绍两个 JavaScript 开源物理库:Matter.js (brm.io/matter-js) 和 Toxiclibs.js (haptic-data.com/toxiclibsjs). 我并不是暗示这些是你在任何和所有需要物理引擎的创意编程项目中唯一应该使用的库(有关其他替代库,请参见第 290 页的“其他物理库”,并查看本书网站上的例子,看看如何将本章的示例移植到其他库中)。然而,这两个库与 p5.js 很好地集成,并且将使我能够展示物理引擎的基本概念以及它们如何与我迄今为止介绍的内容相关联和相互补充。
最终,本章的目的不是教你某个具体物理库的细节,而是为你提供一个使用任何物理库的基础。你在这里获得的技能将使你能够浏览和理解文档,为你打开通向使用任何库的能力的大门。
为什么使用物理库?
我已经提出了编写你自己物理仿真的理由(正如你在前几章中学到的那样),但是为什么要使用物理库呢?毕竟,将任何外部框架或库添加到项目中会引入复杂性和额外的代码。额外的开销值得吗?例如,如果你只是想模拟一个因为重力而下落的圆形物体,你真的需要导入一个完整的物理引擎并学习它的 API 吗?正如本书的前几章所展示的那样,可能并不需要。许多这样的场景足够简单,你完全可以通过自己编写代码来解决。
但请考虑另一个场景。如果你想要让 100 个圆形物体掉落呢?如果它们根本不是圆形,而是不规则形状的多边形呢?而且,如果你希望这些多边形在碰撞时能够以一种现实的方式互相反弹呢?
或许你已经注意到,虽然我详细介绍了运动和力量,但到目前为止,我却略过了物理模拟中一个相当重要的方面:碰撞。假设一下,你现在不是在阅读关于物理库的章节,而是我决定立即解释如何在粒子系统中处理碰撞。我必须讨论两种不同的算法来解决这些问题:
-
如何确定两个形状是否发生碰撞(或相交)?这被称为碰撞检测。
-
如何确定碰撞后形状的速度?这被称为碰撞解决。
如果你在处理简单的几何形状,第一个问题并不太难。事实上,也许你之前已经遇到过类似的情况。例如,对于两个圆来说,如果它们的中心之间的距离小于它们半径的和,你就知道它们是相交的(见图 6.1)。

图 6.1:两个半径分别为 r[1] 和 r[2] 的圆,如果它们之间的距离小于 r[1] + r[2],则它们发生碰撞。
这很简单,但是计算碰撞后圆的速度怎么样呢?这就是我要停止讨论的地方。为什么,你问?不是因为理解碰撞背后的数学不重要或没有价值。(事实上,我在网站上还包含了关于不使用物理库的碰撞的额外例子。)停止的原因是生命是短暂的!(这也是你考虑出去玩一会儿然后再坐下来写下一个草图的一个理由。)你不能指望掌握物理模拟的每一个细节。虽然你可能喜欢学习圆形碰撞的碰撞解决方案,但这只会让你想要处理下一个矩形。然后是奇怪形状的多边形。然后是曲面。然后是摆动的钟摆和弹簧的碰撞。然后,然后,然后……
将像碰撞这样的复杂特性纳入 p5.js 的草图中,同时还有时间与朋友和家人在一起——这就是本章的原因。人们花了多年时间开发解决这类问题的解决方案,而像 Matter.js 和 Toxiclibs.js 这样美妙的 JavaScript 库就是这些努力的成果。至少现在你不需要重新发明“轮子”。
总之,如果你发现自己在描述一个 p5.js 草图的想法时,提到了“碰撞”这个词,那么现在可能是时候学习使用物理引擎了。
其他物理库
还有许多其他物理库值得与本章的两个案例一起探索,每个库都有独特的优势,可能在某些类型的项目中提供优势。事实上,当我刚开始写这本书时,Matter.js 还不存在,因此我最初用来展示示例的物理引擎是 Box2D。它曾是(并且可能仍然是)最著名的物理引擎。
Box2D (box2d.org) 最初是由 Erin Catto 为 2006 年的游戏开发者大会编写的一系列 C++物理教程。自那时以来,Box2D 已经发展成一个丰富且复杂的开源物理引擎。它已被用于无数项目中,最著名的包括获奖游戏Crayon Physics和风靡一时的Angry Birds。
Box2D 的一个重要特性是它是一个真正的物理引擎:它对计算机图形和像素世界一无所知,而是以真实世界的单位进行所有的测量和计算,如米、千克和秒。它的“世界”(Box2D 中的一个关键术语)是一个具有上下左右边界的二维平面。你告诉它诸如“世界的重力是每千克 9.81 牛顿,半径为 4 米、质量为 50 千克的圆形物体位于距离世界底部 10 米的地方。” Box2D 然后会告诉你“1 秒钟后,矩形距离底部 5 米;2 秒钟后,它距离底部 10 米。”依此类推。
虽然这提供了一个极其准确和强大的物理引擎(一个对 C++项目高度优化且快速的引擎),但它也需要大量复杂的代码来在 Box2D 的物理世界与你想绘制的世界——即图形画布的像素世界——之间进行转换。这对程序员来说是一个巨大的负担。我将尽力继续为本书维护一组与 Box2D 兼容的示例(有几个 JavaScript 移植版本),但我相信,使用像 Matter.js 这样的库,它本土支持 JavaScript 并使用像素作为度量单位,将为我 p5.js 示例提供一个更直观、更友好的桥梁。
另一个值得注意的库是 p5play (p5play.org),这是由 Paolo Pedercini 发起、目前由 Quinton Ashley 领导的项目,专门为游戏开发设计。它简化了视觉对象——即精灵(sprites)的创建,并管理它们之间的交互(即碰撞和重叠)。正如你从名称中可能猜到的,p5play 被量身定制以便与 p5.js 无缝配合使用。它在底层使用 Box2D 进行物理模拟。
导入 Matter.js 库
稍后,我将开始使用由 Liam Brummitt 于 2014 年创建的 Matter.js。不过,在你可以在 p5.js 项目中使用外部 JavaScript 库之前,你需要先将它导入到你的草图中。正如你已经非常清楚的那样,我正在使用官方的 p5.js 网页编辑器来开发和分享本书的代码示例。添加库的最简单方法是编辑每个新创建的 p5.js 草图中的index.html文件。
为此,首先展开编辑器左侧的文件导航栏,并选择index.html,如图 6.2 所示。
该文件包含一系列位于 HTML 标签<head>和</head>之间的<script>标签。这是如何在 p5.js 草图中引用 JavaScript 库的方式。它与在页面的<body>中包含sketch.js或particle.js没有什么不同,只是在这里,我们通过内容分发网络(CDN)的 URL 引用库,而不是保存和编辑 JavaScript 代码的副本。这是一种用于托管文件的服务器类型。对于那些在成千上万的网页中使用并被数百万用户访问的 JavaScript 库,CDN 必须非常擅长提供这些库。

图 6.2:访问草图的index.html文件
你应该已经看到一个引用 p5.js CDN 的<script>标签(当你阅读本文时,它可能是更新版本):
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.js"></script>
要使用 Matter.js,请在 p5 的<script>标签下面再添加一个引用其 CDN 的<script>标签:
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js"></script>
在写这篇文章时,Matter.js 的最新版本是0.19.0,我在这个代码片段中引用的就是这个版本。随着 Matter.js 的更新和新版本的发布,通常升级是个好主意,但通过引用一个特定的版本(你知道它与你的草图兼容),你就不必担心库的新特性会破坏你现有的代码。
Matter.js 概述
当你在 p5.js 中使用 Matter.js(或任何物理引擎)时,你的代码看起来会有些不同。以下是第一章到第五章所有示例的伪代码概括:
setup()
- 创建世界中的所有物体。
draw()
-
计算世界中的所有力。
-
对物体应用所有的力(F = M × A)。
-
根据物体的加速度更新它们的位置。
-
绘制所有物体。
相比之下,以下是一个 Matter.js 示例的伪代码:
setup()
- 创建世界中的所有物体。
draw()
- 绘制所有物体。
当然,这就是物理引擎的魅力所在。我已经消除了所有那些根据速度和加速度来计算物体如何运动的痛苦步骤。Matter.js 将为我处理这一切!
虽然还会有更多的细节揭示,但好消息是,这段伪代码的简单性准确反映了整个过程。从这个意义上来说,Matter.js 有点像一个魔法盒子。在setup()中,我将对 Matter 说:“你好,这里是我在世界中想要的所有东西。”然后,在draw()中,我将礼貌地对 Matter 说:“哦,又见面了。如果不麻烦的话,我想把这些东西画出来。能告诉我它们在哪里吗?”
坏消息是:这个过程并不像伪代码所暗示的那样简单。实际上,创建进入 Matter.js 世界的东西需要几个步骤,这些步骤与构建和配置不同种类的形状有关。
学会使用 Matter.js 的语言配置世界中各种力和其他参数也是必要的。以下是核心概念:
-
引擎: 管理物理模拟本身的实体。引擎持有模拟世界以及各种表示世界如何随着时间推移更新的属性。
-
物体: 世界中的主要元素,对应于正在被模拟的物理对象。物体有位置和速度。听起来很熟悉吧?它基本上是我在第一章到第五章中一直在构建的类的另一种版本。它还具有定义形状的几何属性。需要注意的是,物体是物理引擎用来描述世界中事物的通用术语(类似于粒子这个术语);它与拟人化的身体无关。
-
复合体: 一个容器,允许创建复杂的实体(由多个物体组成)。世界本身就是一个复合体的例子,且每个创建的物体都必须被添加到世界中。
-
约束: 作为物体之间的连接。
在接下来的章节中,我将详细介绍这些元素,并在此过程中构建几个示例。但首先,还有一个重要的元素需要简要讨论:
- 向量: 使用 x 和 y 分量描述一个具有大小和方向的实体,定义在 Matter.js 世界中的位置、速度和力。
这把我们带到了一个重要的交叉点。任何物理库的基础都是向量,取决于你如何看待,它可以是好事也可以是坏事。好的一面是,你刚刚花了好几章时间熟悉如何用向量描述运动和力,因此在概念上你不需要学习新的内容。坏的一面——让我眼中掉下了一滴泪——是,一旦你跨越了进入物理库的这道门槛,你就不再能使用p5.Vector了。
很高兴 p5.js 有内建的向量表示,但每次你使用物理库时,你很可能会发现它包括了自己独立的向量实现,设计上特别兼容库的其他代码。这是有道理的。毕竟,为什么 Matter.js 应该了解p5.Vector对象呢?
这一切的结果是,虽然你不需要学习任何新的概念,但你确实需要习惯新的命名约定和语法。为了说明这一点,我将展示一些现在已熟悉的p5.Vector操作,以及等效的Matter.Vector代码。首先,如何创建一个向量?
| p5.js | Matter.js |
|---|
|
let v = createVector(1, -1);
|
let v = Matter.Vector.create(1, -1);
|
那么,两个向量相加怎么办?
| p5.js | Matter.js |
|---|
|
let a = createVector(1, -1);
let b = createVector(3, 4);
a.add(b);
|
let a = Matter.Vector.create(1, -1);
let b = Matter.Vector.create(3, 4);
Matter.Vector.add(a, b, a);
|
这会用结果覆盖向量a。以下是如何将结果放入一个单独向量的方法:
| p5.js | Matter.js |
|---|
|
let a = createVector(1, -1);
let b = createVector(3, 4);
let c = p5.Vector.add(a, b);
|
let a = Matter.Vector.create(1, -1);
let b = Matter.Vector.create(3, 4);
let c = Matter.Vector.add(a, b);
|
如果你想缩放向量(乘以标量值)怎么办?
| p5.js | Matter.js |
|---|
|
let v = createVector(1, -1);
v.mult(4);
|
let v = Matter.Vector.create(1, -1);
v = Matter.Vector.mult(v, 4);
|
大小和归一化?
| p5.js | Matter.js |
|---|
|
let v = createVector(3, 4);
let m = v.mag();
v.normalize();
|
let v = Matter.Vector.create(3, 4);
let m = Matter.Vector.magnitude(v);
v = Matter.Vector.normalise(v);
|
正如你所看到的,概念是相同的,但代码的具体实现不同。首先,每个方法名称前面现在都加上了Matter.Vector,这定义了源代码的命名空间。这在 JavaScript 库中很常见;p5.js 与其他库不同,它没有始终如一地使用命名空间。例如,在 p5.js 中画一个圆,你调用circle()而不是p5.circle()。circle()函数位于全局命名空间中。我认为这是 p5.js 在易用性和对初学者友好的方面的一个特点。然而,这也意味着在使用 p5.js 编写的任何代码中,你不能使用circle作为变量名。为库使用命名空间可以防止这类错误和命名冲突,这也是为什么你会看到 Matter.js 中的所有内容都带有Matter前缀的原因。
此外,与 p5.js 中的静态和非静态版本的向量方法如add()和mult()不同,Matter.js 中的所有向量方法都是静态的。如果你想在操作Matter.Vector时更改它,可以将其作为可选参数添加:Matter.Vector.add(a, b, a)将a和b相加并将结果放入a(第三个参数)。你也可以将一个现有的变量设置为由计算结果生成的新向量对象,像这样v = Matter.Vector.mult(v, 2)。然而,这个版本仍然会在内存中创建一个新向量,而不是更新旧的向量。
在本章中我会介绍更多关于Matter.Vector的基础知识,但如果你需要详细信息,可以在 Matter.js 的官方网站上找到完整文档(* brm.io/matter-js *)。
引擎
许多物理库都包含一个world对象来管理所有的内容。世界通常负责坐标空间,保持模拟中所有物体的列表,控制时间等。在 Matter.js 中,世界是在Engine对象内创建的,这是你物理世界和模拟的主要控制器:

请注意,第一行代码创建了一个Engine变量,并将其设置为Matter.Engine。在这里,我决定将单个关键字Engine指向 Matter.js 中命名空间内的Engine类,以便让我的代码更加简洁。之所以这样做,是因为我知道后面不会再用到Engine这个词作为其他变量名,而且它也不会和 p5.js 中的某个东西冲突。在接下来的示例中,我还会对Vector、Bodies、Composite等进行类似的处理。(不过,虽然关联的源代码中会始终包括所有别名,但书中的文本不会每次都展示它们。)
当你在Engine上调用create()时,Matter.js 会返回一个新的物理引擎和世界,并赋予默认的重力——一个指向下方的(0, 1)向量。你可以通过访问gravity变量来改变这个默认值:

当然,重力不必在整个模拟过程中保持固定;你可以在程序运行时调整重力向量。你还可以通过将其设置为(0, 0)向量完全关闭重力。
对象解构
对象解构是 JavaScript 中一种从对象中提取属性并将其赋值给变量的技术。在 Matter.js 中,Matter对象包含了Engine属性。通常,可以使用let Engine = Matter.Engine为这个属性设置别名,但使用解构后,可以更简洁地创建别名:
const { Engine } = Matter;
等一下,你注意到我偷偷用了一个const吗?我记得在第零章中说过,整本书中我只会使用let来声明变量。不过,使用外部库正是一个很好地尝试const的机会。在 JavaScript 中,const用于声明那些初始化后永远不应被重新赋值的变量。在这种情况下,我希望保护自己避免在代码后面不小心覆盖掉Engine变量,这样可能会把一切弄坏!
话说完了,接下来我们来看一下,当你需要为同一对象的多个属性创建别名时,解构语法是如何发挥作用的:

这行代码将Engine设置为Matter.Engine的别名,将Vector设置为Matter.Vector的别名,所有这些都在一个语句中完成。我将在本章的示例中使用这种技术。
一旦世界初始化完成,就该往里面放东西——物体!
物体
物体是 Matter.js 世界中的主要元素。它相当于我在之前章节中创建的Vehicle(前身为Particle,再前身为Mover)类——也就是在空间中移动并经历力的东西。物体也可以是静态的(固定的,不会移动)。
Matter.js 物体是通过在Matter.Bodies中找到的工厂方法创建的,创建不同类型物体的方法也有所不同。工厂方法是创建对象的函数。虽然你可能更熟悉使用构造函数来创建对象——例如,使用new Particle()——但你之前也见过工厂方法:createVector()是一个创建p5.Vector对象的工厂方法。无论一个对象是通过构造函数还是工厂方法创建,都是库作者的风格和设计选择问题。
创建物体的所有工厂方法都可以在Matter.Bodies文档页面中找到(brm.io/matter-js/docs/classes/Bodies.html)。我将从rectangle()方法开始:

真幸运!rectangle()方法的签名与 p5.js 的rect()函数完全相同。然而,在这个例子中,该方法不是绘制一个矩形,而是为Body对象构建几何形状来存储。(注意,调用Bodies.rectangle()只有在你首先将Bodies作为Matter.Bodies的别名时才有效。)
一个物体现在已经被创建,并具有一个位置和大小,并且对它的引用存储在变量box中。然而,物体还有许多其他影响其运动的属性。例如,密度最终决定了物体的质量。摩擦力和恢复力(弹性)影响物体与其他物体接触时的相互作用。对于大多数情况,默认值已经足够,但 Matter.js 确实允许你通过向工厂方法传递额外的参数来指定这些属性,这些参数的形式是一个 JavaScript对象字面量,它是由逗号分隔并被花括号括起来的键值对集合:

对象字面量中的每个键(例如,friction)作为一个唯一的标识符,而它的值(0.5)是与该键相关的数据。你可以将对象字面量看作一个简单的字典或查找表——在这个例子中,它保存了用于创建新 Matter.js 物体的设置。请注意,虽然options参数对于配置物体非常有用,但其他初始条件,例如线性或角速度,可以通过Matter.Body类的静态方法来设置:

创建一个物体并将其存储在变量中并不足够。任何物体必须显式地添加到世界中,才能通过物理模拟。物理世界是一个名为world的Composite对象,存储在engine内部。可以通过静态的add()方法将box添加到这个世界中:

这个额外的步骤很容易被忘记——这是我无数次犯过的错误。如果你曾经想知道为什么你的物体没有出现或者没有随着世界物理的变化而移动,记得检查你是否已经将它添加到世界中了!
练习 6.1
了解了目前关于 Matter.js 的知识后,请填写以下代码中的空白部分,以展示如何创建一个圆形物体:
let options = {
friction: 0.5,
restitution: 0.8,
};
let ball = Bodies.circle(x, y, radius, options);
Render
一旦一个物体被添加到世界中,Matter.js 将始终知道它的存在,检查它是否发生碰撞,并根据环境中的任何力更新它的位置。所有这些都不需要你动一根手指!但你怎么画出这个物体呢?
在下一部分,我将向你展示如何查询 Matter.js 中各种物体的位置,以便用 p5.js 渲染世界。这种方法对于能够控制你自己的动画外观至关重要。这是你大展拳脚的时候:你可以成为你世界的设计师,利用你的创造力和 p5.js 技能来可视化物体,同时礼貌地让 Matter.js 在后台计算所有物理。
也就是说,Matter.js 确实包括一个非常简单直观的Render类,这对于快速查看和调试你设计的世界非常有用。它提供了自定义调试绘图样式的方法,但我发现默认设置足以快速检查我是否正确配置了世界。
第一步是调用Matter.Render.create()(或者如果用了别名则是Render.create())。这个方法需要一个包含渲染器所需设置的对象,我将其称为params。

请注意,我将 p5.js 画布的引用存储在canvas变量中。这是必要的,因为我需要告诉渲染器在特定的画布上绘制。Matter.js 并不知道 p5.js,所以它所分配的画布是一个原生的 HTML5 画布,存储在 p5.js 画布对象的elt属性中。引擎是我之前创建的engine。Matter.js 的默认画布尺寸为 800×600,如果我希望使用不同的尺寸,我需要配置一个包含width和height的options属性。
一旦我有了render对象,我需要告诉 Matter.js 去运行它:

还有一个重要的步骤:物理引擎必须被告知在时间上向前推进。由于我使用的是内置渲染器,我还可以使用内置的运行器,它以每秒 60 帧的默认帧率运行引擎。运行器也是可以自定义的,但由于目标是在接下来的部分中改用 p5.js 的 draw() 循环(将在下一节介绍),所以这些细节并不那么重要:

这是完整的 Matter.js 代码,其中新增了一个 ground 对象——另一个矩形物体。注意在创建地面物体时使用了 { isStatic: true } 选项,以确保它保持在固定位置。关于静态物体的更多细节,我将在第 307 页的《静态 Matter.js 物体》一节中介绍。

这里没有 draw() 函数,所有变量都在 setup() 中是局部的。实际上,我并没有使用任何 p5.js 的功能(除了将画布注入到页面中)。这正是我接下来要解决的问题!
Matter.js 与 p5.js
Matter.js 会保持一个所有物体的列表,正如你刚才看到的,它可以通过 Render 和 Runner 对象来处理物体的绘制和动画。顺便提一下,这个列表存储在 engine.world.bodies 中。不过,我现在想展示的是一种技术,用于维护你自己的 Matter.js 物体列表,以便你可以使用 p5.js 来绘制它们。
是的,这种方法可能会增加冗余并牺牲一些效率,但它通过简便的使用和定制弥补了这一点。通过这种方法,你将能够像在 p5.js 中一样进行编码,跟踪哪些物体是哪个,并适当地绘制它们。请参阅图 6.3 中显示的草图文件结构。

图 6.3:典型 p5.js 草图的文件结构
从结构上看,这看起来只是另一个 p5.js 草图。它包含一个主要的sketch.js 文件,还有 box.js 文件。这样的额外文件通常是用来声明草图中需要的类——在这个例子中,是描述世界中矩形物体的 Box 类:

现在,我将编写一个 sketch.js 文件,每当点击鼠标时创建一个新的 Box 并将所有 Box 对象存储在一个数组中。(这与我在第四章的粒子系统示例中采用的方法相同。)

现在,这个草图会将固定的盒子绘制到屏幕上。挑战是:我如何才能绘制出那些一出现就会经历物理计算(由 Matter.js 计算)的盒子,同时尽量少修改代码?
为了实现这个目标,我需要三步。
第 1 步:将 Matter.js 添加到 p5.js 草图中
目前,草图中没有引用 Matter.js。这显然需要改变。幸运的是,这部分不算太难:我已经展示了构建 Matter.js 世界所需的所有元素。(别忘了,为了让这个工作正常进行,确保在index.html中导入了这个库。)
首先,我需要为必要的 Matter.js 类添加别名,并在setup()中创建一个Engine对象:

然后,在draw()中,我需要确保调用一个关键的 Matter.js 方法,Engine.update():

Engine.update()方法将物理世界推进一步。将其放在 p5.js 的draw()循环中调用,确保物理引擎在每一帧动画中都进行更新。这个机制取代了我在示例 6.1 中使用的内建 Matter.js Runner对象。现在draw()循环就是运行器!
在内部,当调用Engine.update()时,Matter.js 会遍历世界,查看其中的所有物体,并决定如何处理它们。仅仅调用Engine.update()会将世界按默认设置向前推进。然而,与Render一样,这些设置是可以自定义的,并在 Matter.js 文档中有所说明(* brm.io/matter-js/docs/classes/Engine.html#method_update*)。
步骤 2:将每个 Box 对象与 Matter.js 物体链接
我已经设置好了我的 Matter.js 世界;现在,我需要将每个Box对象在我的 p5.js 草图中与那个世界中的物体链接起来。原始的Box类包含位置和宽度的变量。我现在想说的是:“我在此放弃对这个对象位置的控制,交给 Matter.js。我不再需要跟踪任何与位置、速度或加速度相关的内容。相反,我只需要跟踪一个 Matter.js 物体的存在,并相信物理引擎会完成剩下的事情。”

我不再需要this.x和this.y位置变量了。Box构造函数接收起始的 x 和 y 坐标,将它们传递给Bodies.rectangle()以创建一个新的 Matter.js 物体,然后忘记这些值。正如你将看到的,物体本身会在幕后跟踪其位置。物体理论上也可以跟踪其尺寸,但由于 Matter.js 将它们存储为一个顶点列表,因此在绘制方框时,保留方块的宽度在this.w变量中会更方便。
步骤 3:绘制物体
快完成了。在我将 Matter.js 引入草图之前,绘制Box是很简单的。该对象的位置存储在this.x和this.y变量中:

既然 Matter.js 管理物体的位置,我就不能再使用我自己的x和y变量来绘制该形状了。但别担心!Box对象有一个对 Matter.js 物体的引用,而该物体知道自己的位置。我所需要做的就是礼貌地问物体:“不好意思,请问你在哪里?”
let position = this.body.position;
然而,仅仅知道物体的位置是不够的。物体是一个方形,所以我还需要知道它的旋转角度:
let angle = this.body.angle;
一旦我得到了位置和角度,我就可以通过使用本地的 p5.js translate()、rotate()和square()函数来渲染该物体:

在这里需要注意的是,如果你从boxes数组中删除了一个Box对象——可能是因为它移动出了画布边界或达到了其生命周期的终点,如第四章中所示——你还必须显式地从 Matter.js 世界中移除与该Box对象关联的物体。这可以通过在Box类中使用removeBody()方法来完成:

在draw()中,你将按逆序遍历数组,就像粒子系统示例那样,调用removeBody()和splice(),以便从 Matter.js 世界和你的盒子数组中删除该对象。
练习 6.2
从示例 6.2 的代码开始,按照本章中概述的方法,添加代码以实现 Matter.js 物理。删除离开画布的物体。结果应该如下图所示。你可以在绘制盒子时发挥创意!

静态 Matter.js 物体
在我刚刚创建的示例中,Box对象出现在鼠标位置,并因默认的重力作用而向下掉落。如果我想为世界添加不可移动的边界来阻挡下落的Box对象怎么办?Matter.js 通过isStatic属性使这一切变得简单:

我仍然是使用Bodies.rectangle()工厂方法创建物体,但设置isStatic属性确保该物体永远不会移动。我将在练习 6.2 的解决方案中加入此功能,创建一个单独的Boundary类,将 p5.js 矩形与静态 Matter.js 物体连接起来。为了增加变化,我还将随机化每个掉落盒子的尺寸。(请参阅在线代码,了解Box类的更改。)

静态物体不会包含如恢复力或摩擦力等物理属性。请确保在动态物体中设置这些属性。
多边形和形状组
现在我已经演示了如何使用像矩形或圆形这样的基本形状与 Matter.js,假设你想创建一个更有趣的物体,比如图 6.4 中的抽象人物。

图 6.4:由多个形状组成的复合体
可以使用两种策略来制作这种复杂的形状。通用的Bodies.polygon()方法可以创建任何规则的多边形(如五边形、六边形等)。另外,Bodies.trapezoid()可以创建一个至少有一对平行边的四边形:

更通用的选项是Bodies.fromVertices()。它通过一个向量数组构建一个形状,将它们视为一系列连接的顶点。我将把这个逻辑封装在CustomShape类中。

在 Matter.js 中创建自定义多边形时,你必须记住两个重要细节。首先,顶点必须按顺时针顺序指定。例如,图 6.5 展示了用来创建示例 6.4 中物体的五个顶点。注意,示例将它们按从左上角开始的顺时针顺序添加到vertices数组中。

图 6.5:按顺时针顺序排列的自定义多边形顶点
其次,每个形状必须是凸形的,而不是凹形的。如图 6.6 所示,凹形的形状表面向内弯曲,而凸形则相反。每个凸形的内角必须小于或等于 180 度。Matter.js 可以处理凹形状,但你需要将它们由多个凸形状组成(稍后会详细介绍)。

图 6.6:凹形状可以通过多个凸形状绘制。
由于形状是由自定义顶点构建的,所以在绘制物体时,你可以使用 p5.js 的beginShape()、endShape()和vertex()函数。CustomShape类可以包括一个数组,用于存储顶点的像素位置,相对于 (0, 0),以便绘制。不过,最好是直接查询 Matter.js 来获取位置。这样,就不需要使用translate()或rotate(),因为 Matter.js 物体将顶点存储为绝对世界坐标:

Matter.js 物体将其顶点位置数组存储在一个vertices属性中。注意,我可以使用for...of循环在beginShape()和endShape()之间遍历这些顶点。
练习 6.3
使用Bodies.fromVertices()创建你自己的多边形设计(记住,它必须是凸形的)。这里展示了一些可能的形状。

从一个顶点数组构建的自定义形状会让你走得很远。然而,凸形状的要求确实限制了可能性范围。好消息是,你可以通过创建由多个形状组成的复合体来消除这一限制!比如,使用一个细长的矩形和一个圆形在上面,做一个美味的棒棒糖怎么样?
我将首先创建两个独立的物体,一个矩形和一个圆形。然后,我可以通过将它们放入一个parts数组,并将该数组传递给Body.create()来将它们合并:

虽然通过组合两个形状创建了一个复合体,但代码并不完全正确。如果你运行它,你会发现两个形状都位于相同的(x, y)位置,正如图 6.7 所示。

图 6.7:一个矩形和一个圆形具有相同的(x, y)参考点
相反,我需要将圆形的中心在水平方向上从矩形的中心偏移,如图 6.8 所示。

图 6.8:一个相对于矩形水平偏移的圆形
我将使用矩形宽度的一半作为偏移量,这样圆形就会被居中在矩形的边缘:

由于棒棒糖的身体有两个部分,绘制它稍微复杂一点。我可以采用多种方法。例如,我可以使用物体的vertices数组,将棒棒糖绘制为一个自定义形状,就像示例 6.4 一样。(每个物体都会存储一个顶点数组,即使它不是通过fromVertices()方法创建的。)然而,由于棒棒糖的每个部分都是原始形状,我更倾向于分别平移到每个部分的位置,并按照整体物体的角度进行旋转。

在继续之前,我想强调的是,单纯通过创建 Matter.js 物体并不会让你在画布窗口中绘制的内容自动表现出完美的物理效果。本章中的示例之所以有效,是因为我小心地匹配了每个 p5.js 物体的绘制方式与我为每个 Matter.js 物体定义的几何形状。如果你不小心以不同的方式绘制了一个形状,你不会收到错误提示——无论是来自 p5.js 还是 Matter.js。不过,你的画面看起来会很奇怪,物理效果也会不正常,因为你看到的世界与 Matter.js 理解的世界不一致。
为了说明这一点,让我回到示例 6.5。一个棒棒糖是由两个部分组成的复合体,一个矩形(this.part1)和一个圆形(this.part2)。我一直是通过分别获取两个部分的位置来绘制每个棒棒糖:this.part1.position和this.part2.position。然而,整体复合体也有一个位置,this.body.position。使用这个位置来绘制矩形,并手动计算圆形的位置并使用偏移量,看起来很诱人。毕竟,这就是我最初设想复合形状的方式(回顾图 6.8):

图 6.9 展示了这一变化的结果。

图 6.9:当形状与它们在 Matter.js 中的配置不同绘制时会发生什么
乍一看,这个新版本可能看起来没问题,但如果仔细观察,碰撞就会出现偏差,形状也会以奇怪的方式重叠。这并不是物理引擎出了问题,而是我没有在 p5.js 和 Matter.js 之间正确地进行通信。事实证明,物体的整体位置不是矩形的中心,而是矩形与圆形之间的质心。Matter.js 正在像以前一样计算物理和管理碰撞,但我把每个物体绘制在了错误的位置!(在在线版本中,你可以通过点击鼠标切换正确和错误的渲染效果。)
练习 6.4
通过使用多个附加到单一物体的形状,制作你自己的小外星生物。记住,你不仅限于在 p5.js 中使用基本的形状绘制功能;你还可以使用图片和颜色,添加用线条表示的头发等。把 Matter.js 的形状想象成你原创幻想设计的骨架!
Matter.js 约束
Matter.js 的约束是将一个物体与另一个物体连接的机制,用于模拟摆动的钟摆、弹性桥梁、软体角色、在轴上旋转的车轮等。约束有三种类型:距离约束和转动约束,都通过Constraint类进行管理;以及鼠标约束,通过MouseConstraint类进行管理。
距离约束
距离约束是两个物体之间的固定长度连接,类似于第三章中连接两个形状的弹簧力。约束附加在每个物体的锚点上,锚点是相对于物体中心的位置(见图 6.10)。根据约束的刚度特性,“固定”长度可能会有所变化,就像弹簧可以更硬或更软一样。

图 6.10:约束是两个物体之间的连接,每个物体都有一个锚点。
定义一个约束的方法与创建物体类似,只不过你需要准备好两个物体。假设两个Particle对象分别在一个名为body的属性中存储着指向 Matter.js 物体的引用。我将它们称为particleA和particleB:
let particleA = new Particle();
let particleB = new Particle();
我想在这些粒子之间创建一个约束。为此,我需要定义一系列选项来决定约束的行为:
-
bodyA:约束连接的第一个物体,建立约束的一端。 -
bodyB:约束连接的第二个物体,形成另一端。 -
pointA:约束附加在第一个物体上的位置,相对于bodyA。 -
pointB:约束附加在第二个物体上的位置,相对于bodyB。 -
length:约束的静止或目标长度。约束会在仿真过程中尽力保持这个长度。 -
stiffness:一个从 0 到 1 的值,表示约束的刚性,1 为完全刚性,0 为完全柔软。
这些设置会打包成一个对象字面量:
let options = {
bodyA: particleA.body,
bodyB: particleB.body,
pointA: Vector.create(0, 0),
pointB: Vector.create(0, 0),
length: 100,
stiffness: 0.5
};
从技术上讲,唯一需要的选项是 bodyA 和 bodyB,即由约束连接的两个物体。如果你没有指定任何其他选项,Matter.js 会为其他属性选择默认值。例如,它会使用 (0, 0) 作为每个相对锚点(物体的中心),将 length 设置为两个物体之间的当前距离,并为 stiffness 赋默认值 0.7。还有两个我没有包括的重要选项是 damping 和 angularStiffness。damping 选项影响约束对运动的阻力,值越高,约束失去能量的速度越快。angularStiffness 选项控制约束的角运动刚性,值越高,物体之间的角度灵活性越小。
配置好选项后,就可以创建约束条件。像往常一样,这假定另一个别名——Constraint 等于 Matter.Constraint:

我可以在一个类中包含一个约束来封装和管理多个物体之间的关系。下面是一个代表摆锤摆动的类示例(模仿 示例 3.11 来自 第三章)。

示例 6.6 使用默认的 stiffness 值 0.7。如果你尝试更低的值,摆锤会更像是一个软弹簧。
练习 6.5
创建一个桥梁模拟,通过使用约束将一系列圆形(或矩形)连接起来,如下图所示。使用 isStatic 属性将端点固定在原地。尝试不同的值,使桥梁更加或不那么有弹性。关节没有物理几何形状,因此为了避免桥梁出现间隙,节点之间的间距非常重要。

转动约束
另一种在物理引擎中常见的物体连接方式是 转动关节。这种类型的约束将两个物体连接在一个共同的锚点,也称为 铰链(见 图 6.11)。虽然 Matter.js 没有单独的转动约束,但你可以通过一个长度为 0 的常规 Constraint 来实现。这样,物体就可以围绕一个共同的锚点旋转。

图 6.11:转动约束是两个物体在单一锚点或铰链处的连接。
第一步是创建连接的物体。以第一个例子为例,我想创建一个旋转的矩形(类似于螺旋桨或风车),并固定在某个位置。对于这种情况,我只需要一个物体连接到一个点。这简化了代码,因为我不必担心两个连接在铰链上的物体之间的碰撞。

接下来,我可以创建约束。设置length为0时,它需要一个stiffness为1;否则,约束可能不够稳定,无法将物体固定在锚点上:

将代码组合在一起,我将编写一个名为Windmill的类,表示一个旋转的物体。该草图还包括一个Particle类,用于将粒子投放到风车上。

请注意这个例子中的线条,表示风车的支架。它不是 Matter.js 物理世界的一部分,我也从未为它创建物体。这说明了一个重要的概念:在与 p5.js 一同使用物理引擎时,你可以向画布添加元素,这些元素有助于视觉设计,但不会影响物理模拟,只要你不需要这些元素参与模拟本身。
练习 6.6
创建一个具有转动关节的车辆,考虑车轮的大小和位置。改变stiffness属性如何影响它们的运动?

鼠标约束
在介绍MouseConstraint类之前,先考虑一个问题:如何将 Matter.js 中的物体位置设置为鼠标位置?更进一步,为什么需要一个约束呢?毕竟,你可以访问物体的位置,也可以访问鼠标的位置。那么,将一个位置赋值给另一个位置有什么问题呢?
body.position.x = mouseX;
body.position.y = mouseY;
虽然这段代码会移动物体,但它也会带来一个不幸的结果——破坏物理效果。想象一下,你已经建造了一台传送机,可以让你从卧室瞬间移动到厨房(对深夜小吃来说很有用)。这个场景很容易想象,但现在请重新编写牛顿的运动定律,来考虑瞬移的可能性。现在就不那么容易了,对吧?
Matter.js 也有同样的问题。如果你手动设置物体的位置,就像是在说:“瞬移这个物体”,然后 Matter.js 就不知道如何正确计算物理效果了。然而,Matter.js 确实允许你在腰间绑上一根绳子,让你的朋友站在厨房里并把你拖过去。将你的朋友换成鼠标,那就是鼠标约束的原理。
想象一下,当你点击鼠标在一个形状上时,鼠标会通过一根绳子与那个物体连接。现在你可以移动鼠标,它会把物体一起拖动,直到你松开鼠标。这与转动关节类似,你可以将“绳子”的长度设置为 0,从而有效地用鼠标移动形状。
然而,在你能附加鼠标之前,你需要创建一个 Matter.js Mouse对象,它会监听与 p5.js 画布的鼠标交互:

接下来,使用mouse对象创建一个MouseConstraint:
let mouseConstraint = MouseConstraint.create(engine, { mouse });
Composite.add(engine.world, mouseConstraint);
这将立即允许你通过鼠标与所有 Matter.js 物体进行交互。你不需要显式地将约束附加到特定的物体;你点击的任何物体都会被约束到鼠标上。
你还可以通过在传递给MouseConstraint.create()方法的选项中添加constraint属性,来配置所有常见的约束变量:

下面是一个示例,演示了一个包含两个Box物体的MouseConstraint。静态物体充当画布边界的墙壁。

在这个例子中,你会看到约束的stiffness属性被设置为0.7,给虚拟的鼠标绳子增加了一些弹性。其他属性,如angularStiffness和damping,也可以影响鼠标的互动。如果你调整这些值会发生什么?
添加更多的力
在第二章中,我介绍了如何构建一个具有多重力作用的环境。一个物体可能会受到重力、风力、空气阻力等的影响。显然,在 Matter.js 中,力正在起作用,矩形和圆形物体在屏幕上旋转和飞行!但是到目前为止,我只展示了如何操控单一的全局力:重力。

如果我想在 Matter.js 中使用第二章中的任何技术,我只需使用可靠的applyForce()方法,这个方法是我在Mover类中编写的。它接收一个向量,将其除以质量,并将其累加到移动物体的加速度中。在 Matter.js 中,存在相同的方法,因此我不再需要自己编写所有细节!我可以通过静态的Body.applyForce()来调用它。下面是现在在Box类中的实现:

在这里,Box类的applyForce()方法接收一个力向量,并将其传递给 Matter.js 的applyForce()方法,以将其应用到相应的物体上。与这种方法的关键区别在于,Matter.js 是一个比第二章中的示例更复杂的引擎。早期的示例假设力总是作用于驱动器的中心位置。在这里,我指定了力应用于物体的确切位置。在这个例子中,我像之前一样将力应用于物体的中心,通过询问物体的位置来实现,但这也可以进行调整——例如,施加在箱子边缘的力,使其像掷骰子一样旋转在画布上。
如何将力引入一个由 Matter.js 驱动的草图中?假设我想使用重力吸引力。还记得Attractor类中的示例 2.6 代码吗?
attract(mover) {
let force = p5.Vector.sub(this.position, mover.position);
let distance = force.mag();
distance = constrain(distance, 5, 25);
let strength = (G * this.mass * mover.mass) / (distance * distance);
force.setMag(strength);
return force;
}
我可以使用Matter.Vector重写完全相同的方法,并将其集成到一个新的Attractor类中。

除了为示例 6.9 编写自定义的attract()方法外,还需要另外两个关键元素,才能使草图的行为更像第二章中的示例。首先,记住 Matter.js 的Engine有一个默认的向下重力。我需要在setup()中通过(0, 0)向量禁用它:

第二,Matter.js 中的物体默认具有空气阻力,会导致它们在移动时减速。我还需要将其设置为0,以模拟物体处于真空中的状态:

这是重新审视质量概念的好时机。虽然我在attract()方法中访问了与驱动器相关的物体的mass属性,但我从未显式地设置过它。在 Matter.js 中,物体的质量是根据其大小(面积)和密度自动计算的。因此,较大的物体会有更大的质量。为了增加质量相对于大小的比重,你可以尝试在options对象中设置density属性(默认值为0.001)。对于静态物体,例如引力源,质量被认为是无限的。这就是为什么引力源即使在驱动器不断撞击它时,仍然保持锁定位置。
练习 6.7
将Body.applyForce()集成到新的spin()方法中,用于示例 6.7 的Windmill类,以模拟电动机持续旋转风车。

练习 6.8
将第五章中的任何一个引导行为示例转换为 Matter.js。带有碰撞的群体行为看起来如何?
碰撞事件
这本书的名字不是《Matter.js 的本质》,所以我不会涵盖 Matter.js 库的所有可能特性。到目前为止,我已经讲解了如何创建物体和约束,并展示了库的一些功能。通过你所获得的技能,希望在使用 Matter.js 的其他方面时,学习过程会变得不那么痛苦。然而,在继续之前,库的另一个值得介绍的功能是:碰撞事件。
你可能一直在想一个问题:“如果我希望在两个物体碰撞时发生一些额外的事情怎么办?我的意思是,不要误会我——我很高兴 Matter.js 在后台处理所有的碰撞。但是,如果它为我处理了碰撞,我怎么知道它们发生了呢?”
你可能会这样回答这个问题:“嗯,我知道系统中的所有物体,我也知道它们的位置。我可以开始比较物体的位置,看看哪些物体发生了相交。然后我可以为那些确定发生碰撞的物体做一些额外的事情。”
这是个不错的想法,但喂?使用像 Matter.js 这样的物理引擎的重点就是它会为你处理所有这些工作。如果你打算实现计算几何算法来检测相交,那基本上就是在自己实现一个 Matter.js!
当然,想知道物体何时发生碰撞是一个相当常见的问题,因此 Matter.js 早就考虑到了这一点。它可以通过事件监听器提醒你发生碰撞的时刻。如果你曾在 p5.js 中处理过鼠标或键盘交互,那么你已经有了使用事件监听器的经验。请考虑以下内容:

p5.js 中的全局mousePressed()函数在每次点击鼠标时都会执行。这被称为回调函数,即在某个事件发生时,稍后被调用的函数。Matter.js 的碰撞事件也以类似的方式操作。然而,和 p5.js 只需在鼠标事件发生时查找名为mousePressed()的函数不同,在 Matter.js 中,你需要明确地为碰撞回调函数定义名称:
Matter.Events.on(engine, 'collisionStart', handleCollisions);
这段代码指定了一个名为handleCollisions的函数,当两个物体发生碰撞时就会执行。Matter.js 还有用于'collisionActive'(在持续碰撞的过程中反复执行)和'collisionEnd'(当两个物体停止碰撞时执行)的事件,但对于一个基本的演示,知道碰撞何时开始已经足够。
就像在点击鼠标时会触发mousePressed()一样,当两个物体发生碰撞时,handleCollisions()(或者你选择的回调函数名)也会被触发。它可以写成如下形式:
function handleCollisions(event) {
}
请注意,这个函数包括一个 event 参数。这个对象包含与碰撞相关的所有数据(如果在那个时间步骤内发生了多个碰撞,它会包含多次碰撞的数据),例如涉及到哪些物体。Matter.js 会自动创建这个对象,并在每次碰撞发生时将其传递给 handleCollisions() 回调函数。
假设我有一个 Particle 对象的草图。每个 Particle 都存储一个 Matter.js 物体的引用,我希望粒子在碰撞时改变颜色。以下是实现这一目标的步骤。
步骤 1:事件,你能告诉我是哪两个物体发生了碰撞吗?
那么,到底发生了什么碰撞呢?Matter.js 会检测一对物体之间的碰撞。任何发生碰撞的物体对都会出现在 event 对象中的 pairs 数组里。在 handleCollisions() 中,我可以使用 for...of 循环遍历这些物体对:
for (let pair of event.pairs) {
}
步骤 2:配对,你能告诉我你包含了哪两个物体吗?
pairs 数组中的每一对物体都是一个包含碰撞中涉及的两个物体引用的对象,分别是 bodyA 和 bodyB。我会提取出这些物体:
for (let pair of event.pairs) {
let bodyA = pair.bodyA;
let bodyB = pair.bodyB;
}
步骤 3:物体,你能告诉我你与哪些粒子相关联吗?
从相关的 Matter.js 物体获取它们所关联的 Particle 对象稍微有点困难。毕竟,Matter.js 对我的代码一无所知。它确实在做各种操作来跟踪物体和约束之间的关系,但管理我自己的对象及其与 Matter.js 元素的关联是我自己的责任。话虽如此,每个 Matter.js 物体都会实例化一个空对象——{ }——叫做 plugin,它用来存储关于该物体的任何自定义数据。我可以通过将该对象的引用存储在 plugin 属性中,将物体与自定义对象(在这个例子中是 Particle)链接起来。
看看在 Particle 类中更新后的构造函数,其中物体是如何创建的。请注意,创建物体的过程已经通过增加一行代码进行扩展,往 plugin 中添加了一个 particle 属性。非常重要的一点是,确保你是给现有的 plugin 对象添加一个新属性(在这个例子中是 plugin.particle = this),而不是覆盖整个 plugin 对象(例如,使用 plugin = this)。后者可能会干扰其他功能或自定义设置。

在稍后的 handleCollision() 回调函数中,可以通过 plugin 从 Body 中访问到那个 Particle 对象。

在大多数情况下,你不能假设发生碰撞的对象都是 Particle 对象。毕竟,粒子可能与一个 Boundary 对象发生碰撞(根据你世界里的内容,这可能是另一种类型的对象)。你可以使用 JavaScript 的 instanceof 操作符检查一个对象的类型,正如我在这个例子中所做的那样。
练习 6.9
创建一个模拟,其中Particle对象在相互碰撞时消失。你应该在哪里以及如何删除这些粒子?你能让它们碎成更小的粒子吗?
简短插曲:积分方法
你有没有经历过这样的情境?你正在一个高级鸡尾酒会上,和朋友们分享你那令人难以置信的软件物理模拟的精彩故事。突然,有人插话说:“太迷人了!你使用的是什么积分方法?”
什么?!你心里想,积分?
或许你之前听说过这个术语。与微分一起,它是微积分中的两大基本操作之一。哦,对了,微积分。
我已经成功地完成了与物理模拟相关的大部分内容,而几乎没有真正深入微积分。然而,在我完成本书的上半部分时,值得花点时间来审视我所展示的内容背后的微积分原理,以及它如何与某些物理库(如 Box2D、Matter.js,以及即将发布的 Toxiclibs.js)中的方法论相关联。这样,当有人在下次鸡尾酒会上问你关于积分的问题时,你就能知道该怎么回答。
我先来问一个问题:“积分和位置、速度、加速度有什么关系?”为了回答这个问题,我应该首先定义微分,即求导的过程。导数是衡量一个函数随时间变化的程度。考虑位置和它的导数。位置是空间中的一个点,而速度是位置随时间的变化。因此,速度可以描述为位置的导数。那么加速度是什么?是速度随时间的变化。加速度是速度的导数。
积分,即求积分的过程,是微分的逆过程。例如,物体速度随时间的积分告诉我们物体在该时间段结束时的新位置。位置是速度的积分,速度是加速度的积分。
由于本书中的物理模拟是基于通过力来计算加速度的概念,因此需要使用积分来计算物体在一定时间(例如一个draw()循环周期)后的位置。换句话说,你一直在做积分!
velocity.add(acceleration);
position.add(velocity);
这种方法叫做欧拉积分,或称欧拉方法(以数学家莱昂哈德·欧拉的名字命名,发音为Oiler)。它本质上是最简单的积分形式,并且非常容易在代码中实现——只需两行!然而,尽管它在计算上很简单,但对于某些类型的模拟来说,它并不是最准确或最稳定的选择。
为什么欧拉法不准确?可以这样想:当你用蹦床棒在 sidewalk 上蹦跳时,蹦床棒会在时间等于 1 秒时停在一个位置,然后消失,并在时间等于 2 秒时突然出现在一个新位置,接着在 3 秒、4 秒、5 秒时继续这样做吗?当然不是。蹦床棒是持续不断地在时间中移动的。
那么在 p5.js 草图中发生了什么?一个圆形在第 0 帧时在一个位置,第 1 帧时在另一个位置,第 2 帧时又在另一个位置,依此类推。当然,在每秒 30 帧的情况下,你会看到运动的假象。但是,新的位置只有在每 N 个时间单位时才会计算出来,而现实世界是完全连续的。这会导致一些不准确性,如图 6.12 所示。

图 6.12:曲线的欧拉近似
“现实世界”是平滑的曲线;欧拉模拟则是由一系列直线段组成。改进欧拉法的一个选择是使用更小的时间步长——而不是每帧计算一次,你可以每帧重新计算 20 次物体的位置。但这并不实际,因为草图可能会运行得太慢。
我仍然认为欧拉方法是学习基础的最佳方法,而且对于你可能用 p5.js 做的大多数项目,它也完全足够。任何在效率或准确性上的损失,都会在易用性和可理解性上得到补偿。为了更高的准确性,例如,Box2D 引擎使用了辛欧拉方法,或者说是半显式欧拉法,这是欧拉法的一种轻微修改。其他引擎使用了一种叫做龙格-库塔(Runge-Kutta)的方法,它以德国数学家卡尔·龙格(Carl Runge)和马丁·库塔(Martin Kutta)命名。
在物理库中,包括 Matter.js 和 Toxiclibs.js,另一个常用的积分方法是Verlet 积分。描述 Verlet 积分的一个简单方式是将其看作一种典型的运动算法,不需要显式存储速度。毕竟,你实际上并不需要存储速度;只要你始终知道一个物体在某个时间点的位置以及它当前的位置,你就可以推算出它的速度。Verlet 积分正是这样做的,它在程序运行时动态计算速度,而不是维护一个单独的速度变量。
Verlet 积分特别适合粒子系统,尤其是那些粒子之间有弹簧连接的系统。物理库将细节隐藏在你面前,这样你就不必担心它如何工作,但如果你有兴趣深入了解 Verlet 物理学,我建议阅读这篇关于该主题的开创性论文,几乎所有 Verlet 计算机图形学模拟都源自这篇论文:“Advanced Character Physics” by Thomas Jakobsen (www.cs.cmu.edu/afs/cs/academic/class/15462-s13/www/lec_slides/Jakobsen.pdf)。
Toxiclibs.js 中的 Verlet 物理
大约在 2005 年,Karsten Schmidt 开始了 Toxiclibs 的开发,这是一个广泛而开创性的计算设计开源库,专门为 Processing 的 Java 版本而构建。尽管它已经超过 10 年没有积极维护,但该库展示的概念和技术在今天的无数创意编码项目中仍然可以找到。它的网站是这样描述的:
Toxiclibs 是由 Karsten “toxi” Schmidt 开发的,专为计算设计任务而设计的独立开源库,支持 Java 和 Processing。类的设计故意保持通用,以最大化在不同环境中的重用,涵盖了从生成设计、动画、交互/界面设计、数据可视化到建筑和数字化制造、教学工具等多个领域。
Schmidt 通过他最近的项目,thi.ng umbrella (thi.ng/umbrella),继续为创意编码领域做出贡献。这项工作可以被视为 Toxiclibs 的间接继任者,但其范围和细节远远更广泛。如果你喜欢这本书,你可能会特别喜欢探索thi.ng向量库 (thi.ng/vectors),它提供了使用纯粹的 JavaScript 数组的 800 多个向量代数函数。
尽管thi.ng/umbrella可能是一种更现代、更复杂的方法,但 Toxiclibs 仍然是一个多功能的工具,我继续使用与最新版本 Processing 兼容的版本(截至本文写作时为 4.3)。对于这本书,我们应该感谢我们的幸运星,感谢 Toxiclibs.js,它是由 Kyle Phillips(hapticdata)创建的库的 JavaScript 版本。我将只介绍与 Verlet 物理学相关的少数几个示例,但 Toxiclibs.js 还包括一套与颜色、几何、数学等相关的其他功能包。
我接下来要展示的示例也可以使用 Matter.js 创建,但我决定转向 Toxiclibs.js,原因有很多。这个库在我心中占有特殊的地位,是我的个人最爱,而且它具有历史意义。我还认为展示多个物理库对于提供更广泛的工具和方法理解非常重要。
然而,从 Matter.js 切换到 Toxiclibs.js 提出了一个重要的问题:你应该如何决定在项目中使用哪个库?是 Matter.js,Toxiclibs.js,还是其他的库?如果你属于以下两类之一,那么你的决定就简单一些:
-
我的项目涉及碰撞。我有圆形、方形和其他奇形怪状的物体,它们相互碰撞并弹开。 在这种情况下,你会希望使用 Matter.js,因为 Toxiclibs.js 不处理刚体碰撞。
-
我的项目涉及大量粒子在屏幕上飞动。有时它们相互吸引,有时相互排斥。有时它们还通过弹簧连接。 在这种情况下,Toxiclibs.js 可能是最佳选择。从某些方面来说,它比 Matter.js 更易于使用,尤其适合处理粒子连接系统。它的性能也非常高,因为它可以忽略所有的碰撞几何。
这里有一个小表格,展示了每个物理库的一些特性:
| 特性 | Matter.js | Toxiclibs.js |
|---|---|---|
| 刚体碰撞 | 是 | 否 |
| 3D 物理 | 否 | 是 |
| 粒子吸引与排斥力 | 否 | 是 |
| 弹簧连接(基于力) | 是 | 是 |
| 约束(通用连接) | 是 | 否 |
所有库文件的文档和下载可以在 Toxiclibs.js 官网找到 (haptic-data.com/toxiclibsjs)。对于本书中的示例,我将使用托管的 CDN 版本的库,参考 index.html,就像我之前为 Matter.js 演示的那样。这里是需要添加的 <script> 元素:
<script src="https://cdn.jsdelivr.net/gh/hapticdata/toxiclibsjs@0.3.2/build/toxiclibs.js"></script>
我对 Matter.js 的概述集中在该库的几个关键特性上:世界、向量、物体、约束。这也为你理解 Toxiclibs.js 奠定了基础,因为它遵循类似的结构。下表展示了相应的 Toxiclibs.js 特性:
| Matter.js | Toxiclibs.js |
|---|
|
World
|
VerletPhysics2D
|
|
Vector
|
Vec2D
|
|
Body
|
VerletParticle2D
|
|
Constraint
|
VerletSpring2D
|
我会讨论一些这些特性如何转化到 Toxiclibs.js 中,然后将它们组合起来创建一些有趣的示例。
向量
又来了。记得曾经花了很多时间学习p5.Vector类的细节吗?然后记得你如何必须重新学习这些概念,应用到 Matter.js 和Matter.Vector类上吗?好吧,现在是时候再来一遍了,因为 Toxiclibs.js 也包含了自己的向量类。它有一个二维的和一个三维的:Vec2D和Vec3D。它们都在toxi.geom包中,并且可以像在 Matter.js 中使用Vector那样进行别名化:
let { Vec2D, Vec3D } = toxi.geom;
再次强调,Toxiclibs.js 的向量在概念上与我们熟知并喜爱的 p5.js 向量相同,但它们有自己独特的风格和语法。以下是一些基本的向量数学操作从p5.Vector转化为Vec2D的概览(我坚持使用 2D,以与本书的其余部分保持一致,但我鼓励你也探索 3D 向量)。
| p5.Vector | Vec2D |
|---|
|
let a = createVector(1, -1);
let b = createVector(3, 4);
a.add(b);
|
let a = new Vec2D(1, -1);
let b = new Vec2D(3, 4);
a.addSelf(b);
|
|
let a = createVector(1, -1);
let b = createVector(3, 4);
let c = p5.Vector.add(a, b);
|
let a = new Vec2D(1, -1);
let b = new Vec2D(3, 4);
let c = a.add(b);
|
|
let a = createVector(1, -1);
let m = a.mag();
a.normalize();
|
let a = new Vec2D(1, -1);
let m = a.magnitude();
a.normalize();
|
特别注意,Toxiclibs.js 的向量是通过调用Vec2D构造函数并使用new关键字来创建的,而不是像Matter.Vector()或createVector()那样使用工厂方法。
物理世界
用于描述 Toxiclibs.js 中世界、粒子和弹簧的类位于toxi.physics2d.。我还将使用一个Rect对象(用于描述一个通用的矩形边界)和GravityBehavior来施加全局重力。包括Vec2D后,我现在有了以下所有类的别名:

第一步是创建世界:

一旦我有了VerletPhysics世界,我就可以设置全局属性。例如,如果我想要设置硬边界,防止粒子穿越,我可以提供矩形边界:

此外,我可以通过GravityBehavior对象来添加重力。重力行为需要一个向量——重力的强度和方向是多少?

最后,为了计算世界的物理并移动世界中的物体,我必须调用世界的update()方法。通常,这会在draw()中每帧执行一次:

现在,剩下的就是填充世界了。
粒子
Toxiclibs.js 中相当于 Matter.js 物体的——一个在世界中存在并经历物理的东西——是一个粒子,由VerletParticle2D类表示。然而,与 Matter.js 物体不同,Toxiclibs.js 粒子不存储几何形状。它们只是空间中的点。
如何将 Toxiclibs.js 粒子集成到 p5.js 草图中?在 Matter.js 的示例中,我创建了自己的类(称为Particle),并包含了对 Matter.js 物体的引用:
class Particle {
constructor(x, y, r) {
this.body = Bodies.circle(x, y, r);
}
}
这个技术有点冗余,因为 Matter.js 会跟踪其世界中的物体。然而,它让我可以管理哪个物体是什么(因此如何绘制每个物体),而不必依赖于遍历 Matter.js 的内部列表。我可能会采用同样的方法使用 Toxiclibs.js,创建自己的Particle类,存储对VerletParticle2D对象的引用。这样,我就能给粒子添加自定义属性,并按照我希望的方式绘制它们。我可能会将代码写成如下:

看这段代码,你可能会首先注意到,绘制粒子就像抓取x和y属性并用circle()来绘制一样简单。其次,你可能会注意到,这个Particle类除了存储对VerletParticle2D对象的引用外,并没有做什么。这暗示了一个重要的点。回想一下第四章中关于继承的讨论,然后问问自己:一个Particle对象除了是一个扩展过的VerletParticle2D对象外,还有什么?为什么要为世界中的每个粒子创建两个对象——一个Particle和一个VerletParticle2D,而我完全可以通过扩展VerletParticle2D类,加入绘制粒子所需的额外代码?

此外,冒险地告诉你一件事,VerletParticle2D类实际上是Vec2D类的子类。这意味着除了继承VerletParticle2D的所有内容外,Particle类还继承了所有Vec2D的方法!
我现在可以创建新的粒子了:
let particle = new Particle(width / 2, height / 2, 8);
然而,仅仅创建一个粒子还不够。就像在 Matter.js 中一样,我必须显式地将新粒子添加到世界中。在 Toxiclibs.js 中,这是通过addParticle()方法完成的:
physics.addParticle(particle);
如果你查看 Toxiclibs.js 的文档,你会看到addParticle()方法期望的是一个VerletParticle2D对象。但我传入的是一个Particle对象。这样可以吗?
是的!记住面向对象编程(OOP)的一个原则:多态性。在这里,由于Particle类继承了VerletParticle2D,我可以将粒子以两种方式处理:作为一个Particle或者作为一个VerletParticle2D。这是 OOP 中一个非常强大的特性。如果你创建自定义类继承自 Toxiclibs.js 的类,你可以将这些类的对象与 Toxiclibs.js 提供的所有方法一起使用。
弹簧
除了VerletParticle2D类外,Toxiclibs.js 还提供了一组可以用弹簧力连接粒子的类。Toxiclibs.js 有三种类型的弹簧:
-
VerletSpring2D:两个粒子之间的弹簧连接。弹簧的属性可以配置成创造一个刚性、类似棒子的连接,或者一个高度弹性、可伸展的连接。还可以锁定某个粒子,使得弹簧的另一端只能移动。 -
VerletConstrainedSpring2D:一种可以限制最大距离的弹簧。这有助于使整个弹簧系统达到更好的稳定性。 -
VerletMinDistanceSpring2D:一种只在当前距离小于其静止长度时才会强制执行静止长度的弹簧。如果你希望确保物体之间至少保持一定的距离,但不在乎距离超过强制最小值的情况,这个弹簧很有用。
继承和多态性再次在创建弹簧时发挥了作用。一个弹簧在创建时期望两个VerletParticle2D对象,但和之前一样,两个Particle对象也能工作,因为Particle继承了VerletParticle2D。
这里有一段创建弹簧的示例代码。这个代码假设已经存在两个粒子,particle1和particle2,并通过给定的静止长度和强度在它们之间创建一个连接。

就像粒子一样,为了让连接成为物理世界的一部分,它必须显式地添加到世界中:
physics.addSpring(spring);
我几乎具备了构建一个简单 Toxiclibs.js 示例所需的一切:两个粒子连接在一起形成一个弹簧摆。但我还想添加一个元素:鼠标交互。
使用 Matter.js 时,我解释过,如果手动通过将物体的位置设置为鼠标位置来覆盖物体的位置,物理模拟会崩溃。但在 Toxiclibs.js 中,这不是问题。如果我愿意,可以手动设置粒子的 (x, y) 位置。然而,在这样做之前,通常建议调用粒子的 lock() 方法,这会将粒子固定在当前位置。这与在 Matter.js 中将 isStatic 属性设置为 true 是相同的。
这个想法是暂时锁定粒子,使其停止响应世界的物理作用,改变其位置,然后解锁它(使用 unlock() 方法),这样它就可以从新的位置开始重新运动。例如,假设我想在每次点击鼠标时重新定位一个粒子:

有了这些,我准备将所有这些元素结合在一个简单的草图中,图中有两个粒子通过弹簧连接。一个粒子永久锁定在原位,另一个可以通过拖动鼠标来移动。这个示例与 第三章 中的 示例 3.11 几乎完全相同。


在这个示例中,我继续用一条线来直观表示连接粒子的弹簧。然而,请记住,无论你是否选择将其可视化,弹簧的行为仍然存在。这为创造性可能性打开了大门。例如,你可以决定让弹簧不可见,或者以完全不同的方式表现它,也许用一系列点或者你自己发明的形状来表示。
软体模拟
Verlet 物理学特别适合一种被称为软体模拟的计算机图形学类型。与 刚体 模拟不同,刚体模拟中,硬边框的盒子相互碰撞并保持形状,而 软体 模拟则涉及能够变形并随着物理作用而改变形状的物体。软体物体允许更灵活、更流畅、更有机的运动。它们能在受力和碰撞时拉伸、挤压、抖动,并且看起来...嗯,就是软的。
软体物理学的第一个流行示例之一是 SodaConstructor,这是一款在 2000 年代初期创建的游戏。玩家可以构建并动画化由质量和弹簧组成的自定义 2D 生物。多年来,其他一些例子包括 LocoRoco、World of Goo,以及最近的 JellyCar。
软体模拟的基本构建块是由弹簧连接的粒子——就像 示例 6.11 中的成对粒子一样。图 6.13 展示了如何配置粒子-弹簧连接的网络来创建各种形态。

图 6.13:软体模拟设计
如图所示,字符串可以通过用弹簧连接一排粒子来模拟;毛毯可以通过用弹簧连接一网格粒子来模拟;而一个可爱、柔软、弹性十足的卡通角色则可以通过用弹簧连接粒子的自定义布局来模拟。从一个到另一个并不是很大的飞跃。
一根弦
我将从模拟一个软摆锤开始——它是一个悬挂在柔性弦上的摆锤,而不是悬挂在刚性臂上的摆锤。事实上,Toxiclibs.js 提供了一个方便的ParticleString2D类,可以通过一次构造函数调用来创建由弹簧连接的粒子链。不过,为了演示,我将通过使用数组和for循环来创建我自己的粒子链。通过这种方式,您将对系统有更深的理解,未来可以设计出超出单一弦的自定义设计。
首先,我需要一个粒子数组。我将使用示例 6.11 中构建的相同Particle类:
let particles = [];
现在,假设我想要有 20 个粒子,每个粒子之间间隔 10 个像素,就像图 6.14 那样。

图 6.14:二十个粒子,每个粒子之间间隔 10 个像素
我可以从i等于0开始循环,直到total,在此过程中创建新的粒子并将每个粒子的y位置设置为i * 10。第一个粒子在(0, 10),第二个粒子在(0, 20),第三个粒子在(0, 30),以此类推:

即使这有些冗余,我还是将粒子添加到 Toxiclibs.js 的physics世界和particles数组中。这将有助于我管理草图(特别是当我可能有不止一根粒子链时)。
现在是有趣的部分:是时候连接所有粒子了。粒子索引 0 将与粒子 1 连接,粒子 1 与粒子 2 连接,粒子 2 与粒子 3 连接,依此类推(见图 6.15)。

图 6.15:每个粒子与数组中的下一个粒子连接。
换句话说,粒子i需要与粒子i+1连接(除了当i是数组的最后一个元素时):

现在,如果我想让弦从一个固定点悬挂该怎么办?我可以固定一个粒子——可能是第一个、最后一个或中间的粒子。我选择第一个粒子:
particles[0].lock();
最后,我需要绘制这些粒子。然而,我并不打算将它们绘制成圆形,而是希望将它们视为一条线上的点。为此,我可以使用beginShape()、endShape()和vertex(),并从数组中获取各个粒子的位置。我将使用show()方法将最后一个粒子绘制为圆形,从而在弦的末端创建一个摆锤。

书本网站上提供的完整代码还演示了如何用鼠标拖动摆锤粒子。
练习 6.10
创建一个悬挂布料的模拟,使用粒子和弹簧。你需要将每个粒子与其垂直和水平方向的邻居连接起来。

软体角色
现在我已经建立了一个简单的连接系统——一串粒子——接下来我将在 p5.js 中扩展这个思路,创建一个软乎乎、可爱的朋友,也就是软体角色。第一步是设计一个连接粒子的骨架。我将从一个非常简单的设计开始,只有六个顶点,如图 6.16 所示。每个顶点(绘制为一个点)代表一个Particle对象,每个连接(绘制为一条线)代表一个Spring对象。

图 6.16:软体角色的骨架。顶点按照它们在数组中的位置编号。
创建粒子是简单的部分;它与之前完全相同!不过,我想做一个小的改变。与其让setup()函数将粒子和弹簧添加到物理世界中,不如将这个责任合并到Particle构造函数中:

虽然严格来说不是必要的,但我还想创建一个Spring类,它继承自VerletSpring2D类。在这个示例中,我希望弹簧的静止长度始终等于骨架粒子在创建时的距离。此外,为了保持实现的简单性,我在Spring构造函数中硬编码了一个统一的弹簧强度值0.01。你可能想用一个更复杂的设计来增强这个示例,为软体角色的不同部分设置不同的弹性。

现在我有了Particle和Spring类,我可以通过将一系列具有硬编码起始位置的粒子添加到particles数组中,和一系列弹簧连接添加到springs数组中,来组合这个角色。

这个系统的美妙之处在于,你可以通过添加更多的粒子和弹簧,轻松扩展并创造出属于你自己的设计!然而,这里有一个主要问题:我只在角色的外围做了连接。如果我对身体施加力(如重力),它会立即塌陷。这就是额外的内部弹簧发挥作用的地方,如图 6.17 所示。它们保持角色的结构稳定,同时仍然允许其以现实的方式移动和挤压。

图 6.17:内部弹簧防止结构坍塌。这只是一个可能的设计,尝试其他设计吧!
最终示例包含了图 6.17 中的附加弹簧、重力和鼠标交互。


在软体角色的例子中,你会注意到我不再在画布上绘制物理模拟的所有元素!粒子的show()方法没有被调用,而赋予角色结构的内部弹簧也没有用线条进行渲染。事实上,弹簧本身在setup()之后从未被引用过,因为角色的形状是由其粒子的位置构建的。因此,弹簧数组在这个例子中并不是严格需要的,尽管考虑到将来可能需要增强草图,我觉得它有一定的用处。
将绘图视为一个独立的问题,而不是角色骨架结构的一部分,也为添加其他设计元素如眼睛或触角打开了可能性。这些创意增强不需要直接与角色的物理性质连接,尽管如果你愿意,也可以将它们与物理性相连!
练习 6.11
设计你自己的软体角色,增加额外的顶点和连接。你可以添加哪些其他设计元素?你可以融入哪些其他力量和交互?

力导向图
你有没有想过以下这种情况?“我有一大堆东西想要绘制,并且我希望所有的东西都能均匀地分布在一个漂亮、整洁、有序的方式中。否则,我晚上就没法好好睡觉。”
这在计算设计中并不罕见。一个解决方案是力导向图,它是一个元素的可视化——我们称它们为节点——这些节点的位置不是手动指定的。相反,节点会根据一组力来排列自己。虽然可以使用任何类型的力,但经典方法使用的是弹簧力:每个节点通过弹簧与其他所有节点相连,当弹簧达到平衡时,节点会均匀分布(见图 6.18)。听起来像是 Toxiclibs.js 的工作!

图 6.18:在这个力导向图中,粒子群通过弹簧力相连接。
为了创建一个力导向图,我首先需要一个类来描述系统中的每个节点。由于“节点”这个术语与 JavaScript 框架 Node.js 相关,我将使用粒子这个术语,以避免任何混淆,并且继续使用我在早期软体例子中创建的Particle类。
接下来,我将把一个N粒子的列表封装到一个新的类Cluster中,该类表示整个图形。所有粒子最初都靠近画布的中心:

假设 Cluster 类也有一个 show() 方法来绘制集群中的所有粒子,并且我将在 setup() 中创建一个新的 Cluster 对象,并在 draw() 中渲染它。如果我直接运行这个草图,什么也不会发生。为什么?因为我还没有实现整个基于力的图形部分!我需要将每个节点与其他所有节点通过弹簧连接起来。这有点类似于创建一个软体角色,但与其手工制作一个骨架,我更希望编写一个算法来自动创建所有连接。
这到底是什么意思呢?假设我有五个 Particle 对象:0、1、2、3 和 4。 图 6.19 展示了这些连接。

图 6.19:一个网络图,显示了每个节点与其他所有节点的连接
注意关于连接列表的两个重要细节:
-
没有粒子连接到它自身。 也就是说,0 不连接到 0,1 不连接到 1,依此类推。
-
连接不会反向重复。 例如,如果 0 连接到 1,我就不需要明确地说 1 也连接到 0。我已经知道这一点,基于弹簧工作原理的定义!
如何编写代码来为 N 个粒子创建这些连接?看看 图 6.19 中展示的四列。它们迭代了从粒子 0 到粒子 3 的所有连接。这告诉我,我需要访问列表中从 0 到 N – 1 的每个粒子:

现在看看 图 6.19 中列出的连接。我需要将节点 0 连接到节点 1、2 和 3。对于节点 1,我将其连接到 2 和 3。对于节点 2,仅连接到 3。一般来说,对于每个节点 i,我需要从 i + 1 开始,直到数组的末尾。我将使用计数器变量 j 来实现这个目的:

对于每一对粒子 i 和 j,我可以创建一个弹簧。我将回到直接使用 VerletSpring2D,但你也可以结合一个自定义的 Spring 类:

假设这些连接是在 Cluster 构造函数中创建的,那么剩下的就是在 setup() 中创建集群并在 draw() 循环中调用 show()!

这个例子展示了一个基于力的图形,但并没有涉及任何实际数据!在这里,每个集群中的节点数量和节点之间的平衡长度是随机分配的,而弹簧的强度恒定为 0.01。在实际应用中,这些值可以根据你的特定数据来确定,希望能够形成一个有意义的可视化,展示数据之间的关系。
练习 6.12
设计一个类似集群的结构,作为一个可爱、柔软、黏糊糊的生物的骨架。加入重力和鼠标交互。
练习 6.13
扩展力导向图,使其拥有多个Cluster对象。使用VerletMinDistanceSpring2D对象连接簇与簇。你可能会使用这种技术可视化什么样的数据?

吸引力与排斥力行为
在为 Matter.js 创建吸引力示例时,我展示了Matter.Body类如何包括一个applyForce()方法。然后我只需做的就是将吸引力公式 F[g] = (G × m[1] × m[2]) ÷ d² 作为一个向量计算并应用到物体上。同样,Toxiclibs.js 的VerletParticle2D类也包括一个名为addForce()的方法,可以将任何计算出的力应用到粒子上。
然而,Toxiclibs.js 进一步提升了这一想法,提供了内建的常见力(称为行为)功能,例如吸引力!例如,如果你将一个AttractionBehavior对象添加到某个VerletParticle2D对象,物理世界中的所有其他粒子都会受到向该粒子的吸引力作用。
假设我创建了一个Particle类的实例(该类继承自VerletParticle2D类):
let particle = new Particle(320, 120);
现在,我可以创建一个与该粒子关联的AttractionBehavior:
let distance = 20;
let strength = 0.1;
let behavior = new AttractionBehavior(particle, distance, strength);
请注意,行为是通过三个参数创建的:一个粒子、一个距离和一个强度。距离指定行为将应用的范围。在这种情况下,只有 20 像素范围内的粒子才会受到吸引力的作用。强度当然指定了力的大小。
最后,为了激活力,行为需要添加到物理世界中:
physics.addBehavior(behavior);
现在,物理模拟中的所有对象只要在距离阈值范围内,都会始终被吸引到该粒子上。
AttractionBehavior类是一个非常强大的工具。例如,尽管 Toxiclibs.js 不像 Matter.js 那样自动处理碰撞,但你可以通过为每个粒子添加一个负强度的AttractionBehavior—即排斥行为—来创建一个类似碰撞的模拟。如果力很强并且仅在短范围内激活(缩放到粒子的半径),其效果就像刚体碰撞。以下是如何修改Particle类来实现这一点:

现在,我可以使用一个单一的Attractor对象重建第二章的吸引力示例,使其在画布上的任何地方施加吸引力行为。尽管吸引体位于中心,我使用了整个width的距离阈值来考虑吸引体的任何移动,以及位于画布边界之外的粒子。

正如在第 275 页的“空间划分”中讨论的那样,带有大量相互作用粒子的 Toxiclibs.js 项目可能会运行得非常慢,因为算法的N²特性(每个粒子都要检查其他所有粒子)。为了加速模拟,你可以结合使用手动addForce()方法和分箱算法。请记住,这也要求你手动计算引力,因为内置的AttractionBehavior将不再适用。
练习 6.14
将AttractionBehavior与弹簧力结合使用。
生态系统项目
从第五章获取你的生物系统,并使用物理引擎驱动它们的运动和行为。以下是一些可能的选项:
-
使用 Matter.js 允许生物之间发生碰撞。考虑在两只生物碰撞时触发事件。
-
使用 Matter.js 来增强你生物的设计。使用带有距离关节的骨架或用转动关节做附肢。
-
使用 Toxiclibs.js 来增强你生物的设计。用 Toxiclibs.js 粒子链做触手,或用弹簧网状结构做骨架。
-
使用 Toxiclibs.js 为你的生物添加引力和排斥行为。
-
在物体之间使用弹簧(或关节)连接来控制它们的相互作用。动态创建和删除这些弹簧。考虑将这些连接设置为可见或不可见。

第八章:7 个细胞自动机
单独来看,我们是一个水滴。一起,我们就是一片海洋。
—— 佐藤龙之介

肯特布(照片由 ZSM 提供)
起源于加纳的阿干族,肯特布是一种因其鲜艳的色彩和复杂的图案而备受赞誉的织物。布料由狭窄的条纹织成,每种设计都是独一无二的,当这些条纹拼接在一起时,便形成了一幅复杂且不断变化的挂毯,讲述一个故事或传递信息。图像展示了三条典型的埃维肯特布条纹,突出了反映加纳丰富文化图景的多样编织传统。
在第五章中,我定义了复杂系统为一组具有短程关系、并行运作的元素网络,这些元素表现出涌现行为。我创建了一个群体行为模拟,展示了复杂系统如何比单纯的部分之和更为复杂。在本章中,我将转向开发另一类复杂系统,即细胞自动机。
从某些方面来看,这一转变可能似乎是一步倒退。我的系统中的个体元素将不再是物理世界的成员,不再受到力和向量的驱动在画布上移动。相反,我将从最简单的数字元素——一个比特开始构建系统。这个比特被称为细胞,它的值(0 或 1)被称为它的状态。使用这样简单的元素有助于揭示复杂系统的运作方式,并为阐述一些适用于基于代码的项目的编程技巧提供机会。构建细胞自动机还为本书的其余部分奠定了基础,我将在后续章节中更多地关注系统和算法,而不是向量和运动——尽管这些系统和算法我可以并且会应用于运动物体。
什么是细胞自动机?
细胞自动机(复数形式为细胞自动机,简称CA)是一个由细胞对象组成的系统模型,具有以下特点:
-
细胞生活在一个网格中。(本章中我会提供一维和二维的示例,尽管细胞自动机可以存在于任何有限维度的空间中。)
-
每个细胞都有一个状态,尽管细胞的状态可以随时间变化。状态的可能数量通常是有限的。最简单的例子只有 1 和 0 两种可能(也称为开和关,或生与死)。
-
每个细胞都有一个邻域。邻域可以通过多种方式定义,但通常是指与该细胞相邻的所有细胞。
需要强调的是,细胞自动机中的细胞并不指生物学上的细胞(尽管你将看到细胞自动机如何模仿生物行为,并在生物学中有所应用)。相反,它们仅仅代表网格中的离散单元,类似于电子表格中的单元格(如 Microsoft Excel)。图 7.1 展示了一个细胞自动机及其各个特点。
我列出的第二个元胞自动机特性——即单元格的状态可以随着时间变化——是一个重要的新发展。到目前为止,在本书中,物体(如移动物体、粒子、车辆、群体、物体)通常只存在于一种状态下。它们可能具有复杂的行为和物理特性,但最终它们在其数字生命周期中保持相同的物体类型。我曾提到这些实体可以随着时间变化(例如,转向“愿望”的权重可以变化),但我尚未完全实践这一点。现在,通过元胞自动机,你将看到一个物体的状态如何根据一套规则发生变化。

图 7.1:一个 2D 网格的单元格,每个单元格的状态为开或关。邻域是大网格的一个子区域,通常由所有与给定单元格相邻的单元格组成(以圆圈标出)。
元胞自动机系统的发展通常归功于斯坦尼斯瓦夫·乌拉姆和约翰·冯·诺依曼,他们都是 20 世纪 40 年代新墨西哥州洛斯阿拉莫斯国家实验室的研究人员。乌拉姆研究的是晶体的生长,而冯·诺依曼则设想了一个自我复制的机器人世界。没错,你没有看错:机器人可以建造出自己的复制品。
冯·诺依曼最初的单元格有 29 种可能的状态,因此自我复制机器人这一想法可能是一个过于复杂的起点。相反,想象一排多米诺骨牌;每个骨牌可以处于两种状态之一:竖立(1)或倒下(0)。正如多米诺骨牌会受到相邻骨牌的影响一样,元胞自动机中每个单元格的行为也会受到其邻近单元格状态的影响。
本章探讨了即使是像多米诺骨牌这样最基本的规则,也能引发一系列复杂的模式和行为,类似于自然过程中的生物繁殖和进化。冯·诺依曼在自我复制和元胞自动机方面的工作在概念上与可能是最著名的元胞自动机——生命游戏相似,我将在本章稍后详细讨论。
也许最重要(而且最冗长)的关于元胞自动机的科学工作出现在 2002 年:斯蒂芬·沃尔夫勒姆的 1280 页著作《新科学的种类》(www.wolframscience.com/nks)。这本书可以在网上完全免费阅读,沃尔夫勒姆的书讨论了元胞自动机不仅仅是一些巧妙的技巧,而是与生物学、化学、物理学以及所有科学分支的研究相关的。稍后,我将转向构建沃尔夫勒姆工作的模拟,尽管我仅仅触及了他所阐述的理论的皮毛——我的重点将放在代码实现上,而非哲学含义。如果这些例子激发了你的好奇心,你会在沃尔夫勒姆的书中以及他在沃尔夫勒姆物理学项目中的持续研究中找到更多值得阅读的内容(www.wolframphysics.org)。
基础元胞自动机
你能想象的最简单的 CA 是什么?对于沃尔夫勒姆来说,一个基本的 CA 有三个关键元素:
-
网格
-
状态
-
邻域
最简单的网格是 1D:一行单元(图 7.2)。

图 7.2:一行 1D 单元
最简单的状态集合(除了只有一个状态)是两种状态:0 或 1(图 7.3)。也许初始状态是随机设置的。

图 7.3:一行 1D 单元,标记为状态 0 或 1。哪个熟悉的编程数据结构可以表示这个序列?
对于任何给定单元,一维的最简单邻域是单元本身以及它的两个相邻邻居:一个在左边,一个在右边(图 7.4)。我必须决定如何处理左右边缘的单元,因为这些单元只有一个邻居,但我可以稍后再处理这个细节。

图 7.4:一维邻域是三个单元。
我有一行单元,每个单元有一个初始状态,并且每个单元有两个邻居。令人兴奋的是,即使是这个最简单的 CA,也可以出现复杂系统的特性。但我还没有讨论可能是 CA 工作中最重要的细节:随时间变化。
我不是在谈论现实世界的时间,而是在谈论 CA 在一系列离散的时间步骤中发展,这些步骤也可以称为代。在 p5.js 中的 CA,时间可能与动画的帧数相关。如图 7.5 所示,问题是这样的:给定时间为 0(或第一代)时单元的状态,如何计算第一代所有单元的状态?然后,如何从第一代到第二代?依此类推。

图 7.5:第一代的状态是通过使用第二代单元的状态来计算的。
假设 CA 有一个叫做 cell 的独立单元。计算某一时刻 t (cell[t])单元状态的公式如下:
cell[t] = f(cell neighborhood[t−1])
换句话说,单元的新状态是上一代单元邻域中所有单元状态的函数(时间 t − 1)。通过查看上一代邻域的状态,可以计算出新的状态值(图 7.6)。

图 7.6:第一代的单元状态是上一代邻域的函数。
你可以通过多种方式计算一个单元格的状态,依据的是它邻居的状态。考虑模糊处理图像。(猜猜看?图像处理也使用类似 CA 的规则!)一个像素的新状态(颜色)是其邻居颜色的平均值。同样,单元格的新状态可以是所有邻居状态的总和。然而,在沃尔夫勒姆的基础 CA 中,过程采用了不同的方法:不是数学运算,而是通过预定义的规则来确定新状态,这些规则涵盖了单元格及其邻居的每一种可能配置。这些规则统称为规则集。
这种方法一开始可能看起来很荒谬——难道不会有太多可能性,使得它不切实际吗?好吧,让我们试试看。一个邻域由三个单元格组成,每个单元格的状态是 0 或 1。一个邻域的状态可以有多少种可能的配置?一种快速的方法是将每个邻域配置看作一个二进制数。二进制数使用基数 2,意味着它们只用两个可能的数字(0 和 1)来表示。在这种情况下,每个邻域配置对应一个 3 位的数字,你可以用 3 位表示多少个值?八个,从 0(000)到 7(111)。图 7.7 展示了这一点。

图 7.7:用 3 位二进制进行计数,或者说是三单元格邻域的八种可能配置
一旦定义了所有可能的邻域配置,就为每种配置指定一个结果(新状态值:0 或 1)。在沃尔夫勒姆的原始符号和其他常见的参考资料中,这些配置是按降序排列的。图 7.8 遵循了这个惯例,从 111 开始,倒数到 000。

图 7.8:规则集展示了三个单元格每种可能配置的结果。
请记住,不像求和或平均方法,基础 CA 中的规则集不遵循任何算术逻辑——它们只是输入到输出的任意映射。输入是当前邻域的配置(八种可能性之一),输出是邻域中间单元格的下一个状态(0 或 1——由你来定义规则)。
一旦你有了规则集,就可以让 CA 开始运作。标准的沃尔夫勒姆模型是从第 0 代开始,除了中间单元格的状态为 1 外,其他单元格的状态都是 0。你可以使用任何大小(长度)的网格来实现这一点,但为了清晰起见,我将使用一个 9 单元格的 1D CA,这样可以轻松识别中间单元格(见图 7.9)。

图 7.9:沃尔夫勒姆 CA 的第 0 代,其中间单元格的状态设为 1
基于图 7.8 中的规则集,细胞如何从第 0 代变为第 1 代?图 7.10 展示了如何通过邻域 010,使中心细胞从 1 变为 0。试着应用规则集到剩余的细胞,以填充第 1 代的其余状态。

图 7.10:通过使用 CA 规则集确定第 1 代的状态
现在稍作改变:我将不再用 0 和 1 来表示细胞的状态,而是用视觉提示来表示——白色代表 0,黑色代表 1(参见图 7.11)。虽然这看起来可能不符合直觉,因为在计算机图形学中,0 通常表示黑色,但我使用这种约定是因为本书中的示例背景是白色的,所以“激活”一个细胞就意味着将其颜色从白色变为黑色。

图 7.11:白色细胞表示 0,黑色细胞表示 1。
通过将数值表示转变为视觉形式,CA 的迷人动态和模式将展现出来!为了更清晰地展示它们,我不再一次画出一代,而是开始叠加各代,每一代都出现在前一代的下方,如图 7.12 所示。

图 7.12:将 0 和 1 的网格转换为白色和黑色方块
图 7.12 中出现的低分辨率形状是谢尔宾斯基三角形。它以波兰数学家瓦茨瓦夫·谢尔宾斯基命名,是一个著名的分形示例。我将在第八章中更详细地探讨分形,但简而言之,分形是一些在不同尺度上重复相同形状的模式。为了更好地理解这一点,图 7.13 展示了经过几代演化后的元胞自动机(CA),并且使用了更大的网格。

图 7.13:沃尔夫勒姆基础元胞自动机
图 7.14 再次展示了 CA,这一次每个细胞的宽度仅为一个像素,因此分辨率大大提高。

图 7.14:高分辨率下的沃尔夫勒姆基础元胞自动机
花点时间让你刚刚看到的这一切沉淀下来。通过使用一个极其简单的 0 和 1 系统,配合三个细胞的小邻域,我能够生成一个像谢尔宾斯基三角形这样复杂且精细的形状。这就是复杂系统的美妙之处。
当然,这个特定的结果并不是偶然发生的。我选择了图 7.8 中的规则集,因为我知道它将生成的模式。仅仅定义一个规则集并不能保证产生令人兴奋的视觉效果。事实上,对于一个 1D CA,其中每个单元格可以有两个可能的状态,共有 256 种可能的规则集可供选择,只有少数几种能够与谢尔宾斯基三角形相媲美。我怎么知道有 256 种可能的规则集呢?这涉及到更多的二进制数学运算。
定义规则集
再回头看看图 7.7,再次注意到八种可能的邻域配置,从 000 到 111。这些是规则集的输入,它们在不同的规则集中保持不变。只有输出在不同的规则集中有所变化——即与每个邻域配置配对的单个 0 或 1。图 7.8 用 0 和 1 完全表示了一个规则集。现在,图 7.15 则用黑白方块来可视化同一个规则集。

图 7.15:用黑白方块表示相同的规则集(来自图 7.8)
由于八种可能的输入无论如何都相同,因此指示规则集的简写方式是仅指定输出,并将其写成一串八个 0 或 1——换句话说,就是一个 8 位的二进制数字。例如,图 7.15 中的规则集可以写作 01011010。右侧的 0 对应输入配置 000,紧接着的 1 对应输入 001,依此类推。在 Wolfram 的网站上,CA 规则通常使用二进制简写和黑白方块表示法的组合来展示,形成像图 7.16 这样的图像。

图 7.16:Wolfram 网站如何表示一个规则集
我之前提到过,每个规则集本质上可以简化为一个 8 位数字,那么八个 0 和 1 的组合有多少种呢?正好是 2⁸,即 256 种。你可能还记得在学习 p5.js 中的 RGB 颜色时学到过这一点。当你写下 background(r, g, b) 时,每个颜色分量(红色、绿色和蓝色)都由一个从 0 到 255 的 8 位数字表示,或者说是从 00000000 到 11111111 的二进制数。
图 7.16 中的规则集可以称为规则 01011010,但 Wolfram 反而称它为规则 90。90 是怎么来的?为了使规则集命名更加简洁,Wolfram 使用十进制(或基数 10)表示法,而不是二进制。命名规则时,你需要将其 8 位二进制数字转换为十进制数字。二进制数字 01011010 转换为十进制数字 90,因此它被命名为规则 90。
由于八个 0 和 1 的组合有 256 种可能性,因此也有 256 种独特的规则集。让我们来看另一个例子。比如规则 11011110,或者更常见的规则 222?图 7.17 展示了它的样子。

图 7.17:Wolfram 基础元胞自动机,规则 222
结果是一个可识别的形状,尽管它显然不像谢尔宾斯基三角形那样激动人心。正如我之前所说,大部分 256 个基础规则集不会产生令人信服的结果。然而,令人惊讶的是,即便是其中的一些规则集——由仅有两种可能状态的简单细胞系统构成——也能产生大自然中每天都能看到的迷人图案。例如,图 7.18 展示了一个类似 Wolfram 规则 30 的蜗牛壳。这展示了元胞自动机在仿真和模式生成中的巨大价值。

图 7.18:一只纺织圆锥螺(Conus textile),科德霍尔,澳大利亚大堡礁(理查德·林拍摄)
然而,在我进一步探讨不同规则集的结果之前,先让我们看看如何构建一个 p5.js 草图,生成并可视化一个 Wolfram 基础元胞自动机。
编程一个基础元胞自动机
你可能会想:“好吧,我有了这个细胞的概念。这个细胞有一些属性,比如状态、它所在的代数、它的邻居是谁,以及它在屏幕上的像素位置。它可能还有一些函数,比如显示自己和确定它的新状态。”这种思路非常好,可能会引导你编写类似如下的代码:
class Cell {
}
然而,这并不是我现在想要走的道路。在本章稍后的部分,我会讨论面向对象的方法在开发元胞自动机(CA)仿真中的潜在价值,但一开始,使用更基础的数据结构会更容易。毕竟,一个基础的元胞自动机不就是由一列 0 和 1 组成的列表吗?为什么不通过使用数组来描述一代 1D 元胞自动机呢?
let cells = [1, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0];
这个数组对应于图 7.19 中显示的细胞行。

图 7.19:一代 1D 元胞自动机
为了展示这个数组,我会检查每个元素是 0 还是 1,然后选择相应的填充颜色,并绘制一个矩形:

这个数组描述了当前代的细胞状态。现在,我需要一个机制来计算下一代的状态。这里是描述我想要实现的伪代码:
对于数组中的每个细胞:
-
看看邻域的状态:左侧、中间、右侧。
-
根据规则集查找细胞状态的新值。
-
将细胞的状态设置为这个新值。
这个伪代码可能暗示着编写类似以下的代码:

我离正确的解决方案已经很接近了,但还有一些问题需要解决。首先,我把计算新状态值的工作交给了一个叫做rules()的函数。显然,我需要编写这个函数,所以我的工作还没完成,但我这里的目标是模块化。我希望有一个for循环,它提供一个基本框架来管理任何 CA,无论具体的规则集是什么。如果我想尝试不同的规则集,我不应该修改这个框架;我只需重写rules()函数,改变计算新状态的方式。
所以我仍然需要编写rules()函数,但更重要的是,我在for循环中犯了一个小错误和一个大错误。让我们仔细检查一下代码。
首先,注意观察一个单元格的邻居是多么简单。因为数组是一个有序的数据列表,我可以通过索引的编号来知道哪些单元格是相邻的。例如,我知道单元格 15 的左边是单元格 14,右边是单元格 16。更一般地说,我可以说,对于任何单元格i,它的邻居是i - 1和i + 1。
事实上,事情并没有那么简单。我错在哪里了?想想看代码是如何执行的。第一次进入循环时,单元格索引i等于0。代码希望查看单元格 0 的邻居。左边是i - 1,即-1。哎呀!数组的定义中没有索引为-1的元素。它从索引0开始!
我在本章早些时候提到过边缘情况的问题,并说我可以稍后再处理。现在,稍后就是现在了。我应该如何处理那些没有左右邻居的边缘单元格呢?这里有三种可能的解决方案:
-
边缘保持恒定。 这或许是最简单的解决方案。不必评估边缘,始终保持它们的状态值不变(0 或 1)。
-
边缘环绕。 把 CA 看作是一条纸带,然后将这条纸带变成一个环。左边缘的单元格是右边缘单元格的邻居,反之亦然。这样可以创建一个看起来像是无限网格的效果,并且这可能是最常用的解决方案。
-
边缘有不同的邻域和规则。 如果需要,我可以将边缘单元格与其他单元格区分开,制定适用于只有两个邻居而不是三个邻居的规则。在某些情况下,你可能会这么做,但在本例中,这将增加很多额外的代码行,而收益却很小。
为了让代码现在更容易阅读和理解,我会选择第一种方法,跳过边缘情况,保持值恒定。可以通过让循环从下一个单元格开始,到前一个单元格结束来实现:

在完成之前,我需要修复另一个问题,而识别这个问题对编程 CA 模拟背后的技术至关重要。这个 bug 非常隐蔽,不会触发错误;CA 只是不能正确执行。问题全在这行代码中:
cells[i] = newstate;
这看起来可能完全无害。毕竟,一旦我计算出了一个新的状态值,我就想给单元格分配它的新状态。但想一想接下来for循环的下一次迭代。假设单元格 5 的新状态刚刚计算出来,循环开始处理单元格 6。那么接下来会发生什么呢?
单元格 6,第 1 代 = 单元格 5、单元格 6 和单元格 7 在第 0 代的状态的函数
一个单元格的新状态是前一个相邻单元格状态的函数,因此在这种情况下,需要第 0 代单元格 5 的值才能计算第 1 代单元格 6 的新状态。我是否保存了第 0 代单元格 5 的值?没有!记住,这行代码是刚刚对i等于5时执行的:
cells[i] = newstate;
一旦发生这种情况,第 0 代单元格 5 的状态就消失了;cells[5]现在存储的是第 1 代的值。我不能在处理数组时覆盖数组中的值,因为我需要这些值来计算新的值!
解决这个问题的一种方法是使用两个数组,一个存储当前代的状态,另一个存储下一代的状态。为了省去重新初始化数组的步骤,我将使用 JavaScript 的slice()数组方法,它可以创建一个数组的副本:

一旦当前代的值数组完全处理完毕,cells变量就可以被赋值为新的状态数组,从而有效地丢弃前一代的值:

我快完成了,但我仍然需要定义rules(),这个函数根据邻域(左边、中间和右边的单元格)计算新的状态值。我知道该函数需要返回一个整数(0 或 1),并且需要接收三个参数(代表三个邻居):

我可以用许多方法来编写这个函数,但我想从一个冗长的版本开始,这样或许可以清楚地说明发生了什么。我该如何存储这个规则集?记住,规则集是一系列 8 位(0 或 1),它们定义了每个可能邻域配置的结果。如果你需要复习一下,图 7.20 展示了 Sierpiński 三角形规则集的沃尔夫勒姆符号表示,以及按顺序列出的对应的 0 和 1。这应该能给你一些关于我所设想的数据结构的提示!

图 7.20:一个带有数字编码的沃尔夫勒姆规则集的可视化表示
我可以将这个规则集存储在一个数组中:
let ruleset = [0, 1, 0, 1, 1, 0, 1, 0];
然后我可以说,例如,像这样:
if (a === 1 && b === 1 && c === 1) return ruleset[0];
如果左、中、右三个状态都是 1,这与配置 111 匹配,因此新状态应该等于ruleset数组中的第一个值。对所有八种可能的情况重复这个策略如下所示:
function rules(a, b, c) {
if (a === 1 && b === 1 && c === 1) return ruleset[0];
else if (a === 1 && b === 1 && c === 0) return ruleset[1];
else if (a === 1 && b === 0 && c === 1) return ruleset[2];
else if (a === 1 && b === 0 && c === 0) return ruleset[3];
else if (a === 0 && b === 1 && c === 1) return ruleset[4];
else if (a === 0 && b === 1 && c === 0) return ruleset[5];
else if (a === 0 && b === 0 && c === 1) return ruleset[6];
else if (a === 0 && b === 0 && c === 0) return ruleset[7];
}
我喜欢以这种方式编写rules()函数,因为它逐行描述了每种邻域配置的发生情况。然而,这并不是一个很好的解决方案。毕竟,如果一个细胞自动机有四个可能的状态(从 0 到 3)而不是两个呢?突然间,有 64 种可能的邻域配置。如果有 10 种可能的状态,那就是 1000 种配置。想象一下,如果要编程冯·诺依曼的 29 种可能状态,我将不得不输入成千上万的else...if语句!
另一种解决方案,尽管不那么透明,是将邻域配置(三位二进制数)转换为一个普通整数,并使用该值作为ruleset数组的索引。这可以通过以下方式实现,使用 JavaScript 内置的parseInt()函数:

然而,这个解决方案有一个小问题。考虑规则 222:
let ruleset = [1, 1, 0, 1, 1, 1, 1, 0];
假设正在测试的邻域是 111。根据我最初编写的rules()函数,结果状态应该等于规则集索引 0:
if (a === 1 && b === 1 && c === 1) return ruleset[0];
二进制数 111 转换为十进制数 7。但我不想要ruleset[7],我想要ruleset[0]。为了使其有效,我需要在查找ruleset数组中的状态之前反转索引:

我现在已经拥有计算沃尔夫勒姆基本细胞自动机世代所需的一切。以下是整个代码:

这很棒,但还有一个部分缺失:如果你看不见细胞自动机,它又有什么用呢?
绘制基本细胞自动机
绘制基本细胞自动机的标准技术是将世代一层层叠加起来,每个单元格绘制为一个方形,黑色表示状态 1,白色表示状态 0,如图 7.21 所示。然而,在实现这种特定的可视化之前,我想指出两点。

图 7.21:规则 90 的世代堆叠可视化
首先,这种数据的可视化解释完全是字面上的。它有助于展示沃尔夫勒姆的基本细胞自动机算法和结果,但它不一定应该驱动你个人的工作。你不太可能构建一个需要精确这个算法和这种可视化风格的项目。所以,虽然以这种方式学习绘制细胞自动机有助于你理解和实现细胞自动机系统,但这个技能应该仅仅作为一个基础存在。
第二点是,1D CA 用 2D 图像来可视化可能会误导人。非常重要的一点是要记住,这不是一个 2D CA。我只是选择将所有代数的历史记录垂直堆叠显示。这个技术通过将多次 1D 数据的实例合成一个 2D 图像,但系统本身仍然是 1D 的。稍后,我会展示一个真正的 2D CA(生命游戏),并介绍如何可视化这样的系统。
好消息是,绘制一个基本的 CA(元胞自动机)并不特别困难。我将从绘制单一的世代开始。假设每个单元格应为 10×10 的正方形:
let w = 10;
假设画布宽度为 640 像素,那么 CA 将有 64 个单元格。当然,我可以在 setup() 中初始化 cells 数组时动态计算这个值:

现在,绘制单元格的过程涉及遍历数组并根据每个单元格的状态绘制一个正方形:

这段代码有两个问题。首先,当将状态乘以 255 时,状态为 1 的单元格将变为白色,状态为 0 的单元格将变为黑色,这与我最初的意图相反!虽然这当然没问题,因为颜色表示是任意的,但我将在完整的示例中纠正这一点。
更紧迫的问题是每个正方形的 y 位置被硬编码为 0。如果我希望各代排列成堆叠的形式,每一行单元格表示一个新的世代,那么我还需要根据世代编号计算 y 位置。我可以通过添加一个 generation 变量,并在 draw() 每次执行时将其递增来实现。通过这些改动,我现在可以查看整个草图。

你可能已经注意到我在这个例子中所做的优化,以简化绘制过程:我加入了一个白色背景,并只绘制了黑色正方形,这节省了绘制许多正方形的工作。这个解决方案并不适用于所有情况——如果我想要多色单元格怎么办?——但是在这个简单的情况下,它提供了性能上的提升。(我还要指出,如果每个单元格的大小是 1 像素,我不会使用 p5.js 的 square() 函数,而是直接访问像素数组。)
尽管进行了优化,绘制代码的另一个方面依然非常低效:草图不断绘制一代又一代,延伸至画布底部之外。书中网站上的代码包括一个简单的停止条件,但你可能会想出其他方法来解决这个问题(一些方法在接下来的练习中提到)。
练习 7.1
扩展 示例 7.1,使其具有以下功能:当 CA 到达画布底部时,CA 重新开始并使用新的随机规则集。
练习 7.2
检查如果你用随机状态初始化第 0 代的单元格,出现的模式。
练习 7.3
以非传统的方式可视化细胞自动机(CA)。打破你能打破的所有规则;不要拘泥于使用完美网格上的黑白方块。
练习 7.4
创建一个细胞自动机的可视化,随着代数的增加而向上滚动,这样你就可以看到“无限”代数。提示:与其一次跟踪一代,你需要存储一代代的历史,每帧都添加一代并删除最旧的一代。
Wolfram 分类
现在你已经有了可视化一个基本细胞自动机的草图,你可以为其提供任何规则集并查看结果。你可以期待哪些结果呢?正如我之前提到的,绝大多数基本细胞自动机规则集会产生视觉上乏味的结果,而一些则产生如同自然界中那样奇妙复杂的模式。Wolfram 将结果范围分为四类。
类别 1:均匀性
类别 1 的细胞自动机在经过一定代数后,最终每个单元格的状态都会保持不变。看起来并不特别激动人心。规则 222 是一个类别 1 的细胞自动机;如果你运行它足够多的代数,最终每个单元格都会变为黑色并保持不变(见 图 7.22)。

图 7.22:规则 222
类别 2:重复
像类别 1 的细胞自动机一样,类别 2 的细胞自动机保持稳定,但单元格的状态并不是恒定的。相反,它们在 0 和 1 的重复模式中振荡。在规则 190 中,每个单元格遵循序列 11101110111011101110 (图 7.23)。

图 7.23:规则 190
类别 3:随机
类别 3 的细胞自动机看起来是随机的,并且没有明显的可辨认模式。实际上,规则 30 (图 7.24)被用作 Wolfram Mathematica 软件中的随机数生成器。你可能会惊讶地发现,像这样的简单系统,遵循简单规则,却能发展成混乱和随机的模式。

图 7.24:规则 30
类别 4:复杂性
类别 4 的细胞自动机可以看作是类别 2 和类别 3 的混合体。你可以在细胞自动机中找到重复的、振荡的模式,但这些模式出现的时间和位置是不可预测的,似乎是随机的。如果类别 3 的细胞自动机让你感到惊讶,那么像规则 110 (图 7.25)这样的类别 4 一定会让你大吃一惊!

图 7.25:规则 110
在 第五章 中,我介绍了复杂系统的概念,并用群聚行为展示了简单规则如何导致涌现行为。类别 4 的细胞自动机显著地展示了复杂系统的特征,是模拟森林火灾、交通模式和疾病传播等现象的关键。细胞自动机的研究和应用始终强调类别 4 作为细胞自动机与自然之间桥梁的重要性。
生命游戏
下一步是将一维元胞自动机(CA)扩展到二维:生命游戏。这将引入额外的复杂性——每个单元格将拥有更大的邻域——但随着复杂性的增加,可能的应用范围也变得更广。毕竟,大多数计算机图形学中的现象都发生在二维空间,而本章展示了如何将元胞自动机的思维方式应用于二维的 p5.js 画布。
1970 年,马丁·加德纳在《科学美国人》上撰写了一篇文章,记录了数学家约翰·康威的新生命游戏,并将其描述为娱乐数学:“要玩生命游戏,你必须有一个相当大的棋盘,并且需要大量两种颜色的平面棋子。虽然用铅笔和图纸也能操作,但特别是对于初学者来说,使用棋子和棋盘要容易得多。”
生命游戏已经变成了一种计算上的陈词滥调,许多项目展示了在 LED、屏幕、投影面等上的游戏。然而,使用代码实践构建这个系统仍然非常有价值,原因有几个。
首先,生命游戏为练习二维数组、嵌套循环等技能提供了很好的机会。然而,更重要的是,这个元胞自动机的核心原理直接与本书的核心目标相联系:用代码模拟自然世界。生命游戏的算法和技术实现将为你提供灵感和基础,帮助你构建表现出生物系统繁殖特征和行为的模拟。
与冯·诺依曼不同,后者创建了一个极其复杂的状态和规则系统,康威希望用尽可能简单的规则集来实现类似的生命般的结果。让我们看看加德纳是如何概述康威的目标的。
-
应该没有任何初始模式,它能够简单证明人口可以无限增长。
-
应该有一些初始模式,显然会无限增长。
-
应该有一些简单的初始模式,它们会持续增长并变化,经过相当长的时间后以三种方式之一结束:完全消失(由于过度拥挤或变得过于稀疏),稳定在一个配置中且之后保持不变,或进入振荡阶段,重复两种或更多周期的无尽循环。
这听起来可能有些晦涩,但它本质上描述了沃尔夫拉姆第四类元胞自动机。该元胞自动机应该是有规律的,但随着时间推移变得不可预测,最终会趋于均匀或振荡状态。换句话说,尽管康威没有使用这个术语,生命游戏应该具备复杂系统的所有特性。
游戏规则
让我们看看“生命游戏”是如何运作的。这不会占用太多时间或空间,因为我可以基于沃尔夫拉姆的基础元胞自动机进行构建。首先,代替一行细胞,我现在有一个二维的细胞矩阵。与基础元胞自动机一样,可能的状态是 0 或 1。然而,在这个系统中,由于它是关于生命的,0 表示“死亡”,1 表示“存活”。
由于“生命游戏”是二维的,每个细胞的邻域现在已经扩展。如果一个邻居是相邻的细胞,那么邻域现在是九个细胞,而不是三个,正如在图 7.26 中所示。

图 7.26:一个二维元胞自动机,显示九个细胞的邻域
对于三个细胞,3 位数字有八种可能的配置。对于九个细胞,有 9 位,也就是 512 种可能的邻域。在大多数情况下,为每种可能性定义一个结果是不可行的。生命游戏通过根据邻域的总体特征来定义一套规则来解决这个问题:邻域是过度拥挤,还是被死亡包围,还是刚刚好?以下是生命规则:
-
死亡: 如果一个细胞存活(状态 = 1),在以下情况下它将死亡(状态变为 0):
-
过度拥挤: 如果该细胞有四个或更多的活邻居,它将死亡。
-
孤独: 如果该细胞的活邻居少于或等于一个,它将死亡。
-
-
诞生: 如果一个细胞死亡(状态 = 0),当它恰好有三个活邻居时,它将复生(状态变为 1)。
-
静止: 在所有其他情况下,细胞的状态不变。两种情况是可能的:
-
保持存活: 如果一个细胞存活并且有恰好两个或三个活邻居,它将保持存活。
-
保持死亡: 如果一个细胞死亡并且有除了恰好三个活邻居之外的任何其他邻居,它将保持死亡。
-
图 7.27 展示了这些规则的一些示例。重点关注中心细胞发生了什么。

图 7.27:生命游戏中的死亡和诞生示例场景
在基础元胞自动机中,我一次可视化多个世代,按行堆叠在二维网格中。然而,在“生命游戏”中,元胞自动机是二维的。我可以尝试创建一个复杂的三维可视化结果,并将所有世代堆叠在一个立方体结构中(实际上,你可能想将此作为一个练习来尝试),但可视化生命游戏的更典型方法是将每一代视为动画中的一个单独画面。这样,你不是一次性查看所有世代,而是一次查看一个,结果看起来像是在培养皿中快速发展的细菌。
生命游戏的一个令人兴奋的方面是,一些已知的初始模式会产生有趣的结果。例如,图 7.28 中显示的模式是静止的,永远不会改变。

图 7.28:保持稳定的细胞初始配置
图 7.29 中的模式在两个状态之间来回振荡。

图 7.29:在两个状态之间振荡的细胞初始配置
图 7.30 中的模式看起来像是从一代到下一代在网格中移动。细胞本身并没有实际移动,但你可以看到由于相邻细胞的开关,产生了运动的错觉。

图 7.30:看似在移动的细胞初始配置
如果你对这些模式感兴趣,一些不错的“生命游戏”在线演示允许你配置 CA 的初始状态,并以不同速度观看它运行。以下是两个示例:
-
《通过 Mitchel Resnick 和 Brian Silverman,终身幼儿园小组,麻省理工学院媒体实验室探索涌现》(*
www.playfulinvention.com/emergence*) -
Steven Klise 的 p5.js 版本的康威“生命游戏”(*
sklise.github.io/conways-game-of-life*)
在接下来的部分中,我将专注于随机初始化每个细胞的状态。
实现
我已经拥有了在 p5.js 中实现生命游戏所需的大部分内容:主要是,我只需要将 Wolfram CA 草图的代码扩展到二维。我之前使用了一个一维数组来存储细胞状态列表。现在,我将使用一个二维数组:
let w = 8;
let columns = width / w;
let rows = height / w;
let board = new Array(columns);
for (let i = 0; i < columns; i++) {
board[i] = new Array(rows);
}
我将从为每个细胞初始化一个随机状态(0 或 1)开始:

和之前一样,我需要一个额外的二维数组来接收下一代的状态,这样在处理当前代的二维数组时,就不会覆盖它。然而,与其在setup()和draw()中写出所有创建二维数组的步骤,不如编写一个根据列数和行数返回二维数组的函数。我还会将数组中的每个元素初始化为0,这样就不会填充undefined:
function create2DArray(columns, rows) {
let arr = new Array(columns);
for (let i = 0; i < columns; i++) {
arr[i] = new Array(rows);
for (let j = 0; j < rows; j++) {
arr[i][j] = 0;
}
}
return arr;
}
现在,每当需要一个新的二维数组时,我只需调用这个函数:

接下来,我需要解决如何计算每个细胞的新状态。为此,我需要确定如何引用细胞的邻居。在一维 CA 的情况下,这很简单:如果一个细胞的索引是i,它的邻居就是i-1和i+1。在这里,每个细胞没有单一的索引,而是有一个列和行的索引:i,j。如图 7.31 所示,邻居是i-1,j-1、i,j-1、i+1,j-1、i-1,j、i+1,j、i-1,j+1、i,j+1和i+1,j+1。

图 7.31:细胞邻域的索引值
生命游戏的规则是通过了解有多少个邻居处于“生存”状态来运作的。如果我创建一个变量neighborSum并对每个状态为 1 的邻居进行递增,我就能得到活着的邻居总数:

就像在沃尔夫勒姆 CA 中一样,我发现自己写了一堆if语句。这是另一个情况,对于教学目的,将代码写成这样逐步明确每个步骤(每当一个邻居的状态为 1 时,计数器增加)既有用又清晰。然而,说“如果单元格状态等于 1,就将 1 加到计数器”其实有点傻,因为我可以直接说:“将每个单元格状态加到计数器。”毕竟,如果状态只能是 0 或 1,那么所有邻居状态的总和将给出活跃单元格的总数。由于邻居排列成一个小的 3×3 网格,我可以引入另一个嵌套循环来更高效地计算总和:

当然,我犯了一个重大错误。在生命游戏中,当前单元格不算作邻居之一。我可以加入一个条件,跳过当k和l都等于0时的状态添加,但另一种选择是在循环完成后再减去单元格状态:

最后,当我知道了活跃邻居的总数,我可以根据规则决定单元格的新状态——出生、死亡或静止:

将这些内容组合起来:

现在我只需要绘制棋盘了。我将为每个位置绘制一个方块:关闭时为白色,开启时为黑色。

在这个例子中,我介绍了另一种根据单元格状态绘制方块的方法。记住,将单元格状态乘以 255 会给出白色填充色表示开启,黑色表示关闭。为了反转这个过程,我从 255 开始,减去单元格状态乘以 255:开启时为黑色,关闭时为白色。
练习 7.5
创建一个生命游戏模拟器,允许你手动配置网格,可以通过硬编码初始单元格状态或直接在画布上绘制来配置。利用这个模拟器探索一些已知的生命游戏模式。
练习 7.6
为生命游戏实现一个环绕功能,使得位于边缘的单元格在网格的另一侧也能找到邻居。
练习 7.7
示例 7.2 中的代码虽然很方便,但并不是特别节省内存。它为每一帧动画创建了一个新的二维数组!对于 p5.js 应用程序来说,这影响不大,但如果你在微控制器或移动设备上实现生命游戏,你可能需要更加小心。一个解决方案是只使用两个数组,并不断交换它们,将下一个状态集写入当前数组之外的那个数组。实现这个特定的解决方案。
面向对象的单元格
在本书的过程中,我建立了多个具有属性并在画布上移动的对象系统示例。在这一章中,尽管我一直在把单元格当作对象来讨论,但我并没有在代码中使用面向对象的原则。之所以能够这样工作,是因为单元格是一个非常简单的对象;它唯一的属性就是状态,0 或 1。但我可以在此基础上进一步开发元胞自动机系统,超越这里讨论的简单模型,通常这些开发可能涉及为每个单元格跟踪多个属性。例如,如果单元格需要记住其状态历史怎么办?或者如果你想对元胞自动机应用运动和物理学,并让单元格在画布上移动,动态地改变它们的邻居,从一帧到另一帧怎么办?
为了实现这些想法(以及更多),了解如何将每个单元格视为对象,而不是数组中的单个 0 或 1,将会非常有帮助。例如,在生命游戏模拟中,我将不再像这样初始化每个单元格:
board[i][j] = floor(random(2));
相反,我希望得到如下的效果:
board[i][j] = new Cell(floor(random(2)));
这里,Cell是我将编写的新类。一个Cell对象的属性是什么?在生命游戏的例子中,我可能选择创建一个存储其位置和大小以及状态的单元格:

在非面向对象版本中,我使用了单独的二维数组来跟踪当前和下一代的状态。然而,通过将单元格作为对象,每个单元格可以通过引入一个“前一状态”变量来跟踪两个状态:

突然之间,借助这些额外的属性,单元格的可视化可以包含更多关于状态的信息。例如,如果每个单元格的颜色根据其状态是否从一个帧变化到另一个帧而变化,会怎么样?

代码的其他部分不需要改变(至少对于我这里的目的来说)。邻居的计算方式仍然相同;不同之处在于,邻居的previous状态会被计数,单元格的新state属性会被更新。将这个逻辑封装到一个以board为参数的calculateState()方法中,可能也是有益的。我将这个留给你作为练习。
以下是生命游戏的逻辑,已根据单元格对象进行了调整,但不包括calculateState()增强功能:

通过将单元格转换为对象,出现了许多增强单元格属性和行为的可能性。例如,假设每个单元格都有一个lifespan属性,该属性随着每个周期递增,并随着时间推移影响单元格的颜色或形状?或者,假设单元格有一个terrain属性,可能是land、water、mountain或forest。二维元胞自动机如何融入基于瓦片的战略游戏或其他场景中?
传统元胞自动机的变体
现在我已经介绍了最著名的一维和二维元胞自动机的基本概念、算法和编程策略,是时候思考如何在这个代码基础上进行扩展,开发出自己工作中的创意应用。在本节中,我将讨论一些扩展元胞自动机功能的想法。有关这些习题的示范答案,请访问本书的官方网站。
非矩形网格
没有必要将单元格局限于放置在矩形网格中。如果你设计一个使用其他形状的元胞自动机会发生什么?
习题 7.8
使用六边形网格(如图所示)创建一个元胞自动机,每个六边形有六个邻居。

提示:你可以使用极坐标到笛卡尔坐标的转换来找到六边形的六个顶点!
function drawHexagon(x, y, r) {
push();
translate(x, y);
stroke(0);
beginShape();
for (let angle = 0; angle < TWO_PI; angle += PI / 3) {
let xoff = cos(angle) * r;
let yoff = sin(angle) * r;
vertex(xoff, yoff);
}
endShape(CLOSE);
pop();
}
概率性
元胞自动机的规则不一定需要定义一个确定的结果。
习题 7.9
按照以下方式重写生命游戏的规则:
-
过度拥挤:如果单元有四个或更多的生存邻居,它有 80%的概率死亡。
-
孤独:如果单元有一个或更少的生存邻居,它有 60%的概率死亡。
或者你可以自己编写概率规则!
连续性
本章重点讨论了具有有限个离散单元状态的示例——状态为 0 或 1。假如单元的状态可以是 0 到 1 之间的任何浮动数值,会怎么样呢?
习题 7.10
使沃尔夫拉姆的基本元胞自动机(CA)适应浮动状态。你可以定义诸如“如果状态大于 0.5”或“如果状态小于 0.2”这样的规则。
图像处理
我之前简要提到过这一点,但许多图像处理算法的工作原理类似于元胞自动机规则。例如,模糊图像需要根据像素邻域的平均值创建新的像素。墨水在纸上扩散或水面在图像上泛起涟漪的模拟也可以通过元胞自动机规则来实现。
习题 7.11
创建一个元胞自动机,其中每个像素是一个单元,且像素的颜色即为其状态。
历史性
在面向对象的生命游戏示例中,我使用了两个变量来追踪单元当前和之前的状态。如果你使用数组来追踪单元在较长时间内的状态历史会怎么样?这与复杂适应系统的概念有关,即一个可以通过从历史中学习,随着时间的推移改变其规则的系统。(更多关于这个概念的内容,请参见第九章和第十章。)
习题 7.12
通过根据每个单元生存或死亡的时间长度为其上色来可视化生命游戏。你能否还利用单元的历史来更新规则?
移动单元
在这些基本示例中,单元格在网格上的位置是固定的,但你也可以构建一个元胞自动机,单元没有固定位置,而是可以在画布上移动。
习题 7.13
在群体系统中使用元胞自动机(CA)规则。如果每个个体(boid)都有一个状态(可能影响其转向行为),并且随着它靠近或远离其他个体,邻域会从帧到帧发生变化,这会怎样呢?
嵌套
如第五章所讨论,复杂系统的一个特征是它们可以是嵌套的。城市是一个由人组成的复杂系统,一个人是由器官组成的复杂系统,一个器官是由细胞组成的复杂系统,依此类推。如何将这一点应用到元胞自动机(CA)中?
习题 7.14
设计一个元胞自动机(CA),其中每个单元格都是一个较小的元胞自动机(CA)。
生态系统项目
将元胞自动机(CA)融入到你的生态系统中。以下是一些可能性:
-
给每个生物一个状态。这个状态如何驱动它的行为?从元胞自动机(CA)中获取灵感,如何让这个状态根据邻居的状态随时间变化?
-
将生态系统的世界视为一个元胞自动机(CA)。生物从一个方块移动到另一个方块,每个方块都有一个状态。是陆地?水域?食物?
-
使用元胞自动机(CA)来为你的生态系统中生物的设计生成一个模式。

第九章:8 分形
“病态的怪物!”害怕的数学家喊道 每一个都是我眼中的刺 我讨厌皮亚诺空间和科赫曲线 我害怕康托尔三元集 谢尔宾斯基垫片让我想哭 而在百万英里外,一只蝴蝶扇动了翅膀 在一个寒冷的十一月的日子里,一个叫 Benoit Mandelbrot 的男人出生了
—Jonathan Coulton,歌曲《Mandelbrot Set》的歌词

查克里·玛哈·普拉萨德大厅,泰国曼谷(摄影:Saad Akhtar)
查克里·玛哈·普拉萨德大厅位于泰国曼谷市中心的大皇宫内,是一座因其复杂细节和宏伟而闻名的建筑杰作。每一层多层屋顶都反映出自己更小或更大的版本,代表着佛教宇宙中心的梅鲁山的不同层次。
从前,我在高中上过一门叫做几何学的课,也许你也上过这样的课,学习了一维、二维甚至三维的经典形状。圆的周长是多少?矩形的面积呢?点与直线之间的距离是多少?这种几何学通常被称为欧几里得几何,以希腊数学家欧几里得命名,想一想,这也是我在本书中一直在讲的内容。每当我用向量描述笛卡尔空间中物体的运动时,那就是欧几里得几何。
然而,对于我们这些自然编码者,我想问一下,我们的世界真的能用欧几里得几何来描述吗?我现在盯着的笔记本屏幕看起来确实是一个矩形。而我今天早上吃的李子是球形的。但如果我进一步观察,考虑街道旁的树木、树上挂着的叶子、昨晚雷暴中的闪电、我晚餐吃的花椰菜、我身体中的血管,以及定义地貌的山脉和海岸线呢?正如图 8.1 所示,许多自然界中的事物与欧几里得几何的理想化几何形态相差甚远。

图 8.1:将理想化的欧几里得几何与自然界中的形态进行比较
如果你想开始构建具有超越基本形状(如circle()、square()和line())的计算设计,是时候了解一种不同的几何学了,即自然的几何学:分形。本章探讨了分形背后的概念以及模拟分形几何的编程技巧。
什么是分形?
分形(来自拉丁语fractus,意为“破碎”)这一术语是由数学家 Benoit Mandelbrot 于 1975 年创造的。在他的开创性著作《自然的分形几何》中,他将分形定义为“一个粗糙或碎片化的几何形状,可以被分割成多个部分,每个部分(至少大致上)是整体的一个缩小版。”
我将用两个简单的例子来说明这个定义。首先,想想树的分支结构,如图 8.2 所示。(在示例 8.6 中,我将展示如何编写代码来绘制这棵树。)

图 8.2:一棵分支的分形树
注意,这棵树有一个主干,末端连接着分支。每一根分支的末端都有新的分支,而这些分支又有分支,依此类推。如果你从树上摘下一根分支,并单独仔细观察它,就像在图 8.3 中展示的那样,会怎样呢?

图 8.3:放大分形树的一根分支
放大的分支是整体的精确复制品,就像曼德尔布罗特所描述的那样。然而,并不是所有的分形都必须像这棵树一样完全自相似。例如,看看图 8.4,其中展示了格林兰岛(或在本土 Kalaallisut 语言中称为 Kalaallit Nunaat)海岸线的两幅插图。

图 8.4:两条海岸线
这些插图中缺少比例尺并非偶然。我展示的是整个海岸线还是它的一小部分?没有比例尺参照,你无法知道,因为作为分形,海岸线在任何尺度下看起来本质上都是一样的。(顺便提一下,海岸线 B 展示了海岸线 A 的一个特定部分的约 3 倍放大视图。我已在图 8.5 中添加了比例尺。)

图 8.5:两条海岸线,带有比例尺
海岸线是一个随机分形的例子,这意味着它是由概率和随机性构建的。与确定性(或可预测)树状分支结构不同,随机分形是统计上自相似的。这意味着,即使一个模式在每个尺度上不是完全相同,形状的总体特征和感觉在你放大或缩小时依然保持不变。本章中的示例探讨了生成分形图案的确定性和随机技术。
虽然自相似性是分形的一个关键特征,但重要的是要意识到,仅凭自相似性并不能定义一个分形。毕竟,直线也是自相似的:它在任何尺度下看起来都一样,并且可以被认为由很多小直线组成。但直线并不是分形。分形的特点是,在小尺度下有精细的结构(继续放大海岸线,你会发现不断变化的波动),并且无法用欧几里得几何来描述。通常,如果你能说“那是条线!”那么它就不是分形。
曼德尔布罗特集
最著名且最具辨识度的分形图案之一,以曼德尔布罗特命名。生成曼德尔布罗特集合涉及在将复杂数通过迭代函数后,测试其特性。它们是否趋向于无穷大?它们是否保持在某个范围内?
虽然这是一个引人入胜的数学讨论,但这种逃逸时间算法生成分形的方式,比我们在本章中将要探讨的递归技术更不实用。然而,生成曼德尔布罗特集合的代码已包含在在线示例中。

分形有着比曼德尔布罗特 1975 年出版的书籍更悠久的历史,早在各种文化中就以不同形式出现。它们几乎和自然本身一样古老。许多土著和古代社会早在西方数学正式研究分形之前,就已经将分形图案融入到他们的艺术、建筑和纺织品中。例如,赞比亚传统的巴伊拉村布局和伊斯兰建筑中的复杂几何图案,都展现了分形的特性。这些图案突显了分形在不同文化背景下的重要性以及它们的永恒魅力。
递归
除了自相似性,分形几何学的另一个基本组成部分是递归:反复应用一个规则的过程,称为生成规则,即每一次迭代的结果成为下一次迭代的起点。自从分形在现代数学中首次出现以来,递归就一直是其中的核心概念。当时,德国数学家乔治·康托尔在 1883 年提出了生成无限集合的简单规则。康托尔的生成规则在图 8.6 中进行了说明。

图 8.6:生成康托尔集合分形的递归指令
在康托尔的规则中,存在一个反馈循环。取一条线,将其分成两段。然后回到这两条线,应用相同的规则,每条线再分成两段。现在你有了四条线。再回到这四条线,应用规则。现在你有了八条。以此类推。这就是递归的工作方式:一个过程的输出再次输入到该过程本身,反复进行。在这种情况下,结果被称为康托尔集合。就像图 8.3 中的分形树一样,它是一个确定性的、完全可预测的分形,其中每一部分都是整体的精确复制品。
Cantor 对应用递归规则集进行无限次迭代后的结果非常感兴趣。然而,你我并没有无限的时间。另外,p5.js 草图的像素空间是有限的,因此最终绘制越来越小的线条变得不可能。因此,在本书中,我将主要忽略由无限递归引发的问题和悖论。相反,代码将以某种方式构建,使得规则不是“永远”应用(导致无限循环和电脑死机),而是在满足某个条件时停止。
实现递归函数
一会儿我会写一个草图,递归实现 Cantor 集。但首先,代码中的递归是什么意思?归根结底,就是在一个函数内部调用另一个函数。这本身并不新鲜。毕竟,你可能经常在函数内部调用其他函数。例如:

这里是递归的关键区别:如果你在定义的函数内部调用这个函数会发生什么?someFunction()能否调用someFunction()?

这不仅被允许,实际上还被鼓励!事实上,这对于我实现 Cantor 集的方式至关重要。调用自身的函数被称为递归函数,它们非常适合解决某些问题。例如,一些数学计算是递归实现的;最著名的例子就是阶乘。
任何数字 n 的阶乘,通常写作 n!,定义如下:
n! = n × (n − 1) × . . . × 3 × 2 × 1
0! = 1
这是一个使用for循环来计算阶乘的 JavaScript 非递归函数:

仔细观察,你会发现阶乘的运作方式很有趣。想一想 4!和 3!是如何定义的:
4! = 4 × 3 × 2 × 1
3! = 3 × 2 × 1
3! 的整个定义包含在 4! 的定义中:
4! = 4 × 3!
更一般地说,对于任何正整数 n,以下公式成立:
n! = n × (n − 1)!
0! = 1
写下来,我可以说 n 的阶乘定义为 n 乘以 n - 1 的阶乘。
阶乘的定义中包括了阶乘?这有点像将比萨定义为“包含比萨片的美味餐点”。虽然这种比萨的定义显然是荒谬的,但它突出了定义中的自引用概念,这正是递归的本质。当这种自引用应用到代码中的函数定义时,它能产生非常优雅的解决方案,比如这个递归定义的factorial()函数:
function factorial(n) {
if (n <= 1) {
return 1;
} else {
return n * factorial(n - 1);
}
}
factorial()函数在其自身的定义中调用自己。起初看起来有些奇怪,但只要存在停止条件(在此例中为n <= 1),它就能工作,避免了无限递归(我使用<=而非===作为防止无限递归的保护措施,不过我应该增加额外的错误检查来处理非整数或负数输入,这样可以更符合数学上的准确性。)图 8.7 展示了当调用factorial(4)时所展开的步骤。

图 8.7:可视化调用递归factorial()函数的过程
该函数不断递归调用自身,深入嵌套的函数调用的“兔子洞”,直到达到停止条件。然后它开始从洞中向上回溯,返回值,直到回到最初的factorial(4)调用处。
你可以将factorial()函数中所示的递归原理应用到画布中的图形上,只不过这次不是返回值,而是绘制图形。这正是本章例子所展示的。首先,这里有一个简单的递归函数,用来绘制逐渐变小的圆形。

drawCircles()函数根据它接收的参数集合绘制一个圆形。然后,它用相同的参数调用自己,并对参数做些微调。结果是一系列圆形,每个圆形都绘制在前一个圆形内部。
就像factorial()函数在n等于0时停止递归一样,注意drawCircles()只有在半径大于4时才会递归调用自身。这是一个关键点。和迭代一样,所有递归函数必须有退出条件!你可能已经知道,所有的for和while循环必须包含一个最终会变为false的布尔表达式,从而退出循环。如果没有退出条件,草图将陷入无限循环。同样地,递归也如此。如果一个递归函数无限次地调用自身而没有退出条件,通常你会看到一个冻结的屏幕。然而,浏览器内置了保护机制,它不会冻结,而是会以错误信息Maximum call stack size exceeded退出草图。这只是另一种方式在说:“递归调用次数过多,是时候停止了!”
示例 8.1 相对简单;通过使用for或while循环的简单迭代就能轻松实现。然而,当一个函数被定义为调用自己多次时,结果变得更加有趣。在这种情况下,递归变得非常优雅。为了演示这一点,我将使drawCircles()稍微复杂一点:每绘制一个圆形,就在其中绘制两个半径为原来一半的新圆形,一个位于中心左侧,另一个位于中心右侧。

再添加两行代码,现在每个圆圈里包含了四个圆圈——分别是左、右、上和下。

尝试用迭代代替递归来重现这个草图——我敢打赌你做不到!
使用递归绘制 Cantor 集
现在我已经展示了如何使用递归函数,我准备好在 p5.js 中可视化 Cantor 集了。我该从哪里开始呢?嗯,我知道 Cantor 集是从一条线开始的,所以我从这开始,编写一个画线的函数:
function cantor(x, y, length) {
line(x, y, x + length, y);
}
这个函数绘制了一条长度为length的线,起点是像素坐标(x, y)。这条线是水平绘制的,但这是一个任意的决定。假设这个函数是这样调用的:
cantor(10, 20, width - 20);
你会看到类似于图 8.8 的结果。

图 8.8:调用一次cantor()的结果是一条线。
Cantor 规则通过复制原始线条并删除其中的三分之一部分来操作,剩下两条线——一条从起点到三分之一的位置,另一条从三分之二的位置到线的末端(见图 8.9)。我可以通过手动调用line()两次来实现这个规则,并将 y 位置下移 20 像素,这样下一代线条就会出现在第一代线条的下方。

图 8.9:Cantor 集中下一代的线条是前一条线的三分之一长度。

图 8.10 展示了结果。

图 8.10:使用 Cantor 集规则绘制的两代线条
这种方法适用于两代,但继续手动调用line()会很快变得笨重。对于后续的几代,我需要调用 4 次、8 次、16 次line()。for循环通常是解决这种问题的方式,但尝试一下,你会发现为每次迭代计算数学公式变得异常复杂。不过别灰心:这时候递归就能派上用场!
看看我如何绘制第二代的第一条线,从起点到三分之一的位置:
line(x, y + 20, x + length / 3, y + 20);
与其直接调用line()函数,为什么不调用cantor()函数呢?毕竟,cantor()函数到底做什么呢?它在(x, y)位置画一条给定length的线。x值保持不变,y值增加 20,而长度是length / 3:
cantor(x, y + 20, length / 3);
这次调用cantor()与之前调用line()完全等价。而对于第二代中的下一条线,我可以再次调用cantor():
cantor(x + (2 * length / 3), y + 20, length / 3);
现在cantor()函数看起来是这样的:

由于cantor()函数现在是递归的,因此相同的规则将应用于下一条线,接着是下下一条,依此类推,直到cantor()一次又一次地调用自己!但是,别急着运行这段代码。草图缺少一个至关重要的元素:退出条件。递归必须在某个时刻停止。在这里,我选择当线段长度小于或等于 1 像素时停止。换句话说,当length大于1时继续。

编写一个递归调用自身的函数是生成分形模式的一种简单优雅的技术,但它只允许我做绘制模式这一件事。例如,如果我想让康托集中的线条作为独立的对象存在并且可以独立移动呢?为此,我需要使用一种不同的编程方法,将递归与一个数组结合使用,该数组跟踪所有的独立部分。这正是我接下来要做的!
习题 8.1
使用示例 8.2 和 8.3 作为模型,设计你自己的递归模式。以下是一个使用线条的示例。

科赫曲线
我现在将转向另一个著名的分形模式——科赫曲线,它是由瑞典数学家赫尔格·冯·科赫于 1904 年发现的。图 8.11 概述了绘制这个分形的生成规则。注意,这些规则的开始方式与康托集相同,首先是一条线,然后将其分成三等分。

图 8.11:绘制科赫曲线的规则
图 8.12 展示了通过多次重复这些步骤后,分形的演化过程。

图 8.12:科赫曲线的演变
我可以像处理康托集那样进行操作,编写一个递归函数,反复迭代应用科赫规则。但我打算换一种方式解决这个问题,把科赫曲线的每一段当作一个独立的对象。这将打开一些令人兴奋的设计可能性。例如,如果每一段都是一个对象,它可以独立于原始位置移动,并参与物理模拟。此外,每一段的外观也可以有所不同,如果对象包含可自定义的属性,如颜色、线条粗细等。
怪物曲线
科赫曲线和其他分形图案常被称为数学怪物,因为当你将递归定义应用无限次时,会出现一个奇怪的悖论。如果原始起始线段的长度为 1,那么科赫曲线的第一次迭代将得到一条长度为四分之三的线(每段长度为起始线的三分之一)。再迭代一次,你将得到十六分之九的长度。随着迭代趋向于无穷大,科赫曲线的长度也接近无穷大,但它仍然可以适应这个纸面(或屏幕)上的有限空间!
由于你在 p5.js 的有限像素空间中工作,这个理论悖论不会成为问题。你只需要限制递归应用科赫规则的次数,以免程序耗尽内存或崩溃。
为了实现将每个段落视为一个独立对象的目标,我首先必须决定这个对象应该是什么。它应该存储什么数据?它应该具备什么功能?科赫曲线是一系列相连的线段,因此我会将每个段落视为一个KochLine。每个KochLine对象都有一个起点(a)和一个终点(b)。这些点被表示为p5.Vector对象,线段则通过line()函数绘制:

现在我有了KochLine类,我可以开始编写setup()和draw()了。我需要一个数据结构来追踪最终会成为许多KochLine对象的内容,而一个 JavaScript 数组就能很好地完成这项任务(请参见第四章以复习数组):
let segments = [];
在setup()中,我将希望将第一条线段添加到数组中,这条线段从 0 延伸到画布的宽度:

然后在draw()中,所有KochLine对象(目前只有一个)可以通过for...of循环渲染:
function draw() {
background(255);
for (let segment of segments) {
segment.show();
}
}
这是我草图的基础。我有一个KochLine类,它追踪从起点start到终点end的线段,我还有一个数组追踪所有KochLine对象。有了这些元素,我该如何以及在哪里应用科赫规则和递归原理呢?
还记得第七章中的生命游戏元胞自动机吗?在那个模拟中,我始终追踪着两代细胞:当前代和下一代。当我计算完下一代后,下一代变成当前代,然后我继续计算新的下一代。我将在这里应用类似的技巧。我有一个segments数组列出当前的线段集合(在程序开始时,只有一条)。现在我需要一个第二个数组(我叫它next),在这里我可以放置所有通过应用科赫规则生成的新KochLine对象。对于当前数组中的每一个KochLine,将会有四个新的线段被添加到next中。当我完成时,next数组将成为新的segments数组(请参见图 8.13)。

图 8.13:分形的下一代是通过当前一代计算得出的。然后,next变为新的current,在一代到另一代的过渡中。
代码如下所示:

通过反复调用generate(),Koch 曲线的规则将递归应用到现有的KochLine线段集合中。但是,当然,我跳过了函数的真正工作:我如何将一条线段按照规则分割成四个部分?我需要一种方法来计算每条线段的起点和终点。
因为KochLine类使用p5.Vector对象来存储起点和终点,这是一个很好的机会,来练习第一章中的所有向量数学知识,以及第三章中的一些三角学知识。首先,我需要确定问题的范围:每个KochLine对象需要计算多少个点?图 8.14 给出了答案。

图 8.14:两个点变成五个点。
正如图中所示,我需要将两个点(start,end)转化为五个点(a,b,c,d,e),从而生成四个新的线段(a → b,b → c,c → d,d → e):
next.add(new KochLine(a, b));
next.add(new KochLine(b, c));
next.add(new KochLine(c, d));
next.add(new KochLine(d, e));
那么,我从哪里获得这些点呢?为什么不直接让KochLine对象为我计算它们呢?

等等,让我们仔细看看这一行代码:

正如你可能记得的,在第六章中,我解释了对象解构,这是一种从对象中提取属性并将它们分配给各个变量的方法。猜猜看?你也可以对数组做同样的事情!在这里,只要kochPoints()方法返回一个包含五个元素的数组,我就可以方便地将它们解构并分别赋值给:a、b、c、d和e。这是一种处理多个返回值的漂亮方法。就像对象解构一样,数组解构使得代码保持整洁。
现在,我只需要在KochLine类中写一个新的kochPoints()方法,返回一个p5.Vector对象数组,表示图 8.15 中的点a到e。我将首先处理最简单的a和e——它们只是原始线段的start和end点的副本:

那么,点b和d呢?点b在线段上是三分之一的位置,点d则在两分之二的位置。如图 8.15 所示,如果我创建一个指向原始start到end的向量
,我可以通过将其大小缩放为三分之一来获得新的b,将其大小缩放为三分之二来获得新的d。

图 8.15:原始线段作为向量
,可以通过三等分来找到下一代点的位置。
下面是代码实现:

最后的点,c,是最难计算的。然而,如果你考虑到等边三角形的角度都是 60 度,这就使得你的工作突然变得容易了。如果你知道如何通过一个三分之一长度的向量找到新的b,那么如果你将这个相同的向量旋转 60 度(或π/3 弧度)并加到b上,就像在图 8.16 中那样,你就能得到c!

图 8.16:向量
旋转 60 度来找到第三个点。

最后,在计算出五个点后,我可以将它们一起返回一个数组。这将匹配之前概述的将数组解构成五个独立变量的代码:

现在剩下的就是在setup()中调用generate()一定次数(比如五次),以计算出该代的科赫线段。

在这个例子中,我选择调用generate()五次。每次应用科赫规则时,线段的数量会呈指数增长。这是一个任意的决定,但经过五次迭代后,我得到了 1,024 个线段,这为观察模式提供了相当多的细节。不过,你也可以选择使用前面示例中的方法,设定最小线段长度的阈值,并在线段变得过小之前一直调用generate()。另外,你也可以考虑一个交互选项,设置一个按钮,每按一次按钮就将形状推进到下一代。
练习 8.2
绘制科赫雪花,它由三个科赫曲线组成,排列成一个三角形。或者绘制科赫曲线的其他变体。

练习 8.3
尝试动画化科赫曲线。例如,你能从左到右绘制它吗?你能改变线段的视觉设计吗?你能使用前面章节中的技术移动线段吗?如果你将每个线段做成弹簧(Toxiclibs.js)或约束(Matter.js)会怎么样?
练习 8.4
使用对象和数组重写康托集示例。
练习 8.5
使用递归绘制谢尔宾斯基三角形(如第七章中的沃尔夫勒姆初等 CA 所示)。

树
本章迄今为止呈现的分形是确定性的:它们没有内建的随机性,每次运行时都会产生相同的结果。虽然这为经典分形模式和绘制它们的编程技巧提供了很好的介绍,但结果看起来过于精确,似乎不够有机。
在本节中,我将进一步接近自然世界,通过一个分支分形树的案例研究。我将从一个确定性版本开始。然后,我会引入随机性元素,说明生成随机(或非确定性)分形的技巧,这些分形的结果每次运行时都可能不同。
确定性版本
图 8.17 概述了绘制分形树的确定性生成规则。

图 8.17:每一代分形树,遵循给定的生成规则。最终的树是几代之后的结果。
我再次得到一个很好的分形,其递归定义是:一根树枝是一条线,且有两根树枝与之相连。与之前的分形相比,这个分形稍微复杂一些,因为在分形规则中使用了rotate一词。每一根新树枝必须相对于上一根树枝进行旋转,而上一根树枝又是相对于它所有的前一根树枝进行旋转。幸运的是,p5.js 提供了一个机制来跟踪旋转:变换。
我在第三章中简要介绍了变换。它们是一组函数,如translate()、rotate()、scale()、push()和pop(),这些函数允许你改变草图中形状的位置、方向和大小。translate()函数用于平移坐标系,rotate()用于旋转坐标系,push()和pop()帮助保存和恢复当前的变换状态。如果你不熟悉这些函数,我在 Coding Train 网站上提供了一组关于 p5.js 变换的视频 (thecodingtrain.com/transformations)。
我将从画一根树枝开始,即树干。由于我将使用rotate()函数,我需要确保在绘制过程中不断地沿着树枝进行平移。记住,在 p5.js 中,当你进行旋转时,你总是围绕原点(即点 (0, 0))进行旋转,所以在这里,原点必须始终平移到下一根正在绘制的树枝的起点(相当于上一根树枝的末端)。由于树干从窗口的底部开始,我首先需要平移到那个位置:
translate(width / 2, height);
然后,我可以将树干画成一条向上的线:
line(0, 0, 0, -100);
一旦我画完了这条线,我必须平移到这条线的末端并进行旋转,以便绘制下一根树枝,正如图 8.18 中所示。(最终,我需要将目前的操作封装成一个递归函数,但我会先理清单个步骤。)

图 8.18:绘制一条线的过程,平移到线的末端,并按一个角度旋转
这里是图 8.18 中所示过程的代码。我使用了 30 度的角度,或者π/6 弧度:

现在我已经有了一个向右的分支,我需要一个向左的分支(见图 8.19)。为此,我应该在旋转并绘制右侧分支之前使用push()保存变换状态。然后,我可以在绘制完右侧分支后调用pop()恢复该状态,使我回到正确的位置来旋转并绘制左侧分支。

图 8.19:在“弹出”回去后,一个新的分支旋转到左侧。
下面是所有代码的完整版本:

把每次调用line()函数当作一个分支,你就能开始看到这段代码是如何实现分支定义的:一条线的末端连接着两条线。我可以不断地添加更多的line()调用,绘制更多的分支,但就像 Cantor 集合和 Koch 曲线一样,我的代码很快就会变得极其复杂且难以管理。相反,我将使用已编写的代码作为branch()函数的基础,用递归调用branch()来替代第二和第三个line()调用:

注意,我在每对rotate()和branch()函数调用之间使用了push()和pop()。这是一个优雅的代码解决方案,感觉像是魔法。在每次调用branch()之前,代码会花一点时间记住该分支的起始位置,以便稍后返回。如果你暂时把自己当作 p5.js,并试图用铅笔和纸跟踪递归函数,你会注意到你首先会把所有的分支绘制到右边。在右边的最末端,pop()会让你回到所有已经绘制的分支,这样你就可以绘制左边的分支。
练习 8.6
跟踪绘制分支的递归算法,并在图中按 p5.js 实际绘制的顺序为每个分支编号。

你可能已经注意到,当前写的递归函数存在一个重大问题:它没有退出条件,因此会陷入无限递归调用。此外,树的分支在每一层应该变短,但到目前为止,我将每个分支的长度硬编码为 100 像素。这两个问题的解决方案是相互关联的——如果分支在每一代中变短,我就可以在分支变得太短时让函数停止递归:

我还添加了一个angle变量。在完成的示例中,角度由mouseX位置控制。

递归的branch()函数提供了一种简洁优雅的方式来绘制树,且代码量非常少。然而,这种方法限制了动画的潜力。通过采用类似 Koch 曲线的方法,将每个分支段作为对象存储在数组中,你可以探索创意的方式来为树的生长添加动画,甚至可以为分支加入物理效果!
练习 8.7
为每个分支设置不同的strokeWeight()。让树干粗壮,随后每个分支逐渐变细。

练习 8.8
使用Branch类和一个数组来重新创建树,并跟踪各个分支。(提示:你需要使用向量数学来跟踪分支的方向和长度,而不是使用 p5.js 的变换。)你能为树的生长做动画吗?怎么在分支的末端画叶子?

随机版本
初看之下(且在合适的角度下),可能会觉得我在前面的示例中画了一棵很逼真的树,但仔细观察会发现结果有点过于完美。走到外面看看真实的树,你会发现每个分支的长度和角度都不一样,更别提并非所有的分支都完全分成两根较小的分支。分形树是一个很好的例子,它展示了加入一点随机性如何让最终的效果看起来更加自然。这点随机性也把分形从确定性转变为随机性——每次绘制的结果都会有所不同,但依然保留了树状结构的整体特点。
首先,试着为每个分支的角度加上一些随机性?这很容易,只需要加入random()就可以:

在原始示例中,branch()函数总是调用自己两次。现在,为了增加多样性,我会为每个分支随机选择一个分支数量(每个分支有一个随机角度)。

示例 8.7 展示了角度和分支数量的随机性使用,但也许它做得过头了:结果中的树依然看起来不够自然。为了让树看起来更自然,你可以尝试缩小随机角度的范围,或者使用 Perlin 噪声来实现更平滑的角度变化。
练习 8.9
根据 Perlin 噪声值设置树枝的角度。随着时间推移调整噪声值,给树添加动画。看看你能不能让它看起来像是在风中摇摆。
练习 8.10
使用 Toxiclibs.js 模拟树木的物理效果。树的每个分支可以由两个粒子通过弹簧连接起来。你怎么让树保持直立而不倒下?
L-系统
1968 年,匈牙利植物学家阿里斯提德·林登迈尔(Aristid Lindenmayer)开发了一种基于语法的系统,用于模拟植物的生长模式。这个系统使用文本符号和一组特定的规则来生成模式,类似于语言的语法定义了用单词构建句子的规则。这个被称为L 系统(林登迈尔系统)的技术,可以用来生成本章至今展示的递归分形模式。L 系统还具有额外的价值,因为它们提供了一种机制,使用简单的符号跟踪需要复杂且多面的生成规则的分形结构。
在 p5.js 中实现 L 系统需要使用递归、变换和文本字符串。本章已经介绍了递归和变换,但字符串是新的概念。这里有一段简短的代码示例,展示了与文本相关的 L 系统的三个重要方面:创建、连接和迭代字符串。你可以参考本书网站获取更多字符串的资源和教程。

一个 L 系统有三个主要组成部分:
-
字母表: 一个 L 系统的字母表包括可以包含的有效字符。例如,我可以说字母表是 ABC,这意味着 L 系统中任何有效的“句子”(一串字符)只能包含这三个字符。
-
公理: 公理是一个句子(由字母表中的字符创建),描述系统的初始状态。例如,使用字母表 ABC,一个可能的公理可以是 AAA、B 或 ACBAB。
-
规则: L 系统的生成规则描述了变换句子的方法。这些规则是递归应用的,从公理开始,一次又一次地生成新的句子。L 系统的规则包括两个句子,一个是前驱,另一个是后继。例如,规则 A → AB 表示在句子中每个出现 A(前驱)的地方,在下一代中应该用 AB(后继)替换。
我将从一个简单的 L 系统开始。事实上,这是林登迈尔的原始 L 系统,它模拟了藻类的生长。以下是它的组成部分:
| 字母表 | A, B |
|---|---|
| 公理 | A |
| 规则 | A → AB B → A |

图 8.20:依此类推……
这个 L 系统的字母表包含两个字符,并且有两个简单的规则:将 A 替换为 AB,将 B 替换为 A。与递归分形图形一样,我可以将 L 系统规则的每次应用视为一次世代。世代 0 按定义是公理(A),每个后续的世代显示将生成规则应用于当前世代的结果。图 8.20 展示了这个 L 系统发展的几个世代。
如何用代码实现这个 L 系统呢?我将从将包含公理的字符串存储在一个变量中开始。这个变量我命名为current,因为它将始终存储当前代际(从公理开始):
let current = "A";
再次像在生命游戏和科赫曲线中一样,我现在需要一个完全独立的字符串来表示下一代:
let next = "";
现在是时候将生产规则应用到current并将结果写入next了:

当for循环结束时,current被设置为next:
current = next;
为了确保这段代码有效,我将其打包成一个名为generate()的函数,并使用循环多次调用generate(),将当前字符串绘制到画布上。

现在,你可能在想:“这一切都很有趣,但到底有什么意义呢?毕竟,这一章不就是应该讲如何绘制分形图案吗?我能理解 L 系统句子结构的递归性与分形的递归性有关系,但它是如何在视觉上模拟植物生长的呢?据我所知,没有植物会长出 A 和 B。”
直到现在我还没有提到的一点是,这些 L 系统的句子中嵌入了绘图指令,这就是林登梅耶(Lindenmayer)能够将字符串转换为植物的有机结构的原因。为了展示这如何工作,下面是另一个示例系统:
| 字母表 | A, B |
|---|---|
| 公理 | A |
| 规则 | A → ABA B → BBB |
以下是这个 L 系统在几代中的演变过程:
| 代际 0 | A |
|---|---|
| 第一代 | ABA |
| 第二代 | ABABBBABA |
| 第三代 | ABABBBABABBBBBBBBBABABBBABA |
为了将其转化为绘图,我将按如下方式翻译系统的字母表:
| A | 向前画一条线。 |
|---|---|
| B | 向前移动(不画线)。 |
拥有这个翻译后,我可以将每一代的句子视为绘图指令。图 8.21 展示了结果。

图 8.21:使用 L 系统字母表表示的 Cantor 集
看起来熟悉吗?这个 L 系统生成了 Cantor 集!
为了简化,我一直在使用 AB 作为字母表,但许多 L 系统使用字符 F、G、+、–、[和]。它们的意义如下:
| F | 画一条线并向前移动。 |
|---|---|
| G | 向前移动(不画线)。 |
| + | 向右转。 |
| – | 向左转。 |
| [ | 保存当前状态。 |
| ] | 恢复当前状态。 |
这种类型的绘图框架通常被称为海龟图形(源自 Logo 编程的早期)。想象一下,一只海龟坐在你的 p5.js 画布上,能够接受一小套命令:向左转,向右转,向前移动,画线等等。虽然 p5.js 默认并没有这样设置,但我可以通过translate()、rotate()和line()轻松模拟一个海龟图形引擎。以下是如何将这个 L 系统的字母表转换为 p5.js 代码的方式:
| F |
|---|
line(0, 0, 0, length);
translate(0, length);
|
| G |
|---|
translate(0, length);
|
| + |
|---|
rotate(angle);
|
| – |
|---|
rotate(-angle);
|
| [ |
|---|
push();
|
| ] |
|---|
pop();
|
假设我已经生成了一个 L 系统句子,我可以逐个字符地遍历该句子,并为每个字符执行相应的代码:

有了这些代码和合适的 L 系统条件,我可以绘制出极其复杂、类植物的结构。以下是我将使用的 L 系统:
| 字母表 | F, G, +, –, [, ] |
|---|---|
| 公理 | F |
| 规则 | F → FF + [+ F – F – F] – [– F + F + F] |
本书网站上提供的可下载草图将本节中提供的所有 L 系统代码组织为三个部分:
-
rules:一个 JavaScript 对象,用于存储 L 系统规则中的前驱和后继字符串对 -
LSystem:一个类,用于迭代生成新的 L 系统代 -
Turtle:一个类,用于管理读取 L 系统句子并按照指令在屏幕上绘制
我不会在这里写出这些类,因为它们只是重复了我在本章中已经写过的代码。相反,我会展示如何在主文件sketch.js中将所有元素组合在一起。

本书中,我广泛地介绍了面向对象编程(OOP),并以Particle和p5.Vector等类为例。然而,在示例 8.9 中,你可能注意到我在初始化rules变量时采取了一个简便的方法。我没有定义一个Rule类并使用new关键字调用构造函数,而是用一个 JavaScript 对象字面量来初始化这个变量。通过其键值对,这种数据结构非常方便,用于定义 L 系统的变换规则。每个键代表当前代中需要被替换的字符(在这个例子中,只有一个字符"F"),该键的值定义了替换内容("FF+[+F-F-F]-[-F+F+F]")。虽然这个例子只有一个规则,但你可以在对象字面量中创建更多的规则,通过其他键值对来实现。
习题 8.11
将 L 系统作为一组指令来创建存储在数组中的对象。使用三角函数和向量数学来进行旋转,而不是使用变换(就像我在示例 8.5 中处理科赫曲线时一样)。
习题 8.12
L 系统和植物结构的开创性著作,《植物的算法美学》(The Algorithmic Beauty of Plants)(*algorithmicbotany.org)由 Przemysław Prusinkiewicz 和 Aristid Lindenmayer(Springer)于 1990 年出版。第一章描述了许多复杂的 L 系统,包含了额外的绘制规则和可用的字母表字符。它还描述了生成随机 L 系统的几种方法。扩展示例 8.9 中的 L 系统代码,加入 Prusinkiewicz 和 Lindenmayer 所描述的一个或多个额外特性。
习题 8.13
在这一章中,我强调了使用分形算法生成视觉图案。然而,分形也可以在其他创意媒介中找到。例如,它们在约翰·塞巴斯蒂安·巴赫的《大提琴组曲第三号》中显而易见,大卫·福斯特·华莱士的小说无限的玩笑(Little, Brown,1996)的结构也受到分形的启发。可以考虑使用本章中的示例来生成音频或文本。
生态系统项目
将分形融入到你的生态系统中。以下是一些可能性:
-
向生态系统环境中添加类似植物的生物。
-
假设你的某个植物像一棵分形树。你能在树枝的末端添加叶子或花朵吗?如果这些叶子可以根据风力从树上飘落呢?如果你添加可以被生物采摘并食用的果实呢?
-
设计一个具有分形图案的生物。
-
使用 L 系统生成关于生物如何移动或行为的指令。

第十章:9 进化计算
光阴似箭;果蝇爱香蕉。
—未知

普韦布洛陶器(图片由国家公园管理局提供)
几个世纪以来,美国西南部和墨西哥北部的祖先普韦布洛人和莫戈永文化所创造的陶器在仪式和日常生活中都具有重要意义。像制作这个查科祖先普韦布洛碗所使用的技术和设计元素一样,这些传统代代相传,每一位陶艺家都在学习、保存并微妙地调整这些设计。这一持续的过程孕育了一个不断发展的家族和文化表达的画卷。
花点时间回想一下那个简单的时光,当时你编写了第一个 p5.js 草图,生活自由且轻松。你在这些第一个草图中可能使用了哪个基本的编程概念,并且一直反复使用到今天呢?变量。变量允许你在程序运行时保存数据并重用它。
当然,这并不是什么新鲜事。在这本书中,你已经远远超越了仅有一两个简单变量的草图,逐渐过渡到围绕更复杂数据结构组织的草图:变量持有包含数据和功能的自定义对象。你已经使用这些复杂的数据结构——类——构建了属于你自己的小世界,包括移动物体、粒子、车辆、细胞和树木。但有一个问题:在本书的每个例子中,你都必须担心初始化这些对象的属性。也许你创建了一组具有随机颜色和大小的粒子,或者一个所有车辆都从相同的 (x, y) 位置开始的列表。
如果你不再充当智能设计师,通过随机或深思熟虑的方式赋予物体属性,而是让自然界中的一个过程——进化——为你决定这些值呢?你能将 JavaScript 对象的变量看作该对象的 DNA 吗?对象能否生成其他对象并将它们的 DNA 传递给新一代?一个 p5.js 草图能进化吗?
所有这些问题的答案都是响亮的“是”,而得出这个答案正是本章的重点。毕竟,如果不处理一个自然界中最强大的算法过程——生物进化的模拟,这本书几乎不可能完整。本章致力于研究进化过程背后的原理,并寻找将这些原理应用于代码中的方法。
遗传算法:灵感来自现实事件
发展进化型代码系统的主要手段是遗传算法(简称GA),它们受到达尔文进化论核心原则的启发。在这些算法中,问题的潜在解决方案群体通过模拟生物进化中的自然选择过程,在几代人中逐步演化。尽管进化过程的计算机模拟可以追溯到 20 世纪 50 年代,但我们对遗传算法的现代理解大多源于密歇根大学的约翰·霍兰德教授,他在 1975 年出版的《自然与人工系统中的适应》(MIT 出版社)一书中开创了遗传算法研究。今天,遗传算法已经成为一个更广泛领域的一部分,这个领域通常被称为进化计算。
为了明确,遗传算法只是受到遗传学和进化理论的启发,并不打算精确实现这些领域背后的科学原理。在本章探讨遗传算法时,我不会制作庞内特方格(抱歉让你失望),也不会讨论核苷酸、蛋白质合成、RNA 或其他与生物进化过程相关的主题。我更关心的不是如何创造一个科学上精确模拟物理世界中的进化过程,而是如何将进化策略应用于软件开发。
这并不是说具有更高科学深度的项目就没有价值。事实上,计算生物学研究领域确实承担着更准确地模拟生物进化过程的挑战!我鼓励那些对这个主题特别感兴趣的读者,探索如何在提供的示例中增加更多进化特征。然而,为了确保项目可管理,我将坚持基础知识。而且,正如实际情况所示,基础知识将足够复杂且充满趣味。
我还应该指出,严格来说,遗传算法一词指的是一种以特定方式实现的特定算法,用来解决特定类型的问题,而这些具体细节并不是本书关注的重点。虽然正式的遗传算法将作为本章示例的基础,但由于我更注重在代码中创造性地应用进化理论,所以我不会过分强调算法实现的精确度。因此,本章将分为以下三个部分:
-
传统遗传算法: 我将从传统的教材中介绍遗传算法(GA)。该算法是为了处理计算机科学中那些解空间非常庞大、采用暴力搜索方法需要过长时间的问题而开发的。举个例子:我在想一个介于一和十亿之间的数字。你需要多长时间才能猜出来?采用暴力搜索的方法,你必须检查每一个可能的答案。是 1 吗?是 2 吗?是 3 吗?是 4 吗?。。。运气也起到了一定作用(也许我碰巧选了 5!),但平均下来,你会在从 1 开始数起的过程中花费数年时间才能找到正确答案。然而,如果我能告诉你你的答案是对是错?是温暖还是冷?非常温暖?热?冰冷?如果你能评估你猜测的接近程度(或适应度),你就能相应地调整你的选择,更快地得到正确答案。你的答案将会进化。
-
交互式选择: 在探索了传统的计算机科学版本之后,我将探讨遗传算法在视觉艺术中的其他应用。交互式选择指的是通过用户互动来进化某种事物(通常是计算机生成的图像)的过程。假设你走进一个博物馆的展厅,看到 10 幅画。通过交互式选择,你可能会挑选出你最喜欢的几幅,然后让算法根据你的偏好生成(或进化)新的画作。
-
生态系统模拟: 传统的计算机科学遗传算法和交互式选择技术是你在网上搜索或阅读人工智能教科书时常见的内容。但正如你将很快看到的,它们并没有真正模拟物理世界中进化的过程。在本章中,我还将探讨模拟人工生物生态系统进化的技术。如何让在画布上移动的物体相遇、交配并将基因传递给下一代?这可以直接应用于每章结尾处提到的生态系统项目。在我探讨第十一章中的神经进化时,这也将特别相关。
为什么使用遗传算法?
为了帮助说明传统遗传算法的实用性,我将从猫咪开始。不,不是你每天看到的那些猫咪。我将从一些“嗡嗡叫”的猫咪开始,它们具备打字的天赋,目标是生成完整的莎士比亚作品集(见图 9.1)。

图 9.1:无限只猫在无限键盘上打字
这是我对无限猴子定理的妙趣横生的解读,定理内容如下:一只猴子在打字机上随机敲击键盘,最终会打出莎士比亚的完整作品,前提是有无限的时间。之所以只是一个理论,是因为在实际情况下,字母和单词的可能组合数量使得猴子实际上打出莎士比亚作品的可能性微乎其微。为了让你理解,即便这只猴子从宇宙诞生之初开始打字,到现在,它打出哈姆雷特的概率,别说打出莎士比亚的完整作品,依然是极为不可能的。
假设有一只名叫 Clawdius 的猫。Clawdius 在一个简化的打字机上打字,这台打字机只有 27 个字符:26 个英文字母和空格键。Clawdius 按下任意一个键的概率是 1/27。
接下来,考虑短语“to be or not to be that is the question”(为了简单起见,我忽略了大小写和标点符号)。这个短语包含 39 个字符,包括空格。如果 Clawdius 开始打字,他按下第一个字符正确的概率是 1/27。由于他按下第二个字符正确的概率也是 1/27,他成功输入前两个字符正确的概率是 1/729(27 × 27)。(这一点直接来自我们在第零章中关于概率的讨论。)因此,Clawdius 输入完整短语的概率是 1/27 乘以自身 39 次,或者(1/27)³⁹。这等于一个概率值……
1 in 66,555,937,033,867,822,607,895,549,241,096,482,953,017,615,834,735,226,163
不用说,连打对这一个短语,更不用说打出整部戏剧,甚至是莎士比亚的 38 部戏剧(没错,甚至是《两位贵族亲戚》)都是极不可能的。即便 Clawdius 是一个计算机模拟,每秒能打出一百万个随机短语,要使 Clawdius 最终以 99%的概率打出这一短语,他需要打字 9,719,096,182,010,563,073,125,591,133,903,305,625,605,017 年。(为了做个对比,宇宙的估计年龄仅为 137.5 亿年。)
这些无法想象的大数字的意义,并不是让你头疼,而是为了说明暴力破解算法(打出每一个可能的随机短语)并不是一个合理的随机生成“to be or not to be that is the question”短语的策略。于是,遗传算法(GAs)应运而生,它们从随机短语开始,通过模拟进化迅速找到解决方案,让 Clawdius 有足够的时间享受一场舒适的小猫午睡。
公正地说,这个问题(要得到短语“to be or not to be that is the question”)本身是荒谬的。因为你已经知道答案了,所有你需要做的就是打出来。这里有一个 p5.js 的示例程序,能解决这个问题:
let s = "to be or not to be that is the question";
console.log(s);
然而,这是一个很好的起步问题,因为有了已知的答案,你可以轻松测试代码并评估遗传算法的成功。一旦你成功解决了这个问题,就可以更有信心地使用遗传算法做一些真正有用的事情:解决未知答案的问题。这个第一个示例除了演示遗传算法的工作原理外并没有其他实际用途。如果你将遗传算法的结果与已知答案进行对比,得到“生存还是毁灭”,那你就成功编写了一个遗传算法。
练习 9.1
创建一个生成随机字符串的草图。你需要知道如何做到这一点,才能实现接下来的遗传算法示例。p5.js 生成字符串cat需要多长时间?你如何使用 p5.js 的形状绘制函数将其改编为生成一个随机设计?
遗传算法的工作原理
在我介绍任何代码之前,我想先以一种更通用的方式讲解经典遗传算法的步骤。我将展示如何通过一系列的世代演变,一个生物群体(模拟中的元素的通用术语)是如何进化的。为了理解这一过程,首先需要概述达尔文进化论的三个核心原则。如果自然选择要像自然界中那样在代码中发生,必须具备以下三种要素:
-
遗传性: 必须有一种机制,让某一代的父母生物将它们的特征传递给下一代的子代生物。
-
变异: 在生物群体中必须存在各种不同的特征,或者需要引入某种变异机制,以便进化得以发生。想象一个甲虫群体,它们完全相同:相同的颜色、相同的大小、相同的翼展、相同的一切。如果群体中没有任何变异,那么子代总是与父母以及彼此完全相同。新的特征组合永远不会出现,也无法发生进化。
-
选择: 必须有一个机制,使得某些生物有机会成为父母并传递其遗传信息,而其他生物则没有机会。这个过程通常被称为适者生存。举个例子,假设有一群羚羊被狮子追逐。更快速的羚羊有更大的机会逃脱狮子的追击,从而增加其生存的机会,延长寿命,繁殖并将遗传信息传递给后代。然而,适者一词可能会产生误导。人们通常认为它意味着最大、最快或最强壮,但虽然它有时可以包括体型、速度或力量等身体特征,它并不一定非要如此。自然选择的核心在于任何最适应生物环境的特征,并增加其生存几率,最终促进繁殖。与其说是“优越性”,不如说适者更好理解为“能够繁殖”。以Dolania americana(即美国沙穴蜉蝣)为例,它被认为是寿命最短的昆虫之一。成年雌性只活五分钟,但只要它成功地将卵产入水中,它就能将遗传信息传递给下一代。对于打字猫来说,一个更适应的猫——我将其定义为更可能繁殖的猫——是那种输入了莎士比亚某一短语中更多字符的猫。
我想强调的是,我应用这些达尔文概念的背景:一个模拟的人工环境,在这个环境中,特定的目标可以量化,所有这一切都是为了创造性的探索。纵观历史,遗传学原理曾被用来伤害那些被主导社会结构边缘化和压迫的人群。我认为,在处理涉及遗传算法(GAs)的项目时,必须小心谨慎地使用语言,并确保工作文档和描述以包容性框架呈现。
在这些概念建立之后,我将开始讲解遗传算法的叙述。我会在打字猫的背景下进行讲解。该算法将分为几个步骤,分为两部分展开:初始化条件集,以及在找到正确短语之前反复进行的步骤。
步骤 1: 种群创建
对于打字猫,遗传算法的第一步是创建一个短语种群。我使用短语这个词比较宽泛,指的是任何字符的字符串。这些短语就是这个例子中的生物,尽管它们当然不像生物。
在创建短语种群时,达尔文原理中的变异适用。为了简单起见,假设我正在进化短语cat,并且我有一个包含三个短语的种群:
| rid |
|---|
| won |
| hug |
当然,这些短语有各种各样的变化,但尝试将字符随意混合搭配,你永远也得不到cat。这里的变化不足以进化出最佳解。然而,如果我有一个包含数千个随机生成短语的种群,至少有一个短语的第一个字符是c,第二个是a,第三个是t。一个大规模的种群很可能提供足够的变化来生成所需的短语。(在算法的第 3 步中,我还将展示另一种机制,以便在第一步没有足够变化时引入更多变化。)因此,步骤 1 可以描述如下:
创建一个随机生成元素的种群。
元素可能是比生物更好的、更通用的术语。但什么是元素呢?当你在本章中浏览示例时,你会看到几种不同的场景;你可能有一个图像的种群,或者像第五章那样有一个交通工具的种群。本章的新内容是,每个元素,每个种群成员,都有虚拟 DNA,一组属性(你也可以称之为基因),描述一个元素的外观或行为。例如,对于打字的猫来说,DNA 可能是一串字符。考虑到这一点,我可以更具体地描述遗传算法的第 1 步如下:
创建一个包含N个元素的种群,每个元素都有随机生成的 DNA。
遗传学领域对基因型和表现型这两个概念做出了重要的区分。实际的基因代码——DNA 中分子序列的特定排列——是一个生物体的基因型。这就是从一代传到下一代的东西。相比之下,表现型是这些数据的表达——这只猫会很大,那只猫会很小,另一只猫则会是一个特别快速且高效的打字员。
基因型/表现型的区分对于创造性地使用遗传算法至关重要。你的世界中的对象是什么?你将如何为这些对象设计基因型——存储每个对象属性的数据结构,以及这些属性可能具有的值?你将如何利用这些信息来设计表现型?也就是说,你希望这些变量实际表达什么?
我们在图形编程中经常做这件事,将值(基因型)以可视化的方式呈现(表现型)。最简单的例子可能就是颜色:
| 基因型 | 表现型 |
|---|---|
| 0 | ![]() |
| 127 | ![]() |
| 255 | ![]() |
把基因型看作是数字信息,表示颜色的数据——在灰度值的情况下,是从 0 到 255 的整数。你选择如何表达这些数据是任意的:一个红色值,一个绿色值,和一个蓝色值。甚至不需要是颜色——在不同的方案中,你也可以用这些值来描述线的长度、力的重量等等:
| 相同基因型 | 不同表型(行长) |
|---|---|
| 0 | |
| 127 | ___________________________ |
| 255 | ______________________________________________ |
打字猫示例的一个好处是,基因型和表型之间没有区别。DNA 数据是一个字符串,而这些数据的表现形式就是这个字符串本身。
步骤 2:选择
遗传算法的第二步是应用达尔文的选择原则。这包括评估种群并确定哪些成员适合被选为下一代的父母。选择过程可以分为两步:
-
评估适应度。
-
创建配对池。
对于这些步骤中的第一个,我需要设计一个适应度函数,一个生成数值评分的函数,用来描述种群中某个元素的适应度。当然,这并不是现实世界中的运作方式。生物体并不会被赋予一个分数;它们只是繁殖或不繁殖。然而,传统的遗传算法旨在进化出问题的最佳解决方案,因此需要一个机制来数值化地评估任何给定的可能解决方案。
考虑当前的场景——打字猫。为了简便起见,我假设目标短语是cat。假设种群中有三个成员:hut、car 和 box。显然,car 是最适合的,因为它在正确的位置上有两个正确的字符,hut 只有一个,box 则没有正确的字符。就这样,适应度函数如下:
fitness = 正确字符的数量
| DNA | 适应度 |
|---|---|
| car | 2 |
| hut | 1 |
| box | 0 |
我最终会希望看到更多具有复杂适应度函数的例子,但这是一个很好的起点。
一旦计算出所有种群成员的适应度,选择过程的下一步就是选择哪些成员适合成为父母,并将他们放入配对池中。这个步骤有多种方法。例如,我可以使用精英主义方法,假设,“种群中得分最高的两位成员是谁?你们俩将为下一代繁殖所有的后代。”这是编程中最简单的方法之一,但它违背了变异的原则。如果种群中的两名成员(可能只有几千个成员中的两位)是唯一能够繁殖的个体,那么下一代将缺乏多样性,这可能会阻碍进化过程。
我也可以从更多元素中创建一个交配池——例如,人口中排名前 50%的个体。这是另一个容易编写的代码,但它也不会产生最优结果。在这种情况下,得分最高的个体与中等位置的个体有相同的被选中概率。在 1000 个短语的种群中,为什么排名第 500 的短语和排名第 1 的短语有相同的繁殖机会?更进一步,为什么排名第 500 的短语有很大的繁殖机会,而排名第 501 的短语完全没有机会?
交配池的一个更好的解决方案是使用概率方法,我称之为命运之轮(也叫轮盘赌)。为了说明这种方法,假设一个种群有五个元素,每个元素都有一个适应度分数。
| 元素 | 适应度 |
|---|---|
| A | 3 |
| B | 4 |
| C | 0.5 |
| D | 1 |
| E | 1.5 |
第一步是标准化所有分数。记得标准化一个向量吗?那时涉及将向量的长度标准化,将其设置为 1。标准化一组适应度分数是将其范围标准化为从 0 到 1,以总适应度的百分比表示。为此,首先将所有适应度分数加起来:
total fitness = 3 + 4 + 0.5 + 1 + 1.5 = 10
接下来,将每个分数除以总适应度,得到标准化的适应度。
| 元素 | 适应度 | 标准化适应度 | 以百分比表示 |
|---|---|---|---|
| A | 3 | 0.3 | 30% |
| B | 4 | 0.4 | 40% |
| C | 0.5 | 0.05 | 5% |
| D | 1 | 0.1 | 10% |
| E | 1.5 | 0.1 | 15% |
现在是时候使用命运之轮了,见图 9.2。

图 9.2:在这个命运之轮中,每个切片的大小是根据适应度值来设定的。
转动命运之轮,你会注意到元素 B 的被选中概率最高,其次是 A,然后是 E,再是 D,最后是 C。根据适应度进行的这种基于概率的选择是一个很好的方法。它保证了得分最高的元素最有可能繁殖,同时也不会完全消除种群中的任何变异。与精英主义方法不同,即使是最低得分的元素(在这种情况下是 C)也至少有一些机会将其信息传递到下一代。这一点很重要,因为有可能(而且经常是这种情况)某些低得分的元素拥有一些真正有用的基因代码,应该保留在种群中。例如,在进化“是还是不是”的情况下,我们可能会有以下元素:
| 元素 | DNA |
|---|---|
| A | 是或不是去 |
| B | 是还是不是派 |
| C | 咕噜咕噜咕噜 |
如你所见,元素 A 和 B 显然是最适应的,得分最高。但它们都没有正确的字符来完成短语的结尾。元素 C,尽管得分非常低,但恰好具有短语结尾的遗传数据。虽然我可能希望 A 和 B 被选择以生成下一代的大多数,但我仍希望 C 也有一定机会参与繁殖过程。
步骤 3:繁殖
现在我已经展示了选择父母的策略,最后一步是使用繁殖来创造种群的下一代,牢记达尔文的遗传原则——孩子从父母那里继承特性。同样,可以在这里使用多种技术。例如,一种合理(且容易编程)的策略是克隆,即只选择一个父母,并创建一个该父母的精确副本作为子代元素。然而,和精英选择方法一样,这种方法与变异的目标相悖。相反,遗传算法的标准方法是选择两个父母,并通过两个步骤创造一个孩子:
-
交叉
-
变异
第一步,交叉,是从两个父母的遗传代码中创建一个子代。以猫打字为例,假设我从配对池中选出了以下两个父母短语,如选择步骤所示(我简化了,使用长度为 6 的字符串,而不是“to be or not to be”所需的 18 个字符):
| 父母 A | 编码 |
|---|---|
| 父母 B | 自然 |
现在的任务是从这两个父母中创造一个子短语。也许最明显的方法(称之为50/50 方法)是从 A 取前三个字符,从 B 取后面三个字符,如图 9.3 所示。

图 9.3:50/50 交叉
这种技术的一种变体是选择一个随机的中点。换句话说,我不必总是从每个父母那里选择一半的字符。我可以选择 1 和 5,或 2 和 4 的组合。这比 50/50 方法更可取,因为它增加了下一代的可能性多样性(见图 9.4)。

图 9.4:来自随机中点的两个交叉示例
另一种可能性是为子字符串中的每个字符随机选择一个父母,如图 9.5 所示。你可以将其视为抛硬币六次:正面,取父母 A 的一个字符;反面,取父母 B 的一个字符。这样就会产生更多可能的结果:codurg,natine,notune,等等。
这种策略不会显著改变随机中点方法的结果;然而,如果遗传信息的顺序在适应度函数中起到了作用,你可能会偏向于选择某个解决方案。其他问题则可能更多地受益于通过抛硬币方式引入的随机性。

图 9.5:使用抛硬币的方法进行交叉
一旦通过交叉创建了子代 DNA,在将子代加入到下一代之前,可以应用一个额外的可选过程:变异。这个第二次繁殖阶段在某些情况下是没有必要的,但它的存在是为了进一步维持达尔文的变异原则。最初的人口是随机创建的,确保了开头有各种不同的元素。然而,这种变异会受到人口规模的限制,随着选择的进行,变异会逐渐缩小。变异在整个进化过程中引入了额外的多样性。
变异是通过变异率来描述的。给定的遗传算法可能会有 5%的变异率,1%的变异率,或者 0.1%的变异率。例如,假设我通过交叉得到了子代短语catire。如果变异率是 1%,这意味着该短语中的每个字符都有 1%的概率在进入下一代之前发生变异。那么,字符变异是什么意思呢?在这种情况下,变异可以定义为选择一个新的随机字符。1%的概率相对较低,因此在六个字符的字符串中,大部分时间(实际上大约 94%的时间)变异不会发生。然而,当变异发生时,变异的字符将被一个随机生成的字符替代(见图 9.6)。

图 9.6:变异子代短语
正如你在接下来的示例中看到的,变异率可以大大影响系统的行为。非常高的变异率(比如 80%)会使整个进化过程失效,留下的结果更像是一个暴力破解算法。如果大多数子代基因是随机生成的,就无法保证随着每一代的到来,更适应的基因会以更高的频率出现。
总体而言,选择(选择两个父代)和繁殖(交叉和变异)过程会重复N次,直到生成一个新的N个子代元素的种群。
步骤 4:重复!
此时,新的子代种群将成为当前种群。然后,过程返回到步骤 2,再次开始,评估每个元素的适应度,选择父代,并产生另一个子代种群。希望随着算法循环经过越来越多的代数,系统会越来越接近理想的解决方案。
编写遗传算法代码
现在我已经描述了遗传算法的所有步骤,是时候将它们转化为代码了。在深入实施细节之前,让我们思考一下这些步骤如何融入到 p5.js 草图的标准结构中。setup()中应该包含什么内容,draw()中又应该包含什么内容?
setup()
第一步,初始化:创建一个包含N个元素的初始种群,每个元素都拥有随机生成的 DNA。
draw()
步骤 2,选择:评估种群中每个元素的适应度,并构建配对池。
步骤 3,繁殖:重复N次:
-
按照相对适应度的概率选择两个父母。
-
交叉:通过结合这两个父母的 DNA 创建一个孩子。
-
突变:根据给定的概率修改孩子的 DNA。
-
将新生成的孩子添加到新的种群中。
步骤 4:用新种群替换旧种群,并返回到步骤 2。
有了这个计划,我可以开始编写代码了。
步骤 1:初始化
如果我要创建一个种群,我需要一个数据结构来存储种群中元素的列表:

选择一个数组来表示一个列表是直接的,但问题仍然是:一个数组存储什么呢?对象是存储遗传信息的一个很好的选择,因为它可以包含多个属性和方法。这些遗传对象将根据一个我称之为DNA的类来构建:
class DNA {
}
DNA类应该包含什么?对于一个打字猫来说,它的 DNA 将是它打出的随机短语,一个字符的字符串。然而,使用字符数组(而不是字符串对象)提供了一个更通用的模板,可以轻松扩展到其他数据类型。例如,物理系统中生物体的 DNA 可以是一个向量数组,或者对于图像来说,是一个数字数组(RGB 像素值)。任何一组属性都可以列在一个数组中,尽管字符串在这种特定情境下很方便,但数组将作为未来进化示例的更好基础。
GA 要求我创建一个N个元素的种群,每个元素具有随机生成的基因。因此,DNA 构造函数包括一个循环来填充genes数组的每个元素:

为了随机生成一个字符,我将为每个基因编写一个名为randomCharacter()的辅助函数:

选择的随机数字对应一个特定的字符,这一标准被称为ASCII(美国信息交换标准代码),而String.fromCharCode()是一个原生的 JavaScript 方法,用于根据该标准将数字转换为对应的字符。我指定的范围包括大小写字母、数字、标点符号和特殊字符。另一种方法可以使用 Unicode 标准,它包含表情符号和来自各种世界语言的字符,为目标字符串提供更多种类的字符。
现在我有了构造函数,可以返回到setup()并初始化population数组中的每个DNA对象:

DNA类还远远不完整。我需要给它添加执行 GA 中其他所有任务的方法。在我走过步骤 2 和步骤 3 时,我会做到这一点。
步骤 2:选择
第 2 步写道:“评估种群中每个个体的适应度并建立配对池。”我将从第一部分开始,评估每个对象的适应度。之前我提到过,类型化短语的一个可能适应度函数是正确字符的总数。现在,我稍微修改一下这个适应度函数,表述为正确字符的百分比——也就是正确字符的数量除以字符总数:

我应该在哪里计算适应度呢?由于DNA类包含遗传信息(我要用来与目标短语进行比较的短语),我可以在DNA类内部编写一个方法来计算其自身的适应度。假设有一个目标短语:
let target = "to be or not to be";
现在,我可以将每个基因与目标短语中相应的字符进行比较,每次找到正确位置上的正确字符时,就增加一个计数器。例如,target中有多个t字符,但只有当它在genes数组中的正确位置时,它才会增加适应度:

由于适应度是针对每一代计算的,我在draw()循环内的第一步就是调用适应度函数,计算每个种群成员的适应度:
function draw() {
for (let phrase of population) {
phrase.calculateFitness(target);
}
}
一旦计算出适应度得分,下一步就是为繁殖过程建立配对池。配对池是一个数据结构,从中反复选择两个父母。回顾选择过程的描述,目标是按照适应度计算的概率选择父母。适应度得分最高的种群成员应该是最有可能被选择的;得分最低的,则最不可能被选择。
在第零章中,我介绍了概率的基础知识以及如何生成自定义的随机数分布。我将在这里使用相同的技术,为每个种群成员分配一个概率,通过转动幸运轮来选择父母。重新查看图 9.2,你可能会立刻想起第三章,并思考如何编写一个模拟实际转盘的程序。虽然这可能很有趣(你应该做一个!),但其实并不必要。
一个可行的解决方案是根据图 9.2 中所示的五个选项(A、B、C、D、E)按概率选择父母,方法是用每个父母的多个实例填充一个数组。换句话说,想象一下你有一个木制字母桶,像图 9.7 中那样。根据之前的概率,它应该包含 30 个 A,40 个 B,5 个 C,10 个 D 和 15 个 E。如果你从桶中随机挑选一个字母,你将有 30% 的几率得到 A,5% 的几率得到 C,依此类推。

图 9.7:一个装满字母 A、B、C、D 和 E 的桶。适应度越高,桶中某个字母的出现次数越多。
对于 GA 代码来说,这个桶可以是一个数组,每个木制字母都是一个潜在的父母DNA对象。因此,配对池是通过根据每个父母的适应度分数,按一定次数将每个父母添加到数组中来创建的:

准备好配对池后,接下来是选择两个父母!为每个孩子挑选两个父母是一个有些任意的决定。这确实反映了人类的生殖过程,并且是教科书中遗传算法的标准方法,但在创意应用方面,这里并没有限制。你可以选择只有一个父母进行克隆,或者设计一种繁殖方法,从三个或四个父母中挑选,生成子代 DNA。为了演示,我将坚持使用两个父母,并称它们为parentA和parentB。
我可以使用 p5.js 的random()函数从配对池中选择两个随机 DNA 实例。当一个数组作为参数传递给random()时,该函数返回数组中的一个随机元素:
let parentA = random(matingPool);
let parentB = random(matingPool);
这种构建配对池并从中选择父母的方法是有效的,但这并不是执行选择的唯一方式。其他一些更节省内存的技术不需要额外的数组来存储对每个元素的多个引用。例如,回想一下第零章中关于随机数非均匀分布的讨论。在那里,我实现了接受-拒绝方法。如果应用到这里,这种方法将是从原始的population数组中随机挑选一个元素,然后再挑选一个符合条件的随机数来与该元素的适应度值进行对比。如果适应度小于符合条件的数字,就重新开始,选择一个新的元素。一直重复,直到选出两个适应度足够高的父母。
另一个值得探索的优秀替代方案,类似地利用了适应度比例选择的原理。为了理解它是如何工作的,想象一下一个接力赛,每个种群成员跑的距离与其适应度相关。适应度越高,跑得越远。假设适应度值已经标准化,所有的适应度值加起来为 1(就像幸运转盘一样)。第一步是选择一个起跑线——从终点到起点的随机距离。这个距离是一个从 0 到 1 的随机数。(你会马上看到,终点被假设为 0。)
let start = random(1);
然后接力赛从起点开始,由种群的第一位成员起跑:
let index = 0;
赛跑者按照其标准化的适应度分数跑一定的距离,然后将接力棒交给下一个跑者:

这些步骤会在while循环中反复执行,直到比赛结束(start小于或等于0,即终点)。跨过终点线的跑者被选为父母。
以下是将所有步骤组合在一起的函数,返回选定的元素:

这种方法在选择中表现良好,因为每个成员都有机会跨越终点线(元素的适应度分数总和为 1),但是跑得更远的那些(即适应度分数更高的)更有机会到达终点。然而,尽管这种方法在内存上更高效,但它可能在计算上更为繁重,尤其是在大型种群中,因为每次选择时都需要遍历种群。相比之下,原始的matingPool数组方法每个父代只需进行一次随机查找。
根据遗传算法(GAs)应用的具体需求和约束,某种方法可能比另一种更合适。我将在本章中的示例中交替使用这两种方法。
练习 9.2
回顾第零章中的接受-拒绝算法,并将weightedSelection()函数重写为使用接受-拒绝方法。像接力赛方法一样,这种技术也可能会变得计算密集,因为在最终选择一个父代之前,可能会有多个潜在父代被拒绝。
练习 9.3
在某些情况下,幸运轮算法可能会对某些元素的偏好异常高。考虑以下概率:
| 元素 | 概率 |
|---|---|
| A | 98% |
| B | 1% |
| C | 1% |
这有时是不可取的,因为它会减少系统中的多样性。解决这个问题的一种方法是用评分的序号(即它们的排名)来替换计算出来的适应度分数:
| 元素 | 排名 | 概率 |
|---|---|---|
| A | 1 | 50% (1/2) |
| B | 2 | 33% (1/3) |
| C | 3 | 17% (1/6) |
你如何实现像这样的一个方法?提示:你不需要修改选择算法。相反,你的任务是从排名而不是原始适应度分数计算概率。
对于这些算法中的任何一个,给定的子代可能会选择相同的父代两次。如果需要,我可以改进算法,确保不会发生这种情况。这可能对最终结果几乎没有影响,但作为一个练习,值得探索。
练习 9.4
选择任何一个加权选择算法,并调整该算法,确保选择两个不同的父代。
步骤 3:繁殖(交叉与变异)
一旦得到两个父代,下一步就是执行交叉操作生成子代 DNA,然后进行变异:

当然,crossover()和mutate()方法并不是在DNA类中自动存在的;我必须自己编写它们。我调用crossover()的方式表明它应该接收一个DNA实例作为参数(parentB),并返回一个新的DNA实例,也就是child:

这个实现使用了随机中点交叉法,其中基因的第一部分来自父母 A,第二部分来自父母 B。
练习 9.5
将交叉函数改写为使用抛硬币法,其中每个基因有 50% 的机会来自父母 A,也有 50% 的机会来自父母 B。
mutate()方法比crossover()更简单。我要做的就是遍历基因数组,并根据定义的突变率随机选择一个新字符。例如,在 1% 的突变率下,新的字符每 100 次中只会生成一次:
let mutationRate = 0.01;
if (random(1) < mutationRate) {
/* Any code here would be executed 1% of the time. */
}
因此,整个方法的代码如下:

再次,我可以使用randomCharacter()辅助函数来简化突变过程。
将一切组合在一起
我现在已经走过了两遍遗传算法的步骤——一次是描述算法的叙述形式,另一次是用代码片段实现每个步骤。现在,我准备将它们结合起来,向你展示完整的代码以及算法的基本步骤。


sketch.js 文件精确地反映了遗传算法的步骤。然而,大部分被调用的功能都封装在DNA类中。

在示例 9.1 中,你可能会注意到新子代元素直接添加到population数组中。这种做法之所以可行,是因为我有一个单独的交配池数组,里面包含了指向原始父母元素的引用。然而,如果我改为使用接力赛式的weightedSelection()函数,那么我需要创建一个临时数组来存放新一代的种群。这个临时数组将保存子代元素,只有在繁殖步骤完成后才会用它替换原来的种群数组。你将在示例 9.2 中看到这种实现。
练习 9.6
为示例 9.1 添加功能,以报告更多关于遗传算法进展的信息。例如,显示每一代中最接近目标的短语,并报告代数、平均适应度等信息。解决了目标短语后停止遗传算法。考虑编写一个Population类来管理遗传算法,而不是将所有代码都包含在draw()中。

练习 9.7
探索动态突变率的概念。例如,尝试计算与父代短语的平均适应度成反比的突变率,以便适应度更高时突变较少。这个改变会影响系统的整体行为以及目标短语被找到的速度吗?
定制遗传算法
使用 GA 在项目中的一个好处是,示例代码可以轻松地从一个应用移植到另一个应用。选择和繁殖的核心机制不需要改变。然而,你,作为创建者,需要为每次使用定制 GA 的三个关键组件。这对于将进化模拟的平凡演示(如莎士比亚示例)发展到在 p5.js 和其他编程环境中创造性地应用于项目至关重要。
关键 1:全局变量
GA 的变量不多。如果你查看 示例 9.1 中的代码,你会看到只有两个全局变量(不包括用于存储种群和交配池的数组):
let mutationRate = 0.01;
let populationSize = 150;
这两个变量会极大地影响系统的行为,随意赋值并不是一个好主意(尽管通过反复试验调整它们是达到最佳值的完全合理方式)。
我为莎士比亚示例选择了这些值,以几乎可以保证 GA 能够解决短语,但不会太快(平均约 1,000 代),以便在合理的时间内演示该过程。然而,更大的种群会产生更快的结果(如果目标是算法效率而不是演示的话)。以下是一些结果的表格:
| 种群 | 突变率 | 解决短语所需的代数 | 解决短语所需的总时间(秒) |
|---|---|---|---|
| 150 | 1% | 1,089 | 18.8 |
| 300 | 1% | 448 | 8.2 |
| 1,000 | 1% | 71 | 1.8 |
| 50,000 | 1% | 27 | 4.3 |
注意到,增加种群规模会大幅减少解决短语所需的代数。然而,这并不一定减少所需的时间。一旦种群膨胀到 50,000 个元素,草图开始变得缓慢,因为处理适应度和从这么多元素中建立交配池所需的时间。 (当然,如果你需要如此大的种群,可以进行优化。)
除了种群规模外,突变率还会极大地影响性能。
| 种群 | 突变率 | 解决短语所需的代数 | 解决短语所需的总时间(秒) |
|---|---|---|---|
| 1,000 | 0% | 37 或从未? | 1.2 或从未? |
| 1,000 | 1% | 71 | 1.8 |
| 1,000 | 2% | 60 | 1.6 |
| 1,000 | 10% | 从未? | 从未? |
完全没有变异(0%),你只需要运气好。如果初始种群中的某个元素中恰好有所有正确的字符,你将非常迅速地进化出这个短语。如果没有,草图就无法达到精确的短语。运行几次,你会看到两种情况。此外,一旦变异率足够高(例如 10%),每个新子代中会有很多随机性(每 10 个字母中就有 1 个是随机的),模拟几乎就回到了一个随机打字的猫。理论上,它最终会解出这个短语,但你可能需要等待比合理时间长得多的时间。
关键 2:适应度函数
调整变异率或种群规模是相当容易的,通常只需要在草图中输入数字。开发遗传算法的真正难点在于编写适应度函数。如果你不能定义问题的目标并数值化地评估这些目标的实现程度,你的模拟就不会有成功的进化。
在我继续探索更多复杂适应度函数的其他场景之前,我想先看看我这个莎士比亚式适应度函数的缺陷。假设要解的是一个不是 18 个字符,而是 1000 个字符的短语。并且取种群中的两个元素,一个正确字符数为 800,另一个为 801。它们的适应度得分如下:
| 短语 | 正确字符数 | 适应度 |
|---|---|---|
| A | 800 | 80.0% |
| B | 801 | 80.1% |
这种情况有几个问题。首先,我将元素添加到交配池中N次,其中N等于适应度乘以 100。但是对象只能以整数次加入数组,因此 A 和 B 都会被添加 80 次,给它们相等的被选择概率。即使是一个考虑浮动概率的改进解决方案,80.1%也仅比 80%稍微高一点。但在进化场景中,得到 801 个正确字符比 800 个要好得多。我确实想让那额外的字符算数。我希望 801 个字符的适应度得分比 800 个的得分显著更高。
换句话说,图 9.8 展示了两种可能的适应度函数图。

图 9.8:适应度图,y = x(左)和 y = x²(右)
左边是线性图;随着正确字符数的增加,适应度得分也增加。相反,在右边的图中,随着正确字符数的增加,适应度得分大幅上升。也就是说,适应度随着正确字符数的增加而加速增长。
我可以通过各种方式实现第二种类型的结果。例如,我可以这样说:
fitness = (正确字符数)²
这里,适应度得分呈二次增长,即与正确字符数量的平方成正比。假设我有两个个体,一个有五个正确字符,另一个有六个。数字 6 比 5 大 20%。然而,通过对正确字符进行平方,适应度值将从 25 增加到 36,增加了 44%:
| 正确字符 | 适应度 |
|---|---|
| 5 | 25 |
| 6 | 36 |
这是另一个公式:
fitness = 2^(正确字符)
这是当正确字符数量增加时,公式的演示:
| 正确字符 | 适应度 |
|---|---|
| 1 | 2 |
| 2 | 4 |
| 3 | 8 |
| 4 | 16 |
这里,适应度得分呈指数增长,每增加一个正确字符,适应度得分就翻倍。
练习 9.8
重写适应度函数,使其根据正确字符的数量以二次或指数方式增长。请注意,您可能需要将适应度值标准化到 0 到 1 的范围,以便它们可以合理地多次添加到交配池中,或者使用其他加权选择方法。
虽然关于指数和线性方程的这种具体讨论在设计一个好的适应度函数时是一个重要的细节,但我不希望你错过这里更重要的要点:设计你自己的适应度函数! 我真的怀疑你在 p5.js 中使用遗传算法时,任何项目都会涉及计算字符串中的正确字符数量。在本书的背景下,你更可能是希望演化一个物理系统中的生物体。也许你正在优化一个生物体的转向行为权重,以便它能够最好地逃避捕食者、避免障碍物或穿越迷宫。你必须问自己,最终你希望评估什么。
考虑一个赛车模拟,其中车辆正在演化一种优化速度的设计:
fitness = 车辆达到赛道终点所需的总帧数
那么,如何看待一只小鼠正在演化出寻找奶酪的最佳方式呢?
fitness = 小鼠到奶酪的距离
计算机控制玩家的设计也是常见的场景。假设你正在编写一个足球游戏,其中用户扮演守门员。其余的玩家由你的程序控制,并有一组参数决定他们如何将球踢向球门。那么,任何给定玩家的适应度得分是什么呢?
fitness = 总进球数
当然,这只是对足球游戏的简化描述,但它阐明了要点。一个球员进的球越多,适应度越高,其遗传信息在下一场比赛中出现的可能性也就越大。即使像这里描述的那么简单的适应度函数,这个场景也展示了一个强大的概念——系统的适应性。如果球员们在一场又一场比赛中不断进化,当一个全新的人类用户进入游戏并采用完全不同的策略时,系统将迅速发现适应度得分下降,并进化出新的最佳策略。它将会适应。(别担心,几乎没有什么危险会导致足球机器人变得有知觉并奴役全人类。)
最终,如果你没有一个有效评估种群中个体元素表现的适应度函数,你将无法进行任何进化。而且一个示例的适应度函数可能无法应用于一个完全不同的项目。你必须设计一个函数,有时需要从头开始,适用于你的特定项目。那么你在哪里做这些呢?你只需要编辑那些计算fitness变量的方法中的几行代码:
calculateFitness() {
????????????
????????????
this.fitness = ??????????
}
填写这些问号就是你大展身手的地方!
关键 3:基因型和表型
设计你自己的遗传算法(GA)的最终关键与选择如何编码你系统的属性有关。你想要表达什么?如何将这个表达式转换成一堆数字?什么是基因型和表型?
我之所以从莎士比亚的例子开始,是因为设计基因型(字符数组)及其表达——表型(显示在画布上的字符串)非常简单。然而,这并不总是如此。例如,在讨论足球游戏的适应度函数时,我乐观地假设了存在计算机控制的踢球者,每个踢球者都有一组“决定如何将球踢向球门的参数”,但实际上,确定这些参数是什么以及如何选择编码它们,需要一些思考和创造力。当然,并没有一个正确的答案:你如何设计系统完全取决于你。
好消息是——我在本章中稍早提到过——你一直在将基因型(数据)转化为表型(表达)。每当你在 p5.js 中编写类时,你就创建了很多变量:

你要做的就是将这些变量转换为数组,这样数组就可以与DNA类中的所有方法(如crossover()、mutate()等)一起使用。一个常见的解决方案是使用一个从 0 到 1 的浮点数数组:

请注意,我现在已经将原始的遗传数据(基因型)和它的表现(表型)分成了两个类。DNA 类是基因型——它只是一些数字。Vehicle 类是表型——它是将这些数字转化为动画和视觉行为的表达方式。这两者可以通过在 Vehicle 类中包含一个 DNA 实例来链接:

当然,你可能并不希望所有的变量都在 0 到 1 的范围内。但是与其记住如何在 DNA 类中调整这些范围,不如直接从 DNA 对象中获取原始遗传信息,然后使用 p5.js 的 map() 函数,根据需要调整范围。例如,如果你希望 size 变量在 10 到 72 之间,你可以这样写:
this.size = map(this.dna.genes[2], 0, 1, 10, 72);
在其他情况下,你可能希望设计一个基因型,即一系列对象的数组。考虑设计一个配备多个推进器的火箭。你可以将每个推进器看作是一个向量,描述它的方向和相对强度:

表型将是一个参与物理系统的Rocket类:
class Rocket {
constructor() {
this.dna = ????;
/* and more... */
}
}
将基因型和表型分成不同的类(例如 DNA 和 Rocket 类)是非常有益的。因为当你开始编写所有代码时,你会发现我之前开发的 DNA 类保持不变。唯一变化的是数组中存储的数据类型(数字、向量等),以及这些数据在表型类中的表现。
在接下来的部分,我将进一步跟随这一思路,逐步讲解如何实现一个涉及运动物体和一系列向量作为 DNA 的示例。
进化力量:智能火箭
我提到火箭是有原因的:2009 年,Jer Thorp 在他的博客上发布了一个名为“智能火箭”的遗传算法示例。Thorp 指出,美国国家航空航天局(NASA)使用进化计算技术解决各种问题,从卫星天线设计到火箭发射模式。这激发了他创建了一个 Flash 演示,展示了火箭的进化。
这是一个场景:一群火箭从屏幕底部发射,目标是击中屏幕顶部的靶子。障碍物阻挡了通往目标的直线路径(见图 9.9)。

图 9.9:一群智能火箭寻找美味的草莓星球
每个火箭都配备了五个可调强度和方向的推进器(见图 9.10)。这些推进器不是同时连续发射的,而是按自定义顺序逐一发射。
在本节中,我将基于 Thorp 的思路,演化我自己的简化版智能火箭。等我讲到这一节的最后,我会把实现 Thorp 额外的高级功能留作练习。

图 9.10:一枚智能火箭,配有五个推进器,载着宇航员 Clawdius
我的火箭将只有一个推进器,它能在每一帧动画中以任意方向和任意强度发射。这在现实中并不特别逼真,但它会使得构建这个示例变得稍微简单一些。(你可以稍后将火箭和推进器做得更加先进和真实。)
开发火箭
为了实现我不断发展的智能火箭,我将从第二章中提取Mover类,并将其重命名为Rocket:

有了这个类,我可以通过每一帧动画调用applyForce()来移动火箭。推进器每次通过draw()向火箭施加一个力。但在这时,我离完成还有很长的路要走。为了使我的火箭“智能”并且可进化,我需要考虑上一节中提到的编写自定义 GA 的三个关键。
关键 1是为种群大小和变异率定义正确的全局变量。现在我先不太担心这些变量,随便选择一些合理的数字——也许是 50 个火箭的种群和 1%的变异率。一旦我构建了系统并且草图运行起来,我就可以开始尝试这些数字。
关键 2是开发一个合适的适应度函数。在这种情况下,火箭的目标是到达目标。火箭离目标越近,其适应度越高。因此,适应度与距离成反比:距离越小,适应度越大;距离越大,适应度越小。
为了付诸实践,我首先需要为Rocket类添加一个属性来存储其适应度:

接下来,我需要为Rocket类添加一个方法来计算适应度。毕竟,只有Rocket对象知道如何计算与目标的距离,因此适应度函数应该在这个类中实现。假设我有一个target向量,我可以编写以下代码:

这是我能编写的最简单的适应度函数。通过将1除以距离,较大的距离变成较小的数字,较小的距离变成较大的数字。如果我想使用上一节中的二次技巧,我可以改为将1除以距离的平方:

我还需要对适应度函数进行一些额外的改进,但这已经是一个不错的开始。
最后,关键 3是考虑基因型和表现型之间的关系。我已经说明,每个火箭都有一个推进器,它在一个可变的方向上以可变的强度发射——换句话说,这是一个向量!因此,基因型,也就是编码火箭行为所需的数据,是一个向量数组,每个向量对应动画中的一帧:
class DNA {
constructor(length) {
this.genes = [];
for (let i = 0; i < length; i++) {
this.genes[i] = createVector();
}
}
}
这里的好消息是,我实际上不需要对DNA类做任何其他修改。打字猫的所有功能(交叉和变异)仍然适用。唯一需要考虑的区别是如何初始化基因数组。在打字猫中,我有一个字符数组,并为数组的每个元素随机选择一个字符。现在我将做完全相同的事情,将 DNA 序列初始化为一个随机向量数组。
你在创建随机向量时的直觉可能是这样的:
let v = createVector(random(-1, 1), random(-1, 1));
这段代码完全没问题,很可能能达到预期效果。然而,如果我绘制出所有可能的向量,结果将填满一个正方形(见图 9.11,左)。在这种情况下,可能没什么大碍,但由于从正方形中心到角落的向量比垂直或水平方向的向量长,因此对角线方向会有轻微的偏差。

图 9.11:使用随机的 x 和 y 值(左)以及使用p5.Vector.random2D()(右)创建的向量
正如你可能还记得的第三章,更好的选择是选择一个随机角度,并从该角度创建一个长度为 1 的向量。这会生成一个形成圆形的结果(见图 9.11 右侧),可以通过极坐标到笛卡尔坐标的转换或可靠的p5.Vector.random2D()方法实现:

一个长度为 1 的向量实际上会产生相当大的力。记住,力是作用于加速度的,而加速度每秒 30 次(或根据帧率)累积成速度。因此,在这个例子中,我将在DNA类中添加另一个变量——最大力,并随机缩放所有向量,使它们的大小在 0 到最大值之间。这样就能控制推进器的功率:

注意,我正在使用lifeSpan来设置genes(向量数组)的长度。这个全局变量存储每代生命周期中的总帧数,使我能够为火箭的每一帧创建一个向量。
这个向量数组的表现形式,即表型,是我的Rocket类。为了巩固这个联系,我需要在类中添加一个DNA对象的实例:

我用this.dna做什么?随着火箭的发射,它会依次遍历向量数组,并将它们逐个应用为力。为了实现这一点,我需要包含变量this.geneCounter来帮助逐步遍历数组:

现在,我有了一个DNA类(基因型)和一个Rocket类(表型)。最后一步是实现一个机制,用于管理火箭种群并进行选择与繁殖。
管理种群
为了让我的sketch.js文件更整洁,我将把管理Rocket对象数组的代码放在Population类中。和DNA类一样,好消息是我几乎不需要修改打字猫示例中的任何内容。我只是以更面向对象的方式组织代码,新增了selection()方法和reproduction()方法。为了展示不同的技术,我还将在selection()中标准化适应度值,并在reproduction()中使用加权选择(接力赛)算法。这消除了对单独配对池数组的需求。weightedSelection()代码与本章早些时候编写的代码相同:

然而,我需要做一个相当重要的更改。在打字猫示例中,一旦生成了随机短语,就会立即对其进行评估。字符字符串没有生命周期;它纯粹是为了计算其适应度而存在。然而,火箭需要在被评估之前活跃一段时间——也就是说,它们需要有机会尝试达到目标。因此,我需要在Population类中添加一个方法来运行物理模拟。这与我在粒子系统的run()方法中所做的完全相同——更新所有粒子的位置并绘制它们:

最后,我准备好setup()和draw()了。在这里,我的主要任务是按正确的顺序调用Population类中的方法,来实现 GA 的各个步骤:
population.fitness();
population.selection();
population.reproduction();
然而,与莎士比亚示例不同,我不希望每一帧都做这个。相反,我的步骤如下:
-
创建火箭种群。
-
让火箭在N帧内存活。
-
进化下一代:
-
选择
-
繁殖
-
-
返回到步骤 2。
为了知道何时从步骤 2 转到步骤 3,我需要一个lifeCounter变量来跟踪当前世代的进展,以及lifeSpan变量。在draw()中,当lifeCounter小于lifeSpan时,会调用种群的live()方法来运行模拟。一旦lifeCounter达到lifeSpan,就该调用fitness()、selection()和reproduction()来进化出新一代火箭。

在代码的底部,你会看到我添加了一个新功能:当鼠标点击时,目标位置会移动到鼠标光标的坐标。这一变化让你可以观察火箭如何适应并调整它们的轨迹,以向新的目标位置靠近,同时系统在实时不断进化。
进行改进
我的智能火箭虽然有效,但还没有特别令人兴奋。毕竟,火箭只是简单地朝着目标进化,拥有指向目标的多个向量。为了让这个例子更有趣,我打算提出两个改进。首先,当我首次介绍智能火箭场景时,我提到火箭应该进化出避开障碍物的能力。添加这一功能将使系统更加复杂,并更有效地展示进化算法的强大能力。
为了实现障碍物避让,我需要一些障碍物来避开。我可以通过实现一个Obstacle对象类来轻松创建矩形的静态障碍物,该类存储它们自己的位置和尺寸:

我会在Obstacle类中添加一个contains()方法,如果火箭撞到了障碍物,则返回true,否则返回false:

如果我创建一个Obstacle对象数组,那么每个火箭就可以检查它是否与每个障碍物发生碰撞。如果发生碰撞,火箭可以将布尔标志hitObstacle设置为true。为此,我需要在Rocket类中添加一个方法:

如果火箭撞到障碍物,我会停止火箭更新它的位置。修改后的run()方法现在接收一个obstacles数组作为参数:

我还有机会调整火箭的适应度。如果火箭撞到了障碍物,适应度应该被惩罚并大幅减少:

这样,火箭就应该能够进化出避开障碍物的能力。但我不会停下。我还想做另一个改进。
如果仔细看看示例 9.2,你会注意到火箭并没有因为更快地到达目标而获得奖励。适应度计算中唯一的变量是世代生命周期结束时与目标的距离。事实上,如果火箭非常接近目标,但因为飞过目标而超过了它,它可能会因为更快地到达目标而受到惩罚。在这种情况下,慢而稳才能赢得比赛。
我可以通过几种方式改进算法,以优化到达目标的速度。首先,我可以基于火箭在其生命周期中任何时刻最接近目标的距离来计算火箭的适应度,而不是使用它在世代结束时与目标的距离。我将这个变量称为火箭的recordDistance,并将其作为Rocket类中checkTarget()方法的一部分进行更新:

此外,火箭应该根据它到达目标的速度获得奖励。为此,我需要一种方式来知道火箭是否击中了目标。事实上,我已经有了一种方法:Obstacle 类有一个 contains() 方法,目标也可以实现为障碍物,没理由不能这样做。这只是一个火箭想要击中的障碍物!我可以使用 contains() 方法在每个 Rocket 对象上设置一个新的 hitTarget 标志。如果火箭击中目标,它将停止,就像它撞到障碍物时一样:

记住,我还希望火箭到达目标的速度越快,其适应度越高。相反,越慢到达目标,适应度得分越低。为了实现这一点,可以在火箭生命周期的每个周期中递增 finishCounter,直到它到达目标。生命结束时,计数器将等于火箭到达目标所花费的时间:

我还希望将适应度与finishCounter成反比。为了实现这一点,我可以通过以下修改来改进适应度函数:

这两个改进已经融入到例子 9.3 的代码中。

这个例子可以通过多种方式改进和扩展。以下习题提供了深入探索遗传算法的想法和挑战。你还能尝试什么?
习题 9.9
创建一个更复杂的障碍课程。随着你增加火箭到达目标的难度,是否需要改进遗传算法的其他方面——例如,适应度函数?
习题 9.10
实现 Thorp 原始智能火箭的发射模式。每个火箭只有五个推进器(可以是任意方向和强度),并且有一个发射顺序(长度可任意)。Thorp 的模拟还为火箭提供了有限的燃料。
习题 9.11
以不同的方式可视化模拟。你能画出到目标的最短路径吗?你能以更有趣的方式画出火箭吗?如何添加粒子系统,模拟火箭推进器方向上的烟雾?
习题 9.12
另一种教火箭到达目标的方法是进化一个流场。你能把火箭的基因型变成一个向量流场吗?
互动选择
卡尔·西姆斯是计算机图形学研究员和视觉艺术家,他与遗传算法有着广泛的合作。(他还因其在粒子系统方面的工作而广为人知!)他的一个创新性进化项目是博物馆装置加拉帕戈斯。这个装置最初于 1997 年安装在东京的 NTT 互动通信中心,包含 12 台显示器,展示计算机生成的图像。这些图像随着时间的推移不断进化,遵循选择和繁殖的遗传算法步骤。
这里的创新不在于使用遗传算法(GA),而在于适应度函数背后的策略。在每台显示器前面都有一个地面传感器,可以检测到观众是否正在观看屏幕。图像的适应度与观众观看图像的时间长度相关。这被称为交互式选择,即由人类分配适应度值的遗传算法。
交互式选择并不仅限于艺术装置,它在数字时代的用户生成评级和评论中非常普遍。你能想象根据你的 Spotify 评分进化出完美的歌曲吗?或者根据 Goodreads 的评论进化出理想的书籍吗?然而,为了与本书的自然主题保持一致,我将通过使用一群数字花朵(如图 9.12 中的花朵)来说明交互式选择是如何工作的。

图 9.12:交互式选择的花朵设计
每朵花都有一组属性:花瓣颜色、花瓣大小、花瓣数量、花蕊颜色、花蕊大小、茎长和茎色。花的 DNA(基因型)是一个浮动的数字数组,范围从 0 到 1,每个属性都有一个单独的数值:

表型是一个Flower类,其中包含一个DNA对象的实例:

当需要绘制花朵时,我将使用 p5.js 的map()函数,将任何基因值转换为适当的像素尺寸或颜色值范围。(我还将使用colorMode()将 RGB 范围设置为 0 到 1。)

到目前为止,我还没有做任何新的事情。这和我迄今为止在每个遗传算法示例中所做的流程相同。不同之处在于,我将不会编写一个fitness()函数,通过数学公式计算得分。相反,我将要求用户分配适应度。
如何要求用户分配适应度,最好作为一个交互设计问题来解决,这实际上不在本书的讨论范围内。我不会展开详细讨论如何编程滑块,或是制作自己的硬件旋钮,或者创建一个允许人们提交在线评分的网页应用。如何获取适应度分数取决于你和你正在开发的具体应用。为了本次演示,我将借鉴 Sims 的加拉帕戈斯装置的灵感,当鼠标悬停在花朵上时,便增加其适应度。然后,当按下“进化下一代”按钮时,下一代花朵就会被创建。
看看 GA 的步骤——选择和繁殖——是如何应用在nextGeneration()函数中的,该函数由附加在 p5.jsbutton元素上的mousePressed()事件触发。适应度的增加是Population类的rollover()方法的一部分,该方法检测鼠标是否悬停在某个花朵设计上。你可以在本书的网站上的示例代码中找到更多关于这个草图的细节。

这个例子只是演示了交互选择的思想,并没有达到特别有意义的结果。首先,我没有特别注意花朵的视觉设计;它们只是一些不同大小和颜色的简单形状。(不过,看看你能否在代码中找到极坐标的使用!)Sims 为他的图像使用了更复杂的数学函数作为基因型。你也可以考虑使用基于向量的方法,其中设计的基因型是一组点或路径。
然而,这里更为重要的问题是时间问题。在自然界中,进化发生在数百万年的时间跨度里。而在本章的第一个例子中的计算机模拟世界里,种群能够相对较快地进化出行为,因为新一代是通过算法生成的。在打字猫的例子中,每经过一次draw()(大约每秒 60 次),便会产生一个新一代。每一代智能火箭的寿命是 250 帧——在进化的时间尺度上,依然只是眨眼间的事。然而,在互动选择的情况下,你必须坐下来等着一个人对种群中的每一个成员进行评分,才能进入下一代。一个大种群对于用户来说,评估起来会变得非常繁琐——更不用说,能忍受多少代的评分了?
你当然可以通过巧妙的方法绕过这个问题。Sims 的加拉帕戈斯展览将评分过程隐藏在观众的视线之外,因为它通过正常的艺术观赏行为在画廊中发生。创建一个允许多人分布式评分的网页应用也是一种快速为大种群获取评分的好策略。
最终,成功的交互式选择系统的关键归结为之前所确立的相同关键点。基因型和表现型是什么?你如何计算适应度——或者在这种情况下,你的策略是什么,根据交互来分配适应度?
练习 9.13
构建你自己的交互式选择项目。除了视觉设计外,考虑进化声音——例如,一段短的音调序列。你能设计一个策略吗,比如一个网络应用或物理传感器系统,用来随着时间的推移从很多人那里获取评分?
练习 9.14
卡尔·西姆斯(Karl Sims)在遗传算法领域的另一部开创性作品是“进化虚拟生物”。在这个项目中,一群数字生物在一个模拟物理环境中被评估其执行任务的能力,例如游泳、奔跑、跳跃、跟随和争夺一个绿色立方体。该项目使用基于节点的基因型:生物的 DNA 不是一个线性的向量或数字列表,而是一个节点图(就像第六章中的软体物理模拟)。表现型是生物的身体本身,一个由肌肉连接的肢体网络。
你能否将花、植物或生物的 DNA 设计成一个零件网络?其中一个想法是使用交互式选择来进化设计。或者,你可以结合弹簧力,或许使用 Toxiclibs.js 或 Matter.js,创建一个简化的 2D 版《模拟人生》生物。如果它们根据与特定目标相关的适应度函数进化会怎样呢?想了解更多关于《模拟人生》技术的内容,你可以阅读他 1994 年的论文 (www.karlsims.com/papers/siggraph94.pdf),并观看 YouTube 上的“进化虚拟生物”视频 (youtu.be/RZtZia4ZkX8)。

生态系统模拟
你可能已经注意到,在本章中我所构建的进化系统有些奇怪。在现实世界中,一群婴儿并不会同时出生。这些婴儿也不会同时长大,并且在同一时间繁殖,然后瞬间死去,保持种群数量的完美稳定。那样的话,简直是荒谬的。更不用说,肯定没有人在森林里拿着计算器, crunch 数字并为所有生物分配适应度值了。
在现实世界中,正如我在本章开头所讨论的,你并不是真的有“适者生存”,你有的是繁殖者生存。那些恰巧活得更久的生物,在很多情况下,拥有更大的繁殖机会。婴儿出生了,它们活了一段时间,也许它们自己有孩子,也许没有,然后它们死去。我能否编写一个捕捉这种更现实的进化生物学视角的草图呢?
你不一定会在人工智能教科书中找到现实世界进化的模拟。遗传算法(GA)通常用于本章前面更为正式的方式。然而,由于你正在阅读本书以开发自然系统的模拟,因此值得看看如何使用 GA 构建类似于我在每章末尾项目提示中描述的那种生物生态系统。
我将从想象一个简单的场景开始。我将创建一个名为bloop的生物,一个根据 Perlin 噪声在画布上移动的圆形生物。这个生物会有一个半径和一个最大速度。它越大,移动越慢;越小,移动越快:

和往常一样,bloop 的种群可以存储在一个数组中,数组可以由一个名为World的类来管理:

到目前为止,我只是在重复第四章中的粒子系统。我有一个名为Bloop的实体,它在画布上移动,还有一个名为World的类,管理这些实体的数量。为了将其转变为一个会进化的系统,我需要为我的世界添加两个额外的特性:
-
Bloop 死亡。
-
Bloop 会诞生。
Bloop 的死亡是我用来替代适应度函数和选择过程的方法。如果一个 bloop 死亡了,它就不能被选为父代,因为它不再存在!为了在世界中确保 bloop 死亡,我可以向Bloop类添加一个health变量:

每次通过update()时,bloop 都会失去一些生命值:

如果health降到0以下,bloop 就会死亡:

这一步是个不错的起点,但我还没有真正取得任何成就。毕竟,如果所有的 bloop 都从 100 生命值开始,并且以相同的速率失去生命值,那么所有 bloop 的寿命将是完全相同的,并且会一起死亡。如果每一个 bloop 都活得一样久,那么每个 bloop 都有相等的繁殖机会,因此不会发生进化变化。
你可以通过更复杂的世界设计实现不同的寿命长度。一种方法是引入捕食者来吃掉 bloop。更快的 bloop 更可能逃脱被吃掉的命运,从而导致越来越快的 bloop 进化。另一种方法是引入食物。当 bloop 吃掉食物时,它的生命值增加,从而延长其寿命。
假设我有一个名为food的向量位置数组。我可以测试每个 bloop 与每个食物位置的接近程度。如果 bloop 足够接近,它就会吃掉食物(然后食物从世界中移除),并增加它的生命值。

在这个场景中,吃得更多的“bloop”预期能活得更久,且更有可能繁殖。因此,系统应该进化出能够优化寻找和消费食物的“bloop”。
现在世界已经建立,接下来是添加进化所需的组件。第一步是确定基因型和表型。
基因型与表型
一个“bloop”寻找食物的能力与两个变量相关:大小和速度(见图 9.13)。更大的“bloop”更容易找到食物,因为它们的体积会让它们更频繁地与食物的位置相交。而更快的“bloop”会找到更多的食物,因为它们能够在更短的时间内覆盖更多的地面。

图 9.13:小型和大型“bloop”生物。示例将使用简单的圆形,但你可以更富有创意地设计!
由于大小与速度成反比(大型“bloop”动作慢,小型“bloop”动作快),我需要一个只有单一数字的基因型。

表型就是“bloop”本身,它的大小和速度是通过将一个DNA对象实例添加到Bloop类中来分配的:

请注意,maxSpeed属性被映射到一个从15到0的范围内。一个基因值为0的“bloop”将以速度15移动,而基因值为1的“bloop”将完全不动(速度为0)。
选择与繁殖
现在我有了基因型和表型,接下来需要设计一个方法来选择“bloop”作为父母。我之前提到过,越长寿的“bloop”繁殖的机会就越多。一个“bloop”的寿命长短就是它的适应度。
一种选择是说每当两个“bloop”相互接触时,它们会生成一个新的“bloop”。一个“bloop”活得越久,它与另一个“bloop”接触的机会就越大。这也会影响进化结果,因为除了进食,繁殖的可能性还取决于“bloop”定位其他“bloop”的能力。
一个更简单的选择是让“bloop”自己克隆自己,而不需要伙伴“bloop”,立即创建一个拥有相同基因组成的新的“bloop”。比如,如果我说,在任何给定的时刻,一个“bloop”有 1%的概率进行繁殖,会怎么样?使用这个选择算法,一个“bloop”活得越久,它就越可能克隆自己。这相当于说,你买彩票的次数越多,你赢的机会就越大(尽管我很抱歉地说,你中彩票的机会仍然基本为零)。
为了实现这个选择算法,我可以在Bloop类中编写一个方法,每一帧都选择一个随机数。如果这个数小于 0.01(1%),就会诞生一个新的“bloop”:

那么,bloop 是如何繁殖的呢?在之前的例子中,繁殖过程涉及到调用DNA类中的crossover()方法,并根据生成的基因数组创建一个新对象。然而,在这个例子中,由于我是从一个父体中生成一个孩子,我将调用一个名为copy()的方法:

请注意,我已将繁殖概率从 1%降低到 0.05%。这个改变带来了显著差异;如果繁殖概率过高,系统将迅速过度拥挤。而如果概率太低,一切可能很快灭绝。
将copy()方法写入DNA类非常简单,使用 JavaScript 的数组方法slice(),这是一种标准的 JavaScript 方法,通过复制现有数组中的元素来创建一个新数组:

在选择和繁殖机制到位后,我可以最终确定World类,来管理所有Bloop对象的列表,以及一个Food对象,里面包含食物的位置列表(我会将其画成小方块)。
在运行这个例子之前,先花点时间猜测一下系统最终会进化出哪种大小和速度的 bloop。我会在代码之后讨论这些细节。


如果你猜测是中等大小的 bloop 以中等速度移动,你是对的。根据这个系统的设计,过大的 bloop 根本太慢,无法找到食物。而过快的 bloop 又太小,找不到食物。能够存活最久的 bloop 通常处于中间,既足够大,又足够快,能找到食物(但又不至于过大或过快)。当然,也存在一些异常情况。例如,如果一群大 bloop 恰好聚集在同一个位置(而且由于它们太大,几乎不动),它们可能会突然全部死掉,留下大量的食物供某个恰好在场的大 bloop 食用,从而让一个小规模的大 bloop 种群能够在某个位置上维持一段时间。
这个例子相当简单,因为它只有一个基因,并且是克隆而不是交叉。以下是一些建议,可以将 bloop 的例子应用于更复杂的生态系统模拟中。
生态系统项目
向你的生态系统中添加进化机制,基于本章中的例子进行构建:
-
向你的生态系统中添加一群捕食者。捕食者与猎物(或寄生虫与宿主)之间的生物进化通常被称为军备竞赛,在这个过程中,生物体不断地适应和反适应对方。你能在多个生物体的系统中实现这种行为吗?
-
你会如何在一个模拟 bloop 的生态系统中实现交叉和突变呢?试着实现一个算法,使得当两个生物体在一定距离内时,它们可以交配。
-
尝试将多个引导力量的权重作为生物的 DNA。你能创造一个生物进化出相互合作的场景吗?
-
生态系统模拟中最大的挑战之一是实现平衡。你可能会发现,大多数尝试要么导致大规模人口过剩(随后是大规模灭绝),要么直接发生大规模灭绝。你可以采用哪些技术来实现平衡?考虑使用遗传算法(GA)来演化生态系统的最优参数。

第十一章:10 神经网络
人类大脑有 1000 亿个神经元,每个神经元与其他 1 万个神经元相连。你肩膀上的东西是已知宇宙中最复杂的物体。
—加贺美智夫

马丘比丘博物馆展示的基普,位于秘鲁库斯科(照片由 Pi3.124 提供)**
基普(或基普)是古代印加文明用来记录和传递信息的工具。它由一套复杂的结绳系统构成,用来编码和传递信息。每条颜色不同的绳子、结的类型和样式都代表了特定的数据,如人口普查记录或日历信息。被称为基普卡马约克的解读者充当了一种会计人员的角色,将这些串联的故事解码成可理解的信息。
我从生活在力量世界中的无生命物体开始,赋予它们欲望、自治权以及根据规则系统采取行动的能力。接着,我让这些物体(现在称为生物)生活在一个群体中并随时间进化。现在我想问,是什么决定了每个生物的决策过程?它如何通过学习随时间调整其选择?一个计算实体能否处理它的环境并生成决策?
为了回答这些问题,我再次从自然界寻找灵感——具体来说,是人类的大脑。大脑可以被描述为一种生物学神经网络,这是一个相互连接的神经元网络,传递复杂的电信号模式。在每个神经元内部,树突接收输入信号,基于这些输入,神经元通过轴突发出输出信号(见图 10.1)。或者类似的东西。人类大脑究竟是如何工作的,这仍然是一个复杂且精细的谜团,肯定不是我在本章中打算严格详细解开的问题。

图 10.1:一个神经元,带有树突和与另一个神经元相连的轴突
幸运的是,正如你在本书中所见,开发引人入胜的动画系统并不需要严格的科学性或准确性。设计一枚智能火箭并不需要火箭科学,设计一个人工神经网络也不需要大脑科学。仅仅受到大脑功能这一概念的启发就足够了。
在本章中,我将首先概述神经网络的特性和功能,并构建一个最简单的神经网络示例,即由一个神经元组成的网络。然后,我将通过使用 ml5.js 库向您介绍更复杂的神经网络。这将为第十一章奠定基础,本书的高潮部分,我将在那里结合遗传算法与神经网络进行物理模拟。
介绍人工神经网络
计算机科学家们长期受到人脑的启发。1943 年,神经科学家沃伦·S·麦卡洛克和逻辑学家沃尔特·皮茨开发了第一个人工神经网络的概念模型。在他们的论文《神经活动中固有思想的逻辑演算》中,他们将神经元描述为一个计算单元,生活在由多个细胞组成的网络中,接收输入、处理输入并生成输出。
他们的工作,以及许多后来的科学家和研究人员的工作,并不是为了准确描述生物大脑的工作原理。相反,人工神经网络(以下简称神经网络)旨在作为一个基于大脑的计算模型,设计来解决传统上对计算机来说很困难的某些问题。
一些问题对计算机来说非常简单,但对你我这样的普通人来说却很困难。例如,求 964,324 的平方根。只需一行简单的代码就能得出值 982,这个数字我的计算机不到一毫秒就能计算出来,但如果你让我自己计算,我得让你等上很久。另一方面,某些问题对你我来说非常简单,但对计算机来说却不那么容易。给任何一个幼儿看一张小猫或小狗的照片,他们能很快告诉你哪个是哪个。坐在嘈杂的咖啡馆里,专心听某个人的声音,你能轻松理解他们的讲话。但是,如果让机器执行这些任务呢?科学家们为此花费了大半生的时间,研究并实施复杂的解决方案,而神经网络就是其中之一。
以下是一些今天神经网络在软件中的“人类容易、机器难”的应用:
-
模式识别: 神经网络非常适合那些旨在检测、解释和分类数据集中各类特征或模式的问题。这包括从识别图像中的物体(如面部)到光学字符识别,再到更复杂的任务,如手势识别。
-
时间序列预测与异常检测: 神经网络既用于预测,如预测股市趋势或天气模式,也用于识别异常,这些可以应用于网络攻击检测和防止欺诈等领域。
-
控制与自适应决策系统: 这些应用从自动驾驶汽车和无人机等自主驾驶工具,到游戏玩法、定价模型以及媒体平台上的推荐系统等自适应决策系统不等。
-
信号处理与软传感器: 神经网络在耳蜗植入物和助听器等设备中起着至关重要的作用,通过过滤噪音并放大重要声音。它们还参与了软传感器的工作,即处理来自多个来源的数据,提供关于环境的全面分析的软件系统。
-
自然语言处理(NLP): 近年来最大的进展之一就是神经网络在处理和理解人类语言方面的应用。它们被用于多种任务,包括机器翻译、情感分析和文本摘要,是许多数字助手和聊天机器人的核心技术。
-
生成模型: 新型神经网络架构的兴起使得生成新内容成为可能。这些系统能够合成图像、提高图像分辨率、在图像之间转移风格,甚至生成音乐和视频。
覆盖神经网络应用的所有领域可能需要一本完整的书(或一系列书籍),而且等到书出版时,它可能就已经过时了。希望这个列表能给你一个整体的概念,帮助你了解这些功能和可能性。
神经网络的工作原理
从某些方面来说,神经网络与其他计算机程序有很大的不同。我在本书中所写的计算系统都是过程化的:程序从第一行代码开始,执行完后继续执行下一行,按照线性顺序执行指令。与之相反,一个真正的神经网络并不会遵循线性路径。相反,信息是通过整个节点网络集体并行处理的,每个节点代表一个神经元。从这个意义上讲,神经网络被视为一种连接主义系统。
从某种意义上说,神经网络与你见过的一些程序并没有太大区别。神经网络展现了复杂系统的所有特征,类似于细胞自动机或鸟群。还记得每个个体鸟群(boid)是如此简单易懂,但通过遵循三条规则——分离、对齐、聚合——它却能产生复杂的行为吗?神经网络中的每个个体元素同样简单易懂。它读取输入(一个数字),处理它,并生成输出(另一个数字)。就这么简单,然而,由许多神经元组成的网络却能展现出极为丰富和智能的行为,回响着鸟群中所见的复杂动态。

图 10.2:神经网络是由神经元和连接组成的系统。
实际上,神经网络不仅仅是一个复杂的系统,它还是一个复杂的自适应系统,这意味着它可以根据流经其中的信息改变其内部结构。换句话说,它具有学习的能力。通常,这是通过调整权重来实现的。在图 10.2 中,每个箭头表示两个神经元之间的连接,并指示信息流动的路径。每个连接都有一个权重,这是控制两个神经元之间信号的数字。如果网络产生了一个好的输出(我稍后会定义),则不需要调整权重。然而,如果网络产生了一个差的输出——可以说是一个错误——那么系统就会进行自我调整,改变权重,以期改进后续的结果。
神经网络可能会使用多种学习策略,本章将重点介绍其中的一种:
-
监督学习: 本质上,这种策略涉及一个比网络本身更聪明的教师。以人脸识别为例,教师向网络展示一组人脸,并且教师已经知道每张人脸对应的名字。网络进行猜测,然后教师提供正确的名字。网络可以将其答案与已知的正确答案进行比较,并根据错误进行调整。本章中的神经网络遵循这种模型。
-
无监督学习: 当你没有带有已知答案的示例数据集时,就需要使用这种技术。相反,网络会自主工作,发掘数据中的隐藏模式。其应用之一是聚类:一组元素根据一个未知模式被划分为不同的组。我不会展示无监督学习的实例,因为这种策略与本书的示例关系较小。
-
强化学习: 这一策略建立在观察基础上:学习代理做出决策,并根据其环境来查看结果。它因做出正确决策而获得奖励,因做出错误决策而受到惩罚,从而随着时间的推移学会做出更好的决策。我将在第十一章中更详细地讨论这一策略。
神经网络的学习能力,即随着时间推移对其结构进行调整的能力,是它在机器学习领域如此有用的原因。这个术语可以追溯到 1959 年发表的论文《使用跳棋进行机器学习的研究》,在这篇论文中,计算机科学家亚瑟·李·塞缪尔提出了一个用于下跳棋的“自学习”程序。使计算机在没有显式编程的情况下学习的算法概念是机器学习的基础。
想一想你在这本书中做了什么:编码!在传统的编程中,计算机程序接受输入,并根据你提供的规则生成输出。然而,机器学习却颠倒了这种方式。系统不是由你编写规则,而是给定示例输入和输出,并自行生成规则!可以用许多算法来实现机器学习,神经网络只是其中之一。
机器学习是人工智能(AI)这个广泛领域的一部分,尽管这两个术语有时可以互换使用。在 Mimi Onuoha 和 Diana Nucera(又名 Mother Cyborg)所著的友好入门书籍 A People’s Guide to AI 中,他们将 AI 定义为“能够执行通常需要人类智慧的任务的计算机系统的理论与发展”。机器学习算法是实现这些任务的一种方法,但并非所有的 AI 系统都具备自我学习的组件。
机器学习库
如今,在创意编码和互动媒体中利用机器学习不仅是可行的,而且越来越普遍,这得益于处理大量神经网络实现细节的第三方库。尽管绝大多数机器学习开发和研究都是用 Python 完成的,但在 Web 开发领域,基于 JavaScript 的强大工具也逐渐涌现。值得注意的两个库是 TensorFlow.js 和 ml5.js。
TensorFlow.js 是一个开源库,允许你使用 JavaScript 直接在浏览器中定义、训练和运行神经网络,而无需安装或配置复杂的环境。它是 TensorFlow 生态系统的一部分,由 Google 维护和开发。TensorFlow.js 是一个强大的工具,但其底层操作和高度技术化的 API 对初学者来说可能有些令人畏惧。这时,ml5.js 应运而生,它是建立在 TensorFlow.js 之上的库,专为与 p5.js 一起使用而设计。它的目标是让初学者更易上手,使机器学习变得更加亲民,面向广泛的艺术家、创意编码者和学生。我将在《使用 ml5.js 进行机器学习》一章中展示如何使用 ml5.js,具体内容见 第 521 页。
像 TensorFlow.js 和 ml5.js 这样的库的一个好处是,你可以用它们来运行预训练模型。一个机器学习模型是神经元和连接的特定配置,而预训练模型是已经为特定任务准备好的模型。例如,常见的预训练模型用于图像分类、识别身体姿势、识别面部标志或手部位置,甚至分析文本中的情感。你可以直接使用这样的模型,也可以将其作为进一步学习的起点(通常称为迁移学习)。
在我开始探索 ml5.js 库之前,我想先从零开始构建最简单的神经网络,只使用 p5.js,以此来说明神经网络和机器学习的概念是如何在代码中实现的。
感知机
感知机是最简单的神经网络:一个单一神经元的计算模型。感知机是由 Frank Rosenblatt 于 1957 年在康奈尔航空实验室发明的,它由一个或多个输入、一个处理器和一个输出组成,如图 10.3 所示。

图 10.3:一个简单的感知机,具有两个输入和一个输出
感知机遵循前馈模型:数据在网络中单向流动。输入被送入神经元,经过处理后产生输出。这意味着在图 10.3 中描绘的单神经元网络是从左到右(前向)读取的:输入进来,输出出去。
假设我有一个感知机,两个输入值分别是 12 和 4。在机器学习中,通常用 x 来表示每个输入,所以我将这些输入命名为 x[0] 和 x[1]:
| 输入 | 值 |
|---|---|
| x[0] | 12 |
| x[1] | 4 |
感知机步骤
为了从这些输入得到一个输出,感知机遵循一系列步骤。
步骤 1:加权输入
每个输入进入神经元之前,必须先加权,也就是说它会与一个值相乘,通常这个值在–1 到+1 之间。在创建感知机时,输入通常会被赋予随机权重。我将我的权重命名为 w[0] 和 w[1]:
| 权重 | 值 |
|---|---|
| w[0] | 0.5 |
| w[1] | –1 |
每个输入需要与其对应的权重相乘:
| 输入 | 权重 | 输入 × 权重 |
|---|---|---|
| 12 | 0.5 | 6 |
| 4 | –1 | –4 |
步骤 2:求和输入
然后将加权输入相加:
6 + −4 = 2
步骤 3:生成输出
感知机的输出是通过将总和传递通过一个激活函数生成的,这个激活函数将输出缩减为两个可能值中的一个。可以将这个二进制输出想象成一个 LED 灯,它只有关闭或开启两种状态,或者像大脑中的神经元,要么发射信号,要么不发射信号。激活函数决定了感知机是否应该“发射”信号。
激活函数可能有些复杂。如果你开始阅读人工智能教材,你可能很快就会想拿起微积分教材。然而,你的新朋友——简单的感知机提供了一个更容易理解的选项,同时仍能演示这个概念。我将激活函数定义为总和的符号。如果总和是正数,输出为 1;如果是负数,输出为–1:
sign(2) = +1
将所有部分结合起来
将前面提到的三部分结合起来,以下是感知机算法的步骤:
-
对于每个输入,将该输入与它的权重相乘。
-
对所有加权输入求和。
-
通过将该和值传递给激活函数(和值的符号),计算感知器的输出。
我可以通过使用两个值数组来开始编写这个算法,一个用于输入,一个用于权重:
let inputs = [12, 4];
let weights = [0.5, -1];
步骤 1 中的“对于每个输入”意味着一个循环,将每个输入与其对应的权重相乘。为了获得和,可以在同一个循环中将结果相加:

有了和,我就可以计算输出:

你可能会想,我是如何处理激活函数中的 0 值的。0 是正数还是负数?撇开这个问题的深刻哲学意义,我在这里选择任意将 0 返回为-1,但我也可以很容易地将>改为>=来改变方向。根据应用的不同,这个决定可能会很重要,但在这里为了演示的目的,我可以随便选一个。
现在我已经解释了感知器的计算过程,接下来让我们看一个实际应用的例子。
使用感知器进行简单模式识别
我提到过神经网络通常用于模式识别。前面描述的场景需要更复杂的网络,但即使是一个简单的感知器,也能演示一种基本的模式识别方法,其中数据点被分类为属于两个组中的一个。例如,假设你有一个植物数据集,并且想要将它们识别为耐旱植物(能够在水少、阳光多的环境中生存的植物,如沙漠)或水生植物(适应于生活在水中并且光线较弱的植物)。这就是我将在本节中使用感知器的方式。
对植物进行分类的一种方法是将它们的数据绘制在二维图表上,并将问题视为空间问题。在 x 轴上绘制植物每天接收到的阳光量,在 y 轴上绘制水分量。一旦所有数据都被绘制出来,就很容易在图表上画一条线,将所有耐旱植物放在一边,所有水生植物放在另一边,如图 10.4 所示。(这里我稍作简化,现实世界的数据可能更为复杂,画线会更难。)这样,每个植物就能被分类了。它在直线下方吗?那么它就是耐旱植物。它在直线上方吗?那么它就是水生植物。

图 10.4:二维空间中的点集合,通过一条线将植物分类,表示根据它们的水分和阳光摄取量
事实上,我并不需要一个神经网络——甚至不需要一个简单的感知机——来告诉我一个点是在直线的上方还是下方。我可以用自己的眼睛看到答案,或者让计算机通过简单的代数来算出。但就像在第九章中通过已知答案“生存还是毁灭”来测试遗传算法一样,训练感知机来分类点是否位于直线的两侧,将是展示感知机算法并验证其正常工作的有价值的方式。
为了解决这个问题,我将给感知机两个输入:x[0]是一个点的 x 坐标,代表植物的阳光量,x[1]是该点的 y 坐标,代表植物的水量。感知机根据这些输入的加权和的符号来推测植物的分类。如果加权和为正,感知机输出+1,表示水生植物(在直线之上)。如果加权和为负,它输出-1,表示旱生植物(在直线之下)。图 10.5 展示了这个感知机(注意 w[0] 和 w[1] 是权重的简写)。

图 10.5:一个有两个输入(x[0] 和 x[1])的感知机,每个输入都有一个权重(w[0] 和 w[1]),以及一个生成输出的处理神经元
然而,这个方案有一个相当显著的问题。假设我的数据点是(0, 0),我将这个点作为输入 x[0] = 0 和 x[1] = 0 送入感知机。无论权重如何,0 乘以任何数都是 0。因此,加权输入仍然是 0,它们的和也将是 0。而 0 的符号是……嗯,又回到了那个深刻的哲学困境。无论我对它的感觉如何,点(0, 0)在二维世界中肯定可以位于不同的直线之上或之下。感知机应该如何准确地解释它呢?
为了避免这种困境,感知机需要一个第三个输入,通常称为偏置输入。这个额外的输入始终为 1,并且也有权重。图 10.6 展示了加入偏置后的感知机。

图 10.6:向感知机添加一个偏置输入及其权重
这如何影响点(0, 0)?
| 输入 | 权重 | 结果 |
|---|---|---|
| 0 | w[0] | 0 |
| 0 | w[1] | 0 |
| 1 | w[bias] | w[bias] |
输出是加权结果的总和:0 + 0 + w[bias]。因此,偏置本身就回答了点(0, 0)相对于直线的位置。如果偏置的权重是正的,(0, 0)在直线之上;如果是负的,它就在直线之下。额外的输入及其权重对感知机理解直线相对于(0, 0)的位置产生了偏置!
感知机代码
我现在准备组装Perceptron类的代码了。感知器只需要跟踪输入权重,我可以使用数组来存储它们:

构造函数可以接收一个参数,指示输入的数量(在此案例中为三个:x[0],x[1],以及一个偏置),并相应地调整weights数组的大小,初始时填充随机值:

感知器的工作是接收输入并生成输出。这些需求可以打包在一个feedForward()方法中。在这个例子中,感知器的输入是一个数组(其长度应与权重数组相同),而输出是一个数字,+1 或 –1,作为激活函数根据和的符号返回的结果:

假设我现在可以创建一个Perceptron对象,并要求它对任何给定点进行预测,如图 10.7 所示。

图 10.7:二维空间中的一个 (x, y) 坐标是感知器的输入。
这是生成预测的代码:

感知器猜对了吗?可能是,也可能不是。此时,感知器猜对的概率也就是 50/50,因为每个权重一开始都是随机值。神经网络并不是一个能自动正确猜测的魔法工具。我需要教它如何做到这一点!
为了训练神经网络正确回答,我将使用本章前面描述的监督学习方法。记住,这种方法涉及给网络提供带有已知答案的输入,使得网络能够检查自己是否做出了正确的预测。如果没有,网络可以从错误中学习并调整权重。这个过程如下:
-
提供感知器已知答案的输入。
-
让感知器进行一次预测。
-
计算误差。(它猜对了吗?)
-
根据误差调整所有权重。
-
返回到第 1 步并重复!
这个过程可以打包成Perceptron类中的一个方法,但在编写之前,我需要更详细地检查步骤 3 和 4。我该如何定义感知器的误差?以及我应该如何根据这个误差调整权重?
感知器的误差可以定义为期望答案与预测答案之间的差异:
error = 期望输出 − 预测输出
这个公式看起来很熟悉吗?回想一下我在第五章中计算的车辆转向力公式:
steering = 期望速度 − 当前速度
这也是一种误差计算!当前速度作为预测值,而误差(转向力)则指示如何将速度调整到正确的方向。调整车辆的速度以跟随目标类似于调整神经网络的权重以接近正确答案。
对于感知机来说,输出只有两个可能的值:+1 或 –1。因此,只有三种误差是可能的。如果感知机猜测正确,猜测值等于期望输出,误差为 0。如果正确答案是–1,而感知机猜测为+1,则误差为–2。如果正确答案是+1,而感知机猜测为–1,则误差为+2。下面是该过程的总结表格:
| 期望 | 猜测 | 误差 |
|---|---|---|
| –1 | –1 | 0 |
| –1 | +1 | –2 |
| +1 | –1 | +2 |
| +1 | +1 | 0 |
误差是决定感知机权重如何调整的关键因素。对于任何给定的权重,我需要计算的是权重的变化,通常称为Δweight(或delta weight,Δ为希腊字母 delta):
新权重 = 权重 + Δweight
为了计算Δweight,我需要将误差与输入相乘:
Δweight = 误差 × 输入
因此,新的权重计算如下:
新权重 = 权重 + 误差 × 输入
要理解为什么这样有效,再次考虑一下引导。引导力本质上是速度的误差。通过将引导力作为加速度(或Δ速度)施加,速度就能调整到正确的方向。这正是我想要在神经网络的权重中做的。我想根据误差,将它们调整到正确的方向。
然而,在引导中,我还有一个额外的变量控制着车辆的转向能力:最大力。较高的最大力允许车辆快速加速和转弯,而较低的力则导致较慢的速度调整。神经网络将使用类似的策略,通过一个叫做学习常数的变量:
新权重 = 权重 + (误差 × 输入) × 学习常数
较高的学习常数会使权重变化更剧烈。这可能帮助感知机更快地找到解决方案,但也增加了超越最优权重的风险。较小的学习常数会使权重调整得更慢,需要更多的训练时间,但能够让网络进行小幅度调整,从而提高整体准确性。
假设向Perceptron类添加了一个learningConstant属性,我现在可以按照之前列出的步骤,编写一个感知机的训练方法:

下面是整个Perceptron类:

要训练感知机,我需要一组已知答案的输入。然而,我恰好没有现实世界的数据集(也没有时间去研究和收集一个)来用于干旱植物和水生植物的情境。事实上,这个演示的目的并不是告诉你如何分类植物。而是要展示感知机如何学习判断点在图表中是位于线的上方还是下方,因此任何一组点都可以用来做演示。换句话说,我可以随便编造数据。
我所描述的是一个合成数据的例子,合成数据是人工生成的数据,通常用于机器学习中,创建用于训练和测试的受控场景。在这种情况下,我的合成数据将由一组随机输入点组成,每个点都有一个已知的答案,指示该点是在线上方还是下方。为了定义这条直线并生成数据,我将使用简单的代数。这种方法使我能够清楚地演示训练过程,并展示感知机是如何学习的。
所以问题变成了,如何选择一个点并知道它是在直线的上方还是下方(也就是说,不使用神经网络)?一条直线可以描述为一组点,其中每个点的 y 坐标是其 x 坐标的一个函数:
y = f(x)
对于一条直线(特别是一个线性函数),它们之间的关系可以写成这样:
y = mx + b
这里 m 是直线的斜率,b 是当 x 为 0 时的 y 值(即 y 截距)。以下是一个具体的例子,以及图 10.8 中的对应图形。


图 10.8:一个图表 
我将任意选择这个作为我的直线方程,并相应地编写一个函数:

现在,p5.js 画布默认将 (0, 0) 放在左上角,且 y 轴指向下方。为了本次讨论,我假设我已经在代码中做了以下处理,以将画布重新定向以匹配更传统的笛卡尔坐标系。

现在我可以在二维空间中选择一个随机点:
let x = random(-100, 100);
let y = random(-100, 100);
我如何知道这个点是在直线的上方还是下方呢?直线函数 f(x) 返回该 x 位置上的 y 值。我称之为 y[line]:

如果我检查的 y 值在直线之上,那么它会大于 y[line],如图 10.9 所示。

图 10.9:如果 y[line] 小于 y,则该点在直线之上。
以下是实现该逻辑的代码:

然后,我可以创建一个输入数组,并与 desired 输出一起使用:

假设我有一个 perceptron 变量,我可以通过提供输入和期望的答案来训练它:
perceptron.train(trainingInputs, desired);
如果我在每次通过 draw() 循环时都在一个新的随机点(及其答案)上训练感知机,它将逐渐提高对这些点是在线上方还是下方的分类能力。


在 示例 10.1 中,训练数据与目标解线一起进行可视化展示。每个点代表一条训练数据,其颜色由感知器当前的分类决定——灰色代表 +1,白色代表 –1。我使用了一个小的学习常数(0.0001),以减缓系统在时间推移过程中对分类结果的调整。
这个示例的一个有趣之处在于感知器的权重与分割点的线特征之间的关系——特别是线的斜率和 y 截距(m 和 b 在 y = mx + b 中)。在这种情况下,权重并非只是任意的或“神奇”的数值;它们与数据集的几何形状有直接关系。在这里,我只使用了二维数据,但对于许多机器学习应用来说,数据通常存在于更高维的空间中。神经网络的权重有助于在这些空间中导航,定义 超平面 或决策边界,进而对数据进行分割和分类。
练习 10.1
修改 示例 10.1 中的代码,在训练过程中绘制感知器当前的决策边界——它对分界线应该在哪的最佳猜测。提示:使用感知器当前的权重来计算这条线的方程。
尽管这个感知器示例提供了一个概念基础,但现实世界的数据集通常具有更多样化和动态的输入值范围。在这里的简化场景中,x 的值范围大于 y,因为画布的大小是 640×240。尽管如此,示例仍然有效——毕竟,符号激活函数并不依赖于特定的输入范围,而且这是一个简单的二元分类任务。
然而,现实世界中的数据通常具有更复杂的输入范围。为此,数据标准化是机器学习中的一个关键步骤。数据标准化涉及将训练数据映射到一个统一的范围——通常是 0 到 1,或可能是 –1 到 1。这一过程可以提高训练效率,并防止个别输入主导学习过程。在接下来的章节中,使用 ml5.js 库,我将把数据标准化纳入其中。
练习 10.2
除了使用监督学习,你能否通过使用遗传算法(GA)训练神经网络来找到合适的权重?
练习 10.3
将数据标准化纳入示例中。这是否能提高学习效率?
将“网络”放入神经网络中
一个感知器可以有多个输入,但它仍然只是一个孤独的神经元。不幸的是,这限制了它能够解决的问题范围。神经网络的真正力量来自于 网络 部分。将多个神经元连接在一起,你就能够解决更为复杂的问题。
如果你阅读一本人工智能教材,会发现它说感知机只能解决线性可分的问题。如果数据集是线性可分的,你可以通过画一条直线将其在图上分类为两组(见图 10.10,左)。将植物分为耐旱植物或耐水植物就是一个线性可分的问题。

图 10.10:线性可分的数据点(左)和需要曲线来分离的数据点(右)
现在想象一下,你正在根据土壤酸度(x 轴)和温度(y 轴)对植物进行分类。一些植物可能在酸性土壤中茁壮成长,但仅在一个狭窄的温度范围内;而其他植物则更喜欢不太酸性的土壤,但能够适应更广泛的温度范围。两者之间存在更复杂的关系,因此无法通过一条直线将这两类植物——酸性植物 和 碱性植物(见图 10.10,右)分开。一个单一的感知机无法处理这种非线性可分的问题。(这里有个警告:我是在虚构这些情境。如果你恰好是植物学家,请告诉我我是否接近现实。)
非线性可分问题的一个最简单的例子就是 XOR(异或)。这是一种逻辑操作符,类似于更常见的 AND 和 OR。为了使 A AND B 为真,A 和 B 必须同时为真。而 OR 操作中,A 或 B(或两者)可以为真。这两个问题都是线性可分的。图 10.11 中的真值表展示了它们的解空间。表中的每个真值或假值展示了一个特定真值或假值输入组合的输出。看看你是否可以画一条直线将真值输出和假值输出分开?

图 10.11:AND 和 OR 逻辑运算符的真值表。真值和假值输出可以用一条线来分隔。
XOR 操作符等同于 (OR) 和 (NOT AND)。换句话说,A XOR B 仅在其中一个输入为真时结果为真。如果两个输入都为假或都为真,输出为假。举个例子,假设你晚餐吃披萨。你喜欢披萨上放菠萝,也喜欢放蘑菇,但把它们放在一起——呕!而普通的披萨也不好吃!
图 10.12 中的 XOR 真值表不是线性可分的。试着画一条直线来将真值输出和假值输出分开——你做不到!

图 10.12:你是否想吃披萨(左)和 XOR(右)的真值表。注意如何真值和假值输出无法通过一条直线分隔。
一个感知器甚至不能解决像 XOR 这样简单的问题,这可能看起来非常有限。但如果我用两个感知器构建一个网络呢?如果一个感知器能够解决线性可分的 OR 问题,另一个感知器能够解决线性可分的 NOT AND 问题,那么两个感知器结合起来可以解决非线性可分的 XOR 问题。
当你将多个感知器结合在一起时,你就得到了多层感知器,这是一种由许多神经元组成的网络(参见图 10.13)。其中一些是输入神经元,接收初始输入,一些是隐藏层的一部分(因为它们既不直接与网络的输入相连,也不与输出相连),然后是输出神经元,从中读取结果。
直到现在,我一直在可视化一个单一的感知器,使用一个圆圈表示一个神经元处理它的输入信号。现在,当我转向更大的网络时,通常会将所有元素(输入、神经元、输出)表示为圆圈,使用箭头来指示数据流动的方向。在图 10.13 中,你可以看到输入和偏差流入隐藏层,然后再流向输出。

图 10.13:多层感知器与简单感知器具有相同的输入和输出,但现在它包括了一个隐藏层的神经元。
训练一个简单的感知器是相当直接的:你将数据传入并根据误差评估如何调整输入权重。然而,对于多层感知器来说,训练过程变得更加复杂。网络的整体输出仍然是以与之前基本相同的方式生成的:输入乘以权重后求和,并通过网络的各个层向前传播。你仍然使用网络的猜测值来计算误差(期望结果 - 猜测值)。但现在网络层之间有了如此多的连接,每个连接都有自己的权重。你如何知道每个神经元或连接在网络整体误差中的贡献,以及该如何调整它们?
优化多层网络权重的解决方案是反向传播。这个过程将误差传递回网络,通过这种方式,它可以根据每个连接对总误差的贡献程度调整所有连接的权重。反向传播的详细过程超出了本书的范围。该算法使用多种激活函数(其中一个经典例子是 sigmoid 函数)以及一些微积分。如果你有兴趣继续探索这个领域,了解更多关于反向传播是如何工作的内容,你可以在 Coding Train 网站找到我的“玩具神经网络”项目,并配有视频教程 (thecodingtrain.com/neural-network)。这些教程会详细介绍如何使用多层前馈网络和反向传播解决 XOR 问题。然而,在本章中,我更希望得到一些帮助,打个电话给朋友。
使用 ml5.js 进行机器学习
那个朋友就是 ml5.js。这个机器学习库能够处理复杂过程的细节,比如反向传播,这样你和我就不必担心这些问题。如本章早些时候所提到的,ml5.js 旨在为机器学习和神经网络的新手提供一个友好的入门点,同时仍然借助 Google 的 TensorFlow.js 在后台提供强大的功能。
要在草图中使用 ml5.js,你必须通过 <script> 元素将其导入到 index.html 文件中,正如你在第六章中做过的那样,导入 Matter.js 和 Toxiclibs.js:
<script src="https://unpkg.com/ml5@1/dist/ml5.min.js"></script>
本章剩下的目标是通过开发一个能够识别鼠标手势的系统来介绍 ml5.js。这将为你准备好第十一章,在该章节中,我将为一个自主导航代理添加一个神经网络“脑”,并将机器学习重新融入到本书的故事中。然而,首先我想更一般性地讲解使用监督学习训练多层神经网络模型的步骤。概述这些步骤将突出在开发学习模型之前你需要做出的一些重要决策,介绍 ml5.js 库的语法,并为你训练自己的机器学习模型提供必要的背景知识。
机器学习生命周期
机器学习模型的生命周期通常被分为七个步骤:
-
收集数据。 数据是任何机器学习任务的基础。这个阶段可能包括运行实验、手动输入值、获取公共数据或采用各种其他方法(如生成合成数据)。
-
准备数据。 原始数据通常不适合机器学习算法使用。它可能包含重复值或缺失值,或者包含偏离数据的异常值。这些不一致的地方可能需要手动调整。此外,正如我之前提到的,神经网络在标准化数据上表现最佳,即数据值经过缩放以适应标准范围。准备数据的另一个关键部分是将数据分成不同的集合:训练集、验证集和测试集。训练数据用于训练模型(步骤 4),而验证和测试数据(这两者的区别很微妙——稍后会详细讲解)则被保留并用于评估模型的表现(步骤 5)。
-
选择模型。 设计神经网络的架构。不同的模型更适合某些类型的数据和输出。
-
训练模型。 将数据的训练部分输入模型,并根据模型的错误调整神经网络的权重。这个过程被称为优化:模型调整权重,以使错误数量最少。
-
评估模型。 记得在步骤 2 中留出的测试数据吗?因为这些数据没有用于训练,它为评估模型在新数据上的表现提供了依据。
-
调整参数。 训练过程受一组参数(通常称为超参数)的影响,比如学习率,它决定了模型应该根据预测误差调整权重的程度。我在感知机示例中称其为
learningConstant。通过微调这些参数,并重新审视步骤 4(训练)、步骤 3(模型选择)甚至步骤 2(数据准备),你通常可以改善模型的表现。 -
部署模型。 一旦模型经过训练并且其性能得到了满意的评估,就可以将模型用于真实世界中的新数据!
这些步骤是监督式机器学习的基石。然而,尽管 7 是一个非常完美的数字,我觉得我漏掉了一个更关键的步骤。我将其称为步骤 0。
- 确定问题。 这个初步步骤定义了需要解决的问题。目标是什么?你希望通过你的机器学习模型实现什么,或者预测什么?
这一步零定义了整个过程的其他步骤。毕竟,如果你不知道自己到底在做什么,怎么收集数据和选择模型呢?你是在预测一个数字?一个类别?一个序列?是二元选择,还是有很多选项?这些问题通常归结为在大多数机器学习应用中选择两种任务类型之一:分类和回归。
分类与回归
分类是一个机器学习问题类型,涉及预测数据的标签(也叫做类别或类)。如果这听起来很熟悉,那是因为它确实如此:示例 10.1 中的简单感知机被训练用于将点分类为位于直线之上或之下。举个例子,一个图像分类器可能会尝试猜测一张照片是猫还是狗,并为其分配相应的标签(见图 10.14)。

图 10.14:将图像标记为猫或狗
分类并非凭空发生。模型必须首先展示许多带有正确标签的狗和猫的样本,以便正确配置所有连接的权重。这是有监督学习中的训练部分。
经典的“Hello, world!”机器学习和有监督学习示例是一个 MNIST 数据集的分类问题。MNIST 是修改版国家标准与技术研究院的缩写,MNIST是由 Yann LeCun(纽约大学 Courant 研究所)、Corinna Cortes(谷歌实验室)和 Christopher J.C. Burges(微软研究院)收集和处理的数据集。该数据集在机器学习领域中广泛用于训练和测试,包含了 70,000 个手写数字,从 0 到 9;每个数字是一个 28×28 像素的灰度图像(参见图 10.15 中的示例)。每个图像都标注了对应的数字。

图 10.15:MNIST 数据集中手写数字 0–9 的选取样本(由 Suvanjanprasai 提供)
MNIST 是一个经典的图像分类训练数据集示例:该模型有一个离散的类别可供选择(准确来说是 10 个——不多也不少)。在模型经过 70,000 个标记图像的训练后,目标是让其对新图像进行分类,并分配适当的标签,即 0 到 9 之间的数字。
回归,另一方面,是一种机器学习任务,其预测结果是一个连续值,通常是一个浮动的数字。回归问题可以涉及多个输出,但从一个输出开始通常更为简单。例如,考虑一个机器学习模型,它根据房屋的入住人数、房屋大小和外部温度等输入因素来预测房屋的日常电力使用量(见图 10.16)。

图 10.16:天气、房屋大小和居住人数等因素可能会影响房屋的日常电力使用量。
与从离散的输出选项中选择不同,神经网络的目标是猜测一个数字——任何数字。那天这座房子会使用 30.5 千瓦时的电力吗?还是 48.7 千瓦时?或者 100.2 千瓦时?输出预测可能是一个连续范围内的任何值。
网络设计
知道你要解决的问题(第 0 步)对神经网络的设计有重要影响,特别是对其输入层和输出层的设计。我将用数据科学和机器学习领域的另一个经典“Hello, world!”分类示例来演示:鸢尾花数据集。这个数据集可以在加利福尼亚大学尔湾分校的机器学习库中找到,来源于美国植物学家埃德加·安德森(Edgar Anderson)的研究。
安德森在美国和加拿大的多个地区收集了多年的花卉数据。关于这个著名数据集的起源,详见安东尼·安温(Antony Unwin)和金·克莱因曼(Kim Kleinman)的《鸢尾花数据集:寻找维吉尼卡的来源》一文(* academic.oup.com/jrssig/article/18/6/26/7038520 )。在仔细分析数据后,安德森构建了一个表格,将鸢尾花分为三种不同的物种:鸢尾花雪絮种、鸢尾花变色种和鸢尾花维吉尼卡种*(见图 10.17)。

图 10.17:三种不同的鸢尾花物种
安德森为每朵花包括了四个数值属性:花萼长度、花萼宽度、花瓣长度和花瓣宽度,所有数据均以厘米为单位。(他还记录了颜色信息,但该数据似乎已丢失。)每条记录都会与适当的鸢尾花分类配对:
| 花萼长度 | 花萼宽度 | 花瓣长度 | 花瓣宽度 | 分类 |
|---|---|---|---|---|
| 5.1 | 3.5 | 1.4 | 0.2 | 鸢尾花雪絮种 |
| 4.9 | 3.0 | 1.4 | 0.2 | 鸢尾花雪絮种 |
| 7.0 | 3.2 | 4.7 | 1.4 | 鸢尾花变色种 |
| 6.4 | 3.2 | 4.5 | 1.5 | 鸢尾花变色种 |
| 6.3 | 3.3 | 6.0 | 2.5 | 鸢尾花维吉尼卡种 |
| 5.8 | 2.7 | 5.1 | 1.9 | 鸢尾花维吉尼卡种 |
在这个数据集中,前四列(花萼长度、花萼宽度、花瓣长度、花瓣宽度)作为神经网络的输入。输出是第五列中提供的分类。图 10.18 展示了一个可以在此数据上训练的神经网络可能的架构。

图 10.18:鸢尾花分类的可能网络架构
左侧是网络的四个输入,分别对应数据表的前四列。右侧是三个可能的输出,每个输出代表一种鸢尾花物种标签。中间是隐藏层,如前所述,它为网络架构增加了复杂性,这是处理非线性可分数据所必需的。隐藏层中的每个节点都与前后节点相连接。这通常被称为全连接层或密集层。
你也许会注意到,在这个图表中没有明确的偏置节点。虽然偏置在每个神经元的输出中起着重要作用,但它们通常在视觉表示中被省略,以保持图表的简洁并专注于主要的数据流。(ml5.js 库最终会在内部为我管理偏置。)
神经网络的目标是“激活”正确的输出,以适应输入数据,就像感知机会对其单一的二分类输出 +1 或 -1 一样。在这种情况下,输出值就像是信号,帮助网络决定要分配哪个鸢尾花物种标签。最高计算值的激活代表了网络对分类的最佳猜测。
这里的关键点是,分类网络应该有与数据集中每个项目的值相等的输入数目,并且输出的数目应该等于类别的数量。至于隐藏层,它的设计并不是固定的。图 10.18 中的隐藏层有五个节点,但这个数字完全是任意的。神经网络架构可以有很大的差异,隐藏节点的数量通常通过反复试验或其他有根据的猜测方法(称为启发式方法)来确定。在本书的上下文中,我将依赖于 ml5.js 来根据输入和输出数据自动配置架构。
那么在回归场景中,如我之前提到的家庭电力消耗的例子,输入和输出该如何处理呢?我将为这个场景编造一个数据集,包含居住人数、房屋面积、当天的温度,以及相应的电力使用量。这就像是一个合成数据集,考虑到它并非为真实世界情境收集的数据——但与自动生成的合成数据不同,在这里我正在手动输入我自己想象中的数字:
| 居住人数 | 面积 (m²) | 外部温度 (°C) | 电力使用 (kWh) |
|---|---|---|---|
| 4 | 150 | 24 | 25.3 |
| 2 | 100 | 25.5 | 16.2 |
| 1 | 70 | 26.5 | 12.1 |
| 4 | 120 | 23 | 22.1 |
| 2 | 90 | 21.5 | 15.2 |
| 5 | 180 | 20 | 24.4 |
| 1 | 60 | 18.5 | 11.7 |
对于这个问题的神经网络,应该有三个输入节点,分别对应前面三列(居住人数、面积、温度)。与此同时,它应该有一个输出节点,表示第四列,即网络对电力使用量的预测。我随便说一下,网络的隐藏层应该有四个节点,而不是五个。图 10.19 展示了这种网络架构。

图 10.19:一个可能的网络架构,具有三个输入和一个回归输出
与鸢尾花分类网络不同,后者是从三个标签中选择,因此有三个输出,而这个网络试图预测一个数字,因此只有一个输出。然而,我需要指出的是,单一输出并不是回归的要求。机器学习模型也可以执行预测多个连续值的回归,在这种情况下,模型将有多个输出。
ml5.js 语法
ml5.js 库是一个集合,包含可以通过语法 ml5.functionName() 访问的机器学习模型。例如,要使用一个预训练的模型来检测手部位置,可以使用 ml5.handPose()。要分类图像,可以使用 ml5.imageClassifier()。虽然我鼓励你探索 ml5.js 提供的所有功能(接下来的练习中我会提到一些这些预训练模型),但本章我将重点介绍 ml5.js 中的一个函数,ml5.neuralNetwork(),它为你创建一个空的神经网络供你训练。
要使用这个函数,首先必须创建一个 JavaScript 对象来配置正在创建的模型。此时,我刚才讨论的一些大局因素——这是一个分类任务还是回归任务?有多少个输入和输出?——开始发挥作用。我将首先指定模型要执行的任务("regression" 或 "classification"):
let options = { task: "classification" };
let classifier = ml5.neuralNetwork(options);
然而,这给 ml5.js 在设计网络架构时提供的信息很少。添加输入和输出将完成其余的工作。鸢尾花分类有四个输入和三个可能的输出标签。可以将其配置为 options 对象的一部分,其中包含一个整数表示输入数量,以及一个包含输出标签的字符串数组:
let options = {
inputs: 4,
outputs: ["iris-setosa", "iris-virginica", "iris-versicolor"],
task: "classification",
};
let digitClassifier = ml5.neuralNetwork(options);
电力回归场景有三个输入值(居住者、大小、温度)和一个输出值(kWh 的使用量)。使用回归时,输出没有字符串标签,因此只需要一个整数来表示输出的数量:
let options = {
inputs: 3,
outputs: 1,
task: "regression",
};
let energyPredictor = ml5.neuralNetwork(options);
你可以通过 options 对象设置模型的许多其他属性。例如,你可以指定输入和输出之间的隐藏层数量(通常有几个),每个层中的神经元数量,要使用的激活函数等等。然而,在大多数情况下,你可以省略这些额外的设置,让 ml5.js 根据任务和手头的数据猜测如何设计模型。
构建手势分类器
我将通过一个非常适合 p5.js 的示例问题,带你走过机器学习生命周期的每个步骤,在此过程中使用 ml5.js 编写每一步的代码。我将从第 0 步开始,阐明问题。假设你正在开发一个响应手势的互动应用程序。也许这些手势最终是通过身体追踪记录的,但你想从一个更简单的开始——鼠标的单次点击(见图 10.20)。

图 10.20:单一鼠标手势作为起点和终点之间的向量
每个手势都可以被记录为一个从起点到终点的鼠标移动向量。向量的 x 和 y 分量将作为模型的输入。模型的任务可能是预测该手势的四个可能标签之一:上、下、左 或 右。由于输出是有限的离散集,这听起来像是一个分类问题。这四个标签将是模型的输出。
就像在 第九章中的一些遗传算法示例——以及本章早些时候的简单感知器示例——我在这里选择的问题是一个已知的有解问题,并且可以在没有神经网络的情况下更轻松、高效地解决。通过 heading() 函数和一系列 if 语句,就可以对向量的方向进行分类!然而,通过使用这个看似微不足道的场景,我希望以一种易于理解且友好的方式来解释训练机器学习模型的过程。此外,这个示例将使得检查代码是否按预期工作变得简单。当我完成时,我将提供一些如何将分类器扩展到无法使用简单 if 语句的场景的想法。
收集和准备数据
确定问题后,我可以进入步骤 1 和 2:收集和准备数据。在现实世界中,这些步骤可能会很繁琐,特别是当你收集到的原始数据很杂乱,需要大量初步处理时。你可以把这看作是需要在做饭之前先整理、清洗和切割所有食材的过程。
为了简化,我更倾向于采取一种订购机器学习“餐包”的方法,所有食材(数据)都已分好份并准备好。这样,我就可以直接开始“烹饪”过程,即训练模型。毕竟,这其实只是对 第十一章的开胃菜,届时我将应用神经网络来引导代理。
有鉴于此,我将手动编写一些示例数据,并将其规范化到 –1 到 +1 之间。我会将数据组织成一个对象数组,配对向量的 x 和 y 分量与字符串标签。我选择的值明确指向一个特定的方向,并分配相应的标签——每个标签有两个示例:
let data = [
{ x: 0.99, y: 0.02, label: "right" },
{ x: 0.76, y: -0.1, label: "right" },
{ x: -1.0, y: 0.12, label: "left" },
{ x: -0.9, y: -0.1, label: "left" },
{ x: 0.02, y: 0.98, label: "down" },
{ x: -0.2, y: 0.75, label: "down" },
{ x: 0.01, y: -0.9, label: "up" },
{ x: -0.1, y: -0.8, label: "up" },
];
图 10.21 显示了以箭头形式表示的相同数据。

图 10.21:将输入数据可视化为向量(箭头)
在一个更现实的场景中,我可能会有一个更大的数据集,这些数据将从一个单独的文件加载,而不是直接写入代码中。例如,JavaScript 对象表示法(JSON)和逗号分隔值(CSV)是两种常见的数据存储和加载格式。JSON 以键值对的形式存储数据,遵循与 JavaScript 对象字面量完全相同的格式。CSV 是一种存储表格数据(如电子表格)的文件格式。根据你的需求和所使用的编程环境,你还可以使用许多其他数据格式。
在现实世界中,那个更大的数据集中的值实际上会来自某个地方。也许我会通过要求用户执行特定手势并记录他们的输入来收集数据,或者通过编写算法自动生成大量合成数据,这些数据代表我希望模型识别的理想化手势版本。无论哪种方式,关键是收集一个多样化的示例集,充分代表手势执行方式的不同变化。不过,现在让我们看看仅凭少量数据会怎样。
练习 10.4
创建一个 p5.js 草图,收集用户的手势数据并将其保存到 JSON 文件中。你可以使用 mousePressed() 和 mouseReleased() 来标记每个手势的开始和结束,使用 saveJSON() 将数据下载到文件中。
选择模型
现在,我已经进入了机器学习生命周期的第 3 步,选择一个模型。在这一阶段,我将开始让 ml5.js 为我做繁重的工作。为了用 ml5.js 创建模型,我所需要做的就是指定任务、输入和输出:
let options = {
task: "classification",
inputs: 2,
outputs: ["up", "down", "left", "right"],
debug: true
};
let classifier = ml5.neuralNetwork(options);
就是这样!我完成了!多亏了 ml5.js,我可以绕过一堆复杂的工作,比如每层的神经元数、激活函数的种类,以及如何设置训练网络的算法。这个库会为我做出这些决定。
当然,默认的 ml5.js 模型架构可能并不适用于所有情况。我鼓励你阅读 ml5.js 的文档,了解如何自定义模型的更多细节。我还要指出的是,ml5.js 能够从数据中推断输入和输出,因此这些属性并不完全需要包含在 options 对象中。不过,为了清晰起见(并且因为后续示例中我需要指定它们),我在这里包含了这些属性。
当 debug 属性设置为 true 时,它会启动训练过程的可视化界面。这是一个有助于在训练过程中发现潜在问题,并更好地理解后台发生的事情的工具。你将在本章后面看到这个界面的样子。
训练模型
现在,我已经将数据存储在 data 变量中,并且在 classifier 变量中初始化了神经网络,我已经准备好训练模型。这个过程从将数据添加到模型开始。为了实现这一点,事实证明我还没有完全准备好数据。
目前,我的数据整齐地组织在一个对象数组中,每个对象包含一个向量的 x 和 y 组件以及一个相应的字符串标签。这是训练数据的典型格式,但 ml5.js 并不能直接使用它。(当然,我本可以一开始就将数据组织成 ml5.js 能识别的格式,但我之所以包括这个额外的步骤,是因为当你使用从其他地方收集或获取的数据集时,这个步骤可能是必需的。)为了将数据添加到模型中,我需要将输入数据与输出数据分开,以便模型理解哪些是输入,哪些是输出。
ml5.js 库在接受的格式方面提供了相当大的灵活性,但我选择使用数组——一个用于 inputs,一个用于 outputs。我可以使用循环来重新组织每个数据项并将其添加到模型中:

我在这里所做的是设置数据的形状。在机器学习中,这个术语描述了数据的维度和结构。它表示数据如何在行、列及可能更深的额外维度中组织。理解数据的形状至关重要,因为它决定了模型的结构方式。
在这里,输入数据的形状是一个包含两个数字的 1D 数组(代表 x 和 y)。输出数据同样是一个包含单个字符串标签的 1D 数组。所有进出网络的数据都会遵循这个模式。虽然这是一个小而简单的示例,但它很好地反映了许多现实世界场景,其中输入数据以数组的形式表示数字,输出则是字符串标签。
在将数据传递给 classifier 之后,ml5.js 提供了一个辅助函数来归一化数据。正如我之前提到的,归一化数据(调整数据的尺度至标准范围)是机器学习过程中至关重要的一步:

在这种情况下,手动编码的数据从一开始就被限制在 -1 到 +1 的范围内,因此在这里调用 normalizeData() 可能是多余的。然而,调用这个函数非常重要,因为它有助于演示。提前对数据进行归一化作为预处理步骤肯定是有效的,但 ml5.js 的自动归一化功能也非常有帮助!
现在是机器学习过程的核心:实际训练模型。这里是代码:

是的,就这样!毕竟,繁重的工作已经完成。数据已经收集、准备好,并输入到模型中。剩下的就是调用 train() 方法,坐下来,让 ml5.js 自动执行剩余的工作。
事实上,这并不是那么简单。如果我按原样运行代码然后测试模型,结果可能会不尽如人意。这时,机器学习中的另一个关键术语就派上用场了:epochs(训练轮次)。train()方法告诉神经网络开始学习过程。但是,它应该训练多久呢?你可以把一个 epoch 想象成一次练习,使用整个训练数据集来更新神经网络的权重。一般来说,经过的 epoch 越多,网络的表现会越好,但到了一定阶段,你会遇到收益递减的情况。epoch 的数量可以通过将options对象传递给train()来设置。

训练的 epoch 数量是超参数的一个例子,超参数是训练过程的全局设置。你可以通过options对象设置其他超参数(例如学习率),但我将使用默认设置。你可以在 ml5.js 文档中阅读更多关于自定义选项的信息。
train()的第二个参数是可选的,但最好包括一个。它指定了一个回调函数,该函数在训练过程完成时运行——在这种情况下是finshedTraining()。(有关回调函数的更多信息,请参见“回调函数”框。)这对于知道何时可以继续执行代码中的下一步非常有用。另一个可选的回调函数,我通常将其命名为whileTraining(),会在每个 epoch 之后触发。然而,就我的目的而言,知道训练何时完成就足够了!
回调函数
JavaScript 中的回调函数是一种你并不会直接调用的函数。相反,你将它作为参数传递给另一个函数,目的是让它在稍后的某个时刻自动调用(通常与某个事件相关,比如鼠标点击)。你以前在第六章使用 Matter.js 时见过这种情况,你指定了一个函数,当检测到碰撞时会被调用。
回调函数在异步操作中是必需的,当你希望代码在等待另一个任务(比如训练机器学习模型)完成时,继续进行动画或其他操作。p5.js 中的经典例子是使用loadJSON()加载数据到草图中。
JavaScript 还提供了一种更现代的方法来处理异步操作,这就是promise(承诺)。通过 promise,你可以使用async和await等关键字,使你的异步代码看起来更像传统的同步代码。虽然 ml5.js 也支持这种风格,但为了与 p5.js 的风格保持一致,我还是会坚持使用回调函数。
评估模型
如果在初始调用ml5.neuralNetwork()时将debug设置为true,则在调用train()之后应该会出现一个视觉界面,覆盖大部分 p5.js 页面和画布(参见图 10.22)。这个界面被称为Visor,代表了评估步骤。

图 10.22:Visor,展示了损失函数图和模型细节
Visor 来自 TensorFlow.js(它是 ml5.js 的基础),包括一个图表,实时反馈训练进度。这个图表将模型的损失值绘制在 y 轴上,将训练周期数绘制在 x 轴上。损失是衡量模型预测与训练数据提供的正确输出之间差距的指标。它量化了模型的总误差。当训练开始时,损失通常较高,因为模型尚未学到任何东西。理想情况下,随着模型训练的进行,它的预测应该会变得更好,损失应该会减少。如果图表随着训练周期的增加而下降,那是一个好兆头!
对于图 10.21 中描述的 200 次训练周期,你可能会觉得有些过多。在一个现实世界的场景中,数据量更大的话,我可能会使用更少的周期,比如我在原始代码片段中指定的 25 次。然而,由于这里的数据集非常小,较多的周期有助于模型更充分地与数据进行训练。记住,这是一个示例,目的是让概念变得清晰,而不是为了生产一个复杂的机器学习模型。
在图表下方,Visor 显示了一个模型摘要表,包含了幕后创建的低级 TensorFlow.js 模型架构的详细信息。摘要包括每层的名称、每层的神经元数量(在输出形状列中)以及参数计数,参数计数是每个神经元连接之间的权重总数。在这种情况下,dense_Dense1 是具有 16 个神经元的隐藏层(这是 ml5.js 选择的数字),而 dense_Dense2 是具有 4 个神经元的输出层,每个神经元对应一个分类类别。(TensorFlow.js 并不把输入视为一个独立的层;它们只是数据流的起点。)输出形状列中的 batch 并不指代特定的数字,而是表示模型可以处理任意数量的训练数据(一个批次),用于单次模型训练周期。
在进入评估阶段之前,我有一个细节需要补充。当我首次概述机器学习生命周期的步骤时,我提到准备数据通常涉及将数据集分成三部分,以帮助评估过程:
-
训练集: 用于训练模型的主要数据集
-
验证集: 在训练过程中用于检查模型的数据子集,通常在每个训练周期结束时进行
-
测试集: 在训练过程中从未使用过的额外数据,用于在训练完成后确定模型的最终表现
你可能已经注意到,我并没有这样做。为了简化,我直接使用了整个数据集进行训练。毕竟,我的数据集只有八条记录;把它分成三组太小了!如果数据集更大,三分法则会更为合适。
然而,使用如此小的数据集会有导致模型过拟合数据的风险:模型会过度调整到训练数据的特定特性,以至于在处理新的、未见过的数据时效果大大下降。使用验证集的主要原因是监控模型在训练过程中的表现。随着训练的进行,如果模型在训练数据上的准确率提高,但在验证数据上的准确率下降,这通常是过拟合发生的强烈信号。(测试集严格保留用于最终评估,是训练完成后评估模型性能的最后一次机会。)
对于更现实的场景,ml5.js 提供了一种方法来拆分数据,并且自动化处理验证数据的功能。如果你有兴趣深入了解,可以在 ml5.js 网站上探索完整的神经网络示例 (ml5js.org)。
调优参数
在评估步骤之后,通常会有一个迭代过程,即调整超参数并重新进行训练,以便从模型中获得最佳性能。虽然 ml5.js 提供了参数调优的功能(你可以在库的参考文档中了解更多),但它并不专门用于对模型进行低级、精细的调整。如果你希望更详细地探索这一步骤,直接使用 TensorFlow.js 可能是最好的选择,因为它提供了更广泛的工具集,并允许对训练过程进行更低级的控制。
在这种情况下,调整参数并非绝对必要。Visor 中的图表显示损失值已经降到 0.1,这对于我的目的来说已经足够准确了。我准备继续进行下一步。
部署模型
现在终于到了部署模型的时刻,看看所有辛勤工作的成果。这通常涉及将模型集成到一个独立的应用程序中,根据新的、以前未见过的数据做出预测或决策。为此,ml5.js 提供了一个方便的 save() 函数,可以将训练好的模型从一个草图下载到文件中,还提供了 load() 函数,可以将其加载到另一个完全不同的草图中使用。这样你就不必每次都从头开始重新训练模型。
尽管模型通常会部署到与训练时不同的草图中,但为了简化,我将把模型部署在同一个草图中。事实上,一旦训练过程完成,得到的模型本质上已经在当前草图中部署。它被保存在classifier变量中,并可以通过classify()方法向模型传入新数据来进行预测。传递给classify()的数据的形状应该与训练时使用的输入数据形状相匹配——在这个例子中,是两个浮点数,分别表示方向向量的 x 分量和 y 分量:

classify()的第二个参数是另一个回调函数,用于访问结果:
function gotResults(results) {
console.log(results);
}
模型的预测结果会作为回调函数的参数返回,我在代码中称之为results。在其中,你会找到一个按信心排序的标签数组,信心值是模型为每个标签分配的概率值。这些概率值表示模型对特定预测的确定性。它们的范围从 0 到 1,值越接近 1 表示信心越高,而接近 0 则表示信心较低:
[
{
"label": "right",
"confidence": 0.9669702649116516
},
{
"label": "up",
"confidence": 0.01878807507455349
},
{
"label": "down",
"confidence": 0.013948931358754635
},
{
"label": "left",
"confidence": 0.00029277068097144365
}
]
在这个例子的输出中,模型对正确标签为“right”的信心非常高(约为 96.7%),而对“left”标签的信心极低(0.03%)。信心值经过归一化,所有值加起来总和为 100%。
接下来需要做的就是用代码填充草图,使模型能够接收来自鼠标的实时输入。第一步是向用户发出训练过程完成的信号,以便他们知道模型已经准备好了。我将包含一个全局status变量来跟踪训练过程,并最终在画布上显示预测标签。该变量初始化为"training",但通过finishedTraining()回调函数更新为"ready"。

最后,我将使用 p5.js 的鼠标功能,在拖动鼠标时构建一个向量,并在点击鼠标时调用classifier.classify()对该向量进行分类。

由于results数组是按信心排序的,如果我只想使用单一标签作为预测结果,我可以通过results[0].label访问数组的第一个元素,就像在示例 10.2 中的gotResults()函数一样。这个标签会传递给status变量,最终显示在画布上。
练习 10.5
将示例 10.2 分为三个草图:一个用于收集数据,一个用于训练,另一个用于部署。使用ml5.neuralNetwork函数save()和load()分别保存和加载模型到文件中。
练习 10.6
扩展手势识别模型以分类一系列向量,更准确地捕捉较长鼠标移动的路径。请记住,输入数据必须具有一致的形状,因此你需要决定使用多少个向量来表示一个手势,并为每个数据点存储相同数量的向量,既不多也不少。虽然这种方法可行,但其他机器学习模型(如递归神经网络)专门设计用于处理序列数据,可能提供更多的灵活性和潜在的准确性。
练习 10.7
ml5.js 中的一个预训练模型叫做Handpose。该模型的输入是一张图片,预测结果是一个包含 21 个关键点——x 和 y 位置,也称为地标——的列表,描述了手部。

你能将ml5.handpose()模型的输出作为ml5.neuralNetwork()的输入,并分类各种手势(如竖大拇指或竖小拇指)吗?如果需要提示,你可以观看我在 Coding Train 网站上的视频教程,教程会引导你完成机器学习路径中的身体姿势分类过程 (thecodingtrain.com/pose-classifier)。
生态系统项目
将机器学习融入你的生态系统,以增强生物的行为。如何应用分类或回归方法?
-
你能将生态系统中的生物分类为多个类别吗?如果你使用初始种群作为训练数据集,并且随着新生物的诞生,系统根据它们的特征进行分类呢?你的系统的输入和输出是什么?
-
你能使用回归预测生物的寿命吗?考虑一下大小和速度如何影响第九章中的“bloop”生物的寿命。你能分析回归模型的预测结果与实际结果的对比吗?

第十二章:11 神经进化
读关于大自然的书固然好,但如果一个人走进森林,仔细倾听,他们能学到比书本上更多的东西。
—乔治·华盛顿·卡佛

星鼻鼹鼠(图片由纽约公共图书馆提供,约 1826–1828 年)
星鼻鼹鼠(Condylura cristata)主要分布在美国东北部和加拿大东部,具有独特且高度专业化的鼻部器官。经过多代进化,它的鼻子由 22 个触角组成,拥有超过 25,000 个微小的感官受体。尽管鼹鼠功能性失明,这些触角使它们能够创建其周围环境的详细空间地图。它们可以在漆黑的地下栖息地中以惊人的精确度和速度导航,迅速识别并消耗可食用的物品,仅需毫秒级的时间。
恭喜!你已经完成了本书的最后一章。花点时间庆祝你所学到的一切。

在本书中,你已经探讨了使用 p5.js 进行互动物理仿真的基本原理,深入了解了智能体和其他基于规则的行为的复杂性,并初步接触了激动人心的机器学习领域。你已经变得非常自然!
然而,第十章仅仅触及了数据和基于神经网络的机器学习的表面——这是一个广阔的领域,若要全面覆盖,将需要无数本续集来完成。我的目标从来不是深入探讨神经网络,而是简单地建立核心概念,为一个盛大的结局做准备,在这个结局中,我将找到将机器学习融入动画、互动 p5.js 草图的世界的方法,并为我们的《代码的本质》新朋友们带来最后的欢庆。
接下来的道路将穿过神经进化领域,这是一种将第九章中的遗传算法与第十章中的神经网络相结合的机器学习方法。神经进化系统利用达尔文进化原理,通过几代的试错学习来进化神经网络的权重(在某些情况下,甚至是网络结构本身)。在本章中,我将通过一个熟悉的游戏世界示例来演示如何使用神经进化。接着,我将通过神经进化来修改克雷格·雷诺兹在第五章中的转向行为。
强化学习
神经进化与我在第十章中简要提到的另一种机器学习方法——强化学习有许多相似之处,后者将机器学习融入了一个模拟环境中。一个基于神经网络的智能体通过与环境互动,并通过奖励或惩罚的反馈来学习其决策。这是一种围绕观察建立的策略。
想象一只小鼠在迷宫里跑。如果它左转,它就能得到一块奶酪;如果它右转,它就会受到轻微的电击。(别担心,这只是只假装的小鼠。)可以假设,小鼠会随着时间的推移学会左转。它的生物神经网络做出决策并观察结果(左转或右转),如果观察结果是负面的,网络会调整权重,以便下次做出不同的决策。
在现实世界中,强化学习通常用于开发机器人,而不是用来折磨小动物。在时间 t 时,机器人执行任务并观察结果。它是否撞到墙壁或从桌子上掉下来,或者它是否没有受伤?随着时间的推移,机器人学会以最优的方式解读来自环境的信号,以完成任务并避免伤害。
现在,不是小鼠或机器人,想象一下本书早些时候提到的示例对象(行走者、移动者、粒子、车辆)。假设将一个神经网络嵌入到这些对象中的一个,并用它来计算一个力或其他动作。神经网络可以从环境中获取输入(例如到障碍物的距离),并输出某种决策。也许网络从一组离散选项中做出选择(向左或向右移动),或者选择一组连续值(转向力的大小和方向)。
这开始听起来有点熟悉吗?它和神经网络在第十章中的例子完全一样,接收输入并预测分类或回归!实际上,训练这些对象做出正确决策的过程,就是强化学习与监督学习方法的区别所在。为了更好地说明这一点,让我们从一个可能容易理解且可能熟悉的场景开始——游戏 Flappy Bird(见图 11.1)。
这个游戏看似简单。你控制一只小鸟,它不断地横向移动。每次点击或触摸,鸟儿就会拍动翅膀并向上飞升。挑战是什么呢?一系列不规则间隔的竖直管道从右侧出现。管道之间有间隙,你的主要目标是让小鸟安全地穿过这些间隙。如果撞到管道,就会游戏结束。随着游戏的进行,速度逐渐加快,你穿越的管道越多,得分也越高。

图 11.1: Flappy Bird 游戏
假设你想要自动化游戏玩法,而不是由人类点击,而是由神经网络决定是否要拍打翅膀。那么机器学习在这里能起作用吗?暂时跳过机器学习生命周期中的初始数据步骤,让我们来思考如何选择一个模型。神经网络的输入和输出是什么?
这是一个相当有趣的问题,因为至少在输入的情况下,并没有明确的答案。如果你对游戏了解不多,或者不想在识别哪些游戏方面重要时偏袒一方,那么让输入成为游戏屏幕的所有像素可能是最合适的做法。这种方法尝试将游戏的一切都输入到模型中,让模型自己决定什么是重要的。
然而,我已经玩过足够多的Flappy Bird,并且感觉我已经相当了解它。因此,我可以跳过将所有像素输入到模型的步骤,并将游戏的本质简化为仅几个必要的输入数据点,用于做出预测。这些数据点在机器学习中通常被称为特征,代表了对于预测最为重要的数据的独特特征。想象一下咬一口神秘多汁的水果——它的味道(甜!)、质地(脆!)和颜色(鲜红!)等特征帮助你把它识别为苹果。在Flappy Bird的情况下,最关键的特征列举如下:
-
小鸟的 y 坐标
-
小鸟的 y 速度
-
下一个上方管道开口的 y 坐标
-
下一个下方管道开口的 y 坐标
-
到下一个管道的 x 距离
这些特性在图 11.2 中有说明。

图 11.2:Flappy Bird 输入特征用于神经网络
神经网络将有五个输入,每个特性对应一个输入,但输出呢?这是一个分类问题还是回归问题?在像Flappy Bird这样的游戏中提问这个问题似乎有些奇怪,但实际上它非常重要,并且与游戏的控制方式有关。点击屏幕、按下按钮或使用键盘控制都属于分类的例子。毕竟,玩家只有一个离散的选择集:点击或不点击;按下键盘上的 W、A、S 或 D。另一方面,使用模拟控制器如摇杆则更倾向于回归。摇杆可以在任何方向上以不同的角度倾斜,从而在水平和垂直轴上产生连续的输出值。
对于Flappy Bird,输出代表一个分类决策,只有两个选择:
-
拍打。
-
不要拍打。
这意味着网络应该有两个输出,建议采用类似于图 11.3 所示的整体网络架构。

图 11.3:ml5.js 可能设计的Flappy Bird神经网络
现在,我拥有了配置模型并让 ml5.js 构建它所需的所有信息:
let options = {
inputs: 5,
outputs: ["flap", "no flap"],
task: "classification"
};
let birdBrain = ml5.neuralNetwork(options);
接下来怎么办?如果我按照第十章中列出的步骤进行操作,我就必须回到机器学习过程的第 1 步和第 2 步:数据收集和准备。这里具体该怎么做呢?一个想法是可以去寻找地球上最伟大的Flappy Bird玩家,并记录他们连续玩几个小时。我可以记录下每一时刻的输入特征以及玩家是否按下了翅膀。将所有这些数据输入模型,进行训练,我已经能看到头条新闻了:“人工智能机器人打败了Flappy Bird。”
但等一下,计算机化的代理真的是自己学会了玩Flappy Bird,还是只是学会了模仿人类的游戏玩法?如果那个人错过了Flappy Bird策略的某个关键方面怎么办?自动化玩家永远无法发现这一点。更不用说收集所有这些数据将会非常繁琐。
这里的问题是,我已经回到了像第十章中的监督学习场景,但本节内容应该是关于强化学习的。与监督学习不同,在监督学习中,正确的答案是由训练数据集提供的,而在强化学习中,代理通过与环境互动并接受反馈,通过反复试验来学习答案——即最优决策。在Flappy Bird的情况下,代理每成功通过一个管道就能获得正奖励,但如果撞到管道或地面,则会得到负奖励。代理的目标是弄清楚哪些行为能够在时间上积累最多的奖励。
一开始,Flappy Bird代理并不知道什么时候是最好的翅膀挥动时机,这会导致许多碰撞。然而,随着它从无数次游戏中积累越来越多的反馈,它将开始优化自己的行动,并开发出最佳策略,以避免碰撞并顺利通过管道,从而最大化其总奖励。这种通过实践学习并基于反馈进行优化的过程就是强化学习的本质。
随着章节的推进,我将探讨这里概述的原理,但有个变化。传统的强化学习技术包括定义一个策略(称为策略)和相应的奖励函数,以便为调整策略提供反馈。然而,我不会走这条路,而是会转向本章的明星——神经进化。
进化神经网络真棒!
神经进化(neuroevolution)通过应用遗传算法(GAs)和自然选择的原理来训练神经网络中的权重,而不是使用传统的反向传播、策略和奖励函数。这项技术能够同时将多个神经网络应用于一个问题。定期地,表现最好的神经网络会被“选择”,它们的“基因”(即网络连接权重)会被结合并发生突变,从而创造出下一代的网络。神经进化在学习规则不明确或任务复杂,且存在众多潜在解决方案的环境中,尤其有效。
神经进化的第一个示例之一可以在 1994 年 Edmund Ronald 和 Marc Schoenauer 的论文《Genetic Lander: An Experiment in Accurate Neuro-genetic Control》中找到(* doi.org/10.1007/3-540-58484-6_288)。在 1990 年代,传统的神经网络训练方法仍处于初期阶段,而这项工作探讨了一种替代方法。论文描述了如何通过在一个名为Lunar Lander*的游戏中模拟航天器,使其学习如何安全降落并着陆。研究人员没有使用手工编写的规则或标注的数据集,而是选择使用遗传算法(GAs)对神经网络进行进化和训练,跨越多代进行演化。结果是成功的!
2002 年,Kenneth O. Stanley 和 Risto Miikkulainen 在其论文《Evolving Neural Networks Through Augmenting Topologies》中扩展了早期的神经进化方法(* doi.org/10.1162/106365602320169811*)。与专注于进化神经网络权重的月球着陆者方法不同,Stanley 和 Miikkulainen 引入了一种方法,也进化了网络本身的结构!他们的 NEAT 算法——神经进化增强拓扑(NeuroEvolution of Augmenting Topologies)——从简单的网络开始,并通过进化逐步完善其拓扑结构。因此,NEAT 能够发现针对特定任务量身定制的网络架构,通常能够产生更优化、更有效的解决方案。
一个全面的 NEAT 实现需要深入了解神经网络架构,并直接使用 TensorFlow.js 进行工作。而我的目标是将 Ronald 和 Schoenauer 的原始研究成果,应用于现代的 Web 浏览器环境,并使用 ml5.js 来实现。与其使用Lunar Lander游戏,我决定尝试使用Flappy Bird。为此,我首先需要编写一个Flappy Bird的版本,以便我的神经进化网络能够在其中运行。
编程 Flappy Bird
Flappy Bird由越南游戏开发者 Dong Nguyen 于 2013 年创建。2014 年 1 月,它成为苹果 App Store 上下载量最多的应用。然而,在同年 2 月 8 日,Nguyen 宣布他将撤下这款游戏,原因是其成瘾性。自那时以来,Flappy Bird成为历史上被克隆最多的游戏之一。
Flappy Bird 是诺兰定律的完美示例,这一格言归功于 Atari 创始人以及Pong的创造者诺兰·布什内尔:“所有最好的游戏都容易学习,却难以精通。”这也是一个非常适合初学者编写的游戏,可以作为学习练习,它与本书中的概念完美契合。
为了使用 p5.js 编写游戏,我将从定义一个Bird类开始。这可能会让你感到惊讶,但为了演示,我决定跳过使用p5.Vector,而是直接使用独立的x和y属性来表示鸟的位置。由于鸟在游戏中仅沿着垂直轴移动,x值保持不变!因此,velocity(以及所有相关的力)可以是单一的标量值,只用于 y 轴。
为了进一步简化代码,我会将所有的力直接添加到鸟的速度中,而不是将它们累积到acceleration变量中。除了常规的update()方法,我还会添加一个flap()方法,让鸟向上飞。show()方法不在这里包含,因为它只是画一个圆形。以下是代码:

游戏的其他主要元素是鸟必须穿越的管道。我将创建一个Pipe类来描述一对矩形,一个从画布的顶部延伸,另一个从底部延伸。正如鸟仅沿垂直方向移动一样,管道也仅沿水平方向滑动,因此它们的属性可以是标量值而非向量。管道以恒定的速度移动,不受其他力的影响。

为了明确说明,这个游戏展示了一只鸟穿过管道——鸟在二维空间中移动,而管道保持静止。然而,更简单的做法是将鸟看作在水平方向上保持静止,管道在移动。
在编写了Bird和Pipe类后,我几乎可以开始运行游戏了。然而,缺少一个关键部分:碰撞检测。整个游戏的核心就是让鸟避免撞到管道!幸运的是,这对你来说并不陌生。你在本书中已经看到过许多对象检查其与其他对象的位置的例子。不过,我需要做一个设计选择。碰撞检测方法可以逻辑上放在Bird类中(用来检查鸟是否撞到管道),也可以放在Pipe类中(用来检查管道是否撞到鸟)。根据你的观点,任意一种方式都能找到合理的理由。
我会把这个方法放在Pipe类中并命名为collides()。这段代码比你初看时可能想的要复杂一些,因为该方法需要检查管道的上下两个矩形与鸟的位置的碰撞。我可以用多种方式来实现这一点。一种方法是先检查鸟是否在任意矩形的垂直范围内(即在上管道底部之上或下管道顶部之下)。但只有当鸟水平位于管道宽度的边界内时,鸟才与管道发生碰撞。一个优雅的写法是将这些检查通过逻辑与运算符结合起来:

目前算法将鸟视为一个单一的点,并没有考虑它的大小。为了让游戏更具真实感,应该改进这一细节。
剩下的就是编写setup()和draw()函数。我需要一个表示鸟的变量和一个存储管道列表的数组。交互方式是点击鼠标一次,触发鸟的flap()方法。与其构建一个包含得分、结束屏幕和其他常规元素的完整游戏,不如通过在任何发生碰撞的管道附近绘制文本OOPS!来确保游戏机制正常工作。代码还假设Pipe类中有一个额外的offscreen()方法,用于处理管道移出画布左边缘的情况。


这段代码最棘手的部分在于使用frameCount变量和取模运算符在固定间隔内生成管道。在 p5.js 中,frameCount是一个系统变量,用于追踪自从草图开始以来已经渲染的帧数,并随着draw()循环的每次执行而递增。取模运算符(%)返回除法操作的余数。例如,7 % 3的结果是1,因为 7 除以 3 的商是 2,余数是 1。因此,布尔表达式frameCount % 100 === 0会检查当前的frameCount值是否能被 100 整除,余数为 0。这个条件在每 100 帧时为真,在这些帧上,会生成一个新的管道并将其添加到pipes数组中。
练习 11.1
实现一个得分系统,每成功穿过一组管道就为玩家奖励积分。你也可以为鸟、管道和环境添加你自己的视觉设计元素!
神经进化版 Flappy Bird
我的Flappy Bird克隆游戏目前是通过鼠标点击来控制的。现在我想把游戏控制权交给计算机,并通过神经进化来教它如何玩。幸运的是,神经进化的过程已经集成在 ml5.js 中了,因此实现这一切相对简单。第一步是给鸟一个“大脑”,这样它就能自行决定是否拍动翅膀。
鸟脑
当我引入强化学习时,我建立了一份输入特征列表,应该用于鸟的决策过程。我将使用相同的列表,但做一个简化。由于管道之间的开口大小是恒定的,所以不需要包含顶部和底部的 y 坐标,任选其一即可。因此,输入特征如下:
-
鸟的 y 坐标
-
鸟的 y 方向速度
-
下一个管道顶部(或底部!)开口的 y 坐标
-
到下一个管道的 x 距离
这两个输出表示鸟的两个选择:是否拍打翅膀。设置好输入和输出后,我可以在鸟的构造函数中添加一个brain属性,用来保存一个配置合适的 ml5.js 神经网络。为了展示一种不同的编码风格,我将跳过包含一个单独的options变量,而是直接将属性作为对象字面量传递给ml5.neuralNetwork()函数。请注意,添加了一个neuroEvolution属性并设置为true。这是启用我将在后续代码中使用的一些功能所必需的。

接下来,我将向Bird类添加一个名为think()的方法,用于计算鸟在每一时刻所需的所有输入。前两个输入很简单——它们只是鸟的y和velocity属性。然而,对于输入 3 和 4,我需要确定下一个管道是哪一个。
乍一看,似乎下一个管道总是数组中的第一个,因为管道是逐个添加到数组末尾的。然而,在管道通过鸟之后,它就不再相关了,并且在管道退出画布并从数组开头移除之间还有一段时间。因此,我需要找到数组中第一个右边缘(x 坐标加上宽度)大于鸟的 x 坐标的管道:

一旦我找到了下一个管道,就可以创建四个输入:

这已经很接近了,但我忘记了一个关键步骤。所有输入值的范围是由画布的尺寸决定的,但神经网络期望的是标准化范围内的值,比如 0 到 1。标准化这些值的一种方法是,将与垂直属性相关的输入除以height,而与水平方向相关的输入除以width:

拿到输入数据后,我准备将它们传递给神经网络的classify()方法。不过,我还有一个小问题:classify()是异步的,这意味着我必须在Bird类中实现一个回调函数来处理模型的决策。这会给代码增加显著的复杂性,但幸运的是,在这种情况下完全不需要这样做。ml5.js 的机器学习函数通常需要异步回调,因为模型处理大量数据需要时间。如果没有回调,代码可能会长时间等待结果,而如果模型在 p5.js 草图中运行,这种延迟可能会严重影响动画的流畅性。然而,这里的神经网络只有四个浮动输入和两个输出标签!它非常小,能够快速运行,因此没有必要使用异步代码。
为了完整性,我在书本网站上包括了一个实现神经进化与异步回调的示例版本。然而,在本讨论中,我将使用 ml5.js 的一个特性,它让我能够走捷径。方法classifySync()与classify()完全相同,但它是同步运行的,这意味着代码会停下来等待结果,再继续执行。使用这个版本的方法时要非常小心,因为它可能在其他上下文中引发问题,但在这个简单的场景下,它能很好地工作。下面是think()方法的结尾部分,使用了classifySync():

神经网络的预测格式与第十章中的手势分类器相同,可以通过检查results数组的第一个元素来做出决策。如果输出标签是"flap",则调用flap()。
现在我已经完成了think()方法,真正的挑战可以开始了:教会鸟在正确的时刻通过扑翅膀来赢得游戏。此时,遗传算法(GA)再次成为关键。回想一下第九章的讨论,达尔文进化论的三个关键原则是:变异、选择和遗传。在我将 GA 的步骤应用于神经网络的全新背景时,我将依次回顾这些原则。
变异:一群扑腾的小鸟
一只随机初始化的神经网络鸟不太可能有任何成功。这只孤鸟很可能会不停地跳跃,飞出屏幕,或者坐在画布底部,等待与管道的每一次碰撞。这种反常且不合逻辑的行为提醒我们:一个随机初始化的神经网络没有任何知识或经验。鸟的行为本质上是在做胡乱的猜测,所以成功是非常罕见的。
这就是遗传算法(GA)中的第一个关键原则:变异。我们的期望是,通过引入尽可能多的不同神经网络配置,可能会有一些表现稍微优于其他的。变异的第一步是增加一个包含许多小鸟的数组(图 11.4)。

图 11.4:一群小鸟,每只小鸟都有独特的神经网络,在神经进化过程中穿越管道

你可能会注意到一个出现在 setup() 函数中的特殊代码行:ml5.setBackend("cpu")。在运行神经网络时,很多繁重的计算任务通常会被卸载到 GPU 上。这是默认行为,对于 ml5.js 中包含的大型预训练模型尤其重要。
GPU 与 CPU
-
图形处理单元(GPU): 最初设计用于渲染图形,GPU 擅长处理大量的并行运算。这使得它们在处理机器学习模型频繁执行的数学运算和计算时表现出色。
-
中央处理单元(CPU): 通常被认为是计算机的大脑或通用心脏,CPU 处理的任务种类比专门的 GPU 更广泛,但它并不是为同时执行大量任务而设计的。
但有一个陷阱!将数据传输到 GPU 并从 GPU 传回会引入开销。在大多数情况下,GPU 的并行处理所带来的收益足以抵消这些开销,但对于像这里这样的小型模型,数据复制到 GPU 并返回实际上会减慢神经网络的速度。调用ml5.setBackend("cpu")告诉 ml5.js 将神经网络计算转移到 CPU 上运行。至少在这个简单的小鸟模型的案例中,这是更高效的选择。
选择:Flappy Bird 适应度
一旦我有了一个多样化的小鸟种群,每只小鸟都有自己的神经网络,遗传算法的下一步是 选择。哪些小鸟应该将它们的基因(在这里是神经网络权重)传递给下一代?在 Flappy Bird 的世界里,成功的标准是能够通过避开管道生存得更久。这就是小鸟的 适应度。能够避开更多管道的小鸟被认为比撞到第一个管道就挂掉的小鸟更具适应性。
为了跟踪每只小鸟的适应度,我将在 Bird 类中添加两个属性,fitness 和 alive:

我将为适应度分配一个数值,这个数值在每次通过draw()时都会增加,前提是鸟还活着。存活时间更长的鸟应该有更高的适应度值。这种机制类似于强化学习中的奖励良好决策的技巧。然而,在强化学习中,智能体对每个决策都会收到即时反馈,从而能够相应地调整其策略。在这里,鸟的适应度是其整体成功的累计衡量标准,只会在遗传算法的选择步骤中使用。

alive属性是一个布尔标志,初始值为true。当一只鸟与管道碰撞时,该属性会被设置为false。只有那些还活着的鸟会被更新并绘制到画布上。

在第九章中,我演示了两种运行进化仿真技术。在智能火箭示例中,种群在每一代中都生活了固定的时间。相同的方法可能在这里也能奏效,但我希望让鸟类积累尽可能高的适应度值,而不是根据时间限制随意停止它们。第二种技术,在 bloops 示例中演示,完全取消了适应度分数,并为任何存活生物设定了一个随机克隆概率。对于Flappy Bird,这种方法可能会变得混乱,并有导致过度繁殖或所有鸟类完全死亡的风险。
我提议结合这两种方法的元素。我将允许一个世代继续,直到至少有一只鸟还活着。当所有鸟都死掉时,我将选择父母进行再生产步骤,并重新开始。我将先写一个函数来检查是否所有的鸟都已经死亡:

当所有鸟都死亡时,就到了选择的时候!在之前的遗传算法示例中,我演示了一种接力赛技术,旨在给种群中的所有成员一个公平的机会,同时仍然增加那些适应度较高的个体被选择的机会。我将在这里使用相同的weightedSelection()函数:

为了使这个算法正常运行,我需要首先规范化鸟的适应度值,使它们的总和为 1:

一旦标准化,每只鸟的适应度就等于它被选择的概率。
遗传学:小鸟宝宝
剩下的唯一一步是遗传算法中的再生产。在第九章中,我详细探讨了生成子元素的两步过程:交叉和变异。交叉是遗传学中的第三个关键原理:来自两个选择父代的 DNA 结合形成子代的 DNA。
一开始,发明一个用于两个神经网络的交叉算法可能看起来令人生畏,但实际上非常简单。可以把鸟类大脑的每个“基因”看作是神经网络中的权重。混合两个大脑的过程归结为创建一个新的神经网络,每个权重通过虚拟硬币投掷来选择——权重来自第一个或第二个父代:

哇,今天真是我的幸运日!原来 ml5.js 包括一个crossover()方法,它管理着混合两个神经网络的算法。我可以高兴地进入变异步骤了:

我的运气真不错!ml5.js 库还提供了一个mutate()方法,它接受一个变异率作为主要参数。这个变异率决定了权重会被改变的频率。例如,0.01 的比率表示任何给定的权重有 1%的概率会发生变异。在变异过程中,ml5.js 会通过在权重上加一个小的随机数来稍微调整权重,而不是选择一个完全新的随机值。这种行为模拟了现实世界中的基因变异,通常是引入轻微的变化,而不是完全新的特征。尽管默认方法适用于许多情况,但 ml5.js 通过允许使用自定义变异函数作为mutate()的可选第二个参数,提供了对过程的更多控制。
交叉和变异步骤需要为种群的大小重复进行,以创建一个全新的鸟类世代。这是通过将一个空的本地数组nextBirds填充为新鸟类来实现的。一旦种群满员,全球的birds数组就会更新为这个新的世代:

如果你仔细观察reproduction()函数,可能会注意到我悄悄加入了Bird类的另一个新特性:构造函数的一个参数。当我最初介绍鸟类大脑的概念时,每个新的Bird对象都会创建一个全新的大脑——一个由 ml5.js 提供的新神经网络。然而,现在我希望新的鸟类能够继承一个通过交叉和变异过程生成的子代大脑。为了实现这一点,我会巧妙地修改Bird构造函数,来查找一个名为brain的可选参数:

如果在创建新鸟时没有提供brain,那么brain参数将保持undefined。在 JavaScript 中,undefined被视为false。因此,if (brain)测试会失败,代码将跳转到else语句并调用ml5.neuralNetwork()。另一方面,如果传入了一个现有的神经网络,brain的值将为true,并直接赋值给this.brain。这个巧妙的技巧让一个构造函数能够处理多种场景。
到此为止,示例已经完成。剩下的就是在每一代的末尾,当所有鸟都死亡时,在draw()中调用normalizeFitness()和reproduction()。

注意新增了一个resetPipes()函数。如果在开始新一代之前不移除管道,鸟可能会立即从与管道相撞的位置重新开始,在这种情况下,即使是最好的鸟也没有机会飞行!示例 11.2 的完整在线代码还调整了鸟的行为,使得它们在离开画布时死亡,无论是撞到地面还是飞得太高超出顶部。
习题 11.2
示例 11.2 需要很长时间才能产生任何结果。你能否通过跳过游戏的每一帧绘制来“加速时间”,以更快地达到最优鸟?(解决方案在第 570 页的“加速时间”中提供。)此外,你能否添加一个叠加层,显示有关模拟状态的信息,例如仍在游戏中的鸟的数量、当前代数和最优鸟的寿命?
习题 11.3
为了避免每次都从头开始神经进化过程,可以尝试使用 ml5.js 的神经网络save()和load()方法。你如何添加一个功能来保存最好的鸟模型,并提供加载先前保存模型的选项?
神经进化引导方式
在探索了Flappy Bird的神经进化后,我希望将焦点转回到模拟领域,特别是第五章中介绍的引导代理。假设不是我来规定一个算法的规则来计算引导力,而是一个模拟生物能够进化出自己的策略呢?从 Reynolds 对栩栩如生和即兴行为的目标中汲取灵感,我的目标不是通过神经进化来创造一个完美的生物,它能完美地执行任务。相反,我希望创建一个迷人的模拟生命世界,在那里,进化的怪癖、细微差别和幸运的意外在画布上展开。
我将从第九章中适应智能火箭的示例开始。在该示例中,每个火箭的基因是一个向量数组:

我提议调整这段代码,改为使用神经网络来预测向量或引导力,将genes转化为brain。向量可以具有连续的值范围,因此这是一个回归任务:
this.brain = ml5.neuralNetwork({
inputs: 2,
outputs: 2,
task: "regression",
neuroEvolution: true,
});
在原始示例中,来自genes数组的向量是顺序应用的,通过counter变量查询数组:
this.applyForce(this.genes[this.counter]);
现在,代替数组查找,我希望神经网络在每一帧动画中返回一个新的向量。对于 ml5.js 的回归任务,神经网络的输出是通过 predict() 方法接收的,而不是 classify()。在这里,我将使用 predictSync() 变体来简化代码,并允许模型在火箭的 run() 方法中同步输出数据:

神经网络大脑输出两个值:一个是向量的角度,另一个是向量的大小。你可能会想到使用这些输出作为向量的 x 分量和 y 分量。然而,ml5.js 神经网络的默认输出范围是从 0 到 1,而我希望这些力能够指向正负两个方向。通过将第一个输出值乘以 TWO_PI 来映射角度,就可以实现完整的范围。
你可能已经注意到代码中有一个名为 inputs 的变量,我还没有声明或初始化它。定义神经网络的输入是作为系统设计者的你可以发挥最大创造力的地方。你需要考虑环境的特性,以及你的生物体模拟的生物学和能力,然后决定哪些特征最为重要。
作为第一次尝试,我将为输入分配一些基本的值,看看是否能工作。由于智能火箭的环境是静态的,障碍物和目标是固定的,那么如果大脑能够学习并估算一个流场来导航到达目标会怎么样?正如我在第五章中演示的,流场接收一个位置并返回一个向量,因此神经网络可以模仿这一功能,使用火箭当前的 x 和 y 位置作为输入。我只需要根据画布的尺寸标准化这些值:
let inputs = [this.position.x / width, this.position.y / height];
就是这样!几乎所有原始示例中的其他内容都可以保持不变:种群、适应度函数和选择过程。

现在我正在使用 ml5.js,注意到我不再需要一个单独的 DNA 类来实现 crossover() 和 mutate() 方法了。相反,这些方法已经内置在 ml5.neuralNetwork 中,可以直接调用。
练习 11.4
根据 Reynolds 的定义,转向力是一个代理期望速度与其当前速度之间的差异。那么这个进化系统如何模仿这种方法呢?如果你不只使用位置作为神经网络的输入,而是输入火箭的当前速度会怎样?你可以尝试使用 x 和 y 分量,或者向量的方向和大小。记得标准化这些值!
应对变化
在之前的示例中,环境是静态的,目标和障碍物都固定不动。这使得火箭仅通过位置作为输入就能轻松完成寻找目标的任务。然而,如果目标和火箭路径中的障碍物在移动呢?为了应对更复杂和变化的环境,我需要扩展神经网络的输入,考虑更多的环境特征。这类似于我在Flappy Bird中所做的,我识别了环境中的关键数据点来指导小鸟的决策过程。
我将从这个场景的最简单版本开始,几乎与原始的智能火箭示例相同,但移除了障碍物,并将固定目标替换为由 Perlin 噪声控制的随机行走者。在这个世界里,我将Rocket重命名为Creature,并将行走者改为一个表示温和漂浮光球的Glow类。想象一下,这个生物的目标是到达光源,并在它的光辉怀抱中尽可能长时间地舞动:

随着光源的移动,生物应该将光源的位置纳入其决策过程,作为输入到其大脑。然而,仅仅知道光源的位置是不够的;关键在于光源相对于生物自身的位置。一种很好的方式来综合这些信息作为输入特征,是计算一个从生物指向光源的向量。本质上,我在重新发明第五章中的seek()方法,使用神经网络来估算转向力:

这是一个好的开始,但向量的分量没有落在标准化的输入范围内。我可以将v.x除以width,将v.y除以height,但由于我的画布不是完美的正方形,这可能会导致数据偏斜。另一种解决方案是标准化向量,但虽然这样可以保留从生物到光源的方向信息,但会消除任何关于距离的度量。这也不行——如果生物正坐在光源上方,它应该与远离光源时的行为不同。为了解决这个问题,我会在标准化向量之前将距离保存在一个单独的变量中。不过,为了让它作为输入特征有效,我仍然需要标准化范围。虽然这不是从 0 到 1 的完美标准化,但我将通过画布的宽度来进行除法,这样可以提供一种实用的标准化方式,保持相对的大小:

正如你可能记得的,Reynolds 的引导公式的一个关键元素是将期望速度与当前速度进行比较。车辆当前的运动状态在决定如何转向时起着重要作用!为了让生物将自己的速度作为决策的一部分,我还可以将速度向量作为神经网络的输入之一。为了规范化这些值,将向量的分量除以 maxspeed 属性效果非常好。这样既保留了向量的方向,也保留了相对大小。其余的 seek() 方法与之前的示例遵循相同的逻辑,神经网络的输出合成一个力,施加到生物身上:

从火箭到生物的过渡中,已经发生了足够的变化,因此值得重新考虑适应度函数。以前,适应度是基于火箭每代结束时距离目标的记录距离来计算的。由于目标现在在移动,我更愿意将生物能够捕捉到光点的时间量作为适应度的衡量标准。这可以通过在 update() 方法中检查生物与光点之间的距离,并在它们相交时增加 fitness 值来实现:

Glow 和 Creature 类都包括一个半径属性 r,我用它来判断是否发生相交。
加速时间
你可能注意到进化计算的一个特点,那就是测试代码是一项需要耐心的愉快练习。你必须看到仿真一代又一代地缓慢运行。这也是其中的一部分——我想看到这个过程!这也是一个很好的借口去休息一下,值得提倡。去外面走一走,享受一段不经过模拟的大自然,或者泡一杯舒缓的茶。然后再回到你的生物,看看它们的进展。安慰自己的是,你所需要等待的只是数十亿毫秒,而不是实际生物进化所需的数十亿年。
然而,为了使系统发展,没有固有的要求需要你绘制和动画化整个世界。如果你能够跳过渲染场景的所有时间,几百代可能会在眨眼间完成。或者,与你完全不渲染环境不同,你也可以选择仅仅减少渲染的频率。这样,你就不必每次更改一个小参数时都抓狂,等待看它是否对系统演化产生影响,仿佛等待了几个小时。
这里我可以使用 p5.js 的一个我最喜欢的功能:快速创建标准界面元素的能力。你之前在示例 9.4 中看到了这个功能,使用了createButton()。这次,我将创建一个滑块来控制draw()内部运行的for循环的迭代次数。for循环将包含更新(但不绘制)模拟的代码。循环重复的次数越多,动画看起来就越快。
这是这个新时间滑块的代码,省略了setup()中所有其他全局变量及其初始化。请注意,视觉效果的代码与物理代码分开,以确保每个draw()周期内的渲染仍然只发生一次:

在 p5.js 中,滑块通过三个参数定义:最小值(滑块最左边时的值)、最大值(滑块最右边时的值)和初始值(页面首次加载时的值)。在这个案例中,滑块允许你以 20 倍速运行模拟,以更快地得到进化结果,然后将其慢速调整回 1 倍速,沉浸在智能行为展示的荣耀中。
这是最终版本的示例,包含一个新的Creature构造函数,用于创建神经网络。与应用 GA 步骤相关的其他部分与Flappy Bird示例代码保持一致。

难以置信,但这本书的创作历程已经超过了 10 年。感谢亲爱的读者,感谢你一直陪伴。相信我,这不是一个无限循环。尽管它看起来像是在随意漫步,但我最终通过一个到达引导行为,达到了谜题的最后一块拼图,这是我试图将所有过去的探索汇聚到我自己版本的生态系统项目中的努力。
神经进化生态系统
本章中的一些元素与我模拟自然生态系统的梦想不太契合。第一个问题可以追溯到我在第九章中提出的关于 bloop 生物的问题。一种所有生物共同生活和死亡、每一代都完全从头开始的生物系统——这并不是生物世界的运作方式!我想在本章的神经进化背景下重新审视这个困境。
第二点,可能更为重要的是,我从场景中提取特征以训练模型的方式存在一个重大缺陷。示例 11.4 中的生物是全知的。当然,合理的假设是生物能够感知其当前的速度,但我也允许每个生物知道光源的确切位置,无论它距离多远,或者有任何障碍物阻挡生物的视野或感官。这是远远不够的。它违背了我在第五章中介绍的自治代理的主要原则之一:一个代理应该具有有限的感知其环境的能力。
感知环境
模拟现实世界中的生物(或机器人)对其周围环境的有限感知的一种常见方法是为代理附加传感器。回想一下本章开头迷宫中的那只老鼠(希望它在通过奶酪作为奖励后过得很好),现在想象它必须在黑暗中穿越迷宫。它的触须可能充当接近传感器,来探测墙壁和转弯。老鼠的触须无法看到整个迷宫,只能感知周围的即时环境。另一个传感器的例子是蝙蝠利用回声定位导航,或者在弯曲的道路上,司机只能看到车前灯照射到的区域。
我想在这个触须(或更正式地说是振须)的概念上进一步发展,这种触须存在于老鼠、猫以及其他哺乳动物身上。在现实世界中,动物利用它们的振须来导航并探测附近的物体,特别是在黑暗或障碍重重的环境中(参见图 11.5)。我该如何将触须般的传感器附加到我的神经进化寻求生物上呢?

图 11.5:猫咪 Clawdius 用它的触须感知环境
我将保留通用类名Creature,但现在将其视为来自第九章的类似变形虫的“bloop”生物,增强了从其中心向四面八方发射的触须感应器:

代码创建了一系列向量,每个向量描述了一个附加到生物上的触须传感器的方向和长度。然而,仅仅有向量是不够的。我希望传感器还包括一个value,这是它感知的数值表示。这个value可以类比于触觉的强度。就像猫咪 Clawdius 的触须可能感知到远处物体的轻微触碰,或者较近物体的强力推送,虚拟传感器的数值也可以反映接近度。
在我进一步讲解之前,我需要给这些生物提供一些感知的内容。如何为它们设计一个Food类,用来描述生物想要寻找的美味圆圈?每个Food对象将有一个位置和一个半径:

我怎么判断一个生物的传感器是否接触到食物呢?一种方法可能是使用射线投射。这种技术通常用于计算机图形学中,将直线(通常代表光束)从场景中的起点投射出去,以确定它们与哪些物体相交。射线投射对于可见性和碰撞检测非常有用,这正是我在这里做的!
射线投射(raycasting)虽然提供了一个稳健的解决方案,但它需要比我想在这里深入讨论的更多数学知识。对于感兴趣的人,可以在 Coding Train 网站的编码挑战#145 中找到解释和实现 (thecodingtrain.com/raycasting)。对于这个例子,我将选择一个更简单的方法,检查传感器的端点是否位于食物圆圈内(见图 11.6)。

图 11.6:传感器的端点是否在食物内外,取决于它与食物中心的距离。
因为我希望传感器能够存储它的感知值以及感知算法,所以将这些元素封装到一个Sensor类中是有意义的:

请注意,感知机制通过使用map()函数来衡量端点在食物半径内的深度。当传感器的端点刚好接触到食物的外边界时,value的值为 0。当端点逐渐接近食物中心时,value增加,最大值为 1。如果传感器完全没有接触食物,value保持为 0。这种反馈的梯度反映了现实世界中接触或压力的强度变化。
让我们通过一个简单的例子来测试这种传感器机制:一个由鼠标控制的 blooper 和一个放在画布中心的食物。当传感器接触到食物时,它们会亮起,并随着它们接近食物中心而变得更亮。


在这个例子中,生物的传感器以从中心延伸出去的线条形式绘制。当传感器探测到某物时(即value大于 0 时),一个圆圈会出现。为了可视化传感器读取的强度,我使用value来设置其透明度。
从传感器中学习
你在想我在想什么吗?如果一个生物的传感器的值是神经网络的输入呢?假设我重新赋予生物控制自己动作的能力,我可以编写一个新的think()方法,通过神经网络大脑处理传感器的值并输出转向力,就像前两个转向的例子一样:

逻辑上的下一步可能是将所有常见的 GA(遗传算法)部分融入其中,编写一个适应度函数(每个生物吃了多少食物?),并在固定的世代时间后进行选择。但这是一个很好的机会,可以重新审视连续生态系统的原则,旨在为生物自身创造一个更复杂的环境和潜在行为模式。我将不再为每一代设置固定的生命周期,而是引入第九章的health(健康)评分。对于每一次通过draw()的周期,每个生物的健康都会稍微下降:

在draw()函数中,如果任何 bloop 的健康值降到 0 以下,它会死亡并从bloops数组中删除。而对于繁殖,bloop 不再一次性执行常规的交叉和变异,每个健康值大于 0 的 bloop 都有 0.1%的几率进行繁殖:
function draw() {
for (let i = bloops.length - 1; i >= 0; i--) {
if (bloops[i].health < 0) {
bloops.splice(i, 1);
} else if (random(1) < 0.001) {
let child = bloops[i].reproduce();
bloops.push(child);
}
}
}
在reproduce()函数中,我将使用copy()方法(克隆)而不是crossover()方法(交配),并采用比平常更高的变异率来帮助引入变异。(我鼓励你考虑使用交叉方法。)这是代码:

为了使其生效,某些 bloop 应该比其他 bloop 活得更久。通过食物的摄取,它们的健康会提高,从而给它们更多的时间来繁殖。我将在Creature类的eat()方法中管理这一过程:

这就足够让系统进化并找到平衡了吗?我可以深入探讨,调整参数和行为,追求最终的进化系统。这个无限的兔子洞具有一种我无法轻易逃脱的魅力,但我将会在自己的时间里去探索。为了本书的目的,我邀请你运行这个示例,进行实验,并得出自己的结论。

最后的示例还包括一些附加功能,你将在随附的在线代码中找到它们,例如食物数组会随着被吃掉而缩小(当食物耗尽时会重新生成)。此外,随着健康状况的恶化,bloops 也会缩小。
生态系统项目
尝试将“大脑”这一概念融入你世界中的生物!
-
不同的生物是否有不同的目标和动机?有些生物是否在寻找食物,而其他生物则寻求不同的资源?那么,避开掠食者或毒药等危险的生物呢?
-
每个生物的输入和输出是什么?
-
这些生物如何感知世界?它们是否能看到一切,还是仅限于某些传感器的范围?
-
你能采取哪些策略来建立并维持生态系统中的平衡?

结束
如果您还在阅读,感谢您!您已经读到了本书的结尾。尽管本书包含了大量内容,但我几乎只是触及了我们所居住的物理世界的表面,以及模拟该世界的技术。我希望这本书能作为一个持续进行的项目存在,我也希望继续向本书的网站添加新的教程和示例,并扩展和更新 Coding Train 网站上的配套视频教程。
非常感谢您的反馈,请通过电子邮件与我联系,地址是(daniel@shiffman.net),或通过向 GitHub 仓库贡献代码来与我联系 (github.com/nature-of-code),以保持该项目的开源精神。分享您的作品,保持联系,让我们与自然同在。

第十三章:附录:生物设计
本指南由 Zannah Marsh 编写,她创作了你在这本书中看到的所有插图。
如果你不确定如何开始生态系统项目中的生物设计任务,或者如果让多种生物在生态系统中共存让你感到有些畏惧,别担心!你可以通过使用一些视觉构建模块,比如基本形状和线条,来开始开发生物,并且可以将它们复用以得到不同的效果。这个设计任务类似于通过复用和重用代码来进行编程。
尽管 p5.js 可以轻松绘制形状和线条,我建议你先用纸和铅笔草绘设计。直接在纸上工作可以帮助你专注于设计,并快速评估和比较不同的版本。你不需要在视觉思考和输入代码之间来回切换。首先在纸上创建你的生物,然后再用代码复制它!
漫画家 Greg Stump 和 David Lasky 建议几乎所有东西都可以用九个元素来绘制;前六个被认为是基础元素,最后三个是附加元素:
-
正方形、圆形和三角形
-
矩形、拉长的椭圆形和高三角形
-
弯曲线、直线和点
开始时在纸上画出这九个元素(见图 A.1)。很简单,对吧?

图 A.1:从九个元素开始你的绘画
现在你可以开始将这些视觉元素组合起来,创建一个生物。你的生物将生活在 p5.js 画布的虚拟空间中,所以你不需要创造一个“真实”的生物;你可以发明一个全新的生物!
这是一个设计方案,对于地球上的居民来说非常熟悉:
-
一个身体
-
一对鳍、翅膀、手臂或腿
六种基本形状中的任何一种都可以作为角色的身体。图 A.2 中的极简例子就符合这个标准。

图 A.2:绘制一个简单的生物
你可能想让你的设计保持简单,就停在那里!但在你开始用代码重新创建你的图形之前,考虑一下你对生态系统的视角或视图。你是从上方看这个场景,就像在看一个池塘吗?还是从旁边看,穿越草地,或者进入一片森林?(想象一下顶视角的游戏和横版滚动游戏。)
生物的朝向也很重要,特别是当你要让它在场景中移动时。在图 A.2 中,这两条弯曲的线是代表腿还是触角?大多数生物是朝着头部方向移动的。但在这个例子中,头部在哪里?重新利用基本形状和附加元素来添加特征——例如嘴巴、眼睛、鼻子、耳朵、尾巴、触角和角——来明确你的生物朝向,就像在图 A.3 中所示。

图 A.3:添加细节以指示朝向
我们喜欢这些画吗?它们完美吗?嗯,也许不是。但不要擦掉你的作品,即使你不喜欢它。你将需要所有的画作为数据点,在迭代角色时进行参考。把生物设计看作是安排视觉元素的过程,并观察它们如何让你产生感受——你如何回应它们,以及它们给你带来了什么启示。
你可能会从非常简单的生物开始。然后,随着你向生态系统中添加更多元素,你会实现行为和互动。修改你生物的外观可以帮助你在视觉上组织和强调这些行为和互动——甚至可能激发它们。
尝试改变这些元素,如图 A.4 所示:
-
身体的大小、圆形或狭长形状
-
腿或翅膀的长度、形状或数量,以及它们之间的角度和距离

图 A.4:通过改变形状的各个方面来修改你的生物
想象一个熟悉的环境可能会有所帮助——比如草地、湖底,或者一个阴凉的热带树顶。例如,这些环境中可能需要什么特征呢?大眼睛?大翅膀?长而窄的身体?圆形、上下浮动的形状?伪装的图案?
在你草图的时候,你可能会发现你的生物的形态暗示了一种行为或感觉——这种行为或感觉你可以在代码中实现。你的生物是快速穿梭、爬行还是缓慢漂浮?它有一个大嘴巴可以吞下大餐,还是有一个小嘴巴来啃食小东西?它有巨大的眼睛来寻找美味的小吃,如图 A.5 所示,还是用来发现正在寻找小吃的捕食者?让你的画作激发你的代码,反之亦然。

图 A.5:将你生物的形态与环境相匹配
当你准备好用代码构建你的生物时,像 translate()、rotate()、push() 和 pop() 这样的函数将是你的好朋友,因为所有的角色特征都是相互关联的。记住,面向对象编程(OOP)当然能为你节省时间和麻烦。你可以快速地重用和修改模式。
从简单的开始,慢慢构建。这里有一些最后的建议,尤其是如果你有一段时间没有尝试画画的话:
-
就像我们很多人一样,你可能小时候喜欢画画,但当技能无法跟上想法时就放弃了。把这些画当作实验来进行吧!在探索过程中,没有对错之分。通过在废纸上画画,降低失败的风险。
-
如果你在开始之前感到紧张,通过在纸上画一些涂鸦或螺旋图形来放松自己。这就像运动前的热身一样;艺术家们也会做热身!
-
有很多出色的数字绘图工具可供使用,但要小心那些容易擦除和无尽的“撤销”功能。如果你把所有不喜欢的部分都擦掉,你就无法对比和学习。
所以,拿起笔和废纸,开始画画,准备好遇见一些生物吧!
第十四章:图片来源
本书中的所有表情符号均来自 OpenMoji,这是一个开源表情符号和图标项目,使用 CC BY-SA 4.0 许可。
第零章: 来自《百万个随机数字与 100,000 个正态偏差》,RAND 公司,MR-1418-RC,2001 年第 314 页–第 315 页。 截至 2023 年 10 月 17 日: www.rand.org/pubs/monograph_reports/MR1418.html。
第一章: 感谢吉姆·希菲提供,使用于 CC BY-SA 3.0 授权。 commons.wikimedia.org/wiki/File:Micronesian_navigational_chart.jpg。
第二章: © 埃兹拉·斯托勒/Esto,使用许可。
第三章: © 布里奇特·赖利 2023,版权所有。
第四章: 感谢卡尔·D·安德森提供,属于公有领域。 commons.wikimedia.org/wiki/File:PositronDiscovery.png。
第五章: 感谢美国国家海洋和大气管理局照片库提供,属于公有领域。 en.m.wikipedia.org/wiki/File:Sixfinger_threadfin_school.jpg。
第六章: 感谢 Arshiya Urveeja Bose 提供,使用于 CC BY 2.0 授权。 en.wikipedia.org/wiki/Living_root_bridge#/media/File:Living_root_bridges,_Nongriat_village,_Meghalaya2.jpg。
第七章: 感谢 ZSM 提供,使用于 CC BY-SA 3.0 授权。 commons.wikimedia.org/wiki/File:Ewe_kente_stripes,_Ghana.jpg。
第七章, 图 7.18: 感谢理查德·林提供,使用于 CC BY-SA 3.0 授权。 commons.wikimedia.org/wiki/File:Textile_cone.JPG。
第八章: 感谢萨德·阿赫塔尔提供,使用于 CC BY 2.0 授权。 commons.wikimedia.org/wiki/File:Bangkok-SA5.jpg。
第九章: 感谢国家公园管理局提供,属于公有领域。 commons.wikimedia.org/wiki/File:Bowl_Chaco_Culture_NM_USA.jpg。
第十章: 感谢 Pi3.124 提供,使用于 CC BY-SA 4.0 授权。 commons.wikimedia.org/wiki/File:Quipo_in_the_Museo_Machu_Picchu,_Casa_Concha,_Cusco.jpg。
第十章,图 10.15: 感谢 Suvanjanprasai,使用许可为 CC BY-SA 4.0。 commons.wikimedia.org/wiki/File:MnistExamplesModified.png。
第十一章: 感谢纽约公共图书馆,属于公有领域。 nypl.getarchive.net/media/the-star-nose-mole-end-of-the-nose-magnified-05cbe6。
创意共享许可证的副本可以通过访问以下网站或发送信件到 Creative Commons,PO Box 1866,Mountain View,CA 94042,USA 来获取:
CC BY 2.0: creativecommons.org/licenses/by/2.0/deed.en
CC BY-SA 3.0: creativecommons.org/licenses/by-sa/3.0/deed.en
CC BY-SA 4.0: creativecommons.org/licenses/by-sa/4.0/deed.en
第十五章:索引
符号
+(加法)运算符, 40
/(除法符号), 50
%(取模)运算符, 554
*(乘法符号), 50
===(严格等于)运算符, 5
!==(严格不等于)运算符, 5
A
加速度, xxxi, 36, 59–69
力的积累, 77–78
角度的, 120–121, 123–124, 155, 157–158
流体阻力, 100
引力, 87
牛顿第二定律, 75–77
向量, 59–69
接受-拒绝算法, 18–19, 455
激活函数, 504–505, 508, 520
自然与人工系统中的适应性(Holland),438
自适应决策系统, 499
addForce() 方法, 355, 357
加法(+)运算符, 40
加法混合, 209–210
add() 方法, 42, 45, 65, 298–299
addParticle() 方法, 338
空气阻力, 94. 另见 流体阻力
聚集行为中的对齐, 259, 268–269, 271
align() 方法, 270
alive 属性, 559–560
振幅
定义, 135
振荡, 135–137
带有角速度, 140–142
波, 143
锚点
距离约束, 316–317
转动约束, 319
弹簧力, 147–150
Anderson, Carl D., 167
Anderson, Edgar, 524–525
angleBetween() 方法
点积, 242–243
路径跟随, 248
向量, 45
angleMode() 函数, 119, 281
角度, 118–120
角加速度, 120–121, 123–124, 155, 157–158
角运动(旋转), 119–130
角速度, 120–122, 124, 138–142, 155, 157, 161
度数, 118–119
弧度, 119
角度变量, 122, 139–140, 144, 160
Animal 类和对象, 188–192
异常检测, 499
applyBehaviors() 方法, 266
applyForce() 方法
创建力, 83, 85
考虑质量, 81
流体阻力, 95
力的积累, 78
遗传算法, 471
引力, 101, 103, 105
牛顿第二定律, 77
粒子系统, 170, 172, 197–198
物理引擎, 324–325
弹簧力, 149, 151
驾驶力, 220
applyRepeller() 方法, 202
反余弦, 128, 242
反正弦, 128
反正切, 128–129
亚里士多德, 73
Array 类和对象, 174–175
数组解构, 415
到达行为, 224–229
箭头符号, 40, 178
人工智能(AI), 501. 参见 机器学习;神经网络
ASCII(美国信息交换标准代码), 451
Ashley, Quinton, 291
结合律, 42, 50
异步操作, 534
async 关键字, 534
atan() 函数, 128–129
atan2() 函数, 129–130
AttractionBehavior 类和对象, 355–357
attract() 方法, 106, 111, 326
吸引子类和对象,103–111,151–152,325,356
自主代理,xxxii,213–285
算法效率,274–284
复杂系统,215,257–274
已定义,214
流场跟踪,233–239
关键组件,214–215,220
路径跟踪,240–257
驾驶行为,215–240,260–265,268–274
await 关键字,534
B
background() 方法,368
反向传播,520
Barnes-Hut 算法,115,279
beginShape() 函数
多边形,311
软体仿真,344
车辆与驾驶,223
正态分布,13–14
偏置输入,507–508,519–520,526
大 O 符号,274–275
二维格网空间细分(分箱),275–278,357
鸟类和对象,550,552,555–556,559,563
blendMode() 函数,209
混合模式,208–209
Bloop 类和对象,488–489,491–492
Bob 类和对象,150,153
物体/物体类和对象,294,297–299
碰撞事件,330
将盒子对象与之关联,305
n 体问题,110–111
多边形,309
静态,307
Boid 类和对象,268–269,272,275,277
Boids 模型,216,268–278,500
bounceEdges() 方法,93–94
boundaries() 方法,232
Boundary 类和对象,307–308,331
Box2D,290–291,332
Box 类和对象,302–303,305–307,323–325
box 变量,298
大脑属性,555
Braitenberg, Valentino, 216
branch() 函数,422–424,426
Brummitt, Liam, 291
粗暴算法,439–441
Burges, Christopher J.C., 523
Bushnell, Nolan, 550
蝴蝶效应(非线性),258
C
calculateDrag() 方法,98
calculateState() 方法,392
Calder, Alexander, 71
回调函数,328–330,534,538,556–557
Cantor, Georg, 401–402
cantor() 函数,408–409
康托尔集,402,407–410,431
canvas 变量,300
笛卡尔坐标,130–134
转换为极坐标,131–133
已定义,130
进化计算,473
路径跟随,252
摆,160
Carver, George Washington, 543
Cat 类和对象,188–190
类别。参见 标签
Catto, Erin, 290
CDN(内容分发网络),291–292
Cell 类和对象,390
元胞自动机(CA),xxxii,359–396
细胞,360,390–392
分类,379–381
已定义,360
基本(1D 元胞自动机),362–379
生命游戏(二维元胞自动机),381–390
网格,360–362
邻域,360–364
状态,360–363
变化,392–395
中央处理单元(CPU),559
查克里·玛哈·普拉萨大厅,397
checkTarget() 方法,480
选择变量,5
circle() 函数,44,295,337
类,定义,3。参见 标签
分类,沃尔夫拉姆,379–381
复杂性,380–381
随机性,380
重复,380
一致性,379
机器学习中的分类,522–529,547
分类器变量,532–533,537
classify() 方法,537–539,556–557
classifySync() 方法,557
克隆,447,491,580
Cluster 类及其对象,351–353
Coding Train,xxix–xxx,xxxviii
摩擦系数(Q),91,94,96
群体中的凝聚力,259,268–269,271–272
collides() 方法,552
碰撞
吸引与排斥行为,356
回调,328–330,534
碰撞检测,289–290,329
碰撞事件,327–331
碰撞解决,289
事件监听器,328
理想化弹性碰撞,92
非弹性碰撞,92–93
神经进化,552–553,564
物理学库,334
colorMode() 方法,484
逗号分隔值(CSV),531
交换律,42
复杂适应系统,395,500
复杂系统,215,257–274
第 4 类细胞自动机,380–381
组合行为,265–268
竞争与合作,258–259
定义,258
反馈,259
群体行为,268–274
生命游戏,382
实现群体行为,259–265
关键原则和特征,258
嵌套,395
非线性,258
分离,260–265
复合容器,294,298
复合体,309,312–314
自然的计算之美,生命(Flake),xxx
凹形,310
彩纸子类和对象,194–195,197
连接主义系统,500
恒定加速度,60–62
const 声明,5,297
constrain() 函数
角运动,125
导航行为,237
约束类和对象,315,317,319
约束,294,315–324
距离约束,315–319
鼠标约束,315,322–324
转动约束,315,319–322
constructor() 方法
粒子系统,197
随机行走者,4
构造函数,定义,4
contactEdge() 函数,92
contains() 方法,98,479–480
内容分发网络(CDN),291–292
凸形,310
康威,约翰,381
copy() 方法
力和质量,81
神经进化生态系统模拟,580
繁殖,492
科尔特斯,科琳娜,523
cos() 函数
坐标转换,131
路径跟随,248
摆,160
余弦
坐标转换,131,160
定义,126
点积,242–243
查找表,280–282
振荡,137
路径跟随,247
摆,156
库尔顿,乔纳森,397
库尔维尔,拉斐尔·德,xxx
科维尤,罗伯特·R,1
中央处理单元(CPU),559
createButton() 方法, 571
createCanvas() 方法, 210
create() 函数, 296, 299, 312, 323
createVector() 函数, 39–40, 140, 297
生物类和对象, 568, 570, 572, 574, 580
生物设计, 585–588
使形式与环境匹配, 587
透视, 586
变化元素, 586–587
视觉元素, 585
cross() 方法, 45
交叉, 447–450, 457–459, 561–563
crossover() 方法, 458, 492, 562
自定义分布, 16–19
接受-拒绝算法, 18–19
避免过采样, 17
Lévy 飞行, 17
合格随机值, 17–18
CustomShape 类和对象, 309, 311
D
debug 属性, 532, 534
度数, 118–119
角度差, 143
deltaAngle 变量, 143–145
时间差, 79, 121
权重差, 510
密集(全连接)层, 526
导数, 331
确定性结构, 24, 399–400, 402, 419–424, 426
微分, 331
消散力, 90
距离约束, 316–319
dist() 方法, 45
分配规则, 50
除号 (/), 50
div() 方法, 45, 50, 65
DNA 类和对象, 451–454, 458, 460–461, 467–469, 472–475, 484, 491–492
Dog 类和对象, 187–192
dot() 方法, 45, 241
拖曳力, 94–95. 另见 流体阻力
drag() 方法, 98
drawCircles() 函数, 405–406
E
eat() 方法, 187, 192–193, 580
生态系统项目, xxxvii–xxxviii
自治代理, 285
元胞自动机, 396
力, 116
分形, 435
遗传算法, 495
神经网络, 542
神经进化, 582
振荡, 165
粒子系统, 211
物理引擎, 358
随机性, 31
向量, 69
生态系统仿真, 439, 487–493
基因型/表现型区分, 444, 490
神经进化, 573–582
繁殖, 491–493
选择, 491–493
弹性碰撞, 92
精英化选择父代方法, 445
elt 属性, 300
爱默生,拉尔夫·沃尔多, 213
Emitter 类和对象, 179–184, 186–187, 195–198
发射器, 170, 179–181, 182–185
发射器变量, 184–185
endShape() 函数, 223, 311, 344
引擎类和对象, 294, 296–297, 304, 326
世代, 533–534, 536
平衡, 73
欧几里得, 34, 398
欧几里得几何, 398, 401
欧几里得向量, 34. 另见 向量
欧拉,莱昂哈德, 332
欧拉积分, 332
事件监听器, 328
进化计算, 438. 另见 遗传算法
排他或 (XOR), 518–520
指数增长,465
extends 关键字,190
F
factorial() 函数,404–405
阶乘,403–404
工厂方法,297–298
feedForward() 方法,508
前馈模型,503
filter() 函数,178
finishedTraining() 回调函数,534,538
适应度函数
定制遗传算法,464–467
已定义,445
生态系统模拟,489,579
交互选择,483
繁殖,448
选择,452–453
智能火箭,471–472,481,570
弗莱克,Gary William,xxx
flap() 方法,550,553,557
Flappy Bird 游戏,545–565
分类,547
编码,550–554
碰撞事件,552,564
决策过程,555
特征,546–547,555–556
遗传,561–565
神经网络,546–548
神经进化,554–565
诺兰定律,550
归一化得分,561,563
强化学习,545
繁殖,561–565
选择,559–561
变异,557
逃逸行为,223,261–265
Flock 类和对象,272
群聚行为,268–274
规则控制,268–269
短程关系,270
flock() 方法,269
floor() 方法,5,7
花卉类和对象,484
FlowField 类和对象,237–239
流场跟随,233–239
流体阻力,94–100
公式,94–96
Mover 和 Liquid 对象,96–100
质量可变的对象,98–100
简化公式,96
食物类和对象,492,575
力,xxxi,71–116
累积,77–79
作用/反作用对,74
创建,82–88
定义,72
平衡,73
质量和,79–82
建模,82,88–110
n体问题,110–115
牛顿运动定律,72–77
带有的粒子系统,197–200
物理引擎,72,324
时间步长,79
力的变量,149
forEach() 循环,175
for...in 循环,175
for 循环
粒子数组,174,176
元胞自动机,370–371
定义,175
L-系统,429
神经进化控制行为,571
递归函数,403,405–406,408
软体仿真,343
波,142,144
for...of 循环
粒子数组,175–176
碰撞事件,329
定义,175
科赫曲线,413
粒子发射器,185
带力的粒子系统,199
多边形和形状,311
自然的分形几何,曼德尔布罗集,398,401
分形,397–435
Cantor 集,402,407–410,431
定义,398
确定性结构,399–400
欧几里得几何与,398,401
示例,398–400
科赫曲线,411–419
L-系统,427–435
曼德尔布罗集,401
数学怪物,412
生产规则,401–402
递归,401–410
自相似,400
塞尔皮ński 三角形,366,373
随机结构,400
树形结构,399,419–427
frameCount 变量,136–137,179,554
帧
粒子数组,179
经过的数量,136
振荡,136–138,140
摆,157,161
波,145
频率,定义,138
摩擦,90–94
计算,92
系数,91,94,96
耗散力,90
弹性与非弹性碰撞,92–93
公式,89–91
速度向量,90–91
fromAngle() 方法,133
fromCharCode() 方法,451
fromVertices() 函数,309
完全连接(密集)层,526
function 关键字,4
G
G(万有引力常数),101–103,107
Gala(Riley),117
Galapagos(Sims),483–484
伽利略,87
生命游戏,381–392
实现,385–390
面向对象的单元,390–392
规则,382–385
加德纳,马丁,381
GAs。见 遗传算法
高斯,卡尔·弗里德里希,13
高斯分布。见 正态分布
geneCounter 变量,474
generate() 函数,414,417–418,429
世代。另见 繁殖;选择
元胞自动机,362–363
定义,362
L-系统,428
加速,570
代变量,377
生成模型,500
遗传算法(GAs),xxxii,437–495
编码,450–462
自定义,463–469
已定义,438–439
生态系统模拟,439,487–493
进化与智能设计,438
基因型/表型,443–444,467–469,472,490
遗传,442,447,561–565
交互选择,439,482–487
种群创建,443–444,450–452
复制,447–450,457–459,463–464,491–493,561–565
选择,442,444–447,450,452–457,491–493,559–561
智能火箭,469–482
有用性,440–441
变异,442–443,445–447,449,557–559
基因型
自定义遗传算法,467–469
已定义,443
生态系统模拟,490
基因型/表型区分,444
交互选择,483,486
智能火箭,472
几何向量,34。 另见 向量
手势分类器,529–541
数据收集,530–531
数据准备,530–531
模型部署,537–539
模型评估,534–537
模型选择,531–532
模型训练,532–534
参数调优,537
getNormalPoint() 函数,252
GitHub
反馈与修正,xxxviii
魔法书项目,xxix
书籍的存储库,xxviii–xxix,xxxv,xxxviii,583
全局变量
避免不必要的 p5.Vector 对象,283
遗传算法,463–464,471,474
手势分类器,538
振荡,139
随机漫步,6
Glow 类和对象,568,570
图形处理单元(GPUs),559
重力和引力,101–110
作用于两个物体,86
限制距离,107
吸引力的方向,102
公式,101
与距离成反比,101–102
n-体问题,110–115
一个物体吸引另一个物体,103–107
一个物体吸引多个物体,109–110
有力的粒子系统,197–199
摆,154–159
排斥力与吸引力,200–201
按质量缩放,88
体重,76
GravityBehavior 类和对象,336
重力变量,296
G 变量,102–103,107
H
HALF_PI 常量,119
handleCollisions() 函数,328–330
handPose() 函数,528
heading() 方法,45,130,223,530
健康变量,489,579
遗传,442,447,561–565
高阶函数,175,178
铰链,319
霍金,罗伯特,209
霍兰,约翰,438
赫兹,罗伯特,147
赫兹定律,147–148
雨果,维克多,287
超参数,522,534,537
I
I(转动惯量),124,158
if 语句,231,249,387,530
imageClassifier() 函数,528
图像纹理,205–211
加性混合,209–210
混合模式, 208–209
高斯分布, 207–208
PNG 格式, 206
渲染器, 210
分辨率, 208
img 变量, 207
非弹性碰撞, 92–93
无限猴子定理, 440
无限值, 256–257
继承, 185–192
定义, 187
扩展, 190–191
重写, 191
带有粒子的, 193–197
弹簧, 339
子类, 188, 190
超类, 188, 190
树形结构, 189
instanceof 运算符, 331
积分,已定义, 331
积分, 331–333
定义, 331
欧拉积分, 332
龙格-库塔积分, 333
Verlet 积分, 333
交互式运动, 66–69
交互式选择, 482–487
已定义, 439, 483
种群创建, 483–484
用户指定的适应度评分, 484, 486
反比例, 75, 101–102
iris 数据集, 524–525
isDead() 方法, 179, 197
isStatic 属性, 300, 307
J
Jakobsen, Thomas, 333
JavaScript, xxvii–xxviii。参见具体库和编程元素的名称
丰富的选项, 174
加法运算符, 40
数组, 174, 187, 233
箭头表示法, 178
回调函数, 534
const 与 let, 5
无限值, 257
继承, 190
对象解构, 297
对象字面量, 298, 434
对象, 3
严格相等和不等运算符, 5
JSON(JavaScript 对象表示法), 531
K
k(弹簧常数), 148
角田道雄, 497
坎特布, 359
基普 装置, 497
克莱曼, 金, 525
克里斯, 史蒂文, xxix, 385
Koch, Helge von, 411
Koch 曲线, 411–419
KochLine 类和对象, 412–415
kochPoints() 方法, 415
库塔, 马丁, 333
L
标签, 522–524, 526, 538, 540
拉斯基, 大卫, 585
学习常量, 511
学习处理(Shiffman), 185
LeCun, Yann, 523
lerp() 方法, 45
let 声明, 5
莱维飞行, 17
lifeCounter 变量, 477–478
寿命属性, 170–171, 392, 474, 477
limit() 方法, 45, 61
林登迈尔, 阿里斯蒂德, 427
林登迈尔系统。参见 L-系统
线性可分问题, 517–519
line() 函数, 408–409, 412, 421–422, 432
Liquid 类和对象, 96–99
液体变量, 97
活根桥, 287
load() 函数, 537
loadJSON() 函数, 534
lock() 方法, 340
lookup() 方法, 237–239
查找表, 280–282
洛伦茨, 爱德华·诺顿, 258
L-系统(林登迈尔系统), 427–435
字母表, 428
公理, 428
代, 428
生成规则, 428
简单, 428–430
字符串, 427–428
月球着陆 游戏, 549–550
M
机器学习。参见 神经网络
分类, 522–529
纪元, 533–534, 536
特性, 546–547
生命周期, 521–522, 529–541
损失, 536
使用 ml5.js, 521–529
网络设计, 524–528
回归, 524, 527–528
强化学习, 501, 545–549
有监督学习, 501, 509, 521–523, 549
迁移学习, 502
无监督学习, 501
Madsen, Rune, xxix
mag() 函数, 45, 51, 95
磁层, 209
数量的平方, 279–280
magSq() 函数, 280
Mandelbrot, Benoit, 398, 401
曼德尔布罗集, 397, 401
map() 函数
遗传算法, 484
基因型/表型, 468
神经进化生态系统仿真, 576
噪声范围, 21–22
振荡, 136
粒子系统, 195
Marsh, Zannah, 31, 585–588
马绍尔群岛的航海图, 33–69
质量
流体阻力与质量可变物体, 98–100
力的积累, 77–79
引力, 101–102, 104, 109
融入仿真, 79–82
牛顿第二定律, 75–77
测量单位, 80
重量与, 76
质量变量, 79, 83, 197
数学怪物, 412
配对池, 445, 453–455
Matter.js 库, 288, 291–328
添加力, 324–327
物体对象, 294, 297–299
碰撞事件, 327–331
与 Toxiclibs.js 相比, 334–335
复合容器, 294
约束条件, 294, 315–324
引擎对象, 294, 296
导入, 291–293
对象解构, 297
概述, 293–296
p5.js 和, 302–305
多边形和形状组合, 309–315
Render 类, 299–301
静态物体, 307–308
向量, 294–295
McCulloch, Warren S., 499
均值
计算, 14–15
定义, 13
方法
定义, 4
静态与非静态, 64–66
Miikkulainen, Risto, 550
百万个随机数字与 100,000 个正态偏差, A (RAND), 1
millis() 函数, 136
Mills, Mike, xxvii
ml5.js 库, 502
机器学习, 521–529
模型部署, 537
模型选择, 531–532
模型训练, 532
神经进化, 554–557, 559, 562–563, 566–567
参数调优, 537
强化学习, 548
数据集拆分, 537
语法, 528–529
MNIST(修改后的国家标准与技术研究所)数据集, 523–524
模运算符(%), 554
mo’i 鱼, 213
惯性矩(I), 124, 158
Mouse 类和对象, 323
MouseConstraint 类和对象, 315, 323–324
鼠标约束, 315, 322–324
mousePressed() 函数, 328, 485
Mover 类和对象
加速度, 60–61
角动量, 122–123, 126–128
结合引导行为, 266
创建力, 82–83, 85–86
流体阻力, 96–98
摩擦力, 92
引力, 103–110
在仿真中加入质量, 79, 81
交互运动, 67
向量运动, 55–57, 59
牛顿第一定律, 73
牛顿第二定律, 76–77
弹簧力, 149
Muhonen, Taru, xxx
多层感知器, 519–520
乘法符号 (*), 50
mult() 方法
结合引导行为, 267
交互运动, 68
向量乘法, 45, 48–49, 65
mutate() 方法, 458–459, 562
突变, 449–450, 457–459, 463–464, 562
N
N(法向力), 91–92
命名空间, 295
自然语言处理 (NLP), 500
n 体问题, 110–115
数组, 113–115
两体吸引力, 111–113
NEAT(神经进化拓扑增强)算法, 550
邻域, 360–364, 367, 371–374, 381–383
neighborSum 变量, 387
neuralNetwork() 函数, 528, 534, 555, 563, 567
神经网络, xxxii, 497–542
适应性特征, 500
反向传播, 520
分类, 522–529
数据归一化, 517
定义, 498
人工神经网络的难应用, 499–500
训练周期, 533–534, 536
手势分类器, 529–541
人脑, 498–499
学习策略, 501, 502
损失, 536
机器学习
定义, 501
库, 502, 521–529
生命周期,521–522,529–541
网络设计,524–528
神经元,498–499
非线性可分问题,518
感知机,502–520
预训练模型,502
程序化与连接主义系统,500
回归,524,527–528
合成数据,512
权重调整,500–501
神经进化,xxxii,543–582
碰撞事件,552,564
决策过程,555
定义,544
早期例子,549
生态系统模拟,573–582
特征,555–556
遗传,561–565
NEAT 算法,550
归一化分数,561,563
强化学习与,545–549
生殖,561–565
选择,559–561
引导行为,565–573
变化,557
神经进化属性,555
新科学的种类,A(沃尔夫拉姆),361
新运算符,6
牛顿,艾萨克,xxxi,72
牛顿运动定律,72–77
第一法则,72–73
第二定律,75–77,80,89,155,157–158
第三定律,73–75
nextGeneration() 函数,485
阮东,550–554
noiseDetail() 函数,27
noise() 函数,20–21,27,139
非线性(蝴蝶效应),258
非线性可分问题,518–519,526
非静态方法,64–66
非均匀分布,9–13
对多个结果应用不等权重,11–12
请求随机数, 11
控制事件的概率, 13
用数字填充数组, 11
用途, 9–10
正态分布(高斯分布), 13–16, 207
钟形曲线, 13–14
已定义, 13
标准, 15
法向力 (N), 91–92
归一化
数据, 517, 533
适应度分数, 445–446, 561, 563
向量, 53–55, 67, 102–103, 295
normalizeData() 函数, 533
normalizeFitness() 函数, 563
normalize() 方法
交互式运动, 68
路径跟踪, 248
向量归一化, 45, 53–54
速度单位向量, 96
n平方算法, 115
Nucera, Diana, 501
O
对象解构, 297, 415
对象字面量, 298, 316–317, 434, 555
面向对象编程(OOP),3–4, 59, 83, 158–159, 239. 另见 继承;多态
对象,已定义, 3
障碍类和对象, 478–480
offscreen() 方法, 553
一维 Perlin 噪声, 20–22
Onuoha, Mimi, 501
优化, 522
振荡, xxxi, 117–165
角度, 118–120
角运动(旋转), 119, 120–130
具有角速度, 138–142
已定义, 118, 134
摆, 154–164
极坐标与笛卡尔坐标, 130–134
属性, 134–138
弹簧力, 147–154
三角函数, 125–126
波,142–146
振荡器类和对象,140–141,165
过拟合数据,536
过采样,17
P
p5.Image 类和对象,170
p5.js 库,xxvii–xxviii
const 和 let,5
文档,xxix
“入门”页面,xxix
笔记本列表,xxxv
Matter.js 和,302–305
噪声,27
向量,37–40
Web 编辑器,xxxv
p5play 库,291
p5.Vector 类和对象,37,42
加速度,67–68
角动量,130
避免不必要的,282–284
坐标转换,133
点积,241
遗传算法,473
将质量纳入力量模拟,81–82
科赫曲线,414–415
振动,140,142
粒子系统,198
驱动力和行为,218,235
向量,39–40,44,47–48,50–51,53,61,64–66
参数调整,522,537
parseInt() 函数,374
Particle 类和对象,59
吸引力和斥力行为,355–356
碰撞事件,329–331
约束,316,320
力导向图,351–352
粒子系统,169–170,172–175,186,193–199,203
软体模拟,343,346–347
Toxiclibs.js 粒子,337–339
ParticleString2D 类和对象,343
ParticleSystem 类和对象,59,169,179
粒子系统,167–211
粒子数组,174–179
定义,168
发射器,179–185
与力相关,197–200
图像纹理,205–211
继承,185–197
多态,185–187,192–197
与排斥力相关,200–205
单个粒子,169–173
相关情况,168–169
路径类和对象,245–246,252,254–255
路径跟踪,240–257
定义,240
点积,240–243
多段,252–257
法线,246–247
路径半径,245
标量投影,249
简单,243–252
模式识别,499,505–508
Pedercini,Paolo,291
摆类和对象,158–159
摆,154–164
角加速度和角速度,155,157
阻尼技巧,161
定义,154
距离约束,317–318
摆的力,156–157
重力和张力,154–155
惯性矩,158
合力,155
面向对象结构,159–160
软,343–345
扭矩,158
人工智能的人民指南,A(Onuoha 和 Nucera),501
感知机类和对象,508–511
感知机,502–517
激活函数,504–505
偏置输入,507–508
编码,508–517
计算输出,504–505
定义,502–503
前馈模型,503
学习常数,511
线性与非线性可分问题,517–519
多层,519–520
模式识别,505–508
感知机算法,504–505
输入求和,504–505
训练,512–515,520
加权输入,503–505
周期
定义,135–136
帧计数,136–138,179
频率和,138
振荡,135–138,140
波,143
Perlin,肯,19–20
Perlin 噪声,19–30
定义,19
生态系统模拟,488
分形,427
神经进化驾驶行为,568
噪声范围,22–25
一维,20–22
过度使用,29
驾驶行为,236,238
二维,25–30
均匀分布与,19–20
用途,29
表型
自定义遗传算法,467–469
定义,443–444
生态系统模拟,490–491
基因型/表型区分,444
交互选择,484,486
智能火箭,472,474
菲利普斯,凯尔,333
物理引擎,72,79,288。另见 力;物理库
物理库,xxxii,287–358
Box2D,290–291
积分方法,331–333
Matter.js 库,291–328
p5play 库,291
使用原因,289–290
Toxiclibs.js 库,333–357
pi (π),定义,120
PI 常数,119–120
Pipe 类和对象,551–553
皮茨,沃尔特,499
轴心,154,158–161
插件对象,329–330
极坐标,130–134
转换为笛卡尔坐标,131–133
定义,130
演化计算,473
摆,160
polygon() 方法,309
多态,185–187,192–193
定义,187,193
带有粒子的,193–197,338
弹簧,339
pop() 函数,420–422
种群类和对象,475–476,485
正电子(安德森),167
predict() 方法,566
predictSync() 方法,566
preload() 方法,207
预训练机器学习模型,502,528,559
概率性父代选择方法,445–446
适者生存的概率,10
过程系统,500
Processing,xxvii–xxix,xxxviii,333
产生规则
分形树,419–420
科赫曲线,411
L-系统,427–429
递归,401–402
promises,534
伪随机数,1,9,19
普韦布洛陶器,437
push() 方法,175,420–422
勾股定理,51,280
Q
二次增长,465,472
四叉树数据结构,278–279
R
弧度,119–120
radians() 函数,119
兰德公司,1
random2D() 方法,45,63–64,235,473
random3D() 方法,45
随机加速度,62–64
randomCharacter() 函数,451,459
random() 函数
自定义分布,17
分形树,426
遗传算法,455
非均匀分布,9,11,13
正态分布,13
Perlin 噪声,20–21,27
随机游走者,4–5,7–9
randomGaussian() 函数
正态分布,14–16
粒子系统,208
随机性,xxxi,1–31
元胞自动机,380
自定义分布,16–19
非均匀分布,9–13
正态分布,13–16
Perlin 噪声,19–30
伪随机数,9
随机游走,2–9,63
单事件概率,10
引导行为,235
随机游走和游走者,2–9
自定义分布,16–17
定义,2–3
神经进化引导行为,568
非均匀分布,12–13
Perlin 噪声,23
概率,8
随机加速度,63
均匀分布,8–9
用途,3
射线投射,575
recordDistance 变量,480
rectangle() 方法,298,305,307
Rect 类和对象,336
rect() 函数,298
递归,401–402
Cantor 集,407–410
退出条件,404–405
阶乘,403–404
递归圆形,405–407
递归函数,402–404
回归,524,527–528,547,565–566
强化学习,545–549,555,559
定义,501
特性,546–547
策略,549
奖励函数,549
监督学习与,549
removeBody() 方法,306–307
Render 类和对象,299–302,305
渲染器,210
Repeller 类和对象,200–205
排斥物,200–205
repel() 方法,202
reproduce() 方法,580
reproduction,447–450,457–459,491–493
克隆,447
交叉,447–449,450,457–459,561–563
突变,449–450,457–459,463–464,562
重复,450
reproduction() 方法,475,563
resetPipes() 函数,564
Resnick, Mitchel,215,385
静长,147–148,150,339
转动约束,319–322
Reynolds, Craig,215–216,218,220–221,227–229,233,238,240,243,249–250,253,257,261,263,265,268,275
刚体模拟,334,342。另见 Matter.js 库
Riley, Bridget,117
火箭类和对象,469–472,474–475,479–480
rollover() 方法,485
Ronald, Edmund,549–550
Rosenblatt, Frank,502–503
rotate() 函数,45,420–422
角动量,118,121–122
物理引擎,306
海龟图形,432
规则集, 363–365, 367–370, 373–374, 379
任意性, 364
已定义, 363
存储, 373
rules() 函数, 370, 372–374
Runge, Carl, 333
Runge-Kutta 积分, 333
run() 方法
粒子数组, 174
群体行为, 272
遗传算法, 479
神经进化引导行为, 566
粒子发射器, 184
粒子系统, 197
Runner 类和对象, 300–302, 304
S
Samuel, Arthur Lee, 501
Satoro, Ryunosuke, 359
save() 函数, 537
标量投影, 243, 249, 252
标量
角动量, 121
公式, 89
质量, 80
向量乘法, 48
向量与, 40
缩放, 48
Schmidt, Karsten, 333
Schoenauer, Marc, 549–550


所示,这在此情况下代表速度单位向量。
。
指的是阻力,这个向量用于计算并传递给
指的是引力力,计算该力并传递给
,质量也是我可以选择忽略的东西。毕竟,屏幕上绘制的形状没有物理质量。然而,如果你跟踪这个值,你可以创建更有趣的模拟,其中“更大”的物体比“更小”的物体施加更强的引力。

,如果


浙公网安备 33010602011771号