透視投影と平行投影

CG画像処理

ピンホールカメラ では、光の直進だけで3次元の物体が2次元の像になる仕組みを見ました。
CGでは、ピンホールカメラモデルを数式化した透視投影と、それを発展させた平行投影という仕組みがよく使われます。

投影
CG画像処理

3次元空間に置かれた物体を、2次元の平面上の像へ写す操作を投影といいます。

立体を平面の画像として表示する以上、3DCGではこの投影の処理が必要です。大まかには、次のような処理を行います。

  1. 物体の上の各点から1本ずつ投射線を引く
  2. 線が投影面 と交わった位置を、その点のとする

ピンホールカメラモデル では、投射線はすべて光学中心という1点を通っていました。このような、投射線が集まる点を投影中心といいます。

CGで使われる投影法は、この投影中心を空間のどこに置くかによって、大きく2種類に分けられます。
投影中心を有限の距離に置いたものが透視投影、無限に遠ざけたものが平行投影です。

透視投影
CG画像処理

透視投影は、1点に集まる投射線で物体を写す投影です。
物体上の点と投影中心を結ぶ直線が、投影面と交わる位置に、その点の像ができます。

Three.jsによる実装概要
// 投影中心。投射線が集まる 1 点で、大きさをもたない点として原点に置く
const centerGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute([0, 0, 0], 3)
)
scene.add(new Points(centerGeometry, new PointsMaterial({ color: CENTER_COLOR, size: 0.14 })))

// 投影面。投影中心の前(形状と同じ側)に PLANE_DISTANCE だけ離して置いた正方形の面
const planeGeometry = new PlaneGeometry(PLANE_SIZE, PLANE_SIZE)
const plane = new Mesh(
  planeGeometry,
  new MeshBasicMaterial({
    color: PLANE_COLOR,
    side: DoubleSide,
    transparent: true,
    opacity: 0.12
  })
)
plane.position.z = PLANE_DISTANCE
scene.add(plane)

const border = new LineSegments(
  new EdgesGeometry(planeGeometry),
  new LineBasicMaterial({ color: PLANE_COLOR })
)
border.position.z = PLANE_DISTANCE
scene.add(border)

// 投影する形状。透視投影での歪み方が形ごとに違うので、平らな板・曲面・角のある立体を混ぜる。
// どれも投影中心の前(z の正の側)に、投影中心からの距離を変えて置く
const shapeMaterial = new LineBasicMaterial({ color: SHAPE_COLOR })
const shapes = [
  {
    // 円柱。投影面に正対していない円は、像の上では楕円になる
    geometry: new CylinderGeometry(0.4, 0.4, 1.05, 12),
    position: new Vector3(-0.88, 0.6, 3),
    rotation: new Euler(0.55, 0, 0.45)
  },
  {
    // 立方体。投影面に正対していない面は、像の上では正方形ではなく台形になる
    geometry: new BoxGeometry(0.9, 0.9, 0.9),
    position: new Vector3(0.86, -1, 4.3),
    rotation: new Euler(0.25, -0.5, 0)
  },
  {
    // 奥行きに長い直方体。手前の面ほど大きく写り、奥行き方向の稜線は像の上で狭まっていく
    geometry: new BoxGeometry(0.75, 0.75, 1.5),
    position: new Vector3(1.03, 1.25, 5.5),
    rotation: new Euler(0, 0.25, 0)
  }
].map(({ geometry, position, rotation }) => {
  // 稜線だけを線で描く。もとのジオメトリは稜線を取り出したら要らない
  const edgesGeometry = new EdgesGeometry(geometry)
  geometry.dispose()

  const shape = new LineSegments(edgesGeometry, shapeMaterial)
  shape.position.copy(position)
  shape.rotation.copy(rotation)
  // 投影の計算には世界座標での頂点位置が必要なので、行列を作っておく
  shape.updateMatrixWorld()
  scene.add(shape)

  // 稜線の頂点を世界座標に直したもの。投射線と像はこの位置から求める
  const localPosition = edgesGeometry.getAttribute("position")
  const vertices = Array.from({ length: localPosition.count }, (_, i) =>
    new Vector3().fromBufferAttribute(localPosition, i).applyMatrix4(shape.matrixWorld)
  )

  return vertices
})

