这篇笔记主要写一下摄像机:视图矩阵和投影矩阵到底在做什么,以及怎么把鼠标和键盘接上去。
摄像机不存在
渲染管线里没有"摄像机"这个东西。所谓移动摄像机,实际上是把整个世界往反方向移动,让观察者永远待在原点朝向 -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。
下一篇进入光照。