ピンホールカメラ

CG画像処理

カメラで撮影された画像も、CG作品も、3次元の光景や物体を2次元平面上に写しとったものです。CGでは、このように平面に落とし込むための変換を投影といいます。

カメラでの撮影とCG で学んだように、カメラでの撮影過程はCGの制作過程に応用されるため、CGの学習ではカメラの仕組みをある程度知っておくと有用です。

カメラを最も単純にした装置である、ピンホールカメラから始めてみましょう。

ピンホールカメラ
CG画像処理

現実のカメラは、レンズで光を集めて像を結びます。しかし、被写体の各点が画像のどこに写るのかという位置関係だけを考えるなら、レンズは一旦取り払ってしまい、光の進み方だけに着目するとわかりやすいです。光をまっすぐ進む線として捉えると、像がどのように作られるのかがはっきりと見えてきます。

光は、周りが一様な空間の中をまっすぐに進みます。この性質を光の直進性といいます。

今、内側を黒く塗った箱を用意し、その面に、針の先ほどの小さな穴(ピンホール)を開けます。

被写体の表面の各点からは、光があらゆる方向へ散らばって出ていきますが、ピンホールを通り抜けられるのは、穴へまっすぐ向かった光だけです。穴を通った光はそのまま直進し、箱の反対側の面に届いて、そこに1つの明るい点を作ります。

被写体のすべての点について同じことが起こるため、反対側の面には被写体の像が浮かび上がります。

光を出す点を動かして、被写体のどこから出た光が像のどこに写るのか、体感してみよう

Three.jsによる実装概要
// 穴のある面。正方形の面に、半径 HOLE_RADIUS の穴を 1 つ開ける
const wallShape = new Shape()
wallShape.moveTo(-HALF_WALL, -HALF_WALL)
wallShape.lineTo(HALF_WALL, -HALF_WALL)
wallShape.lineTo(HALF_WALL, HALF_WALL)
wallShape.lineTo(-HALF_WALL, HALF_WALL)
const holePath = new Path()
holePath.absarc(0, 0, HOLE_RADIUS, 0, Math.PI * 2, true)
wallShape.holes.push(holePath)

const wallMaterial = new MeshBasicMaterial({
  color: WALL_COLOR,
  side: DoubleSide,
  transparent: true,
  opacity: 0.12
})
scene.add(new Mesh(new ShapeGeometry(wallShape, HOLE_SEGMENTS), wallMaterial))

// 像ができる面。穴のある面と向かい合わせに、箱の奥行きだけ離して置く
const backWallGeometry = new PlaneGeometry(WALL_SIZE, WALL_SIZE)
const backWall = new Mesh(backWallGeometry, wallMaterial)
backWall.position.z = -BOX_DEPTH
scene.add(backWall)

const borderGeometry = new EdgesGeometry(backWallGeometry)
const borderMaterial = new LineBasicMaterial({ color: WALL_COLOR })
scene.add(new LineSegments(borderGeometry, borderMaterial))
const backBorder = new LineSegments(borderGeometry, borderMaterial)
backBorder.position.z = -BOX_DEPTH
scene.add(backBorder)

// 箱の稜線。2 つの面の隅どうしを結ぶ
const boxGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute(
    CORNERS.flatMap(([x, y]) => [
      x * HALF_WALL,
      y * HALF_WALL,
      0,
      x * HALF_WALL,
      y * HALF_WALL,
      -BOX_DEPTH
    ]),
    3
  )
)
scene.add(new LineSegments(boxGeometry, new LineBasicMaterial({ color: BOX_COLOR })))

// ピンホール。穴の縁を円で描く
const holeGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute(
    Array.from({ length: HOLE_SEGMENTS }, (_, i) => {
      const angle = (i / HOLE_SEGMENTS) * Math.PI * 2
      return [Math.cos(angle) * HOLE_RADIUS, Math.sin(angle) * HOLE_RADIUS, 0]
    }).flat(),
    3
  )
)
scene.add(new LineLoop(holeGeometry, new LineBasicMaterial({ color: HOLE_COLOR })))

// 被写体。輪郭を閉じた折れ線として、穴のある面から SUBJECT_Z だけ離して置く
const subjectGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute(
    SUBJECT_OUTLINE.flatMap(([x, y]) => [x, y, SUBJECT_Z]),
    3
  )
)
scene.add(new LineLoop(subjectGeometry, new LineBasicMaterial({ color: SUBJECT_COLOR })))

