T.TAO
ブログへ戻る
/8 min read/Graphics Engine

Metal #6 カメラと操作

#ComputerGraphics#GraphicsEngine#Metal

このノートではカメラを扱います。ビュー行列と投影行列が実際に何をしているのか、そしてマウスとキーボードをどう接続するか。

カメラは存在しない

レンダリングパイプラインにカメラというものはありません。カメラを動かすとは、実際には世界全体を逆方向に動かすことであり、観察者は常に原点にいて -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 で割ること。

次回はライティングに入ります。

このシリーズの記事

Metal
  1. 01Metal #0 Swift 復習
  2. 02Metal #1 初期化
  3. 03Metal #2 レンダリングパイプライン
  4. 04Metal #3 頂点関数
  5. 05Metal #4 フラグメント関数
  6. 06Metal #5 テクスチャ
  7. 07Metal #6 カメラと操作
  8. 08Metal #7 ライティング
  9. 09Metal #8 マテリアル
  10. 10Metal #9 レンダーパス
  11. 11Metal #10 シャドウ
  12. 12Metal #11 遅延レンダリング
  13. 13Metal #12 パーティクルシステム
  14. 14Metal #13 テッセレーション
  15. 15Metal #14 ポストプロセス
  16. 16Metal #15 反射と屈折
  17. 17Metal #16 アニメーション
  18. 18Metal #17 レイトレーシング(一)レンダリングアルゴリズム
  19. 19Metal #18 レイトレーシング(二)影とライティング
  20. 20Metal #19 レイトレーシング(三)性能最適化
  21. 21Metal #21 [付録] Compute Shader
  22. 22Metal #22 [付録] SwiftUI における Metal