// 投射線。稜線の頂点 1 つにつき 1 本(2 頂点)、頂点から投影中心へ引く。
// どの頂点も同じ 1 点へ向かうので、投影面をどこに置いても投射線は変わらない
const rayVertices = shapes.flat()
const rayPosition = new Float32BufferAttribute(new Float32Array(rayVertices.length * 6), 3)
rayVertices.forEach(({ x, y, z }, i) => {
  rayPosition.setXYZ(i * 2, x, y, z)
  rayPosition.setXYZ(i * 2 + 1, 0, 0, 0)
})
const rayGeometry = new BufferGeometry().setAttribute("position", rayPosition)
scene.add(new LineSegments(rayGeometry, new LineBasicMaterial({ color: RAY_COLOR })))

// 投影面に写る像。形状 1 つにつき 1 つで、稜線のつなぎ方は形状と同じ
const imageMaterial = new LineBasicMaterial({ color: IMAGE_COLOR })
shapes.forEach((vertices) => {
  const imagePosition = new Float32BufferAttribute(new Float32Array(vertices.length * 3), 3)
  vertices.forEach(({ x, y, z }, i) => {
    // 頂点と投影中心(原点)を結ぶ直線が投影面 z = PLANE_DISTANCE と交わる位置は、
    // 投影面に平行な向きの座標を PLANE_DISTANCE / z 倍したもの。
    // 投影中心から遠い頂点ほど倍率が小さくなるので、1 つの形状の中でも
    // 手前と奥で縮み方が変わり、像は歪んで写る
    const scale = PLANE_DISTANCE / z
    imagePosition.setXYZ(i, x * scale, y * scale, PLANE_DISTANCE)
  })
  const imageGeometry = new BufferGeometry().setAttribute("position", imagePosition)
  scene.add(new LineSegments(imageGeometry, imageMaterial))
})

これは、ピンホールカメラモデル で見た像の成り立ちと同じものです。
カメラも人の眼も、1点に光を集めて像を作る以上、その写り方は透視投影で表されます。

光が集まる点である投影中心はカメラであり、その点から眺めた投影面が、眼や画像に映る景色だと捉えると、CGにおける透視投影の役割がわかってきます。

透視投影の強み:写実的な描画

透視投影では、投射線が1点に集まることで、同じ大きさの物体でも、投影中心から遠いものほど小さく写ります。
この縮み方が像に遠近感を与え、私たちが普段目にしている光景と同じ写り方になります。そのため、写実的な描写を目的とする場合は、透視投影が基本の投影方式になります。

透視投影の弱点:形状の把握

その一方で、像の大きさが投影中心からの距離に左右されることは、デメリットにもなり得ます。
同じ長さの辺でも、手前にあるか奥にあるかで像の長さは変わってしまうため、透視投影で描いた図から、もとの形状の寸法を読み取ることはできません。
さらに、平行な線が歪んで表示されることもあり、透視投影は形状を正確に把握したい場合には適していないのです。

平行投影
CG

たとえば、機械の部品や建物の設計図では、描かれた図から長さや角度を正しく把握できることが求められます。このような場合、透視投影は適していません。

そこで、各点から投影面への投射線を平行にした、平行投影という投影法があります。

透視投影と平行投影の関係

平行投影は、透視投影の特別な場合と考えることができます。

透視投影において、投影中心を投影面から遠ざけていくと、投射線の間隔が小さくなっていき、やがて投射線が重なってしまいそうです。
そして無限に遠ざけた先では、重なり合った投射線が互いに平行になります。この平行な投射線で形状を写す投影が平行投影なのです。

投影中心までの距離を最大にした後に、投影中心を無限遠に置くことで、投射線が平行になる過程を見てみよう

Three.jsによる実装概要
// 投影面。z = 0 に置いた正方形の面
const planeGeometry = new PlaneGeometry(PLANE_SIZE, PLANE_SIZE)
scene.add(
  new Mesh(
    planeGeometry,
    new MeshBasicMaterial({
      color: PLANE_COLOR,
      side: DoubleSide,
      transparent: true,
      opacity: 0.12
    })
  )
)
scene.add(
  new LineSegments(new EdgesGeometry(planeGeometry), new LineBasicMaterial({ color: PLANE_COLOR }))
)

