このノートではカメラを扱います。ビュー行列と投影行列が実際に何をしているのか、そしてマウスとキーボードをどう接続するか。
カメラは存在しない
レンダリングパイプラインにカメラというものはありません。カメラを動かすとは、実際には世界全体を逆方向に動かすことであり、観察者は常に原点にいて -Z 方向を向いています。
それがビュー行列です。カメラの変換行列の逆行列にほかなりません。
Swiftstruct Camera {
var position = SIMD3<Float>(0, 0, 5)
var rotation = SIMD3<Float>(0, 0, 0) // ラジアン: pitch / yaw / roll
var viewMatrix: float4x4 {
let t = float4x4(translation: position)
let r = float4x4(rotation: rotation)
return (t * r).inverse
}
}
ただし実務では本当に逆行列を求めてはいけません。剛体変換の逆行列には閉じた形があります。回転部分を転置し、平行移動を反転してその転置回転で変換するだけです。
Swiftvar viewMatrix: float4x4 {
let r = float3x3(rotation: rotation).transpose
let t = -(r * position)
return float4x4(rotation: r, translation: t)
}
これは速度だけの話ではありません。一般の逆行列計算は閉じた形より数値的にも劣ります。
投影行列と Metal の深度規約
投影行列は視錐台をクリップ空間へ写します。ここに Metal と OpenGL の決定的な違いがあります。
- OpenGL の NDC 深度範囲は [-1, 1]。
- Metal(および D3D、Vulkan)は [0, 1]。
OpenGL のチュートリアルから投影行列をそのまま写すと、Metal では「手前のものがすべてクリップされる」か、深度テストがまったく効かないという結果になります。simd に投影行列は組み込まれていないので、自分で正しく書く必要があります。
Swiftinit(perspectiveFov fov: Float, aspect: Float, near: Float, far: Float) {
let y = 1 / tan(fov * 0.5)
let x = y / aspect
let z = far / (far - near) // Metal: [0, 1]。(f+n)/(f-n) ではない
self.init(
SIMD4<Float>( x, 0, 0, 0),
SIMD4<Float>( 0, y, 0, 0),
SIMD4<Float>( 0, 0, z, 1),
SIMD4<Float>( 0, 0, -z * near, 0)
)
}
遠平面よりも近平面のほうがはるかに重要です。 深度バッファの精度は 1/z について一様に分布するため、その大半が近平面の近くで消費されます。near を 0.1 から 1.0 に動かすほうが、far を 10000 から 1000 に下げるより遠方の深度精度をずっと改善します。z ファイティングへの最初の対応は、常に近平面を遠ざけることであるべきです。
さらに進んだ手法が reversed Z です。近平面を 1、遠平面を 0 に写し、浮動小数深度バッファ(.depth32Float)と .greater 比較関数を併用します。浮動小数は 0 付近でもっとも精度が高いので、反転させるとその精度が遠方に割り当てられ、深度精度は桁違いに向上します。変更点は三つだけ。投影行列、クリア値(0 にする)、比較関数です。
平行投影は UI とシャドウマップに使います。
Swiftinit(orthographic rect: Rect, near: Float, far: Float) { ... }
透視除算がないため、平行線は平行のままで、物体の大きさは距離によって変わりません。
オービットカメラ
注視点のまわりを回るカメラは、モデルを確認するのにもっとも便利な形式です。
Swiftstruct ArcballCamera {
var target = SIMD3<Float>.zero
var distance: Float = 5
var pitch: Float = 0 // ラジアン
var yaw: Float = 0
var position: SIMD3<Float> {
let x = distance * cos(pitch) * sin(yaw)
let y = distance * sin(pitch)
let z = distance * cos(pitch) * cos(yaw)
return target + SIMD3(x, y, z)
}
var viewMatrix: float4x4 { .lookAt(eye: position, center: target, up: [0, 1, 0]) }
mutating func rotate(delta: CGPoint) {
yaw += Float(delta.x) * 0.01
pitch += Float(delta.y) * 0.01
pitch = min(max(pitch, -.pi / 2 + 0.001), .pi / 2 - 0.001) // 反転を防ぐ
}
mutating func zoom(delta: Float) {
distance = max(0.5, distance - delta * 0.1)
}
}
pitch のクランプは必須です。ちょうど ±90° では視線方向が up ベクトルと平行になり、lookAt 内の外積がゼロベクトルに退化して、カメラが突然ひっくり返ります。わずかな余裕を残すだけで完全に回避できます。
入力の接続
macOS では MTKView のサブクラスでイベントメソッドを直接オーバーライドできます。
Swiftclass InteractiveMTKView: MTKView {
var camera: ArcballCamera?
override var acceptsFirstResponder: Bool { true }
override func mouseDragged(with event: NSEvent) {
camera?.rotate(delta: CGPoint(x: event.deltaX, y: event.deltaY))
}
override func scrollWheel(with event: NSEvent) {
camera?.zoom(delta: Float(event.scrollingDeltaY))
}
}
一人称カメラのキーボード入力では、keyDown の中でカメラを動かしてはいけません。キーリピートの頻度はシステムが決めるものでフレームレートとは無関係であり、移動速度が予測不能になります。押されているキーの集合を保持し、毎フレームの draw(in:) で deltaTime に対して積分してください。
Swiftprivate var keysDown = Set<UInt16>()
override func keyDown(with e: NSEvent) { keysDown.insert(e.keyCode) }
override func keyUp(with e: NSEvent) { keysDown.remove(e.keyCode) }
// 毎フレーム
func update(deltaTime: Float) {
var move = SIMD3<Float>.zero
if keysDown.contains(13) { move.z -= 1 } // W
if keysDown.contains(1) { move.z += 1 } // S
if move != .zero {
camera.position += normalize(move) * speed * deltaTime
}
}
normalize(move) も省かないでください。これがないと W と D を同時に押したとき √2 倍の速さで斜めに進みます。ほぼすべての一人称デモが最初に踏むバグです。
スクリーンからワールドへ:ピッキング
クリックをワールド空間のレイに変えることが、オブジェクト選択の基礎です。NDC 座標を投影の逆行列で戻します。
Swiftfunc ray(fromScreen point: CGPoint, viewSize: CGSize) -> (origin: SIMD3<Float>, direction: SIMD3<Float>) {
let ndc = SIMD4<Float>(
Float(point.x / viewSize.width) * 2 - 1,
1 - Float(point.y / viewSize.height) * 2, // スクリーンの y は下向き、NDC の y は上向き
0, // Metal の近平面は 0
1
)
let inv = (projectionMatrix * viewMatrix).inverse
var world = inv * ndc
world /= world.w // 透視除算を忘れない
let origin = camera.position
return (origin, normalize(world.xyz - origin))
}
間違えやすい二点はどちらもコメントにあります。スクリーン座標の y 軸は NDC と逆向きであること、そして変換して戻したあとは必ず w で割ること。
次回はライティングに入ります。