Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions SampleApp/App/Constants.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,12 @@ enum Constants {
static let fovy = Angle(degrees: 65)
#endif
static let modelCenterZ: Float = -8
/// Fly speed, as a fraction of the current camera distance per second.
static let movementSpeed: Float = 0.9
/// Multiplier while the speed modifier (shift) is held.
static let movementBoost: Float = 3
/// Keyboard look rate, in mouse-pixel equivalents per second.
static let lookSpeed: Float = 260

// Procedural splat geometry
static let proceduralCubeSize: Float = 1.0
Expand Down
2 changes: 2 additions & 0 deletions SampleApp/MetalSplatter_SampleApp.xcodeproj/project.pbxproj
Original file line numberDiff line numberDiff line change
Expand Up@@ -250,6 +250,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.metalsplatter.sampleapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
Expand DownExpand Up@@ -287,6 +288,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.metalsplatter.sampleapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
Expand Down
3 changes: 2 additions & 1 deletion SampleApp/Model/ProceduralSplatController.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,7 +28,8 @@ final class ProceduralSplatController: @unchecked Sendable {
depthFormat: MTLPixelFormat,
sampleCount: Int,
maxViewCount: Int,
maxSimultaneousRenders: Int) async throws {
maxSimultaneousRenders: Int,
isolation: isolated (any Actor)? = #isolation) async throws {
splatRenderer = try SplatRenderer(device: device,
colorFormat: colorFormat,
depthFormat: depthFormat,
Expand Down
154 changes: 141 additions & 13 deletions SampleApp/Scene/MetalKitSceneRenderer.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,7 @@ import SwiftUI
@MainActor
class MetalKitSceneRenderer: NSObject, MTKViewDelegate {
private static let log =
Logger(subsystem: Bundle.main.bundleIdentifier!,
Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.metalsplatter.sampleapp",
category: "MetalKitSceneRenderer")

let metalKitView: MTKView
Expand All@@ -28,6 +28,23 @@ class MetalKitSceneRenderer: NSObject, MTKViewDelegate {
var lastRotationUpdateTimestamp: Date? = nil
var rotation: Angle = .zero

// Orbit camera. `rotation` above is the idle auto-spin, which folds into
// cameraYaw and stops as soon as the user takes control.
var autoRotate = true
var cameraYaw: Float = 0
var cameraPitch: Float = 0
var cameraDistance: Float = -Constants.modelCenterZ
var cameraTarget: SIMD3<Float> = .zero
/// Whether to apply the built-in 180-degrees-about-Z up-axis calibration.
/// Toggle at runtime rather than guessing the convention of a given file.
var applyUpCalibration = true
/// Camera-relative fly input: x = right, y = up, z = forward. Unit length.
var moveInput: SIMD3<Float> = .zero
/// Speed multiplier while the modifier key is held.
var moveBoost: Float = 1
/// Keyboard look input: x = right, y = down (matching mouse delta signs).
var lookInput: SIMD2<Float> = .zero

var drawableSize: CGSize = .zero

init?(_ metalKitView: MTKView) {
Expand DownExpand Up@@ -85,32 +102,143 @@ class MetalKitSceneRenderer: NSObject, MTKViewDelegate {
private var viewport: ModelRendererViewportDescriptor {
let projectionMatrix = matrix_perspective_right_hand(fovyRadians: Float(Constants.fovy.radians),
aspectRatio: Float(drawableSize.width / drawableSize.height),
nearZ: 0.1,
farZ: 100.0)
nearZ: max(0.01, cameraDistance * 0.002),
farZ: max(100, cameraDistance * 20))

let rotationMatrix = matrix4x4_rotation(radians: Float(rotation.radians),
axis: Constants.rotationAxis)
let translationMatrix = matrix4x4_translation(0.0, 0.0, Constants.modelCenterZ)
// Turn common 3D GS PLY files rightside-up. This isn't generally meaningful, it just
// happens to be a useful default for the most common datasets at the moment.
let commonUpCalibration = matrix4x4_rotation(radians: .pi, axis: SIMD3<Float>(0, 0, 1))
let commonUpCalibration = applyUpCalibration
? matrix4x4_rotation(radians: .pi, axis: SIMD3<Float>(0, 0, 1))
: matrix_identity_float4x4

// view = inverse(target * yaw * pitch * dolly)
let viewMatrix = matrix4x4_translation(0, 0, -cameraDistance)
* matrix4x4_rotation(radians: -cameraPitch, axis: SIMD3<Float>(1, 0, 0))
* matrix4x4_rotation(radians: -(cameraYaw + Float(rotation.radians)), axis: Constants.rotationAxis)
* matrix4x4_translation(-cameraTarget.x, -cameraTarget.y, -cameraTarget.z)
* commonUpCalibration

let viewport = MTLViewport(originX: 0, originY: 0, width: drawableSize.width, height: drawableSize.height, znear: 0, zfar: 1)

return ModelRendererViewportDescriptor(viewport: viewport,
projectionMatrix: projectionMatrix,
viewMatrix: translationMatrix * rotationMatrix * commonUpCalibration,
viewMatrix: viewMatrix,
screenSize: SIMD2(x: Int(drawableSize.width), y: Int(drawableSize.height)))
}

private func updateRotation() {
let now = Date()
defer {
lastRotationUpdateTimestamp = now
let deltaTime = lastRotationUpdateTimestamp.map { now.timeIntervalSince($0) } ?? 0
lastRotationUpdateTimestamp = now

if autoRotate {
rotation += Constants.rotationPerSecond * deltaTime
}
// Clamp dt so a stall (window drag, model load) doesn't teleport the camera.
let dt = Float(min(deltaTime, 0.1))
applyLook(deltaTime: dt)
applyMovement(deltaTime: dt)
}

private func applyLook(deltaTime: Float) {
guard lookInput != .zero, deltaTime > 0 else { return }
let d = Constants.lookSpeed * deltaTime
look(dx: lookInput.x * d, dy: lookInput.y * d)
}

/// Fly the orbit target through the scene. Moving the target carries the camera
/// with it, since the camera is derived as target + rotation * (0, 0, distance).
private func applyMovement(deltaTime: Float) {
guard moveInput != .zero, deltaTime > 0 else { return }
takeManualControl()

let basis = cameraBasis
let right = SIMD3<Float>(basis.columns.0.x, basis.columns.0.y, basis.columns.0.z)
let up = SIMD3<Float>(basis.columns.1.x, basis.columns.1.y, basis.columns.1.z)
let forward = -SIMD3<Float>(basis.columns.2.x, basis.columns.2.y, basis.columns.2.z)

// Scale by distance so flying feels the same zoomed in or out.
let speed = max(0.05, cameraDistance) * Constants.movementSpeed * moveBoost * deltaTime
cameraTarget += (right * moveInput.x + up * moveInput.y + forward * moveInput.z) * speed
}

// MARK: - Camera interaction

/// Freeze the idle spin at its current angle and hand control to the user.
private func takeManualControl() {
guard autoRotate else { return }
autoRotate = false
cameraYaw += Float(rotation.radians)
rotation = .zero
}

func toggleAutoRotate() {
if autoRotate { takeManualControl() } else { autoRotate = true }
}

/// Camera orientation as a rotation matrix: columns are right, up, and backward.
private var cameraBasis: matrix_float4x4 {
matrix4x4_rotation(radians: cameraYaw + Float(rotation.radians), axis: Constants.rotationAxis)
* matrix4x4_rotation(radians: cameraPitch, axis: SIMD3<Float>(1, 0, 0))
}

/// Where the eye actually sits. The orbit target is the pivot, not the camera.
private var cameraPosition: SIMD3<Float> {
let backward = SIMD3<Float>(cameraBasis.columns.2.x, cameraBasis.columns.2.y, cameraBasis.columns.2.z)
return cameraTarget + backward * cameraDistance
}

private func applyRotationDelta(dx: Float, dy: Float) {
let sensitivity: Float = 0.006
cameraYaw += dx * sensitivity
let limit = Float.pi / 2 - 0.01
cameraPitch = min(limit, max(-limit, cameraPitch - dy * sensitivity))
}

/// Turntable orbit: swings the eye around the pivot, so the eye moves.
func orbit(dx: Float, dy: Float) {
takeManualControl()
applyRotationDelta(dx: dx, dy: dy)
}

/// Free-look: re-aims the camera while the eye stays put. Same rotation as
/// `orbit`, but the pivot is moved afterwards to hold the eye in place.
func look(dx: Float, dy: Float) {
takeManualControl()
let eye = cameraPosition
applyRotationDelta(dx: dx, dy: dy)
let backward = SIMD3<Float>(cameraBasis.columns.2.x, cameraBasis.columns.2.y, cameraBasis.columns.2.z)
cameraTarget = eye - backward * cameraDistance
}

/// Slide the orbit target across the camera plane. Scaled by distance so it
/// feels the same whether you're across the room or up against a surface.
func pan(dx: Float, dy: Float) {
takeManualControl()
let basis = cameraBasis
let right = SIMD3<Float>(basis.columns.0.x, basis.columns.0.y, basis.columns.0.z)
let up = SIMD3<Float>(basis.columns.1.x, basis.columns.1.y, basis.columns.1.z)
let scale = cameraDistance * 0.0015
cameraTarget += (-right * dx + up * dy) * scale
}

/// Multiplicative dolly, so each notch moves a constant fraction of the way in.
func zoom(_ factor: Float) {
cameraDistance = min(1000, max(0.01, cameraDistance * factor))
}

func toggleUpCalibration() {
applyUpCalibration.toggle()
Self.log.info("up-axis calibration: \(self.applyUpCalibration ? "on (default)" : "off (flipped)")")
}

guard let lastRotationUpdateTimestamp else { return }
rotation += Constants.rotationPerSecond * now.timeIntervalSince(lastRotationUpdateTimestamp)
func resetCamera() {
autoRotate = true
rotation = .zero
cameraYaw = 0
cameraPitch = 0
cameraDistance = -Constants.modelCenterZ
cameraTarget = .zero
}

func draw(in view: MTKView) {
Expand All@@ -125,7 +253,7 @@ class MetalKitSceneRenderer: NSObject, MTKViewDelegate {
}

let semaphore = inFlightSemaphore
commandBuffer.addCompletedHandler { (_ commandBuffer)-> Swift.Void in
commandBuffer.addCompletedHandler { @Sendable _ in
semaphore.signal()
}

Expand Down
Loading