艺术家的编程指南-全-

艺术家的编程指南(全)

原文:zh.annas-archive.org/md5/ebdbd7633dd1fdd6d4a3327881c1542e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

编程语言基础:Processing

当有人编写计算机程序时,他们实际上是在与计算机沟通。这是一种命令式且精准的沟通。命令式是因为计算机没有选择权;它被告诉该做什么,它就会准确地执行。而精准是因为计算机不会对它被告知的内容进行任何解释。计算机不会思考,因此无法对“给病人暴露于致命剂量的辐射”这种指令产生任何怀疑。所以,我们作为程序员,必须小心且精准地指示计算机做什么。

当人类相互沟通时,我们使用语言。同样地,人类也使用语言与计算机沟通,但这些语言是人工的(人类为此目的发明的)、简洁的(几乎没有修饰词——无法表达情感或任何感觉的细微差别)、精准的(语言中的每个元素都有一个明确的含义)和书面的(我们目前还不能用编程语言与计算机对话)。

编程过程始于一个需要解决的问题,第一步是尽可能清晰地表述问题。接着,我们分析问题并确定可以解决问题的方法。计算机只能直接处理数字,因此在这一阶段讨论的解决方案通常是数值或数学的。解决方案的草图,可能是用人类语言和数学表示的,首先被创建出来。然后,这些内容会被翻译成计算机语言并通过键盘输入计算机。生成的文本文件被称为脚本、源代码,或更常见的计算机程序。接下来,另一个程序叫做编译器,它会将程序转换成计算机可以执行的形式。基本上,所有程序都被转换成机器码,它由数字组成,计算机能够执行这些数字。

你将学习一种名为 Processing 的编程语言。它是为艺术家设计的,但它对许多其他用途也很有效,尤其适合教学,因为它让许多事情变得简单,并且始终有图形化的输出。它的结构(语法)与当今许多其他编程语言相似。事实上,它是 Java 语言的一个特殊易用版本。Processing 程序被称为“草图”,以此致敬其艺术起源。

为了使用一种编程语言,你需要理解一些基本的概念和结构,至少在基本层面上。这些概念将在本介绍中介绍。本书的其余部分将通过示例教你编程:当你随意翻开书时,左页几乎总是概述一个问题或 Processing 语言的概念,右页几乎总是显示说明该概念的代码,并附有该程序的输出屏幕图像。目的是在任何一页上只引入一到两个新知识点。代码将在安装了免费的 Processing 语言下载的计算机上执行,支持任何主流操作系统。访问 processing.org/download 下载适用于你操作系统的最新稳定版本。

要开始编程,你需要理解语言有语法或结构,对于计算机语言来说,这种结构是不能改变的。计算机将始终决定什么是正确的,如果任何程序存在语法错误或产生错误结果,责任在于程序,而不是计算机。

接下来,你需要理解语法是任意的。它是由一个具有个人观点、偏见和新想法的人设计的,尽管语法可能看起来很丑或难以记忆,但它就是这样。你可能一开始不理解它的某些部分,但过一段时间,读完并执行本书前 50 或 60 个草图后,大部分内容会变得有意义。

程序由符号组成,这些符号的顺序很重要。有些符号是具有特定含义的特殊字符。例如,+ 通常表示 加法 通常表示 减法。有些符号是单词,且这些单词由语言定义,比如 ifwhiletrue,这些不能被程序员重新定义——它们的含义是语言规定的,它们被称为保留字。有些名字由系统定义,但如果需要,程序员可以重新使用。这些被称为预定义名字或系统变量。然而,某些单词可以由程序员定义,作为程序员想要在程序中使用的事物的名称:变量和函数就是例子。

开始

所有草图都有相同的基本结构。这里有一个叫做 setup()(预定义名字)的东西,它只会执行一次,在程序开始时。这里是我们进行初始化的地方,比如定义输出窗口的大小。如果我们需要从文件中读取一堆图像或声音,通常会在这里进行。

setup() 的语法如下:

void setup ()
{
    `your code goes here`
}

这就是我们在 Processing 中所称的函数(见草图 24)。它是一段被大括号({})包围的代码,并且有一个名称。当我们在后续代码中使用该名称时,它会被执行(我们称之为调用)。在这个例子中,函数名为setup(),它由 Processing 自动调用一次,程序开始执行时调用。void(一个保留字)现在并不重要,但它意味着该函数不会返回一个值。

setup()完成后,屏幕上会打开一个窗口,程序将在其中绘制。这被称为草图窗口,它的大小是setup()中初始化的内容之一。

中间部分

草图的第二部分是另一个函数,名为draw()。该函数每秒被调用多次(默认是 60 次,但可以更改),它的目的是更新程序正在绘制的图像。Processing 假设程序员正在编写一个绘制某种图像的程序。

每 1/60 秒,Processing 系统会调用draw()函数。每次出现的代码都会被执行,目的是让程序员能够在用户观看时更新正在绘制的图像。例如,如果一系列动物移动的图像按顺序逐个显示,结果将是动物的动画图像。程序员可以绘制形状、显示文本和图像、更改颜色并在屏幕上移动形状,用户可以看到这些变化。

draw(一个预定义的名称)的语法如下:

void draw ()
{
  `your code goes here`
}

其余部分

程序员编写的代码位于setup()draw()中的某个函数内,或者由这两个函数执行。任何无法从setup()draw()访问的程序部分都不会被执行(除了某些鼠标和键盘函数)。

程序员可以为其他函数命名并提供代码,这些函数可以通过draw()setup()调用执行。通常,这些函数会放在程序中的draw()函数之后。例如,如果程序员想要定义一个名为doSomething()的函数,它可能看起来是这样的:

void doSomething ()
{
  `your code goes here`
}

当使用其名称进行调用时,这将被执行:

void draw ()
{
  doSomething();
}

分号用于结束语句,让 Processing 知道程序员认为语句已经结束。它被用来检测错误:如果程序员认为语句已经完成,而 Processing 编译器没有,编译器会发出错误消息。毕竟,编译器总是正确的。

变量

变量的概念是大多数初学者感到困难的内容。本质上,变量是用来存放结果的地方,通常是一个数字。在编程语言中,变量通过一个名称来表示,名称与值之间的连接通过一种叫做赋值语句的语言语句来建立:它将一个值赋给变量。以下是一个示例:

count = 0;

这表明名为count的变量的值是0。我们怎么知道count是一个变量呢?它必须出现在声明中:我们“声明”count是一个变量,并指定类型。类型定义了可以赋给该变量的值的集合。对于数值变量,常见的类型有integer(整数)和float(小数)。如果count是一个整数,那么声明应该是这样的:

int count;

预定义的名称int表示整数,这个声明表明名为count的变量将存储一个整数。如果它应该是一个带小数的数字(实数或浮点数),声明应该如下:

float count;

变量只有在声明后才能使用。尝试使用未声明的变量是错误的,部分原因是它的类型未知。

现在你可以定义变量了,你可以进行复杂的计算。对于算术运算,常见的操作有:+(加),(减),*(乘),和/(除)。在数学表达式中,变量和常量都可以使用,就像代数中一样。以下是一个合法的赋值语句(假设radius已被声明):

count = 2 * 3.1419926 * radius;

它将计算给定半径的圆的周长。

如何编写程序

当启动 Processing 时,无论是点击processing.exe还是点击一个 Processing 源文件,集成开发环境(IDE)都会在屏幕上打开一个窗口。它的样子类似于 Figure 1,尽管根据操作系统和所使用的 Processing 版本,界面可能略有不同。

f00001

Figure 1:Processing 集成开发环境中的新窗口

这个特定的草图被称为start,并且存储在一个名为start.pde的文件中(pde代表Processing 开发环境)。start.pde文件也必须位于一个名为start的目录中。这就是规则。

现在你可以开始输入代码,它会出现在窗口中的白色矩形内。点击启动图标i00001时,代码将执行,点击停止图标i00002时,正在运行的代码将停止。

让我们尝试一个简单的程序:一个绘制圆形的程序。首先,输入刚才描述的基本空程序,如 Figure 2 所示。

f00002

Figure 2:Processing 程序的基本结构

现在我们可以编写代码了。我们希望绘制一个圆形,Processing 将为我们打开一个绘图窗口。我们应该指定它的大小,以免太小。在setup()中,我们可以使用预定义的size()函数来指定一个宽度为 400 像素、高度为 300 像素的草图窗口。

我们希望draw()函数每次被调用时都能绘制一个圆形,默认情况下每秒调用 60 次。在 Processing 中,圆形是椭圆的一种特殊情况,其宽度和高度相等。ellipse()函数绘制一个以指定坐标为中心的椭圆(函数名后括号中的前两个值),并根据第二对值设置宽度和高度。括号中函数名后面的这些值称为参数。背景色默认设置为中等灰色,填充圆形的颜色为白色,圆形周围有一条黑色的轮廓线。

调用ellipse(200, 100, 50, 50)将绘制一个以(200, 100)为中心、宽高均为 50 像素的椭圆。一旦输入此代码,窗口将呈现出图 3 的样子。

f00003

图 3:绘制圆形的代码

现在点击开始图标。一个新窗口会打开,显示我们的绘图,如图 4 所示。

f00004

图 4:绘图窗口

你已经学到了一些东西。椭圆中的值 200 是 x 或水平方向的位置,100 是 y 或垂直方向的位置。值 50 是椭圆的大小,在这种情况下是一个圆形,因为水平和垂直的大小相同。圆形填充了一种颜色,在此案例中为白色,圆形周围有一条黑色的线。

本书的其余部分基本上涉及通过实践来学习。书中有大量代码,而解释相对较少。你可以尝试运行代码,改变参数,看看会发生什么。这就是整个重点。你将通过示例和实际操作来学习语法。

第一章:绘制基础

草图 1:一个圆

在 C 或 Java 中绘制一个圆需要相当多的代码,但在 Processing 中,它是最简单的程序之一。Processing 中没有圆形函数,因此为了绘制一个圆,我们绘制一个宽度和高度相等的椭圆,这与圆形是一样的。

示例 A

setup() 函数调用预定义的 size() 函数来打开一个宽度为 400 像素,高度为 300 像素的草图窗口 1。

draw() 函数每次被调用时(默认每秒 60 次)都会绘制一个圆形,使用 ellipse() 函数,它有四个参数。前两个参数指定椭圆中心的像素坐标。后两个参数指定椭圆的宽度和高度。调用 ellipse (200, 150, 50, 50) 2 绘制一个中心位于 (200, 100) 的椭圆,宽度和高度都是 50 像素,实际上是一个直径为 50 像素的圆。

默认情况下,背景颜色设置为中灰色,填充圆形的颜色为白色。圆形的轮廓由黑色线条勾画。

示例 B

这个示例与示例 A 很相似,但现在背景颜色设置为白色,填充圆圈(以及其他任何基本的闭合形状)的颜色设置为黑色。

background() 函数 1 用一个单一的数字参数指定背景颜色,范围从 0 到 255,表示灰度级别,其中 0 为黑色,255 为白色。范围之外的数值是非法的。在这个例子中,颜色设置为白色(255)。background() 函数在 draw() 函数中被调用,这样每次都会重新绘制背景。如果 background()setup() 中被调用,背景只会在执行开始时绘制一次。

fill() 函数 2 指定基本闭合形状的填充颜色,使用与 background() 函数相同的单一数字参数。在这个例子中,填充颜色设置为黑色(0),直到通过另一次调用 fill() 改变。因此,fill() 本可以仅在 setup() 中调用一次,效果也将相同。

示例 C

在这个例子中,背景颜色(白色 = 255)1 和填充颜色(黑色 = 0)2 在 setup() 中被指定。这个草图在 draw() 函数中绘制了两个椭圆,而不是圆形,以展示宽度和高度参数是如何使用的。第一次调用 ellipse() 3 绘制最左边的椭圆,宽度为 100 像素,高度为 50 像素。第二次调用 ellipse() 4 绘制最右边的椭圆,宽度为 50 像素,高度为 100 像素。

noFill() 函数使得椭圆和其他对象绘制时没有填充颜色,以便背景颜色能够显示在物体内部。

草图 2:颜色

我们可以通过单一的数值分量来指定灰色的色调,但我们也可以通过提供三个数值分量来指定颜色,这些分量的顺序是传统的:红色、绿色、蓝色。每个分量都占用一个字节(8 位),并且它由一个范围从 0 到 255 的数字表示,决定了该分量的色调。数值越小,颜色越暗。

数字(255, 0, 0)指定了最亮的红色,而数字(254, 0, 0)指定了稍微暗一点的红色。绿色是(0, 255, 0),蓝色是(0, 0, 255)。黄色是红色和绿色的组合,因此黄色的 RGB 坐标是(255, 255, 0)。品红色是红色和蓝色的组合,因此它表示为(255, 0, 255)。灰色的值则是三个分量几乎相等的情况。

这是颜色的 RGB 表示法。还有其他的表示方法。

示例 A

这个草图绘制了不同颜色的圆圈。在 draw() 函数中,前三次调用 fill()ellipse() 绘制了第一行圆圈:红色 1、绿色 2 和蓝色 3。每次绘制圆圈之前,填充颜色都会发生变化。

第二行的圆圈分别填充了黄色 4、品红色 5 和青色 6。每种颜色都有两个非零的色值。

最后一行包含一组填充颜色逐渐变亮的灰色圆圈 7。这里的每种颜色都有三个相等的色值。

示例 B

我们还可以使用第四个颜色分量来表示透明度,通常称为 alpha 通道。分量(255, 0, 0, 128)表示红色是 255,绿色和蓝色是 0,透明度是 128,或 50%。数值越大,透明度越低。我们可以为任何颜色指定任何合法的透明度值,除了 R、G 和 B 分量。

这个草图绘制了一些重叠的红色、绿色和蓝色圆圈来展示透明度。

draw() 函数中,前三次调用 fill()ellipse() 绘制左上方的一组圆圈,填充颜色的透明度值为 20\。

后三次调用绘制右上方的一组圆圈,透明度值为 100\。

第三次调用 3 绘制了左下方的一组圆圈,透明度值为 180。

最后三次调用 4 绘制了右下方的一组圆圈,透明度值为 255,这意味着颜色是完全不透明的。

草图 3:if 语句—有条件地改变颜色

在日常生活中,人们经常处理有条件的行为,虽然很少有意识地去思考这个概念。我们当然用人类语言表达这些条件:

  1. “如果下雨,我们看电视;如果是晴天,我们去滑雪。”

  2. “如果红灯亮了,那就停;如果绿灯亮了,那就继续开。”

我们在编程时也可以使用条件动作。如果某种情况为真,我们执行某段代码。条件或情况必须以数字形式表达,结果是truefalse。这样的条件通常是数字之间比较的结果,比如“i是否等于 10”或“x 坐标是否小于宽度”。

条件代码通过if语句来处理,其语法如下:

if ( condition ) code ; 

条件可以是数字之间的比较,因此以下都是条件:

(x > 2)      (P < q+1)     (width == 640)     (width != height)

这里有一些特殊符号在使用。=符号表示赋值,因此要比较是否相等,必须使用另一个符号:Processing 使用==。要比较不相等,使用符号!=,表示“不等于”。

示例 A

这个草图使用if语句,每次调用draw()时增加一个整数变量count,并在count达到 100 时将背景颜色从红色变为绿色。

示例 B

之前的英语条件示例展示了另一种常见的使用方式:否则。一个例子是“如果下雨,我们就看电视;如果是晴天,我们就去滑雪。”这个例子也可以这样表达:“如果下雨,我们就看电视;否则我们就去滑雪,”意思是如果没有下雨,我们就去滑雪。在大多数编程语言中,这通常写作else部分,作为if语句的附加部分,语法如下:

if ( condition ) code ;
else code;

示例 B 使用else来实现与示例 A 相同的任务。

示例 C

第三个代码示例在每次调用draw()时交替使用红色和绿色,产生一个颜色闪烁效果。

草图 4:循环——绘制 20 个圆

程序员通常需要反复执行相同的代码,有时会有一些小的变化。一个绘制 50 个椭圆的程序可以写成五十次调用ellipse()函数,每次绘制一个椭圆。另一种方法是使用一个语句调用ellipse(),并在循环中执行 50 次。

程序中的循环是一个语句集合,它从第一个语句执行到最后一个语句,并且按相同的顺序重复执行。你必须指定一个条件,来决定循环何时退出。通常情况下,你知道循环应该执行多少次,比如绘制 50 个椭圆的例子。有时你不知道次数,但可以通过计算来得出,那么循环将执行N次,N取决于其他因素。在这两种情况下,计数循环在 Processing 中称为for循环,因为预留字for用于开始循环。例如:

for (i=0; i<10; i=i+1)  statementA ;

这个循环执行 10 次:当变量i=0时执行一次,当i=1时再执行一次,当i=2时再执行一次,依此类推直到i=9。当i为 10 时,条件(i<10)变为假,循环结束。因此,statementA执行了 10 次,每次对应i从 0 到 9 的值。

for循环有四个部分:

  1. i=0 初始化操作在循环的第一次执行时进行。

  2. i<10 只要循环条件为真,循环将继续执行。

  3. i=i+1 在每次迭代结束时,语句执行完后,递增操作会执行。

  4. statementA 这是反复执行的代码。

如果在开始时条件为假,则循环一次也不会执行。

执行的语句可以是复合语句,即一组由大括号括起来的语句。事实上,每当我提到语句时,它都可以指代复合语句。

示例 A

在 Processing 中,一个简单的循环可以绘制 20 个椭圆,起点为(20, 40),终点为(210, 40)。这些椭圆是圆形的,并且彼此相邻。draw()函数存在但不执行任何操作。

示例 B

我们可以通过使用复合语句来使每个圆的颜色发生变化。每次绘制一个圆时,将绿色值增加 10,从green = 10开始。红色和蓝色的值保持在最大值 255。循环中的代码需要设置填充颜色、绘制圆形,并在下一次迭代时调整填充颜色。

循环会执行 20 次,即i的值从 0 到 19(包括 19)。如果我们展开代码以展示执行的内容,它会如下所示:

(i=0);  fill (255, 10, 255);  ellipse (20, 40, 10, 10);  g = 20;
(i=1);  fill (255, 20, 255);  ellipse (30, 40, 10, 10);  g = 30;
(i=2);  fill (255, 30, 255);  ellipse (40, 40, 10, 10);  g = 40;
`--snip--`
(i=19);	 fill (255, 190, 255); ellipse (210, 40, 10, 10); g = 200;

草图 5:线条

绘制线条是图形绘制中的基本操作。在 Processing 中,线条实际上是线段,通过标识两个要连接的端点来指定。绘制线条的函数名为line(),它接受两个端点的坐标作为参数(共四个参数)。调用line (10,10, 20,20)将在窗口中绘制一条从坐标(10, 10)到(20, 20)的线。

示例 A

让我们来画一些便签纸。我们可以通过以下调用绘制一条水平线,这条线将横跨整个草图窗口的宽度,y表示垂直位置:

line (0, y, width, y);

窗口的宽度由变量width给出,窗口的高度由变量height给出。线的起点是(0, y),位于图像窗口的左侧,距离顶部y像素;线的终点是(width, y),位于窗口的右侧,且y值保持不变。

绘制线条的颜色可以通过调用stroke()并传入颜色参数来指定。例如,stroke (255,0,0) 表示将绘制红色线条。

示例 B

Processing 会通过内建变量mouseXmouseY 1 告诉你鼠标光标在窗口中的位置。每当按下鼠标按钮时,Processing 会调用一个名为mousePressed()的函数(如果它存在)。如果你希望使用鼠标,必须编写这个函数。当鼠标按钮释放时,Processing 会调用mouseReleased()函数 2。你也必须编写这个函数。mousePressed()mouseReleased()函数被称为回调函数,它们提供了一种非常简单的方式来访问按钮按下事件。此外,按下和释放操作相当于触摸屏设备上的触摸操作,因此该程序也可以在触摸屏设备上运行。

这个例子使用鼠标点击(按下和释放)来绘制线条。第一次鼠标点击定义了线条的起点(x0y0)3。第二次点击(当x1 < 0时)4 定义了线条的终点。第三次点击(当x1 >= 0时)5 清除终点并重新开始。

草图 6:数组——绘制多个圆形

变量可以保存一个值,比如一个数字。如果我们需要更多的值,可以使用更多的变量。例如,若要绘制两个圆形,我们可以有两组坐标变量,分别是x0y0x1y1,然后我们可以通过两次调用来绘制这两个圆形:

ellipse (x0, y0, 10, 10);
ellipse (x1, y1, 10, 10);

但是,如果我们希望每次点击鼠标时都绘制一个圆形,并且把圆形绘制在光标所在的屏幕位置呢?我们无法提前知道要绘制多少个圆形,因此也不知道要声明多少个变量。相反,我们可以使用 Processing 所称的数组来跟踪xy。数组是一个包含相同类型值的集合。声明数组的语法是

int [] x = new int[100];

这个声明定义了一个名为x的数组,它可以存储 100 个整数。int [] x表示“定义一个名为 x 的新数组”,new int[100]表示数组的大小,其中 100 可以被任何常量替代。上述声明也可以分两部分进行:

int [] x;
x = new int[100];

你通过索引访问数组中的值,索引是一个数字,用来指定你想要的值,索引从 0 开始:x[0]是数组中的第一个元素(值),索引为0x[1]是第二个元素,索引为1,以此类推,直到最后一个元素x[99]

示例草图使用了两个数组,一个用于x,一个用于y,并在鼠标点击(按下和释放)时绘制圆圈的坐标。最初在setup()中,xy数组的每个元素被赋值为-1 1。这称为哨兵值,它表示该索引位置没有定义圆圈。ncircles变量表示已经定义了多少个圆圈,即记录了多少次鼠标点击;它从 0 开始,并递增至最大圆圈数量(MAXCIRCLES,一个定义为 100 的常量)。当鼠标按钮释放时,系统调用mouseReleased()回调函数 3,保存当前鼠标坐标的值到xy数组的当前位点(ncircles),并将ncircles增加 1。如果ncircles等于MAXCIRCLES,它会被重置为 0 4,这意味着新的圆圈将覆盖最早绘制的圆圈。旧的圆圈自然会丢失。

draw()函数首先设置背景,然后在鼠标坐标处绘制一个圆圈。接着检查x数组的所有元素,如果元素i的值大于 0,就会使用以下调用在x[i], y[i]的位置绘制一个圆圈:

ellipse (x[i], y[i], 18, 18);

常量值MAXCIRCLES在声明时使用特殊的final属性进行定义:

final int MAXCIRCLES = 100;

MAXCIRCLES的值在程序中不能被更改,因为它是final。它可以(并且应该)用于定义两个数组的大小:

int x[] = new int[MAXCIRCLES];

使用常量定义数组的大小意味着,要增加允许的圆圈数量,只需要更改MAXCIRCLES的值。

草图 7:带有橡皮筋的线条

我们将再次使用鼠标绘制线条。一条线由一个起点和一个终点组成,每个点都有 x 和 y 坐标。我们之前在鼠标点击屏幕上的起点和终点时绘制了一条线,但它只绘制了条线。如果我们想能够像这样绘制多条线,该怎么办呢?

我们可以像之前一样,当鼠标按下时定义起点 3,释放按钮时定义终点 4。但现在我们可以将这些点存储在数组中,并在每次屏幕更新时绘制它们。数组x0保存线条的起始 x 坐标,y0保存对应的 y 坐标。数组x1y1将存储终点坐标。当鼠标按下时,我们保存起点(x0[n], y0[n]),当鼠标释放时,我们保存终点为x1[n]y1[n],并将n的值递增。由于数组的固定大小,该程序将允许我们绘制 256 条线。

当起始点被选择后,我们从该点画一条线到当前的鼠标坐标,以显示这条线应该如何呈现。这被称为橡皮带效果,因为线条在鼠标移动时看起来像是伸展和收缩。当鼠标按钮被释放时,我们确定最终坐标并画出最终的线条。

在每一帧中(默认是每秒 30 帧),我们通过调用 line (x0[i], y0[i], x1[i], y1[i]) 来绘制所有保存的线条,i0n-1。然后,如果鼠标按钮当前被按下(当 down 被设置为 true 时),我们将绘制橡皮带线。在 mousePressed 中设置 downtrue,在 mouseReleased 中将其设置为 false。如果 downtrue,则从最后选定的点到鼠标坐标之间绘制一条线:

line (x0[n], y0[n], mouseX, mouseY);

这实现了橡皮带效果。

作为一种新功能,草图实现了一个 擦除 特性。如果用户按下退格键,最近的线条将被删除。当系统检测到按键时,Processing 会调用一个用户定义的名为 keyPressed() 的函数。一个名为 key 的变量提供了被按下的键的值,因此在 keyPressed() 内,我们检查按下的键是否是退格键,如果是,则将变量 n(到目前为止的线条数)减 1。结果是,最后一条线不会被绘制,下一条线将覆盖被擦除的线条在坐标数组中的位置。

草图 8:随机圆圈

这个草图在屏幕上随机位置绘制圆圈,并使用随机颜色。随机性指的是不可预测性,它是一个复杂的概念。如果你尝试用铅笔画直线,那么不可能有两条完全相同的直线。总会有些微小的变化,导致每条线有所不同。画画时也一样,笔触不会完全一致。没有两个人类活动会完全相同,差异虽不可预测,但是显而易见的。

使用计算机时,随机数生成器会创建彼此间相对随机的数字。随机数可用于模拟游戏中的随机事件,比如骰子或扑克牌,用来做一些用户难以预测的事情,或模拟复杂的现实世界情境。例如,道路上车辆之间的间距以及窗户上雨滴的出现看似随机,因为我们并不了解所有涉及到的复杂因素。

Processing 中的随机数生成器被命名为 random。调用 random (100) 将生成一个介于 0 和 100 之间的实数,但不包括 100。调用 random (10, 20) 将返回一个介于 10 和 20 之间的实数,但小于 20。调用 random (0, width) 会生成一个介于 0 和窗口宽度之间的随机 x 坐标,而 random (0, height) 会生成一个介于 0 和窗口高度之间的随机 y 坐标。

像 Sketch 6 一样,这个草图将坐标存储在数组中,并通过调用 ellipse() 来绘制圆形,但不是在点击鼠标时绘制圆形,而是每秒自动创建一个新的圆形。为此,我们在 setup() 中通过调用 frameRate(1)draw() 的调用频率设置为 1。每次调用 draw() 时,我们使用 random() 生成一个新的 x 和 y 坐标,并将其保存在 xy 数组中:

x[ncircles] = (int)random(0, width); 
y[ncircles] = (int)random(0, height);

调用 random 时前面的 (int) 将结果(float 类型)转换为新类型 int。这叫做类型转换,我们将浮点值转换为整数,因为不能使用带小数点的值作为坐标。也可以使用调用函数 int() 来实现这一点:

x[ncircles] = int(random(0, width)); 
y[ncircles] = int(random(0, height));

草图 9:一个矩形

你可以通过绘制四条代表矩形边缘的线来绘制一个矩形,但 Processing 系统不会认为这是一种矩形;它无法知道这四条线是一个单独的对象。相反,Processing 提供了一个绘制矩形的函数,叫做 rect()。矩形会使用当前的填充颜色,就像绘制圆形时一样。

指定矩形的默认方式是 CORNER 模式,在这种模式下,你提供的前两个参数是矩形左上角的坐标,后面跟着矩形的宽度和高度(以像素为单位)。如果你指定 CENTER 模式,前两个参数是矩形中心的坐标。CORNERS 模式指定的是第一个角的坐标,然后是对角线另一角的坐标。你可以使用以下调用之一来更改模式:

rectMode(CORNER);
rectMode(CENTER);
rectMode(CORNERS);

在这个草图中,我们将使用 CORNERS 模式 1,正如在 setup 函数中指定的那样,并将矩形填充为一种紫色的色调:(200, 0, 160)。与之前的草图一样,当鼠标按钮被按下时,mousePressed() 函数将布尔值 flag 变量设置为 true 3,而 mouseReleased() 则清除该变量(将其设置为 false) 5。

全局变量 xy 代表矩形的第一个角,并初始化为 -1。当鼠标按钮被按下时,我们将 xy 设置为当前的 mouseXmouseY 值 4,并将 flag 变量设置为表示 x 已设置。然后,draw() 函数将绘制一个矩形,以 (x, y) 作为一个角,当前鼠标位置 (mouseX, mouseY) 作为另一个角 2。这样就实现了橡皮筋效果。

全局变量 x1y1 是矩形第二个角的坐标。当鼠标释放时,我们将 x1y1 的值设置为当前鼠标坐标 6,这样矩形就完成了。draw() 函数将使用 x1y1 作为对角线的另一角来绘制矩形,因为 flag 现在为假。

草图 10:三角形与运动

就像使用内置的rect()函数绘制矩形一样,三角形是通过内置的triangle()函数绘制的。三角形不能通过高度和宽度来绘制;它们的形状由三个角度决定。因此,triangle()函数有六个参数:三个顶点(角点)的 x, y 坐标。

这个草图使用鼠标绘制三角形。与前面绘制矩形和线条的草图一样,这个草图使用mouseReleased() 3 来确定何时选择一个点。经过三次点击后,三角形将使用这三个选择的点作为顶点绘制出来。

三角形绘制完成后,它开始向下移动,好像被轻轻推了一下。它继续向下移动,直到碰到草图窗口的底部边界,在那里它消失了。

我们通过在每次绘制三角形后,将一个小值 1(delta = 1)添加到三角形的 y 坐标来实现这个运动。这样,三角形会在窗口中依次绘制在较低的位置,直到它似乎越过了窗口的底边。事实上,三角形仍然存在于 Processing 系统中,尽管它无法被看到,其坐标仍在更新。

如果该程序的用户在三角形绘制后点击鼠标,三角形会消失,绘图过程重新开始。我们通过将所有顶点重新初始化为−1 4 来重新启动绘图过程,这表示它们还没有被定义。

以下代码行在draw()内部被注释掉:

2 // delta = delta + 1;

如果你删除行首的//,该行代码将会执行,三角形将会加速下落,就像受到某种力(例如重力)的作用一样。也删除mouseReleased()末尾附近的//,这样每次绘制新的三角形时,初始速度将重置为 1。

草图 11:显示文本

文本在几乎所有实用的计算程序中都是必不可少的,在许多生成艺术和网络艺术程序中也是如此。文本是人类沟通的主要方式,尽管我们常说“一张图胜过千言万语”,但实际上,几句精心挑选的话常常能把一幅本来难以理解的图像变成有价值的沟通工具。例如,想想图表轴上的标签。

我们在草图窗口中绘制文本的方式与绘制线条和椭圆的方式相同,使用一个简单的函数。首先,你需要知道的是,文本的绘制是从特定的(x, y)位置开始的,其中 xy 表示包围文本的框的左下角的坐标(不考虑下行字形)。像 yj 这样的字符会延伸到这个框的下方,因此它们的 y 值大于指定的值。

我们将通过调用text()函数来绘制文本 2:

text ("This is a string to be drawn", 100, 20);

在这种情况下,字符串左下角的坐标是(100, 20)。初始字体和大小是默认值,这些默认值依赖于系统。可以使用textSize(n)函数轻松指定大小,传递所需的字符大小以像素为单位(不是点数)。绘制文本的颜色是当前的填充颜色,而不是描边颜色。

文本的对齐可以通过调用textAlign()函数来指定。水平对齐可以是LEFTCENTERRIGHT,相对于在text()函数调用中指定的 x 和 y 坐标;默认值是LEFT。垂直对齐可以是TOPCENTERBOTTOMBASELINE,相对于在text()函数调用中指定的 x 和 y 坐标;默认值是BASELINEBOTTOM是定义任何字符最低y值的行,例如下降部分的底部。BASELINE定义了没有下降部分的典型字符的最低点。因此,调用

textAlign (CENTER, BOTTOM);

会使当前文本从左到右居中(指定的x值是字符串的中心),并使其对齐,以便指定的y值是字符串的底部。

示例 A 展示了如何以两种不同的大小显示文本。示例 B 显示了一条在每个 x 坐标上水平绘制并在每个 y 坐标上垂直绘制的线。它展示了文本相对于指定坐标的对齐情况。

示例 12:操作文本字符串

前面的示例实际上是对字符字符串的介绍,字符字符串是一种人与计算机交流的自然方式。字符串是字符的序列;单词、句子或段落也是如此。从高层次来看,字符串由按特定顺序排列的字符集合组成。它有第一个字符、第二个字符,以此类推,直到到达最后一个字符。这个序列中字符的数量就是字符串的长度。

字符串常量是用双引号括起来的字符序列,如:"To be or not to be"。我们可以使用字符串常量声明String类型的变量并为其赋值。例如,在 1 中,我们声明了两个字符串变量并为其赋值字符串常量:

String s1 = "To be or not to be"
String s2 = "that is the question."

字符串可以通过将其他字符串拼接在一起来构造。当对字符串应用+运算符时,意味着连接或附加,因此可以通过拼接这两个字符串来完成引号:

s1 = s1 + s2;

这使得s1变成了"To be or not to bethat is the question." 不幸的是,这样不太对,因为我们需要在两个字符串之间加上一个逗号和一个空格。这样会更好:

2 s1 = s1 + ", " + s2;

这个新字符串中的第一个字符“T”在索引位置 0,意味着它在字符串的第 0 位置。字符“o”在位置 1,以此类推。

子字符串是由索引指定的字符串中的字符序列。s1从索引 6 到 11 的子字符串是字符串"or not",可以在 Processing 中如下找到:

s1.substring (6,12)

这个字符串的长度是六个字符,length()函数返回这个长度:

s1.substring(6,12).length()

可以使用charAt()函数在特定位置找到字符。例如,s1.charAt(3)是“b”,s1.charAt(18)是“,”。

字符串不能使用标准的关系运算符进行比较(因为它们实际上是类实例,稍后会讨论)。相反,有用于比较的函数。比较s1s2可以通过以下方式实现:

if (s1.equals(s2)) ...;

这个草图展示了一些字符串操作及其结果,这些操作是使用第 11 个草图中讨论的text()函数绘制的。草图包括length() 3、charAt() 4 和substring() 5 的示例。

第二章:使用已有的图像

草图 13:加载并显示图像

图像在网络空间无处不在,即使是没有显式计算机技能的人,也知道图像格式的名称,或者至少知道文件后缀:GIF、JPEG、BMP、PNG 等等。这些字母组合象征着不同的方式来以计算机形式存储图像,每种格式都有其特定的优缺点,适用于不同的目的。GIF 图像是为早期互联网使用而开发的,它们支持透明颜色,并且能够存储动画。JPEG(或 JPG)格式被几乎所有数码相机使用,并且能够将图片压缩成相对较少的字节。

你应该认识到图像的重要性以及从这些文件格式中读取数据的复杂性。一个用来读取大多数 GIF 文件的程序可能需要超过一千行代码。Processing 提供了一个易于使用的功能来读取、显示和写入图像,这是它相较于其他编程语言的许多优势之一。

Processing 中有一个类型表示图像,就像整数用 int 类型表示一样,系统可以通过调用一个函数将图像文件读取到变量中。这个类型是 PImageProcessing 图像 的缩写),而函数是 loadImage()。为了加载图像,图像应该保存在与草图文件相同的文件夹中,或保存在名为 data 的子文件夹中。

示例 A

假设有一个名为 image.jpg 的图像文件,我们想读取该图像并将其显示在草图窗口中。首先要做的是声明一个 PImage 变量 im,并将图像放入其中。然后,在 setup() 函数内,我们将创建一个草图窗口(使用 size())并读取图像。以下语句读取图像并将其分配给变量 im

2 im = loadImage ("image.jpg");

现在,图像数据以某种内部形式存储在变量 im 中。

显示图像是在 draw() 函数中完成的,尽管在这个例子中也可以从 setup() 中完成。Processing 系统提供了一个名为 image() 的函数,该函数将在草图窗口的特定 (x, y) 位置绘制一个 PImage(指定图像的左上角位置)。以下调用会将图像绘制,使其左上角与窗口的左上角对齐:

3 image (im, 0, 0);

示例 B

这个程序与示例 A 相同,但它将图像绘制在位置 (150, 30) 1。现在图像在可用空间中更加整齐地显示。

草图 14:图像——理论与实践

图像在视觉艺术中被广泛使用,Processing 是为艺术家设计的,所以图像在程序中使用起来非常简单也不意外。不过,还是有一些基本的内容需要了解。

其中之一是,要在计算机上使用图像,必须将其数字化;也就是说,必须将其转化为数字。如果图像最初不是由计算机创建的,那么它必须经过扫描或拍照处理,每个原始图像上的位置都必须赋予一个数字,表示该位置的颜色。结果是一个二维的数字数组,每个数字表示特定位置的颜色。图像中的每个小区域被认为是颜色统一的,即使实际上并非如此,因此选取最突出的颜色来代表整个区域。这个颜色被存储在内部表示中的对应(x, y)位置,它被称为图像元素,简称为像素。所有这些像素的完整集合是对原始图像的近似。将图像绘制到屏幕上意味着将计算机屏幕上一部分的像素设置为与图像中的像素匹配。这正是image()函数在 Processing 中的作用。

图像通常被认为是N×M像素大小,其中N是行数,M是列数。这样的图像的总像素数是N×M

PImage数据类型为程序员提供了多种访问图像像素并操作它们的方式。可以使用“.”(点)符号来访问图像的属性。例如,对于一个名为myImagePImage变量,我们有以下属性:

myImage.width   // Width of the image, in pixels
myImage.height  // Height of the image, in pixels

我们常常希望创建一个与特定图像大小相同的图形区域,但setup()中的size()函数只能使用常量来设置窗口大小。为了解决这个问题,我们可以在setup()中添加surface.setResizable(true)。它允许我们在草图运行时通过调用surface.setSize()来调整图形区域的大小,该函数可以使用诸如myImage.width这样的非常量。

可以使用返回或设置基于(x, y)坐标的颜色的函数来访问单个像素的值:

myImage.get(x,y);                  // Returns the color of the pixel at column x and row y
myImage.set(x,y, color(255,0,0))   // Sets the pixel at (x,y) to red

如果我们仅仅调用get()set()而没有指定图像,Processing 会假设所引用的图像就是当前在草图窗口中显示的图像。

示例 A

这个草图读取一个图像文件,并检查是否成功读取 1;如果没有,程序会通过调用名为exit()的函数来结束。loadImage()函数如果无法读取图像,会返回一个名为null的特殊值,作为图像文件未找到的指示符。例如,图像文件未找到。如果图像正常,程序会使用图像的widthheight属性 2,将草图窗口的大小设置为与图像相同。当setup()函数显示图像时,它会填满整个窗口。

示例 B

第二个草图如果无法打开图像文件,不会调用exit()。相反,它会在图形窗口中显示一个错误信息 1。

草图 15:图像操作 I——纵横比

在之前的草图中,我们使用图像的大小来定义草图窗口的大小。也可以改变图像的大小,使其适应已有的空间。resize() 函数是 PImage 数据类型的一部分,可以用来指定图像的新大小。它不会创建副本,而是直接调整 PImage 本身的大小。以下是对该函数的示例调用:

1 img.resize (w, h);

这个调用会导致存储在 img 变量中的图像被扩展或收缩,使其宽度为 w 像素,高度为 h 像素。

示例 A

在第一个示例中,我们将图像缩放到窗口的大小,即 240×480。注意,图像已经发生了失真,从两侧压缩并被拉伸得更高。还要注意,所有工作都在 setup() 中完成,draw() 中没有代码。

任何图像都有一个长宽比,这是图像宽度和高度之间的关系。它通常表示为 w:h。例如,16:9 就是一个图像的长宽比,表示每 9 个像素的 y 方向(高度)对应 16 个像素的 x 方向(宽度)。长宽比有时也会以分数的形式表示,将高度除以宽度,这样 16:9 的比率就可以写成 1.8。示例 A 中的图像看起来奇怪的原因是长宽比被改变了,强行将图像适配到了一个任意的矩形框中。

示例 B

这个草图将图像绘制到一个窗口中,并进行缩放,以确保长宽比保持不变。首先要做的是计算原始图像的长宽比:

1 aspect = (float)w/(float)h;

我们在这里使用 float 变量,因为长宽比将是一个分数。当我们将图像放入固定的空间时,图像的最大维度(高度或宽度)决定了图像在窗口中的整体大小。我们将调整图像的最大边以精确适应窗口的对应边,无论这意味着将图像放大还是缩小。图像的另一维度将保持与这个新缩放值成比例。因此,如果图像比宽度高,我们将把图像的高度映射到窗口的高度:

h = height;

宽度将与原始长宽比成比例(转换为整数):

w = (int)(h*aspect);

现在可以调整图像大小进行显示:

img.resize (w, h);

草图 16:图像操作 II—裁剪

裁剪图像是指去除图像的一些外部部分。你可以更一般地认为它是选择一个任意的矩形子图像。我们裁剪图像是为了使图像更具吸引力或去除不必要的部分。在画图或 Photoshop 中,我们使用鼠标,首先点击裁剪图像的左上角,然后拖动鼠标到新的右下角,松开按钮。所有选定矩形框外的部分都会被丢弃。这个草图会裁剪图像,并可以选择将裁剪后的区域扩展以填充整个图像窗口。

首先读取图像并将草图窗口的大小调整为适应图像。draw()函数使用以下代码 2 将图像(命名为img)居中显示在窗口中:

image (img, (width-img.width)/2, (height-img.height)/2);

如果图像尚未被裁剪,则width-img.width为 0,调用将是image(img, 0, 0)。否则,图像将小于窗口,并且(width-img.width)/2将是需要在左边放置的像素数,以便将裁剪后的图像居中。高度也是如此,将图像放置在窗口的中心。

当鼠标按钮被按下时(mousePressed()),裁剪过程开始,使用鼠标光标所在的位置,该位置被保存为x0y0。然后从这个位置绘制一个矩形到当前的鼠标坐标,实现一个橡皮筋矩形 3。

当鼠标按钮释放时,会评估鼠标坐标,以确保当前的mouseXmouseY表示裁剪框的右下角;换句话说,确保mouseX比鼠标按钮按下时的值大,mouseY也是如此。如果不是,则交换x0y0的值与mouseXmouseY的值。然后我们使用get()函数创建一个裁剪后的图像,使用左上角和右下角的坐标 4:

sub = get(x0, y0, (mouseX-x0), (mouseY-y0));

get()函数返回由坐标对、宽度和高度指定的图像的矩形区域。在前面的调用中,(x0, y0)是左上角的坐标,宽度是mouseX值与左上角的x值之间的距离,高度是mouseY与左上角的y值之间的距离。在这种情况下,get()使用的是显示在草图窗口中的图像作为原始图像。

get()函数返回的子图像成为当前需要在draw()中显示的图像(变量img),并居中显示在窗口 5 中。

这个草图中的一个新想法是测试按下了哪个鼠标按钮。在mouseReleased()函数中,以下语句用来测试右键鼠标按钮:

if (mouseButton == RIGHT) sub.resize (width, height);

如果释放的是该按钮,子图像会被重新缩放以适应窗口。

在第 1 步,我们将图形窗口的大小调整为与图像大小相同,正如我们之前所做的那样。

草图 17:图像操作 III——放大镜

一些电脑有一个由鼠标控制的“放大镜”对象,显示屏幕一部分的放大视图。它帮助有轻度视力障碍的人看得更清楚,同时也让每个人都能更好地查看菜单和其他基于屏幕的对象。

放大是通过增加原始图像中每个像素的大小来实现的。如果原始图像中的每个像素变成四个像素(以正方形排列),那么新图像的大小将是原始图像的两倍,从而呈现出放大效果,如图 17-1 所示。图像的细节不会比原始图像更多,只是看起来更容易看清楚。

f17001

图 17-1:放大图像

实现放大镜是使用 Processing 提供的函数来完成的。首先,我们显示目标图像,并使用之前讨论的技巧在草图窗口中选择一个矩形区域进行放大。按下鼠标按钮时,用户从鼠标坐标开始选择一个 50×50 大小的正方形 1。按下和释放按钮时会调用 Processing 函数mousePressed()mouseReleased() 3,我们使用这些函数来设置一个名为mag的标志变量。如果mag被设置,我们使用get()函数将选定的原始图像部分复制到另一个名为subPImage中。然后,通过resize()函数将复制的图像调整为 100×100 像素 2:

sub.resize (100,100);

将一张 50×50 的图像调整为 100×100,实际上是将其大小翻倍。现在,调整大小后的图像将大致在原来被复制的地方显示。新图像比原始图像大,因此新的位置是近似的,一些原图中的像素将被新图像覆盖。

草图 18:旋转

旋转某物时,我们始终需要指定旋转轴,在二维情况下就是一个和一个角度。旋转是通过调用rotate()函数来指定的,

rotate(angle);

其中angle以弧度为单位指定。一个圆包含 2π弧度,也包含 360 度,所以从度数转换为弧度的方法是将度数乘以 3.14159/180.0,或者使用 Processing 的radians(x)函数。旋转将围绕窗口坐标系的原点(0, 0)进行,默认情况下原点位于窗口的左上角,旋转将是围绕这个点顺时针进行的。

当指定旋转时,从该点开始绘制的所有物体都会被旋转。再次调用rotate()函数将会进行进一步的旋转。旋转本身无法直接关闭,但通过调用rotate(-angle)可以撤销rotate(angle)的效果。

示例 A

第一个示例绘制了一个表示旋转的图形。先绘制一条水平线并标记为 1,接着是旋转了 10 度的线 2,再旋转了 20 度的线 3。为了避免文本被旋转,旋转会在绘制文本标签之前被“撤销”(以负角度进行旋转)。

示例 B

从原点画出一条线,线的末端有一个小球。线从 0 度旋转到 90 度,按小步长旋转,每次 draw() 被调用时都会显示每一步的结果,因为线和小球都会在其中被绘制。当线顺时针旋转时,每次调用 draw() 时,角度会增加 0.01 弧度 1。当线变为垂直时,进一步旋转会使其超出视野,之后每帧的角度变化(变量 d)会改为 -d 2。现在,线开始旋转回原始位置,在 0 度时,d 的值再次变为正数。这个看起来像秋千摆动的物体在 90 度和 0 度之间来回摆动。

每次调用 draw() 时,旋转角度都会重置为 0。

草图 19:围绕任意点旋转—平移

能够旋转物体是至关重要的,但仅仅能围绕屏幕的左上角旋转物体是很不方便的。通常我们希望围绕物体的中心旋转,但这需要了解物体的具体情况。物体在图形中可能是复杂的;一个物体可能只是一个圆或方块,或者它可能是一个建筑物或一辆车。我们不能期望 Processing 知道一个物体是什么,或者它的中心在哪里。然而,Processing 使得我们可以使用 translate() 函数将旋转中心移动到我们选择的任何坐标。

translate() 接受 x 和 y 坐标,并将原点移动到该位置,用于所有未来的绘制。以下示例将原点移动到窗口中的 (100, 200) 位置,现在这个位置变成了坐标 (0, 0):

translate (100, 200);

在数学术语中,translate 这个词意味着重新定位,因此平移涉及到改变物体的位置。如果我们将原点平移到 (50, 50),然后在 (0, 0) 画一个圆,这个圆将出现在屏幕的窗口坐标 (50, 50) 处。进一步的圆将相对于窗口坐标 (50, 50) 被绘制。

因为旋转总是以 (0, 0) 作为轴心,这意味着我们可以将轴心设置为我们喜欢的任何坐标,并围绕任何点旋转物体。

示例 A

作为一个基本示例,我们将使用 ellipse() 函数 1 在 (0, 0) 画一个圆,然后调用 translate() 将原点移到 (50, 50)。第二次调用与第一次完全相同的 ellipse() 会在屏幕坐标 (50, 50) 画出圆。进一步的 ellipse() 调用,在 (30, 40) 画一个圆,最终会在屏幕坐标 (80, 90) 画出圆;也就是说,(30, 40) 是相对于新原点 (50, 50) 的位置。

示例 B

在这个草图中,我们将一条线绕其中心旋转。过程是:translate() 到线的中心,这里是(150,100)1;按当前角度旋转 2;然后绘制这条线。线的坐标必须反映原点是线的中心而非一端的事实。由于线的中心在(150,100),因此线应当从 x 方向的 −50 绘制到 +50,长度为 100 像素。起始点的平移坐标将是(150 − 50,200 − 100 − 0),即(100,100)。端点的坐标将在 x 方向上再向前 100 像素,即(200,100)。在中点(原点)处绘制一个小圆圈,以便能够看到它。

每次调用 draw() 时,旋转角度都会增加 3。由于每次调用 draw() 时都会绘制这条线,图像就会呈现出缓慢旋转的效果。

Sketch 20: 旋转图像

旋转和平移不仅适用于简单的线条和圆形,也适用于复杂的对象。特别是,我们可以绕任意点按任意角度旋转图像。

在确定如何放置图像以确保其完全位于屏幕上时,可能会遇到问题。图像是矩形的,旋转它们会增加它们的宽度或高度。如果我们没有将图像正确地放置在窗口中,图像的一个或多个角落可能会旋转到窗口的边界之外,如图 20-1 所示。

g20001a

图 20-1: 图像旋转出窗口边界

上方的两幅图像展示了将图像旋转的结果,图像原本显示在窗口的左上角。旋转 45 度会使图像的一半超出屏幕。下方的两幅图像展示了当图像围绕其中心旋转时,若窗口大小不足会发生什么:图像的角落被裁剪掉。

这个草图展示了一个持续旋转的图像。图像被读取后,窗口大小设置为图像每个维度的两倍1。draw() 函数将原点平移到图像的中心,然后旋转图像 angle 角度并显示,从而使图像围绕其自身的中心旋转 2。然后,angle 的值会增加一个微小的量,以便下次调用 draw() 时使用 3。图像出现在窗口的中心,并且看起来在旋转。

Sketch 21: 获取像素值

在开发 Sketch 14 时,我们讨论了如何使用 get() 函数从 PImage 中获取像素值。我们可以按如下方式获取名为 imPImage 中坐标为(xy)的像素的颜色值:

color c;
PImage im;
c = im.get (x, y);

当前显示在草图窗口中的图片处于优先位置,因为它可以在不使用变量的情况下访问。通过调用 get(),我们可以直接获取屏幕上的像素值:

c = get (x, y);

因此,我们可以通过以下方式获取当前鼠标位置处像素的颜色:

c = get (mouseX, mouseY);

该草图加载一张图像,并允许用户点击任何像素查看其颜色,颜色将以彩条的形式显示在屏幕右侧,并以文本形式显示 RGB 值。首先,草图加载一张图像并调整窗口大小以适应它,右侧有额外区域。在draw()中,它用背景色(200,200,200)1 显示图像;当按下鼠标按钮时,它将mouseXmouseY坐标处的像素值(颜色)赋值给颜色变量c3;然后,它将在图像右侧显示颜色,并在屏幕的右上角以文本形式显示 RGB 值 2。每当点击鼠标按钮时,显示的颜色值将发生变化。

草图 22:设置和更改像素的值

图像中的像素值,包括绘图区域,可以使用set()函数进行更改。我们使用坐标指定像素位置,并确定要在该点绘制的颜色。例如,

set (i,j, color(255, 255, 0));

这将把图形区域中位置(ij)的像素设置为黄色,或 RGB(255,255,0)。如果坐标超出了窗口范围,像素将被绘制,但不可见。

我们可以使用background()函数为窗口中的所有像素设置颜色:

background (255, 100, 40) 

该调用将草图窗口填充为橙色。

示例 A

如果不使用background()函数而要设置窗口中的所有像素,需要使用一个循环实际上是两个嵌套的循环。第一个循环检查水平方向上的所有像素;也就是说,检查指定行中的所有像素。第二个循环查看i的所有可能值,也就是所有行。第一个循环嵌套在第二个循环中,以便修改所有行中的所有像素:

1 for (i=0; i<width; i++)
  for (j=0; j<height; j++)
    set(i,j, color(255, 100, 40));

这将把草图窗口中的所有像素设置为橙色。

示例 B

在图像显示在草图窗口之前,可以修改图像的像素值。我们不仅可以替换颜色,还可以对现有像素值进行更细微的变化,得到与原有值不同的变化。例如,该示例首先加载并显示图像。当程序检测到通过mousePressed()按下按钮时,它设置标志grey为 2,这表示要按像前一个示例中那样逐个像素修改屏幕上的图像。在这种情况下,我们用每个像素的亮度值 1 替换屏幕上每个像素的 RGB 值,结果是显示没有颜色的灰色图像。当鼠标按钮释放时(mouseReleased()),程序清除该标志(将其设置为false),图像将重新显示为彩色 3。

草图 23:更改像素的值—阈值处理

对图像进行阈值处理会将每个像素的颜色值改为黑色或白色,具体取决于原始的颜色或亮度。阈值处理会生成一张二值图像:每个像素可以被视为是开启还是关闭。为什么要这样做?有些图像的内容本质上是二值的:例如,一张文本页的扫描图像上有黑色字符和白色背景。在其他情况下,这是一种简化图像的方式,以便我们可以进行其他操作,比如检测边缘或人脸。例如,对红细胞图像进行阈值处理可能有助于对其进行计数。

