基本的な変換

CG画像処理

ここからは、座標 の位置に置かれた点や図形を「動かす・変える」操作に話を進めます。
平行移動・拡大縮小・回転といった基本的な変換を、すべて行列の掛け算ひとつで扱えるようにするのが、この記事のゴールです。

幾何学的変換
CG画像処理

図形を構成するすべての点を、ある規則に従って別の位置へ移す操作を幾何学的変換といいます。
ひとつひとつの点の座標を新しい座標へ写すことで、図形全体が移動したり、大きさや向きが変わったりします。

CGや画像処理で基本となるのは、平行移動拡大・縮小回転3つです。

平行移動

図形の形と向き・大きさはそのままに、位置だけをずらす変換が平行移動です。
各点の座標に、x軸方向・y軸方向への移動量を加えることで、図形全体を平行に移動させます。

2次元では、移動量をとすると、点(x, y)は次のように移ります。

3次元でも考え方は同じで、z軸方向の移動量が加わるだけです。

ここで注目したいのは、平行移動が座標への足し算で表されている点です。
たとえば、2次元の平行移動をベクトルの形で書くと、座標ベクトルに移動量のベクトルを足し合わせる操作になります。

この後で見る拡大・縮小や回転が行列の掛け算で書けるのに対して、平行移動だけはこの形のままでは掛け算にまとめられません。この食い違いが、後に同次座標を導入する動機になります。

拡大・縮小

原点からの距離を一定の倍率で伸び縮みさせ、図形の大きさを変える変換が拡大・縮小です。
x軸方向・y軸方向の倍率をとすると、点の座標はそれぞれの倍率を掛けた値になります。

この関係は、倍率を対角線上に並べた行列との掛け算でそのまま表せます。

が等しいときは縦横が同じ比率で変わる等倍スケーリング、異なるときは縦横の比率が変わる非等倍スケーリングになります。

3次元の場合も、z軸方向の倍率を対角線に加えた行列を座標ベクトルに掛けることで、拡大・縮小を表せます。

ここで紹介した拡大・縮小の式は、原点を中心とする場合の式です。
原点以外の点を中心に拡大・縮小するには、一旦その中心を原点へ平行移動し、変換してから元の位置へ戻す、というように変換を組み合わせる必要があります。
こうした複数の変換を組み合わせる方法は、変換の合成と順序 で扱います。

回転

図形をある角度だけ回す変換が回転です。
ここでは、2次元平面上の原点を中心に、点(x, y)を反時計回りに角度θだけ回転させたときの新しい座標を導いてみます。

回転の前後で、原点からの距離は変わりません。
そこで、回す前の点を原点からの距離rと角度αを使って表すと、各座標は次のように書けます。

回転後の点は、距離rはそのままで、角度がα + θになった点です。

ここで三角関数の加法定理を使って展開し、r cosαr sinαがそれぞれxyであることを当てはめます。

こうして、回転後の座標を回転前のx, yと角度θだけで表せました。
この関係をベクトルと行列の形にまとめてみます。このとき現れる行列が、2次元の回転行列です。

3次元では、どのを中心に回すかによって回転行列が3種類に分かれます。

ある軸まわりの回転は、その軸の座標を固定したまま、残りの2軸が張る平面の中で2次元の回転を行うものと考えられます。
そのため各回転行列は、回転の軸に対応する成分を単位ベクトルとして固定し、残りの2軸の部分に2次元の回転行列をはめ込んだ形になります。

たとえばでは、座標ベクトルのx成分に影響するのが1行目と1列目です。
この行と列が(1, 0, 0)となっているため、行列を座標ベクトルにかけても、x座標はそのまま出力されます。つまり、変換(掛け算)の前後でx座標はそのまま保たれ、残るy, z座標だけが2次元の回転を受けることになります。

同様に、では2行目・2列目、では3行目・3列目が、「その軸の成分は変化させない」役割を担っています。

ある軸のまわりの回転では、その軸方向の位置は変化しません。中心軸に対応する座標は入力をそのまま出力へ渡す必要があるため、その座標に影響を与える行列の成分は単位ベクトルとするわけです。

ここで紹介した回転の式も、原点を中心とする場合の式です。

線形変換と平行移動の壁
CG画像処理

ここまで見てきたように、拡大・縮小と回転の操作は、行列を座標ベクトルに掛けるだけで行うことができます。このように、行列の掛け算だけで表せる変換を線形変換と呼びます。

一方、平行移動は座標ベクトルへの足し算で表されていました。つまり、平行移動はこのままでは線形変換の仲間には入れません。

このように変換式が統一されていないことは、実は不便なことです。
たとえば「移動してから回す」のように複数の変換を続けて行いたいとき、回転や拡大は行列の掛け算で、平行移動は足し算で、と異なる演算を混ぜて扱わなければなりません。これは、コンピュータで計算する場合に、プログラムの複雑さや計算量の増大につながります。