// 奥の面に浮かび上がる像。被写体のすべての点で同じことが起こった結果にあたる。
// 穴を通るので、上下も左右も反転する
const imageGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute(
    SUBJECT_OUTLINE.flatMap(([x, y]) => [
      (-x * BOX_DEPTH) / SUBJECT_Z,
      (-y * BOX_DEPTH) / SUBJECT_Z,
      -BOX_DEPTH
    ]),
    3
  )
)
scene.add(new LineLoop(imageGeometry, new LineBasicMaterial({ color: IMAGE_COLOR })))

// 光を出す点。被写体の表面の 1 点
const pointGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute([POINT_X, POINT_Y, SUBJECT_Z], 3)
)
scene.add(new Points(pointGeometry, new PointsMaterial({ color: POINT_COLOR, size: 0.14 })))

/**
 * 点から出た光が、穴のある面のどこに当たるか。
 * 面の上に渦巻き状に散らして、四方八方へ出た光のうち箱に向かうぶんを均して並べる。
 * 最初の 1 本(index が 0)だけは、穴へまっすぐ向かった光になる。
 */
const wallTarget = (index: number, count: number): [number, number] => {
  const radius = HALF_WALL * Math.sqrt(index / (count - 1))
  const angle = GOLDEN_ANGLE * index
  return [radius * Math.cos(angle), radius * Math.sin(angle)]
}

// 点から出た光を、穴を通り抜けたものと通れなかったものに振り分ける
const passing: number[] = []
const blocked: number[] = []

for (let i = 0; i < RAY_COUNT; i++) {
  const [wallX, wallY] = wallTarget(i, RAY_COUNT)

  // 穴を通り抜けられるのは、穴へまっすぐ向かった光だけ。
  // 通った光はそのまま直進して奥の面(z = -BOX_DEPTH)まで届き、
  // 穴を外れた光は黒く塗られた面に当たってそこで止まる
  const passes = Math.hypot(wallX, wallY) <= HOLE_RADIUS
  const distance = passes ? 1 + BOX_DEPTH / SUBJECT_Z : 1
  const endX = POINT_X + (wallX - POINT_X) * distance
  const endY = POINT_Y + (wallY - POINT_Y) * distance
  const endZ = SUBJECT_Z * (1 - distance)

  const vertices = passes ? passing : blocked
  vertices.push(POINT_X, POINT_Y, SUBJECT_Z, endX, endY, endZ)
}

const passingGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute(passing, 3)
)
scene.add(new LineSegments(passingGeometry, new LineBasicMaterial({ color: PASS_COLOR })))

const blockedGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute(blocked, 3)
)
scene.add(new LineSegments(blockedGeometry, new LineBasicMaterial({ color: RAY_COLOR })))

// その点の光が奥の面に作る、1 つの明るい点。穴を通るので、被写体の点と上下左右が逆になる
const spotGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute(
    [(-POINT_X * BOX_DEPTH) / SUBJECT_Z, (-POINT_Y * BOX_DEPTH) / SUBJECT_Z, -BOX_DEPTH],
    3
  )
)
scene.add(new Points(spotGeometry, new PointsMaterial({ color: PASS_COLOR, size: 0.14 })))

ピンホールを通った光はまっすぐ進むため、被写体の上から来た光は面の下へ、下から来た光は面の上へ届きます。そのため、浮かび上がる像は上下・左右に反転した形になります。

このように、ピンホールを通る光だけで像を作る装置をピンホールカメラといいます。

ピンホールカメラモデル
CG画像処理

ピンホールカメラのしくみを、CGや画像処理で扱いやすい幾何的なモデルに置き換えることを考えます。

まず、光が通る穴(ピンホール)を、大きさをもたない1つの点とみなします。この点を光学中心と呼びます。被写体から出た光は、すべてこの1点を通って反対側へ抜けていくと考えます。

像が結ばれる面は投影面と呼びます。被写体の各点から光学中心へ向かった光が投影面と交わる位置が、その点の像になります。

現実のカメラでいえば、投影面撮像素子 が置かれる面にあたります。

