• 5

Swift [第6單元(下)] RealityKit 空間運算

第10課 動作(Action)與動畫(Animation)

學過第4單元「SwiftUI 動畫與繪圖」之後,我們大致可以了解什麼是動畫。本質上,動畫就是隨時間變化的動作;更抽象一點來說,動畫是物件的某個或某些「屬性值」(例如位置、顏色、透明度…等)隨時間而變的視覺效果。

「時間」是動畫最關鍵的要素。

3D 空間運算也是如此,例如,物體位置從 x = -2 變成 x = 2,即水平位移4公尺,這叫動作;若加上時間:「在5秒內完成」,這個過程就變成動畫。

在我們平常認知中,「動作」通常指位置或外形有所變化,對應空間運算中的座標變換 — 也就是位移、旋轉、縮放。但本課要學的動作,則是任何一個屬性變化,都可稱之為動作;將動作約束在一段時間內,則稱之為動畫。

例如在2秒內透明度(opacity)從100%降到0%,便產生淡出(Fade-out)的動畫效果;或是0.5秒內顏色從全黑慢慢變成明黃,就產生燈光亮起的感覺。

第4單元第1課(4-1a)曾介紹十幾種時間曲線(Timing Curve),不同的時間曲線,會產生不同的視覺效果。在第7課(4-7b)還提到,時間曲線其實就是一條三階貝茲曲線,因此,時間曲線可以有無限多種。

例如,SwiftUI 預設的時間曲線是緩進緩出(EaseInOut),如下圖,兩個控制點分別位於(0.42, 0)與(0.58, 1),接近垂直中線的上下兩端:


來源 https://www.smashingmagazine.com/2014/04/understanding-css-timing-functions/


上圖是正規化座標,橫(X)軸代表時間比例(0%~100%),縱(Y)軸代表動作完成度(0%~100%),因此,隨著時間變化,曲線上升的幅度(斜率)就代表動作相對的快慢。

預設的時間曲線,啟動時較慢,接下來逐漸加速,在中間(x=50%)速度最快,然後逐漸減速,最後緩慢結束。根據 Apple 官方文件,這樣的時間曲線,視覺上是最自然的。類似這樣的曲線,也俗稱S曲線。

6-10a 自轉(SpinAction)

在 RealityKit 中,要讓個體(Entity)做出動畫效果,需要三個步驟:

(1) 先定義動作(Action)物件
(2) 加入時間,定義動畫物件(AnimationResource)
(3) 呼叫個體的 playAnimation()

RealityKit 預設的動作有以下幾種,大多是去(2024)年才發布,本課會介紹前三種:

1. SpinAction 自轉
2. OrbitEntityAction 公轉
3. FromToByAction 一般化(任何屬性)動作
4. EmphasizeAction
5. BillboardAction
6. ImpulseAction
7. PlayAnimationAction
8. PlayAudioAction
9. SetEntityEnabledAction

定義好動作之後,再加上時間參數,納入動畫物件中,便產出 AnimationResource 物件。具體做法是呼叫類型方法 AnimationResource.makeActionAnimation(),圖解如下:


除此之外,AnimationResource 還有其他類型方法,也可以產出動畫物件,但本課並不會介紹,留給讀者自行探索:

本節先做一個最簡單的自轉動畫,借用上一課的正十二面體,讓它轉動起來。

相較於上一課客製化模型,自轉動畫簡單多了,只需幾行程式:
let 自轉 = SpinAction(revolutions: 1.0)    //(1)定義動作物件
if let 動畫 = try? AnimationResource.makeActionAnimation( //(2)定義動畫
for: 自轉,
duration: 3.0,
bindTarget: .transform
) {
模型.playAnimation(動畫.repeat()) //(3)執行動畫
}
其實就三句程式碼,對應上面提到的三個步驟。

第一步的 SpinAction 其實有4個參數,除了圈數(revolutions)之外,其他都有預設值,故可省略:
- revolutions (自轉圈數):無預設值
- localAxis(自轉軸):預設為個體區域座標的正Y軸
- timingFunction(時間函數):預設為緩進緩出(EaseInOut)
- isAdditive(是否疊加):預設為否 — 作用不明(註解5)
// https://developer.apple.com/documentation/realitykit/spinaction
SpinAction(
revolutions: Float,
localAxis: SIMD3<Float> = [0, 1, 0],
timingFunction: AnimationTimingFunction = .default,
isAdditive: Bool = false
)

第二步 AnimationResource.makeActionAnimation() 實際上有13個參數,除了第1個參數(動作)之外,其他都有預設值:
makeActionAnimation<T>(
for action: T,
duration: TimeInterval = 1.0,
name: String = "",
bindTarget: BindTarget? = nil,
blendLayer: Int32 = 0,
repeatMode: AnimationRepeatMode = .none,
fillMode: AnimationFillMode = [],
trimStart: TimeInterval? = nil,
trimEnd: TimeInterval? = nil,
trimDuration: TimeInterval? = nil,
offset: TimeInterval = 0,
delay: TimeInterval = 0,
speed: Float = 1.0
) throws -> AnimationResource where T : EntityAction

其中比較特殊,也最重要的是 bindTarget(綁定屬性),是與動作關聯的物件屬性,對旋轉、位移、縮放來說,屬性都是 .transform (座標變換)。動畫過程中,(預設每1/60秒)會根據時間曲線計算綁定屬性的內插值,並加以變更,進而產生動畫效果。

通常我們至少要提供動作(for)、時間長度(duration, 預設1秒)、綁定屬性(bindTarget)等三個參數。範例中設定每3秒鐘自轉一圈,並且在第3步執行動畫playAnimation()時,加上重複 repeat() 指令。

最後得到的效果如下,注意預設的時間函數「緩進緩出」的作用:


完整範例程式如下:
// 6-10a 自轉(SpinAction)
// Created by Heman Lu on 2025/04/24
// Based on 6-9c 作業1 by Heman Lu, 2025/04/07
// Tested with iMac 2019 (macOS 15.4.1) + Swift Playground 4.6.4

import SwiftUI
import RealityKit

