Navigating a 3D Scene
카테고리: Metal
Main Reference
- Metal by Tutorials - 4th edition
Scenes
var scene = GameScene()
// ...
scene.update(deltaTime: timer)
for model in scene.models {
model.render( encoder: renderEncoder, uniforms: uniforms, params: params)
}
Scene은 기본적인 models, cameras and lighting 뿐만 아니라 매 프레임 게임 로직을 업데이트 해야하고 유저의 input도 고려해야 한다. 따라서 이를 구분하여 update와 render로 나누는 것이 좋다.
참고로 Scene 구조체를 만들어 사용할 경우 SwiftUI의 Scene과 충돌하는 경우가 있으니 GameScene으로 만드는걸 추천
Input
Game controllers, keyboards, mice and trackpads 등 여러 형태의 input이 존재한다. 이때 Apple의 GCController API를 이용하면 이러한 다양한 형태의 input을 처리할 수 있다.
본래 GCController는 물리적인 게임 컨트롤러 입력을 처리하기 위한 API이기 때문에 IOS의 터치 입력을 직접 받지는 않는다 . 하지만 GCVirtualController를 통해 화면에 가상 조이스틱(버튼)을 그리고, 사용자의 터치 입력을 GCController 신호로 변환해 사용하는 방식을 지원다.
GCController가 input을 처리하는 방식은 다음 두 가지이다.
- Events or Interrupts
- Takes action when the user presses the key
- You can set delegate methods or closures of code to run when an event occurs
- 점프, 공격 등 단발성으로 발생하는 입력에 적합
- Polling
- Processes all pressed keys on every frame
- 이동, 카메라 조작 등 지속적이고 부드러운 입력에 적합
import GameController
class InputController {
static let shared = InputController()
var keysPressed: Set<GCKeyCode> = []
private init() {
let center = NotificationCenter.default
center.addObserver(
forName: .GCKeyboardDidConnect,
object: nil,
queue: nil) { notification in
let keyboard = notification.object as? GCKeyboard
keyboard?.keyboardInput?.keyChangedHandler
= { _, _, keyCode, pressed in
if pressed {
self.keysPressed.insert(keyCode)
} else {
self.keysPressed.remove(keyCode)
}
}
}
}
}
- Controller는 singleton 패턴 적용
- Currently pressed keys를 추적하기 위해
Set<GCKeyCode>프로퍼티 선언 - 입력 키를 추적하기 위해 옵저버를 세팅해야 함
- 키보드, 마우스 등 원하는 디바이스를 감지하는 옵저버들을 center에 붙여줄 수 있음
NotificationCenter
개발자가 직접 매 프레임마다 “혹시 컨트롤러가 새로 연결됐나? 아니면 연결이 끊겼나?”라고 물어보는 폴링(polling) 방식 대신, NotificationCenter를 사용하면 상태가 변했을 때만 알아서 알림(callback)을 받을 수 있어 매우 효율적.
.GCControllerDidConnect- 언제: 새로운 게임 컨트롤러가 기기(iPhone, Mac 등)에 성공적으로 연결될 때 방송됩니다.
- 목적: 앱에게 새로운 플레이어가 참가할 준비가 되었음을 알립니다.
.GCControllerDidDisconnect- 언제: 연결되어 있던 컨트롤러의 연결이 끊기거나, 배터리가 다 되거나, 사용자가 직접 연결을 해제했을 때 방송됩니다.
- 목적: 앱에게 플레이어의 연결이 끊겼음을 알려 게임을 일시 정지시키거나 “컨트롤러를 다시 연결해주세요”와 같은 메시지를 표시하게 합니다.
- When the player presses or lifts a key, the
keyChangedHandlercode runs
[!question]
- center가 지역 변수라서 생성자가 끝날 때 리소스가 해제되는 줄 알았음
- NotificationCenter.default : singleton 객체!
- 즉, 참조만 사라지고 noti는 계속 받는 형태
#if os(macOS)
NSEvent.addLocalMonitorForEvents(
matching: [.keyUp, .keyDown]) { _ in nil }
#endif
MacOS App의 경우 키를 누르면 해당 이벤트가 Responder Chain 경로를 따라 상위 뷰, 윈도우, 앱으로 이벤트가 전달된다. 만약 끝까지 갔는데도 키 이벤트를 처리하는 객체가 없으면 macOS는 “처리되지 않은 입력”으로 간주하고 기본 동작으로 시스템 경고음(삐-)을 재생한다.
GCController를 통해 직접 입력을 처리하는 Metal App에서는 입력 신호를 먼저 가로채 Responder Chain으로 전달하지 않는다.
Camera Operation
frame rate는 고정되어 있다고 장담할 수 없기 때문에 delta time과 speed & sensitivity로 업데이트
var lastTime: Double = CFAbsoluteTimeGetCurrent()
let currentTime = CFAbsoluteTimeGetCurrent()
let deltaTime = Float(currentTime - lastTime)
lastTime = currentTime scene.update(deltaTime: deltaTime)
enum Settings {
static var rotationSpeed: Float { 2.0 }
static var translationSpeed: Float { 3.0 }
static var mouseScrollSensitivity: Float { 0.1 }
static var mousePanSensitivity: Float { 0.008 }
}
Movement
// Camera..
mutating func update(deltaTime: Float) {
let transform = updateInput(deltaTime: deltaTime)
position += transform.position
}
extension Movement {
func updateInput(deltaTime: Float) -> Transform {
var transform = Transform(
let input = InputController.shared
var direction: float3 = .zero
if input.keysPressed.contains(.keyW) {
direction.z += 1
}
if input.keysPressed.contains(.keyS) {
direction.z -= 1
}
if input.keysPressed.contains(.keyA) {
direction.x -= 1
}
if input.keysPressed.contains(.keyD) {
direction.x += 1
}
let translationAmount = deltaTime * Settings.translationSpeed
if direction != .zero {
direction = normalize(direction)
transform.position += (direction.z * forwardVector
+ direction.x * rightVector) * translationAmount
}
return transform
}
}
Mouse and Trackpad
class InputController {
struct Point {
var x: Float
var y: Float
static let zero = Point(x: 0, y: 0)
}
var leftMouseDown = false
var mouseDelta = Point.zero
var mouseScroll = Point.zero
static let shared = InputController()
var keysPressed: Set<GCKeyCode> = []
private init() {
// add keyboard observer...
center.addObserver(
forName: .GCMouseDidConnect,
object: nil,
queue: nil) { notification in
let mouse = notification.object as? GCMouse
mouse?.mouseInput?.leftButton.pressedChangedHandler = { _, _, pressed in
self.leftMouseDown = pressed
}
mouse?.mouseInput?.mouseMovedHandler = { _, deltaX, deltaY in
self.mouseDelta = Point(x: deltaX, y: deltaY)
}
mouse?.mouseInput?.scroll.valueChangedHandler = { _, xValue, yValue in
self.mouseScroll.x = xValue
self.mouseScroll.y = yValue
}
}
}
}
- Point 구조체는 CGPoint 등과 이름 충돌을 피하기 위해 Controller 내부에 선언하는게 좋음
- Mouse(Trackpad) input을 담당하는 Observer 추가
- Trackpad의 경우 scroll이 x축, y축 모두 동작
struct MetalView: UIViewRepresentable {
// ...
}
struct ContentView: View {
var body: some View {
MetalView()
.gesture(
DragGesture()
.onChanged { value in
// 드래그하는 동안 손가락의 이동량을 InputController에 전달
InputController.shared.mouseDelta.x = Float(value.translation.width)
InputController.shared.mouseDelta.y = Float(value.translation.height)
}
.onEnded { _ in
// 드래그가 끝나면 이동량을 0으로 초기화
InputController.shared.mouseDelta = .zero
}
)
}
}
- ios의 경우 mouse tracking보다는 View 에서 touch value(gesture) 받아 사용
Arcball Rotation

struct ArcballCamera: Camera {
var transform = Transform()
let minDistance: Float = 0.0
let maxDistance: Float = 20
var target: float3 = [0, 0, 0]
var distance: Float = 2.5
mutating func update(deltaTime: Float) {
let input = InputController.shared
let scrollSensitivity = Settings.mouseScrollSensitivity
distance -= (input.mouseScroll.y) * scrollSensitivity
distance = min(maxDistance, distance)
distance = max(minDistance, distance)
input.mouseScroll = .zero
if input.leftMouseDown {
let sensitivity = Settings.mousePanSensitivity
rotation.x += input.mouseDelta.y * sensitivity
rotation.y += input.mouseDelta.x * sensitivity
rotation.x = max(-.pi / 2, min(rotation.x, .pi / 2))
input.mouseDelta = .zero
}
let rotateMatrix = float4x4(
rotationYXZ: [-rotation.x, rotation.y, 0])
let distanceVector = float4(0, 0, -distance, 0)
let rotatedVector = rotateMatrix * distanceVector
position = target + rotatedVector.xyz
}
}
rotationYXZ: Z, X, Y 순서로 회전- 보통 Z, X 축 기준 회전은 90도를 넘지 않으므로 대부분의 짐벌 락 현상을 피할 수 있음
Orthographic Projection
struct OrthographicCamera: Camera, Movement {
// ...
var projectionMatrix: float4x4 {
let rect = CGRect(
x: -viewSize * aspect * 0.5,
y: viewSize * 0.5,
width: viewSize * aspect,
height: viewSize)
return float4x4(orthographic: rect, near: near, far: far)
mutating func update(deltaTime: Float) {
let transform = updateInput(deltaTime: deltaTime)
position += transform.position
let input = InputController.shared
let zoom = input.mouseScroll.x + input.mouseScroll.y
viewSize -= CGFloat(zoom)
input.mouseScroll = .zero
}
}
// float4x4 extension
// XYZ : [-1, 1] normalize
// W : Centering in Clip space
init(orthographic rect: CGRect, near: Float, far: Float) {
let left = Float(rect.origin.x)
let right = Float(rect.origin.x + rect.width)
let top = Float(rect.origin.y)
let bottom = Float(rect.origin.y - rect.height)
let X = float4(2 / (right - left), 0, 0, 0)
let Y = float4(0, 2 / (top - bottom), 0, 0)
let Z = float4(0, 0, 1 / (far - near), 0)
let W = float4(
(left + right) / (left - right),
(top + bottom) / (bottom - top),
near / (near - far),
1)
self.init()
columns = (X, Y, Z, W)
}
가끔 large Scene에서 전체적으로 무슨 일이 일어나는지 확인하기 어려울 때가 존재. 이런 경우 perspective 왜곡 없이 top-down camera로 보는게 도움이 될 수 있음
- Shadow 구현할 때도 orthographic 필요하다는데 와이..? (Directional Light는 orthographic)
댓글 남기기