阈值处理图像是一个两步过程。首先,我们确定一个阈值——这个值保留了图像的必要特征。我们通常通过检查图像中的所有像素并使用某些统计公式计算出一个值来实现这一点。阈值值是一个介于 0 到 255 之间的数字;所有小于阈值的像素亮度值将被设置为黑色 (0),而大于阈值的则会被设置为白色 (255)。第二步是查看所有像素并实际应用该阈值。

为了先解决第二步,应用阈值其实很简单:只需查看每个像素并判断它是否小于或大于 threshold。我们将像素值赋给变量 g,提取亮度,然后进行测试:

2 if (g<threshold) g = black;
  else g = white;

black 的值是颜色 (0, 0, 0),而 white 的值是 (255, 255, 255)。

在这个示例中,我们将手动确定阈值,使用鼠标的位置。这是鼠标的水平位置,表示为窗口宽度的百分比:

mouseX/width

如果我们将这个分数乘以 255,就会得到一个介于 0 到 255 之间的值,这个值与鼠标的位置远离窗口左侧的程度成正比。我们将使用这个值作为阈值。当鼠标在窗口的左侧时,阈值会很小,大部分图像会是白色;当鼠标在右侧时,阈值会很大,图像大部分会是黑色。

如果 draw() 函数计算并应用了阈值,它将变得动态,我们可以在鼠标移动时观察图像的变化。

示例 24:用户自定义函数

到目前为止,我们一直在使用 Processing 系统提供的绘图函数:ellipse()line()mousePressed() 等等。我们还没有深入分析函数的概念,部分原因是函数的作用看起来比较明显。然而,如果我们想要创建自己的函数,有一些内容是我们需要理解的。

函数是给一段代码集合起的名字。当函数的名字在语句中被使用时,称该函数被调用,函数内部的代码将被执行。这意味着,函数可以从许多不同的地方执行,而无需重复编写代码,只需调用它即可。

函数可以返回一个值:我们以前使用过这样的函数。例如,color()get() 返回颜色和像素值。没有返回值的函数被称为返回 void,这也是 setup()draw() 前面有 void 关键字的原因。它们是没有返回值的函数。

要创建一个新函数,我们必须遵循与 setup()draw() 相同的语法:我们先写返回类型,再写函数名、括号,然后是花括号中的函数体。例如:

void newFunctionA ()            int newFunctionB ()
{                               {
  `code                            code`
                                  return `value`;
}                               }

上面,newFunctionA() 不返回值,它是通过函数调用 newFunctionA(); 来调用的。newFunctionB() 返回一个整数值,必须有一个 return 语句来指示要返回的值。这种类型的函数像是表达式的一部分被调用:

a = newFunctionB();
x = newFunctionB()*2 + 1;

一个函数可以有多个 return 语句,但只有一个会被执行,因为一旦函数返回,函数中的其他代码就无法再执行。

函数可以有参数或实参,即在调用函数时传递给函数的值。当调用函数 color() 时,我们在括号中列出三个值:红色、绿色和蓝色。这些变量专门传递给函数使用,并且它们的值可用于在函数内部进行计算。

让我们创建一个函数,计算两点之间的距离,(x0, y0) 和 (x1, y1):

float distance (int x0, int y0, int x1, int y1)

传递给函数的参数分别命名为 x0y0x1y1,并且它们有类型,在本例中为整数类型(int)。这些参数用于计算两点 (x0, y0) 和 (x1, y1) 之间的距离,计算公式如下:

C02eq001

这个草图使用两次鼠标点击来确定两个点:(x0, y0) 2 和 (x1, y1) 3。它在每个点上绘制一个标记,并在窗口底部显示它们之间的距离作为文本信息 1。

草图 25:编程风格的元素

程序中的风格指的是代码的一些方面,这些方面通常不会影响程序的执行,但会影响其他人在阅读、修改或修复代码时的体验。

例如,有一种方法可以在程序中插入人类可读的文本供其他程序员阅读。任何在一对斜杠后面的文本被称为注释,Processing 会忽略它们,像 /**/ 之间的文本也会被忽略,它们标示了可以跨越多行的注释。程序应在代码中嵌入相关的注释,以便让任何查看代码的人理解发生了什么。注释应该清晰、提供解释,而不是简单地重复代码本身。程序中的注释性质是我们所说的编程风格的一部分。

风格的另一个方面是使用缩进来传达结构。没有一种单一的正确缩进方式,但本书中的草图所示标准有一些一致的特征。例如,用于包围代码块的“{”和“}”字符始终垂直对齐,以便容易识别代码块。唯一的例外是它们在同一行时:

if (x0 < 0) 
{
  x0 = mouseX; 
  y0 = mouseY;
}

在其他书籍中,你可能会看到另一种风格:

if (x0 < 0)  {
  x0 = mouseX; 
  y0 = mouseY;
}

括号的位置对编程语言的编译器来说并不重要,但程序员应该保持一致性。

变量应该有有意义的名称。上面x0y0代表 x 和 y 坐标,因此这些名称是合适的。一个名为pixelCount的变量应该包含像素的计数。给变量起个好名字是很容易的,而且这么做不会影响代码的执行速度或占用的内存量。

数字常量应该像变量一样命名,以便从名称中推断出常量的用途。一个完美的例子是PI而不是 3.1415。一个程序应该尽量避免使用数字常量,而应该使用名称。考虑以下代码:

r = d*0.01745;

数字 0.01745 对大多数人来说没有意义。现在考虑以下内容:

r = d *2*PI/360.0;

这更好。两倍的 PI/360 是弧度和角度之间的转换。最好的做法是:

radians = degrees * degrees_to_radians;

其中degrees_to_radians等于0.01745。现在任何阅读代码的人都能轻松看出发生了什么。

这个草图中的代码与前一个草图做的是一样的,但它展示了更好的风格。不过,请注意,它占用了更多的屏幕空间——这是典型的情况,也正是这些规则并非总是被遵循的原因,即便在本书中(因为文本需要适应一页纸)。

草图 26:复制图像—更多的函数

这个草图的目的是给你一些关于如何将代码组织成函数的想法。这个程序将读取一张图像,创建其副本,并增加副本的亮度。当鼠标按钮按下时,亮度增强的版本将被显示出来。

第一个函数被命名为brighten()。它接收一个图像(命名为img)和一个整数值(命名为val)作为参数。它的作用是通过指定的数量增加图像的亮度值。它通过嵌套循环逐个提取每个像素的 HSB 值,将val值加到亮度部分,并将像素保存回图像中。这是关键代码:

// Extract the HSB values from the pixel at (i,j)
c = img.get(i,j);
// Add val to the brightness and save again.
img.set (i,j, color(hue(c), saturation(c), brightness(c)+val));

我们将在draw()中使用一个新特性。Processing 提供了一个名为mousePressed的变量,若鼠标按钮被按下,它的值为true,否则为false,在非常简单的情况下,可以用它替代mousePressed()回调函数。在这种情况下,当鼠标按钮被按下时,我们显示亮度增强后的图像,否则显示原始图像。

if (mousePressed) image (img2, 0, 0);
  else image (img1, 0, 0);

这个草图中的第二个函数创建了原始图像的副本。我们定义复制函数如下:

PImage duplicate (PImage from)

根据这个函数的定义,函数接受一张图像作为参数并返回一张图像。事实上,它返回的是一张新的图像,作为传入图像的副本。Processing 提供的创建新图像的函数名为createImage() 4,它的形式如下:

createImage (width, height, RGB);

宽度和高度应该不言自明;常量RGB指定了图像的形式,在这里是 RGB 颜色。CreateImage返回的图像是未初始化的,像素值未知,因此,在创建一张与传入图像相同大小的图像后,我们的duplicate()函数使用标准的嵌套循环将新图像中的每个像素设置为原始图像中对应像素的值。

第三章:2D 图形与动画

草图 27:保存图像并调整透明度

我们将编写一个草图,让用户选择图像中的一个颜色使其变为透明,然后将图像保存为 GIF 格式。我们可以将任何PImage保存为文件,就像大多数图像文件可以被读入PImage一样。如果img是一个PImage变量,我们可以通过以下函数调用将其保存为文件:

img.save ("image.jpg");

参数是要创建的文件名。在上面的情况下,它将创建一个名为image.jpg的文件,并将PImage的像素以 JPEG 格式保存。文件格式由文件名的最后三个字母方便地确定:.jpg表示 JPEG 文件,.gif表示 GIF 文件,.png表示 PNG 文件,等等。如果没有给定PImage变量,Processing 将保存出现在草图窗口中的图像。

对于这个草图,第一步是读取并显示图像。接下来,我们将鼠标放置在一个我们希望透明的像素上,然后点击按钮。最后,我们将图像保存为支持透明的格式(GIF)。

在草图 2 中,我提到了透明颜色。我们可以设置第四个颜色分量,称为 alpha,它的值在 0(完全透明)和 255(完全不透明)之间,只要PImage颜色格式允许透明;支持透明的格式是ARGB。在这个草图中,当图像被读取时,我们像之前的草图那样制作一个副本,但使用ARGB作为颜色格式。当我们点击鼠标按钮时,程序查看光标坐标处的像素,并将透明度值 0 添加到颜色坐标中。然后,PImage中的颜色会更新为新的透明度值。

我们从文件中读取的原始图像是一个名为img1的变量;包括 alpha 值的副本是img2。Processing 使用以下语句制作图像的副本,正如我们在步骤 2 中所做的那样:

img2 = createImage (img1.width, img1.height, ARGB);

这将创建一个正确大小的空图像,现在我们必须将所有像素从img1复制到img2。当我们这样做时,img2中的像素会带有 alpha 组件,因为它是在createImage()调用中指定的。当鼠标点击指定了背景颜色时,所有该颜色的像素都会被赋予 alpha 值 0 1。然后,img2会保存为一个名为out.gif的文件。

程序以调用exit()结束,因为否则它会不断地保存同一个文件。

为什么为图像设置透明背景很重要?计算机游戏!

草图 28:在窗口中反弹一个对象

这个草图展示了检查一个对象是否在草图窗口内的一个好方法(尽管只有当对象是圆形时,它才完全准确)。这里的对象是一个圆形,或者如果你喜欢,可以是一个球。程序移动这个球,当球到达窗口边界(“墙”)时,它会反弹或改变方向。

一个简单的测试可以确定球是否超过了边界。例如,在右边界的情况,测试条件是x + radius > width 2,其中x是球的中心位置,radius是球的半径,width是窗口的宽度。如果球移动得足够慢,我们可以在球通过这个测试时通过将dx(球每帧水平移动的距离)改为-dx来简单地反转运动方向。然而,这种方法并不完全准确,当球以高速移动时情况会变得更糟。为什么?因为球会在程序判断它是否到达边界之前越过边界。请参考图 28-1 中的情况。

f28001

图 28-1:一个快速移动的球可能会在你告诉它反弹之前越过边界。

如果选择的dx值使得球每帧移动多个直径,它可能在一帧内越过墙壁的左侧,而在下一帧越过墙壁的右侧。在这个过程中,球必须与墙壁发生碰撞。此时,应该找出球越过墙壁的距离,并将球放置在墙壁左侧的相应距离位置,以模拟反弹。我们计算这个距离为delta(Δ),对于一个圆形,它等于(x + radius) - width 1。根据这个距离,球的新反弹后的 x 位置是width - delta - radius 3,如图 28-1 底部所示。

在窗口的左侧,当x < radius 4 时,我们知道球已经越过了边界。在这种情况下,我们通过将x设置为(2 * radius) - x 5 来重新定位球,并反转球的运动方向。

垂直(y)方向是对称的 6。

草图 29:基本精灵图形

我们可以将之前的两个草图结合起来,展示程序员如何在计算机游戏中移动精灵。精灵是表示游戏中物体的低分辨率图形。精灵通常是原始形状或导入的图像。如果是后者,精灵图像必须具有透明颜色,这样我们才能看到精灵背后的背景;否则,精灵看起来就像是一个实心颜色的矩形,里面有一张图像。

这个草图使用了草图 27 中的火箭作为精灵,并使用草图 28 中的代码来在窗口中移动它。火箭将在星空背景图像上移动,以完成类似游戏的外观。

判断火箭是否到达边界的测试与圆形示例不同,因为精灵是从左上角绘制的矩形图像,左右和上下边界之间的距离不同。左边缘的测试几乎与之前相同,但由于 x 坐标位于精灵的左侧,而不是在其中心,因此缺少半径的偏移量 2:

if (px < 0) // left side
{
  px = -px;
  dx = -dx;         // Reverse x-direction
}

右侧的测试有所不同,因为精灵的整个宽度也位于坐标px的右侧 1:

delta = (px+sprite.width) - width;
if (delta > 0) // right side
{
  px = width-delta-sprite.width;
  dx = -dx;
}

所以px+sprite.width是精灵右侧的坐标。

对于 y 坐标的检查是对称的 3。

草图 30:检测精灵与精灵的碰撞

判断一个精灵是否仍在窗口内是相对简单的,因为窗口的大小保持固定且窗口不会移动。但是,如果有多个精灵同时移动呢?我们如何确定两个精灵在同时移动的情况下是否发生了碰撞?圆形物体的情况是最简单的,也是一个通用的解决方案,因此本草图将处理任意数量的圆形物体(球),这些球会在边界和彼此之间反弹。

每个球的坐标将存储在xpos[]ypos[]数组中 1。绘制对象i是简单的 2:

ellipse (xpos[i], ypos[i], 10, 10);

如果两个物体的距离小于两倍半径(在此情况下为 10 像素),它们就会发生碰撞。这是草图中的步骤:

  1. 为每个nballs对象定义位置和速度(dxdy)。

  2. 每一步(帧)由调用draw()来定义。首先,在每个位置xpos[i]ypos[i]绘制一个圆 2。

  3. 改变位置:xpos[i] = xpos[i] + dx[i]y同理 3。

  4. 检查是否与边界发生碰撞(反弹),如果有碰撞,则实现碰撞反应。反弹?爆炸? 4。

    对于每个球,检查它与其他每个球之间的距离。如果距离小于两倍半径,则改变两个球的方向(将碰撞实现为反弹) 5。

就这样。bounce()函数 6 与之前的有所不同,但它基本上完成了相同的事情。distance()函数计算两个球之间的欧几里得距离,就像你在草图 24 中看到的那样。如果两个球在反弹后重叠,它们可能会粘在一起,直到碰到另一个球。

f30001

图 30-1:矩形物体的包围圆

草图 31:动画—生成电视噪声

我们之前在草图 8 和 30 中使用了随机数。随机数在游戏、模拟和其他软件中有几个重要功能:

  • 大自然使用不可预测的形式和形状。在二维网格中布置森林中的树木,显然是有人为种植的迹象。这在自然界中是不会发生的。相反,森林中的树木彼此之间有一个平均距离,并且看起来是随机分布的。

  • 智能生物的行为并不完全可预测。在高速公路上,如果所有的汽车行为相同,看起来会非常奇怪。汽车之间有随机的距离,随机的速度,以及可能范围内的随机行为。

  • 玩扑克或掷骰子时,牌和骰子应该显示随机值,否则游戏就没有趣味了。

这个草图绘制了一个电视机,看起来好像调到了一频道没有信号。屏幕上看到的图像曾被称为雪花,实际上是由随机电压信号产生的像素,这些信号来自太空和各种本地电子电气设备。我们无法预测电视在任何特定时刻会收到什么信号,因此我们绘制了一组 2D 随机灰色像素值。这些值每次屏幕更新时都会变化,给人一种随机运动的印象,快速闪烁的点在屏幕上,但没有组织的图像。

首先,我们显示一个电视机背景图像 1,然后在每次调用draw()时,将屏幕部分的像素设置为随机的黑白值 3:

if (random(3)<1) set (i, j, BLACK);
  else set (i, j, WHITE);

为了让频道显示得像是调节不当,我们可以在静态画面上淡淡地显示一张图像,将图像的 alpha 值设置为较低的值,或许是 30 左右。图像后面的静态将可见。tint()函数会改变从此之后绘制的内容的颜色和透明度,所以我们可以用它来改变频道图像的透明度,代码如下:

tint (255, 255, 255, 127);
image (back, 49, 49);

tint()的参数是颜色坐标,前三个是 RGB 值,第四个是透明度(alpha)。在前面的例子中,颜色是白色(没有实际的色调),但透明度是 127,即半透明。

在此草图的代码中,tint和电视图像被注释掉。要查看图像,请去掉这两行代码前的注释符号 2。

草图 32:帧动画

动画涉及在屏幕上以一定的速率显示一系列静态图像,人的视觉系统通过插值图像中位置的变化,感知到运动。这是一种错觉,就像任何电影都是一种错觉。之前的草图通过代码生成随机电视图像,以非常基础的方式创建了动画,呈现了图像的错觉。大多数动画需要由艺术家创建一组图像序列,然后按顺序显示它们。

要让 Processing 草图显示动画,程序必须读取要显示的图像(帧),然后将它们依次显示。图像帧可以存储在一个PImage类型的数组中,每帧一个元素。

这个草图中的两个示例使用了一个表示人类步态的图像序列;这 11 张图像组成了一个完整的步伐周期,重复这些图像就能让人物看起来像是在走路。

示例 A

十一张图像,从a000.bmpa010.bmp,代表动画。程序将这些图像读取到frames数组的连续元素中 1。每次调用draw()函数时,它会显示下一个图像,依次增加索引变量n从 0 到 10,然后再次减小到 0,循环往复 2。

示例 B

在示例 A 中,我们需要提前知道动画中包含多少个图像。在示例 B 中,我们只要求文件名以a000.bmp开头,并且连续图像的编号递增。当程序无法读取图像文件时(通过loadImage()返回null来指示),程序假设所有图像已加载完毕 1。程序会在读取图像时进行计数,并像之前一样显示它们。

加载图像的循环中有一个break2 语句,当检测到null时退出循环。

草图 33:洪水填充——填充复杂形状

在 Processing 中,绘制一个填充特定颜色的矩形或椭圆是很简单的。你只需使用fill()函数指定填充颜色,然后绘制形状。然而,没有用于填充任意形状或区域的函数,因此我们自己来实现一个。这不仅可以展示如何在一般情况下进行填充,还有其他优点。

这个草图读取了一张具有白色背景的图像,图像中包含用黑色(当然也可以用其他颜色)勾画的区域。这些区域不需要是规则的多边形,但它们应该是封闭的,即有内部和外部,且边缘没有间隙。当用户点击一个像素时,该像素周围的区域会被填充为随机颜色。

被点击的像素有一个颜色,即背景颜色(草图中的bgcolor)。系统将选择一个随机颜色作为填充颜色(变量fillColor)。目标是将所有当前具有背景颜色值的像素设置为填充颜色。第一步是将选定的像素设置为填充颜色,然后重复设置所有相邻像素,直到没有更多候选像素。

在第一个像素被改变后,所有与之相邻的背景色像素也会被设置为填充颜色 1。相邻像素被定义为与其垂直或水平相邻的像素。然后,所有像素会再次被扫描,任何与填充色像素相邻的背景像素都会被设置为填充色。该过程如图 33-1 所示。

f33001

图 33-1:填充相邻像素

该过程会一直重复,直到没有变化为止。过程会在边界处停止,因为边界像素没有背景颜色且不会改变。这不是实现填充的唯一方法,也不是最快的方法,但可能是最容易理解的方法。

mouseReleased()函数设置bgColorfillColor变量的值,并将第一个(种子)像素设置为填充颜色 3。nay()函数如果参数所指示的像素是填充色像素的邻居,则返回true2。每次调用draw()函数(每帧调用一次)时,它都会显示填充过程的一个迭代,因此过程看起来像是动画。

第四章:处理文本和文件

草图 34:字体、大小、字符属性

当文本在屏幕上绘制时,有许多方法可以绘制每个字符。大小、粗细、方向和样式可以有很大的变化。字体指定了特定大小、粗细和样式的字体。字体作为文件保存,其中包含绘制每个字符的指令。粗体、斜体、正常体和每种重要大小都是独立的文件。字体名称、样式和大小通常是文件名的一部分。

Processing 支持多种字体,但每种字体都必须事先通过工具菜单作为文件进行设置。选择工具创建字体以打开一个字体创建窗口,在该窗口中你可以选择字体名称、样式和大小,如图 34-1 所示。

f034001

图 34-1:设置字体

选择 CourierNewPS-BoldMT,大小为 48,并点击确定,在名为data的本地目录中创建一个名为CourierNewPS-BoldMT-48.vlw的文件。你可以根据需要重复此过程,创建多个字体文件。你需要字体文件才能在 Processing 中加载和使用字体。

使用字体是一个相对复杂的过程。你需要首先为每个想要使用的字体创建一个PFont(Processing 字体)类型的变量,然后使用loadFont()函数 1 加载字体:

PFont font1;
font1 = loadFont ("CourierNewPS-BoldMT-48.vlw");

要设定一个字体为使用的字体,调用textFont()并传入字体变量和所需的大小:textFont(font1, 48) 2.大小是以像素为单位,而不是字体标准的磅(points)。最后,你可以通过调用textSize(size) 3 来随时更改字体大小。

这个草图加载了 Courier Bold 48 字体并将其设定。然后,它以从 2 像素到 55 像素变化的大小绘制字符串“Hello”,每次调用draw()时,字体大小都会增加 1 像素。

草图 35:滚动文本

新闻滚动条是电视新闻和天气频道的常见功能。它是一个故事摘要,会从屏幕的右侧向左侧滚动,其他内容则继续显示在屏幕的其他部分。股票价格通常也以这种方式显示。那么,我们如何在 Processing 的草图窗口中做到这一点呢?

首先,特定项目的文本有一个 x 坐标,它将在该坐标处绘制,使用text()函数进行绘制。y 坐标是恒定的,通常接近屏幕底部。在这个草图中,屏幕的尺寸是 400×200,文本的 y 坐标是 190\。x 坐标会变化。

要显示的文本应该从屏幕的右侧开始,例如,从width-10像素 2 开始。每次绘制时,文本会向左移动,因此draw()每次调用时会将 x 值减去 1:

text (s1, x, y);
x = x - 1;

通常,滚动中会有不止一条消息。第一条消息可能在第二条消息显示之前消失,但这种情况对于文本滚动来说并不常见。另一种想法是让多个滚动字符串彼此紧挨着绘制,并同步移动。因此,这些字符串本身保存在一个名为headlines的数组中 1。

假设我们只有两个字符串。每个字符串都有一个索引访问数组中的字符串(i1i2)以及 x 坐标(x1x2)。如果第一个字符串headlines[i1]绘制在位置x1,第二个字符串应该绘制在位置x1加上字符串i1的像素数再加上一个小空格。用 Processing 术语表示如下:

x2 = x1 + (int)textWidth(headlines[i1])+ 10;

textWidth()是一个接受字符串作为参数的函数,利用当前的字体大小,返回该字符串绘制时的宽度(单位:像素)。值 10 是小空格。当第一个字符串从屏幕左侧消失时,它的绘制位置加上它的长度将小于 0 3:

x1+textWidth(headlines[i1]) < 0

此时,应该获取一个新的字符串(即下一个索引),并将其定位在第二个字符串的右侧:

i1 = (i2+1)%5;
x1 = x2 + (int)textWidth(headlines[i2])+ 10;

当第二个字符串从左侧消失时,情况也会一样。

草图 36:文本动画

动画化文本可以创造出有趣的效果。它曾被用于广告和艺术创作中,但现在做起来比以前容易得多。一个字符串可以沿着曲线路径绘制,甚至是沿着移动的曲线路径;字符串中的字符可以在方向、大小、颜色,甚至字体上发生变化。运动甚至可以根据用户输入变化,可能是跟随鼠标,或者因音频或视频输入而移动。

动画化文本的关键是通过charAt()函数访问字符串中的每个字符。字符串str中的第一个字符可以通过str.charAt(0)访问,第二个字符是str.charAt(1),依此类推。通过这种方式,可以单独访问每个字符,并使其与其他字符以不同的方式表现。

这个草图使得单词Processing发生爆炸,组成的字母以不同的速度向四面八方飞散;字符大小也会变化。每个字符都有一个独特的位置(数组xy)、速度(数组dxdy)以及大小(数组size) 1。

最初,我们将单词Processing整齐地绘制在屏幕中心,作为一组独立的字符 2:

for (int i = 0; i<10; i++)
  text (s1.charAt(i), x[i], y[i]);

几秒钟后(60 帧)3,我们每一帧都改变每个字符的位置 4,从而使它们移动,并且我们还会调整各个字符的大小。字符会朝着随机方向移动,最终从屏幕上消失。

草图 37:输入文件名

到目前为止,本书中所有的草图在读取图像时都使用了文件名常量。为了更灵活,大多数程序允许用户从键盘输入一个命令、文件名,甚至一个数字,并且这些用户输入会指示代码使用特定的数据。这就是我们接下来的任务——要求用户从键盘输入一个图像文件名,并在草图窗口中显示该图像。

我们已经知道,每当用户按下一个键时,keyPressed()函数都会被调用,变量key包含表示所按键的字符,至少对于字母和数字是这样。其他键,比如方向键,使用键码值,比如ENTERBACKSPACE,来告诉我们按下的是哪个键。基于这些事实,读取用户提供的文件名的一种方法是将用户输入的字符附加到一个字符串中,当我们看到回车键时,就使用之前的字符串作为文件名。这应该没问题,但我们需要处理一些约定。

首先,用户需要看到他们正在输入的内容。用户输入的字符串必须显示在屏幕上的某个地方,以便用户看到实际输入的内容。

接下来,必须能够进行更正。传统上,按下退格键可以向后移动并删除字符,以便输入新的正确字符,所以我们将使用退格键实现更正。最后,如果输入了错误的名称,可能找不到对应的图像文件,需要通知用户。

当用户输入一个字母或数字时,key变量指示该字符,我们将该字符添加到一个名为s的字符串中,使用连接操作 3:

s = s + key;

如果该字符是退格键,并且字符串中有字符,我们将删除最后一个输入的字符 1:

if (s.length()>0 && key==BACKSPACE)   
s = s.substring (0, s.length()-1);

draw()函数将每次更新屏幕时显示这个字符串,允许用户看到当前的字符串。最后,如果按下的键是回车键,那么字符串就完成了,我们应该打开并显示该文件。如果loadImage()返回null,则表示没有这样的图像,并且在文件名 2 的位置显示Error

if(key==ENTER || key==RETURN)
{
  img = loadImage (s);
  if (img == null) s = "Error";
}

草图 38:输入整数

在之前的草图中,我们让用户从键盘输入一个字符串,并将这个字符串作为文件名。这是字符串的基本用法——使用字符序列与计算机交换数据。如果我们想输入一些数字而不是文件名怎么办?这就意味着输入一个整数。然而,当在键盘上输入数字时,字符串不是数字,而是数字的文本表示。为了得到实际的数字,必须将组成它的字符转换为数字形式。