このように、光学中心という1点を通る光の直進だけで像の成り立ちを表したカメラのモデルをピンホールカメラモデルといいます。

先ほどのデモで見たように、ピンホールカメラでは投影面(箱の反対側の面)が光学中心(ピンホール)より後ろ側にあり、像は反転します。

ただし、モデルとして扱うときは、投影面を光学中心の側(被写体と同じ側)に置くのが一般的です。
こうすることで、像の向きが被写体と同じになり、位置関係を考えやすくなります。

投影面を前に置くことで、像の向きが被写体と揃うことを確認してみよう

Three.jsによる実装概要
// 被写体。輪郭を閉じた折れ線として、光学中心から SUBJECT_Z だけ離して置く
const subjectGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute(
    SUBJECT_OUTLINE.flatMap(([x, y]) => [x, y, SUBJECT_Z]),
    3
  )
)
scene.add(new LineLoop(subjectGeometry, new LineBasicMaterial({ color: SUBJECT_COLOR })))

// 光学中心。大きさをもたない 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 })))

// 投影面。光軸に垂直な正方形の面を、光学中心の後ろ(z の負の向き)に置く
const planeZ = -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 = planeZ
scene.add(plane)

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

// 投影面に結ばれる像。頂点は被写体の輪郭と同じ数だけ確保して、投影の計算結果で埋める
const imagePosition = new Float32BufferAttribute(new Float32Array(SUBJECT_OUTLINE.length * 3), 3)
const imageGeometry = new BufferGeometry().setAttribute("position", imagePosition)
scene.add(new LineLoop(imageGeometry, new LineBasicMaterial({ color: IMAGE_COLOR })))

// 被写体の各点から光学中心へ向かう光線。1 本につき 2 頂点
const rayPosition = new Float32BufferAttribute(new Float32Array(SUBJECT_OUTLINE.length * 6), 3)
const rayGeometry = new BufferGeometry().setAttribute("position", rayPosition)
scene.add(new LineSegments(rayGeometry, new LineBasicMaterial({ color: RAY_COLOR })))

/** 被写体上の点 (x, y, SUBJECT_Z) と光学中心を結ぶ直線が、z = targetZ の面と交わる位置 */
const intersectAt = (x: number, y: number, targetZ: number): [number, number, number] => [
  (targetZ * x) / SUBJECT_Z,
  (targetZ * y) / SUBJECT_Z,
  targetZ
]

SUBJECT_OUTLINE.forEach(([x, y], i) => {
  // 投影面が後ろ(planeZ < 0)にあるとき、像の位置は被写体と符号が逆になる=反転する
  const [imageX, imageY] = intersectAt(x, y, planeZ)
  imagePosition.setXYZ(i, imageX, imageY, planeZ)

  // 光線は被写体の点から光学中心へ向かい、投影面が後ろにあるときは
  // 光学中心を通り抜けて投影面まで伸びる
  const rayEnd = intersectAt(x, y, Math.min(planeZ, 0))
  rayPosition.setXYZ(i * 2, x, y, SUBJECT_Z)
  rayPosition.setXYZ(i * 2 + 1, ...rayEnd)
})

光軸と焦点距離
CG画像処理

ピンホールカメラモデルの幾何は、基準となる線と点をいくつか定めることで、さらに整理できます。

  • 光軸:光学中心を通り、投影面に垂直に立てた直線
  • 画像中心:光軸が投影面と交わる点(投影された画像の中央点)
  • 焦点距離:光学中心から投影面までの、光軸に沿った距離

光軸は、カメラが正面としてまっすぐ見ている方向に伸びていきます。

焦点距離を動かして、どこが焦点距離にあたるのか、焦点距離によって像の大きさがどう変わるのか、観察してみよう

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 })))

// 光軸。光学中心を通り、投影面に垂直な向き(z の正の向き)へ伸びる直線。
// 光学中心から画像中心までの区間が焦点距離にあたるので、そこだけ色を分けて描く
const focalGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute([0, 0, 0, 0, 0, FOCAL_LENGTH], 3)
)
scene.add(new LineSegments(focalGeometry, new LineBasicMaterial({ color: FOCAL_COLOR })))

const axisGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute([0, 0, FOCAL_LENGTH, 0, 0, AXIS_END], 3)
)
scene.add(new LineSegments(axisGeometry, new LineBasicMaterial({ color: AXIS_COLOR })))

