從第3單元開始,就介紹過什麼是非同步事件(簡稱「事件」),舉凡網路連線(URLSession)、互動手勢(Gesture)、按鍵(Button)…等,都會觸發非同步事件,當事件發生時,會回頭呼叫我們寫的函式(或匿名函式)— 稱為事件處理函式(event handler)。
事件處理是每位程式設計師必須熟悉的重要觀念。
在 Swift 中,事件處理大多採用「發布者-訂閱者」(Publisher-Subscriber)溝通模式,要先有程式在事件發生時發布訊息,我們才能訂閱。在本課程中,發布者大多是作業系統或框架底層,我們寫的程式作為訂閱者。
在前一課6-10e其實就用過事件處理:
// 6-10e 動畫控制器這行程式會訂閱公轉動作(OrbitEntityAction)的暫停事件,RealityKit 框架是發布者,當動作暫停時,會發布資料(稱為訊息或通知)給每個訂閱者。此例中,資料會以參數「事件」(名稱可自訂)帶進我們所寫的匿名函式 — 即事件處理函式。
OrbitEntityAction.subscribe(to: .paused) { 事件 in
...
}
上一節6-11c手勢互動也同樣用到事件處理:
// 6-11c事件處理的應用很廣,到處都看得到。
var 手勢: some Gesture {
TapGesture()
.targetedToAnyEntity()
.onEnded { 事件 in
...
}
}
RealityKit 提供哪些事件可供訂閱呢?根據原廠文件,包括以下幾大類別:
# | 事件類別 | 說明 | 相關事件 |
1 | AccessibilityEvents | 無障礙輔助事件 | 略 |
2 | AnimationEvents | 動畫事件 | .PlaybackCompleted 播放結束 .PlaybackLooped 循環播放 .PlaybackStarted 開始播放 .PlaybackTerminated 播放終止 .SkeletalPoseUpdateComplete |
3 | AudioEvents | 音效播放事件 | .PlaybackCompleted 播放結束 |
4 | CollisionEvents | 碰撞事件 | .Began 碰撞開始 .Ended 碰撞結束 .Updated 持續碰撞 |
5 | ComponentEvents | 元件事件 | .DidActivate 元件已啟用 .DidAdd 元件已加入 .DidChange 元件已變更 .WillDeactivate 元件將失效 .WillRemove 元件將移除 |
6 | PhysicsSimulationEvents | 物理模擬事件 | .DidSimulate 模擬已開始 .WillSimulate 模擬即將開始 |
7 | SceneEvents | 場景事件 | .AnchoredStateChanged 錨點變更 .DidActivateEntity 個體已啟用 .DidAddEntity 個體已加入 .DidReparentEntity 變更父個體 .Update 場景更新 .WillDeactivateEntity 個體將失效 .WillRemoveEntity 個體將移除 |
8 | SynchronizationEvents | 網路同步事件 | 略 |
9 | VideoPlayerEvents | 影片播放事件 | 略 |
對物理模擬來說,最常用的是碰撞事件,共有三個,分別是碰撞開始、碰撞結束、持續碰撞中,本節將利用碰撞開始事件(CollisionEvents.Began),在炸彈與上升氣泡碰撞時發出響聲,並讓氣泡消失。
這段事件處理的程式碼如下:
struct 物理模擬: View {其中最關鍵的一行,當然就是訂閱(subscribe)以及尾隨的事件處理函式:
@State var 事件簿: [EventSubscription] = [] // 維持事件有效
var body: some View {
RealityView { 內容 in
...
// 碰撞事件處理
let 玻璃聲 = try? await AudioFileResource(named: "6-11d.m4a")
let 碰撞事件 = 內容.subscribe(to: CollisionEvents.Began.self, on: 上升氣泡) { 事件 in
print("碰撞雙方:A-\(事件.entityA.name) B-\(事件.entityB.name)")
if let 音效 = 玻璃聲 {
事件.entityB.playAudio(音效)
}
事件.entityA.isEnabled = false // 讓氣泡消失(暫時隱藏)
}
事件簿.append(碰撞事件) //「碰撞事件」生命較短,「事件簿」較長命
...
}
}
}
let 碰撞事件 = 內容.subscribe(to: CollisionEvents.Began.self, on: 上升氣泡) { 事件 in這裡的「內容」包含 RealityView 整個場景,若沒有指定「on: 上升氣泡」,則場景內任何碰撞事件(包括炸彈碰到地面反彈)都會呼叫後面的匿名函式,造成許多雜音。
// 匿名函式:每當事件發生時執行一次
}
每一種事件帶進來的參數「事件」內容都不太一樣,對於 CollisionEvents.Began 而言,事件屬性包括:
1. .entityA — 碰撞的一方(甲方)
2. .entityB — 碰撞的另一方(乙方)
3. .impulse — 碰撞的衝量(撞擊力)
4. .impulseDirection — 撞擊方向(單位向量)
5. .position — 碰撞點座標(全域坐標)
6. .contacts — 雙方詳細資料(需設定.fullContactInformation)
7. .penetrationDistance — 撞擊距離
因為我們指定「on: 上升氣泡」,因此甲方(entityA)就一定是「上升氣泡」,乙方(entityB)則是炸彈。在匿名函式中(也就是碰撞發生時),令甲方(上升氣泡)消失、乙方(炸彈)發出撞擊聲。
末尾還有一行特殊用途的程式碼:
事件簿.append(碰撞事件) //「碰撞事件」生命較短,「事件簿」較長命如果少了這行程式碼,事件處理函式就不會被呼叫,原因跟變數(或常數)的生命週期有關。
還記得 RealityView 的第一個匿名函式(make: { } )是非同步模式(async)且在背景支線執行,當執行到最後一行返回主線之後,在此 { } 內定義的變數/常數就會全部失效,此時事件處理函式可能還未執行過(因為要等事件發生才會執行),就已失效。
因此,透過狀態變數「事件簿」將「碰撞事件」(以及處理函式)複製出來,保存在生命週期較長的陣列中,就可維持事件處理函式的有效性。
@State var 事件簿: [EventSubscription] = [] // 維持事件有效這類問題曾經出現在好幾個地方(最早應該是3-9c),萬一漏掉,不會有任何錯誤訊息,因此很不好除錯,要特別注意。
完整程式碼如下,除了事件處理之外,另外加上「場景還原」的功能,請自行參考:
// 6-11d 碰撞處理
// Created by Heman Lu on 2025/05/27
// Tested with Mac mini M2 (macOS 15.5) + Swift Playground 4.6.4
import SwiftUI
import RealityKit
struct 物理模擬: View {
@State var 事件簿: [EventSubscription] = [] // 維持事件有效
@State var 場景還原 = true // 輕按「靜止氣泡」可釋放炸彈或還原場景
var 手勢: some Gesture {
TapGesture()
.targetedToAnyEntity()
.onEnded { 事件 in
// print(事件.entity)
// 找到氣泡內的炸彈
if let 炸彈 = 事件.entity.findEntity(named: "炸彈") as? ModelEntity {
場景還原.toggle() // 觸發 update:
if 場景還原 {
炸彈.position = .zero
炸彈.physicsBody = nil
} else { // 釋放炸彈
炸彈.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: [玻璃材質])
氣泡模型.name = "靜止氣泡"
氣泡模型.components.set(InputTargetComponent()) // 手勢偵測
氣泡模型.components.set(HoverEffectComponent()) // 游標偵測
內容.add(氣泡模型)
氣泡模型.position.y = 1.0
氣泡模型.generateCollisionShapes(recursive: false)
// 共享程式6-10b 正十二面體模型
if let 模型 = try? await 正十二面體模型(外接球半徑: 0.1) {
模型.name = "炸彈"
// 內容.add(模型)
氣泡模型.addChild(模型) // 將正十二面體綁到氣泡中
}
let 上升氣泡 = ModelEntity(mesh: 氣泡, materials: [玻璃材質])
上升氣泡.name = "上升氣泡"
內容.add(上升氣泡)
上升氣泡.position.y = -1.2
上升氣泡.physicsBody = .init(mode: .kinematic)
上升氣泡.generateCollisionShapes(recursive: false)
上升氣泡.physicsMotion = PhysicsMotionComponent()
上升氣泡.physicsMotion?.linearVelocity = [0, 0.3, 0]
// 碰撞事件處理
let 玻璃聲 = try? await AudioFileResource(named: "6-11d.m4a")
let 碰撞事件 = 內容.subscribe(to: CollisionEvents.Began.self, on: 上升氣泡) { 事件 in
print("碰撞雙方:A-\(事件.entityA.name) B-\(事件.entityB.name)")
if let 音效 = 玻璃聲 {
事件.entityB.playAudio(音效)
}
事件.entityA.isEnabled = false // 讓氣泡消失(暫時隱藏)
}
事件簿.append(碰撞事件) //「碰撞事件」生命較短,「事件簿」較長命
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(天空盒)
}
} update: { 內容 in
print("Update: 場景還原(\(場景還原))")
if 場景還原 {
for 個體 in 內容.entities where 個體.name == "上升氣泡" {
個體.isEnabled = true // 恢復上升氣泡
個體.position.y = -1.2 // 還原初始位置
}
}
}
.realityViewCameraControls(.orbit)
.gesture(手勢)
}
}
import PlaygroundSupport
PlaygroundPage.current.setLiveView(物理模擬())
執行結果影片如下,加了碰撞音效,請記得打開喇叭,同時觀察場景還原的效果:
碰撞音效來源,下載後用QuickTime Player播放,然後在「檔案」→「輸出為」→「只限音訊」→另存為”6-11d.m4a”,再匯入Swift Playground。
💡註解
- 我們當然也可以寫發布者(Publisher)程式,不過這屬於進階應用,不在本系列基礎課程範圍內。
- RealityKit 事件的發布者與訂閱者,背後採用 Combine 框架(2019年發表),而 Combine 框架是根據觀察者模式(Observer Pattern)所設計。若要寫發布者程式,最好先了解 Combine 框架。
- 作業1:請給正十二面體加上地面陰影元件(GroundingShadowComponent),以投射影子。
- 作業2:網路搜尋「免費音效下載」,找個合適的音效,替代”6-11d.m4a”。