字符串“184”是一个字符串形式的整数,显然代表数字一百八十四(184)。这就是一百加上八个十加上四,或者 10² + 8×10¹ + 4×10⁰。为了将字符串形式转换为数字形式,我们需要一次剥离一个数字并乘以正确的 10 的幂。

我们可以先取第一个数字 1,并将其加到总和中。然后取下一个数字并加到总和中,乘以 10;一直重复,直到接收到的字符不是数字。10 的幂随着第一位数字代表最高幂次,最后一位数字代表 10⁰,即 1。

这是核心代码部分 1:

val = val * 10 + (key-'0');

表达式key-'0',其中key是一个数字,表示数字字符的数值(即从 0 到 9)。假设val最初为 0,当用户输入'1'时,我们得到以下结果:

val = 0*10 + ('1'-'0') = 0 + 1 = 1

现在用户输入'8',我们得到以下结果:

val = 1 * 10 + ('8'-'0') = 10 + 8 = 18

最后,用户输入'4'

val = 18*10 + ('4'-'0') = 180 + 4 = 184

为了让这个示例稍微有些实用,它允许我们输入两个值,一个是x值,另一个是y值,并在这些坐标上绘制一个圆形。输入错误时,坐标会被设置为 0。

示例 39:从文件中读取参数

许多计算机程序将值保存在文件中,以便程序启动或重新启动时使用。初始值、按钮和其他界面对象的位置、游戏的高分:这些都可以在程序开始时从文件中读取。大多数人都有过玩电脑游戏并保存游戏状态,以便稍后继续玩耍的经验;这也涉及将数据保存在文件中,并在以后读取。这个示例从一个文本文件中检索游戏状态,虽然是一个简单的游戏——跳棋——但文件中包含了游戏中所有棋子的位置信息。

跳棋使用一个 8×8 的格子,上面放置两种颜色的棋子,通常称为黑色和白色。实际上,只有一半的格子被使用,而且这些格子也有两种颜色。跳棋只能放置在其中一种颜色的格子上,因此这个示例的简单部分就是绘制这些格子,并在已知位置时将棋子放置在这些格子上。新的部分是读取数据并将这些数据解释为棋子的位置。

作为表示跳棋棋盘的方案,可以想象一个包含八行八列的方格集合。一个方格可以通过(i, j)索引,其中i是行号,j是列号。棋子在方格上的颜色可以用 0 表示一种颜色,1 表示另一种颜色——实际颜色并不重要,重要的是颜色为 0 的棋子属于一个玩家,而颜色为 1 的棋子属于另一个玩家。方格位置是固定的,但棋子的位置是从文件中读取的,文件包含每个棋子的位置和颜色,格式如下:

row col color   (e.g. 1 2 1)
row col color   (e.g. 1 4 1)
...

文件包含由单个空格分隔的一位整数,每行三个数字。结构化的格式易于读取,事实上,它是计算机生成数据的典型格式。

在 Processing 中读取文件,我们将使用内置函数loadStrings(),它从文件中读取一组字符串(传入一个字符串参数作为文件名),每个字符串对应文件中的一行。loadStrings()返回一个字符串数组,我们将其赋值给变量dlines2。为了找出数组中的项目数(即文件中数据行数),我们使用dlineslength属性:dlines.length

当一行被读取时,我们使用它来在方格上放置一个棋子,当所有棋子都读取完后,我们将在屏幕上绘制它们。为了放置棋子,我们从dlines中的每个字符串中提取三个整数,然后使用行和列的整数将正确的棋子放置到正确的位置。

我们将字符串数据转换为数字,具体方法如下 3:

y[i] = dlines[i].charAt(1) - '0';

每个棋子有两种颜色之一,由变量k[i]表示。一个棋子宽度为 20 像素,所以我们会在位置(x[i]y[i])处绘制一个棋子,代码如下:

if (k[i]==0) fill (200,0,0); else fill (200,2000,0);  // Color?
  ellipse (x[i]*40+20, y[i]*40+20, 20, 20);

水平位置从左侧偏移 20 像素,每个后续位置向右偏移 40 像素。表达式x[i]*40+20给出了绘制棋子编号iX位置。垂直的Y位置也是对称的。

方格为 40×40 像素,颜色交替,所以当我们绘制一个红色方格时,我们会切换填充颜色为下一个方格的颜色。绘制 8 个方格后,会额外切换一次,使得颜色在垂直方向上也交替变化。如果ij是方格的坐标,我们按以下方式绘制:

1 rect (i*40, j*40, 40, 40)

在草图中,棋子是红色或绿色,方格是红色或黄色。

草图 40:将文本写入文件

计算机程序使用文本向用户说明发生了什么。有时,像在前面的草图中,程序使用文本保存程序的状态,通常是游戏的状态;有时程序会写出数字结果或记录程序的进度。文本是计算机与人类沟通的典型且自然的方式。

这是需要解决的问题:我们想在屏幕上模拟一个以恒定速度移动的小球,正如在草图 28 中所做的那样;在每一帧中将小球的位置写入文件;并记录小球与屏幕边缘碰撞的情况。

loadStrings()对应的输出方法是saveStrings()函数。我们将声明一个字符串数组,每个字符串作为文件中的一行文本被写入。当要保存小球位置时,会创建一个代表位置的字符串,并将其存储在数组的一个位置中。然后,数组索引加 1,以便下一个字符串存放在下一个位置 2。

data[index] = "(X0,Y0)= ("+x0+","+y0+")";
index = index+1;

当小球与屏幕的边缘发生碰撞时,我们会在数组中放入类似“左侧碰撞”之类的消息,然后将索引加 1。

当数组填满时(即索引大于 499 时),saveStrings()会将所有字符串写入文件并结束程序 3:

saveStrings("save.txt", data);

使用saveStrings()保存文件后,无法再向文件添加内容;如果使用相同的文件名再次调用,它将覆盖该文件。因此,必须先保存所有内容,然后一次性写入。对于 500 个字符串,你可以记录大约 7 秒的内容。

草图 41:模拟计算机屏幕上的文本

想象一下,正在制作一部为电视而拍的电影。它讲述的是计算机、黑客和程序员的故事,而扮演黑客角色的演员们,嗯,都是演员。他们对编程一无所知。他们不会打字,当然也不能输入代码。所以,在镜头从主角肩膀上方俯视她打字的场景中,我们需要一种特效——让它看起来像是在编程。我们会用计算机动画吗?那可能会很贵。不,通常的技巧是使用一个简单的程序,显示特定的文本,无论按下什么键。这样,演员们就只需要知道如何按下一个键。

在 Processing 中制作这个程序很简单,基于我们目前所知道的。程序打开一个窗口,并初始化一个字符串message,将其设置为要在屏幕上打出的文本 1,可以从文件中读取。一个变量N初始值为0,它用来索引字符串:每个到达N的字符都已经被打出,并应该显示在屏幕上。draw()函数每次调用时,都会绘制到N为止的所有字符,每次绘制一个字符,水平间隔(在这个例子中)九个像素。

为了将文本组织成行,我们使用“!”字符来表示行的结束。当程序在字符串中看到这个字符时,它不会显示它,而是将x位置重置为起始值,并将y位置增加 15 像素(即一行的高度)。

draw()函数从语句 2 开始输出文本:

for (int i=0; i<N; i++) // display the next character

然后,它会显示字符串中的某个字符 4:

text (""+message.charAt(i), x, y);
x = x + 10;

或者字符串中的字符是“!”并开始新的一行 3:

if (message.charAt(i) == '!') 
{
  y = y + 15;    // Move vertically down to next line
  x = 15;        // and start over at pixel 15.
}

最后,当按下某个键时,如 Processing 调用keyPressed()函数所示,计数值N增加 1,以便屏幕上显示一个新的字符 6。无论按下什么字符,message字符串中的预定义字符都会显示出来。如果N超过了字符串的长度,程序可以将N重置为0,从而重新开始并显示一个新的屏幕,或者进一步的按键操作可以被忽略。

第五章:创建用户界面和小部件

草图 42:一个按钮

在控制台或文件中的文本和基本鼠标手势之后,简单的按钮是第三大最常见的用户输入方式。它在网页、游戏屏幕以及任何需要用户做出开/关或是/否选择的系统中无处不在。它当然是基于传统的按键,作为一种电气设备已经存在很久了,而且它的工作方式非常自然:按下按钮后,某些事情就会发生。

从图形上看,按钮其实就是一个矩形。它通常会填充一种颜色,并且有一个文本标签或图像来表示它的功能。当用户点击鼠标按钮并且光标位于按钮内部时,按钮分配的任务会被执行,通常是通过调用某个函数来实现。按钮的属性包括它的位置(按钮左上角的 x 和 y 坐标)、大小(按钮的宽度和高度)、标签(按钮中写的文本字符串),以及一个颜色图像,该颜色或图像会出现在按钮上。

当鼠标光标位于按钮内时,按钮被称为被激活。激活时,点击鼠标会执行按钮的功能。有时,按钮在激活时会用不同的颜色或字体来绘制,以向用户指示激活状态。

本草图中实现的按钮会导致草图窗口的背景颜色发生变化。当鼠标进入矩形区域 3 时,按钮被激活。

if ( (mouseX>=bx) && (mouseX<bx+bw) && (mouseY>=by) && (mouseY<by+bh) )

其中(bxby)是按钮的位置,(bwbh) 是按钮的大小。

buttonArmed()函数在if条件为真时返回 true。drawButton()函数绘制并填充矩形,并绘制文本 1。当按钮被激活时,drawButton()还会将填充颜色从红色更改为绿色。当然,mousePressed()函数确定在鼠标按下时按钮是否被激活,如果是,它会改变背景颜色 4。

因为本草图只实现了一个按钮,所以它使用的代码并不多。通常,一个应用会有很多按钮,正如你在下一个草图中将看到的那样。

草图 43:类对象——多个按钮

本草图将创建并显示三个按钮,每个按钮代表一个颜色组件:红色、绿色和蓝色。当按钮被点击时,相应的背景颜色组件将随机变化。

如果一个应用需要很多按钮,那么在草图 42 中呈现的方案就会显得笨拙。我们想要的是一种类型,像PImagePFont那样,代表一个按钮,这样我们就可以声明按钮变量或按钮数组。新的button类型应该包含按钮的所有属性以及执行合法按钮操作的代码,这些代码作为函数来编写。

使用名为类(class)的特性来创建带有关联函数的自定义类型。类是一种封装某些变量和函数并给它们命名的方式。button类应如下所示:

class button
{
  `your code here`
}

在大括号内,我们声明按钮使用的变量:xywidthheightlabel等。drawButton()buttonArmed()函数也在类内定义,还有一个叫做构造函数的东西:每次创建新按钮(或者一般来说,类对象)时,构造函数会自动被调用。class语句及其后面的大括号内的内容将类声明为自定义类型,当你声明该类的变量时,你就创建了一个实例,即一个具体的对象,它包含类中的变量和函数。

button类的变量声明方式与PImage变量相同:

button b1, b2, b3;

下一步,和PImagePFont一样,是使用new创建button类的实例并将其赋值给一个变量:

b1 = new  button (100, 150, 90, 30, "Button");

当你使用new时,Processing 会调用类的构造函数。构造函数接受参数,如位置或大小,并将这些参数保存起来,供之后绘制按钮时使用。构造函数的名称与类名相同(在这里是button),并且没有返回类型——它前面不会有void或类型名称。构造函数本身没有返回值,但new操作符会返回该类的新实例。如果你定义了多个构造函数,Processing 会调用与new语句中提供的参数类型和数量匹配的那个构造函数。构造函数随后会返回该类的新实例。你可以创建尽可能多的实例,只要你的计算机内存允许。

你可以通过点符号(dot notation)来访问类变量和函数。对于button类的实例bred 1,x 位置是bred.bx,要绘制它,你需要调用bred.draw()。主绘制函数必须为每个按钮调用draw(),否则它们不会显示,而主程序中的mousePressed()函数必须检查每个按钮,看它是否被点击(即鼠标光标是否在按钮内部),这可以通过每个按钮中的armed()函数来完成。

草图 44: 滑块

滑块是一个用户界面控件,允许用户沿着线性路径(水平或垂直)移动一个小物体(光标)。光标在路径上的相对位置代表一个数字。光标在一端的位置对应最小值,而在另一端的位置代表最大值。如果光标位于最小值和最大值之间的中点,那么与滑块相关联的值就是最小值和最大值之间的中间值。

这种控件可以用于在小窗口中定位大图像或在较小区域内定位大量文本,我们在这些情况下称其为滚动条。更一般来说,滑块的目的是让用户通过在两个限制之间滑动光标来几何地选择一个数字,而不是通过输入它。这是一个自然的想法:选择一个作为总数的分数或作为一个值范围的一部分。如果我们将sliderPos定义为光标从滑块起始位置开始的像素位置,将sliderWidth定义为滑块的宽度(以像素为单位),将sliderMaxsliderMin定义为与最小和最大光标位置相关联的数值,那么这是所选的值 3:

`value = (int)(((float)sliderPos/sliderWidth)*sliderMax + sliderMin);`

这个表达式基于滑块位置是总可选位置集的一个分数的事实,这代表了从sliderMinsliderMax值之间范围的相同分数(见图 44-1)。

f044001

图 44-1:一个滑块

滑块可以通过多种方式图形化表示。在这张草图中,控件是一个水平矩形,带有一个圆形光标,当前的数值显示在右侧。然而,光标可以是矩形的、椭圆形的、三角形的、指针形的或其他形状。

drawSlider()函数 1 绘制矩形并使用sliderPos变量定位光标,当用户通过鼠标选择光标并将其移动(滑动)到矩形的两端时,该变量会被设置。要构建一个滑块类,你需要为位置、大小、当前光标位置和值创建类变量,并为绘制滑块和定位光标创建类函数(然后你可以像slider.drawSlider()slider.draw()这样调用它们)。

滑块的常见用途之一是作为显示图像的一种方式。通常,图像可能无法适应某个特定窗口,或者根本无法适应任何窗口;有些图像非常大。与其调整图像大小,通常会在窗口的底部和右侧设置一个滑块,使用光标将窗口定位在图像上,这样就能看到图像的不同部分。通过滑块选择的值代表窗口在更大图像上方的(x, y)位置。

草图 45:仪表显示

计算机显示数值结果的显而易见方式是直接显示数字,但有时更类比的方式更容易为人们所接受。有些人喜欢数字时钟,而有些人则偏爱带指针的旧款时钟。类比显示可以让人类更快地处理信息。一种常见的显示方式是仪表,其中某种指针旋转并指向一个数字。比如,大多数老式的车速计就是这种类型的显示。图 45-1 以图形方式展示了一个仪表,并简要抽象了这种情况。

f045001

图 45-1:一个显示接近 0 值的仪表(左),以及显示涉及的角度(右)

仪表可以显示介于最小值和最大值之间的数值。最小值对应指针能达到的最小角度(图中标记为α),最大值对应指针能达到的最大角度(图中标记为β)。在这个草图中,角度直接映射到数值,因此每一个度数的变化始终代表相同的变化量。为了显示一个值,我们计算出与该值对应的角度,草图中将其命名为theta 1,并在该角度绘制指针。

一种看待这个问题的方法是将其视为一个形状像曲线的滑块。虽然仪表仅仅是一个显示工具,但确定指针位置的数学原理与滑块相同,只是我们使用的是角度而不是直线距离,并且它经过重新组织以提供位置值。图 45-2 展示了滑块情况如何转换为我们需要的仪表,并展示了用于确定绘制指针位置的公式。这个公式实际上与我们为滑块使用的公式相同。

f045002

图 45-2:仪表就像一个弯曲的滑块。这里展示的方程根据数值确定一个位置值(角度),但它与我们为滑块使用的方程基本相同。

我们确实需要理解 0 度是水平的,并且我们将起始角度(α)和结束角度(β)转换为相对于 0 的角度。从α开始,当数值增加到最大值时,指针的角度逐渐减小。如果α是 140 度,那么β应该是−45 度,而不是等效的角度 315 度,这样β < α。

gauge()函数根据图 45-2 中的方程,在给定数据值v的情况下,绘制出指针的位置。别忘了,Processing 中的角度需要以弧度表示,因此pos必须从度数转换过来。

草图 46:Likert 量表

Likert 量表是一种用于回答问题的评分量表,通常用于问卷调查中。被问者从一组选项中选择一个答案(通常是五个选项),这些选项从“强烈不同意”到“强烈同意”不等。其目的是收集标准答案,以便进行统计计算。

这个草图通过将问题绘制在屏幕顶部附近提出问题 2。可能的答案从 1(强烈不同意)到 5(强烈同意)编号,每个答案对应一个圆圈。用户通过点击一个圆圈来选择答案,圆圈会被填充 3。当用户对自己的回答满意后,按下任意键,草图会提问下一个问题。

问题存储在名为questions.txt的文本文件中,该文件在setup()中打开。我们假设文件中有多个问题,每个问题占一行。loadStrings()函数将它们全部读取到一个名为question的数组中,数组的长度就是问题的数量。每个问题根据其索引变量questionNo被逐一显示,questionNo从 0 开始,直到问题数量。用户通过点击五个圆圈中的一个来选择答案。所选的答案会作为当前选择(使用名为select的变量)保存在mouseReleased()函数中。

当用户按下一个键时,keyPressed()函数会被调用,所选答案将被写入一个名为save.txt的文件中。然后,questionNo变量会增加,显示下一个问题。当所有问题都被问完(即当questionNo > question.length时),文件将被关闭,程序结束。用户选择的所有问题答案现在都存储在save.txt文件中。

草图 47:一个温度计

原始的温度计,由玻璃制成,内部有彩色液体,设计上受到功能的限制,但它也是展示数字数据的一个绝佳方式。它通过彩色线条或矩形的高度来表示一个数字。很容易看出一个矩形有多高,并且容易与其他矩形进行比较。这个理念已被广泛应用,最显著的是在音响设备上用来显示音量。

在计算机上进行表示非常直接。一个彩色矩形的大小根据数字变量的大小变化。这样的变量有最小值和最大值,而矩形也有最小(通常为 0)和最大高度。数字与高度之间的映射可以像在滑块(草图 44)和指针仪表(草图 45)中一样进行。在这个草图中,它的实现方式稍有不同,但计算方式是相同的。

这个草图计算了每次变量 1 增加时,矩形高度增加的量。如果矩形的高度可以从ystart变化到yend,而数据值的范围是从dataMindataMax,那么每次数据增加时,矩形高度的变化如下:

delta = (float)(ystart-yend)/(float)(dataMax-dataMin);

然后,对于任何数据值data,矩形相对于ystart的高度如下:

val = ystart-(int)(data*delta);

这个过程只绘制一个矩形,虽然不算太有趣,但我们会添加一张背景图像(专门为这个程序制作),其中包含一个玻璃温度计的图像以及刻度线,允许用户将矩形的高度解读为数字。矩形的坐标必须特别映射到图像上,以便矩形与温度计柱对齐,使用的过程与草图 45 中类似。

这个例子生成一个随机数字进行展示。从data = 15开始,这个值在每一帧中会按一个小的随机量变化。

第六章:网络通信

草图 48:打开一个网页

一个网页其实只是一个文本文件,其中包含足够详细的页面描述,以便在屏幕上绘制它。一个名为浏览器的程序读取并渲染该文件,生成可视页面。文件本身存储在互联网上某台计算机上,为了显示它,我们必须先将其上传到用户的计算机。浏览器安排执行这一操作,但文件必须有一个独特的名字来标识它——这个名字在整个世界范围内唯一,因为互联网是一个全球网络。这个独特的名字被称为统一资源定位符(Uniform Resource Locator,简称 URL)。大多数人称之为网页地址,一个例子是 www.microsoft.com

URL 包含了如何找到网页的方向,它相当于一个文件名。显示页面是一个复杂的操作,浏览器是非常复杂的软件系统。

Processing 使用名为 link() 的函数来打开和显示网页,该函数接受一个 URL 作为参数。这个函数将 URL 传递给计算机上的默认浏览器,浏览器会打开并显示该页面。因此,下面的调用将会在浏览器中打开 Microsoft 页面:

link ("https://www.microsoft.com"); 

如果浏览器已经打开,它可能会打开一个新标签页。

示例 A

这个草图按照之前的描述打开了 Microsoft 页面。当鼠标按钮按下并且光标位于显示窗口内时,它会打开该页面。

示例 B

这个草图是示例 A 和草图 37 的结合体。用户输入一个 URL,草图根据输入的字符构建一个字符串。当用户按下 ENTER 键时,草图将 URL 传递给 link() 函数,浏览器将打开并显示对应的页面。

当用户输入一个字符时,通常会把它放入变量 key 中,然后将其添加到字符串中。然而,某些键不会产生字符,比如方向键或 Shift 键。在 Processing 中,大写字母涉及两次按键操作:SHIFT 键和字符键。Processing 系统将这些视为编码键,并以不同的方式处理它们。如果 key 变量的值为 CODED,那么按下的就是这些特殊键之一,而 keyCode 变量表示按下了哪个键。例如,值 UP 表示按下了向上箭头键。

在这个草图中,我们将忽略所有编码键,因为 SHIFT 键用于输入大写字母和一些标点符号(比如冒号“:”,),但不应当视为一个按键操作。keyPressed() 函数通过以下代码来忽略编码键:

if (key == CODED) return;

草图 49:从网页加载图像

由于网页本质上只是一个文本文件,正如你在草图 48 中看到的,它应该是可以被读取并查看其中的内容。例如,它应该能够识别网页访问的任何声音文件(例如 MP3 文件),或者网页中将包含哪些图片(如 .jpg.gif.png 等)。这个草图将定位网页中引用的图片文件,并将它们显示在显示窗口中。

首先要做的是读取网页。它包含 HTML,这是一种描述文档的语言,读取它其实很简单:Processing 允许像使用文件名一样在 loadStrings() 函数中使用 URL。你可以通过直接将 URL 传递给 loadStrings() 来将 Mink Hollow Media 网页作为文本文件读取:

String webin[] = loadStrings("https://minkhollowmedia.ca");

或者,正如在这个草图中所做的那样,使用 loadStrings(url+"/"+file),其中 url 是网页地址,file 是我们想要的文件名 1。此时,网页作为一组字符串存储在数组 webin 中,每行对应文件中的一行。

HTML 使用一种叫做 img 标签来在页面中显示图片:

<img src="imagename.jpg">

图片的文件名位于文本 src=" 后面,因此草图应该在 webin 中的字符串内查找这一字符序列。如果找到,接下来的字符直到结束的双引号 (") 为止即为文件名。我们可以使用 indexOf() 函数 2 在一个字符串中查找另一个字符串:

i = s.indexOf ("src=", j);

在这个例子中,indexOf() 在字符串 s 中查找字符串 "src=",从字符索引 j 开始。它返回找到该字符串位置的索引,如果未找到则返回 -1。如果找到该字符串,我们会调用 getName() 函数 3 来提取字符串中的文件名。getName() 函数读取并保存字符,直到遇到终止的双引号,并将文件名作为字符串 4 返回。这个字符串会作为文件名传递给 loadImage(),如果能够加载到该文件名的图片,则会显示出来。

有许多合法的方式可以指定文件名,这段代码也尝试了另一种方法:它会获取 URL,并加上斜杠 (/) 和文件名 1 来查看是否有效。某些图片通过这种方法可能无法定位,而一些非图片文件(如 JavaScript、视频和音频文件)则可以提取出来。它们无法作为图片显示,控制台会出现错误信息。

草图 50: 客户端/服务器通信

很多计算机网络通信都是基于所谓的客户端/服务器模型。它也可以叫做监听者/发言者模型或接收者/发送者模型,因为它实际上就是有一个计算机或进程通过网络发送信息(服务器),而另一个计算机或多个计算机接收这些数据(客户端)。

客户端/服务器软件应该这样工作:服务器首先向世界宣布它是活跃的并正在发送数据。它必须拥有一个可以唯一标识它的地址,并且必须开始发送数据(例如字节)。客户端通过使用服务器的地址来识别它想要获取数据的服务器。如果该地址代表的是一个活跃的服务器,客户端就开始从服务器读取数据。服务器必须指示新数据何时可用,如果客户端请求数据而没有收到任何数据,客户端将等待直到数据出现。

这个例子有一个服务器发送字符数据,客户端接收并显示这些数据,分别通过两个不同的草图实现。服务器不断发送消息“这是一条来自 J Parker 的消息”;客户端从服务器读取字符,将其组成一个字符串,并在显示窗口中显示该字符串。

Processing 本身没有构建客户端/服务器系统的原生功能,但有一个库可以实现此功能。Processing 使用外部库来处理许多事务,包括视频、音频以及各种特定接口。对于这个例子,我们需要在客户端和服务器草图 1 的开头导入网络库,使用这一行代码:

import processing.net.*; 

在服务器代码中,第一步是创建一个Server(属于网络库的一部分),并将其分配给名为sender的变量,然后指定端口(在此案例中为 5000 端口),这只是一个数字。端口就像电视频道,用于发送或接收数据,重要的是确保没有其他软件在使用该端口。服务器通过调用write函数 2 逐个字符地从字符串中将数据发送到外部世界。

sender = new Server(this, 5000);
`--snip--`
sender.write(nextChar);

nextChar是消息中的一个字符。

客户端草图首先尝试连接到服务器。客户端必须知道服务器的 IP 地址,它是服务器的唯一标识符(此处为***.***.***.***)。客户端通过构造函数使用相同的端口 3 连接到服务器:

me  = new Client(this, "***.***.***.***", 5000);

客户端使用readChar()函数 4 逐个读取字符:

nextChar = (char)me.readChar ();

在这个例子中,你必须先启动服务器并找出它的 IP 地址。你可以使用在运行服务器草图的计算机上的ipconfig程序来查找 IP 地址。然后,你可以在网络上的另一台计算机上启动客户端。

第七章:3D 图形与动画

51 号草图:基本的 3D 物体

到目前为止,我们一直在绘制二维(2D)物体:线条、圆形、三角形、矩形和图像。Processing 也可以绘制三维(3D)物体,尽管我们在计算机屏幕上能显示的只是它们的视图,即投影到平面上的二维图像。这种投影特性使得 3D 更加复杂。x 维度是水平的,y 维度是垂直的,在二维屏幕上显示这些坐标是显而易见的。第三个维度,称为 z,应该垂直于屏幕表面。为了可视化它,必须将三个坐标压缩为两个坐标,这正是投影所做的。

Processing 提供了一个 3D 立方体和一个球体。在这个草图中,我们将绘制这些标准物体,以展示 3D 的效果。

