iT邦幫忙

2025 iThome 鐵人賽

DAY 6
0

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 來移動。

世界錨點:讓魔杖在真 3D 空間飛行

把魔杖掛在世界錨點上(而不是相機錨點),移動時用世界座標,製造從遠方飛近的感覺。

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)
  • 或用縮放(要一併改 overshoot)
wand.scale = .init(repeating: 1.2)          // 基礎 1.2 倍
// overshoot 段落對應改為
wand.scale = .init(repeating: 1.2 * 0.95)   // 縮小
// 回彈再回到 1.2

這樣就完成「從螢幕外飛入到眼前」的基本版:有固定落點、單段 ease-out 飛入、並帶小幅度的 scale/rotation 彈性。


上一篇
Day 5 AR 加載畫面加入提示小卡
系列文
使用 AR 魔杖呼喚屬於自己的魔法小卡!6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言