iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0
Mobile Development

使用 AR 魔杖呼喚屬於自己的魔法小卡!系列 第 5

Day 5 AR 加載畫面加入提示小卡

  • 分享至 

  • xImage
  •  

由於開啟 App 時,加載 AR 都要花費一些時間,AR 鏡頭才會由卡頓變成順暢。
因此從使用者的角度,我想先加一些提示畫面,轉移注意力,才不會覺得等很久。

目前開啟 App 之後的流程:
AR 開機 → 先顯示提示卡牌 → 使用者選擇 → 決定是否把魔杖加進場景


畫面架構:ZStack 疊 UI

用 ZStack 疊兩層:

  • 底層:RealityView(承載 RealityKit 場景)
  • 上層:OnboardingCard(SwiftUI 卡牌)
ZStack {
  RealityView { ... } update: { ... }
    .ignoresSafeArea()

  if vm.phase != .done {
    OnboardingCard(onChoose: { summon in ... })
      .transition(.opacity)
  }
}

使用 ZStack 可以讓我們不動 AR 場景就能替換前景 UI,互不干擾。


只管狀態的 VM

@MainActor
final class AROnboardingVM: ObservableObject {
  enum Phase {
    case loadingAR // AR 載入中
    case onboarding // 顯示卡牌互動
    case done // 卡牌收起,進入正式體驗
}
  enum Choice { 
    case undecided // 尚未決定
    case summonWand // 召喚魔杖
    case noWand // 不要魔杖
}

  @Published var phase: Phase = .loadingAR
  @Published var choice: Choice = .undecided
}

分別記錄畫面階段 Phase 與使用者對於是否要魔杖的選擇 Choice。


SceneStore 保存 RealityKit 物件的弱引用

private final class SceneStore {
  weak var cameraAnchor: AnchorEntity?
  weak var wandEntity: ModelEntity?
}

原本是把 RealityKit 的物件放在 RealityView 中,結果在 app 運行期間出現警告:

Modifying state during view update, this will cause undefined behavior.

為何有這警告?
SwiftUI 在更新畫面的時候去改 @State/@Published 等狀態,會造成未定義行為。
把 RealityKit 實體的增刪移交由非 @Published 的物件管理,就不會碰到渲染期改狀態的問題。


RealityView 初始化:只建相機 Anchor,不建魔杖

RealityView { content in
  content.camera = .spatialTracking
  if scene.cameraAnchor == nil {
    let anchor = AnchorEntity(.camera)
    content.add(anchor)
    scene.cameraAnchor = anchor
  }
} update: { _ in
  syncWandIfNeeded() // 只讀 vm.choice,操作 RealityKit
}
.onAppear {
  if vm.phase == .loadingAR { vm.phase = .onboarding }
}

使用者還在卡牌場景時,AR 畫面中先不要有魔杖。


Onboarding 卡牌

struct OnboardingCard: View {
  var onChoose: (_ summonWand: Bool) -> Void
  var body: some View {
    VStack(spacing: 16) {
      Text("準備好了嗎?").font(.title2).bold()
      Text("請把手放到鏡頭前。\n拇指與食指捏合可以抓住魔杖,放開即可鬆手。")
        .multilineTextAlignment(.center)
        .font(.callout)
        .foregroundStyle(.secondary)

      Divider().opacity(0.2)
      Text("要使用召喚咒召喚魔杖嗎?").font(.headline)

      HStack(spacing: 12) {
        Button("稍後再說") { onChoose(false) }.buttonStyle(.bordered)
        Button("速速前魔杖!") { onChoose(true) }.buttonStyle(.borderedProminent)
      }
    }
    .padding(20)
    .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
    .padding(.horizontal, 24)
    .shadow(radius: 12)
  }
}

召喚或不召喚:動態新增/移除魔杖

重點:讀 vm.choice,用 scene 存取 RealityKit 物件,只動場景、不動 SwiftUI 狀態。

private func syncWandIfNeeded() {
  guard let cameraAnchor = scene.cameraAnchor else { return }

  switch vm.choice {
  case .summonWand:
    if scene.wandEntity == nil {
      let wand = makeWandPlaceholder()
      wand.position = [0, 0, -0.3]
      cameraAnchor.addChild(wand)
      scene.wandEntity = wand
      Task { await applyWandTexture(to: wand) }
    }

  case .noWand, .undecided:
    scene.wandEntity?.removeFromParent()
    scene.wandEntity = nil
  }
}

材質細節

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 {
  do {
    let texture = try await TextureResource(named: "Wand") 
    var material = UnlitMaterial()
    material.color = .init(tint: .white, texture: .init(texture))
    material.blending = .transparent(opacity: 1.0) 
    entity.model?.materials = [material]
  } catch {
    print("Failed to load Wand texture: \(error)")
  }
}

後面兩區都是屬於前幾天的範疇,只是時機的調動。


現在一打開 App 呈現的樣子:
https://ithelp.ithome.com.tw/upload/images/20250912/20162426ioaL7UcEVB.png

AR 相機運行起來之後:
https://ithelp.ithome.com.tw/upload/images/20250912/201624265w9l8ftTVp.png

點擊速速前魔杖,魔杖就會出現在畫面中
如果點稍後再說...那恭喜獲得滿版相機鏡頭!


上一篇
Day 4 魔杖去背了!
下一篇
Day 6 速速前動畫
系列文
使用 AR 魔杖呼喚屬於自己的魔法小卡!6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言