为了渲染 3D 物体,Processing 需要使用执行 3D 绘图操作的软件,称为 3D 渲染器。默认的渲染器叫做 P2D,它只处理二维图形。要指定三维度,我们需要在 setup 函数中的 size 函数里提供 P3D 渲染器作为参数:

size (300, 400, P3D);

现在所有的 3D 操作都可以使用了。立方体和球体通过函数提供,就像二维中的矩形和椭圆一样。sphere(R) 3 函数绘制一个半径为 R 的球体,位于原点处。

球体是通过一组三角形绘制的,每个顶点都有 x、y 和 z 坐标,这些三角形沿球体表面方向排列并连接边缘。可以将其视为使用许多短直线绘制圆形的 3D 版本;它并不完全平滑,但如果三角形足够小,视觉效果就能达到理想的效果。除非通过调用 noStroke() 关闭轮廓显示,否则这些三角形将是可见的。

box(s) 函数绘制一个立方体,其中每个边长为 s 像素 4。为了指定每个方向的大小,我们可以使用 box 的第二种形式:box(w, h, d)

要在原点以外的位置绘制任何形状,我们必须先调用 translate() 2 函数,将原点移动到要绘制球体的位置。在 3D 中,坐标有三个值:x、y 和 z,translate() 函数有三个对应的参数。

最后,在绘制三维图形时,我们需要照明来创建深度感。为了启用照明,我们在 draw() 函数中调用 lights()。如果没有调用 lights(),示例 A 输出中的左侧球体将看起来只是一个圆形。

示例 A

我们绘制了两个球体:一个显示球体组成的三角形(右侧),另一个将它们隐藏起来(使用noStroke(),左侧)。球体从视角处移开然后再回来,比静止状态更清楚地展示了第三维度。

示例 B

我们绘制了两个立方体,再次右侧显示立方体的轮廓,左侧则不显示。立方体也从相机处移开然后再回来(沿着 z 轴)。

52 号草图:3D 几何—视点与投影

3D 物体实际上是在虚拟空间中具有三维坐标的边缘和面部的模拟。由于计算机屏幕是二维的,视觉化这些物体意味着将它们投影到一个平面上,以便它们能够在屏幕上绘制出来。

这个平面位于物体与观察物体的视点之间。视点是三维空间中的一个位置,由图 52-1 中的眼睛标示。(二维场景实际上没有视点;整个图像本身就是一个平面。)

f052001

图 52-1:查看一个 3D 物体

定义 3D 视图外观的第二个关键点是观察者(摄像机)看向的地方。这是场景的中心,用(cx, cy, cz)表示。3D 场景投影的平面与(ex, ey, ez)(cx, cy, cz)之间的连线垂直,能够看到的内容完全取决于视野,或者说是可见场域的角度,决定了在不移动摄像机的情况下能看到什么。

在 Processing 中,我们使用调用camera() 1 来设置基本的 3D 配置:

camera(ex, ey, ez, cx, cy, cz, 0, 1, 0);

前三个参数是视点,接下来的三个是场景的中心。最后三个表示一个向量,用来定义方向,以确保场景正确地定向。在这个示例中,是正 y 方向。这是程序员做出的选择。

这个草图使用camera()函数,根据用户的按键操作,改变一对 3D 物体的视角。我们通过在keyPressed()函数中增加或减少exez的值来移动视点的位置,当按下相应的键时:a 减小 x(向左移动),d 增加 x(向右移动),w 减小 z,s 增加 z(物体的距离)。这等同于在视频游戏中移动玩家。球体绘制在(cx, cy, cz)位置,确保它一开始就可见。为了将场景的中心从球体移开,我们可以使用上下箭头键改变cy的值。你可以通过使用键盘来实验这些改变视点和场景中心的效果。

草图 53:3D 照明

照明可以深刻地改变场景的外观。光源的位置会使物体或场景的特定部分可见,而其他部分则不可见。彩色光源可以改变物体的外观颜色。定向光可以照亮物体的某些部分而不照亮其他部分。Processing 提供了所有这些选项。

在这个示例中,我们将绘制一个球体,并允许用户通过输入数字来选择所使用的光照类型。光照可以是环境光(1)、定向光(2)、点光源(3)、聚光灯(4)或三者的组合:定向光、点光源和聚光灯(5)。默认光照为代码 0。当用户改变光照类型时,颜色也会随之改变:环境光是青色,定向光是紫色,点光源是黄色,聚光灯是绿色。

之前的示例使用了对lights()的调用来提供默认的照明。或者,我们可以调用ambientLight()函数 1 来指定环境光的颜色,并且可以选择性地指定环境光的位置,环境光是照亮整个场景的光源。

ambientLight (r, g, b, x, y, z);

前三个参数指定光的 RGB 颜色值。接下来的三个是可选的,用来指定光源的三维位置。从该点发出的光在各个方向上扩散。

directionalLight()函数 2 指定来自特定方向的光线,因此当光线垂直照射到表面时会显得更亮,而角度改变时亮度会减小。

directionalLight (r, g, b, dx, dy, dz);

同样,前面的三个参数表示光的颜色。接下来的三个参数表示光的方向。例如,如果dy=1dx=0dz=0,则物体将从上方照射。

pointLight()函数 3 创建一个光源位置,类似于灯泡。此调用会在指定的(xyz)位置放置一个 RGB 值的光源:

pointLight (r, g, b, x, y, z);

最后,spotlight() 4 是一种集中的定向光源,它是最复杂的光源之一。此调用指定一个在位置(xyz)上,朝着方向(dxdydz)照射的 RGB 颜色光源:

spotlight (r,g,b, x,y,z, dx,dy,dz, angle, concentration);

angle的值是光的散射角度;角度越小,光圈越小。该角度以弧度为单位。concentration指定光在横截面上的变化,中心较亮,边缘较暗。值的范围从 1 到 10,000 不等。

示例 54:3D 弹球

示例 28 是一个弹球的模拟。一个圆形(球体)在窗口中移动,当它碰到边界时会反弹。将其扩展到三维,球体在立方体内反弹。当球体(弹球)撞到立方体的某一面时,它会反弹。这个问题在概念上与二维情况相同,但需要更多的代码,因为需要检查更多的条件并绘制更多的内容。

场景由一个立方体和一个球体组成。立方体占据了大部分视野,由坐标轴界定。我们将用特定的颜色绘制坐标轴,以显示三种主要方向:x 轴为绿色,y 轴为蓝色,z 轴为红色。我们将不调用box(),而是绘制组成边缘的 12 条线,以便我们可以看到里面的球体。

我们将从左上角的原点开始绘制立方体,接着绘制剩下的九条边,使用 mycube() 函数 1。为了判断球体是否与某个面发生碰撞,我们将测试球体的坐标与边界平面的 x、y 和 z 值,这些平面与坐标轴对齐。

我们仍然可以使用 sphere() 函数绘制在位置 (x, y, z) 处反弹的球体,通过将原点平移到该点再进行绘制。每绘制完一帧后,我们将球体移动一定的量 (dx, dy, dz)。如果球体的坐标使得球体超出了任何一个立方体面的边界,则球体会发生反弹——它会反转运动方向,远离该面。这通过 moveSphere() 函数实现。例如,在 x 方向上,这是特定的反弹测试 2:

if (x<=6 || x>=194) dx = -dx;

这个测试特定于半径为 12 的球体,因为它是与 6 像素的半径进行比较的。如果球体的半径 r 使得球体的中心距离某个面不超过 r 像素,则球体与立方体接触,而 r 是指定球体大小的一半。由于立方体从 (0, 0, 0) 开始,且每个方向的尺寸为 200 单位,因此球体在 x 坐标为 6 和 194 附近发生碰撞\。

立方体的中心位于 (100, 100, 100) 3。这个点是场景的中心。我们从视点的 (x, y) 中心(即 (100, 100))凝视立方体,但沿 z 轴偏移 400 个单位。

草图 55:使用平面构造 3D 对象

Processing 仅提供球体和立方体作为基本的 3D 对象,但这并不意味着我们不能做更复杂的物体。我们可以从多边形构造任意对象。这意味着我们需要先设计对象,无论是在纸上还是使用像 Blender 或 Maya 这样的 3D 建模程序。设计过程会得到多边形顶点(角点)的三维坐标。然后我们可以使用 Processing 绘制这些多边形,从而显示该对象。

由于棱柱是最容易构建的对象,这个草图将绘制一个棱柱,并用不同的颜色表示不同的面,这样我们就可以分辨各个面。视角将沿着一定的模式移动,从而使物体的 3D 特性变得清晰。

长方体由沿边缘连接的矩形组成。例如,立方体就是一种长方体。第一步是确定组成棱柱的每个矩形的角点坐标。画一些方格纸来帮助理解非常有用:在纸上画出棱柱,并定义 x、y、z 坐标系(x 为水平轴)。然后,从原点 (0, 0, 0) 开始,将坐标按位置放置在图纸上,如 图 55-1 所示。现在你可以随意读取每个矩形的坐标。例如,图中棱柱的前面是由以下坐标定义的:(0, 0, 0)、(sx, 0, 0)、(sx, sy, 0)、和 (0, sy, 0)。

f055001

图 55-1:棱柱的 3D 坐标

要绘制作为一个对象连接的多边形,我们将绘制代码包裹在对beginShape()endShape()函数的调用之间。在这种情况下,由于使用的多边形是矩形,beginShape()传入参数QUAD 1;另一种选择是TRIANGLES。这个参数告诉 Processing 每个多边形所需的顶点数量(在本例中为四个)。在开始和结束的调用之间,我们调用一个名为vertex()的函数 2。每次调用都指定了 3D 空间中的一个点,在此实例中代表矩形的一个角。例如,棱镜的前面是通过这些调用来定义的:

vertex (0., 0., 0.);
vertex (sx, 0., 0.);
vertex (sx, sy, 0.);
vertex (0., sy, 0.); 

草图绘制了四个连接在一起的矩形,沿垂直边缘形成一个没有顶部和底部的矩形棱镜。每个矩形通过在指定矩形的四个顶点之前立即调用fill(),并使用不同的颜色填充。

视点在每一帧中根据dz的量发生变化,z 的最小值为-200,最大值为 300 4,从而展示棱镜的各种视图。

草图 56:纹理映射

在草图 55 中,我们给棱镜的每一面赋予了不同的颜色,以便轻松识别每一面。这样做是作为一个练习,但在大多数实际应用中,棱镜通常是单一颜色,或者会有纹理被应用到它上面。纹理是一种图案,通常只是一个图像,我们像贴花一样将其应用到多边形上。通过这种方式,我们可以使一个简单的棱镜看起来像许多东西:建筑物、书籍、椅子——几乎任何有角的物体。这个草图将纹理(地毯)应用于多边形(矩形),并移动视点,以便可以看到 3D 效果。

将图像应用于多边形作为纹理的过程叫做纹理映射。算法的细节很复杂,但这个概念足够简单,而且它在 Processing 中的实现很自然地融入了之前为绘制对象所解释的方案中。英文过程如下:

  1. 读取将作为纹理的图像 1。这将是一个PImage

  2. 定义一个 3D 多边形的坐标,可能是更大对象的一部分。

  3. 将纹理图像的每个四个角映射到多边形的一个顶点;也就是说,如果多边形是矩形,决定纹理图像的哪些角将被覆盖在矩形的哪些角上。

  4. 将坐标映射转换为对vertex()函数的调用 4。

  5. vertex()的调用包裹在beginShape() 2 和endShape() 5 之间。

  6. beginShape()之后,立即通过调用内置的texture()函数 3 来告诉 Processing 使用哪个纹理图像。

在这个例子中,我们使用的是地毯纹理的图像。作为方位标记,一个红色矩形放置在左上角,一个绿色矩形放置在右上角。纹理图像的尺寸为 524 乘 928 像素。这是从纹理到顶点的坐标映射,如图 56-1 所示:

  • 纹理(0, 0)映射到多边形(0, 0, 0)。

  • 纹理(524, 0)映射到多边形(sx, 0, 0)。

  • 纹理(524, 928)映射到多边形(sx, sy, 0)。

  • 纹理(0, 928)映射到多边形(0, sy, 0)。

f056001

图 56-1:将纹理坐标映射到多边形

vertex()函数允许我们使用两个可选参数来指定纹理坐标的映射。以下是之前顶点的映射:

vertex (0., 0., 0.,   0.,  0.);    vertex (sx, 0., 0.,   524, 0.);
vertex (sx, sy, 0.,   524, 928);   vertex (0., sy, 0.,   0.,  928);

因为 Processing 知道纹理图像(timage)的大小,前面映射中的数值常量 524 可以替换为timage.width 4。同样,我们可以用timage.height代替 928。

草图 57:广告牌——模拟一棵树

让我们在三维空间中画一棵树。棱柱是一个简单的物体,但树呢?树有很多部分:叶子、树枝、树皮以及各种细节。图形专家已经设计出了非常复杂的方法来创建像树、山脉和生物等复杂的东西,但在大多数情况下,我们不需要那么麻烦。对于艺术作品、动画和游戏,有一些方法可以简化事物(即“作弊”),使它们看起来相当不错,同时仍然容易实现。将树构建为广告牌就是其中之一。

在最简单的形式下,广告牌是一个矩形,上面绘制有纹理。它类似于你在旅游公路上驾驶时看到的广告牌,而在计算机图形学中,广告牌通常只出现在距离观察者较远的位置。为了做一棵树,我们将使用两个垂直的广告牌,它们在各自的垂直中心连接。每个广告牌都是一个矩形,上面贴有树的纹理图像。这个想法是,从任何角度看都能看到完整的树,并且通过移动视角,似乎能够改变树的视图。近距离观察时,很容易看出其中的原理,但从中等距离或在观察者移动时,这种幻象效果非常逼真。

图 57-1 展示了我们如何在三维空间中排列两个垂直的矩形。放置在它们上的纹理需要具有透明背景,否则白色矩形将会显现出来。这意味着需要使用 GIF 或 PNG 格式的图像文件,因为它们支持透明度。

f057001

图 57-1:两个垂直的矩形

草图首先读取我们将用作纹理的树的图像,并像往常一样打开窗口。draw()函数设置摄像机 1,并在原点绘制两个矩形,都使用树作为纹理,并将纹理映射到矩形 2 上,这与草图 56 中所做的类似。我们将第二个纹理映射的矩形旋转 90 度 3,并将其在 x 和 z 方向上平移 13 个单位,使其与第一个矩形的中心对齐。(矩形宽度为 26 单位,13 单位是其中的一半。)

我们还会在每一帧中稍微改变视角,以便在草图执行时,三维效果更为明显。

草图 58:在三维中移动视角

在第一人称电脑游戏中,玩家在游戏中的表现是一个头像,由玩家控制。按 w 键让头像前进,按 s 键让它后退,按 a 键让它左移,按 d 键让它右移。这种方案对于玩家来说容易理解,但比我们一直使用的方案更难实现。

到目前为止展示的草图中,运动是自动的或基于简单的假设——a 和 d 沿 x 轴移动,w 和 s 沿 z 轴移动——但人类的运动并不是这样。a 和 d 键应该使玩家围绕自己的轴旋转,w 和 s 键应该让玩家沿着由角度定义的方向前进或后退。作为头像运动控制的演示,这个草图绘制了九个立方体,并允许用户通过这种技术在它们之间移动。

头像有一个面向的方向,由变量angle(以度为单位)定义。按下 a 和 d 键可以让用户每按一次键改变一个角度。改变角度不会修改相机的位置,但会通过围绕头像旋转改变场景的中心。因为垂直轴是 y 轴,我们可以在 x-z 平面内通过简单的三角函数关系来计算这一点 4:

cx = cos(radians(angle))*20000.0;
cz = sin(radians(angle))*20000.0;

值 20,000 代表一个大距离,实际上是无限的,提供一个遥远的焦点。

按下 w 键让头像沿其面向的方向移动一个单位,这个方向由变量angle定义。每移动一个单位,x位置会改变dxz位置会改变dz,如图 58-1 所定义。

f058001

图 58-1:将(x,z)运动转换为(角度,距离)

头像的位置是(eyexeyez),并且对于任何给定的前进 1 或后退 2 动作,这两个值可能都会变化。按一次键将移动头像 5 个单位,或dx*5

草图 59:聚光灯

如果环境光照关闭且背景较暗,任何绘制在 Processing 图形世界的 3D 空间中的物体都将不可见。这个草图以一种新的方式模拟了光照——作为一个在黑暗空间中的小聚光灯源。聚光灯照射在场景中心坐标上,其余的场景则没有光照。这个草图在场景中放置了三个不同颜色的立方体,用户可以通过旋转并观察立方体的亮起情况来探索这个空间。

这个草图与之前的草图使用相同的keyPressed()代码,因此头像可以旋转并前后移动 3。一个 Processing 聚光灯被放置在相机坐标 1:

spotLight(255,255,20, eyex,eyey,eyez, cx,cy,cz, PI/4, 300);

聚光灯的前三个参数(25525520)代表光线的 RGB 值,接下来的三个参数(eyexeyeyeyez)是光线的 3D 坐标,接下来的三个参数(cxcycz)是光线指向的坐标。这意味着无论相机/化身如何移动,聚光灯始终照射到场景的中心。光线的角度,PI/4(45 度),是第 10 个参数,我们可以增加或减少它,以查看场景发生的变化。数值300表示光线在聚光点中心附近的集中程度,较大的数值表示光线更为集中。

我们可以为其他类型的局部照明定义光源。例如,汽车的车头灯实际上就是两个相隔较小距离的聚光灯。代码中有一个被注释掉的语句,用来为草图中的光源添加第二个光源:

spotLight(255,255,20, eyex+3*dz,eyey,eyez+3*dx, cx,cy,cz, PI/4, 300);

聚光灯只能通过照射在物体上的光线反射来看见,不能作为发光物体被直接看到。点光源和其他光源也是如此。从这个意义上讲,光源并不是物体。围绕光源放置物体会照亮周围的物体,但不会使光源本身可见。

我们可以通过交替调用spotLight()函数或不调用它,来使光源闪烁或改变颜色,取决于一个标志(flash),它的值为真或假。只需在每一帧后改变计数器,并在固定的帧数(此处为 20 帧)后改变标志。以下代码交替地用红色或蓝色照亮两个球体,就像警车的闪烁灯一样。

if (count % 20 == 0) flash = !flash;  // If flash is true, make it false
if (flash)   spotLight(255,0,0,  0,  335,  0, 0,  -1, 0, PI/4, 300);
else   spotLight(0,0,255,  50,  335,  0, 0,  -1, 0, PI/4, 300);
sphere(20);
translate (50, 0, 0); sphere(20); translate (50, 0, 0);

草图 60:驾驶模拟

驾驶模拟和游戏具有特定的标准界面和视觉呈现。与之前的草图不同,在那些草图中用户可以在空间中移动但自己不可见,驾驶模拟显示的是化身为一辆车,摄像头(视角)通常位于车后方且偏上方,以便车的视角始终朝向前方。汽车行驶在道路上,因此背景非常重要;没有背景,用户就无法判断自己是否在道路上,也无法准确知道自己的行驶速度。这一草图将允许用户驾驶一辆车(实际上是一个长方体)沿着轨道行驶,使用与之前相同的方式来移动化身。

首先需要创建一个轨道。它将只是一个图像,因此我们可以使用画图工具或其他绘图程序。图像的大小应足够大,以提供一些娱乐性(多样性),并且在显示时不会严重失真。图中显示的示例(图 60-1)是 1,000×1,000 像素。

f060001

图 60-1:简单的驾驶轨道

草图读取这张图像,并将其用作绘制在 x-z 平面上的 1,000×1,000 方形纹理:

beginShape(QUADS);
  texture (track);
  vertex (0, 0, 0, 0, 0);   vertex (1000, 0, 0, 1000, 0);
  vertex (1000, 0, 1000, 1000, 1000);
  vertex (0, 0, 1000, 0, 1000);
endShape();

视角需要位于汽车的上方和后方。如果变量 dxdz 代表给定 angle(汽车当前的朝向角度)下 x 和 z 方向的单位变化,而 carXcarZ 是汽车的水平和垂直位置,那么视角应该是这样设置的 3:

eyex = carX - dx*50;  eyez = carZ - dz*50;

它应该有一个固定的高度 eyey=20。值 50 是一个与图像大小相关的缩放因子。

我们在坐标 (carX, 0, carZ) 处绘制汽车。每次移动后,这些坐标会随着汽车速度(变量 velocity)的变化而变化;velocity 的值会随着用户按下 w 和 s 键而增加或减少(与之前的草图不同,在之前的草图中我们使用这些键来向前和向后移动)。w 键是油门踏板,s 键是刹车。汽车在用户按下任意一个键后会保持当前速度,用户可以专注于通过 a 和 d 键进行转向。

我们通过以下方式计算汽车的运动:

carX = carX + velocity*dx 
carZ = carZ + velocity*dz 

然后我们使用 translate() 函数使汽车朝向运动方向 2。汽车应该始终背对相机,因此为了让汽车朝向正确的方向,我们将其旋转 –angle

效果是,汽车(一辆红色的棱柱体)可以加速(w)和减速(s),并可以向左转(a)或向右转(d),以保持在灰色的圆形路径上,同时相机保持在适当的距离跟随汽车。

第八章:高级图形与动画

草图 61:图层叠加

现代图形程序(如 Photoshop)的一个关键特性是图层的概念。这是创建一组图形对象(图像),将它们叠加以实现复杂效果。透明度使得可以看到下层的物体。这个草图使用了三层:月亮的图像、环绕陨石坑的圆圈和一个目标显示(准星)。通过键盘,用户可以重新定位月亮图像。界面的目标是让用户将准星与目标圆圈对齐。

在草图窗口中绘制涉及使用一种名为PGraphics的图形对象类。background()line()ellipse()等函数,许多其他函数,都是PGraphics类的一部分,尽管我们可以在没有PGraphics对象的情况下使用它们。或者,我们的绘图可以发生在这些对象之一中,然后通过调用image()将其显示在屏幕上。这个草图将在PGraphics实例中绘制月亮图像,并用椭圆突出显示陨石坑,然后我们会在草图窗口中显示它。

用于PGraphics对象的变量名为pg,创建该对象的函数被称为(合乎情理地)createGraphics() 1:

PGraphics pg;
pg = createGraphics(moon.width, moon.height);

这里,moonPImage变量,保存了月亮的背景图像。

要在PGraphics对象中绘制,我们使用之前使用过的图形函数,但指定pg变量作为目标 2:

pg.beginDraw(); 
pg.image(moon, 0, 0);
pg.stroke (0, 200, 0); 
pg.noFill(); 
pg.ellipse (393, 233, 12, 12);
pg.endDraw();

绘制之前会调用beginDraw(),这是一个类似于括号的函数;对应的结束括号是调用endDraw()。如果不使用这些调用,Processing 将无法初始化对象,绘制将无法进行(尽管 Processing 可能不会生成错误)。前面的代码在PGraphics对象中绘制月亮图像,并在目标周围绘制一个圆圈。

draw()函数通过调用image(pg, xoff, yoff)来显示PGraphics对象,其中xoffyoff是位置偏移量,使用传统的 W、A、S 和 D 键来控制 3。(PGraphics对象具有PImage的许多属性,因为image()可以显示两者。)xoffyoff的值通常为负数,以便将底层图形向左和向上移动,保持窗口稳定,从其在左上角的起始点开始。draw()函数还绘制了一个准星,作为指向窗口中心的小线条集 4。

草图 62:通过窗口看世界

许多游戏、动画和仿真(例如驾驶或太空旅行)将通过窗口的视图作为界面的一部分。这个草图实现了一个可以看到 3D 场景的窗口,并允许用户在场景中移动,同时透过窗口查看。

这是一个更高级的PGraphics应用。我们将渲染一个简单的 3D 场景到一个名为 pgPGraphics 实例中,将一个具有透明部分的 2D 图像(窗户)读取到一个名为 g2PGraphics 实例中,并通过调用 image() 将两个图形对象绘制到屏幕上。

我们将用于绘制 3D 场景的 3D 基本图形原语都属于 PGraphics。我们将通过 createGraphics() 的参数启用 3D 渲染引擎,1 而不是像草图 51 中那样通过 size() 来启用,然后通过调用 camera() 2 和 ambientLight() 来设置 3D 参数。基本的 size() 调用设置图形窗口;每个 PGraphics 实例就像是一个单独的绘图窗口,可以通过点符号来使用所有常见的图形方法:pg.line()pg.ellipse() 等等。直到被绘制在图形窗口中,PGraphics 对象才会显示。因此,我们可以在 pg 对象内部创建一个模拟的 3D 空间,在那里绘制四个立方体,作为通过窗户查看的目标。

2D 部分涉及显示一个 2D 图像(一个名为 backPImage 变量),它表示窗户(图 62-1)。该 GIF 图像具有透明部分,通过使用图像编辑器(如 Photoshop)将某个颜色(在这种情况下是绿色)定义为透明来创建。我们称这种类型的图像为模板。

f062001

图 62-1:窗户的模板

草图首先绘制 pg(3D 渲染)3,再绘制 g2(模板)4。g2 的透明部分允许通过窗户部分看到 3D 场景。

用户可以使用键盘按常规方式控制 3D 场景的视角。5 3D 场景会根据视角变化而变化,但 2D 场景则不会。结果是窗户保持在原地,但透过窗户看到的画面(透明部分)会随着视角的变化而变化,就像用户在移动的车辆内看到外面场景一样。

草图 63:PShape 对象——一个旋转的行星

这个草图将展示一个旋转的行星(一个球体),并允许用户在 3D 空间中环绕它移动。这个草图中新加入的部分是将行星表面纹理映射到球体上,而球体本质上是由多边形组成的。实现这种效果的一种方法是用多边形构建一个球体模型,并在 beginShape()endShape() 块内进行纹理映射。更简单的方法是使用 PShape 对象,它是一种用于存储任意形状的数据类型。

为了实现旋转的行星,我们将通过调用createShape()来创建一个PShape对象,这使我们能够利用PShape类提供的大量绘图操作构建任意复杂的形状。使用PShape几乎可以创建任何东西,而在线文档对于复杂的创作是必不可少的。我们的例子很简单,因为球体是已提供的形状之一。这是创建行星的调用,其中globe是一个PShape对象 1:

globe = createShape(SPHERE, 100);  // 100 is the size of the sphere

纹理,作为一个名为timgPImage变量,应用于globe对象,使用globe.setTexture(timg) 2。

然后我们在draw()函数中通过调用shape()函数 3 来显示行星。

translate (x, y, z);
globe.rotateY(radians(0.5));  // rotateY function is a part of PShape
shape(globe);                 // This displays the shape in the window

这段代码将球体放置在视野中心,并绕其自身轴旋转,随后进行显示。常用的按键允许用户改变视角。