// 投影する形状。投影面の手前(z の正の側)に立方体を 1 つ置く
const shapeGeometry = new BufferGeometry()
  .setAttribute("position", new Float32BufferAttribute(VERTICES.flat(), 3))
  .setIndex(CUBE_EDGES)
scene.add(new LineSegments(shapeGeometry, new LineBasicMaterial({ color: SHAPE_COLOR })))

// 投影中心。投影面の向こう側(z の負の側)に、大きさをもたない 1 つの点として置く。
// 無限に遠ざけた投影中心は空間のどこにも置けないので、点としては描かない
const centerGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute([0, 0, 0], 3)
)
const center = new Points(centerGeometry, new PointsMaterial({ color: CENTER_COLOR, size: 0.14 }))
center.position.z = -DISTANCE
center.visible = !AT_INFINITY
scene.add(center)

// 投影面に写る像。稜線のつなぎ方(インデックス)は形状と同じ
const imagePosition = new Float32BufferAttribute(new Float32Array(VERTICES.length * 3), 3)
const imageGeometry = new BufferGeometry()
  .setAttribute("position", imagePosition)
  .setIndex(CUBE_EDGES)
scene.add(new LineSegments(imageGeometry, new LineBasicMaterial({ color: IMAGE_COLOR })))

// 投射線。形状の頂点 1 つにつき 1 本(2 頂点)
const rayPosition = new Float32BufferAttribute(new Float32Array(VERTICES.length * 6), 3)
const rayGeometry = new BufferGeometry().setAttribute("position", rayPosition)
scene.add(new LineSegments(rayGeometry, new LineBasicMaterial({ color: RAY_COLOR })))

VERTICES.forEach(([x, y, z], i) => {
  // 投影中心 (0, 0, -DISTANCE) と頂点を結ぶ直線が投影面 z = 0 と交わる位置は、
  // 投影面に平行な向きの座標を DISTANCE / (DISTANCE + z) 倍したもの。
  // 投影中心を遠ざけるほどこの倍率は 1 に近づき、無限遠では縮まなくなる
  const scale = AT_INFINITY ? 1 : DISTANCE / (DISTANCE + z)
  imagePosition.setXYZ(i, x * scale, y * scale, 0)

  // 投射線は頂点から投影中心へ向かう。無限遠では 1 点に集まらず、
  // 投影面に垂直なまま向こう側へ抜けていく
  rayPosition.setXYZ(i * 2, x, y, z)
  if (AT_INFINITY) rayPosition.setXYZ(i * 2 + 1, x, y, -RAY_TAIL)
  else rayPosition.setXYZ(i * 2 + 1, 0, 0, -DISTANCE)
})

投射線が平行であれば、物体が投影面からどれだけ離れても、投射線の間隔は変わりません。そのため、同じ向きの同じ長さの辺は、手前にあっても奥にあっても同じ長さに写ります。空間で平行な直線どうしは、像の上でも平行なままです。

平行投影の特徴とユースケース

平行投影では、投影面に平行な向きの長さは縮まずに写るので、図の上で測った長さが、そのまま形状の寸法に対応します。設計図や製図で平行投影が使われるのは、この読み取りやすさによります。

その代わり、平行投影の像に遠近感はありません。奥行きの違いが像の大きさに現れないため、どちらが手前にあるのかは、形の重なりや陰影といった別の手がかりから判断することになります。

カメラ(平行投影と透視投影)を切り替えて、箱の壁に写る像を見比べてみよう

Three.jsによる実装概要
// 透視投影の枠は画角と近接面までの距離で決まる。平行投影には画角にあたるものがないので、
// 奥行きによらず一定の枠の大きさを直に決める
const PERSPECTIVE_HALF_SIZE = NEAR * Math.tan(((FOV / 2) * Math.PI) / 180)

// 写す側のカメラ。どちらも原点に置き、three.js の既定の向き(-z)のまま使う。
// アスペクト比は 1(正方形の枠)にして、2 つのカメラを同じ条件で比べる
const perspectiveCamera = new PerspectiveCamera(FOV, 1, NEAR, FAR)
const orthographicCamera = new OrthographicCamera(
  -ORTHOGRAPHIC_HALF_SIZE,
  ORTHOGRAPHIC_HALF_SIZE,
  ORTHOGRAPHIC_HALF_SIZE,
  -ORTHOGRAPHIC_HALF_SIZE,
  NEAR,
  FAR
)

