本教程适用于 Processing 的 Python 模式。如果您发现任何错误或有建议,请告诉我们。部分内容改编自 Daniel Shiffman 等教程,遵循 CC BY-NC-SA 4.0 协议。
Processing 提供了内置的2D变换函数,让你可以轻松实现图形的移动、旋转、缩放等效果。常用的有 translate()
、rotate()
、scale()
。这些变换操作的本质是改变坐标系,而不是直接改变图形本身。
用代码绘制的简单矩形rect(x, y, width, height),x和y是矩形的左上角坐标,width和height是矩形的宽度和高度。
rect(20, 20, 40, 40)
20x20网格与(20,20)处的40x40黑色方块
平移演示:从(20,20)到(80,100)的平移变换
如果要将矩形向右移动 60 个单位,向下移动 80 个单位, 你只需通过添加到 X 和 Y 起点来更改坐标:矩形就会出现 在不同的地方。(我们把箭头放在那里是为了达到戏剧性的效果。
rect(20 + 60, 20 + 80, 40, 40)
但是有一种更有趣的方法:移动坐标纸。如果你将坐标纸向右移动60个单位,向下移动80个单位,你会得到完全相同的视觉效果。移动坐标系被称为平移。
移动坐标纸演示:上方网格原点(0,0),下方网格原点(60,80)
前面图表中需要注意的重要一点是,就矩形而言,它根本没有移动。它的左上角仍然在 (20,20)。当你使用变换时,你绘制的图形永远不会改变位置;改变的是坐标系。
下面的代码通过改变坐标来绘制红色矩形,然后通过移动网格来绘制蓝色矩形。矩形是半透明的,这样你就可以看到它们在视觉上处于相同的位置。只有移动它们的方法发生了变化。将这段代码复制粘贴到 Processing 中试试看。
平移变换对比:红色直接加坐标,蓝色用translate
def setup():
size(200, 200)
background(255)
noStroke()
# draw the original position in gray
fill(192)
rect(20, 20, 40, 40)
# draw a translucent red rectangle by changing the coordinates
fill(255, 0, 0, 128)
rect(20 + 60, 20 + 80, 40, 40)
# draw a translucent blue rectangle by translating the grid
fill(0, 0, 255, 128)
pushMatrix()
translate(60, 80)
rect(20, 20, 40, 40)
popMatrix()
注意:pushMatrix()
保存当前坐标系,popMatrix()
恢复坐标系。
让我们详细看看平移代码。pushMatrix()
是一个内置函数,用于保存坐标系的当前位置。translate(60, 80)
将坐标系向右移动60个单位,向下移动80个单位。rect(20, 20, 40, 40)
在原来的位置绘制矩形。记住,你绘制的图形不会移动——移动的是网格。最后,popMatrix()
将坐标系恢复到执行 translate 之前的状态。
是的,你也可以用 translate(-60, -80)
将网格移回原来的位置。但是,当你开始对坐标系进行更复杂的操作时,使用 pushMatrix()
和 popMatrix()
来保存和恢复状态会更容易,而不必撤销所有操作。在本教程的后面,你会发现为什么这些函数的名字看起来如此奇怪。
你可能会想,移动坐标系比直接加坐标要麻烦得多。对于像矩形这样的简单例子,你是对的。但是让我们看一个例子,说明 translate()
如何让生活更轻松。这里有一些代码可以绘制一排房子。它使用一个循环调用名为 house()
的函数,该函数以房子左上角的 x 和 y 位置作为参数。
方法一:直接修改坐标(复杂)
def setup():
size(400, 100)
background(255)
# 需要手动计算每个房子的位置
for i in xrange(10,350,50):
house(i, 20)
# 这是通过改变房子位置来绘制房子的代码。看看你需要跟踪的所有加法运算。
def house(x, y):
# 每个图形都需要加上 x, y 偏移
triangle(x+15, y, x, y+15, x+30, y+15)
rect(x, y+15, 30, 30)
rect(x+12, y+30, 10, 15)
方法二:使用 translate()(简洁)
相比之下,使用 translate()
的版本。在这种情况下,代码每次都把房子画在同一个地方,其左上角在 (0, 0),让平移来完成所有工作。
def house(x, y):
pushMatrix()
translate(x, y) # 移动坐标系
triangle(15, 0, 0, 15, 30, 15) # 坐标都是相对值
rect(0, 15, 30, 30)
rect(12, 30, 10, 15)
popMatrix()
使用 translate()
的优势:
除了移动网格,你还可以使用 rotate()
函数来旋转它。这个函数接受一个参数,即你想要旋转的弧度数。在 Processing 中,所有与旋转相关的函数都使用弧度而不是角度来测量角度。当你用角度谈论角度时,你说一个完整的圆有 360°。当你用弧度谈论角度时,你说一个完整的圆有 2π 弧度。下面是一个图表,显示了 Processing 如何用角度(黑色)和弧度(红色)来测量角度。
角度按顺时针方向测量,零度在 3 点钟位置
由于大多数人习惯用角度思考,Processing 有一个内置的 radians()
函数,它接受角度数作为参数并为你转换。它还有一个 degrees()
函数可以将弧度转换为角度。有了这个背景,让我们尝试将一个正方形顺时针旋转 45 度。
旋转也是对坐标系的操作。rotate(angle)
以当前原点为中心,单位为弧度。常用 radians()
将角度转为弧度:
def setup():
size(200, 200)
background(255)
smooth()
fill(192)
noStroke()
rect(40, 40, 40, 40)
pushMatrix()
rotate(radians(45))
fill(0)
rect(40, 40, 40, 40)
popMatrix()
嘿,发生了什么?为什么方块被移动和切断了?答案是:方块没有移动。是网格被旋转了。这就是真正发生的事情。正如你所看到的,在旋转后的坐标系中,方块的左上角仍然在 (40, 40)。
顺序很重要:先 translate()
再 rotate()
,最后画图。
旋转方块的正确方法是:
将坐标系的原点 (0, 0) 平移到你想要方块左上角所在的位置。
将网格旋转 π/4 弧度(45°)
在原点绘制方块。
这是代码及其结果,去掉了网格标记。
def setup():
size(200, 200)
background(255)
smooth()
fill(192)
noStroke()
rect(40, 40, 40, 40)
pushMatrix()
# move the origin to the pivot point
translate(40, 40)
# then pivot the grid
rotate(radians(45))
# and draw the square at the origin
fill(0)
rect(0, 0, 40, 40)
popMatrix()
这是一个通过旋转生成颜色轮的程序。为节省空间,已缩小了截图。
def setup():
size(200, 200)
background(255)
smooth()
noStroke()
def draw():
if (frameCount % 10 == 0):
fill(frameCount * 3 % 255, frameCount * 5 % 255,
frameCount * 7 % 255)
pushMatrix()
translate(100, 100)
rotate(radians(frameCount * 2 % 360))
rect(0, 0, 80, 20)
popMatrix()
最后的坐标系变换是缩放,即改变网格的大小。来看这个例子,它先绘制一个正方形,然后将网格缩放至正常大小的两倍,然后再绘制一次。
def setup():
size(200,200)
background(255)
stroke(128)
rect(20, 20, 40, 40)
stroke(0)
pushMatrix()
scale(2.0)
rect(20, 20, 40, 40)
popMatrix()
可以分别缩放x和y:scale(3.0, 0.5)
。
首先,你会发现黑色方块"看起来"好像移动了。其实并没有。它的左上角依然在缩放后的网格的 (20, 20) 位置,只不过这个点现在距离原点是原来的两倍远了。你还会注意到线条变粗了,这不是错觉——线条确实变粗了,因为坐标系被整体放大了两倍。
编程挑战:
让黑色方块放大,但让它的左上角和灰色方块重合。提示:先用 translate()
把原点移动到目标位置,再用 scale()
。
另外,缩放时并不一定要 x 和 y 方向等比缩放。你可以试试 scale(3.0, 0.5)
,让 x 方向变为原来的 3 倍,y 方向变为原来的一半。
当你执行多个变换时,顺序会产生影响。先旋转再平移最后缩放的结果,与先平移再旋转最后缩放的结果是不同的。以下是一些示例代码和结果。
def setup():
size(200, 200)
background(255)
smooth()
line(0, 0, 200, 0) # draw axes
line(0, 0, 0, 200)
pushMatrix()
fill(255, 0, 0) # red square
rotate(radians(30))
translate(70, 70)
scale(2.0)
rect(0, 0, 20, 20)
popMatrix()
pushMatrix()
fill(255) # white square
translate(70, 70)
rotate(radians(30))
scale(2.0)
rect(0, 0, 20, 20)
popMatrix()
建议多用 pushMatrix()
/ popMatrix()
包裹每组变换,避免混乱。
每次进行旋转、平移或缩放时,执行变换所需的信息都会被积累到一个数字表格中。这个表格,或者说矩阵,只有几行几列,然而,通过数学的奇迹,它包含了执行任何一系列变换所需的所有信息。这就是为什么pushMatrix()和popMatrix()函数名称中会有这个词。
至于 push 和 pop 这两个名字的由来?它们来自计算机中的“栈”结构。可以把栈想象成食堂里那种带弹簧的托盘架:每次有人把托盘放回去,托盘的重量会把底座往下压;有人取托盘时,总是从最上面拿,剩下的托盘会“弹”上来一点。 同理,pushMatrix() 就是把当前坐标系的状态“压”到一块内存区域的顶部,popMatrix() 则是把这个状态“弹”出来恢复。前面的例子用 pushMatrix() 和 popMatrix(),确保每一部分绘制前坐标系都是“干净”的。在其他例子里,其实用不用这两个函数都没关系,但多用也不会有坏处,反而能让坐标系的状态更容易管理。 注意:在 Processing 里,每次执行 draw() 函数时,坐标系都会自动恢复到初始状态(原点在窗口左上角,没有旋转,也没有缩放)。
如果你在三维空间中工作,可以用 translate() 函数传递三个参数,分别表示 x、y、z 方向的平移距离。同样,scale() 也可以传递三个参数,分别控制在每个维度上的缩放比例。 对于旋转,可以使用 rotateX()、rotateY() 或 rotateZ() 函数,分别绕 x、y、z 轴旋转。这三个函数都只需要一个参数:要旋转的弧度数。
让我们用这些变换来制作一个会挥手的蓝色机器人动画。我们不会一次性写完所有代码,而是分阶段逐步实现。第一步,先画出静态的机器人(不带动画)。 机器人的设计参考了下图(虽然最终成品可能没那么可爱)。首先,我们让机器人的左边和上边分别贴着 x 轴和 y 轴,这样就可以用 translate() 很方便地把机器人移动到任意位置,或者像画小房子那样画出多个机器人。 注意:这里说的“左”和“右”都是指你屏幕上的左边和右边,而不是机器人的“左手”“右手”。
def setup():
size(200, 200)
background(255)
smooth()
drawRobot()
def drawRobot():
noStroke()
fill(38, 38, 200)
rect(20, 0, 38, 30) # head
rect(14, 32, 50, 50) # body
rect(0, 32, 12, 37) # left arm
rect(66, 32, 12, 37) # right arm
rect(22, 84, 16, 50) # left leg
rect(40, 84, 16, 50) # right leg
fill(222, 222, 249)
ellipse(30, 12, 12, 12) # left eye
ellipse(47, 12, 12, 12) # right eye
接下来,我们需要确定手臂的旋转(枢轴)点,如下图所示。左右手臂的旋转中心分别是 (12, 32) 和 (66, 32)。注意,“旋转中心”(center of rotation)是“枢轴点”的更正式说法。 现在,把绘制左臂和右臂的代码单独拆出来,并把每只手臂的旋转中心都移动到原点 (0, 0),因为所有旋转操作都是围绕 (0, 0) 进行的。为了节省篇幅,setup() 的代码这里不再重复。
def drawRobot():
noStroke()
fill(38, 38, 200)
rect(20, 0, 38, 30) # head
rect(14, 32, 50, 50) # body
drawLeftArm()
drawRightArm()
rect(22, 84, 16, 50) # left leg
rect(40, 84, 16, 50) # right leg
fill(222, 222, 249)
ellipse(30, 12, 12, 12) # left eye
ellipse(47, 12, 12, 12) # right eye
def drawLeftArm():
pushMatrix()
translate(12, 32)
rect(-12, 0, 12, 37)
popMatrix()
def drawRightArm():
pushMatrix()
translate(66, 32)
rect(0, 0, 12, 37)
popMatrix()
现在测试一下手臂是否正确旋转。作为测试,我们将只将左侧手臂旋转135度,右侧手臂旋转45度,而不是尝试完整的动画。这是需要添加的代码和结果。由于窗口边界,左侧手臂被切断,但我们将在最终动画中修复。
def drawLeftArm():
pushMatrix()
translate(12, 32)
rotate(radians(135))
rect(-12, 0, 12, 37) # left arm
popMatrix()
def drawRightArm():
pushMatrix()
translate(66, 32)
rotate(radians(-45))
rect(0, 0, 12, 37) # right arm
popMatrix()
现在我们通过放入动画来完成程序。左臂必须从0°旋转到135°并向后旋转。由于手臂摆动是对称的,右臂角度总是左臂角度的负值。为了简单起见,我们将以5度为增量。
armAngle = 0
angleChange = 5
ANGLE_LIMIT = 135
def setup():
size(200, 200)
smooth()
frameRate(30)
def draw():
global armAngle, angleChange, ANGLE_LIMIT
print armAngle
background(255)
pushMatrix()
translate(50, 50) # place robot so arms are always on screen
drawRobot()
armAngle += angleChange
# if the arm has moved past its limit,
# reverse direction and set within limits.
if (armAngle > ANGLE_LIMIT or armAngle < 0):
angleChange = -angleChange
armAngle += angleChange
popMatrix()
def drawRobot():
noStroke()
fill(38, 38, 200)
rect(20, 0, 38, 30) # head
rect(14, 32, 50, 50) # body
drawLeftArm()
drawRightArm()
rect(22, 84, 16, 50) # left leg
rect(40, 84, 16, 50) # right leg
fill(222, 222, 249)
ellipse(30, 12, 12, 12) # left eye
ellipse(47, 12, 12, 12) # right eye
def drawLeftArm():
global armAngle
pushMatrix()
translate(12, 32)
rotate(radians(armAngle))
rect(-12, 0, 12, 37) # left arm
popMatrix()
def drawRightArm():
global armAngle
pushMatrix()
translate(66, 32)
rotate(radians(-armAngle))
rect(0, 0, 12, 37) # right arm
popMatrix()
这一次,我们不让机器人的手臂自己动,而是修改程序,让手臂在按下鼠标时跟随鼠标移动。写代码之前,先分析一下问题,理清程序需要做什么。 由于两只手臂是独立运动的,我们需要为每只手臂分别设置一个角度变量。判断要控制哪只手臂也很简单:如果鼠标在机器人中心的左侧,就控制左臂,否则控制右臂。 剩下的问题是如何计算旋转角度。已知枢轴点和鼠标位置,怎么得到它们连线的角度?答案是用 atan2() 函数。它能返回从原点到指定 (x, y) 坐标的连线与 x 轴的夹角(单位是弧度)。注意,atan2() 的参数顺序是先 y 后 x。它的返回值范围是 -π 到 π(也就是 -180° 到 180°)。 如果不是从原点出发,比如要算 (10, 37) 到 (48, 59) 的连线角度怎么办?其实一样,把起点当成原点,算 (48-10, 59-37) 就行。一般来说,已知 (x0, y0) 到 (x1, y1) 的连线角度,可以这样计算: atan2(y1 - y0, x1 - x0) 因为这是一个新概念,建议你先单独写个小程序测试一下 atan2() 的用法。比如画一个矩形,让它的旋转中心在 (100, 100),并且能跟随鼠标旋转。
def setup():
size(200, 200)
def draw():
angle = atan2(mouseY - 100, mouseX - 100)
background(255)
pushMatrix()
translate(100, 100)
rotate(angle)
rect(0, 0, 50, 10)
popMatrix()
效果很好!如果我们把矩形画得更高而不是更宽会怎样?把前面的代码改成 rect(0, 0, 10, 50)。为什么看起来矩形不再跟随鼠标了? 答案是:矩形其实还是在跟随鼠标,只是现在是短边在跟随。我们的眼睛习惯性地希望长边来跟随。由于长边与短边成90度角,你需要减去90°(或 π/2 弧度)才能得到想要的效果。把前面的代码改成 rotate(angle - HALF_PI) 再试试。 因为 Processing 几乎完全使用弧度,所以语言中定义了常量 PI(180°)、HALF_PI(90°)、QUARTER_PI(45°)和 TWO_PI(360°)来方便使用。 现在我们可以写最终版本的手臂跟踪程序了。我们先定义常量和变量。MIDPOINT_X 定义中的数字 39 是这样来的:机器人身体从 x 坐标 14 开始,宽度是 50 像素,所以 39(14 + 25)就是机器人身体的水平中点。
def draw():
global armAngle, angleChange, ANGLE_LIMIT
print armAngle
background(255)
pushMatrix()
translate(50, 50) # place robot so arms are always on screen
drawRobot()
armAngle += angleChange
# if the arm has moved past its limit,
# reverse direction and set within limits.
if (armAngle > ANGLE_LIMIT or armAngle < 0):
angleChange = -angleChange
armAngle += angleChange
popMatrix()
def drawRobot():
noStroke()
fill(38, 38, 200)
rect(20, 0, 38, 30) # head
rect(14, 32, 50, 50) # body
drawLeftArm()
drawRightArm()
rect(22, 84, 16, 50) # left leg
rect(40, 84, 16, 50) # right leg
fill(222, 222, 249)
ellipse(30, 12, 12, 12) # left eye
ellipse(47, 12, 12, 12) # right eye
def drawLeftArm():
global armAngle
pushMatrix()
translate(12, 32)
rotate(radians(armAngle))
rect(-12, 0, 12, 37) # left arm
popMatrix()
def drawRightArm():
global armAngle
pushMatrix()
translate(66, 32)
rotate(radians(-armAngle))
rect(0, 0, 12, 37) # right arm
popMatrix()
接下来是draw()函数。它确定是否按下鼠标以及鼠标位置与枢轴点之间的角度,相应地设置左臂角度和右臂角度。
def draw():
"""
These variables are for mouseX and mouseY,
adjusted to be relative to the robot's coordinate system
instead of the window's coordinate system.
"""
global leftArmAngle, rightArmAngle
background(255)
pushMatrix()
translate(ROBOT_X, ROBOT_Y) # place robot so arms are always on screen
if (mousePressed):
mX = mouseX - ROBOT_X
mY = mouseY - ROBOT_Y
if (mX < MIDPOINT_X): # left side of robot
leftArmAngle = atan2(mY - PIVOT_Y, mX - LEFT_PIVOT_X) - HALF_PI
else:
rightArmAngle = atan2(mY - PIVOT_Y, mX - RIGHT_PIVOT_X) - HALF_PI;
drawRobot()
popMatrix()
drawRobot()函数保持不变,但现在需要对drawLeftArm()和drawRightArm()进行修改。因为左臂角度和右臂角度现在是以弧度计算的,所以这些函数不必进行任何转换。这两个功能的更改以粗体显示。
def drawLeftArm():
pushMatrix()
translate(12, 32)
rotate(leftArmAngle)
rect(-12, 0, 12, 37) # left arm
popMatrix()
def drawRightArm():
pushMatrix()
translate(66, 32)
rotate(rightArmAngle)
rect(0, 0, 12, 37) # right arm
popMatrix()