// 投影面。光軸に垂直な正方形の面を、焦点距離だけ光学中心から離して置く
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 = FOCAL_LENGTH
scene.add(plane)

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

// 画像中心。光軸が投影面と交わる点なので、投影面と同じ位置に置く
const imageCenterGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute([0, 0, 0], 3)
)
const imageCenter = new Points(
  imageCenterGeometry,
  new PointsMaterial({ color: CENTER_COLOR, size: 0.14 })
)
imageCenter.position.z = FOCAL_LENGTH
scene.add(imageCenter)

// 被写体。輪郭を閉じた折れ線として、光学中心から SUBJECT_Z だけ離して置く
const subjectGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute(
    SUBJECT_OUTLINE.flatMap(([x, y]) => [x, y, SUBJECT_Z]),
    3
  )
)
scene.add(new LineLoop(subjectGeometry, new LineBasicMaterial({ color: SUBJECT_COLOR })))

// 被写体の各点から光学中心へ向かう光線。1 本につき 2 頂点。
// 焦点距離を変えても、この光線の束は変わらない
const rayGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute(
    SUBJECT_OUTLINE.flatMap(([x, y]) => [x, y, SUBJECT_Z, 0, 0, 0]),
    3
  )
)
scene.add(new LineSegments(rayGeometry, new LineBasicMaterial({ color: RAY_COLOR })))

// 光線が投影面と交わる位置が像。被写体までの距離が同じなら、
// 像は焦点距離に比例して、画像中心を中心に相似に拡大縮小する
const magnification = FOCAL_LENGTH / SUBJECT_Z
const imagePosition = new Float32BufferAttribute(new Float32Array(SUBJECT_OUTLINE.length * 3), 3)
SUBJECT_OUTLINE.forEach(([x, y], i) => {
  imagePosition.setXYZ(i, x * magnification, y * magnification, FOCAL_LENGTH)
})
const imageGeometry = new BufferGeometry().setAttribute("position", imagePosition)
scene.add(new LineLoop(imageGeometry, new LineBasicMaterial({ color: IMAGE_COLOR })))

レンズの焦点距離 は、レンズから光が集まる位置までの距離として定義されますが、レンズをもたないピンホールカメラでは、光学中心から投影面までの距離が焦点距離にあたります。
表すものは違っても、どちらも写る範囲や像の大きさを左右する距離だということは変わりません。

画角と遠近感
CG画像処理

ピンホールカメラモデルだけでも、カメラが写すことができる範囲の広さを考察することができます。
投影面に写る範囲を表す角度を画角といい、画角が広いほど、写せる範囲は広くなります。

画角は、焦点距離投影面の大きさで決まります。投影面の大きさ(現実のカメラでは撮像素子の大きさ)が同じであれば、焦点距離によって画角は次のように変わります。

  • 焦点距離が長いほど画角は狭くなり、遠くのものが大きく写る(近くのものは写らない)
  • 焦点距離が短いほど画角は広くなり、広い範囲が画面に収まる

この関係から、レンズを備えたカメラでは、焦点距離によってレンズが区別されます。

  • 広角レンズ:焦点距離の短いレンズ(広い風景を写せる)
  • 望遠レンズ:焦点距離の長いレンズ(遠くの被写体を大きく写せる)

焦点距離や投影面の大きさを動かして、画角の値がどう変わるか、投影面に写る範囲に収まる立方体の数がどう変わるか、観察してみよう

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 })))

// 光軸。光学中心から投影面に垂直な向き(z の正の向き)へ伸びる直線
const axisGeometry = new BufferGeometry().setAttribute(
  "position",
  new Float32BufferAttribute([0, 0, 0, 0, 0, MAX_DEPTH], 3)
)
scene.add(new LineSegments(axisGeometry, new LineBasicMaterial({ color: AXIS_COLOR })))

// 投影面。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.scale.set(PLANE_SIZE, PLANE_SIZE, 1)
plane.position.z = FOCAL_LENGTH
scene.add(plane)

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

// 画角は、光学中心から投影面の端を見込む角の 2 倍。投影面の大きさと焦点距離だけで決まる
const spread = PLANE_SIZE / 2 / FOCAL_LENGTH // 奥行き 1 あたりの広がり
const angleOfView = (2 * Math.atan(spread) * 180) / Math.PI // 度

