Day 5 的魔杖只是"出現"而已
今天要來讓魔杖"移動"到眼前
透過相機的世界矩陣,取相機的 forward(-Z)方向,得到前方固定距離的落點 Transform。
protocol WandTargetProvider {
func targetTransform(cameraMatrix: simd_float4x4) -> Transform
}
struct CenterAheadTargetProvider: WandTargetProvider {
var distance: Float = 0.5 // 魔杖最後停在相機前方的距離(公尺)
func targetTransform(cameraMatrix: simd_float4x4) -> Transform {
// 相機的 +Z 指向相機後方,取負號就是鏡頭看出去的方向(前方)
let forward = -normalize(SIMD3<Float>(cameraMatrix.columns.2.x,
cameraMatrix.columns.2.y,
cameraMatrix.columns.2.z))
let camTranslation = SIMD3<Float>(cameraMatrix.columns.3.x,
cameraMatrix.columns.3.y,
cameraMatrix.columns.3.z)
var t = Transform(matrix: cameraMatrix)
t.translation = camTranslation + forward * distance
t.scale = [1, 1, 1]
return t
}
}
simd_float4x4 是 Apple SIMD 函式庫的「4x4 浮點矩陣」型別,用來表示 3D 變換(位置、旋轉、縮放,甚至投影)。在 ARKit/RealityKit 裡,相機與實體的姿態(pose)都是用這個矩陣表示。
本段重點:
Transform
是世界座標,用 relativeTo: nil
來移動。把魔杖掛在世界錨點上(而不是相機錨點),移動時用世界座標,製造從遠方飛近的感覺。
RealityView { content in
content.camera = .spatialTracking
if scene.cameraAnchor == nil {
let anchor = AnchorEntity(.camera)
content.add(anchor)
scene.cameraAnchor = anchor
}
if scene.worldAnchor == nil {
let world = AnchorEntity(world: .zero)
content.add(world)
scene.worldAnchor = world
}
}
避免一開始看到白色方塊:占位材質先設透明;等貼圖載入完成才啟動飛入動畫。
if scene.wandEntity == nil {
let wand = makeWandPlaceholder() // 透明占位
worldAnchor.addChild(wand)
scene.wandEntity = wand
scene.didSummonAnimated = true // 避免重複觸發
Task {
await applyWandTexture(to: wand) // 套上圖片材質
animateWandFlyIn(wand: wand, cameraAnchor: cameraAnchor)
}
}
占位與貼圖載入:
func makeWandPlaceholder() -> ModelEntity {
let mesh = MeshResource.generatePlane(width: 0.04, height: 0.25)
var mat = UnlitMaterial()
mat.blending = .transparent(opacity: 0.0) // 先全透明,避免白方塊
return ModelEntity(mesh: mesh, materials: [mat])
}
@MainActor
func applyWandTexture(to entity: ModelEntity) async {
let texture = try? await TextureResource(named: "Wand")
var material = UnlitMaterial()
material.color = .init(tint: .white, texture: texture.map { .init($0) })
material.blending = .transparent(opacity: 1.0)
entity.model?.materials = [material]
}
計算起點 start
(相機座標的上方 +Y 與前方 -Z),設定初始姿態後做 ease-out 飛入,最後加上輕微 overshoot。
func animateWandFlyIn(wand: ModelEntity, cameraAnchor: AnchorEntity) {
let camMatrix = cameraAnchor.transformMatrix(relativeTo: nil)
let provider = CenterAheadTargetProvider(distance: 0.5)
let target = provider.targetTransform(cameraMatrix: camMatrix)
let forward = -normalize(SIMD3<Float>(camMatrix.columns.2.x,
camMatrix.columns.2.y,
camMatrix.columns.2.z))
let up = normalize(SIMD3<Float>(camMatrix.columns.1.x,
camMatrix.columns.1.y,
camMatrix.columns.1.z))
var start = target
let verticalOffset: Float = 2.0 // 從螢幕上方(+Y)幾公尺外
let depthOffset: Float = 3.0 // 從畫面深處(-Z)幾公尺外
start.translation = target.translation + (up * verticalOffset) + (forward * depthOffset)
// 讓平面朝相機 + 加些角度,提升動態感
let faceCameraFlip = simd_quatf(angle: .pi, axis: [0, 1, 0])
let yaw = simd_quatf(angle: .pi/12, axis: [0, 1, 0])
let pitchDown = simd_quatf(angle: -.pi/12, axis: [1, 0, 0])
wand.setTransformMatrix(start.matrix, relativeTo: nil)
wand.orientation = (faceCameraFlip * yaw * pitchDown) * wand.orientation
wand.scale = .init(repeating: 1.0)
// 飛入
let flyInDuration: TimeInterval = 0.8
wand.move(to: target, relativeTo: nil, duration: flyInDuration, timingFunction: .easeOut)
// 輕微 overshoot(縮小→回到 1.0)
DispatchQueue.main.asyncAfter(deadline: .now() + flyInDuration * 0.95) {
wand.scale = .init(repeating: 0.95)
wand.move(to: target, relativeTo: nil, duration: 0.2, timingFunction: .easeInOut)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) {
wand.scale = .init(repeating: 1.0)
}
}
}
depthOffset
並縮短 flyInDuration
。let verticalOffset: Float = 2.0 // 上下:+ 上方、- 下方
let depthOffset: Float = 3.0 // 前後距離(越大越遠)
let flyInDuration: TimeInterval = 0.8
makeWandPlaceholder()
調整平面大小let mesh = MeshResource.generatePlane(width: 0.05, height: 0.32)
wand.scale = .init(repeating: 1.2) // 基礎 1.2 倍
// overshoot 段落對應改為
wand.scale = .init(repeating: 1.2 * 0.95) // 縮小
// 回彈再回到 1.2
這樣就完成「從螢幕外飛入到眼前」的基本版:有固定落點、單段 ease-out 飛入、並帶小幅度的 scale/rotation 彈性。