// 投影の計算はカメラの位置・向きも使うので、行列を作っておく(どちらも原点から動かさない)
perspectiveCamera.updateMatrixWorld()
orthographicCamera.updateMatrixWorld()

const isOrthographic = PROJECTION === "orthographic"
const camera: Camera = isOrthographic ? orthographicCamera : perspectiveCamera
const half = isOrthographic ? ORTHOGRAPHIC_HALF_SIZE : PERSPECTIVE_HALF_SIZE

// 近接面。1 辺 1 の正方形を、選んだカメラの枠の大きさに合わせて広げる
const planeGeometry = new PlaneGeometry(1, 1)
const plane = new Mesh(
  planeGeometry,
  new MeshBasicMaterial({
    color: PLANE_COLOR,
    side: DoubleSide,
    transparent: true,
    opacity: 0.12
  })
)
plane.position.z = -NEAR
plane.scale.set(half * 2, half * 2, 1)
scene.add(plane)

// 写される立方体。1 つのジオメトリを 3 つで共有する
const cubeGeometry = new BufferGeometry()
  .setAttribute(
    "position",
    new Float32BufferAttribute(
      CUBE_VERTICES.flatMap(([x, y, z]) => [x * CUBE_SIZE, y * CUBE_SIZE, z * CUBE_SIZE]),
      3
    )
  )
  .setIndex(BOX_EDGES)
const cubeMaterial = new LineBasicMaterial({ color: CUBE_COLOR })
CUBE_POSITIONS.forEach((position) => {
  const cube = new LineSegments(cubeGeometry, cubeMaterial)
  cube.position.set(...position)
  scene.add(cube)
})

// 視錐台。正規化デバイス座標の 8 隅を逆投影すると、そのカメラの視錐台の 8 隅になる。
// 透視投影では奥ほど広がる角錐台、平行投影では奥まで太さの変わらない直方体
const point = new Vector3()
const frustumPosition = new Float32BufferAttribute(new Float32Array(NDC_CORNERS.length * 3), 3)
NDC_CORNERS.forEach(([x, y, z], i) => {
  point.set(x, y, z).unproject(camera)
  frustumPosition.setXYZ(i, point.x, point.y, point.z)
})
const frustumGeometry = new BufferGeometry()
  .setAttribute("position", frustumPosition)
  .setIndex(BOX_EDGES)
scene.add(new LineSegments(frustumGeometry, new LineBasicMaterial({ color: FRUSTUM_COLOR })))

// 近接面に写る像。稜線のつなぎ方(インデックス)は立方体と同じ
const imageMaterial = new LineBasicMaterial({ color: IMAGE_COLOR })
CUBE_POSITIONS.forEach(([centerX, centerY, centerZ]) => {
  const imagePosition = new Float32BufferAttribute(new Float32Array(CUBE_VERTICES.length * 3), 3)
  CUBE_VERTICES.forEach(([vertexX, vertexY, vertexZ], v) => {
    // 頂点をカメラで投影すると、-1〜1 の正規化デバイス座標が返る。
    // それを近接面の枠に合わせて広げた位置が、この面に写る像になる。
    // 透視投影では奥の頂点ほど中心へ寄り、平行投影では奥行きによらず同じ位置に写る
    point
      .set(
        centerX + vertexX * CUBE_SIZE,
        centerY + vertexY * CUBE_SIZE,
        centerZ + vertexZ * CUBE_SIZE
      )
      .project(camera)
    imagePosition.setXYZ(v, point.x * half, point.y * half, -NEAR)
  })
  const imageGeometry = new BufferGeometry()
    .setAttribute("position", imagePosition)
    .setIndex(BOX_EDGES)
  scene.add(new LineSegments(imageGeometry, imageMaterial))
})

平行投影は、形状の読み取りやすさが重視される場面で活用されます。
一方、写実的な仕上がりを求める場合や、奥行きがあってダイナミックな世界を表現する映画やゲームに使われることはほとんどなく、これらは透視投影の出番です。

平行投影の写り方を決めるのは、投影中心の位置ではなく投射線向きです。その向きをどう取るかによって、さまざまな平行投影 を考えることができます。