できれば、すべての基本変換を同じ「行列の掛け算ひとつ」という形に揃えたいところです。
これを実現するのが、次に紹介する同次座標です。

同次座標
CG画像処理

平行移動を行列の掛け算では表せなかった背景を、もう少し掘り下げてみます。
行列の掛け算では、出力の各座標(x', y')は、入力の座標(x, y)に係数を掛けて足し合わせたものになります。

係数a, b, c, dをどう選んでも、出力x', y'xyを定数倍して足したものにしかなりません。
つまり出力は入力座標x, yの重み付きの和にしかならず、xyの値によらない一定の量を加えることはできません。
平行移動の移動量tx, tyは、まさにこの「座標によらない一定の量」なので、もとの2成分への掛け算だけでは表せなかったのです。

では、掛け算で一定の量を足し込むにはどうすればよいでしょうか。
答えは単純で、常に1をとる成分をひとつ用意すればよいのです。

新たに追加した3つめの成分が常に1なので、係数p, qxyの値によらず、そのまま出力に足し込まれます。このようにすると、先ほどの掛け算だけでは加えられなかった一定の量を、掛け算の中で足せるようになります。

行列の最下行を(0 0 1)としているのは、出力の3つめの成分も常に1に保ち、同じ形の掛け算を何度でも続けられるようにするためです。

このように、すべての変換を行列の掛け算に統一するために、常に1をとる成分を座標に追加したものが同次座標です。

2次元の変換の書き換え

2次元の点(x, y)は、新しい成分wを加えた(x, y, w)という3次元のベクトルで表します。
通常の点ではw1とし、(x, y, 1)のように表します。これが同次座標です。このとき、各変換は33列の行列で表せます。

平行移動は、移動量を行列の右上の列に置くことで、掛け算で表せるようになります。

この掛け算を展開すると、x' = x + txy' = y + tyとなり、たしかに平行移動の足し算が再現されています。最下行は常に(0 0 1)で、変換後もw1に保つ役割を果たします。

拡大・縮小や回転も、もとの22列の行列を左上に置き、平行移動量tx, tyの部分を0にすれば、同じ33列の枠組みに収まります。

拡大・縮小(2次元)

回転(2次元)

3次元の変換の書き換え

3次元の点でも同じ考え方で、(x, y, z)wを加えた(x, y, z, w)という4次元のベクトルで表し、各変換を44列の行列で扱います。このときの最下行は(0 0 0 1)になります。

3次元の拡大・縮小や回転も、先ほどの33列の行列を左上に置けば、そのまま44列の枠組みに収まります。

拡大・縮小(3次元)

x軸まわりの回転

y軸まわりの回転

z軸まわりの回転

wの値の意味

ところで、通常の点でw1にしたことには意味があります。

同次座標では、wの値によって、そのベクトルが位置を表すのか向きを表すのかを区別します。
w1のものは、これまで通り空間内の一点、つまり「どこにあるか」という位置を表します。

一方、w0にしたものは方向ベクトルと呼ばれ、「どこにあるか」ではなく「どちらを向いているか」という向きだけを表します。「右へ」「上へ」といった向きは、それ自体に決まった置き場所を持ちません。こうした向きを表すものが方向ベクトルです。

w1でも0でもない一般の値をとる場合の扱いは、遠近感を表す射影変換 と深く関わります。

では、なぜ向きを表すときにはw0にするのでしょうか。
向きとは、もともと「ある点から別の点へ向かう矢印」、つまり2点のとして得られるものです。
同次座標で2つの点の差を計算してみると、それぞれの点のw1どうしなので引き算で打ち消し合い、差のwはかならず0になります。

このような差の計算から、向きを表すベクトルのw0になるのは自然なことだとわかります。

逆に、「ある点」に「向き」を足すという操作は、その点を矢印の分だけ動かして新しい点を求めることにあたります。移動後のwは、「ある点」のw = 1と「向き」のw = 0を足して1 + 0 = 1のままなので、結果はきちんとw1の点になります。

位置と向きのこの違いは、同じ平行移動の行列を掛けたときの振る舞いにそのまま現れます。
3つめの成分を1に固定せず、wのまま掛け算を展開してみましょう。

x, yに加わる移動量は、wを掛けたものになっています。

  • w1の点では、がそのまま座標に加わるため、点が移動する
  • w0の方向ベクトルでは、0が掛かって消えるため、座標は変わらない

w0の場合、すなわち方向ベクトルは、平行移動の影響を受けないのです。

