投影の手順
空間の点が投影面のどこへ写るかが分かっても、それだけでは1枚の画像にはなりません。投影面のどの範囲を画像とし、空間のどこまでを写す対象にするのかも、合わせて決める必要があります。
ウィンドウ
空間の点が投影面上のどこへ写るかが決まっても、画像として切り取る範囲は決まっていません。
そこで、投影面の上に長方形の枠を置き、その内側に写ったものだけを画像として取り出します。この枠をウィンドウといいます。
透視投影のウィンドウと画角
投影中心 は光が集まる点であり、眼やカメラの視点として捉えることができます。
この投影中心(視点)からウィンドウの左端から右端へ線を引くと、2本の線ができます。この2本の線がつくる角度が画角です。
たとえば、画角が75度なら、視点から見渡せる世界も75度の範囲になります。このように、投影中心(視点)から見たときの視野を角度で表したものという意味で、画角は視野角とも呼ばれます。
ウィンドウを通して見える空間は、視点を頂点とし、ウィンドウの枠を断面とする四角錐の内側です。
ウィンドウを大きくとるほど、または投影面を投影中心へ近づけるほど、画角は広がり、写せる範囲が広くなります。
ウィンドウの大きさや投影面までの距離を変えたときに、画角の広さがどう変わるかを観察してみよう
Three.jsによる実装概要
const halfWindow = WINDOW_SIZE / 2
// 画角は、視点からウィンドウの端を見込む角の 2 倍。ウィンドウの大きさと投影面までの
// 距離の比だけで決まるので、比が同じなら見える空間も同じ
const spread = halfWindow / PLANE_DISTANCE // 奥行き 1 あたりの広がり
const halfAngle = Math.atan(spread)
const angleOfView = (2 * halfAngle * 180) / Math.PI
// 1 辺 1 の正方形。投影面とウィンドウで共有し、大きさは scale で決める
const squareGeometry = new PlaneGeometry(1, 1)
// 投影面。視線に垂直な平面で、本来はどこまでも広がっている
const projectionPlane = new Mesh(
squareGeometry,
new MeshBasicMaterial({
color: PLANE_COLOR,
side: DoubleSide,
transparent: true,
opacity: 0.1
})
)
projectionPlane.scale.set(PLANE_SIZE, PLANE_SIZE, 1)
projectionPlane.position.z = PLANE_DISTANCE
scene.add(projectionPlane)
// ウィンドウ。投影面の上に置いた長方形の枠で、この内側に写ったものだけを画像にする
const windowFrame = new LineSegments(
new EdgesGeometry(squareGeometry),
new LineBasicMaterial({ color: WINDOW_COLOR })
)
windowFrame.position.z = PLANE_DISTANCE
windowFrame.scale.set(WINDOW_SIZE, WINDOW_SIZE, 1)
scene.add(windowFrame)
// 視点(投影中心)。大きさをもたない 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 depth = Math.min(MAX_DEPTH, MAX_HALF_SIZE / spread)
const half = spread * depth
// 視線。視点から投影面に垂直な向き(z の正の向き)へ伸びる
const axisGeometry = new BufferGeometry().setAttribute(
"position",
new Float32BufferAttribute([0, 0, 0, 0, 0, depth], 3)
)
scene.add(new LineSegments(axisGeometry, new LineBasicMaterial({ color: AXIS_COLOR })))
// ウィンドウを通して見える空間。視点を頂点とし、ウィンドウの枠を断面とする四角錐で、
// その稜線は視点からウィンドウの 4 隅を通ってまっすぐ奥へ伸びる
const edgePosition = new Float32BufferAttribute(new Float32Array(CORNERS.length * 6), 3)
CORNERS.forEach(([cornerX, cornerY], i) => {
edgePosition.setXYZ(i * 2, 0, 0, 0)
edgePosition.setXYZ(i * 2 + 1, cornerX * half, cornerY * half, depth)
})
const edgeGeometry = new BufferGeometry().setAttribute("position", edgePosition)
scene.add(new LineSegments(edgeGeometry, new LineBasicMaterial({ color: PYRAMID_COLOR })))
// 画角をつくる 2 本の線。視点からウィンドウの左右の辺の中央へ引く
const angleMaterial = new LineBasicMaterial({ color: ANGLE_COLOR })
const angleGeometry = new BufferGeometry().setAttribute(
"position",
new Float32BufferAttribute(
[0, 0, 0, -halfWindow, 0, PLANE_DISTANCE, 0, 0, 0, halfWindow, 0, PLANE_DISTANCE],
3
)
)
scene.add(new LineSegments(angleGeometry, angleMaterial))
// 画角の開き。視点を中心とする扇形として塗る。頂点は 0 番が視点で、1 番以降が円弧を
// 分割した点。2 本の線の間を埋めるので、半径は投影面までの距離より短くとる
const radius = Math.min(ARC_RADIUS, PLANE_DISTANCE * 0.6)
const sectorPosition = new Float32BufferAttribute(new Float32Array((ARC_SEGMENTS + 2) * 3), 3)
sectorPosition.setXYZ(0, 0, 0, 0)
for (let i = 0; i <= ARC_SEGMENTS; i += 1) {
const angle = -halfAngle + (2 * halfAngle * i) / ARC_SEGMENTS
sectorPosition.setXYZ(i + 1, radius * Math.sin(angle), 0, radius * Math.cos(angle))
}
// 四角錐の稜線を隠さないよう、薄く塗って深度は書き込まない
const sectorGeometry = new BufferGeometry()
.setAttribute("position", sectorPosition)
.setIndex(SECTOR_FACES)
scene.add(
new Mesh(
sectorGeometry,
new MeshBasicMaterial({
color: ANGLE_COLOR,
side: DoubleSide,
transparent: true,
opacity: 0.26,
depthWrite: false
})
)
)
// 扇形の外周の円弧。同じ頂点をたどるので、視点(0 番)だけ描画から外す
const arcGeometry = new BufferGeometry().setAttribute("position", sectorPosition)
arcGeometry.setDrawRange(1, ARC_SEGMENTS + 1)
scene.add(new Line(arcGeometry, angleMaterial))平行投影のウィンドウと画角
平行投影 では、投影中心が無限遠にあるため、画角はありません。
写る範囲を決めるのはウィンドウの大きさだけで、投影面をどこに置いても写り方は変わりません。
クリッピング面
ウィンドウは、投影面の写った像のうち、どこを切り取るかを決めるものでした。
次に決めるべきは、投影面に写す対象の絞り込みです。
そもそも投影面にどれを映し、どれを映さないことにするか、その制約はまだ決めていません。近くにあっても重要でないものや、ほとんど見えない遠くのものまで、なんでもかんでも投影して描画していたら、CGではパフォーマンスが落ちてしまいます。
そこで、ウィンドウの前後に面を用意し、その面に挟まれた範囲に収まる物体だけを描画することにします。視点から見て、ウィンドウより手前にある面を前方クリッピング面、ウィンドウより奥にある面を後方クリッピング面といいます。
- 前方クリッピング面:視点に近すぎるものを対象から外すために用意
- 後方クリッピング面:遠すぎて像にほとんど現れないものを対象から外すために用意
透視投影では、視点より後ろにある点でさえ、視点と結んだ直線が投影面と交わるために像を持ってしまいます。手前に1枚の面を立てておけば、こうした点が計算対象に入ることはなくなります。
ビューボリューム
ウィンドウを通る投射線が作る立体を、この2枚のクリッピング面で切り取ったものがビューボリュームです。この内側にある部分だけが、画像として写ることになります。
ビューボリュームの形は、投影の種類によって変わります。
透視投影のビューボリューム
投射線が投影中心から放射状に広がるため、奥へ行くほど断面の大きくなる四角錐台になる
この立体を視錐台という
平行投影のビューボリューム
投射線が平行なので、どこで切っても断面の変わらない直方体になる
次の点に着目して、デモを観察してみよう
- 投影面までの距離を変えることで、ビューボリュームの大きさがどう変わるか
- クリッピング面を動かすことで、ビューボリュームに収まる範囲がどう変わるか
- 平行投影に切り替えた場合はどうなるか
Three.jsによる実装概要
// マテリアルごとのクリッピング(ビューボリュームの外を描かない)を有効にする
renderer.localClippingEnabled = true
const isOrthographic = PROJECTION === "orthographic"
const halfWindow = WINDOW_SIZE / 2
// 奥行き depth での断面の半分の大きさ。透視投影では投射線が投影中心から放射状に
// 広がるので奥行きに比例し、平行投影では投射線が平行なのでどこでも変わらない
const halfAt = (depth: number) =>
isOrthographic ? halfWindow : (halfWindow * depth) / PLANE_DISTANCE
// 1 辺 1 の正方形。投影面・ウィンドウ・2 枚のクリッピング面で共有し、大きさは scale で決める
const squareGeometry = new PlaneGeometry(1, 1)
// 投影面。視線に垂直な平面で、本来はどこまでも広がっている
const projectionPlane = new Mesh(
squareGeometry,
new MeshBasicMaterial({
color: PLANE_COLOR,
side: DoubleSide,
transparent: true,
opacity: 0.1
})
)
projectionPlane.scale.set(PLANE_SIZE, PLANE_SIZE, 1)
projectionPlane.position.z = PLANE_DISTANCE
scene.add(projectionPlane)
// ウィンドウ。投影面の上に置いた長方形の枠で、この内側に写ったものだけを画像にする
const windowFrame = new LineSegments(
new EdgesGeometry(squareGeometry),
new LineBasicMaterial({ color: WINDOW_COLOR })
)
windowFrame.position.z = PLANE_DISTANCE
windowFrame.scale.set(WINDOW_SIZE, WINDOW_SIZE, 1)
scene.add(windowFrame)
// 視線。投影座標系の z 軸で、投影中心から奥へ伸びる
const axisGeometry = new BufferGeometry().setAttribute(
"position",
new Float32BufferAttribute([0, 0, -RAY_TAIL, 0, 0, MAX_DEPTH], 3)
)
scene.add(new LineSegments(axisGeometry, new LineBasicMaterial({ color: AXIS_COLOR })))
// 投影中心。大きさをもたない 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.visible = !isOrthographic
scene.add(center)
// ビューボリュームの 8 隅は、2 枚のクリッピング面それぞれの奥行きでの断面の 4 隅
const nearHalf = halfAt(NEAR)
const farHalf = halfAt(FAR)
const corners = [
...CORNERS.map(([x, y]) => new Vector3(x * nearHalf, y * nearHalf, NEAR)),
...CORNERS.map(([x, y]) => new Vector3(x * farHalf, y * farHalf, FAR))
]
const volumeGeometry = new BufferGeometry()
.setAttribute(
"position",
new Float32BufferAttribute(
corners.flatMap(({ x, y, z }) => [x, y, z]),
3
)
)
.setIndex(VOLUME_EDGES)
scene.add(new LineSegments(volumeGeometry, new LineBasicMaterial({ color: VOLUME_COLOR })))
// ウィンドウの 4 隅を通る投射線のうち、2 枚のクリッピング面の外側に出る部分。
// 内側に残る部分はビューボリュームの稜線そのものなので、ここでは描かない
const tailHalf = halfAt(MAX_DEPTH)
const rayPosition = new Float32BufferAttribute(new Float32Array(CORNERS.length * 12), 3)
CORNERS.forEach(([cornerX, cornerY], i) => {
// 手前側。透視投影では投影中心から、平行投影では視線に平行に伸びてくる
if (isOrthographic) {
rayPosition.setXYZ(i * 4, cornerX * halfWindow, cornerY * halfWindow, -RAY_TAIL)
} else {
rayPosition.setXYZ(i * 4, 0, 0, 0)
}
rayPosition.setXYZ(i * 4 + 1, cornerX * nearHalf, cornerY * nearHalf, NEAR)
// 奥側。切り取られなければどこまでも伸びていく
rayPosition.setXYZ(i * 4 + 2, cornerX * farHalf, cornerY * farHalf, FAR)
rayPosition.setXYZ(i * 4 + 3, cornerX * tailHalf, cornerY * tailHalf, MAX_DEPTH)
})
const rayGeometry = new BufferGeometry().setAttribute("position", rayPosition)
scene.add(new LineSegments(rayGeometry, new LineBasicMaterial({ color: RAY_COLOR })))
// 前方・後方クリッピング面。ビューボリュームを奥行き方向で区切る 2 枚
const capMaterial = new MeshBasicMaterial({
color: VOLUME_COLOR,
side: DoubleSide,
transparent: true,
opacity: 0.1
})
const nearCap = new Mesh(squareGeometry, capMaterial)
nearCap.position.z = NEAR
nearCap.scale.set(nearHalf * 2, nearHalf * 2, 1)
const farCap = new Mesh(squareGeometry, capMaterial)
farCap.position.z = FAR
farCap.scale.set(farHalf * 2, farHalf * 2, 1)
scene.add(nearCap, farCap)
// 形状を切り取る 6 面を 8 隅から作る。3 隅から作った面は表裏が定まらないので、
// ビューボリュームの重心が正の側(残す側)に来るように向きをそろえる
const centroid = new Vector3()
corners.forEach((corner) => centroid.add(corner))
centroid.multiplyScalar(1 / corners.length)
const clippingPlanes = VOLUME_FACES.map(([a, b, c]) => {
const plane = new Plane().setFromCoplanarPoints(corners[a], corners[b], corners[c])
if (plane.distanceToPoint(centroid) < 0) plane.negate()
return plane
})
// 空間に置く立方体。同じ位置に 2 つ重ね、クリッピングで残る部分と取り除かれる部分を描き分ける
const cubeGeometry = new EdgesGeometry(new BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE))
const removedMaterial = new LineBasicMaterial({ color: REMOVED_COLOR })
const keptMaterial = new LineBasicMaterial({ color: KEPT_COLOR, clippingPlanes })
CUBE_POSITIONS.forEach((position) => {
const removed = new LineSegments(cubeGeometry, removedMaterial)
removed.position.set(...position)
scene.add(removed)
const kept = new LineSegments(cubeGeometry, keptMaterial)
kept.position.set(...position)
// 取り除かれる部分と線が重なるので、そのあとに描いて上書きする
kept.renderOrder = 1
scene.add(kept)
})クリッピング
ビューボリュームの外にある部分を取り除く処理は、クリッピングと呼ばれます。
クリッピングの計算方法は、クリッピングのアルゴリズム ページで詳しく学びます。
正規化ビューボリューム
ビューボリュームの形と大きさは、ウィンドウの取り方とクリッピング面の位置によって毎回変わってしまうため、その度にクリッピングの再計算が必要です。
特に、透視投影の場合、ある点が視錐台の内側にあるかどうかを調べるには、複数の斜めの面との位置関係を繰り返し計算することになります。これはあまり軽い処理とはいえません。
そこで、クリッピングに入る前に、ビューボリュームをあらかじめ決めた大きさの直方体へ変換します。
この変換後のビューボリュームを正規化ビューボリュームといいます。
正規化ビューボリュームの大きさの設定は環境によって異なりますが、たとえば、
となるようにすると、
x < -1なら左にはみ出しているx > 1なら右にはみ出している
といったように、座標値の単純な大小比較だけで、点が内側にあるかどうかを判定できるようになります。そして、元のビューボリュームがどんな形でも、この判定処理は変わりません。
透視投影の正規化
透視投影のビューボリュームは四角錐台で、断面は奥へ行くほど大きくなります。
透視投影の場合、ビューボリュームの正規化は、どの奥行きでも断面が同じ大きさになるような直方体へ変換する処理です。
奥に行くほど断面が大きくなり、その断面上の点を直方体に収まるようにするには、奥にある点ほど、x座標とy座標を大きく縮ませる必要があります。
正規化ビューボリュームの透視投影
正規化ビューボリュームは立体で、その内側の点は、正規化ビューボリュームの中で奥行きを持って並んでいます。
しかし、画像は平面です。画像を得るには、この奥行きを落として、点がx座標とy座標だけで表される平面に写す必要があります。
つまり、最後には正規化ビューボリュームを透視投影する処理が必要になるのです。どの点も、正規化ビューボリュームの手前の面へまっすぐ潰すだけで像になります。
正規化に変換し終わった後に、奥行きを落とすと、正規化ビューボリュームの手前の平面に物体が写ることを確認しよう
Three.jsによる実装概要
const halfWindow = WINDOW_SIZE / 2
// 奥行き depth での四角錐台の断面の半分の大きさ。投射線が投影中心から放射状に広がるので、
// 奥行きに比例して大きくなる
const halfAt = (depth: number) => (halfWindow * depth) / PLANE_DISTANCE
// 四角錐台を -1 ≤ x ≤ 1, -1 ≤ y ≤ 1, 0 ≤ z ≤ 1 の直方体へ移す変換。
// x と y は、その奥行きでの断面の大きさ halfWindow × z / PLANE_DISTANCE で割れば ±1 に収まる。
// 奥行きで割る操作は最下行に z を置き、同次座標の正規化に任せる(この割り算が像の縮み)。
// z は前方クリッピング面が 0、後方クリッピング面が 1 になるように写す
const normalization = new Matrix4().set(
PLANE_DISTANCE / halfWindow, 0, 0, 0,
0, PLANE_DISTANCE / halfWindow, 0, 0,
0, 0, FAR / (FAR - NEAR), (-FAR * NEAR) / (FAR - NEAR),
0, 0, 1, 0
)
// 変換前のビューボリューム(四角錐台)の 8 隅。
// 前方・後方クリッピング面それぞれの奥行きでの断面の 4 隅
const volumeBefore = BOX_CORNERS.map(([signX, signY, signZ]) => {
const depth = signZ < 0 ? NEAR : FAR
const half = halfAt(depth)
return new Vector3(signX * half, signY * half, depth)
})
// 変換後の 8 隅。四角錐台の 8 隅が、そのまま直方体の 8 隅へ移る
const volumeAfter = volumeBefore.map((corner) => corner.clone().applyMatrix4(normalization))
// 空間に置いた同じ大きさの立方体。8 隅それぞれの変換前後の位置を求める。
// 奥行きで割る変換なので、奥にある隅ほど強く中心へ寄せられる
const cubesBefore = CUBE_POSITIONS.flatMap(([centerX, centerY, centerZ]) =>
BOX_CORNERS.map(
([signX, signY, signZ]) =>
new Vector3(
centerX + (signX * CUBE_SIZE) / 2,
centerY + (signY * CUBE_SIZE) / 2,
centerZ + (signZ * CUBE_SIZE) / 2
)
)
)
const cubesAfter = cubesBefore.map((corner) => corner.clone().applyMatrix4(normalization))
// 頂点と稜線から線分のジオメトリを作る
const wireframe = (corners: Vector3[], edges: number[]) =>
new BufferGeometry()
.setAttribute(
"position",
new Float32BufferAttribute(
corners.flatMap(({ x, y, z }) => [x, y, z]),
3
)
)
.setIndex(edges)
// 立方体の稜線。1 つのジオメトリにまとめて描くので、隅の番号を立方体ごとに 8 ずつずらす
const cubeEdges = CUBE_POSITIONS.flatMap((_, i) => BOX_EDGES.map((corner) => corner + i * 8))
// 変換前の位置に残す線。変換後と見比べられるように動かさない
const beforeMaterial = new LineBasicMaterial({ color: BEFORE_COLOR })
scene.add(new LineSegments(wireframe(volumeBefore, BOX_EDGES), beforeMaterial))
scene.add(new LineSegments(wireframe(cubesBefore, cubeEdges), beforeMaterial))
// 変換前の位置と変換後の位置を、変換の進み具合で混ぜる
const morph = (before: Vector3[], after: Vector3[], progress: number) =>
before.map((corner, i) => corner.clone().lerp(after[i], progress))
// ビューボリュームの前面は、変換が進むにつれて手前へ寄り、±1 の大きさへ広がる
const frontZ = MathUtils.lerp(NEAR, 0, PROGRESS)
const frontHalf = MathUtils.lerp(halfAt(NEAR), 1, PROGRESS)
// 変換の途中のビューボリューム。
// 変換前(進み具合 0)では変換前の線と重なるので、そのあとに描いて上書きする
const volume = new LineSegments(
wireframe(morph(volumeBefore, volumeAfter, PROGRESS), BOX_EDGES),
new LineBasicMaterial({ color: VOLUME_COLOR })
)
volume.renderOrder = 1
scene.add(volume)
// 変換の途中の立方体。奥行きを落とすと、x と y をそのままに前面へ潰れて、投影面上の像になる
const cubeCorners = morph(cubesBefore, cubesAfter, PROGRESS).map(
(corner) => new Vector3(corner.x, corner.y, MathUtils.lerp(corner.z, frontZ, FLATTEN))
)
const cubes = new LineSegments(
wireframe(cubeCorners, cubeEdges),
new LineBasicMaterial({ color: CUBE_COLOR })
)
cubes.renderOrder = 1
scene.add(cubes)
// ビューボリュームの前面。奥行きを落とすと、この面の上に像ができる
const frontFace = new Mesh(
new PlaneGeometry(1, 1),
new MeshBasicMaterial({
color: VOLUME_COLOR,
side: DoubleSide,
transparent: true,
opacity: 0.1
})
)
frontFace.position.z = frontZ
frontFace.scale.set(frontHalf * 2, frontHalf * 2, 1)
scene.add(frontFace)
// 視線(投影座標系の z 軸)。立方体が寄っていく先を示す
const axisGeometry = new BufferGeometry().setAttribute(
"position",
new Float32BufferAttribute([0, 0, 0, 0, 0, FAR], 3)
)
scene.add(new LineSegments(axisGeometry, new LineBasicMaterial({ color: AXIS_COLOR })))奥行き(z座標)を落とす処理を最後に行うのは、z座標の大小比較で前後の範囲を判定するためです。先に奥行きを落としてしまうと、2枚のクリッピング面で挟まれた範囲の中に各点が収まっているかを判断できません。
透視投影と平行投影によって処理が分かれるのは、ビューボリュームの正規化までです。
透視投影では奥にあるものを縮ませる処理が必要ですが、正規化を終えた後のクリッピングと、奥行きを落として像を得る処理は、どちらの投影でも同じになります。