T.TAO
返回博客
/7 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-fighting 的第一反应应该永远是推远近平面

进一步的做法是反转 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 倍速度斜着走——这是几乎所有第一人称 demo 的第一个 bug。

从屏幕到世界:拾取

把一次点击变成世界空间的射线,是选中物体的基础。做法是把 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 [附录] 计算着色器
  22. 22Metal #22 [附录] SwiftUI 中的 Metal