直感的に考えると、図形を平行移動でどれだけ動かしても、「右を向いている」「上を向いている」といった向きそのものは変わりません。位置は変わっても向きは変わらない、という当たり前の事実が、wの値の違いとして自然に表現されているわけです。

デモを動かして、次のことを確かめてみよう

  • txtyを動かすと、w = 1平面上の点PQだけが動き、w = 0平面上の方向ベクトルQ − Pは動かない(方向ベクトルは平行移動の影響を受けない)
  • Qの向きやPからQまでの距離を変えると、txtyでは動かなかったQ − Pの向きと長さが変わる(方向ベクトルは2点の差だけで決まる)
Three.jsによる実装概要
/**
 * 文字を描いた canvas をテクスチャにして、常にカメラを向く板(Sprite)にする。
 * Q − P のような幅の異なるラベルがあるので、文字の幅を測って板の横幅を決める
 */
const createLabel = (text: string, color: string, height: number) => {
  const canvas = document.createElement("canvas")
  const context = canvas.getContext("2d")

  let textWidth = LABEL_TEXTURE_HEIGHT
  if (context) {
    context.font = LABEL_FONT
    textWidth = context.measureText(text).width
  }

  canvas.width = Math.ceil(textWidth + LABEL_TEXTURE_PADDING * 2)
  canvas.height = LABEL_TEXTURE_HEIGHT

  if (context) {
    // canvas の大きさを変えると描画状態が初期化されるので、書体はここで指定し直す
    context.font = LABEL_FONT
    context.textAlign = "center"
    context.textBaseline = "middle"
    context.fillStyle = color
    context.fillText(text, canvas.width / 2, canvas.height / 2)
  }

  const texture = new CanvasTexture(canvas)
  texture.colorSpace = SRGBColorSpace
  // 透明な余白まで深度を書くと、あとから描く半透明の面や線がラベルの矩形の形に欠けてしまう
  const sprite = new Sprite(
    new SpriteMaterial({ map: texture, transparent: true, depthWrite: false })
  )
  // 高さを指定の値に揃え、幅は canvas の縦横比から決める
  sprite.scale.set((height * canvas.width) / canvas.height, height, 1)

  return sprite
}

// 1 本の軸を、原点をまたぐ直線・正の向きを指す矢印・軸名のラベルの 3 点セットで作る
const createAxis = (name: string, color: string, direction: Vector3) => {
  const group = new Group()

  const lineGeometry = new BufferGeometry().setFromPoints([
    direction.clone().multiplyScalar(-AXIS_LENGTH),
    direction.clone().multiplyScalar(AXIS_LENGTH)
  ])
  group.add(new LineSegments(lineGeometry, new LineBasicMaterial({ color })))

  // ConeGeometry は +y を向いているので、軸の正の向きへ回してから先端に置く
  const arrow = new Mesh(
    new ConeGeometry(ARROW_RADIUS, ARROW_HEIGHT, 16),
    new MeshBasicMaterial({ color })
  )
  arrow.position.copy(direction).multiplyScalar(AXIS_LENGTH)
  arrow.quaternion.setFromUnitVectors(CONE_UP, direction)
  group.add(arrow)

  const label = createLabel(name, color, AXIS_LABEL_HEIGHT)
  label.position.copy(direction).multiplyScalar(AXIS_LENGTH + LABEL_OFFSET)
  group.add(label)

  return group
}

// 同次座標が指す位置を表す球
const createPoint = (position: Vector3, color: string, radius: number) => {
  const point = new Mesh(new SphereGeometry(radius, 16, 12), new MeshBasicMaterial({ color }))
  point.position.copy(position)
  return point
}

// 始点から終点へ向かう矢印。線の先に円錐を載せて作る
const createArrow = (from: Vector3, to: Vector3, color: string, opacity = 1) => {
  const group = new Group()
  const transparent = opacity < 1
  const direction = to.clone().sub(from).normalize()

  // 円錐の底面が線の先端に来るよう、矢印の高さのぶん手前で線を止める
  const shaftEnd = to.clone().addScaledVector(direction, -ARROW_HEIGHT)
  group.add(
    new LineSegments(
      new BufferGeometry().setFromPoints([from, shaftEnd]),
      new LineBasicMaterial({ color, transparent, opacity })
    )
  )

  // ConeGeometry の原点は円錐の中心なので、高さの半分ぶん戻した位置に置く
  const head = new Mesh(
    new ConeGeometry(ARROW_RADIUS, ARROW_HEIGHT, 16),
    new MeshBasicMaterial({ color, transparent, opacity })
  )
  head.position.copy(to).addScaledVector(direction, -ARROW_HEIGHT / 2)
  head.quaternion.setFromUnitVectors(CONE_UP, direction)
  group.add(head)

  return group
}