struct 顯示正十二面體 : View {
let 圖示陣列: [String] = [
"apple.logo",
"apple.intelligence",
"apple.meditate",
"appletv",
"applewatch",
"wind.snow",
"mountain.2",
"dog",
"cat.fill",
"lizard.fill",
"soccerball",
"theatermasks"
]

func 文字轉圖片(_ 文字: String = "", 圖示: String = "", 寬高: CGFloat = 100) -> CGImage {
if 圖示.count > 0 {
let 視圖 = Image(systemName: 圖示)
.font(.largeTitle)
.shadow(radius: 3)
.blur(radius: 0.2)
.foregroundStyle(.black)
.frame(width: 寬高, height: 寬高)
.background {
Color.white
}
let 圖片 = ImageRenderer(content: 視圖)
return 圖片.cgImage!
} else {
let 視圖 = Text(文字)
.font(.largeTitle)
.padding()
.overlay {
Circle()
.stroke(.black, lineWidth: 3)
}
.foregroundStyle(.black)
.frame(width: 寬高, height: 寬高)
.background {
Color.white
}
let 圖片 = ImageRenderer(content: 視圖)
return 圖片.cgImage!
}
}

var body: some View {
RealityView { 內容 in
內容.add(座標軸(0.9)) // 共享程式6-6b

var 材質陣列: [PhysicallyBasedMaterial] = []
for i in 0..<12 {
var 材質 = PhysicallyBasedMaterial()
材質.baseColor.tint = .cyan
// let 圖片 = 文字轉圖片("\(i)", 寬高: 200)
let 圖片 = 文字轉圖片(圖示: 圖示陣列[i])
if let 紋理 = try? await TextureResource(image: 圖片, options: .init(semantic: .color)) {
print("紋理匯入成功#\(i)")
材質.baseColor.texture = .init(紋理)
}
材質陣列.append(材質)
}

// 共享程式6-9c 正十二面體
if let 模型 = try? await ModelEntity(mesh: .正十二面體(外接球半徑: 0.6)) {
模型.model?.materials = 材質陣列
內容.add(模型)

let 自轉 = SpinAction(revolutions: 1.0)
if let 動畫 = try? AnimationResource.makeActionAnimation(
for: 自轉,
duration: 3.0,
bindTarget: .transform
) {
模型.playAnimation(動畫.repeat())
}
}

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
}
.realityViewCameraControls(.orbit)
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(顯示正十二面體())

💡註解
  1. 第6單元第3課,SceneKit 是以動畫(Animation)組合成動作(Action),兩者作用剛好與 RealityKit 相反。
  2. 並非任何物件屬性都可做動畫,有一個必要條件,就是屬性須符合 AnimatableData (可動畫資料)規範,具體來說,就是實數(Float, Double)或衍伸的類型,如 simd_float3, CGFloat 等;整數(Int)或字串(String)不能當作動畫屬性。
  3. 為什麼整數不能當作動畫屬性?因為動畫是屬性的時間函數,在開始與結束之間,必須用內插法得出屬性值,只有實數才能算出內插值。
  4. 例如,一秒鐘從 x=-2 位移到 x=2 的動畫,必須切割為1/60秒一幅畫面,相當於每1/60秒就需要一個x座標的內插值,如果屬性類型是整數,就無法分割計算。
  5. SpinAction 的參數 isAdditive 有何作用?老實說,筆者試不出來,不管設定 true 或 false,效果看起來並無差別。原廠說明也是有看沒有懂:
    A Boolean value that indicates whether the animation system additively blends the action’s output with the base value.
    (簡譯:「動作的輸出是否要與基礎值混合疊加」)
  6. 注意 RealityKit 將動畫時間拆成兩部分,時間函數(timingFunction)放在動作中;而時間長度(duration)則放在動畫資源裡。
  7. 作業1:請將時間函數改用線性(linear),也就是等速旋轉。
  8. 作業2:請將動作改為(由上往下看)順時針旋轉0.5圈。
  9. 作業3:請讓座標軸與正十二面體一起同步旋轉。
  10. 作業4:實際影響物體旋轉速度的參數,除了時間函數,還有哪些?試試看能做出最快轉速是多少?
6-10b 衛星繞地球公轉(OrbitEntityAction)

據統計,地球軌道上的人造衛星目前約有四千多顆,其中約1,300顆仍在運作中。人造衛星繞地球轉稱為公轉,繞行一週的時間稱為公轉週期,依軌道高度,從1.5小時(低軌衛星)到24小時(同步衛星)不等。

在宇宙中,任何星體都不是靜止的,衛星繞行星、行星繞恆星,恆星繞星系,整個星系同樣在自轉與公轉。

假如有一天,我們設計一個正十二邊形的人造衛星,繞著地球轉,畫面會是如何呢?我們用程式來模擬看看。

為了方便,我們將正十二面體模型改寫成共享程式,用函式直接產出模型個體(ModelEntity),這樣以後就不必再重複。以下程式碼請放置於共享程式區:
// 6-10b 共享程式:正十二面體模型
// Created by Heman Lu on 2025/04/27
// Tested with iMac 2019 (macOS 15.4.1) + Swift Playground 4.6.4

import SwiftUI
import RealityKit

/// 文字轉圖片()->CGImage?
/// 若文字為系統圖示(SFSymbols)名稱,則優先處理
/// 否則一般文字以字串顯示,再轉為圖形
@MainActor
public func 文字轉圖片(_ 文字: String = "", 圖示: String = "", 寬高: CGFloat = 100) -> CGImage? {
if 圖示.count > 0 {
let 視圖 = Image(systemName: 圖示)
.font(.largeTitle)
.shadow(radius: 3)
.blur(radius: 0.2)
.foregroundStyle(.black)
.frame(width: 寬高, height: 寬高)
.background {
Color.white
}
return ImageRenderer(content: 視圖).cgImage
} else {
let 視圖 = Text(文字)
.font(.largeTitle)
.padding()
.overlay {
Circle()
.stroke(.black, lineWidth: 3)
}
.foregroundStyle(.black)
.frame(width: 寬高, height: 寬高)
.background {
Color.white
}
return ImageRenderer(content: 視圖).cgImage
}
}

@MainActor
public func 正十二面體模型(外接球半徑 r: Float = 1.0) async throws -> ModelEntity {
let 圖示陣列: [String] = [
"apple.logo",
"apple.intelligence",
"apple.meditate",
"appletv",
"applewatch",
"wind.snow",
"mountain.2",
"dog",
"cat.fill",
"lizard.fill",
"soccerball",
"theatermasks"
]
var 材質 = PhysicallyBasedMaterial()
材質.baseColor.tint = .cyan
let 材質陣列 = 圖示陣列.map { 圖示名稱 in
if let 圖片 = 文字轉圖片(圖示: 圖示名稱),
let 紋理 = try? TextureResource(image: 圖片, options: .init(semantic: .color)) {
// print("紋理匯入成功#\(i)")
材質.baseColor.texture = .init(紋理)
}
return 材質
}

// 6-9c 共享程式:正十二面體
let 模型 = try await ModelEntity(mesh: .正十二面體(外接球半徑: r))
模型.model?.materials = 材質陣列
return 模型
}

這裡用到一個新的語法:@MainActor,這會指定所宣告的函式要在CPU主線(main thread)執行,為什麼需要這個呢?主要是因為用到 SwiftUI 的物件,所有與使用者互動的 UI 物件都必須在主線執行,Swift Playground 會自動檢查並提示加上 @MainActor。

主程式仿照上一節自轉動畫的寫法,公轉用的動作物件是 OrbitEntityAction,此物件共有5個參數,後三個有預設值:
- pivotEntity:公轉所繞行的個體
- revolutions:公轉圈數
- orbitalAxis:轉軸向量,預設為世界(全域)座標的正Y軸 [0, 1, 0]
- isOrientedToPath:是否永遠面向公轉圓心,預設為否
- isAdditive(是否疊加):預設為否
OrbitEntityAction(
pivotEntity: ActionEntityResolution,
revolutions: Float,
orbitalAxis: SIMD3<Float> = [0, 1, 0],
isOrientedToPath: Bool = false,
isAdditive: Bool = false
)

實際程式的寫法相當直接,利用共享程式產出正十二面體模型,當作衛星,然後設定公轉的軸心個體與圈數,再加入動畫物件即可:
// 共享程式6-10b 正十二面體模型
let 衛星模型 = try await 正十二面體模型(外接球半徑: 0.1)
衛星模型.position.z = 1.1
內容.add(衛星模型)

let 繞地公轉 = OrbitEntityAction(pivotEntity: .entityNamed("地球"), revolutions: 1)
let 動畫 = try AnimationResource.makeActionAnimation(
for: 繞地公轉,
duration: 3.0,
bindTarget: .transform)
衛星模型.playAnimation(動畫.repeat())

與上一節相比,只是多一個軸心個體參數 pivotEntity: .entityName(”地球”),會用名稱來指定個體,因此在產出地球模型時,記得加上個體名稱。

這裡的地球模型當然是直接用球體模型即可,不過為了逼真,我們特地到 NASA 網站下載地球的 UV 映射全景圖當作材質,然後再以23.5度傾斜角模擬地球自轉,自轉方向自西向東(繞Z軸逆時針方向)。程式碼稍長一些,但都是學過的技巧。

主程式如下:
// 6-10b 公轉(OrbitEntityAction)
// Created by Heman Lu on 2025/04/27
// Tested with iMac 2019 (macOS 15.4.1) + Swift Playground 4.6.4

import SwiftUI
import RealityKit

struct 衛星繞地球公轉 : View {
let 地球全景圖網址 = "https://eoimages.gsfc.nasa.gov/images/imagerecords/57000/57735/land_ocean_ice_cloud_2048.jpg"
var body: some View {
RealityView { 內容 in
內容.add(座標軸(1.2)) // 共享程式6-6b

// (1) 製作地球模型
let 地球模型 = ModelEntity(mesh: .generateSphere(radius: 0.8))
地球模型.name = "地球"

// (2) 下載地球全景圖,製作材質
var 地球材質 = UnlitMaterial()
do {
if let myURL = URL(string: 地球全景圖網址) {
let (下載內容, 回應碼) = try await URLSession.shared.data(from: myURL)
if let 全景圖 = UIImage(data: 下載內容)?.cgImage {
let 紋理 = try await TextureResource(
image: 全景圖,
options: .init(semantic: .color))
地球材質.color.texture = .init(紋理)
}
}
} catch {
print("有問題:\(error)")
}
地球模型.model?.materials = [地球材質]
內容.add(地球模型)

// (3) 設定地球自轉動畫
let 傾斜角: Float = 23.5 * .pi / 180.0
地球模型.orientation = simd_quatf(angle: 傾斜角, axis: [0, 0, -1])
let 地球自轉 = SpinAction(
revolutions: 1.0,
localAxis: [0, 1, 0],
timingFunction: .linear)
if let 動畫 = try? AnimationResource.makeActionAnimation(
for: 地球自轉,
duration: 60.0,
bindTarget: .transform
) {
地球模型.playAnimation(動畫.repeat())
}

// (4) 設定衛星模型公轉動畫
do {
// 共享程式6-10b 正十二面體模型
let 衛星模型 = try await 正十二面體模型(外接球半徑: 0.1)
衛星模型.position.z = 1.1
內容.add(衛星模型)

let 繞地公轉 = OrbitEntityAction(
pivotEntity: .entityNamed("地球"),
revolutions: 1,
isOrientedToPath: true)
let 動畫 = try AnimationResource.makeActionAnimation(
for: 繞地公轉,
duration: 3.0,
bindTarget: .transform)
衛星模型.playAnimation(動畫.repeat())
} catch {
print("有問題:\(error)")
}

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
}
.realityViewCameraControls(.orbit)
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(衛星繞地球公轉())

最後執行結果如下:

注意衛星會以同一面朝向地球旋轉,和月亮一樣,這是哪一行程式的作用呢?

💡註解
  1. 台灣目前有9顆人造衛星運行中,分別由中華電信(中新二號)與國家太空中心(福衛五、福衛七x6、獵風者)營運管理。
  2. 想知道福衛七號衛星什麼時候會經過台灣嗎?可參考美國ESRI公司網站
  3. @MainActor 是 Actor 的一種,Actor 曾在第5單元語法說明介紹過,這是新版 Swift 6 要徹底解決資料衝突(data race)的核心機制之一。
  4. 作業1:太陽繞銀河系一週的公轉週期是幾年?
  5. 作業2:程式啟動時,會等地球全景圖(到 NASA 網站)下載完之後,才顯示整個畫面,有沒有辦法改善,以減少等候時間呢?
  6. 作業3:衛星通常也會自轉,如何讓自轉與公轉同時進行呢?
  7. 作業4:目前衛星公轉軌道是在 X-Z 平面,也就是黃道面(地球繞太陽轉的軌道面),如何調整衛星軌道,改成繞赤道公轉?
6-10c 燈光與陰影元件(components)

模型個體預設包含3種元件:模型元件(ModelComponent)、座標變換(Transform)以及同步元件(SynchronizationComponent)。因此,對模型個體來說,適用於動畫的屬性,除了座標變換的位移、旋轉、縮放,似乎只有模型元件的材質顏色。對嗎?

第6課 RealityKit ECS 簡介曾提過,在 RealityKit 建構的虛擬世界中,個體(Entity)都是由元件(Component)組成,元件則是某些屬性與方法構成的物件,需要時可動態加入個體之中,RealityKit 用這種方式(稱為ECS架構)來避免個體物件太過臃腫。

也就是說,模型個體還可加入其他元件,元件帶來的屬性有些就可用於動畫效果,因此,本節先來學習如何加入新元件。

其實很簡單,對任何個體(Entity)而言,動態加入某個元件只需一行:
個體.components.set(元件類型())

上一節的衛星繞地球公轉,並不完美,因為沒有光影,所以看不到日夜變化。想模擬太陽光照射地球,出現日出日落的效果,需要兩個額外元件,一是燈光元件,二是陰影元件。

RealityKit 和燈光陰影相關的元件如下表(完整元件列表,待下節補充說明):
# 物件名稱 中文名稱 功能說明
1 DirectionalLightComponent 定向光元件
平行光元件
在虛擬空間中提供固定方向的光源
2 DirectionalLightComponent.Shadow 定向光陰影元件 讓定向光源產生陰影
3 DynamicLightShadowComponent 動態陰影元件 虛擬空間中動態計算陰影
4 GroundingShadowComponent 地面陰影元件 對平面或地面產生陰影(實體或虛擬空間)
5 PointLightComponent 點光源元件 對任意方向產生光照
6 SpotLightComponent 聚光燈元件 對某方向圓錐狀的光照
7 SpotLightComponent.Shadow 聚光燈陰影元件 對聚光燈源產生陰影效果

我們選用「定向光元件」(DirectionalLightComponent)來模擬太陽光,對應的個體為 DirectionalLight 定向光個體,這是第6課提到的9種預設個體之一。

模擬太陽光的程式碼如下:
// (5) 設定太陽光與陰影
let 太陽光 = DirectionalLight()
太陽光.light.color = .white
太陽光.light.intensity = 20000
太陽光.orientation = simd_quatf(angle: .pi * 0.5, axis: [0, 1, 0])
太陽光.components.set(DirectionalLightComponent.Shadow())
內容.add(太陽光)

先產出一個定向光個體,設定光的顏色與強度,燈光強度(intensity)的單位是流明(lumen),定向光的強度不會隨距離而衰減,因此設定強度2萬流明即可。

定向光的預設方向是往 -Z 軸,即視線(眼睛看向螢幕)的方向,程式設定 orientation 沿 Y 軸逆時針轉90度,變成往 -X 軸方向,也就是說,設定太陽光從右方(正X軸)照射過來。這是北半球夏至時的角度,陽光直射北緯23.5度,北極圈進入永晝,南極圈則是永夜。

另外還要加入陰影元件(DirectionalLightComponent.Shadow),否則預設不會產生陰影。

地球和人造衛星則須加入「動態陰影元件」(DynamicLightShadowComponent)才能投射出陰影,產生日食與月食的效果:
let 動態陰影元件 = DynamicLightShadowComponent(castsShadow: true)
地球模型.components.set(動態陰影元件)
衛星模型.components.set(動態陰影元件)

先來看看產生的效果,投射的陰影很自然,感覺相當逼真:


完整程式如下,修改自上一節衛星繞地球公轉的主程式,增加第(5)段 — 設定太陽光與陰影,人造衛星的公轉角度有稍加調整:
// 6-10c 燈光與陰影
// Created by Heman Lu on 2025/04/29
// Tested with iMac 2019 (macOS 15.4.1) + Swift Playground 4.6.4

import SwiftUI
import RealityKit

struct 衛星繞地球公轉 : View {
let 地球全景圖網址 = "https://eoimages.gsfc.nasa.gov/images/imagerecords/57000/57735/land_ocean_ice_cloud_2048.jpg"
var body: some View {
RealityView { 內容 in
內容.add(座標軸(1.1)) // 共享程式6-6b

// (1) 製作地球模型
let 地球模型 = ModelEntity(mesh: .generateSphere(radius: 0.8))
地球模型.name = "地球"

// (2) 下載地球全景圖,製作材質
// var 地球材質 = UnlitMaterial()
var 地球材質 = SimpleMaterial()
地球材質.color.tint = .gray
do {
if let myURL = URL(string: 地球全景圖網址) {
let (下載內容, 回應碼) = try await URLSession.shared.data(from: myURL)
if let 全景圖 = UIImage(data: 下載內容)?.cgImage {
let 紋理 = try await TextureResource(
image: 全景圖,
options: .init(semantic: .color))
地球材質.color.texture = .init(紋理)
}
}
} catch {
print("有問題:\(error)")
}
地球模型.model?.materials = [地球材質]
內容.add(地球模型)

// (3) 設定地球自轉動畫
let 傾斜角: Float = 23.5 * .pi / 180.0
地球模型.orientation = simd_quatf(angle: 傾斜角, axis: [0, 0, -1])
let 地球自轉 = SpinAction(
revolutions: 1.0,
localAxis: [0, 1, 0],
timingFunction: .linear)
if let 動畫 = try? AnimationResource.makeActionAnimation(
for: 地球自轉,
duration: 60.0,
bindTarget: .transform
) {
地球模型.playAnimation(動畫.repeat())
}

// (5) 設定太陽光與陰影
let 太陽光 = DirectionalLight()
太陽光.light.color = .white
太陽光.light.intensity = 20000
// print(太陽光.position, 太陽光.orientation)
太陽光.orientation = simd_quatf(angle: .pi * 0.5, axis: [0, 1, 0])
太陽光.components.set(DirectionalLightComponent.Shadow())
內容.add(太陽光)
// print(太陽光)

let 動態陰影元件 = DynamicLightShadowComponent(castsShadow: true)
地球模型.components.set(動態陰影元件)

// (4) 設定衛星模型公轉動畫
do {
// 共享程式6-10b 正十二面體模型
let 衛星模型 = try await 正十二面體模型(外接球半徑: 0.1)
衛星模型.position.z = 1.1
衛星模型.components.set(動態陰影元件)
內容.add(衛星模型)

let 繞地公轉 = OrbitEntityAction(
pivotEntity: .entityNamed("地球"),
revolutions: 1,
orbitalAxis: [-0.3, 1, 0],
isOrientedToPath: true)
let 動畫 = try AnimationResource.makeActionAnimation(
for: 繞地公轉,
duration: 3.0,
bindTarget: .transform)
衛星模型.playAnimation(動畫.repeat())
} catch {
print("有問題:\(error)")
}

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
}
.realityViewCameraControls(.orbit)
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(衛星繞地球公轉())

💡註解
  1. 第7課(6-7a)提過,燈光個體只用來提供光照計算,本身是看不到的。只有包含模型元件(有網格與材質)的個體才能被渲染而顯示出來。
  2. 如果將燈光從上往下照,並不會在地面上產生陰影,原因是背景(天空盒)在虛擬座標中,任何方向的距離都是無限遠。
  3. 作業1:如何模擬一年365天的太陽光呢?其實只要改變定向光的角度即可,光源來自+X軸是(北半球)夏至,來自-Z軸是秋分,來自-X軸是冬至,來自+Z軸是春分。請做一個動畫模擬看看。
  4. 作業2:請將背景更換為滿天星斗的夜空(提示:天空盒做法參考6-7c與補充20)。
  5. 作業3:請將人造衛星更換為月球,設定距離、自轉傾斜角、月球公轉角度等參數,以模擬月亮盈虧的效果。
補充(23) RealityKit 元件列表

RealityKit 採用 ECS 架構,將「個體」(Entity)的屬性分散到各個元件(Component)中,需要時再動態載入。好處之一是讓「個體」保持輕盈,另一個好處是可自行定義元件,無限擴充,可說兼顧效率與彈性。

從另一個角度看,元件也是「個體」能發揮作用的關鍵,個體想要什麼功能,就須具備相關元件。因此,了解內建有哪些可用元件,對初學者來說非常重要。

有趣的是,RealityKit 從2019年發表,到2023年之間,內建元件只有寥寥十幾個,很多功能還不如 SceneKit (如缺乏粒子系統、臉部動畫等),2023年推出 Vision Pro 之後,Apple 才大舉投入空間運算,2024年 RealityKit 一口氣新增36個元件,總算彌平差距。

在可預期的未來,Apple 必然持續開發空間運算相關產品,不論是低價版的 Vision Pro、AR眼鏡或其他穿戴裝置,空間運算的應用可結合AR、AI、元宇宙、網路服務…,將越來越重要。

可以說,現在是學習 RealityKit 的好時機!

以下是目前 RealityKit 內建元件列表,共計56個元件,本單元課程大約會用到其中三分之一基礎元件,其餘三分之二以及未來發布的新元件有賴讀者自行探索。

RealityKit 內建元件(Components)
# 物件名稱 中文名稱 功能說明
【預設元件】
1 SynchronizationComponent 網路同步元件 小範圍多人AR遊戲的同步機制
2 Transform 座標變換元件 虛擬空間中的位移、旋轉、縮放
【模型元件】
3 ModelComponent 模型元件 模型網格(Mesh)與材質(Materials)
4 ModelDebugOptionsComponent 模型偵錯元件 視覺化法線、UV座標、材質等資料
5 ModelSortGroupComponent 模型排序元件 調整模型階層的渲染次序
【一般用途元件】
6 AdaptiveResolutionComponent 解析度元件 對遠離的物件降低解析度,以減少計算量
7 AnimationLibraryComponent 動畫庫元件 將動畫加入動畫庫,以便存檔或複製
8 BillboardComponent 告示板元件 讓模型永遠面對鏡頭視角
9 OpacityComponent 透明度元件 調整整個模型(而非材質)的透明度
10 TextComponent 2D文字元件 在虛擬空間中加入2D文字
【UI互動元件】
11 AccessibilityComponent 輔助元件 提供視障、聽障輔助功能
12 HoverEffectComponent 懸浮效果元件 (macOS)滑鼠游標移到該物件時會稍亮
13 InputTargetComponent 輸入標定元件 用手指觸控或滑鼠點選3D物件
【鏡頭相關元件】
14 OrthographicCameraComponent 正投影鏡頭元件 非透視鏡頭,尺寸固定,不會隨深度縮小
15 PerspectiveCameraComponent 透視鏡頭元件 物體會隨深度縮小(近大遠小)
16 ProjectiveTransformCamera Component 座標投影鏡頭元件 非透視鏡頭,物體邊緣會扭曲
【音效相關元件】
17 AmbientAudioComponent 環境音效元件 加入環境音效
18 AudioLibraryComponent 音效庫元件 將音效加入音效庫,以便存檔或複製
19 AudioMixGroupsComponent 混音元件 混音群組
20 ChannelAudioComponent 多聲道元件 單聲道或多聲道
21 ReverbComponent 殘響音效元件 提供多種殘響音效
22 SpatialAudioComponent 空間音效元件 播放空間音效
23 VideoPlayerComponent 影片播放元件 在虛擬空間中播放影片
【燈光相關元件】
24 DirectionalLightComponent 定向光元件 在虛擬空間中提供某個方向的光源
25 DirectionalLightComponent.Shadow 定向光陰影元件 讓定向光源產生陰影
26 DynamicLightShadowComponent 動態陰影元件 虛擬空間中動態計算陰影
27 GroundingShadowComponent 地面陰影元件 對平面或地面產生陰影(實體或虛擬空間)
28 PointLightComponent 點光源元件 對虛擬空間任意方向產生光照
29 SpotLightComponent 聚光燈元件 對某方向圓錐狀的光照
30 SpotLightComponent.Shadow 聚光燈陰影元件 對聚光燈源產生陰影效果
31 EnvironmentLightingConfiguration Component 環境光照元件 調整環境光照(即天空盒)效果
32 ImageBasedLightComponent 圖像光照元件 產生環境光照(即天空盒)效果
33 ImageBasedLightReceiverComponent 圖像光照接收元件 接收環境光照,產生反光或動態陰影
34 VirtualEnvironmentProbeComponent 虛擬環境探測元件 計算環境光照的偵測元件
【門戶相關元件】
35 PortalComponent 門戶元件 產生門戶材質(PortalMaterial),以展示另一個虛擬場景,就像3D版的畫中畫
36 PortalCrossingComponent 門戶穿越元件 穿過門戶後是否隱藏
37 WorldComponent 門戶世界元件 產生門戶材質(PortalMaterial)
【物理模擬相關元件】
38 CollisionComponent 碰撞偵測元件 偵測兩個物理模擬本體是否接觸
39 ForceEffectComponent 力場元件 提供物理力場模擬效果
40 ParticleEmitterComponent 粒子噴射元件 產生粒子系統特效
41 PhysicsBodyComponent 物理本體元件 要加入物理模擬的個體
42 PhysicsJointsComponent 物理關節元件 加入關節模擬
43 PhysicsMotionComponent 物理運動元件 用程式手動控制運動與旋轉速度
44 PhysicsSimulationComponent 客製物理模擬元件 調整物理系統的參數(如重力加速度)
【AR擴增實境元件】
45 AnchoringComponent 錨定元件 錨定虛擬空間與實體空間的座標點
46 ReferenceComponent 參考個體元件 AR應用的參考個體(以便錨定)
47 SceneUnderstandingComponent 場境分析元件 自動場景分析(需LiDAR鏡頭)
48 BlendShapeWeightsComponent 臉部動畫元件 臉部網格分析(需NPU神經網路晶片)
49 BodyTrackingComponent 人體追蹤元件 人體辨識並且實時追蹤錨定
50 CharacterControllerComponent 角色控制元件 模擬虛擬角色(虛擬人物)
51 CharacterControllerStateComponent 角色狀態元件 虛擬角色狀態
52 GeometricPinsComponent 幾何關節元件 模擬關節位置
53 IKComponent 肢體控制元件 用反向推導關節控制的肢體動作
54 SkeletalPosesComponent 骨骼姿態元件 調整骨骼姿態
【visionOS專用元件】
55 DockingRegionComponent 場景投射元件 投射虛擬場景的範圍
56 ViewAttachmentComponent 視圖附件元件 在擴增實境中顯示2D視圖
6-10d 屬性動畫(FromToByAction)

在 RealityKit 內建的幾種動作當中,功能最豐富的應該是 FromToByAction,這是個通用化的動作物件,只要是可用內插法分解的個體參數,都能製作動畫效果。

目前支援的參數類型包括:

1. Float — 32位元浮點數
2. Double — 64位元實數
3. Transform — 座標變換(位移、旋轉、縮放)
4. simd_quatf — 四元數
5. simd_float2/3/4 — 2/3/4維向量(參考補充(18) SIMD

FromToByAction 的本義是「從(From)、到(To)、差值(By)」三個條件,三者可單獨使用,也能並用。

例如,以個體的透明度(opacity)為例,假設個體原本的透明度為 1.0 (完全不透明),單獨設定三種條件的預期結果如下:
動作# 參數 含義 結果
1 from: 0.1 從0.1回到原值 動作會從 opacity = 0.1 逐漸回到 1.0
2 to: 0.1 從原值變到0.1 動作會從 opacity = 1.0 逐漸降到 0.1
3 by: -0.1 從原值降0.1 動作會從 opacity = 1.0 逐漸降到 0.9

不過要注意,實際寫程式時,FromToByAction 有個隱藏陷阱。若按一般習慣寫:
let 淡出 = FromToByAction(to: 0.1)
語法雖然正確,但執行時可能完全沒有動畫效果。

正確的寫法要改成:
let 淡出 = FromToByAction<Float>(to: 0.1)  // 官方文件寫法

let 淡出 = FromToByAction(to: Float(0.1)) // 本節範例寫法
必須指明參數的資料類型,為什麼呢?

這是因為 FromToByAction 適用於多種資料類型(如上面所列支援的參數類型),若參數未明確表達資料類型,0.1 會被預設推論為 Double 類型,而綁定屬性 opacity 卻是 Float 類型,造成類型不匹配,在第二步就無法產生動畫物件。

官方文件中,此物件的定義為:
struct FromToByAction<Value> where Value : AnimatableData

<Value> 指的是某些(符合 AnimatableData 規範)資料類型的屬性,這種語法叫做「泛型」(Generic Type,Generic 意思是通用化的),在角括號中的類型名稱可隨意取,例如上一句等同於:
struct FromToByAction<T> where T : AnimatableData
若要指定其中某種資料類型,可在角括號中指定,例如 FromToByAction<Float>,表示後面參數類型為 Float (32位元浮點數)。

泛型通常用於設計API模組或框架,好處是支援的資料類型更廣泛、通用性更佳;相對的,泛型的缺點是語法較抽象,初學者不易理解。

當用到泛型設計的物件,就得注意類型匹配,一不小心就容易犯錯,這類錯誤很難偵錯。

動畫三步驟(定義動作、動畫物件、執行)的程式段落如下:
if let 模型 = try? await 正十二面體模型(外接球半徑: 0.5) {
模型.components.set(OpacityComponent())
let 淡出 = FromToByAction(to: Float(0.0)) // (1)動作物件
if let 動畫 = try? AnimationResource.makeActionAnimation( //(2)動畫
for: 淡出,
duration: 3.0,
bindTarget: .opacity,
repeatMode: .autoReverse
) {
模型.playAnimation(動畫) // (3)執行動畫
}
}
注意第(2)步產出動畫物件時,綁定屬性(bindTarget)設為 .opacity,這是個體屬性,不是之前用過的PhysicallyBasedMaterial材質屬性,須額外加入透明度元件(OpacityComponent)才會有這個屬性。

若綁定屬性找不到,或與動作物件的資料類型不匹配,第2步產出動畫物件將會失敗。

在以下範例中,我們共顯示3個正十二面體:一個採用透明材質(opacity=0.5)、一個展示透明動畫(淡出淡入,opacity=0~1)、一個用預設值(opacity=1)。

先看看實際執行效果:

能看出個體的透明度與材質的透明度有何差別嗎?

完整的主程式如下,注意更改材質時,材質的資料類型(PhysicallyBasedMaterial)必須與原先(共享程式6-10b)的定義一致,此例正十二面體用了12個材質,故用for迴圈逐一更改:
// 6-10d 屬性動畫(FromToByAction)
// Created by Heman Lu on 2025/05/08
// Tested with iMac 2019 (macOS 15.4.1) + Swift Playground 4.6.4

import SwiftUI
import RealityKit

struct 顯示正十二面體 : View {
var body: some View {
RealityView { 內容 in
內容.add(座標軸(1.2)) // 共享程式6-6b

// 共享程式6-10b 正十二面體模型
if let 模型 = try? await 正十二面體模型(外接球半徑: 0.5) {
for i in 0...2 {
let 新模型 = 模型.clone(recursive: false)
新模型.position.x = Float(i) * 1.0 - 1.0
內容.add(新模型)

if i == 0 {
if var 新材質陣列 = 新模型.model?.materials as? [PhysicallyBasedMaterial] {
for j in 新材質陣列.indices {
新材質陣列[j].blending = .transparent(opacity: 0.5)
新材質陣列[j].roughness = 0.1
新材質陣列[j].metallic = 0.9
}
新模型.model?.materials = 新材質陣列
}
} else if i == 1 {
新模型.components.set(OpacityComponent())
let 淡出 = FromToByAction(to: Float(0.0))
if let 動畫 = try? AnimationResource.makeActionAnimation(
for: 淡出,
duration: 3.0,
bindTarget: .opacity,
repeatMode: .autoReverse
) {
新模型.playAnimation(動畫)
}
}
}
}

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
}
.realityViewCameraControls(.orbit)
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(顯示正十二面體())


💡註解
  1. opacity 原意是不透明度,形容詞 opaque 是不透光、不通透、隱晦的;相對於 transparent 是透明的,transparency 是透明度。
  2. 在 SwiftUI 或 RealityKit 中,常用 opacity 來表示透明(opacity = 0)或不透明(opacity = 1)。為行文方便,opacity 也譯為透明度,因為中文「不透明度」較少用,唸起來不順。
  3. Float 和 Double 都是實數,為什麼不能通用呢?這是因為 Swift 是屬於「強類型」的程式語言,對資料類型的檢查匹配比較嚴格,好處是程式比較安全、可靠,缺點是程式設計師要多費心。
  4. 泛型(Generic Type)屬進階語法,在補充(18)曾遇過,例如 SIMD3<Scalar>,其中 Scalar 包含 Float, Double, Int 等類型。
  5. SIMD3<Float> 類型也可寫成 simd_float3,後者稱為「類型別名」。Swift 可用 typealias 定義類型別名(alias 意思就是別名、化名,近似 aka - also known as):
    typealias simd_float3 = SIMD3<Float>
    typealias 旋轉四元數 = simd_quatf
6-10e 動畫控制器(AnimationPlaybackController)

在遊戲中,有些動畫是由操作者控制,例如爬樓梯 — 當角色在某個位置,按向上鍵就會啟動爬樓梯動畫。若我們要設計類似的遊戲,如何在 RealityKit 控制動畫呢?

還記得在啟動動畫時,會呼叫個體的 playAnimation(),這個函式其實會傳回一個「動畫控制器」(AnimationPlaybackController)物件,可用來控制動畫:
// 6-10c
衛星模型.playAnimation(動畫.repeat())
// 改成:
let 動畫控制器 = 衛星模型.playAnimation(動畫.repeat(), startsPaused: true)

末行 playAnimation() 加一個額外參數 startsPaused: true 可讓動畫一開始先進入暫停狀態,等候操作。

動畫控制器只有三個操作,分別控制動畫的暫停、繼續、停止:

1. 動畫控制器.pause() — 暫停
2. 動畫控制器.resume() — 從暫停點繼續執行
3. 動畫控制器.stop() — 動畫停止,不再執行,控制器將失去效用

使用時,要注意動畫控制器的生命週期,只有在動畫結束前有效 — 若未設定重複(repeat),當動畫完成(complete)或停止(stop)之後,這個控制器就會跟著失效。

另外,在語法上,還要注意變數的有效範圍。

第6課6-6c提過,完整的 RealityView 包含三個 { } 匿名函式,名稱分別是 make:, update: 與 placeholder:,其中第一個匿名函式(make:)會在背景以非同步模式執行,只會執行一次;第二個匿名函式(update:)則是每當狀態變數(@State var)有所變化時執行。

以6-10c衛星繞地球為例,我們希望在衛星繞地球公轉時,用輕觸螢幕(onTapGesture)來暫停公轉,怎麼做呢?

第一步先設定一個狀態變數 @State var 暫停 = false,在輕觸螢幕時切換 — 暫停.toggle(),這時候應該會觸發 RealityView 的 update: 匿名函式。第二步在 update: { } 中,根據「暫停」值是 true 或false,呼叫動畫控制器.pause()或.resume() ,來控制公轉動畫。

不過,動畫控制器必須在 make: { } 裡面呼叫 playAnimation() 才能取得,怎麼傳給 update: { } 呢?最簡單的方法是設定為狀態變數(@State var 動畫控制器: AnimationPlaybackController?),然後在 make: { } 設定初始值。

這樣就可以隨意控制衛星公轉的暫停或繼續了,實際執行效果如下:

有沒有注意到,當公轉暫停時,衛星會開始明暗閃爍?

明暗閃爍是借用上一節淡出淡入動畫,不過只有在公轉暫停時才啟動,公轉繼續後就會停止閃爍,這又怎麼做到的呢?

其實,這個效果必須結合兩個特殊物件,一是動畫控制器、二是事件處理。我們先看一下實現這個效果的程式碼:
// (6) 動畫控制器、事件處理
衛星模型.components.set(OpacityComponent())
let 淡出 = FromToByAction(from: Float(1), to: Float(0))
let 淡出動畫 = try AnimationResource.makeActionAnimation(
for: 淡出,
duration: 0.6,
bindTarget: .opacity,
repeatMode: .autoReverse
)
let 淡出動畫控制器 = 衛星模型.playAnimation(淡出動畫, startsPaused: true)

OrbitEntityAction.subscribe(to: .paused) { 事件 in
print("繞地公轉暫停:\(Date.now)")
淡出動畫控制器.resume()
}
OrbitEntityAction.subscribe(to: .resumed) { 事件 in
print("繞地公轉繼續:\(Date.now)")
淡出動畫控制器.pause()
}
前半段產出動作、動畫物件,在執行動畫時產出「淡出動畫控制器」,並設定一開始就進入暫停狀態。

後半段則是設定事件處理,遇到公轉動作(OrbitEntityAction)暫停時,呼叫「淡出動畫控制器.resume()」;若公轉繼續,則呼叫「淡出動畫控制器.pause()」。

何謂事件處理?在第3單元3-2d曾經介紹過「發布-訂閱模式」,這是一種非同步的溝通模式,發布者會在某些觸發條件滿足時,發布資料給訂閱者,這些資料稱為「事件」;訂閱者收到事件時,則會呼叫某個函式或匿名函式,這些函式稱為事件處理者(event handler)。
// 3-2d 定時器(發布者)
let 定時器 = Timer.publish(every: 5.0, on: .main, in: .common).autoconnect()

// 3-2d SwiftUI(訂閱者)
.onReceive(定時器) { 時間 in }

// 6-10e 公轉動作(訂閱者)
OrbitEntityAction.subscribe(to: .paused) { 事件 in }

RealityKit 的任何個體動作(EntityAction)都可以訂閱事件,可能發生的事件包括:

1. .started — 動作啟動
2. .ended — 結束(動畫執行完)
3. .paused — 暫停
4. .resumed — 繼續
5. .updated — 有任何更新(預設每1/60秒更新一次)
6. .skipped — 有事件被跳過(來不及處理)
7. .terminated — 停止,不再執行(動畫未執行完)

在設定事件處理時,需要呼叫動作的「類型方法」subscribe() 訂閱某類事件:
OrbitEntityAction.subscribe(to: .paused) { 事件 in
// 事件觸發時,要執行的指令
}
所謂「類型方法」就是說,並不是只有某個物件實例訂閱,如「公轉.subscribe()」,而是整個類型都訂閱 — OrbitEntityAction.subscribe(),所以不管有幾個公轉動作實例,只要有一個暫停,就會發布 .paused 事件,觸發後面的匿名函式。

以上合併起來,完整的主程式如下,分為6段:
// 6-10e 動畫控制器
// Created by Heman Lu on 2025/05/11
// Tested with iMac 2019 (macOS 15.4.1) + Swift Playground 4.6.4

import SwiftUI
import RealityKit

struct 衛星繞地球公轉 : View {
@State var 暫停: Bool = false
@State var 動畫控制器: AnimationPlaybackController? = nil

let 地球全景圖網址 = "https://eoimages.gsfc.nasa.gov/images/imagerecords/57000/57735/land_ocean_ice_cloud_2048.jpg"

var body: some View {
RealityView { 內容 in
內容.add(座標軸(1.1)) // 共享程式6-6b

// (1) 製作地球模型
let 地球模型 = ModelEntity(mesh: .generateSphere(radius: 0.8))
地球模型.name = "地球"

// (2) 下載地球全景圖,製作材質
// var 地球材質 = UnlitMaterial()
var 地球材質 = SimpleMaterial()
地球材質.color.tint = .gray
do {
if let myURL = URL(string: 地球全景圖網址) {
let (下載內容, 回應碼) = try await URLSession.shared.data(from: myURL)
if let 全景圖 = UIImage(data: 下載內容)?.cgImage {
let 紋理 = try await TextureResource(
image: 全景圖,
options: .init(semantic: .color))
地球材質.color.texture = .init(紋理)
}
}
} catch {
print("有問題:\(error)")
}
地球模型.model?.materials = [地球材質]
內容.add(地球模型)

// (3) 設定地球自轉動畫
let 傾斜角: Float = 23.5 * .pi / 180.0
地球模型.orientation = simd_quatf(angle: 傾斜角, axis: [0, 0, -1])
let 地球自轉 = SpinAction(
revolutions: 1.0,
localAxis: [0, 1, 0],
timingFunction: .linear)
if let 動畫 = try? AnimationResource.makeActionAnimation(
for: 地球自轉,
duration: 60.0,
bindTarget: .transform
) {
地球模型.playAnimation(動畫.repeat())
}

// (5) 設定太陽光與陰影
let 太陽光 = DirectionalLight()
太陽光.light.color = .white
太陽光.light.intensity = 20000
// print(太陽光.position, 太陽光.orientation)
太陽光.orientation = simd_quatf(angle: .pi * 0.5, axis: [0, 1, 0])
太陽光.components.set(DirectionalLightComponent.Shadow())
內容.add(太陽光)
// print(太陽光)

// 定向燈光不需要移動位置,只需轉動方向即可
let 四季變化 = SpinAction(revolutions: 1.0, timingFunction: .linear)
if let 太陽動畫 = try? AnimationResource.makeActionAnimation(
for: 四季變化,
duration: 3600,
bindTarget: .transform
) {
太陽光.playAnimation(太陽動畫.repeat())
}

let 動態陰影元件 = DynamicLightShadowComponent(castsShadow: true)
地球模型.components.set(動態陰影元件)

// (4) 設定衛星模型公轉動畫
do {
// 共享程式6-10b 正十二面體模型
let 衛星模型 = try await 正十二面體模型(外接球半徑: 0.1)
衛星模型.position.z = 1.1
衛星模型.components.set(動態陰影元件)
內容.add(衛星模型)

let 繞地公轉 = OrbitEntityAction(
pivotEntity: .entityNamed("地球"),
revolutions: 1,
orbitalAxis: [-0.3, 1, 0],
isOrientedToPath: true)
let 動畫 = try AnimationResource.makeActionAnimation(
for: 繞地公轉,
duration: 3.0,
bindTarget: .transform)
動畫控制器 = 衛星模型.playAnimation(動畫.repeat())

// (6) 動畫控制器、事件處理
衛星模型.components.set(OpacityComponent())
let 淡出 = FromToByAction(from: Float(1), to: Float(0))
let 淡出動畫 = try AnimationResource.makeActionAnimation(
for: 淡出,
duration: 0.6,
bindTarget: .opacity,
repeatMode: .autoReverse
)
let 淡出動畫控制器 = 衛星模型.playAnimation(淡出動畫, startsPaused: true)

OrbitEntityAction.subscribe(to: .paused) { 事件 in
print("繞地公轉暫停:\(Date.now)")
淡出動畫控制器.resume()
}
OrbitEntityAction.subscribe(to: .resumed) { 事件 in
print("繞地公轉繼續:\(Date.now)")
淡出動畫控制器.pause()
}
} catch {
print("有問題:\(error)")
}

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
} update: { 內容 in
print("Updating: \(Date.now)")
if let 衛星控制器 = 動畫控制器 {
暫停 ? 衛星控制器.pause() : 衛星控制器.resume()
}
}
.realityViewCameraControls(.orbit)
.backgroundStyle(暫停 ? .black : .white)
.onTapGesture {
暫停.toggle()
print("切換暫停:\(暫停)")
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(衛星繞地球公轉())


💡註解
  1. 上面影片中,當公轉動畫暫停,衛星開始明暗閃爍時,有沒有注意到衛星的陰影並無濃淡變化,即使衛星完全透明,還是一樣的陰影,這是為什麼呢?
  2. 後面有一行神秘的程式碼:.backgroundStyle(暫停 ? .black : .white),這行實際上沒有作用(因為已有天空盒當背景),但如果將這行刪除,整個動作就無法暫停(update: 不會被觸發),原因不明。若有讀者知道原因,請留言告訴筆者。
  3. 對上一題(註解2)有興趣者,可以試試以下簡化程式,筆者在 Mac mini M2 (macOS 15.4.1) + Swift Playground 4.6.4 與 Xcode 12.3 測試過,同樣結果。有 backgroundStyle 的話(去掉標註),輕觸螢幕就正常(背景變色、觸發 update:),否則就無法更新(觀察主控台輸出)。
    // Tested by Heman, 2025/05/13

    import SwiftUI
    import RealityKit

    struct ContentView: View {
    @State var pause: Bool = false

    var body: some View {
    RealityView { content in
    let box = MeshResource.generateBox(size: 0.5)
    let model = ModelEntity(mesh: box, materials: [SimpleMaterial()])
    content.add(model)
    } update: { content in
    print("Updating: \(Date.now)")
    }
    .border(.red)
    .realityViewCameraControls(.orbit)
    // .backgroundStyle(pause ? .gray : .black) // Won't update without this line. Why???
    .onTapGesture {
    pause.toggle()
    print("pause: \(pause)")
    }
    Button("Switch", systemImage: "arrow.up.arrow.down") {
    pause.toggle()
    print("pause: \(pause)")
    }
    }
    }

    import PlaygroundSupport
    PlaygroundPage.current.setLiveView(ContentView())
第11課 物理模擬(6-11a)

上一課介紹的動作與動畫,讓3D模型可以動起來,除了空間中的位移、旋轉、縮放之外,也可讓某些屬性(如顏色、透明度…)也加以變化。在動畫過程中,我們可以控制變化速率,或讓整個動畫暫停、繼續、停止。

本課的物理模擬是動畫的進一步應用,藉由牛頓運動三定律,自動計算物體的速度,就可模擬現實中的物體碰撞、運動以及力場。例如,地球的重力加速度約為 9.8m/sec²,只要具有質量的物體(在不考慮空氣阻力下),就會以每秒增加 9.8m/sec 的速度往下掉。

本節先來觀察重力加速度的作用。

物理模擬只適用於模型個體,想加入物理模擬的場域中,最少需要兩個額外元件:

1. 物理本體元件(PhysicsBodyComponent)
2. 碰撞偵測元件(CollisionComponent)

啟用這兩個元件的最基本程式碼如下:
模型.physicsBody = .init(mode: .dynamic)
模型.generateCollisionShapes(recursive: false)
第1行會加入物理本體元件(PhysicsBodyComponent),負責牛頓力學的運算,有3種模式:

1. dynamic — 動態本體,具有質量(預設值1Kg)與碰撞偵測,會受到外力影響
2. static — 靜態本體,不具質量,不受外力影響,可與動態本體碰撞
3. kinematic — 可動本體,不具質量,可與動態本體碰撞,運動速度與旋轉速度完全由程式手動控制,如人物爬樓梯、魚蝦游泳、電動門窗等

第2行會加入碰撞偵測元件(CollisionComponent),負責偵測兩個物理本體是否發生碰撞 — 也就是模型網格是否有重疊,過去(SceneKit)這是由外框(BoundingBox)範圍決定,現在 RealityKit 改用網格外形(CollisionShapes)比對,偵測範圍更精確。

要發生碰撞,必須至少一方為動態本體,兩個可動本體之間、或可動本體與靜態主體之間並不會產生碰撞。一旦碰撞,模型個體就轉變成「剛性物體」(rigid body),受到外力不會變形,兩個物理本體也不會彼此穿透。

若模型由多個子個體組成,需要設定參數 recursive: true,才能完整算出外形:
模型.generateCollisionShapes(recursive: true)
用「模型.generateCollisionShapes()」產出碰撞偵測的外形,其資料類型是 ShapeResource 陣列(Shapes複數),類似模型個體的網格資源(MeshResource),差別是碰撞偵測用的外形必須是凸體(術語是Convex),不能有中空或凹陷(像6-8a 空心管、6-8b 甜甜圈)。

在這兩個元件啟用之後,動態本體就會受到重力作用(預設加速度為Y軸-9.8m/sec²),開始往下掉,而不像之前,模型個體都是漂浮在空中。

此時,整個虛擬空間就像籠罩在無形的「重力場」(gravity field)中。重力場類似上一課6-10c的定向光,在此空間任何一點,力的方向都相同(Y軸往下),大小與個體質量成正比。

不過,在實際的重力場或其他力場中,力的方向與大小可能會與空間位置有關,不同位置受到不同的力,進而造成有趣的運動效果,例如旋渦,後面課程會學到其他力場模擬。

我們在動態本體的模型下方,用 generatePlane() 做一個平面「地板」,設定為靜態本體,這樣就能承接上方墜落的模型個體:
// 6-11a 物理本體(physicsBody)
// Created by Heman Lu on 2025/05/15
// Tested with Mac mini M2 (macOS 15.5) + Swift Playground 4.6.4

import SwiftUI
import RealityKit

struct 物理模擬: View {
var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b

// 共享程式6-10b 正十二面體模型
if let 模型 = try? await 正十二面體模型(外接球半徑: 0.1) {
內容.add(模型)
模型.position.y = 0.8
模型.physicsBody = .init(mode: .dynamic)
模型.generateCollisionShapes(recursive: false)
}

let 地板 = MeshResource.generatePlane(width: 2.0, depth: 2.0)
let 地板材質 = SimpleMaterial(color: .brown, isMetallic: false)
let 地板模型 = ModelEntity(mesh: 地板, materials: [地板材質])
內容.add(地板模型)
地板模型.position.y = -1.0
地板模型.physicsBody = .init(mode: .static)
地板模型.generateCollisionShapes(recursive: false, static: true)

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
}
.realityViewCameraControls(.orbit)
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(物理模擬())

程式相當簡單,不需要設定動作、動畫等物件,只需加入兩個物理模擬的必要元件,就能模擬自由落體與反彈動作。實際效果如下:

💡註解
  1. PhysicsBody 本單元譯為物理本體,也有人譯為物理主體、物理實體、物理體。英文與 PhysicalBody 不同,PhysicalBody 指實質身體、實體;PhysicsBody 應是以物理學規則運作的個體。
  2. dynamic 與 kinematic 都有「動」的意思。從古希臘就有人研究物體的運動規則,稱為運動學(kinematics),近代物理學以數學分析力與運動的關係,稱為動力學(dynamics),如空氣動力學(aerodynamics)、量子動力學(quantum dynamics)。
  3. 上一課提到,動畫本質上是屬性的時間函數(Y軸為屬性值、X軸為時間值),變化快慢由時間曲線決定,時間曲線的斜率等於變化速率。
  4. 物理模擬中,屬性(位置)變化也是有時間曲線,只不過時間曲線是根據牛頓運動定律自動計算。本節範例的時間曲線實際上是一條二次曲線(拋物線),也就是自由落體公式 h = 1/2 gt²。
  5. 物理模擬只限於模型個體,其他像燈光或鏡頭個體,因為沒有可見的幾何模型,無法模擬。
  6. 影片中,正十二面體模型掉到平面之後,為什麼會反彈?如何調整反彈的強弱?
  7. 如果模型掉出平面之外,最深會掉到哪裡?
  8. 地板模型有沒有可能和背景(天空盒)的地面重合在一起?
  9. 影片最後,可以看到平面上的靜止模型並沒有倒影,為什麼呢?
  10. 作業1:根據影片中的執行結果,請描述與三大運動定律契合的地方。
  11. 作業2:在正十二面體與地面之間,加入一個6-8b甜甜圈(有物理模擬與沒有物理模擬兩種情況),觀察正十二面體是否能穿過甜甜圈中心。
6-11b 物理模擬預設屬性

在補充(23)元件列表中,與物理模擬相關的元件包括:
# 物件名稱 中文名稱 功能說明
38 CollisionComponent 碰撞偵測元件 偵測兩個物理模擬本體是否接觸
39 PhysicsBodyComponent 物理本體元件 要加入物理模擬的個體
40 PhysicsMotionComponent 物理運動元件 用程式手動控制運動與旋轉速度
41 ForceEffectComponent 力場元件 提供物理力場模擬效果
42 ParticleEmitterComponent 粒子噴射元件 產生粒子系統特效
43 PhysicsJointsComponent 物理關節元件 加入關節模擬
44 PhysicsSimulationComponent 客製化物理模擬元件 調整物理系統的參數(如重力加速度)

其中,碰撞偵測元件(CollisionComponent)與物理本體元件(PhysicsBodyComponent)為必要元件,運動元件(PhysicsMotionComponent)用來配合可動主體(kinematic),由程式控制運動速度與旋轉速度。

本節先來了解這三個元件所帶的屬性與方法,以熟悉物理模擬的基本功能。這三個元件分別對應模型個體的三個屬性:

1. 模型.physicsBody? — 物理本體元件
2. 模型.collision? — 碰撞偵測元件
3. 模型.physicsMotion? — 運動元件

就像之前(如6-7c)透過「模型.model?」來變更模型元件的網格(mesh)與材質(materials)一樣,這些加上 ? 的屬性都是 Optional 類型,在加入對應的元件之後才會生效。

當我們設定物理本體時,其實就是在初始化物理本體元件:
模型.physicsBody = .init(mode: .dynamic)
// 相當於:
模型.physicsBody = PhysicsBodyComponent() // 預設為動態本體
// 或
模型.components.set(PhysicsBodyComponent())
同樣的,產出模型的碰撞外形時,也是在初始化碰撞偵測元件:
模型.generateCollisionShapes(recursive: false)
// 相當於:
模型.collision = try? await CollisionComponent(shapes: [.generateConvex(from: 模型.model!.mesh)])
// 或
try? await 模型.components.set(CollisionComponent(shapes: [.generateConvex(from: 模型.model!.mesh)]))
這些元件附帶的屬性大多有預設值,從預設值可以了解預設的物理屬性,對進一步寫物理模擬程式相當重要。

以下分別列出這三個元件的屬性與預設值。

PhysicsBodyComponent (模型.physicsBody?)預設屬性
# 屬性名稱 預設值 說明
1 mode .dynamic .dynamic
.static
.kinematic
2 massProperties mass = 1Kg
inertia = [0.1, 0.1, 0.1]
centerOfMass = [0, 0, 0]
質量
慣量(單位kg/m²)
重心
3 material friction = 0.8
restitution = 0.8
model.physicsBody?.material = .generate()
4 linearDamping 0.02 移動阻尼
5 angularDamping 0.05 轉動阻尼
6 isTranslationLocked x: false
y: false
z: false
是否鎖定x/yz/軸位移
7 isRotationLocked x: false
y: false
z: false
是否鎖定x/y/z軸旋轉
8 isCCDEnabled false 是否開啟連續碰撞偵測isContinuousCollisionDetectionEnabled
9 isAffectedByGravity true 是否受重力影響
10 addForce() - 對個體加上外力
11 addTorque() - 對個體加上力矩(扭力)
12 clearForcesAndTorques() - 清除所有外力
13 applyImpulse() - 對個體加上衝量(Impulse)
14 applyLinearImpulse() - 對個體加上位移衝量(Impulse)
15 applyAngularImpulse() - 對個體加上旋轉衝量(Impulse)
16 resetPhysicsTransform() - 將物理模擬的座標變換重置為單位矩陣

CollisionComponent (模型.collision?)預設屬性
# 屬性名稱 預設值 說明
1 shapes [ ] [ShapeResource] 碰撞外形 — 偵測碰撞的空間範圍
2 mode .default .default 參與碰撞並發出事件
.trigger 僅觸發碰撞事件
.colliding 參與碰撞(不需physicsBody)
3 filter .default 對碰撞個體加以分組,預設群組為:
group: .default (== 1)
mask: .all (== 0xFFFFFFFF)
4 collisionOptions .none 碰撞事件所發布的資訊
.none
.fullContactInformation
.static
5 isStatic false 固定的碰撞外形(效能較佳)

PhysicsMotionComponent (模型.physicsMotion?)預設屬性
# 屬性名稱 預設值 說明
1 angularVelocity [0, 0, 0] 角速度/自轉速度
2 linearVelocity [0, 0, 0] 位移速度

本節的重點放在可動本體(kinematic mode)與運動元件(PhysicsMotionComponent)之間的配合。

繼續擴充上一節的範例程式,在正十二面體上方放一個靜止氣泡,在地面之下放另一個往上飄浮的氣泡。也就是增加兩個可動本體,一靜一動,程式如下:
let 氣泡 = MeshResource.generateSphere(radius: 0.2)
let 氣泡模型 = ModelEntity(mesh: 氣泡, materials: [玻璃材質])
內容.add(氣泡模型)
氣泡模型.position.y = 1.0
氣泡模型.physicsBody = .init(mode: .kinematic)
氣泡模型.generateCollisionShapes(recursive: false)

let 上升氣泡 = 氣泡模型.clone(recursive: false)
內容.add(上升氣泡)
上升氣泡.position.y = -1.2
上升氣泡.physicsMotion = PhysicsMotionComponent()
上升氣泡.physicsMotion?.linearVelocity = [0, 0.6, 0]
最後兩行就是氣泡往上飄浮的關鍵,一行加入運動元件,一行設定速度為 .linearVelocity = [0, 0.6, 0],也就是每秒往上(Y軸)移動0.6公尺。

先看一下執行結果,觀察重點在於(1)可動本體氣泡會穿透地面與另一個靜止氣泡 (2)動態本體會與任何模式的物理本體發生剛體碰撞:
以下是完整的範例程式:
// 6-11b 可動本體(kinematic mode)
// Created by Heman Lu on 2025/05/20
// Tested with Mac mini M2 (macOS 15.5) + Swift Playground 4.6.4

import SwiftUI
import RealityKit

struct 物理模擬: View {
var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b

// 共享程式6-10b 正十二面體模型
if let 模型 = try? await 正十二面體模型(外接球半徑: 0.1) {
內容.add(模型)
模型.position.y = 0.8
模型.physicsBody = .init(mode: .dynamic)
模型.generateCollisionShapes(recursive: false)
}

var 玻璃材質 = PhysicallyBasedMaterial()
玻璃材質.roughness = 0.1
玻璃材質.metallic = 0.9
玻璃材質.blending = .transparent(opacity: 0.3)

let 氣泡 = MeshResource.generateSphere(radius: 0.2)
let 氣泡模型 = ModelEntity(mesh: 氣泡, materials: [玻璃材質])
內容.add(氣泡模型)
氣泡模型.position.y = 1.0
氣泡模型.physicsBody = .init(mode: .kinematic)
氣泡模型.generateCollisionShapes(recursive: false)

let 上升氣泡 = 氣泡模型.clone(recursive: false)
內容.add(上升氣泡)
上升氣泡.position.y = -1.2
上升氣泡.physicsMotion = PhysicsMotionComponent()
上升氣泡.physicsMotion?.linearVelocity = [0, 0.6, 0]

let 地板 = MeshResource.generatePlane(width: 2.0, depth: 2.0)
let 地板材質 = SimpleMaterial(color: .brown, isMetallic: false)
let 地板模型 = ModelEntity(mesh: 地板, materials: [地板材質])
內容.add(地板模型)
地板模型.position.y = -1.0
地板模型.physicsBody = .init(mode: .static)
地板模型.generateCollisionShapes(recursive: false, static: true)

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
}
.realityViewCameraControls(.orbit)
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(物理模擬())

💡註解
  1. 可動本體(Kinematic body)也可譯為活動本體或運動本體,老實說,從名詞本身並無法充分理解此物件的特性,必須從程式的實際運作去理解。
  2. 運動元件的兩個屬性(位移速度、角速度)只對可動本體有效,對動態本體與靜態本體沒有作用。
  3. 靜態本體實際上也並非靜止不動,還是能透過座標變換(Transform)與動畫加以移動,只是在物理模擬中不受外力影響。
  4. 作業1:請將上升氣泡質量改為0.1Kg,觀察碰撞結果是否不同。
  5. 作業2:請將上方的氣泡改為靜態本體,上升氣泡仍為可動本體,觀察有何不同。
6-11c 手勢互動(InputTargetComponent)

去(2024)年更新的 RealityView 讓 iOS/iPadOS/macOS 也能像 visionOS 一樣,輕鬆寫出空間運算或AR程式,其中關鍵之一,就是能與 SwiftUI 緊密結合,尤其在手勢控制,比原先的 ARView + UIKit 好多了。

所有App遊戲都會有些物體或角色讓使用者控制,這也是遊戲之所以好玩的關鍵:讓使用者能參與互動。而SwiftUI 的優勢之一,就是易用的控制視圖與手勢 。

設想一幕遊戲場景:當看到怪物從海底漂浮上來時,用手勢釋放深水炸彈將它炸死。

此處先借用上一節模型,將正十二面體當作炸彈,一開始放在上方靜止氣泡裡面,當滑鼠或手勢點選氣泡時,釋放正十二面體,讓它往下掉,與下方漂浮上來的氣泡碰撞(碰撞後的處理,待下一節介紹)。

效果如下,為了方便觀察,將上升氣泡的速度從原來0.6m/sec降為0.3m/sec:

影片後半段,當滑鼠游標移到靜止氣泡時,會稍微變亮,這是另一個元件,「懸浮效果元件」(HoverEffectComponent)的功能,只對 macOS 有效,從影片中可看出,這個元件也會影響子個體,當游標移到正十二面體時,同樣會變亮。
氣泡模型.components.set(HoverEffectComponent())  // 游標偵測
...
氣泡模型.addChild(模型) // 將正十二面體綁到氣泡中

至於影片一開始,當游標或手勢點選靜止氣泡時,就會釋放正十二面體往下掉落,這個功能怎麼做到的呢?這是本節的重點。

第一步,在設定手勢之前,要被點選的個體須載入「輸入標定元件」(InputTargetComponent),否則手勢無效;而輸入標定元件需要碰撞偵測元件,才知道點選位置是否在個體範圍內,因此至少須載入兩個元件:
氣泡模型.components.set(InputTargetComponent())     // 手勢偵測
氣泡模型.generateCollisionShapes(recursive: false) // 碰撞偵測

第二步是設定手勢:
var 手勢: some Gesture {
TapGesture()
.targetedToAnyEntity()
.onEnded { 事件 in
print(事件.entity)
if let 炸彈 = 事件.entity.findEntity(named: "炸彈") as? ModelEntity {
炸彈.physicsBody = .init(mode: .dynamic)
炸彈.generateCollisionShapes(recursive: false)
}
}
}
最重要的是手勢要加入 .targetedToAnyEntity() 修飾語,這是為搭配 RealityView 所增加,作用是透過游標或觸控位置標定虛擬空間中的個體。

在此設定輕觸手勢(TapGesture),當手指提起(手勢結束 .onEnded)時,從標定的個體 findEntity() 搜尋 “炸彈” 個體,然後將「炸彈」設定為動態本體,啟動物理模擬,「炸彈」自然就往下掉落。

第三步別忘了對整個 RealityView 加入設定好的手勢:
RealityView { 內容 in
...
}
.gesture(手勢)
若對手勢程式還不熟悉的話,可參考第3單元3-5b

其他小部分也需要適度修改,修改後的完整範例程式如下:
// 6-11c 手勢互動(InputTargetComponent)
// Created by Heman Lu on 2025/05/22
// Tested with Mac mini M2 (macOS 15.5) + Swift Playground 4.6.4

import SwiftUI
import RealityKit

struct 物理模擬: View {
var 手勢: some Gesture {
TapGesture()
.targetedToAnyEntity()
.onEnded { 事件 in
print(事件.entity)
if let 炸彈 = 事件.entity.findEntity(named: "炸彈") as? ModelEntity {
炸彈.physicsBody = .init(mode: .dynamic)
炸彈.generateCollisionShapes(recursive: false)
}
}
}
var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b

var 玻璃材質 = PhysicallyBasedMaterial()
玻璃材質.roughness = 0.1
玻璃材質.metallic = 0.9
玻璃材質.blending = .transparent(opacity: 0.3)

let 氣泡 = MeshResource.generateSphere(radius: 0.2)
let 氣泡模型 = ModelEntity(mesh: 氣泡, materials: [玻璃材質])
氣泡模型.components.set(InputTargetComponent()) // 手勢偵測
氣泡模型.components.set(HoverEffectComponent()) // 游標偵測
內容.add(氣泡模型)
氣泡模型.position.y = 1.0
// 氣泡模型.physicsBody = .init(mode: .kinematic)
氣泡模型.generateCollisionShapes(recursive: false)

// 共享程式6-10b 正十二面體模型
if let 模型 = try? await 正十二面體模型(外接球半徑: 0.1) {
模型.name = "炸彈"
// 內容.add(模型)
氣泡模型.addChild(模型) // 將正十二面體綁到氣泡中
}

let 上升氣泡 = ModelEntity(mesh: 氣泡, materials: [玻璃材質])
內容.add(上升氣泡)
上升氣泡.position.y = -1.2
上升氣泡.physicsBody = .init(mode: .kinematic)
上升氣泡.generateCollisionShapes(recursive: false)
上升氣泡.physicsMotion = PhysicsMotionComponent()
上升氣泡.physicsMotion?.linearVelocity = [0, 0.3, 0]

let 地板 = MeshResource.generatePlane(width: 2.0, depth: 2.0)
let 地板材質 = SimpleMaterial(color: .brown, isMetallic: false)
let 地板模型 = ModelEntity(mesh: 地板, materials: [地板材質])
內容.add(地板模型)
地板模型.position.y = -1.0
地板模型.physicsBody = .init(mode: .static)
地板模型.generateCollisionShapes(recursive: false, static: true)

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
}
.realityViewCameraControls(.orbit)
.gesture(手勢)
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(物理模擬())

💡註解
  1. 當手勢點選螢幕時,點選位置是 SwiftUI 的螢幕座標(2D),但是模型個體的碰撞外形是 RealityKit 的3D座標,兩者之間如何轉換呢?
  2. 未來學到AR程式後,或許可直接對著鏡頭,用手指隔空控制,感覺會很酷。
  3. 作業1:請修改程式,可重置上升氣泡(當手勢點選到上升氣泡時,將其位置還原到原來地板之下,重新往上飄浮)。
  4. 作業2:請修改程式,可重置炸彈(當炸彈釋放之後,若再點按靜止氣泡,則將炸彈還原回到氣泡內,等候釋放)。範例如下:
補充(24) ARView + UIKit 手勢

若有人好奇,在 RealityView 之前是如何用 ARView + UIKit 手勢,以下就針對 6-11c 加以改寫,以便比較兩者差異。

仿照上半單元補充(11),透過 UIViewRepresentable 將 ARView 轉換成 SwiftUI 視圖,但手勢部分無法轉換,只能用 UIKit 手勢 UITapGestureRecognizer 來設定輕點手勢,手勢的處理函式指定為「#selector(視圖.點選)」:
struct 物理模擬: UIViewRepresentable {
func makeUIView(context: Context) -> ARView {
var 視圖 = ARView()
...
let 輕點手勢 = UITapGestureRecognizer(
target: 視圖,
action: #selector(視圖.點選))
視圖.addGestureRecognizer(輕點手勢)
...
}
func updateUIView(_ uiView: ARView, context: Context) { }
}

「視圖.點選」函式的定義如下,必須用 @objc func 宣告:
extension ARView {
@objc func 點選(_ sender: UITapGestureRecognizer) {
let 點選位置 = sender.location(in: self)
if let 點選個體 = self.entity(at: 點選位置) {
if let 炸彈 = 點選個體.findEntity(named: "炸彈") as? ModelEntity {
炸彈.physicsBody = .init(mode: .dynamic)
炸彈.generateCollisionShapes(recursive: false)
}
}
}
}

這其中兩個特殊語法:#selector() 與 @objc func,主要是為了 Swift 與 Objective-C 兩種語言混用,因為 UIKit 早在2008年(iPhone首次發布的隔年)發表,採用 Objective-C 語言撰寫,Swift 語言到 2014年才正式發布,兩者在編譯後的執行期配置(如函式呼叫的參數堆疊)稍有不同,須藉由 @objc 轉換。

凡是從 UIKit 物件跳到我們寫的 Swift 函式,就需要用 @objc 宣告。

因此,用 RealityView + SwiftUI 另一個好處,就是可以完全擺脫 Objective-C (對語法的)影響。在範例 6-11c,手勢加一個匿名函式,就能輕鬆取代 #selector 與 @objc 的作用,讓語法顯得更平易近人。
// 6-11c
var 手勢: some Gesture {
TapGesture()
.targetedToAnyEntity()
.onEnded { 事件 in
print(事件.entity)
if let 炸彈 = 事件.entity.findEntity(named: "炸彈") as? ModelEntity {
炸彈.physicsBody = .init(mode: .dynamic)
炸彈.generateCollisionShapes(recursive: false)
}
}
}
這也是為什麼本單元課程在 WWDC 2024 發布之後,等待了半年多,堅持用 RealityView 的原因。

以下複製6-11c的功能,用 ARView + UITapGestureRecognizer 改寫的版本。因為 ARView 與 RealityView 同屬於 RealityKit,因此模型、材質、天空盒等程式碼都可沿用:
// 補充(24): ARView + UIKit 手勢
// Revised by Heman, 2025/05/24
// Based on 6-11c 手勢互動

import RealityKit
import SwiftUI

struct 物理模擬: UIViewRepresentable {
func makeUIView(context: Context) -> ARView {
var 視圖 = ARView()
let 錨點 = AnchorEntity()
錨點.addChild(座標軸()) // 共享程式6-6b

var 玻璃材質 = PhysicallyBasedMaterial()
玻璃材質.roughness = 0.1
玻璃材質.metallic = 0.9
玻璃材質.blending = .transparent(opacity: 0.3)

let 氣泡 = MeshResource.generateSphere(radius: 0.2)
let 氣泡模型 = ModelEntity(mesh: 氣泡, materials: [玻璃材質])
氣泡模型.components.set(InputTargetComponent()) // 手勢偵測
氣泡模型.components.set(HoverEffectComponent()) // 游標偵測
錨點.addChild(氣泡模型)
氣泡模型.position.y = 1.0
氣泡模型.generateCollisionShapes(recursive: false)

// UIKit 手勢
let 輕點手勢 = UITapGestureRecognizer(
target: 視圖,
action: #selector(視圖.點選))
視圖.addGestureRecognizer(輕點手勢)

// 共享程式6-10b 正十二面體模型
Task {
if let 模型 = try? await 正十二面體模型(外接球半徑: 0.1) {
模型.name = "炸彈"
// 錨點.addChild(模型)
氣泡模型.addChild(模型) // 將正十二面體綁到氣泡中
}
}

let 上升氣泡 = ModelEntity(mesh: 氣泡, materials: [玻璃材質])
錨點.addChild(上升氣泡)
上升氣泡.position.y = -1.2
上升氣泡.physicsBody = .init(mode: .kinematic)
上升氣泡.generateCollisionShapes(recursive: false)
上升氣泡.physicsMotion = PhysicsMotionComponent()
上升氣泡.physicsMotion?.linearVelocity = [0, 0.3, 0]

let 地板 = MeshResource.generatePlane(width: 2.0, depth: 2.0)
let 地板材質 = SimpleMaterial(color: .brown, isMetallic: false)
let 地板模型 = ModelEntity(mesh: 地板, materials: [地板材質])
錨點.addChild(地板模型)
地板模型.position.y = -1.0
地板模型.physicsBody = .init(mode: .static)
地板模型.generateCollisionShapes(recursive: false, static: true)

Task {
if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
視圖.environment.background = .skybox(天空盒)
視圖.environment.lighting.resource = 天空盒
}
}

視圖.scene.addAnchor(錨點)
return 視圖
}
func updateUIView(_ uiView: ARView, context: Context) {
// keep blank
}
}

extension ARView {
@objc func 點選(_ sender: UITapGestureRecognizer) {
print("點選():", sender.debugDescription)
let 點選位置 = sender.location(in: self)
if let 點選個體 = self.entity(at: 點選位置) {
print("找到了:\(點選個體)")
if let 炸彈 = 點選個體.findEntity(named: "炸彈") as? ModelEntity {
炸彈.physicsBody = .init(mode: .dynamic)
炸彈.generateCollisionShapes(recursive: false)
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(物理模擬())
ARView 沒有類似 .realityViewCameraControls(.orbit) 的功能,無法隨意轉動視角。

執行結果如下:

若出現錯誤,可能是缺少必要的共享程式,相關檔案如下:

1. 座標軸 — 共享程式6-6b
2. 正十二面體模型 — 共享程式6-10b
3. 正十二面體 — 共享程式6-9c
4. 天空盒「威尼斯清晨.realityenv」 — 第7課6-7c

💡註解
  1. SwiftUI 的手勢功能,底層其實用的還是 UIKit 手勢,只是重新包裝,在語法上更友善、更容易使用。
  2. 從這兩個版本的比較可以看出,用 Objective-C 寫的 AppKit 與 UIKit,到完全用 Swift 開發的 SwiftUI,其實是一個逐步演化的過程,舊框架雖被取代,但不會消失,而是被包容、改良而成新框架。
  3. 要完全擺脫 Objective-C 影響是不可能的,也沒必要。根據此篇文章統計,iOS 18 作業系統本身大約有58%是用Objective-C寫的,這些核心程式幾乎不可能改寫。Source: https://blog.timac.org/2024/1208-state-of-swift-and-swiftui-ios18/
  4. 在程式語言的策略上,Apple 陣營比 Google 好太多了。Apple 的開發策略非常明確,從 Objective-C 過渡到 Swift 的大工程也還算順利(背後功臣是 Chris Lattner);Google 至今仍沒有一個主導的官方程式語言,除了開發 Android 用別人的 Java (Java 最早由 Sun 發表,後來賣給 Oracle),內部還另行發展 Go, Dart/Flute 等語言,加上第三方 Kotlin, Typescript, React Native 等各立山頭,簡直是一團混戰。
  • 5
內文搜尋
X
評分
評分
複製連結
Mobile01提醒您
您目前瀏覽的是行動版網頁
是否切換到電腦版網頁呢?