草图 64:样条—绘制曲线

到目前为止,我们已经使用预定义的 Processing 函数渲染了简单的几何物体,如线条、椭圆、矩形和球体。但许多现实世界中的物体并非线性或椭圆形的;它们有复杂的形状。比如汽车、风扇叶片、珠宝、衣物以及生物体,甚至数据图表等,都是复杂形状的例子。在 Processing 中,复杂形状通过曲线来渲染。为了演示,这个草图允许用户通过一系列鼠标点击来绘制曲线,并观察所选的“控制点”如何影响曲线。

Processing 使用splines来渲染曲线。在早期的绘图时代,当人们使用铅笔和 T 型直尺时,曾使用一种叫做样条的工具来绘制平滑的、不规则的曲线。它是一条长而灵活的金属条,可以保持形状,和纸上的点对齐,并允许绘图人员用铅笔将这些点连接起来。从数学上讲,样条是一种多项式函数,通过使用一组点来逼近曲线。具体细节可能很复杂,但基本思想是使用许多连接在一起的多项式来构建曲线。Processing 隐藏了这些复杂性。

Processing 提供了一个名为curve()的函数,来实现一种叫做 Catmull-Rom 样条的多项式类型。该函数使用四个点来定义曲线的每一部分。前两个点定义曲线开始时的方向,后两个点定义曲线结束时的方向。曲线本身由中间两个点之间的一组点(像素)组成。如图 64-1 所示,前两个点定义的角度确定了曲线在 P[1]点的方向,从而定义了 P[1]和 P[2]之间多边形的形状;通过 P[2]和 P[3]之间的方向来确定曲线在 P[2]的方向。在图中,两个示例中的 P[1]和 P[2]是相同的,但由于 P[0]和 P[3]的位置不同,曲线的形状也有所不同。

f064001

图 64-1:样条曲线的控制点

这是在 Processing 中用来绘制 P[1]=(x1,y1)和 P[2]=(x2,y2)之间曲线段的函数调用:

curve (x0,y0,  x1,y1,  x2,y2,  x3,y3);

起始点和结束点(x0y0)和(x3y3)控制曲线的形状 1。为了绘制更长的曲线,我们需要多次调用curve(),每次的结束点是下一个曲线的起始点。

这个草图允许用户选择点,这些点通过鼠标点击绘制为小红圈 2,并观察随着鼠标移动,下一个点的位置如何影响曲线形状。四个点定义了一条曲线,因此当用户选择第四个点时,将使用指定的点绘制一条红色曲线,然后从最后一个点到鼠标位置(mouseXmouseY)绘制一条蓝色曲线,随着鼠标移动而变化。再次点击将向曲线添加一个新点,将红色部分扩展以包含新点,并显示一个新的蓝色部分。按下 BACKSPACE 键将删除曲线中的最后一个点 3,按下空格键将打开或关闭最后一段(蓝色)曲线的绘制 4。

这个草图将点坐标保存在数组xy中,并将连续的四个坐标组传递给curve()函数。

草图 65:带有路标的驾驶模拟

草图 60 允许用户在 3D 赛道上驾驶,草图 64 展示了如何创建曲线,比如模拟汽车可以驾驶的赛道。计算机驾驶游戏通常有自动化的车辆与玩家竞争,给人一种真实对手的印象。这个草图将实现一个类似于那些计算机游戏中使用的方法的计算机控制汽车系统。

重要的是要意识到,游戏和模拟不一定像人类一样做事情。人类驾驶员会根据他们看到的下一个转弯来调整汽车的方向,并不断地转动方向盘以保持在车道上。我们也可以编写一个计算机程序来实现这一点,但这会相当复杂。另一种选择是使用关于赛道的预设知识来控制车辆。在这种情况下,程序员必须在一开始就为程序提供更多信息,但代码的简洁性弥补了这一努力。

具体来说,程序员将赛道分解成线性段。线性段应该尽可能长,并在称为路标的顶点处连接,路标是线的方向发生变化的地方,也是汽车改变方向的地方。(我们可以以这种方式分解任何曲线。)每个路标都有一个编号或标签,由程序员分配。当汽车到达路标 1 时,程序将改变其运动方向,朝向路标 2 移动。当它到达路标 2 时,它将转向路标 3。由于这些段是直线的,因此我们不需要在路标之间转向。

本草图将路径点实现为一个数组集合,每个数组包含路径点的一个维度。路径点 i 的位置存储在数组 wpx[i]wpy[i] 中。在更精确的模拟中,路径点会关联更多信息:速度和加速度的变化、转弯的变化率,甚至可能包括一些图形信息,比如刹车灯的亮起。在当前草图中,唯一需要的其他信息是当前路径点和下一个路径点之间的角度,这样我们就能旋转车辆,使其朝向新的方向。我们本可以计算这个角度,但这会需要更多的代码,而路径点的位置和它们之间的角度可以预先确定。我们声明了数组 wpxwpywpa,并用位置和角度数据初始化它们,这也隐含地定义了数组的大小。(不能同时使用数字指定数组大小并通过数据初始化它。)

使用车辆的指定 speed(通过 W 和 S 键更改),我们计算其每帧的位移变化为 dx = speed * ( wpx(i+1) - wpx(i) )/d(i,i+1),其中 d(i,i+1) 是路径点 ii+1 之间的距离。我们说车辆到达路径点 i 时,它距离该点的像素数在 speed 范围内,此时它会改变方向,瞄准下一个路径点 i+1wayPoint 变量表示最后到达的路径点,意味着车辆正在瞄准 wayPoint+1。路径点计数会在末尾环绕,因此我们按模 N 递增,其中 N 是路径点的数量:路径点 N 之后的路径点是 0。

按下空格键可以让用户查看路径点和路径的位置。

草图 66:许多小物体——一场雪暴

一个 Processing 程序每秒钟会重绘多次屏幕。每一帧中,屏幕上的可见物体都需要被重新绘制,为了做到这一点,程序必须保存所有物体的图形参数(大小、位置、形状和颜色)。绘制每个物体需要时间,那么如果物体很多,是否仍然能够足够快地重绘它们?在许多情况下,如果物体本身不复杂,是完全可能的。本草图将绘制雪花飘落,每一片雪花都是一个物体,它在每一帧之间会真实地移动。

雪花实际上是非常复杂的形状,但从远处看,它们只是白色的斑块。我们将它们绘制为小矩形,每一帧的宽度和高度都会略微随机变化,以模拟雪花在下落过程中轻微飘动的效果。我们通过公式 width = size + random(3)-1.5 来设置尺寸。

size 是一个常量,设置为 3,而 random(3) 的值是介于 0 和 3 之间的一个数字,因此 random(3)-1.5 的值将介于 −1.5 和 +1.5 之间,从而导致大小在 1.5 和 4.5 之间变化。每个雪花的下落速度也略有不同。这种差异造成了深度的假象,因为下落更快的雪花看起来离观察者更近,而下落较慢的雪花则更远。速度是随机选择的,但它达到了预期的效果。

程序会在屏幕顶部生成雪花,并给它们设置一个向下(+y)速度,这样它们看起来就会下落。为了追踪每片雪花的位置信息、大小和速度(在 x 和 y 方向上),我们使用数组:例如,数组 x 存储 x 位置,x[i] 是第 i 个雪花的 x 位置。数组大小由常量 SIZE 给出,表示最大雪花数量。(这个值是 5,000,是通过试验和误差,根据观察到的背景和最大降雪速率确定的。)

雪花通常不会垂直下落;我们看到它们随着气流飘动和漂浮。雪花下落的速度保持不变,但每片雪花的 x 位置会在下落过程中随机变化一些,以模拟真实的雪花效果。如果我们将 dx 设置为非零值,它就模拟了风的作用,雪花将会在指定的方向上吹动。

每一帧,我们生成最多 30 片新的雪花,随机设置它们的横向位置和 y 坐标为 0 4(位于窗口顶部,以保持假象)。每帧生成的雪花数量是随机的,但它与鼠标的 y 位置有关。鼠标越接近屏幕顶部,看到的雪花就越少。以下是生成的雪花数量:

N = (int)random (((float)mouseY/height)*30);

这意味着,对于小的 mouseY 值,几乎没有雪花会落下,而当 mouseY/height 达到最大值 1.0 时,每帧会最多生成 30 片新的雪花。

全局变量 SIZE 的值为 5,000,这是屏幕上可以同时显示的最大雪花数量。最初只有几个雪花,但数组会迅速填满。当所有 5,000 个数组元素被占用时,我们会从 0 开始重新计数,假设数组开始位置的雪花已经掉过屏幕底部并且不可见。这个技术被称为循环数组。

草图 67:粒子图形—烟雾

有些东西用多边形建模很困难,例如水、火、云和烟雾等柔软且无定形的物体。这些物体的运动方式不可预测,并且可以扩展以填充任意形状。这个草图将绘制从烟囱中冒出的烟雾,并展示现代计算机图形学中的一个关键方法:粒子系统。

粒子系统将大量小物体组合起来,形成复杂的形状。这些物体通常很简单,比如球体或圆形,并具有一组控制其显示的参数。圆形的基本参数包括位置、速度、颜色和大小。初始参数通常包含随机元素,例如速度加上或减去一个随机数。发射器是系统创建新粒子(圆形)的地方,通常带有一个小的随机位移,这样粒子就不会完全位于发射器位置。

这个草图中的粒子系统生成了大量重叠的圆形,这些圆形可能是半透明的,以略微不同的速度移动(与之前的草图相比,唯一不同的是密度)。之前的草图绘制了大量小物体,仍然可以看作是单独的雪花。而在这个草图中,如果粒子的数量足够多,我们就无法区分它们的个体,它们会组合成一个物体。随着数量的增加和物体的重叠,结果看起来像雾气或烟雾。

该草图定义了要创建的大量圆形(SIZE),并声明了数组来保存每个圆形的位置、速度和大小。例如,x[121]的值表示第 121 个圆形的 x 位置。初始时没有圆形,变量last保存最后一个定义的圆形的索引。每一帧我们创建新的圆形时都会增加last的值,当圆形的数量超过SIZE时,我们会将其重置为零。

draw()函数首先遍历数组,绘制每一个存在的圆形(意味着x[i] > 0)1。它会将圆形的位置稍微改变,可能会略微改变大小,并赋予它一个围绕 RGB = (205, 205, 150)变化的颜色。然后,它会创建一个随机数量的新圆形,赋予它们靠近发射器的位置、垂直的速度和一个较小的大小 2。

效果非常引人注目。通过多达 800 个圆形,系统能极好地模拟烟雾向上升腾的效果。该草图展示并呈现了一个烟囱的背景图,以增强视觉效果。

圆形的轮廓已经通过noStroke()关闭,但将该语句删除并运行程序,可以看到粒子的形态。这样,粒子的移动和重叠变得更加清晰,如图 67-1 所示。

f067001

图 67-1:粒子显示圆形的轮廓

草图 68:保存状态——旋转螺旋桨

这个草图将绘制一个旋转的螺旋桨。我们可以用多种方式编写代码,有些方式比这个草图中的方法更简单,但这里的目的是通过一个简单的例子来解释如何以及为什么要保存(和恢复)草图的几何状态。

几何状态是所有平移、旋转和缩放的结果组合,这些操作在绘制过程中的某一特定时刻累积。围绕物体的中心旋转意味着首先将原点平移到物体的中心,进行旋转,然后再将原点平移回原位置。如果不通过撤销平移来恢复状态,那么从此时起绘制的所有物体都会平移到该物体的位置。

当前状态,无论是什么,包括所有的旋转、平移和缩放,都是通过调用函数pushMatrix()来保存的,并通过调用popMatrix()来恢复。这些调用必须始终成对出现,就像括号一样;调用pushMatrix()总是有一个对应的popMatrix()调用。例如,您可以在围绕(100, 100)的中心旋转一个三角形时,保存并恢复状态,如图 68-1 所示,代码如下:

pushMatrix();
translate (100, 100);
rotate(angle);
triangle (0.-20, 20, -20, -20, 20);
popMatrix();

此时,原点和旋转角度已恢复到原始值,接下来可以从一个干净的状态绘制下一个物体。

这个草图绘制了一个四部分的螺旋桨,每个部分是一片刀片,即一张图片。我们绘制这片刀片四次:一次是原始方向,然后是围绕螺旋桨中心点每次旋转 90 度,共三次。每个部分的绘制都使用了保存和恢复状态:

pushMatrix();            // Save 
rotate(PI);              // Rotate 
image (prop, 0, 0);      // Draw 
popMatrix();             // Restore

这个四部分的螺旋桨绘制在一个drawProp(x, y)函数中,该函数在进入时保存状态,然后平移到(x, y),缩放图像,旋转图像,更新角度,以便下次调用drawProp()时绘制出不同角度的螺旋桨,并绘制四个部分。我们通过多次调用drawProp()函数,在多个位置绘制旋转中的螺旋桨。

f068001

图 68-1:围绕物体中心旋转所需的变换

草图 69:L 系统——绘制植物

绘制逼真的植物是困难的。生物体通常没有直线,而直线是计算机最擅长绘制的形状。此外,生命形式有一种人类能够识别的随机性,因此我们对渲染的结果会产生批判。1968 年,一位名叫阿里斯蒂德·林登梅耶的植物学家开发了一种描述真菌和藻类生长的方案,后来将其扩展到处理更高级的植物生命。此方案随后被计算机图形学从业者改编成一种绘制植物的方案。我们称这种方案为 L 系统。

L 系统在技术上是一种语法,是一组用于生成字符串的规则。如果语法有两条规则,X -> Xf 和 X -> z,那么它就展示了如何将一个符号 X 转换成一个字符序列。对于每个 X,我们选择遵循哪个替换规则,并继续替换大写 X(称为非终结符号),直到没有更多可以替换的 X。以下是该语法的一个扩展示例:X -> Xf -> Xff -> Xfff -> zfff。

在 L 系统中,一种可以定义植物的文法,最终的字符串代表着绘制某物的配方。它使用以下符号:

  1. f 绘制一条直线段。

  2. [ 保存当前状态(pushMatrix())。

  3. ] 返回到先前的状态(popMatrix())。

    • 按固定的正角度旋转。
    • 按固定的负角度旋转。

该文法使用两个规则来生成这些符号的字符串:

  1. X -> ff

  2. X -> f–[[X]+X]+f[+fX]–X

除非植物只包含两条直线(ff),否则第一步将是 X –> f–[[X]+X]+f[+fX]–X。然后,每个 X 将被替换为产生式的右侧,因此第二步可能是 f–[[X]+X]+f[+fX]–X –> f–[[ f–[[X]+X]+f[+fX]–X]+ff]+f[+ff]–ff,接下来可能是 f–[[ f–[[ff]+ff]+f[+fff]–ff]+ff]+f[+ff]–ff,现在可以绘制了。

makeString()函数 1 会调用自身,将非终结符号 X 展开为字符串,并将这些字符串附加到正在构建的字符串上。它只会根据第一个参数levels指定的深度调用自身,然后返回,从而保证程序最终会结束。文法生成的字符串传递给drawPlant()函数 2,后者将每个字符作为图形操作执行,从而绘制植物。在void drawPlant(float length, float angle, String s, int drawLevel)函数中,第一个参数length是绘制直线段的长度(对于符号 f);angle是+和-字符的旋转角度;s是由makeString()生成的字符串;drawLevel表示绘制线条的深度级别。本质上,makeString()创建了一个绘制植物的程序字符串,而drawPlant()执行这个程序。

示例图 70:图像的变形

1991 年,公众首次见到了形态变换效果,这是一种利用计算机平滑地将一幅图像转换成另一幅图像的效果。计算机生成一小段图像序列,当这些图像以视频序列回放时,一个物体似乎会不断地改变形状,直到变成另一个物体。电影《终结者 2》使用了这一效果,最具代表性的是迈克尔·杰克逊的《黑白》音乐视频,其中人物的面孔相互变形。这段示意图执行的是图像的弯曲或变形,但并没有进行完全的形态变换。

形态变换方法的原理是所谓的多项式变形。假设我们将图像放置在一个常规网格上,然后使用数学函数弯曲网格并将图像一同弯曲。结果是图像以特定的方式发生变化——变形。两个图像之间的形态变换要求我们建立图像之间的对应关系,通常由人工完成。一个函数将(映射)一幅图像变形为另一幅图像(变形),而像素的颜色值会系统性地从源值变到目标值。

如果图像是一张人脸,并且扭曲基于正弦曲线,那么我们会得到一种像是镜厅效果的视觉效果,如图 70-1 所示。原始人脸的几何形状会根据函数变形(映射)到新的人脸几何形状。

f070001

图 70-1:扭曲人脸

这个草图实现了图像的扭曲。我们读取一张图像,并根据正弦函数变换坐标来显示像素。原始图像是source,这是原始和新像素坐标之间的映射:

newX = (int)(x + size*sin(radians(3*y)));
newY = (int)(y + size*cos(radians(4*x)));

这个映射是任意选择的,目的是为了创造有趣的效果。执行映射的循环 2 必须将目标像素值映射回源像素,而不是反过来。如果我们反向映射,源图像中的每个像素确实对应目标图像中的一个像素,但结果中可能会出现未映射的像素。因此,对于目标图像中的每个像素(x,y),我们使用所需的函数将其转换为(newX, newY)值,然后找到源图像中对应的像素。接着,我们将目标图像中的(x,y)像素设置为源图像中的(newX, newY)

在这个草图中,dssize的值是变换函数的参数,它们每一帧都会略微变化 1,从而在图像中创造出一个周期性的变化,这种变化会被人眼感知为弯曲或扭曲的动画效果。

第九章:处理声音

草图 71:播放声音文件

一方面显示的是图像,但播放的是声音;这是为什么呢?无论原因是什么,Processing 并没有标准的音频显示功能。不过,它确实有一些库可以实现这一目的,最重要的就是 Minim。(我们在草图 50 中使用了一个库。)

使用 Minim,这个草图将通过标准的 PC 音频接口播放一个 MP3 或 WAV 格式的声音文件。除此之外,如果用户按下 A 键,声音将会朝左扬声器移动;如果他们按下 D 键(在 A 键的右边),声音将会朝右扬声器移动。

程序中的第一条语句 1 表示我们想要访问 Minim 库:

`import ddf.minim.*;`

然后我们需要创建一个 Minim 库的单一实例。Minim 库是一个类,包含了能够加载和播放声音文件的函数。定义一个名为 minimMinim 类型的变量,并在 setup() 函数 3 中初始化它,如下所示:

minim = new Minim(this);

现在声明一个声音播放器变量 2:

AudioPlayer player;

使用 Minim 函数 loadFile() 4 将其分配为从 MP3 文件读取的声音文件:

player = minim.loadFile ("song.mp3");

我们可以使用 play() 函数 5,通过计算机的音频硬件播放该文件,这是 AudioPlayer 的一部分:

player.play();

为了改变立体声扬声器中声音的平衡(声道),用户按下 A(左)和 D(右)键。每按一次键,就会向 pan 变量添加一个小值,或从中减去一个小值,然后用这个变量来设置平衡 6:

player.setPan (pan)

对于其他效果,控制声音显示的函数种类繁多,包括获取和设置平衡/声道、增益和音量的函数:getBalance()getVolume()getGain()。Minim 的文档可能会在网上有所变动,但在 2022 年可以在 code.compartmental.net/2007/03/27/minim-an-audio-library-for-processing/ 找到。

草图 72:显示声音的音量

草图 71 的显示效果并不特别引人注目。它的显示是听觉上的,虽然这与其主要功能相符,但 Processing 语言通常会生成更多图形输出。一种显而易见的方法是通过视觉方式显示声音的音量,像是通过表盘上的数字,或者像本草图一样,通过垂直条形的高度。

为了让这个草图正常工作,我们必须获取从文件中读取的声音的数值。MinimAudioInput 组件类允许与计算机当前的录音源设备建立连接。为了让这个草图正常运行,用户需要将源设备设置为监控正在播放的声音。例如,如果声音输入来自一个文件,我们可以使用如下代码:

3 player = minim.loadFile ("song.mp3");

假设这一点成立,草图使用 AudioInput 类型的变量(命名为 in 1),并通过 getLineIn() 2 来初始化它:

in = minim.getLineIn(Minim.STEREO);

现在,变量in可以访问属于AudioInput的函数,包括获取单个数据值的功能。计算机中的声音由采样电压组成,这些电压被重新缩放到一个便于使用的范围。因此,音频值是一个数字,通常在 −1 和 +1 之间,代表音量。我们可以访问每个立体声通道:左通道是in.left,右通道是in.right(这些是AudioBuffer类型,也就是一个实数数组)。get()函数允许访问数值:

ly = in.left.get(128);

这会获取缓冲区中的第一个值,它可能是正数或负数,因此为了显示目的,最好使用值abs(in.left.get(128))*2 4,它仅仅是将值的大小移动到 0 到 2 的范围内。现在这个数字可以表示矩形 6 的高度,与声音的音量成比例:

rect (100, 200, 20, -ly*100);

同样的过程适用于左通道和右通道。

加载到变量 player 中的声音的总时长是player.duration();假设正在播放,则当前的播放位置是player.position()。当声音播放完毕时,player.length() <= player.position()Minim的规范要求在结束时关闭并停止Minim,以确保资源被归还给系统(通过in.close(); minim.stop();)。在草图中,stop()函数 7 做了这一点。

该草图还显示了声音数据的数值。一个实数可能包含很多位数,其中大多数实际上并不重要。为了像草图中那样只显示两位小数,将数值乘以 100,然后将其转换为整数。这会去掉剩余的小数部分(所有右侧的数字)。然后再将其转换回实数并除以 100 5:

(int)(ly*100)/100.0

草图 73:带有音效的弹跳球

在电影、动画、戏剧和电脑游戏中,音效通常是一个短小的音频片段,用来表示某个事件的发生。电话铃声、球棒击打棒球的声音、石头落入湖中的水花声都是音效的例子。这个草图将展示如何在简单的模拟中使用音效。

草图 28 模拟了一个弹跳球。看起来不错,但如果每次弹跳都伴有声音效果,会更像一个动画。声音是人类的重要提示,音效为图形增添了真实感。它不需要非常精准,只要是与事件对应的一些点击或碰撞声就可以。从草图 28 的代码开始,我们将添加来自 Minim 库的 AudioPlayer 对象,在球与窗口的边缘碰撞时播放一个短的 MP3 文件。

为了创建声音效果,我们将使用 PC 麦克风和免费提供的声音编辑/捕捉工具(例如 Audacity (www.audacityteam.org/) 或 GoldWave (www.goldwave.ca/))保存一个重击声(例如,球弹到地板上或杯子放在桌子上的声音)。这个示例假设声音已经保存为 click.mp3

Minim 初始化后 1,AudioPlayer(变量 player)读取 MP3 文件。当球击中窗口的某一侧时,xbounce()2 和 ybounce()5 函数检测到这一点,球改变方向,然后通过调用 player.play() 3 播放声音。

每次播放声音文件之前,我们必须倒带文件,确保它从头开始播放。AudioPlayer 内的 rewind() 函数完成了这个操作。

示例 74:混合两种声音

在声音混音的过程中,我们将多个声音源分配到不同的输出级别或音量。在现场音乐会中,这可以使每个乐器的声音以适当的音量级别听到。我们在录制多个声音源时也会这么做,例如麦克风、吉他和其他乐器,这些音源的音量需要调整,以确保没有一个元素的音量压过整体的声音。混音器已经存在很长时间了,大多数混音器都有滑动控制来调整多个声音信号的音量。这个示例将使用在示例 43 中开发的滑动控制来调整两个不同声音文件的音量。

示例首先声明了两个 AudioPlayer 变量 1,一个用于每个声音,加载声音文件 2,并开始播放它们 3。接下来,我们创建两个滑动控制;一个是控制 A,具有以“a”开头的位置和控制变量(asliderXasliderYavalue),另一个是控制 B(bsliderXbsliderY 等)。滑动控制 A 的值用于设置第一个声音文件(由 playera 播放)的音量,滑动控制 B 控制另一个文件(由 playerb 播放)的音量。

我们通过调用 Minim 函数 setGain() 来设置输出级别。这个函数有一个参数表示增益的值(与音量成正比)。增益的单位是分贝(dB),范围从 −80 到 +14,总共 94 dB 的范围。滑动控制的总范围是 1,000。因此,playera 的增益通过以下调用 4 设置:

playera.setGain(avalue/1000.0 * 94 - 80);

如果滑动控制的值为最小值 0,增益将为 0/1,000 * 94 − 80 = 0 − 80 = −80。若滑动控制的值为最大值 1,000,增益将为 1,000/1,000 * 94 − 80 = 94 − 80 = 14。增益值在极值下正确输出,这支持了映射是正确的观点。不过,dB(分贝)刻度是对数的,因此这是对真实值的近似。

当草图执行时,两个声音文件将播放。将上方滑块向右滑动将增加sounda.mp3文件的音量,而滑动下方滑块将控制soundb.mp3文件的音量。其目的是找到听起来合适的相对音量。

草图 75:显示音频波形

大多数基于计算机的声音编辑器显示音频信号的图形渲染,并允许用户用鼠标“抓取”其中的部分并移动或删除它们。这个图形显示实际上是音频音量与时间的关系图。一些音乐播放器实时显示这种图表,在音乐播放时展示。这正是这个草图将要做的。它绘制计算机播放的任何声音的图表。

绘制这一点需要能够实时获取声音数据作为数字。稍微的误差并不重要,因为这不是一个科学工具,所以可以使用草图 72 中的部分代码,该代码也显示了音频可视化。在这里,我们将填充一个声音缓冲区,然后将其作为声音数据播放,直到数据播放完毕。

音频以一组连续的数值表示,这些数值可以合理地存储在数组(缓冲区)中。通常有两个通道(立体声),并且可以通过in.left_get()in.right_get()函数检索缓冲区中的任何值,指定所需的样本。例如,程序通过调用left_get() 3 获取左通道的数据点,并使用此值表示当前缓冲区中的所有级别。这只是一个数据点,来自许多样本,并且在调用getLineIn()时可以指定缓冲区的大小。系统从这个缓冲区播放声音,并在需要更多数据时重新填充它。我们指定每个缓冲区 1 包含 1,024 个样本:

in = minim.getLineIn(Minim.STEREO, 1024);

如果窗口宽度为 512 像素,则每 2 个样本对应 1 个像素,其高度是通过调用get()获取的值。假设数据元素的值在-1 到+1 之间,我们将 1,024 个数据点绘制为从(i, datai)到(i+1, datai+1)的线条,其中所有i在 0 到 1,023 之间,每次跳 2 个 2。这在图 75-1 中进行了说明。

f075001

图 75-1:缩放样本并将其绘制为线条

换句话说,我们得到如下内容:

for (int i=0; i<1024; i=i+2)
{
  ly = in.left.get(i)*100+height/2;
  if (i!=0) line (i, y, i+2, ly);
  y = ly;
}

我们在draw()函数中执行此操作,这样它每秒刷新 10 次并显示音频的动画版本。我们通过乘以 100 来缩放数据,使总高度为 200 像素,然后通过将该值加到数据点上将其平移到窗口的垂直中心。

草图 76:通过声音控制图形

基于 PC 的音乐播放器通常会提供一组可视化工具,展示与音乐同步变化的抽象动态图像,如图 76-1 所示。草图 75 是一个显示实际信号的可视化工具,这对于信号分析和编辑非常有用,但音乐播放器可视化的目的是通过呈现有趣的图像来娱乐用户。这个草图展示了实现这样一个可视化工具的尝试。

f076001

图 76-1:一个示例可视化工具

使用音乐控制图像有很多方式,但其基本思路是从声音数据中提取数字,并将其作为参数应用于某个图形模型,从而使显示响应实际的声音。除了前面草图中描述的原始声音数据点,我们还希望测量能够表示声音变化的数值,这样显示效果才会动态变化。两个连续值之间的差异是一种衡量标准。这些数字通常会相似,因此两个固定时间间隔内的数值可能会提供更好的数值范围。另一个思路是使用左右声道之间的差异。更复杂的测量包括数据值与短时间内平均值之间的差异,或者一段时间内最大值与最小值之间的差异。

一旦我们决定使用哪些测量值,我们将如何使用这些数值?这取决于我们想要的视觉效果。它们可以表示 x、y 位置、颜色、速度,甚至是形状参数。

本草图将使用椭圆作为显示的基础。当前缓冲区左右声道的数据将定义在屏幕中央绘制椭圆的宽度和高度参数。椭圆的大小将在每一帧增加五个像素,因此它将从中心向外增长 2。椭圆的颜色将与当前左声道数据值与前一个缓冲区相应的左声道数据值之间的差异 4 相关联;这意味着颜色是时间变化的函数。通过为每个椭圆绘制一个透明度(alpha)值为 30 的颜色,我们可以让这些颜色相互融合。由于使用了透明度,我们应该先绘制最大的椭圆,然后再绘制较小的椭圆,否则较小的椭圆可能会被上面的椭圆遮挡。我们必须为这些椭圆维护一组参数,以便每次迭代都能正确显示它们,我们通过将它们保存在一组数组中来做到这一点:colorshsizevsize 用于椭圆的颜色和大小。

启动程序后,再用 PC 上的另一个程序播放一个声音文件。该草图从声音 3 中提取数值参数,并在每一帧 1 中显示相应的椭圆。考虑到方法的简单性,视觉效果出奇地有趣。

草图 77:位置声音

因为人类有两只耳朵,我们大致可以识别声音的位置。我们部分是通过声音到达每只耳朵的时间差和声音的音量来实现的。声音在靠近源头的耳朵中会更响,我们可以利用这一事实,通过计算机模拟位置音效。在这个示例中,我们将播放一个声音,并让用户在窗口中央选择一个听音位置。用户可以移动,使用 A 和 D 键改变朝向,使用 W 和 S 键向前或向后移动。

当用户正对着或背对着声音源时,两只耳朵的音量应该差不多。用户面对声音源时,如果左耳朝向源,左耳的音量最大,右耳的音量最小;反之亦然,如果右耳朝向声音源时。考虑到这一点,我们可以根据用户的朝向来调整左右耳音量的大小,从最响的左耳到最响的右耳。

假设有一个由听者的位置、声音源的位置和 x 轴之间形成的角度,如图 77-1 所示。听者的朝向角度与听者与物体之间的角度结合,决定了每只耳朵听到的声音有多大,从而决定了我们应该如何调节每只扬声器播放的音量,以模拟位置音效。

f077001

图 77-1:位置音频的几何图形

角度θ是通过三角函数计算的,它是 x 差与 y 差的反正切,或者以下公式,atan2函数处理角度为垂直时的情况:

θ = atan2(y1-y0, x1-x0)

朝向角度与θ(theta)之间的差值定义了一个角度,这个角度控制通过setPan()函数在两个立体声通道间的音量。参数−1 表示全左声道,0 表示平衡,+1 表示全右声道。稍微在纸上推算一下,可以得出:与声音源的角度为 0 度时,对应的 pan 值为 0,90 度时 pan 值为−1,180 度时 pan 值为 0,270 度时 pan 值为+1。这些是-sin(facing-theta)函数的极值点,因此这个值将传递给setPan()

总结来说,声音文件(一个简单的音调)开始播放 1;声音源初始位置在(200, 200) 2,用户初始位置在(300, 200),但可以旋转和移动。每只扬声器播放的音量通过确定角度θ,计算delta = facing-theta,并将平衡设置为–sin(delta) 4 来设置。

示例 78:合成声音

这个示例将实现一个小型的声音合成器。它只有八个键,更像是一个儿童玩具钢琴,但它是功能性的,并且可以作为更复杂的声音合成项目的基础。

Minim提供了一种类型(类),名为AudioOutput,它允许我们在 PC 硬件上显示信号,而不仅仅是声音文件。它允许播放一个音符,尽管这些音符不完全是通常理解的音乐音符。在这个上下文中,音符是具有特定频率的数字音频信号。

草图中AudioOutput变量的名称是out,它初始化为如下所示:

out = minim.getLineOut(Minim.STEREO);

此调用分配了一个新的AudioOut实例,可以通过变量out访问。要播放一个音符,调用playNote()函数:

out.playNote(440.0);

这会将频率为 440 Hz(音符 A)的正弦波发送到声卡。playNote() 可以用几乎任何频率调用,因为“音符”只是正弦波的片段。

不幸的是,AudioOutput对象倾向于为音符指定一个持续时间,所以音符会播放系统认为的一个单位时间。为了模仿人类演奏的乐器,可以变化持续时间,我们需要使用更多参数来调用playNote()

out.playNote(0, 1000, 493.9);

在这个示例中,0 是音符播放的时间(立即),1,000 是持续时间,最后一个参数是频率;1,000 单位是一个很长的时间。

草图显示一个简单的钢琴图像,并标注了钢琴键。当用户点击图形钢琴键之一时,程序播放该音符;鼠标的 x 位置值告诉我们音符是什么(在mousePressed()中)。当鼠标按钮释放时,程序创建一个新的AudioOutput,以便停止播放旧的音符并开始播放新的音符(在mouseReleased()中)。

草图 79:录制和保存声音

这个草图捕获当前在计算机上播放的音频,并将其保存为.wav格式的文件。这允许录制来自 Skype 通话、网站和播客的声音,仅举几例。

在草图 75 和 76 中,我们使用了MinimAudioInput对象来访问当前播放的声音进行可视化。在这种情况下,下一步是创建一个AudioRecorder,它接受一个输入作为参数,我们可以从中收集声音;也就是说,连接到当前播放声音的AudioInput对象。

AudioInput 有三个重要的功能(方法):

  1. beginRecord() 开始保存音频样本。

  2. endRecord() 停止保存音频样本。

  3. save() 将保存的样本作为音频文件存储。

我们可以保存的音频数据量取决于计算机上的可用内存。

草图打开一个窗口,并显示播放的声音信号,类似于草图 75。如果用户按下 R 字符(由keyReleased()处理),我们调用beginRecord()并开始保存数据。当用户按下 Q 时,我们调用endRecord(),录音停止。如果用户按下 S,我们调用save()

我们在创建AudioRecorder时将用于保存数据的文件指定为一个参数:

recorder = minim.createRecorder(input, "processing.wav", true);

这里,input是已经存在的AudioInput对象,processing.wav是我们将保存声音数据的文件,最后一个参数表示录音是否被缓冲,也就是说,数据是保存在内存中,还是直接写入文件。如果没有缓冲,系统会在录音开始时打开文件。否则,系统会在写入数据时打开文件。

对这段代码稍作修改,就能让用户在每次开始和停止录音时保存到不同的文件中。这对于语音录音可能很有用,比如读脚本或读书籍到磁带中。

第十章:使用视频

草图 80:播放视频

我们可以使用 Processing 来播放视频,但正如处理音频时一样,Processing 本身没有提供视频播放功能。因此,我们使用processing.video库中的Movie类,它又使用了底层基于 Java 的视频功能。作为第一个示例,该草图将加载并显示一个短视频。

首先,我们在程序的第一行导入processing.video库 1:

import processing.video.*;

现在我们可以声明一个Movie类的实例 2,为每个我们想要播放的视频创建一个实例:

Movie movie;

我们在初始化类实例时通过调用其构造函数加载视频文件(见草图 43),并指定文件名作为参数 3:

movie = new Movie(this, "car.avi");

setup()函数中,我们通过调用movie.play()函数开始从文件读取视频(这个函数不仅仅是播放视频,正如你所期望的那样)。视频是压缩的图像或帧的序列,就像动画一样,每一帧的读取和解码可能需要一些显著的时间。当我们调用play()后,系统尝试从文件中读取帧,当某一帧准备好时,available()函数返回true。然后我们可以使用read()获取该帧。像PGraphics对象一样,Movie对象可以作为图像进行处理,并使用image()函数显示。因此,显示电影的过程是这样的 4:

if (movie.available()) 
{
  movie.read();
  image (movie, 0, 0);
}

如果没有新的帧可用,read()将不会被调用,之前读取的帧将显示在其位置上。通常这种情况不容易察觉。

Movie类会与电影一起播放声音。

该草图还会在窗口顶部打印相关信息。它统计已读取的帧数并显示该数字。它还显示时间计数,即已经播放的秒数,通过调用movie.time()函数获取 5。当电影播放完成时,如movie.time() >= movie.duration()所示 6,计数器会重置,电影通过调用movie.jump(0)从第一帧重新播放。jump(t)函数调用将当前帧移至时间t的帧。通过调用movie.loop()而不是movie.play(),也可以实现循环播放。在这种情况下,电影从位置 0 重新播放将是自动的。

草图 81:使用快进拨盘播放视频

偏移轮(或快进拨盘)是一个通常呈圆形的设备,允许用户在视频中前进或后退。顺时针转动它会将视频逐帧向前播放,逆时针转动它会将视频向后播放。编辑人员通常使用它来精确定位每一帧的视频。这个草图将实现这种快进过程的近似。视频将开始播放,用户可以使用鼠标调整播放的速度和方向。在任何时刻,用户都可以停止视频并慢慢倒回,以达到任何特定的帧。

为了做到这一点,我们必须解决如何倒放视频的问题。jump()函数允许我们将视频定位到任何特定时刻 2。任何特定帧的时间取决于帧率,即每秒播放的帧数。假设帧率为rate,我们知道每一帧的持续时间是 1/rate秒。最后一帧发生在从开始算起的duration()秒处,因此可以使用以下调用将视频定位到该帧之前的帧:

movie.jump (movie.duration-(1/rate))

前一帧位于movie.jump(movie.duration-(1/rate)*2),依此类推。通过这种方式,逐帧向后跳转,读取帧并显示它。

在该示例中,我们将当前帧的时间存储在time变量中,帧与帧之间的时间存储在ftime变量中。我们将使用鼠标来控制视频显示的速度。点击屏幕中间将通过将ftime设置为 0 来设置速度为 0。点击右侧将ftime设置为一个与屏幕中间的距离成比例的值,从而使视频向前播放;点击左侧将ftime设置为一个值,使视频倒放。最初ftime = 1/rate,但当点击最左边时,这个值变为原来的负 3 倍,点击最右边时,变为正 3 倍。这就是整个计算 3:

ftime = 3*((float)(width/2 - mouseX)/(width/2))/rate;

在视频的结尾(如果倒放的话,实际上是开始)会出现一个小问题。如果在向前播放时找到结尾,时间将被设置为 0;如果在倒放时找到开始,时间将被设置为duration()-ftime

基本的显示过程 1 发生在draw()中,过程如下:

if (movie.available())  movie.read();  // Read a frame if one is there
image(movie,0,0);                      // Display it
time = time - ftime;                   // Advance/retard the time value
movie.jump(time);                      // Set frame to the one at that time

示例显示一个简单的校准界面,允许用户选择速度,并显示ftime的值。

示例 82:从视频中保存静止帧

这个示例允许用户从视频中保存一组静止图像帧。视频会循环播放,以便用户可以选择所需的所有帧。点击鼠标将开始保存图像,再次点击将停止保存。

保存帧是通过使用Movie类对象的save()函数实现的。如果movie是一个Movie对象,以下调用将当前帧保存到指定文件中,并根据文件扩展名指定文件类型:

movie.save("name.jpg");

这与我们保存PImage图片的方式相同。在这种情况下,我们保存 JPEG 格式,但 GIF、PNG 和其他文件格式也同样适用。

为了在不每次覆盖同一文件的情况下保存多个帧,我们可以将已经保存的静止图像的数量存储在变量v中,并将其加入文件名,示例如下:

movie.save("frame"+v+".jpg");

这意味着文件名将会是frame1.jpgframe2.jpg,依此类推。

然而,使用这种标签方案时,无法判断保存的一个序列何时结束,另一个序列何时开始。这个草图通过将变量nclicksv结合使用来解决这个问题。当用户在保存帧时点击鼠标时,保存停止,nclicks会增加,并且v被重置。我们通过帧计数和相对于nclicks变量的字母构建文件名:nclicks = 0 时在文件名中添加字母“a”,nclicks = 1 时添加字母“b”,依此类推。每个帧的文件实际上是按以下方式保存的:

movie.save("frame"+char(nclicks+int('a'))+v+".jpg");

第一组序列将是framea1.jpgframea2.jpg,依此类推,第二组将是frameb1.jpg,依此类推。

草图会在屏幕上绘制时间,但这是为了用户查看——它不会出现在保存的图像中。

另一个保存视频帧的方法是将其显示在草图窗口中,然后将草图窗口保存为图像。如果我们在这种情况下这样做,窗口上绘制的时间实际上会与图像一起保存到文件中。

草图 83:实时处理视频

一些应用程序逐帧处理或分析视频帧,而不需要实时查看结果。例如,可以通过捕捉视频来分析击球手的挥棒动作,增强每一帧中的相关部分,然后将增强后的帧重新组合成视频形式。甚至当每一帧的分析不需要太多计算时,也可以在视频播放时进行处理,并看到实时结果。

在这个草图中,我们之前使用的那个视频将被转换为灰度图像,并在实时中进行阈值处理,就像我们在草图 23 中对静态图像所做的那样。

请记住,我们可以像对待PImage对象一样对待Movie对象(它们具有相同的本地功能)。我们使用movie.loadPixels()方法提取电影图像中的每个像素p,并通过计算颜色组件的平均值来得出亮度或灰度值:(red(p)+green(p)+blue(p))/3。如果该值小于阈值,则显示图像中的相应像素将被设置为黑色;否则,它将被设置为白色。在这个草图中,阈值为 100。结果是一个只显示黑白像素的视频。

设置与之前相同,但我们还创建了一个第二个图像,其大小与视频帧相同(命名为display),用于保存每个显示帧的处理副本。draw()函数在帧准备好时读取该帧,然后调用本地的thresh()函数来计算阈值处理后的图像。在thresh()创建了一个阈值化的电影图像版本后,两个图像会一个接一个地显示,并且这两个版本同时播放。

这种情况下的结果不算令人印象深刻,但它确实提供了我们可以做什么的一个想法。例如,如果我们仔细选择阈值,可能只会显示场景中汽车的运动,去除背景的杂乱。

在其他视频中,我们可以定位面部、增强和读取行驶中汽车的车牌,或者检查并计算传送带上经过摄像头的苹果。这些问题属于计算机视觉领域,Processing 是构建计算机视觉系统的一个好工具,因为它在处理图像方面非常简便。

示例 84:从网络摄像头捕捉视频

大多数电脑和几乎所有的笔记本电脑都有内置摄像头。之前的示例处理的是已经捕获的视频,意味着已经有一个视频文件可以展示或处理。这个示例将从网络摄像头捕获实时视频数据,并以灰度显示。

Capture 类处理相机和图像/视频捕捉。要使用它,首先声明一个实例 1:

Capture camera;

然后通过类构造函数初始化它。类构造函数可能只需要参数 this,或者 this 和设备说明符 2:

Camera = new Capture (this);  
camera = new Capture (this, myCamera);

myCamera 变量是一个设备说明符字符串,格式如下:

"name=USB2.0 HD UVC WebCam,size=160x120,fps=15"

该字符串中的大部分信息都有明显的含义,并且大多数并非绝对必要。如果你知道摄像头的分辨率为 640×480,以下调用将打开摄像头:

camera = new Capture (this, "size=640x480");

图像捕获始于调用 start() 3:

camera.start();

就像播放视频时一样,当 camera.available() 返回 true 时,帧数据可用。此时,摄像头实例可以像 PImage 一样处理,并通过调用 image() 来显示。

这个示例将摄像头图像复制到 PImage 变量 display 4 中。grey() 函数将彩色图像转换为灰度图像,并显示在原始图像位置。结果是一个实时的灰度图像,展示了摄像头正在捕捉的内容。请耐心等待——打开摄像头设备可能需要一些时间。

Capture 类的 list() 函数查看计算机上可用的摄像头设备,并返回一个可以在构造函数中使用的描述符列表。所以,如果这一行

String[] cameras = Capture.list();

这将紧随其后

for (int i=0; i<cameras.length; i++)
  println (cameras[i]);

然后,所有可用的摄像头将被打印到窗口上。我们可以选择一个,并在代码中使用该摄像头的索引,从 cameras[] 数组中选择它。例如,你可以搜索分辨率为 640×480 且帧率为 130fps 的摄像头,并在列表中找到它作为摄像头 i。然后你可以通过数组索引选择你想要的摄像头:

camera = new Capture (this, cameras[i]);

示例 85:将实时视频映射为纹理

在之前的示例中,你看到 Movie 对象可以作为 PImage 来处理,用于显示或从视频帧中提取像素。这个示例展示了将视频用作 3D 表面的纹理,再次像 PImage 一样。其思路是用视频绘制一个四角平面(四边形),使视频在 3D 平面上播放,并随着用户视角的变化而进行缩短。

草图的第一部分设置了网络摄像头(与之前相同),将camera变量作为图像源,并将 P3D 设置为当前的渲染器。在执行时,系统需要几秒钟来判断连接了哪些摄像头以及使用哪个摄像头。我们通过在setup()中调用start()来完成这一切,包括启动摄像头。

draw()中,首先检查是否有新的图像可用。如果有,我们读取它;如果没有,则保留上一张图像作为当前图像。接下来,我们建立一个 3D 环境,通过调用camera设置视角。我们在 3D 空间中绘制一个四边形,并将网络摄像头作为纹理。视角会稍微摆动一下(x 在−30 到 100 之间)以显示视图正在变化。

效果是,四边形似乎在不断地改变位置和方向,而实时视频在四边形内播放。这个效果的一个有趣变种是绘制一个旋转的立方体,并将视频映射到所有面上。虽然这不会展示新内容,但它需要更多的代码。

第十一章:测量和模拟时间

草图 86:显示时钟

在计算机程序中,时间可以有很多含义。有执行时间,即程序在某一特定点上所消耗的 CPU 周期数;有进程时间,即程序已经运行的时间;还有实际时间,就是你手表上的时间。我们也可以称之为时钟时间。本草图将从计算机系统获取时钟时间,并将其显示为传统时钟的指针。

从 Processing 获取时间非常简单。这些是基本的功能:

  1. hour(): 返回当前的小时,使用 24 小时制。

  2. minute(): 返回已经过去的分钟数。

  3. second(): 返回当前分钟内经过的秒数。

时钟将是一个圆形,并且将有三根指针(时针、分针、秒针)。由于一分钟有 60 秒,秒针每秒钟将围绕其中心点旋转 360/60,即 6 度。分针也一样;由于每分钟有 60 秒,每小时有 60 分钟,因此它每分钟旋转 6 度。绘制秒针的原点是时钟的中心,但另一个端点是未知的,只有角度。如果秒针的长度是r,那么第二个点可以通过三角函数确定,如图 86-1 所示。

f086001

图 86-1:确定时钟指针的位置

Processing 中定义的角度与时钟上的角度不同。在时钟上,垂直表示 0 度,而在 Processing 中是−90 度。绘制秒针时,以(cx, cy)为中心点,长度为r,可以按如下方式绘制,其中变量s表示秒数 1:

s = radians(second()*6 - 90.0);
line (cx, cy, cx + cos(s)*sr, cy+sin(s)*sr);

同样的原理适用于较短的分钟针。小时针应该更短,如果hour()值超过 12,则将其除以 2。此外,360 度的周期中只有 12 个小时,而不是 60 个,所以每个小时对应 30 度。小时针是连续旋转的,而不会在每小时变化时跳跃,所以每经过一分钟,小时针会稍微转动一点;30 度(1 小时)等于 60 分钟,因此每分钟使小时针转动 0.5 度 2。以下是代码:

h = radians(hour()*30.0-90.0) + radians(minute()*0.5);

草图 87:时间差异——测量反应时间

测量两个事件之间的时间是本草图的主题:特别是计算机的提示和用户的反应之间的时间,即反应时间。一个典型的(平均)人类反应时间大约是 0.215 秒。也就是说,从灯光亮起到某人按下按钮作出反应之间,平均会过去 215 毫秒。

这个示例通过让用户尽可能快速地点击鼠标来测量反应时间,当背景从灰色变为绿色时。背景随后变回灰色,循环重复五次。程序使用millis()函数测量背景变为绿色和鼠标点击之间的时间,并通过对五次试验求平均来得到更精确的测量结果。

我们使用millis()函数,因为在之前的示例中用来移动秒针的函数second()只返回整数秒数。millis()返回自示例开始执行以来的毫秒数(1/1000 秒)。表面上看,这个值似乎没有太大意义,但它的确意味着可以相当准确地测量两个事件之间的时间差。只需在第一个事件发生时调用millis(),保存该值,在第二个事件发生时再次调用,并将两者相减。

millis()函数可以用于其他目的,其中之一是确定特定循环或函数执行所需的时间。这种测量对于程序运行时间过长,需要找到加速方法的程序员来说非常重要。测量一个函数的调用可能不太有效,因为大多数函数执行得太快,即使是慢函数也是如此。相反,我们将需要测试的函数放入循环中并执行多次。然后我们将执行循环所需的时间除以迭代次数,以确定单次执行所需的时间。以下是如何为函数get(12,100)计时:

t1 = millis();
for (int i=0; i<100000000; i++)   y = get(12,100);
t2 = millis();
println ("Time was "+(t2-t1)+" or "+((t2-t1)/100000000.0));

获得的时间会有所不同,因此对多个试验进行平均可以获得更准确的结果。执行时间可能会因为其他程序同时执行或虚拟内存页错误的发生次数而发生变化。

示例 88:M/M/1 排队—仿真中的时间

单服务器排队系统,或称 M/M/1 排队系统,类似于银行柜员。顾客在随机时间到达柜台接受服务。服务需要一定的随机时间,之后顾客离开。如果柜员正在为一位顾客服务时,另一位顾客到达,新的顾客将排队等待。当有顾客离开时,队列中的下一个顾客将被服务;如果队列中没有人,柜员(服务器)就变为空闲状态。这个系统类似于我们在现实生活中看到的许多场景:超市结账、加油站、等公交,甚至是航空交通和船只在港口的到达。

这个示例模拟了一个服务器和一个排队队列,但可以适应更多的情况,并计算平均排队长度。进行此类系统仿真模拟的意义在于找出队列的长度、客户在队列中花费的时间、服务器的忙碌时间百分比等。这些都与成本和浪费的时间有关。

在现实世界中,时间是连续的,但在计算机中,这是不可能的。因此,模拟中的时间采用离散值:时间 = 0,时间 = 1.5,时间 = 3.99,依此类推。当模拟开始时,我们将变量time设置为第一次到达事件的时间 1,之后的时间将是处理事件时的时间。这被称为下一事件模拟:模拟中的当前时间不断跳跃到下一个发生的事件(到达或离开)的时间。

到达事件发生在随机时间,符合特定的概率分布。当一个到达事件发生时,它(客户)进入服务队列(柜台)。如果队列为空,它将立即接受服务;否则,它必须等待。当它到达服务器(柜台)时,它将需要一些随机时间来接受服务,然后离开。以下是处理每个事件的步骤:

到达 离开
1. 将到达事件放入队列 2. 1. 从队列中移除工作任务 3.
2. 服务器忙碌吗? 2. 队列为空?
3. 如果不是,启动服务器。 3. 如果是,服务器变为空闲状态。
4. 安排下一个到达事件。 4. 如果不是,安排离开事件。

队列是一个存储数字的数组。加入队列意味着将一个新的值(工作任务的随机生成服务时间)放到队列的末尾。当一个值离开队列时,意味着移除队列中的第一个元素,并将每个后续的值向前移动一个位置。函数into(t) 5 将时间t插入队列,而out() 6 则移除队列中的第一个元素。如果队列为空(或系统空闲),则表示队列中没有任何内容 4。

到达和离开之间的时间统计分布遵循负指数分布。如果到达的平均时间间隔是μ,则在模拟中下一个到达事件的时间将是:

–μ * log(random(1))

离开事件也有类似的情况。

第十二章:创建模拟和游戏

草图 89:捕食者-猎物模拟

想象一下兔子和土狼共同生活在它们的自然环境中。它们在传统意义上并不和睦:土狼会在有机会时捕食兔子。兔子繁殖速度非常快,而土狼却不行。当然,如果兔子是唯一的猎物,那么一旦兔子全部死去,土狼也会很快灭绝。这是一种捕食者-猎物关系,当每组中只有一个物种时,它可以被简单建模。

从数学角度看,捕食者-猎物关系通过一对微分方程表示(别担心,这里没有复杂的数学),其形式如下:

c12eq001

这里c12i001表示土狼种群增长的速度,而c12i002表示兔子种群增长的速度。这些方程的解,即 Lotka-Volterra 方程,并不重要。程序将模拟它们。在这些方程中,变量如下:

  1. x:兔子数量(猎物)

  2. y:土狼的数量(捕食者物种)

  3. α:兔子种群在不受约束的情况下增长的速率

  4. β:猎物和捕食者相遇的速率,以及兔子因相遇而死亡的速率

  5. γ:捕食者因自然原因或远离而死亡的速率

  6. δ:捕食者种群增长的速率

模拟将从四个变量α、β、γ和δ的指定值(alphabetagammadelta)开始,并有已知的初始种群规模。然后,每次执行draw()时,它都会根据前述方程计算新的种群。这是兔子 3 的关键代码:

dr = alpha*Nrabbits - beta*Nrabbits*Ncoyotes;        
Nrabbits = (int)(Nrabbits + dr);