// w の値を書いたラベル付きの平面。
// 奥の点や矢印を隠さないよう、薄く塗って深度は書かない
const createPlane = (w: number, name: string) => {
  const group = new Group()

  const plane = new Mesh(
    new PlaneGeometry(PLANE_HALF * 2, PLANE_HALF * 2),
    new MeshBasicMaterial({
      color: PLANE_COLOR,
      side: DoubleSide,
      transparent: true,
      opacity: PLANE_OPACITY,
      depthWrite: false
    })
  )
  plane.position.z = w
  group.add(plane)

  const label = createLabel(name, PLANE_COLOR, VALUE_LABEL_HEIGHT)
  label.position.set(PLANE_HALF - 0.5, PLANE_HALF + 0.28, w)
  group.add(label)

  return group
}

// 同次座標 (x, y, w) の w を 3 本目の軸として立てる。
// Three.js の x・y をそのまま平面の x・y にあて、z を w にあてると、
// w = 1 の平面は x が右・y が上のまま読める板になる
scene.add(
  createAxis("x", X_COLOR, X_DIRECTION),
  createAxis("y", Y_COLOR, Y_DIRECTION),
  createAxis("w", W_COLOR, W_DIRECTION)
)

// 位置を表す点が乗る w = 1 の平面と、向きを表す方向ベクトルが寝ている w = 0 の平面
scene.add(createPlane(1, "w = 1"), createPlane(0, "w = 0"))

// Tweakpane で動かす値。平行移動の移動量と、点 Q の向き・距離。
// ここでは初期表示の値をそのまま定数として置く
const tx = 0.85
const ty = 0.65
const angle = 35
const length = 1.1

// 平行移動の行列。移動量は最後の列に置かれ、掛け算のときに w と掛け合わされる
// prettier-ignore
const matrix = new Matrix3().set(
  1, 0, tx,
  0, 1, ty,
  0, 0, 1
)

// 平行移動する前の 2 点。どちらも w = 1 の平面上の点
const radians = MathUtils.degToRad(angle)
const sourceP = new Vector3(P_X, P_Y, 1)
const sourceQ = new Vector3(
  P_X + length * Math.cos(radians),
  P_Y + length * Math.sin(radians),
  1
)

// 2 点の差を取ると、w は 1 どうしで打ち消し合って 0 になる。これが方向ベクトル
const difference = sourceQ.clone().sub(sourceP)

// 点にも方向ベクトルにも、まったく同じ行列を掛ける
const p = sourceP.clone().applyMatrix3(matrix)
const q = sourceQ.clone().applyMatrix3(matrix)
// w = 0 の方向ベクトルでは tx・ty に 0 が掛かって消えるので、差はそのまま残る
const movedDifference = difference.clone().applyMatrix3(matrix)

// 平行移動したあとの 2 点と、P から Q へ向かう矢印。
// 矢印は方向ベクトルと同じ色を薄く塗り、同じ矢印を w = 1 の平面に置いたものだと読めるようにする
scene.add(
  createPoint(p, POINT_COLOR, POINT_RADIUS),
  createPoint(q, POINT_COLOR, POINT_RADIUS),
  createArrow(p, q, DIRECTION_COLOR, ARROW_OPACITY)
)

// 方向ベクトルは、原点から生えて w = 0 の平面に寝た矢印として描く。
// 先端は矢じりが示すので、点は置かない
const origin = new Vector3(0, 0, 0)
scene.add(
  createPoint(origin, GUIDE_COLOR, ORIGIN_RADIUS),
  createArrow(origin, movedDifference, DIRECTION_COLOR)
)

// ラベルは、それが指す点の上か下に置く。
// P だけ下に置いて、矢印とぶつからないようにする
const pLabel = createLabel("P", POINT_COLOR, VALUE_LABEL_HEIGHT)
pLabel.position.set(p.x, p.y - (LABEL_GAP + pLabel.scale.y / 2), 1)

const qLabel = createLabel("Q", POINT_COLOR, VALUE_LABEL_HEIGHT)
qLabel.position.set(q.x, q.y + LABEL_GAP + qLabel.scale.y / 2, 1)

const differenceLabel = createLabel("Q − P", DIRECTION_COLOR, VALUE_LABEL_HEIGHT)
differenceLabel.position.set(
  movedDifference.x,
  movedDifference.y + LABEL_GAP + differenceLabel.scale.y / 2,
  0
)

scene.add(pLabel, qLabel, differenceLabel)

平行移動、拡大・縮小、回転などの変換は、この同次座標を使うことで、一つの行列の掛け算として表せるようになります。

同次座標については、射影変換と同次座標 でさらに詳しく学びます。

変換が同じ形に揃うと、複数の変換を続けて行う操作も、対応する行列を順に掛け合わせるだけで表せます。次は、このような変換の合成 について学びましょう。