// 視錐台。光学中心から投影面の 4 隅を通って奥へ伸びる 4 本の稜線と、その先を閉じる四角形
const depth = Math.min(MAX_DEPTH, MAX_HALF_SIZE / spread)
const half = spread * depth
const frustumPosition = new Float32BufferAttribute(new Float32Array(16 * 3), 3)
CORNERS.forEach(([cornerX, cornerY], i) => {
  frustumPosition.setXYZ(i * 2, 0, 0, 0)
  frustumPosition.setXYZ(i * 2 + 1, cornerX * half, cornerY * half, depth)

  const [nextX, nextY] = CORNERS[(i + 1) % CORNERS.length]
  frustumPosition.setXYZ(8 + i * 2, cornerX * half, cornerY * half, depth)
  frustumPosition.setXYZ(8 + i * 2 + 1, nextX * half, nextY * half, depth)
})
const frustumGeometry = new BufferGeometry().setAttribute("position", frustumPosition)
scene.add(new LineSegments(frustumGeometry, new LineBasicMaterial({ color: FRUSTUM_COLOR })))

// 光学中心から見た点が画角の内側にあるか。spread は奥行き 1 あたりの広がり
const isInsideAngleOfView = ([x, y, z]: [number, number, number], spread: number) =>
  Math.abs(x) <= spread * z && Math.abs(y) <= spread * z

// 空間に置いた立方体のうち、画角の内側に入っているものだけが投影面に写る
const cubeGeometry = new EdgesGeometry(new BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE))
CUBE_POSITIONS.forEach((position) => {
  const color = isInsideAngleOfView(position, spread) ? INSIDE_COLOR : OUTSIDE_COLOR
  const cube = new LineSegments(cubeGeometry, new LineBasicMaterial({ color }))
  cube.position.set(...position)
  scene.add(cube)
})

光学中心という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 })))

// 投影面。光学中心の前(被写体と同じ側)に、焦点距離だけ離して置く
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 = FOCAL_LENGTH
scene.add(plane)

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

// 同じ大きさの立方体。1 つのジオメトリを 4 つで共有する
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(CUBE_EDGES)

// 光線。立方体の中心から光学中心へ向かい、その途中で投影面を通る
const rayPosition = new Float32BufferAttribute(new Float32Array(CUBE_OFFSETS.length * 6), 3)

CUBE_OFFSETS.forEach(([offsetX, offsetY], i) => {
  // 奥へ行くほど光学中心から遠ざかる。大きさは 4 つとも同じまま
  const centerZ = DISTANCE + i * CUBE_SPACING
  const cube = new LineSegments(cubeGeometry, new LineBasicMaterial({ color: CUBE_COLOR }))
  cube.position.set(offsetX, offsetY, centerZ)
  scene.add(cube)

  // 立方体の 8 頂点を投影面へ写す。稜線のつなぎ方(インデックス)は立方体と同じ
  const imagePosition = new Float32BufferAttribute(new Float32Array(CUBE_VERTICES.length * 3), 3)
  CUBE_VERTICES.forEach(([vertexX, vertexY, vertexZ], v) => {
    const x = offsetX + vertexX * CUBE_SIZE
    const y = offsetY + vertexY * CUBE_SIZE
    const z = centerZ + vertexZ * CUBE_SIZE
    // 光学中心(原点)とこの頂点を結ぶ直線が、投影面と交わる位置。
    // 焦点距離が同じでも、遠い頂点ほど FOCAL_LENGTH / z が小さくなり、像は小さくなる
    imagePosition.setXYZ(v, (FOCAL_LENGTH * x) / z, (FOCAL_LENGTH * y) / z, FOCAL_LENGTH)
  })
  const imageGeometry = new BufferGeometry()
    .setAttribute("position", imagePosition)
    .setIndex(CUBE_EDGES)
  scene.add(new LineSegments(imageGeometry, new LineBasicMaterial({ color: IMAGE_COLOR })))

  rayPosition.setXYZ(i * 2, offsetX, offsetY, centerZ)
  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 })))

ピンホールカメラモデルは単純でありながら、光学中心を通る光の直進だけで写る範囲や遠近感まで説明できる、カメラの最も基本的な幾何モデルとなっています。