而对于土狼 4 的代码是:

dc= delta*Nrabbits*Ncoyotes - gamma*Ncoyotes;
Ncoyotes = (int)(Ncoyotes + dc);

然后,种群数量会在窗口中以图形方式呈现。我们将每只兔子画成一个绿色圆圈,位于屏幕的某个地方(位置无关紧要)1,而每只土狼画成一个红色圆圈 2。我们可以观察到,当捕食者种群和猎物种群变化时,它们的相对数量增减。如果所有猎物都死了,捕食者也会死;如果所有捕食者都死了,猎物则会无限增长。

草图 90:群体行为

克雷格·雷诺兹(Craig Reynolds)在 1986 年创建了一个名为 Boids 的系统。它是模拟鸟群飞行或鱼群游动时的行为。鸟群是同类物体的集合,它们会一起移动,并希望最终停在同一地方。它们也不希望相互碰撞。这个模拟需要知道每个物体的位置、速度和运动方向,然后迭代地更新每个物体的位置。三条规则使得这些物体形成一个鸟群:

  1. 分离对象尝试保持与邻居之间的距离尽可能小。在每次迭代中,若某个对象与邻居的距离小于d,它将尽可能远离该邻居。

  2. 对齐力:物体会尝试与附近的物体匹配速度,这样它们会朝相似的方向移动,并防止它们之间的间距过大。我们计算一个局部速度,视角为物体自身,然后将这个速度的一部分添加到物体的速度中,以便下一次迭代使用。

  3. 聚合力:物体会尝试朝着邻居的质心移动,这样它们会保持在一起。我们找到质心(不包括当前物体本身),然后将物体移动一小部分(1%到 3%)朝向那个点。

每个位置都被存储为一个向量(PVector对象),该向量有 x 和 y 分量。向量数组FlockV存储每个物体的速度。draw()函数调用移动然后绘制群体的函数。match()计算一个新的速度,尝试匹配邻居 2;toCenter()将每个物体朝着质心 3 移动;而away()则试图保持物体之间的间距 4。在每次迭代中,我们会对每个物体调用这三个函数中的每一个。每个函数都会返回一个值,我们将其添加到物体的位置 1。物体是小圆圈,当我们移动鼠标时,它们会跟随鼠标。

草图 91:模拟极光

在计算机上难以呈现的物体中,北极光或极光位列其中。它们闪烁并翻滚,颜色变化,形状以不同速度变化,且通常没有固定的形状。曾经有过不少努力来绘制极光,虽然成功与否不一,但这幅草图就是其中的一次尝试。

极光可以呈现出多种形状,我们在这幅草图中只尝试绘制其中的一种:典型的窗帘型,类似的一个例子见于图 91-1。

f091001

图 91-1:红色和绿色的极光

这个草图会使颜色随 y 位置变化缓慢地变化。从极光窗帘底部的红色值开始,色调会随着上方像素的增加而变换。初始色调值h=15,色调根据以下公式 2 增加:

h = h + random(.87);

因此,色调以随机的速度增加,但它始终随着 y 坐标的变化而增加。在窗帘的最顶部,亮度会降低,颜色逐渐褪去。

接下来,注意到极光似乎由垂直的笔触组成,并且在水平方向上呈带状。这可以通过在程序中定期改变像素的饱和度来实现,饱和度是 x 坐标 1 的函数。下面是代码,其中i是水平方向的位置,s是饱和度:

if (i%3 == 0) s = 220+random(20)-10;
else if (i%2 == 0) s = 210+random(20)-10;
else s = 200+random(20)-10;

i%3i除以 3 后的余数,因此饱和度会有一些随机变化,形成较暗的带状区域。

窗帘效果通过使用正弦函数来定位像素的垂直位置实现。对于基本坐标(ij),实际的像素位置会是(i,j-bb*sin(a*i)),其中参数abb在每次迭代中会发生微小且随机的变化 3。这使得窗帘效果看起来在移动。

视觉效果通过一对图像得到增强。我们使用了一张星星的背景图像,模拟夜空。接着我们在其上绘制极光,然后再叠加一张前景图像,展示树木和灌木。这张图像是一个模板,黑色物体位于透明背景上。结果是对极光的一个令人愉悦的演绎,虽然远非完美,仍然有很多工作可以做来提高其现实感。

草图 92:动态广告

在全球的视频屏幕上,我们看到公共广告。在机场、购物中心,甚至学校中,各种推广材料都呈现给了被动观众。视频是一种便捷的媒介,因为大屏幕等离子和 LCD 屏幕的价格已经降到每英寸不到 10 美元。视频也是一种更具动态感的媒介,可以展示移动的广告和连续展示的多种内容,这是印刷海报和广告牌无法做到的。

与视频广告相关的技术也非常成熟(如 Biteable Ad Maker、InVideo,甚至 Adobe Premiere),并且这些工具可以在计算机桌面上使用。这个草图是一个简单广告的例子——为一家德州-墨西哥餐厅做的广告。它大致基于在北美机场看到的一些实际视频展示。

首先,我们需要一张关于主题(产品)的好图:一个墨西哥卷饼。这里使用的图像是公开可用的(commons.wikimedia.org/wiki/File:Carne-asada-burrito.jpg),但通常这种图像是以高分辨率拍摄的专业照片。在草图中,这张图像的尺寸为 800×431 像素。我们将其缩小为更小的尺寸,770×401,或每个维度小了 30 像素。这样做是为了让图像能够缓慢移动,呈现更具动态感的效果。我们使用语句 1 来显示该图像,其中 xoffyoff 是显示前的图像定位像素偏移量:

image (ad1, xoff, yoff);

这些偏移量在每一帧中会发生小的变化,最大变化为 30 像素,届时位移方向会发生反转 2:

xoff += dx; yoff += dy;
if (xoff <= -30 || xoff > 0) dx = -dx;
if (yoff <= -30 || yoff > 0) dy = -dy;

dxdy 的值非常小,分别为 0.05 和 0.03。它们的值不同,使得图像以模糊的椭圆形方式移动。

文字显示在图像上一个固定的位置,增强了图像的运动感。底部的文字保持不变,而顶部的文字会变化。实现分为两个阶段:如果变量 stage = 0,我们显示第一段文字(“我们花了几个小时制作它”)3。在 850 帧(约 28 秒)后,变量 stage 增加,因此我们显示第二段文字(“你花五分钟就能吃掉它”)4。再过 900 帧后,stage 重新变为 1,循环重复。

我们可以允许任意数量的阶段,以便展示多个不同的消息和图像,并且以随机顺序播放。

草图 93:尼姆

Nim 是一款历史悠久的游戏,其起源已无从考证。它可能是在中国发明的,是已知最古老的游戏之一。它也是最早实现计算机或电子版本的游戏之一,且一直是计算机编程课程中的常见作业题目。游戏开始时有三行物品,如火柴或硬币,每一行的物品数量不同。玩家可以从任意一行中移除任意数量的物品,但必须至少移除一个,并且只能从一行中移除物品。玩家轮流移除物品,最后移除物品的玩家获胜。

这个草图将使用 9、7 和 5 枚硬币实现游戏,并且它将执行一方的操作。

设置游戏玩法的前提是读取对象的图像,在本例中是一个便士,并在窗口中绘制正确数量的它们。当玩家点击其中一枚硬币时,这枚硬币以及其左侧的所有硬币将被移除,剩余的硬币将向左移动。然后,计算机会移除一些硬币。

三行之间相隔 100 像素,所以当玩家点击鼠标时,行索引就是 i = (mouseY/100)-1。移除的硬币数量是左侧硬币的数量,在这个草图中,j = (mouseX-10)/45+1,这是因为我们绘制时的方式(每个硬币间隔 45 像素,距离左侧 10 像素)。一个名为val的数组包含每一行中的硬币数量,因此当用户点击鼠标时,这就是相应的操作:

val[i] = val[i] - j;

这将减少第(mouseY/100)-1行中的硬币数量,减少的数量为(mouseX-10)/45+1

然后轮到计算机的回合。有一种策略可以让计算机几乎总是获胜,只要用户先走一步。该策略涉及计算奇偶性值,并做出移动以确保维持该奇偶性值。考虑初始状态和从第 1 行取走两枚硬币后的状态:

之前 之后
第 1 行 5 = 0 1 0 1 3 = 0 0 1 1
第 2 行 7 = 0 1 1 1 7 = 0 1 1 1
第 3 行 9 = 1 0 0 1 9 = 1 0 0 1
奇偶性 1 0 1 1 1 1 0 1

奇偶性是通过查看每个值的二进制表示中的每一位来确定的。在每一列中,如果该列中 1 的数量为奇数,则该列的奇偶性位为 1;如果为偶数,则为 0。我们可以通过使用异或运算符来计算这一点,在 Processing 中是“^”,像这样:val[0]^val[1]^val[2]

Nim 游戏中的策略是做出一个使奇偶性值为 0 的移动。事实证明,这始终是可能的;在前面的情况中,计算机可能从第 3 行移除 5 个硬币,得到如下状态:

第 1 行 3 = 0 0 1 1
第 2 行 7 = 0 1 1 1
第 3 行 4 = 0 1 0 0
奇偶性 0 0 0 0

这是草图在玩家每次移动后所做的操作:计算所有可能的移动的奇偶性,直到找到一个奇偶性为 0 的移动。

草图 94:路径寻找

寻路就是在二维或三维空间中找到一条从一个地方到另一个地方的路线。潜在的路径可能会被墙壁、河流、电线或其他障碍物阻挡。当然,我们希望找到最佳路径,“最佳”可以基于许多因素,比如物理距离、时间或成本。在电路设计中,我们使用寻路来创建电路元件之间的连接。在计算机游戏中,它用于找到将游戏对象从一个地方移动到另一个地方的路径。这个示例将实现一种二维的基本寻路方法。

该方法从某个初始点(xy)开始,并有一个目标点(x[t],y[t])要到达。每个邻居(x[n],y[n])的距离被标记为从(xy)到(x[n],y[n])的距离。然后我们查看这些位置(x[n],y[n])的邻居,并通过将邻居(x[n],y[n])到(xy)的距离加到(x[n],y[n])的距离上来标记这些位置。我们不断重复这个过程,直到我们到达目标像素(x[t],y[t])。现在我们知道了从起始像素的距离,可以通过追溯标记值最小的连接位置来追踪最佳路径。一个邻居必须是开放空间,而不是障碍物,才能被标记,因此路径永远不会穿过障碍物。

程序开始时读取一张图像,图像中的障碍物为黑色,背景为白色。路径的起点和终点在程序中通过 x,y 坐标指定:startxstarty,和endxendy(你可以更改这些值来找到不同的路径)。

从起始坐标开始,我们检查直接相邻的像素 1。任何像素的邻居是它左边、右边、上边或下边的像素。因此,像素(x[0],y[0])和(x[1],y[1])之间的距离为|x[0] – x[1]| + |y[0] – y[1]|,是一个整数。起始像素与其邻居之间的距离为 1。这种测量距离的方式叫做曼哈顿距离;你也可以将寻路方法适配为使用其他距离度量方式。

如果其中一个邻居是路径的终点,则搜索完成 2;否则,我们将像素涂上与其距离起始点成比例的青色。我们使用 RGB 颜色中的红色分量作为距离,因此随着红色的增加,颜色会变得更亮。我们也可以使用一个单独的二维数组来存储距离,特别是当需要浮动距离时,比如计算欧几里得距离时。

接下来,我们以相同的方式检查所有红色值为 1 的像素(那些与起始位置距离为 1 的像素),并将它们的邻居设置为 2。然后我们将它们的邻居设置为 3,以此类推,直到到达终点位置。

此时,距离起点的距离是N。为了追踪回起点的路线,我们寻找终点位置的一个邻居,邻居的值为N − 1;任何一个邻居都可以。将该位置标记为路径上的点,然后寻找该位置的一个邻居,邻居的值为N − 2;标记它并重复。任何时刻都会有许多像素具有特定值,但只有与路径连接的像素才是有趣的。当我们到达起点位置时,路径就完成了。drawRoute()函数会搜索终点像素的邻居,寻找一个值为N的邻居,标记该像素并递归地寻找该像素的邻居,再标记它,依此类推 3:

set (i,j,color(0,100,200)); 
drawRoute (i,j,n-1); 

结果是在显示的图像上绘制了一条路径。

草图 95:元球—一盏熔岩灯

f095001

图 95-1:一盏熔岩灯(在线动态展示:en.wikipedia.org/wiki/File:Lava_lamp_(oT)_07_ies.ogv

这个草图代表了尝试创建一个动态图形模拟的熔岩灯,这是 1960 年代的一个流行物品(见图 95-1)。大多数北美人都能认出它,因为熔岩灯已经重新流行起来,也许是因为人们对复古家具的兴趣。这个灯是一个充满油的玻璃容器。底部有一个白炽灯和一些彩色蜡。当灯加热时,蜡熔化,蜡球慢慢上升到顶部,形状发生变化。冷却的蜡球会掉到底部,创造出动态的视觉效果,因为光滑的蜡形相互作用。

每个蜡块在灯中似乎都在独立移动,因此我们将使用一组具有 x、y 坐标的点来表示每个蜡块的中心,这些点可以在 2D 区域中移动。我们将以有趣的方式创建实际的蜡块:每个蜡块都是一个 3D 函数,我们将渲染一个俯视图,看到的部分是具有大于阈值的 z(高度)值的 3D 蜡块,就像俯瞰从水面冒出的岛屿一样(见图 95-2)。这些 3D 函数被称为等值面或元球。

f095002

图 95-2:阈值如何切割 3D 函数

当两个元球接近时,它们交汇的区域的高度是两个物体的总和,随着它们靠得更近,这个区域将超过 z 阈值,因此会出现在 2D 渲染中(见图 95-3)。这就产生了蜡块相互作用的错觉。

f095003

图 95-3:元球如何相加形成一个蜡块

我们将使用一个简单的函数来表示元球:一个球体,正如通过名为equation()的函数定义的那样。它定义了在任意点xy处相对于另一个点的球体k的像素值,如下所示:

radius[k] / sqrt( (xx-x[k])*(xx-x[k]) + (yy-y[k])*(yy-y[k]) ) );

这个示例中有六个球体,由数组xy定义,并且它们根据数组dxdy的定义移动。setup()函数初始化这六个球体。第一个球体相当大,不会移动,位于区域的底部,用来模拟大多数灯具底部的大蜡油储存器 1。

draw()函数计算绘图区域内任何一点的所有球体的总和 2。在许多情况下,这个总和为零,但随着球体逐渐靠近,总和增加,并且如果超过阈值MINT,则会变得可见。可见的像素会被绘制为绿色,背景会是黄色。球体每次迭代时会移动 3,并且大小可以随机变化 4。

示例 96:机器人手臂

机器人一词通常与类人形机械装置相关联,但迄今为止,最常见的机器人通常是功能单一、运动范围较小的设备。例如,焊接接头或涂装汽车的机器人。这些机器人通常看起来像一只手臂,包含多个关节,并且在手臂的末端装有某种工具。这个示例允许用户通过按键移动一个二维仿真机器人手臂。

仿真中的机器人是典型的此类机器人的代表,比如市面上可购买的 PUMA 机器人。它由三个连接的部分组成,每个部分都可以在关节处旋转,如图 96-1 所示。关节分别是肩部(jangle1),由肱二头肌连接到肘部(jangle2),由前臂连接到手腕(jangle3),手腕再连接到手部。用户通过按键控制关节所形成的角度:jangle1 由 Q 和 E 控制,jangle2 由 A 和 D 控制,jangle3 由 Z 和 C 控制。

f096001-r

图 96-1:三部分连接形成的机器人手臂

我们将通过图像表示每个手臂部分。旋转轴不是图像的左上角或中心,而是图像中关节与前一个部分连接的点。任何关节的角度可以通过按一个按键增加,通过按另一个按键减少,但由于它们是相互连接的,因此旋转必须相对于前一个部分进行计算。旋转从肩部开始,一直到手部。然后,手部会在最终旋转的位置绘制出来(所有三个旋转),前臂绘制在之前的位置(两个旋转),最后绘制旋转后的肱二头肌。这是通过使用 Processing 函数pushMatrix()popMatrix()来实现的:首先旋转肩部关节,然后将状态推送 1;接着旋转肘部并推送 2;然后旋转手腕并绘制。接下来恢复之前的状态,绘制肱二头肌 3,然后再进行一次恢复。

必须分析代表手臂部分的图像,并将结果编码到程序中作为坐标。例如,考虑肘部:这是肱二头肌(代码中的 armA)与前臂(代码中的 armB)相接触的地方。它们相接触的点分别与不同图像有不同的偏移量,如 图 96-2 所示。对于肱二头肌,接触点是从其左上角开始的 (167, 37)。与前臂的连接是相对于前臂图像的 (31, 25),这也是其旋转轴。因此,为了旋转前臂,我们首先将其平移 -31, -25,使其看起来围绕正确的位置旋转。当绘制前臂时,必须将其平移到肱二头肌上的连接点 (167, 37) 与前臂上 (31, 25) 的连接点对齐,因此下一个平移是 (167 - 31, -(37 - 25)),即 (136, -12)。我们反转 y 坐标的符号,因为 y 的方向与数学中的通常 y 轴相反。每个连接点的坐标都来自图像,如果它们改变了,那么这些点将需要重新测量。

f096002-r

图 96-2:手臂段之间的连接点

Sketch 97:闪电

闪电迅速、随机且明亮。从计算机图形的角度来看,捕捉它似乎是一件困难的事情,然而因为每个人都有相关经验,所以在某些情况下,能够绘制闪电是很重要的。这个 Sketch 是对此的基本尝试。

就像在 Sketch 91 的极光模拟中一样,关于绘制闪电的主题有其历史和文献,其中很多是基于模拟闪电在真实世界中发生的物理过程。这个过程太复杂了,无法在一个小程序中重现,但其中一些成果可能是有用的。例如,研究人员测量了闪电条与分支之间的角度(大约 16 度),以及分支的可能性。

这个 Sketch 将生成随机的闪电形状,作为小的、连接的线段。每个段落的长度和与上一个段落的角度将是随机的。一个二维数组将保存主部分和分支部分的各种段落。主部分是数组的第一部分中的一系列线段:以起点 x[0][i]y[0][i] 连接到段落终点 x[0][i+1], y[0][i+1] 1。分支将随机发生,概率为 0.11 2,并占据数组的另一行,第一个分支从 x[1][0], y[1][0] 开始,第二个从 x[2][0], y[2][0] 开始,依此类推。

一个分支也可能会终止,概率为 0.23,但主分支不能。它会继续直到达到大于 205 的 y 值,然后终止。新的闪电击发将在稍后的随机时间和 x 位置发生。

每次调用draw()时,都会创建并绘制每个笔画的新部分,因此闪电是一个动态展示。它似乎从图像顶部降到地面,或者在这个案例中,降到水面:显示了一个海上风暴的背景图像,闪电似乎从云层中开始并击中水面。

这个方案存在一些缺陷。有时,笔画会以人类认为不现实的方式随机出现。分支可能会互相交叉,有时甚至是多次交叉。在现实生活中可能发生,但并不常见。闪电路径通常会有周围的光晕,但在这幅草图中缺失了这一效果。闪电也是光源,并会改变场景中的环境光。虽然有可能再现这种效果,但使用静态图像作为背景使得改变光照变得困难。最后,我们是迭代地添加闪电笔画,并且一旦它们被确定到某个特定点,就不再改变。闪电路径已被观察到沿着它们的长度移动,而不仅仅是在下端,但这一效果是微妙的。

代码提供了实验的机会。我们可以改变新分支创建的概率,或者现有分支被删除的概率。每段的长度现在在 0 到 12 之间随机,角度也在−30 到+30 度之间随机,这些变化会对结果产生显著的视觉影响。

草图 98:计算机游戏《打砖块》

原始的《打砖块》游戏由传奇的早期游戏开发者诺兰·布什内尔、史蒂夫·沃兹尼亚克(后来成为苹果公司名人)和史蒂夫·布里斯托在 1975 年于雅达利设计和构建。从基本概念上看,它是一个单人版的乒乓球变种,玩家用挡板将球弹向砖块,砖块被击中后消失。原版游戏有八排矩形砖块,每两排砖块颜色相同。球会从屏幕的左右和顶部反弹,在砖块消失后也会反弹,但可以自由穿过底部。玩家必须移动挡板以击中向下移动的球,防止它消失。玩家有三次机会(也就是可以错过三次球),以清除屏幕上的砖块,不同颜色的砖块得分不同。

这幅草图将实现一个简化版本的游戏。游戏中有三排红色砖块,所有砖块的得分相同。没有声音,也没有高分。砖块是填充矩形,尺寸为 30 像素×15 像素,球则是一个简单的小圆圈,直径 3 像素。一个 2D 数组exists[][]用于跟踪哪些砖块已被消除,如果exists[i][j]为真,那么行ij的砖块将被绘制。因此,绘制砖块非常简单。

for (int i=0; i<Ncols; i++)  // Draw all bricks
  for (int j=0; j<Nrows; j++)
    if (exists[i][j]) rect (i*30+20, j*15+30, 30, 15);

球的位置为(x, y),并在每一帧中按(dx, dy)的量移动。球板只是画在(px, py)位置的水平线。按 A 键可以将球板向左移动 10 像素(px=px-10),按 D 键可以将其向右移动相同的距离。如果球越过底部坐标py(=300),并且其x值位于px−30 和px+30 之间,那么球的 y 方向会改变(dy=-dy),看起来就像是反弹。球还会从屏幕顶部(y==0)和两侧(x<0x>width)反弹。

我们在每一帧中测试球与每个砖块的碰撞;这是通过使用每个砖块的绝对坐标来完成的。如果(i, j)位置的砖块存在,则该砖块的边界为:

维度 坐标值 边界 坐标值 边界
X i*30+20 左边缘 i*30+50 右边缘
Y j*15+30 上边缘 j*15+45 下边缘

只需检查球的位置与每个砖块的坐标值,如果球位于砖块 3 内,则反弹,同时将exists[i][j]设置为false,并增加score

在球掉到底部之后,我们将life减一,并将球重新绘制在y值为 150 的随机x位置。当life值为 0 或者score达到最大值 36 时,游戏结束。

这个简单版本有缺陷。砖块的反弹不依赖于击中的砖块侧面;球的 y 方向始终会发生变化。无论撞击点在哪里,球从球板反弹的方式总是相同的。

示例 99:中点偏移法——模拟地形

这个示例将生成一个伪随机的地形轮廓,伴随着逐渐变暗的天空和闪烁的星星。这个示例的核心是使用中点偏移方法生成地形,虽然这个例子是二维的,但它很好地展示了更一般的算法。

该方法从一条线开始,在这个示例中是整张图像的水平线。接下来,我们选择该线的中点,将其按dy–dy之间的随机值偏移,并像图 99-1 那样创建两条线。

f099001

图 99-1:分割一条线

然后,我们对刚创建的两条线做相同的操作,除了减少dy的值。结果是四条线。每次我们生成一对新线段时,生成的段可以使用更小的dy值再次分割,直到达到某个终止标准。在这个示例中,dy的初始值是 75,当其小于 2 时,分割过程停止。

分割过程通过递归过程md()完成:

void md (float x0, float y0, float x1, float y1, float dy)

在这里,(x0, y0) 和 (x1, y1) 是线段的端点,dy 是随机高度变化的最大值。该过程找到中点并调用自身两次,传递线段的左右两半以及更小的 dy。这个过程会继续,如图 99-2 所示,直到达到最小的 dy 值。

然后,线段的端点会保存在一对数组中,lx[]ly[]。我们实际上并不绘制线段,而是通过从每个端点绘制一条到窗口底部的线来创建一个填充区域,这条线的宽度是线段 x 宽度的一半 3。结果是一个具有令人信服随机特性的地平线。

f099002

图 99-2:多次递归分割创建了一个逼真的地平线。

天空是一组水平线,起始颜色为(50, 50, 240),每绘制两条线,蓝色值减少 1。这在天空中产生了美丽的深蓝色渐变效果。

星星只是绘制在随机位置的小圆圈,但它们必须在每一帧中出现在相同的位置,因此数组 starx[]stary[] 存储了它们的位置。它们并不真正闪烁,但我们以 99%的概率绘制它们,这样偶尔某颗星星在某一帧中没有被绘制出来 2。在任何一帧中,至少有一颗星星是暗的。整体效果呈现出傍晚的天空和乡村景观。

第十三章:公开你的作品

草图 100:在网页上的处理

Processing 草图通常可以在浏览器中执行,几乎不需要修改,就能创建动态和交互式的网页对象。实现这一功能的系统是 Processing.js;它将 Processing 草图转换成 JavaScript 代码后再运行,并将结果显示在 HTML5 画布中。

在网页上运行草图有四个步骤:

  1. 下载 Processing.js。这意味着你需要访问像 processingjs.org/download/ 这样的网站,下载 processing.jsprocessing.min.js 文件。

  2. 创建 Processing 草图。我们将使用草图 91,即极光模拟,作为示例。这个草图将命名为 sketch100.pde

  3. 创建一个网页,在其中嵌入草图。该网页必须在页面的头部加载 processing.min.js 作为脚本 2:

    <script src="processing.min.js"></script>
    
  4. 创建一个画布,指定 sketch100.pde 作为数据处理源 3:

    <canvas data-processing-sources="sketch100.pde"> </canvas>
    

这只有在网页服务器上才能正常工作,因此你需要将所有文件上传到服务器,并从互联网上显示页面,或者在你的电脑上安装一个服务器。

所有三个文件——HTML 源文件、草图和processing.min.js——应该放在网页服务器的同一个目录下。当页面加载时,草图应当运行并在画布中显示结果。

根据草图的不同,可能会有其他问题。首先,如果草图使用了图像,这些图像必须被预加载,以便在草图运行时能够获取它们的大小和其他属性。preload 指令必须出现在草图开头的注释中。例如,在这个例子中,使用了 trees.gifstars.jpg 文件 1:

/* @pjs preload="trees.gif, stars.jpg"; */

接下来,如果草图使用了整数,要小心。Processing 代码会被转换成 JavaScript,而 JavaScript 并没有整数类型。整数将变成浮点数。任何依赖于整数运算的程序(例如 5/2 = 2)将无法正常工作。

任何需要 Java 库的程序也无法工作。Minim 是一个 Java 库,视频类也是如此。这些库有 JavaScript 版本,但使用它们将需要学习 JavaScript 的工作原理,以及如何从 Processing 访问 JavaScript,反之亦然。

网页的 HTML 代码位于下一页草图代码之后。

posted @ 2025-12-01 09:43  绝不原创的飞龙  阅读(21)  评论(0)    收藏  举报