2D变换(2D Transformations)


本教程适用于 Processing 的 Python 模式。如果您发现任何错误或有建议,请告诉我们。部分内容改编自 Daniel Shiffman 等教程,遵循 CC BY-NC-SA 4.0 协议。

简介

Processing 提供了内置的2D变换函数,让你可以轻松实现图形的移动、旋转、缩放等效果。常用的有 translate()rotate()scale()。这些变换操作的本质是改变坐标系,而不是直接改变图形本身。

平移 translate()

用代码绘制的简单矩形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() 的优势:

  • 代码更简洁:不需要在每个图形坐标前加 x, y
  • 易于维护:修改房子形状时,只需改一处
  • 逻辑清晰:房子内部坐标都是相对值,更直观
  • 易于扩展:添加更多房子只需修改循环

旋转 rotate()

除了移动网格,你还可以使用 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()
旋转示例:灰色原始方块,黑色旋转45度方块

嘿,发生了什么?为什么方块被移动和切断了?答案是:方块没有移动。是网格被旋转了。这就是真正发生的事情。正如你所看到的,在旋转后的坐标系中,方块的左上角仍然在 (40, 40)。

旋转坐标系演示
旋转坐标系演示:网格旋转45度,方块在旋转后的坐标系中绘制

顺序很重要:先 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()

缩放 scale()

最后的坐标系变换是缩放,即改变网格的大小。来看这个例子,它先绘制一个正方形,然后将网格缩放至正常大小的两倍,然后再绘制一次。

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()
左臂旋转135°,右臂旋转-45°的效果

现在我们通过放入动画来完成程序。左臂必须从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()
拖动鼠标,矩形会指向鼠标方向(atan2演示)

效果很好!如果我们把矩形画得更高而不是更宽会怎样?把前面的代码改成 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()