• 6

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

6-11d 碰撞處理

從第3單元開始,就介紹過什麼是非同步事件(簡稱「事件」),舉凡網路連線(URLSession)、互動手勢(Gesture)、按鍵(Button)…等,都會觸發非同步事件,當事件發生時,會回頭呼叫我們寫的函式(或匿名函式)— 稱為事件處理函式(event handler)。

事件處理是每位程式設計師必須熟悉的重要觀念。

在 Swift 中,事件處理大多採用「發布者-訂閱者」(Publisher-Subscriber)溝通模式,要先有程式在事件發生時發布訊息,我們才能訂閱。在本課程中,發布者大多是作業系統或框架底層,我們寫的程式作為訂閱者。

在前一課6-10e其實就用過事件處理:
// 6-10e 動畫控制器
OrbitEntityAction.subscribe(to: .paused) { 事件 in
...
}
這行程式會訂閱公轉動作(OrbitEntityAction)的暫停事件,RealityKit 框架是發布者,當動作暫停時,會發布資料(稱為訊息或通知)給每個訂閱者。此例中,資料會以參數「事件」(名稱可自訂)帶進我們所寫的匿名函式 — 即事件處理函式。

上一節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 影片播放事件
這些類別表明在什麼時間點,可以插入我們寫的程式碼,例如「場景更新」(SceneEvents.Update)預設每1/60秒更新一次,每次就會呼叫一次事件處理函式,用得好的話,可以給畫面帶來驚奇的效果。

對物理模擬來說,最常用的是碰撞事件,共有三個,分別是碰撞開始、碰撞結束、持續碰撞中,本節將利用碰撞開始事件(CollisionEvents.Began),在炸彈與上升氣泡碰撞時發出響聲,並讓氣泡消失。

這段事件處理的程式碼如下:
struct 物理模擬: View {
@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(碰撞事件) //「碰撞事件」生命較短,「事件簿」較長命
...
}
}
}
其中最關鍵的一行,當然就是訂閱(subscribe)以及尾隨的事件處理函式:
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。

💡註解
  1. 我們當然也可以寫發布者(Publisher)程式,不過這屬於進階應用,不在本系列基礎課程範圍內。
  2. RealityKit 事件的發布者與訂閱者,背後採用 Combine 框架(2019年發表),而 Combine 框架是根據觀察者模式(Observer Pattern)所設計。若要寫發布者程式,最好先了解 Combine 框架。
  3. 作業1:請給正十二面體加上地面陰影元件(GroundingShadowComponent),以投射影子。
  4. 作業2:網路搜尋「免費音效下載」,找個合適的音效,替代”6-11d.m4a”。
6-11e 氣泡戳戳樂

本課所學習的物理模擬,加上手勢互動與碰撞處理,可以應用在很多地方,也足以做出很好玩的3D遊戲。

筆者利用端午節假期,寫了一個「氣泡戳戳樂」遊戲:設想從房間地板下放出 n 個上升氣泡,每個氣泡的初始位置與上升速度都不太一樣,戳到一個氣泡可得2分,若讓氣泡上升到天花板就扣1分。

為了讓遊戲更好玩,在其中一個氣泡藏有寶物(正十二面體),戳到可多得10分,不過上升速度會比較快,而且隨機出現。

Mac 可以用滑鼠玩,不過,若用 iPad 或 iPhone 手指觸控應該更好玩,甚至以後可改為 AR 遊戲,或以手把控制的射擊遊戲。

以下是在 Mac mini M2 上錄影,遊戲過程相當順暢,第一次20個氣泡,還算容易,第二次改100個氣泡,難度變很高。記得開啟喇叭:

程式用到三個音效,分別在得分、扣分、獲得寶物時,音效來源網址附在程式碼之中,下載後同樣用 QuickTime Player 輸出成壓縮格式 .m4a,再匯入 Swift Playground,會比原來 .wav 檔案小很多。

戳到氣泡時得2分,是利用6-11c手勢互動;氣泡上升到天花板扣1分,則是利用6-11d 碰撞處理;戳到寶物時,讓寶物掉下來,同樣沿用6-11c手勢互動。

這裡比較特別的地方,在於天花板要設定為動態本體(dynamic),才能和可動本體(kinematic)的氣泡發生碰撞。原先設想天花板為靜態本體(static),則無法與氣泡觸發碰撞事件。

天花板雖然設為動態本體,但又必須讓它固定不動,怎麼辦呢?只好解除重力的影響,讓它飄浮在固定位置,但這樣一來,就會被氣球往上頂,所以還要鎖住 Y 軸位移以及三軸旋轉,才能保持不動:
// 天花板設定為動態(.dynamic)才能與氣泡(.kinematic)碰撞
天花板模型.physicsBody = .init(mode: .dynamic)
天花板模型.physicsBody?.isAffectedByGravity = false
天花板模型.physicsBody?.isTranslationLocked.y = true
天花板模型.physicsBody?.isRotationLocked = (x: true, y: true, z: true)

那為什麼不將天花板設為靜態本體,而將氣泡改為動態本體呢?因為這樣一來,氣泡的動作就只能靠外力影響,無法人為控制上升速度。下一課會學習「力場」,可以更進一步熟悉動態本體的控制,或許就能想到不同的做法。

另外一個比較麻煩之處,在於如何讓寶物掉下來。原先,寶物(正十二面體)綁在某顆上升氣泡裡面,作為氣泡.addChild()的子個體,當戳到此氣泡時,在讓氣泡消失之前,我們要先解除父子關係,否則寶物會隨氣泡一起消失。

一開始筆者寫的程式碼如下:
// 讓寶物掉落地面(有問題)
寶物.removeFromParent()
寶物.physicsBody = .init(mode: .dynamic)
寶物.generateCollisionShapes(recursive: false)
結果一直看不到寶物掉下來,經過一天多的偵錯,最後才發現忘了將寶物重新加入 RealityView 的內容之中。

最後藉由 setParent() 重新設定父子關係,將寶物改由祖父(氣泡.parent)認養,並且保持原先所在位置(preservingWorldTransform: true),才解決這個問題:
// 讓寶物掉落地面(正確)
寶物.setParent(氣泡.parent, preservingWorldTransform: true)
寶物.physicsBody = .init(mode: .dynamic)
寶物.generateCollisionShapes(recursive: false)
真是關鍵的一行。

完整主程式列表如下(用到共享程式6-6b, 6-9c, 6-10b,請參考前面課程):
// 6-11e 氣泡戳戳樂
// Created by Heman Lu on 2025/05/31
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

struct 物理模擬: View {
let 氣泡數 = 20
var 隨機數: Float { .random(in: -1.0 ..< 1.0) }
@State var 事件簿: [EventSubscription] = [] // 維持事件有效
@State var 音效1: AudioResource? = nil // 碰到天花板失1分
@State var 音效2: AudioResource? = nil // 戳破氣泡得2分
@State var 音效3: AudioResource? = nil // 獲得寶物+10分
@State var 得分: Int = 0
@State var 計數器: Int = 0
@State var 遊戲結束 = false

var 手勢: some Gesture {
TapGesture()
.targetedToAnyEntity()
.onEnded { 事件 in
if 事件.entity.name == "氣泡", // 點到氣泡才算分
let 氣泡 = 事件.entity as? ModelEntity {
print("戳到了", 事件.entity.name, 計數器)
if let 音效2 { 氣泡.playAudio(音效2) }
得分 += 2
計數器 += 1
if 計數器 == 氣泡數 { 遊戲結束 = true }
if let 寶物 = 氣泡.findEntity(named: "寶物") as? ModelEntity {
print("找到寶物了,目前分數\(得分)")
if let 音效3 { 寶物.playAudio(音效3) }
得分 += 10
// 讓寶物掉落地面
寶物.setParent(氣泡.parent, preservingWorldTransform: true)
寶物.physicsBody = .init(mode: .dynamic)
寶物.generateCollisionShapes(recursive: false)
print("寶物加分,目前分數\(得分)")
}
// 氣泡.isEnabled = false // 若 Disabled 則無法播放音效
氣泡.physicsMotion = nil // 解除運動元件
氣泡.physicsBody = nil
氣泡.scale = .zero
}
}
}
var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b

// 音效1來源 https://assets.mixkit.co/active_storage/sfx/2936/2936.wav
音效1 = try? await AudioFileResource(named: "6-11d.m4a")
// 音效2來源 https://assets.mixkit.co/active_storage/sfx/213/213.wav
音效2 = try? await AudioFileResource(named: "6-11e.m4a")
// 音效3來源 https://assets.mixkit.co/active_storage/sfx/2285/2285.wav
音效3 = try? await AudioFileResource(named: "6-11e2.m4a")

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

// 氣泡模型母版,設定為可動(.kinematic),運動速度才能隨機變化
let 氣泡 = MeshResource.generateSphere(radius: 0.1)
let 氣泡模型 = ModelEntity(mesh: 氣泡, materials: [玻璃材質])
氣泡模型.name = "氣泡"
氣泡模型.physicsBody = .init(mode: .kinematic)
氣泡模型.generateCollisionShapes(recursive: false)
氣泡模型.components.set(InputTargetComponent()) // 手勢偵測
氣泡模型.physicsMotion = PhysicsMotionComponent()

// 加入100個上升氣泡,位置、速度隨機變化
let 隨機選擇 = Int.random(in: 0 ..< 氣泡數) // 選一個氣泡藏寶物
for i in 0 ..< 氣泡數 {
let 上升氣泡 = 氣泡模型.clone(recursive: false)
上升氣泡.position = simd_float3( // 隨機位置
x: 隨機數,
y: 隨機數 * 5.0 - 7.5,
z: 隨機數)
let 上升速度: Float = 0.2 + abs(隨機數) * 0.7
上升氣泡.physicsMotion?.linearVelocity = [0, 上升速度, 0]

if i == 隨機選擇, // 藏入寶物
let 寶物 = try? await 正十二面體模型(外接球半徑: 0.08) {
print("i = \(隨機選擇)")
寶物.name = "寶物"
上升氣泡.addChild(寶物)
上升氣泡.position.y = 隨機數 * 5.0 - 10.0
上升氣泡.physicsMotion?.linearVelocity = [0, 0.7, 0]
}
內容.add(上升氣泡)
}

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

// 天花板設定為動態(.dynamic)才能與氣泡(.kinematic)碰撞
let 天花板模型 = 地板模型.clone(recursive: false)
天花板模型.name = "天花板"
天花板模型.position.y = 2.0
// 天花板模型.orientation = .init(angle: .pi, axis: [1, 0, 0])
天花板模型.physicsBody = .init(mode: .dynamic)
天花板模型.physicsBody?.isAffectedByGravity = false
天花板模型.physicsBody?.isTranslationLocked.y = true
天花板模型.physicsBody?.isRotationLocked = (x: true, y: true, z: true)
天花板模型.generateCollisionShapes(recursive: false, static: true)
內容.add(天花板模型)

// 當上升氣泡碰到天花板:氣泡消失、發出撞擊聲、扣1分
let 碰撞事件 = 內容.subscribe(to: CollisionEvents.Began.self, on: 天花板模型) { 事件 in
print(事件.entityA.name, 事件.entityB.name, 計數器)
if let 音效1 { 事件.entityA.playAudio(音效1) }
if 事件.entityB.name == "氣泡" {
事件.entityB.isEnabled = false
得分 -= 1
計數器 += 1
if 計數器 == 氣泡數 { 遊戲結束 = true }
}
}
事件簿.append(碰撞事件)

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
}
.realityViewCameraControls(.orbit)
.gesture(手勢)
.overlay(alignment: .topTrailing) { // 在右上角顯示分數
顯示分數(分數: 得分, 剩餘: 氣泡數 - 計數器)
}
.overlay { // 在正中央顯示遊戲結束
if 遊戲結束 {
Text("Game Over.")
.font(.system(size: 80))
.foregroundStyle(.red)
}
}
}
}

struct 顯示分數: View {
let 分數: Int
let 剩餘: Int
var body: some View {
VStack(alignment: .trailing) {
Text("\(分數)")
.font(.system(size: 64))
.foregroundStyle(.blue)
.shadow(color: .yellow, radius: 5, x: 0, y: 0)
.blur(radius: 2)
.padding()
Text(剩餘 < 0 ? "0" : "\(剩餘) left")
.font(.title)
.foregroundStyle(.gray)
.padding()
}
}
}

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

💡註解
  1. 這個遊戲可視為一個MVP (Minimum Viable Product) 或 POC (Proof of Concept),所謂 MVP 是指最小可行產品,POC 則是概念原型(通常比MVP更簡略),兩種都是在設計之初,用來判斷這個產品或遊戲是否有潛力、好不好玩、值不值得繼續投入的做法。
  2. 簡單地說,如果試用者覺得好玩,那就繼續投入,開發成完整的App。如果反應不佳,那就改弦易轍,重新設計。
  3. 目前這個遊戲還有很多地方需改善,例如一開始就產出100個氣泡隱藏在地板下,速度比較快的會一起湧出來,造成開頭氣泡太多,根本來不及戳,而最後剩下慢吞吞的幾個,等好久才結束。
  4. 作業1:用前面學過的3D模型,例如正四面體、多角柱體、空心管、甜甜圈、花瓶等,加入第2或第3個寶物。
  5. 作業2:在遊戲結束時,加入「重新開始」的按鈕。
第12課 粒子系統(Particle System)

上一課「物理模擬」可視為動作動畫在牛頓力學下的應用,本課「粒子系統」則是物理模擬的進階版,用來仿造大自然中最難模擬的現象,例如雲霧、火焰、爆炸…等,這些現象大多無法單純用牛頓力學計算,曾經是電腦繪圖的一大挑戰,卻也是最迷人的部分。

筆者還記得30年前第一次玩「迷霧之島」(Myst)遊戲時,光看那如夢似幻的場景,就令人陶醉其中。迷霧之島可能是最早利用「粒子系統」的遊戲之一,不過,粒子系統最初的應用,並非遊戲,而是電影特效。

粒子系統最早在1983年,由皮克斯(Pixar)前身盧卡斯製片公司(Lucasfilm)的電腦動畫部門所發表,用來製作《星艦迷航記2》(Star Trek II)中的星球爆炸場面(用200個粒子系統、75萬粒子),成功以電腦特效取代危險的實質炸藥。

經過40多年的發展,粒子系統已普遍應用在電影與遊戲之中,規模宏大的爆炸場面幾乎都是電腦特效所製作。

RealityKit 直到去(2024)年才引入粒子系統,主要元件為「粒子發射器元件」(ParticleEmitterComponent),目前提供6種內建特效:

1. 煙火(fireworks)
2. 撞擊(impact)
3. 魔法(magic)
4. 下雨(rain)
5. 下雪(snow)
6. 火花(sparks)

基本用法相當簡單,因為所有屬性都有預設值,因此只要寫 ParticleEmitterComponent() 即可初始化元件,然後將元件加入個體就能生效。

內建的6種特效是類型屬性,同樣都是 ParticleEmitterComponent 元件,用法如下:
let 預設 = ParticleEmitterComponent()
let 煙火 = ParticleEmitterComponent.Presets.fireworks
let 魔法 = ParticleEmitterComponent.Presets.magic
let 撞擊 = ParticleEmitterComponent.Presets.impact
let 下雨 = ParticleEmitterComponent.Presets.rain
let 下雪 = ParticleEmitterComponent.Presets.snow
let 火花 = ParticleEmitterComponent.Presets.sparks
//...
模型.components.set(煙火)
本節先大略試用這幾個預設元件,下一節再仔細調整。

先來觀察執行的效果:
程式裡面我們用巧克力甜甜圈(參考6-8b)當範例,加了一個來回位移的動畫(參考6-10d),可以看得出來,粒子特效也會跟著移動。

影片一開始,下排左起是預設、煙火、魔法、撞擊,上排左起是下雨、下雪、火花。

「預設」的效果是上升霧氣或火焰;「煙火」、「火花」效果很容易看出來;「下雨」則有些模糊;「下雪」有雪花飄落的感覺;「魔法」若配合移動的魔法棒或手指,會更有趣;「撞擊」顯然是物體掉落撞到地面的效果,掀起的煙塵很細緻。

可惜規模有點小,下一節試著來調整屬性,讓效果更突出。

本節完整程式如下:
// 6-12a 粒子系統
// Created by Heman Lu on 2025/06/03
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

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

let 預設 = ParticleEmitterComponent()
let 煙火 = ParticleEmitterComponent.Presets.fireworks
let 魔法 = ParticleEmitterComponent.Presets.magic
let 撞擊 = ParticleEmitterComponent.Presets.impact
let 下雨 = ParticleEmitterComponent.Presets.rain
let 下雪 = ParticleEmitterComponent.Presets.snow
let 火花 = ParticleEmitterComponent.Presets.sparks

let 粒子系統 = [預設, 煙火, 魔法, 撞擊, 下雨, 下雪, 火花]

var 巧克力材質 = PhysicallyBasedMaterial()
巧克力材質.baseColor.tint = .brown
巧克力材質.roughness = 0.2
巧克力材質.metallic = 0.1

let 位移 = FromToByAction(by: Transform(translation: [0, 0, -1]))

do {
let 模型 = try await ModelEntity(
mesh: .製作甜甜圈(內徑: 0.1, 外徑: 0.05), // 共享程式6-8b
materials: [巧克力材質])
let 動畫 = try AnimationResource.makeActionAnimation(
for: 位移,
duration: 3.0,
bindTarget: .transform,
repeatMode: .autoReverse)

let 半數: Int = 粒子系統.count / 2
for i in 0 ..< 粒子系統.count {
let 新模型 = 模型.clone(recursive: false)
if i > 半數 {
let x = Float(i - 半數) * 0.5 - 1.0
let y = Float(0.5)
新模型.position = [x, y, 0.5]
} else {
let x = Float(i) * 0.5 - 0.75
let y = Float(-0.5)
新模型.position = [x, y, 0.5]
}
新模型.components.set(粒子系統[i])
新模型.playAnimation(動畫)
內容.add(新模型)
}
} catch {
print("有問題:\(error)")
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(粒子系統()

💡註解
  1. William T. Reeves “Particle systems—a technique for modeling a class of fuzzy objects”, SIGGRAPH '83.
  2. 在粒子系統發明之前,電影的爆破場面必須用真實炸藥(在台灣還須跟國防部申請),不但效果難以控制,危險性也很高,經常有演員被意外炸傷的新聞。
6-12b 修改粒子屬性

若仔細觀察 RealityKit 內建的6種特效(煙火、魔法、撞擊、下雨、下雪、火花),會發現粒子系統一些特性:

1. 整體特效由許多「微粒子」構成(故稱粒子系統)
2. 特效帶有隨機性,例如煙火不會總是射在同一個地方,每個粒子方向、速度也不同
3. 粒子行為很多樣,有些能漂浮、有些快速噴出、有些持續較久,有些很短暫
4. 每個粒子在一段時間後都會消失,不再出現

當然,這只從外表粗淺的觀察,想要仔細了解,還是要從程式碼與屬性著手。

在6種內建特效之中,煙火是比較特殊的一個,因為它實際上分為兩段,第一段只有一個粒子往上射出,到最頂端時變成許多粒子爆炸開來(這是第二段)。兩段的粒子行為明顯不同,怎麼做到的呢?

本節就來調整「煙火」屬性,藉此來了解粒子系統的內在特質。

先從最後結果往回看,以下影片中包含調整前與調整後兩組煙火相比較,可以看到調整前煙火規模很小,炸開來的粒子限縮在一個地方,調整後視覺效果較突出。

要調整成這樣的效果並不簡單,可從以下調整的程式碼來看,煙火1是內建特效,煙火2有調整屬性:
let 煙火1 = ParticleEmitterComponent.Presets.fireworks
print(煙火1) // 觀察 fireworks 預設值

var 煙火2 = 煙火1 // struct 複製
煙火2.speed = 2.0 // ← 1.4
煙火2.speedVariation = 0.5 // ← 0.1
煙火2.timing = .repeating(warmUp: 0, emit: .init(duration: 1.5, variation: 1.0), idle: nil)

煙火2.mainEmitter.birthRate = 2.0 // ← 1.2
煙火2.mainEmitter.birthRateVariation = 1.5 // ← 1.0
煙火2.mainEmitter.lifeSpan = 1.0 // ← 0.52
煙火2.mainEmitter.lifeSpanVariation = 0.5 // ← 0
煙火2.mainEmitter.size = 0.01 // ← 0.004
煙火2.mainEmitter.sizeVariation = 0.005 // ← 0
煙火2.mainEmitter.acceleration = [0, -0.2, 0] // ← [0, -0.1, 0]
煙火2.mainEmitter.dampingFactor = 0 // ← 3.2
煙火2.mainEmitter.color = .constant(.single(.yellow))

煙火2.spawnedEmitter?.birthRate = 20000 // ← 39000
煙火2.spawnedEmitter?.birthRateVariation = 5000 // ← 8000
煙火2.spawnedEmitter?.lifeSpan = 2.0 // ← 1.8
煙火2.spawnedEmitter?.lifeSpanVariation = 1.0 // ← 0.5
煙火2.spawnedEmitter?.size = 0.1 // ← 0.03
煙火2.spawnedEmitter?.sizeVariation = 0.05 // ← 0.01
煙火2.spawnedEmitter?.acceleration = [0, -0.3, 0] // ← [0, -0.15, 0]
煙火2.spawnedEmitter?.dampingFactor = 1.0 // ← 4

煙火2一共調整20個屬性,分成三組:

一、「元件」屬性,調整其中3個;
二、「主噴射器」(mainEmitter),相當於煙火第一段效果,調整其中9個屬性;
三、「次生噴射器」(spawnedEmitter),對應煙火第二段的爆炸效果,調整8個屬性。

元件屬性全部有23個屬性,主噴射器與次生噴射器各有34個屬性,下節再詳細介紹。

以上三組屬性修改前後的比較,整理成以下三個表格。其中「預設值」欄位是指 ParticleEmitterComponent() 產出的特效,也就是最原始的預設值。

一、粒子噴射元件(ParticleEmitterComponent)屬性
# 屬性名稱 預設值 煙火1 煙火2
1 speed 0.5 1.4 2.0
2 speedVariation 0 0.1 0.5
3 timing .repeating() .repeating() .repeating()
1. speed: 初始噴發速度(平均值)
2. speedVariation: 噴發速度差異(增減範圍)
3. timing: 元件時程(單次或重複,包含warmUp: 熱機時間、emit duration: 持續時間、emit variation: 持續時間差異、idle: 待機時間)

在煙火1,平均噴發速度是1.4 m/sec,差異是 0.1 m/sec,也就是噴發速度會在 1.3~1.5 m/sec 之間,持續時間 1 sec,因此,預期高度應在 1.3~1.5 m 之間。不過,後面還有個往下的加速度 [0, -0.15, 0],以及 dampingFactor 阻尼設為 3.2,生命期只有0.52秒,這三個因素大幅降低噴發高度。

煙火2我們希望煙火可以(第一段)射得高一點,(第二段)爆開來範圍大一點。

想要射得高一點,可以調整速度(speed)、速度差異(speedVariation)、時程(timing),以及主噴射器的生命期(lifeSpan)、生命期差異(lifeSpanVariation)、加速度(acceleration)、阻尼(dampingFactor)等這幾個屬性。

想要爆開範圍大一點,則調整次生噴射器(spawnedEmitter)的出生率(birthRate)、出生率差異(birthRateVariation)、生命期(lifeSpan)、生命期差異(lifeSpanVariation)、尺寸(size)、尺寸差異(sizeVariation)、加速度(acceleration)、阻尼(dampingFactor)、顏色(color)等屬性。

(主、次)粒子噴射器的屬性有34個,其中比較基本的就是這9個:

1. birthRate: 出生率(平均每秒噴出多少粒子)
2. birthRateVariation: 出生率變化(增減幅度)
3. lifeSpan: 生命期(每個粒子的平均壽命)
4. lifeSpanVariation: 生命期變化(增減幅度)
5. size: 尺寸(每個粒子的平均尺寸)
6. sizeVariation: 尺寸變化(增減幅度)
7. acceleration: 加速度(m/sec²)
8. dampingFactor: 阻尼
9. color: 顏色

二、主噴射器(mainEmitter)屬性
# 屬性名稱 預設值 煙火1.mainEmitter 煙火2.mainEmitter
1 birthRate 100 1.2 2.0
2 birthRateVariation 0 1.0 1.5
3 lifeSpan 1 0.52 1.0
4 lifeSpanVariation 0.2 0 0.5
5 size 0.02 0.004 0.01
6 sizeVariation 0 0 0.005
7 acceleration [0, 0, 0] [0, -0.1, 0] [0, -0.2, 0]
8 dampingFactor 0 3.2 0.5
9 color - - .yellow

三、次生噴射器(spawnedEmitter)屬性
# 屬性名稱 預設值 煙火1.spawnedEmitter 煙火2.spawnedEmitter
1 birthRate 100 39000 20000
2 birthRateVariation 0 8000 5000
3 lifeSpan 1 1.8 2.0
4 lifeSpanVariation 0.2 0.5 1.0
5 size 0.02 0.03 0.1
6 sizeVariation 0 0.01 0.05
7 acceleration [0, 0, 0] [0, -0.15, 0] [0, -0.3, 0]
8 dampingFactor 0 4 1.0

本節完整範例程式如下:
// 6-12b 調整粒子屬性
// Created by Heman Lu on 2025/06/08
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

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

let 煙火1 = ParticleEmitterComponent.Presets.fireworks
print(煙火1)

var 煙火2 = 煙火1 // struct 複製
煙火2.speed = 2.0 // ← 1.4
煙火2.speedVariation = 0.5 // ← 0.1
煙火2.timing = .repeating(warmUp: 0, emit: .init(duration: 1.5, variation: 1.0), idle: nil)

煙火2.mainEmitter.birthRate = 2.0 // ← 1.2
煙火2.mainEmitter.birthRateVariation = 1.5 // ← 1.0
煙火2.mainEmitter.lifeSpan = 1.0 // ← 0.52
煙火2.mainEmitter.lifeSpanVariation = 0.5 // ← 0
煙火2.mainEmitter.size = 0.01 // ← 0.004
煙火2.mainEmitter.sizeVariation = 0.005 // ← 0
煙火2.mainEmitter.acceleration = [0, -0.2, 0] // ← [0, -0.1, 0]
煙火2.mainEmitter.dampingFactor = 0 // ← 3.2
煙火2.mainEmitter.color = .constant(.single(.yellow))

煙火2.spawnedEmitter?.birthRate = 20000 // ← 39000
煙火2.spawnedEmitter?.birthRateVariation = 5000 // ← 8000
煙火2.spawnedEmitter?.lifeSpan = 2.0 // ← 1.8
煙火2.spawnedEmitter?.lifeSpanVariation = 1.0 // ← 0.5
煙火2.spawnedEmitter?.size = 0.1 // ← 0.03
煙火2.spawnedEmitter?.sizeVariation = 0.05 // ← 0.01
煙火2.spawnedEmitter?.acceleration = [0, -0.3, 0] // ← [0, -0.15, 0]
煙火2.spawnedEmitter?.dampingFactor = 1.0 // ← 4

var 巧克力材質 = PhysicallyBasedMaterial()
巧克力材質.baseColor.tint = .brown
巧克力材質.roughness = 0.2
巧克力材質.metallic = 0.1

do {
let 模型1 = try await ModelEntity(
mesh: .製作甜甜圈(內徑: 0.1, 外徑: 0.05), // 共享程式6-8b
materials: [巧克力材質])
模型1.position = [0.5, -0.5, 0]
模型1.components.set(煙火1)
內容.add(模型1)

let 模型2 = 模型1.clone(recursive: false) // class 複製
模型2.position = [-0.5, -0.5, 0]
模型2.components.set(煙火2)
內容.add(模型2)
} catch {
print("有問題:\(error)")
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(粒子系統())

注意這裡面有個非常重要的語法觀念,就是 struct 與 class 的基本差異(參考第5單元語法說明:class 與 struct 異同):
// struct 與 class 兩種物件的複製方式
let 煙火1 = ParticleEmitterComponent.Presets.fireworks
var 煙火2 = 煙火1 // struct 複製
//...
let 模型1 = try await ModelEntity()
let 模型2 = 模型1.clone(recursive: false) // class 複製

用 struct 所定義的類型是 value type,在指定句或函式呼叫時,被指定的變數或參數會複製一份內容值,指定給另外一個變數,此後兩者各走各路,不再相關。

而用 class 定義的類型則是 reference type,在指定句或函式呼叫時,內容不會複製,而是參數與指定的變數會指向(或參照)同一組內容,兩者外表(即名稱)不同,內在是合一的。

若只想要複製 class 變數的內容值,不指向同一內容,則須呼叫「變數.clone()」,如上面最後一行。

這個差異影響非常巨大,隨處可見,例如,若要修改 struct 類型的變數,必須用 var 宣告(如煙火2);相對的,用 let 宣告的 class 變數(如模型2),仍然可以隨意修改其屬性。

還有一個例子,是很容易踩到的「陷阱」:
// 陷阱!!
var 預設 = ParticleEmitterComponent()
預設.speed = 2.0
模型.components.set(預設)
...
預設.speed = 30.0 // 此行不會生效,為什麼呢?

// 比較 6-11d
let 氣泡模型 = ModelEntity(mesh: 氣泡, materials: [玻璃材質])
氣泡模型.name = "靜止氣泡"
內容.add(氣泡模型)
氣泡模型.position.y = 1.0 // 這行有效,為什麼?

也就是說,用 struct 定義的元件,在加入個體之後,若要再變更元件屬性,就必須重新加入才能生效。正確的寫法如下(加最後一行,重新複製內容值):
// 正確寫法
var 預設 = ParticleEmitterComponent()
預設.speed = 2.0
模型.components.set(預設)
...
預設.speed = 30.0
模型.components.set(預設) // 屬性變更生效

若是不知道 struct 與 class 之間的差異,這樣的錯誤很難自己找出來。

💡註解
  1. 這樣一來,我們寫程式之前,不都得先看一下該物件是用 struct 或 class 定義的嗎?實務上不用這麼麻煩,通通先用 let 宣告即可,如果是 struct 物件,Swift Playground (或 Xcode)會自動提示改成 var 宣告。
  2. 有沒有可能程式都用 struct 物件,完全捨棄 class 物件呢?理論上雖然可行,但實際上幾乎不可能,因為 Swift 程式框架從過去數十年累積下來(包括繼承自Objective-C),除非從零設計一個新的程式語言,否則就 Swift 來說,struct 與 class 混用是必然的事。
  3. RealityKit 跟 SwiftUI 一樣,是以 struct 物件為主的框架,除了少數(如 Entity, ModelEntity, LowLevelMesh)是 class 之外,大部分物件都是以 struct 定義。
6-12c 粒子系統完整屬性(RealityKit)

粒子系統的視覺效果驚人,若再配合音效,簡直就是專業的電影特效。不過,要學好粒子系統的代價也不小,完整粒子系統有57個屬性,構成一套相當完整的體系,要運用自如並不容易,可謂易學難精。

對初學者而言,57個屬性當中,大約有15~20個是基礎,必須先熟悉。而其他有些結合物理模擬(如加速度、力場),有些產生動畫效果,有些則添加隨機性,都相當有趣,非常好玩。例如以下改自上節程式6-12b,待看完本課內容後,再來猜猜看做了哪些修改。

以下分為元件屬性(x23)與噴發器屬性(x34)兩大列表,再細分組加以解說,內容較長,但最好先全部瀏覽一遍,才能理解粒子系統的邏輯與特性。

粒子系統元件(ParticleEmitterComponent)預設屬性
# 屬性名稱 預設值 說明
1 emitterShape .plane 粒子噴射器(發射源)形狀
.point 單點(在原點)
.plane X-Z平面
.box 立方體
.sphere 球體
.cylinder 圓柱體(X-Z半徑、Y軸高)
.cone 圓錐體(X-Z半徑、Y軸高)
.torus 甜甜圈(內環半徑+X-Y半徑)
2 emitterShapeSize [0.1, 0.1, 0.1] 粒子噴射器尺寸
3 radialAmount .pi * 2.0 圓周弧度(若發射源形狀為sphere, cylinder, cone, and torus等適用)
4 torusInnerRadius 0.25 甜甜圈內環半徑
以上4個屬性描述粒子噴發源頭,可以是一個點、一個面、或是某個3D形狀。預設值是 0.1m x 0.1m 的水平面,粒子會從此平面任一點(往上)噴發。
5 birthLocation .surface 粒子發射位置
.surface 從模型表面
.vertices 從模型頂點
.volume 從模型內部空間
6 birthDirection .normal 粒子發射方向
.local 以區域座標為方向
.normal 以法線方向發射
.world 以全域坐標為方向
7 emissionDirection [0, 1, 0] 指定粒子發射方向(當 birthDirection = .local or .world)
8 speed 0.5 粒子的初始速度(m/sec)
9 speedVariation 0 粒子初始速度的隨機範圍(±)
以上5個屬性描述粒子噴發的方向與速度,若發射源是3D形狀,預設會從垂直於表面的法線方向射出,預設速度為 0.5±0 m/sec。
10 timing .repeating(
warmUp: 0.0,
emit: .init(duration: 1.0, variation: 0.0),
idle: .init(duration: 0.0, variation: 0.0))
發射器的噴發次數與時間
.once() 單次
.repeating() 重複
timing 屬性設定噴發時程,可分單次(once)或重複(repeating),每次又細分warmUp熱機時間(0秒)、emit持續時間(1±0秒)、idle待機間隔時間(0±0秒)。

其中最重要的是噴發持續時間(emit duration),這個時間與主噴發器的出生率(birthRate)、生命期(lifeSpan)三個因素決定看到的粒子多寡,影響整個特效的規模。

timing 這個物件的參數不太好寫,範例如下:
粒子元件.timing = .repeating(
warmUp: 0,
emit: .init(duration: 1.0, variation: 0.2),
idle: .init(duration: 2.0, variation: 0.5))

11 mainEmitter 參考下表 主發射器(ParticleEmitter類型)
參考「主發射器屬性表」
12 spawnedEmitter? nil 次生發射器(預設無)
13 spawnOccasion .onDeath 次生發射器的啟用時機
.onBirth 粒子產出時
.onDeath 粒子消滅時
.onUpdate 狀態更新時
14 spawnInheritsParentColor false 次生時是否繼承主發射器的外觀
15 spawnSpreadFactor 0 次生粒子的散射速率
16 spawnSpreadFactorVariation 0 次生粒子散射速率的隨機範圍(±)
17 spawnVelocityFactor 1 次生粒子的速度比例(* speed)
18 particlesInheritTransform false 是否繼承變換座標
以上為主發射器與次生發射器,兩者是相同的物件類型(ParticleEmitter),各包含34個屬性。每個元件必有一個主發射器,預設沒有次生發射器。

spawn 是產卵、繁殖的意思,spawnedEmitter 是由主發射器繁衍出來的,故稱「次生發射器」(就像「次生林相」)。

若要啟用次生發射器,可以指定啟用時機(粒子出生時、消滅時、更新時),以及繼承主發射器的相關參數。
19 isEmitting true 是否正在噴發粒子
20 simulationState .play 粒子系統狀態
.pause 暫停
.play 啟動
.stop 停止(並清除設定)
21 fieldSimulationSpace .global 力場模擬空間
.global 全域(整個場景)座標
.local 模型本身的區域座標
22 burstCount 100 瞬間爆發量
23 burstCountVariation 0 瞬間爆發量隨機範圍(±)
24 burst() 物件方法 瞬間爆發
25 encode() 物件方法 將物件編碼(準備存檔)
26 restart() 物件方法 重新初始化(元件也需重新指定)
以上其餘的元件屬性與物件方法,可進一步調控粒子系統,simulationState 控制元件啟動、暫停或終止,burst() 可瞬間爆發粒子,通常用在某些事件(例如碰撞)時。

主噴射器(mainEmitter: ParticleEmitter)預設屬性
# 屬性名稱 預設值 說明
1 birthRate 100 出生率(每秒產出粒子數)
2 birthRateVariation 0 出生率增減範圍(±)
3 lifeSpan 1 生命期(秒)
4 lifeSpanVariation 0.2 生命期增減範圍(±)
上面提過,噴發持續時間(emit duration)、出生率(birthRate)、生命期(lifeSpan)三個因素共同決定粒子多寡,是非常重要的三個屬性。三個屬性都有附帶增減範圍(Variation),用來增加粒子的隨機性。
5 size 0.02 粒子尺寸(半徑)
6 sizeVariation 0 粒子尺寸隨機範圍(±)
7 sizeMultiplierAtEndOfLifespan 0.1 生命期結束後的尺寸比例
8 sizeMultiplierAtEndOfLifespanPower 1.0 生命期結束後的尺寸衰減指數
以上4個屬性設定粒子大小(單位m),以及在生命期逐漸縮小的速度,生命期結束便會消失。每個粒子的外形實際上是(2D)正方形,size 是邊長的一半(內切圓半徑)。
9 acceleration [0, 0, 0] 每個粒子的加速度(m/sec²)
10 dampingFactor 0 速度阻尼
加速度(acceleration)相當於給粒子施加某個方向的引力,與元件屬性 speed 初始速度配合,讓粒子朝某個方向加速(正加速度)或減速(負加速度),就可大致知道粒子的運動軌跡。

阻尼相當於無方向的阻力(想像深陷泥沼或潛入水中的感覺),任何方向都適用。而加速度與初始速度則有方向性(未必同一方向)。
11 color * 粒子顏色
.constant()
.evolving(start:, end:)
12 colorEvolutionPower 1.0 顏色轉變速度(指數變化)
= 1.0 線性轉變
< 1.0 較快
> 1.0 較慢
color 是粒子的顏色,這個屬性有點複雜。

首先可以設定為恆定.constant(),或隨生命期變化.evolving(start:, end:) — 出生時一個顏色,逐漸轉變到結束時另一個顏色。

接著,可以設定.single() — 所有粒子統一顏色,或是.random(a:, b:) — 各粒子在a, b兩種顏色之間隨機變化 。

因此,粒子顏色的寫法有以下幾種:
粒子.mainEmitter.color = .constant(.single(.yellow))
粒子.mainEmitter.color = .constant(.random(a: .yellow, b: .red))
粒子.mainEmitter.color = .evolving(
start: .random(a: .blue, b: .purple),
end: .random(a: .cyan, b: .white))
// 預設值:
粒子.mainEmitter.color = .evolving(
start: .single(.init(red: 1, green: 0.29, blue: 0, alpha: 1)),
end: .single(.init(red: 0.0, green: 0.03, blue: 1, alpha: 1)))

13 image? nil 粒子外觀(TextureResource?)
14 imageSequence? nil 動畫圖片(a sprite sheet),包含以下屬性
.rowCount
.columnCount
.frameRate
.frameRateVariation
.animationMode
.hashValue
.initialFrame
.initialFrameVariation
另一種設定粒子外觀的方法,是透過紋理貼圖,直接帶入圖片,當然,圖片不能太複雜,否則縮小成粒子也看不出來。

除了單一圖片貼圖之外,一張圖片中還可以切割為 m x n 方格,每個方格為大小一致的正方形,對應一個紋理,設定好 frameRate(幀率)就可依次載入方格,形成短暫的動畫效果。

關於 imageSequence 的用法,可參考上半單元補充(9) 粒子圖案particleImage
15 spreadingAngle 0 隨機散射角度(弳度)
16 angle 0 每個粒子旋轉角度(弳度)
17 angleVariation 0 旋轉角度隨機範圍(±弳度)
18 angularSpeed 0 初始角速度
19 angularSpeedVariation 0 角速度隨機範圍(±)
散射角 spreadingAngle 與噴射方向(birthDirection以及emissionDirection)有關,會在一定角度內隨機調整噴射方向(形成角錐狀)。舉例來說,若散射角為90° (spreadingAngle = .pi / 2.0),散射範圍將涵蓋上半球。

angle與angularSpeed 設定粒子的旋轉角度與角速度。
20 attractionCenter [1, 1, 0] 粒子吸引點(區域座標)
21 attractionStrength 0 吸引強度
attractionCenter與attractionStrength會將粒子吸引到空間中的某個點(可想像成粒子回收處)。
22 billboardMode .billboardYAligned 告示板模式
.billboard 永遠面向鏡頭
.billboardYAligned 面向鏡頭,但保持垂直(與Y軸平行)
.free(axis: variantion:) 面向axis
23 stretchFactor 0.0 告示板模式(Billboard)伸展比例
billboardMode告示板模式用來設定粒子的面向,通常粒子外型並非3D模型,而是2D形狀,告示板模式可讓粒子從各個角度都看得清楚。
24 blendMode .alpha 混合模式(粒子與後方畫素混合)
.additive 顏色相加
.alpha 顏色透明度相乘
.opaque 遮擋後方顏色
25 isLightingEnabled false 是否受光照影響
26 opacityCurve .quickFadeInOut 粒子透明度變化曲線
.constant
.easeFadeIn
.easeFadeOut
.gradualFadeInOut
.linearFadeIn
.linearFadeOut
.quickFadeInOut
27 sortOrder .increasingDepth 粒子渲染次序規則
.decreasingAge 先出生者優先
.decreasingDepth 靠近鏡頭者優先
.decreasingID ID較高者優先
.increasingAge 後來者優先
.increasingDepth 遠離鏡頭者優先
.increasingID ID較低者優先
.unsorted 無次序(隨機)
以上幾個屬性設定粒子與背景的混合模式。
28 mass 1 質量(克)
29 massVariation 0 質量隨機範圍(±克)
30 noiseStrength 0 雜訊或亂流強度
31 noiseScale 1 雜訊或亂流變化尺度
32 noiseAnimationSpeed 0 雜訊變化速度
33 vortexStrength 0 渦流力場強度(牛頓)
34 vortexDirection [0, 1, 0] 渦流轉軸
最後幾個屬性與物理力場有關,雜訊(noise)可視為一種隨機方向的力場。當設定力場強度時,粒子質量才有作用,在此質量單位是克(g),而非公斤(kg)。

💡註解
  1. SceneKit 的粒子系統有69個屬性(參考上半單元補充(10)粒子系統屬性表),與 RealityKit 粒子系統屬性大多重疊,應該有先後傳承的關係。
  2. 除了寫程式試驗每個屬性之外,也可透過工具來輔助。幾乎所有3D繪圖軟體都會支援粒子系統(但屬性未必相同),例如 Blender 或 Apple 原廠的 Reality Composer Pro (附屬於 Xcode),可提供視覺化的參數調整。
  3. 本節範例程式中,甜甜圈的縮放動畫與衝擊特效並未同步,因為粒子系統的時程有隨機性,更好的做法應該改為事件處理,也就是動畫完成時,釋放衝擊特效,有興趣的同學,要不要挑戰看看?

附錄:6-12c程式原始碼
// 6-12c 粒子系統完整屬性
// Created by Heman Lu on 2025/06/10
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

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

var 衝擊 = ParticleEmitterComponent.Presets.impact
print(衝擊)
衝擊.timing = .repeating(
warmUp: 0,
emit: .init(duration: 0.1, variation: 0.05), // 0.05
idle: .init(duration: 2.0, variation: 0.3)) // 3.0
衝擊.speed = 2.0 // 0.5
衝擊.speedVariation = 1.2 // 0.1
衝擊.mainEmitter.birthRate = 5000 // 2000
衝擊.mainEmitter.dampingFactor = 4.0 // 8.0
衝擊.mainEmitter.lifeSpan = 0.0 // 2.0
衝擊.mainEmitter.lifeSpanVariation = 1.0 //1.0
衝擊.mainEmitter.noiseScale = 2.0 // 1.0
衝擊.mainEmitter.noiseStrength = 0.2 // 0.1

let 煙火1 = ParticleEmitterComponent.Presets.fireworks

var 煙火2 = 煙火1 // struct 複製
煙火2.speed = 2.0 // ← 1.4
煙火2.speedVariation = 0.5 // ← 0.1
煙火2.timing = .repeating(warmUp: 0, emit: .init(duration: 1.5, variation: 1.0), idle: nil)

煙火2.mainEmitter.birthRate = 3.0 // ← 1.2
煙火2.mainEmitter.birthRateVariation = 1.5 // ← 1.0
煙火2.mainEmitter.lifeSpan = 1.0 // ← 0.52
煙火2.mainEmitter.lifeSpanVariation = 0.5 // ← 0
煙火2.mainEmitter.size = 0.1 // ← 0.004
煙火2.mainEmitter.sizeVariation = 0.08 // ← 0
煙火2.mainEmitter.acceleration = [0, 0, 0] // ← [0, -0.1, 0]
煙火2.mainEmitter.dampingFactor = 1.0 // ← 3.2
煙火2.mainEmitter.color = .evolving(
start: .random(a: .blue, b: .purple),
end: .random(a: .cyan, b: .yellow))

煙火2.spawnedEmitter?.birthRate = 20000 // ← 39000
煙火2.spawnedEmitter?.birthRateVariation = 5000 // ← 8000
煙火2.spawnedEmitter?.lifeSpan = 2.0 // ← 1.8
煙火2.spawnedEmitter?.lifeSpanVariation = 1.0 // ← 0.5
煙火2.spawnedEmitter?.size = 0.1 // ← 0.03
煙火2.spawnedEmitter?.sizeVariation = 0.05 // ← 0.01
煙火2.spawnedEmitter?.acceleration = [0, 0, 0] // ← [0, -0.15, 0]
煙火2.spawnedEmitter?.dampingFactor = 1.0 // ← 4
煙火2.spawnedEmitter?.attractionStrength = 1.0 // ← 0
煙火2.spawnedEmitter?.attractionCenter = [1, 0, 0]

var 巧克力材質 = PhysicallyBasedMaterial()
巧克力材質.baseColor.tint = .brown
巧克力材質.roughness = 0.2
巧克力材質.metallic = 0.1

let 縮放 = FromToByAction(to: Transform(scale: [1.2, 1.2, 1.2]), mode: .local)

do {
let 模型1 = try await ModelEntity(
mesh: .製作甜甜圈(內徑: 0.1, 外徑: 0.05), // 共享程式6-8b
materials: [巧克力材質])
let 動畫 = try AnimationResource.makeActionAnimation(
for: 縮放,
duration: 2.0,
bindTarget: .transform,
repeatMode: .repeat)
模型1.position = [0.5, -0.5, 0]
模型1.components.set(衝擊)
模型1.playAnimation(動畫)
內容.add(模型1)

let 模型2 = 模型1.clone(recursive: false) // class 複製
模型2.position = [-0.5, -0.5, 0]
模型2.components.set(煙火2)
內容.add(模型2)
} catch {
print("有問題:\(error)")
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(粒子系統())
6-12d 雨雪特效詳解

初步理解粒子系統的完整屬性之後,再回頭看6種內建特效,就可從裡而外了解特效的原理,例如,以下雨(rain)和下雪(snow)為例,先將所有屬性列印到主控台:
let 下雨 = ParticleEmitterComponent.Presets.rain
let 下雪 = ParticleEmitterComponent.Presets.snow
print("下雨屬性\n", 下雨)
print("下雪屬性\n", 下雪)
再一一與預設值加以比較。原先在第12課(6-12a)範例中,下雨的效果有點模糊不清,從以下解析,我們就可知道為何如此。

元件屬性
# 屬性名稱 預設值 下雨(rain) 下雪(snow)
1 emitterShape .plane .plane .plane
2 emitterShapeSize [0.1, 0.1, 0.1] [0.1, 1.0, 0.1] [0.1, 1.0, 0.1]
3 radialAmount .pi * 2.0 6.283185 6.283185
4 torusInnerRadius 0.25 0.25 0.25
5 birthLocation .surface .volume .surface
6 birthDirection .normal .world .world
7 emissionDirection [0, 1, 0] [0, 1, 0] [0, -1, 0]
8 speed 0.5 -5.0 0.08
9 speedVariation 0 5.0 0.04
10 timing .repeating()
warmUp: 0
emit: 1.0±0
idle: 0±0
.repeating()
warmUp: 0
emit: 0.21±0
idle: 0±0
.repeating()
warmUp: 0
emit: 1.0±0
idle: 0±0
下雨和下雪的粒子源都是從0.1m x 0.1m水平面往下發射,下雨的粒子速度比較快(5.0 ± 5.0 m/sec),下雪則慢很多(0.08 ± 0.04 m/sec)。

此處下雨噴射方向(emissionDirection)設為Y軸往上[0, 1, 0],但速度(speed)設為-5 m/sec,其實就跟方向設為Y軸往下[0, -1, 0]、速度5 m/sec,結果是一樣的。

另外,下雨和下雪的 timing 設定雖有不同,但實質效果並無差別,為什麼呢?因為設定重複(repeating)且待機時間(idle)為0,因此噴射持續0.21秒後,馬上又重複,實際上會連續不斷地噴射。

簡單地說,如果沒有要設定待機時間(idle)的話,timing 就不需要任何更改。

# 屬性名稱 預設值 下雨(rain) 下雪(snow)
12 spawnedEmitter? nil 參考下表 nil
13 spawnOccasion .onDeath .onBirth .onUpdate
14 spawnInheritsParentColor false false false
15 particlesInheritTransform false false false
16 spawnSpreadFactor 0 0.2 0
17 spawnSpreadFactorVariation 0 0.0 0
18 spawnVelocityFactor 1 0.4 1
比較意外的是,下雨特效用到次生噴射器,而且是在每個粒子一出生(onBirth)就啟動,顯然想同時混合兩種粒子效果。再看主、次噴射器的屬性:

噴射器(ParticleEmitter)屬性表
# 屬性名稱 預設值 下雨.主噴射器 下雨.次生噴射器 下雪.主噴射器
1 birthRate 100 3000.0 300.0 500.0
2 birthRateVariation 0 1000.0 0 10.0
3 lifeSpan 1 0.04 0.06 3.0
4 lifeSpanVariation 0.2 0.002 0.004 0.2
下雨主噴射器的平均出生率為3000粒子/秒,平均生命期只有0.04秒,因此每個瞬間看到的粒子數平均為 3000 x 0.04 = 120 粒子。

次生噴射器的出生率為300粒子/秒,但只在主噴射器粒子出生的瞬間(1/60秒)觸發,所以每次觸發的粒子數為 300 x 1/60 = 5 個粒子。因為每秒觸發3000次,存活時間(lifeSpan)0.06秒,因此可觀察到的次生粒子數約為 3000 x 5 x 0.06 = 900 粒子。

下雪平均每秒噴發500粒子,平均存活時間3秒鐘,因此可看到的粒子數平均為 500 x 3 = 1500 粒子。

# 屬性名稱 預設值 下雨
.主噴射器
下雨
.次生噴射器
下雪
.主噴射器
5 size 0.02 0.001 0.0012 0.001
6 sizeVariation 0 0.0005 0.0 0.002
7 sizeMultiplierAtEndOfLifespan 0.1 1 1 0.8
8 sizeMultiplierAtEndOfLifespanPower 1.0 1 1 10.0
雨雪粒子尺寸都遠小於預設值,只有0.1cm,在 RealityView 裡面看起來實在太小。

# 屬性名稱 預設值 下雨
.主噴射器
下雨
.次生噴射器
下雪
.主噴射器
11 color .evolving()
[1, 0.3, 0, 1] - [0, 0, 1, 1]
.constant(.random(a: [1, 1, 1, 0.19],
b: [1, 1, 1, 0.14]
.constant(.single([1, 1, 1, 0.42])) .constant(.single(.white))
雨滴的顏色,主噴射器設定為白色,但透明度(alpha channel)從0.14-0.19之間變化,次生雨滴透明度0.42,這裡透明度的設定,應該是下雨特效看不清楚的主要原因。

雪花的顏色則是白色,透明度等於1(完全不透明),所以看起來較顯眼。

💡註解
  1. 此處顏色屬性可以指定透明度,稱為 RGBA 格式,即 R (Red) 紅、G (Green) 綠、B (Blue)藍三原色之外,加上 A (Alpha Channel) — 相當於 opacity 透明度。RGBA 格式由皮克斯創辦人卡特姆(Ed Catmull)制定。
  2. 網路圖片目前主流格式包括 .jpg 與 .png,後者(.png)較新,支援 RGBA 格式,因此允許透明背景,前者(.jpg)只支援 RGB 格式,圖片不會有透明部分。

# 屬性名稱 預設值 下雨
.主噴射器
下雨
.次生噴射器
下雪
.主噴射器
22 billboardMode .billboardYAligned .billboard .billboard .billboard
23 stretchFactor 0 3.0 0.3 0
30 noiseStrength 0 4.0 0 0.03
其他比較特別的設定,是主噴射器的 “stretchFactor” 設為 3.0,這會導致雨滴沿運動方向拉長,而次生雨滴拉長一點點(0.3),雪花則完全不變形。這個屬性只在 billboardMode = .billboard 時才有效。stretch 是伸展、拉伸的意思。

此現象可以從下圖觀察到:

圖左雨滴混合兩種形狀,長線是主噴射器的雨滴(stretchFactor = 3),短線是次生雨滴(stretchFactor = 0.3),共同表現出雨絲的感覺。圖右雪花則保持顆粒狀,沒有變形(stretchFactor = 0)。

另外,下雨主噴射器 noiseStrength = 4,下雪 noiseStrength = 0.03,雜訊(noise)會讓粒子運動路線不會保持一直線,這對速度慢的雪花較明顯,很像雪花飄落(不會直線落下)。

經過這樣的解析,要調整下雨、下雪的特效,可直接從這幾個屬性加以調整。調整後效果如下,比原先清楚多了:


詳細調整內容,請直接參考程式碼:
// 6-12d 雨雪特效詳解
// Created by Heman Lu on 2025/06/15
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

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

var 下雨 = ParticleEmitterComponent.Presets.rain
print("下雨屬性\n", 下雨)
下雨.emissionDirection = [0, -1, 0] // ← [0, 1, 0]
下雨.speed = 4.0 // ← -5.0
下雨.speedVariation = 3.0 // ← 5

下雨.mainEmitter.birthRate = 5000 // ← 3000
下雨.mainEmitter.birthRateVariation = 2000
下雨.mainEmitter.lifeSpan = 0.1 // ← 0.04
下雨.mainEmitter.lifeSpanVariation = 0.1 // ← 0.002
下雨.mainEmitter.size = 0.002 // ← 0.001
下雨.mainEmitter.sizeVariation = 0.001 // ← 0
下雨.mainEmitter.noiseStrength = 1.0 // ← 4.0
下雨.mainEmitter.spreadingAngle = 0.0 // ← 0.02
下雨.mainEmitter.color = .constant(.random(
a: .init(white: 1.0, alpha: 0.5),
b: .init(white: 1.0, alpha: 0.2)))

下雨.spawnedEmitter?.birthRate = 600 // ← 300±0
下雨.spawnedEmitter?.size = 0.004 // ← 0.0012±0
下雨.spawnedEmitter?.lifeSpan = 0.1 // ← 0.06
下雨.spawnedEmitter?.lifeSpanVariation = 0.1 // ← 0.004
下雨.spawnedEmitter?.color = .constant(.single(
.init(white: 1.0, alpha: 0.5)))

var 下雪 = ParticleEmitterComponent.Presets.snow
print("下雪屬性\n", 下雪)
下雪.speed = 0.12 // ← 0.08
下雪.speedVariation = 0.1 // ← 0.04

下雪.mainEmitter.birthRate = 1000 // ← 500
下雪.mainEmitter.birthRateVariation = 800 // ← 10
下雪.mainEmitter.lifeSpan = 3.0 // ← 3
下雪.mainEmitter.lifeSpanVariation = 2.0 // ← 0
下雪.mainEmitter.size = 0.003 // ← 0.001
下雪.mainEmitter.sizeVariation = 0.002 // ← 0.002

var 巧克力材質 = PhysicallyBasedMaterial()
巧克力材質.baseColor.tint = .brown
巧克力材質.roughness = 0.2
巧克力材質.metallic = 0.1

do {
let 模型1 = try await ModelEntity(
mesh: .製作甜甜圈(內徑: 0.1, 外徑: 0.05), // 共享程式6-8b
materials: [巧克力材質])
模型1.position = [0.5, 0.5, 0]
模型1.components.set(下雨)
內容.add(模型1)

let 模型2 = 模型1.clone(recursive: false) // class 複製
模型2.position = [-0.5, 0.5, 0]
模型2.components.set(下雪)
內容.add(模型2)
} catch {
print("有問題:\(error)")
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(粒子系統())
補充(25)Reality Composer Pro

Apple 原廠在 Xcode 裡面附了一個軟體工具,用以輔助 RealityKit 的 3D 內容編輯,稱為 Reality Composer Pro,其定位並不像 Blender 是套完整 3D 內容創作軟體,而是作為程式設計的輔助、擔任 Blender 與 Xcode 之間的橋樑。

Reality Composer Pro 在2023年與 Vision Pro 一起發表,前身為2019年發表的 Reality Composer。老實說,筆者第一次接觸時,觀感並不好,因為功能相當陽春,使用介面也不友善,心想:「花同樣的時間,還不如去學 Blender」。

不過,Reality Composer Pro 最大的優勢,一是完全為 RealityKit 量身打造,所有屬性、參數完全一樣,名稱也大同小異,大部分 RealityKit 功能都可在裡面視覺化操作;二是與 Xcode 密切整合,編輯好的場景、3D模型、動畫、物理模擬等,可直接匯入 Xcode,在程式中使用。

底下範例,我們簡單建一個室內場景,用一台飛機模型加上粒子系統,讓飛機下雨(噴灑農藥):

經過參數調整,將毛毛細雨改成傾盆大雨:

透過 Reality Composer Pro 視覺化調整粒子系統,當然簡單多了,一調整馬上就可看到變化。

怎麼做呢?很簡單,上一節我們知道下雨特效的調整方向後,用 Reality Composer Pro 大約五分鐘即可完成。

1. 在 Xcode 中開啟 Reality Composer Pro


2. 開新專案(Create New Project) → 加入天空盒(Living Room - Night)


3. 加入玩具飛機


4. 加入粒子系統


5. 改為內建的下雨特效


6. 啟動特效


7. 調整主噴射器屬性


8. 調整次生噴射器屬性


這樣就完成啦!

💡註解
  1. Reality Composer Pro 只能隨 Xcode 一起下載,用於 macOS;Reality Composer 可支援 iPad,目前仍可從 App Store 下載
  2. 用 Reality Composer Pro 視覺化介面來學習粒子系統是不是比較快?直覺如此,但實際卻未必。若從零開始學習,就直接採用 Reality Composer Pro,反而很快陷入參數迷宮,完全學不到整體的邏輯。
  3. 筆者建議還是先從本課了解粒子系統的所有屬性以及背後邏輯,再使用 Reality Composer Pro,才會更順手、更方便。
6-12e 客製化粒子系統:龍捲風

在本課一開始提過,粒子系統是為了模擬自然界的一些現象,例如雲霧、火焰,這些現象在似有似無之間,運動軌跡變幻莫測,非常難以計算,甚至比登月軌道還難,如今透過大量算力,總算可以完美模擬出來。

粒子系統的應用場景非常多,但都必須客製化,要熟悉60多個參數並不容易,還好 RealityKit 提供6種內建特效作為模板,幫助我們很快做出想要的視覺特效。

本課最後一節範例,想從 ParticleEmitterComponent() 預設值來客製化粒子系統,利用其中一個關鍵參數 vortexStrength (渦流力場)來做出龍捲風特效,配合前面所學的動作動畫,模擬一個行進中的龍捲風。

實際效果如下,跟預期有點落差,變成是龍捲風+暴風雪的感覺:


不過實際程式倒是不難,只調整13個參數,其中幾個重要參數都有 “Variation”,能提供隨機性,讓特效看起來比較自然。

完整程式如下:
// 6-12e 客製化粒子系統:龍捲風
// Created by Heman Lu on 2025/06/23
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

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

// (1) 粒子系統
var 龍捲風 = ParticleEmitterComponent()
龍捲風.speed = 2.4 // ← 0.5
龍捲風.speedVariation = 1.0 // ← 0
龍捲風.emitterShapeSize = [0.05, 0.05, 0.05] // ← 0.1

龍捲風.mainEmitter.birthRate = 50000 // ← 100
龍捲風.mainEmitter.birthRateVariation = 10000
龍捲風.mainEmitter.lifeSpan = 3.0 // ← 1
龍捲風.mainEmitter.lifeSpanVariation = 2.0 // ← 0.2
龍捲風.mainEmitter.size = 0.02 // ← 0.02
龍捲風.mainEmitter.sizeVariation = 0.01 // ← 0
龍捲風.mainEmitter.color = .constant(.random(a: .white, b: .black))

龍捲風.fieldSimulationSpace = .local // ← .global
龍捲風.mainEmitter.vortexStrength = -36.0 // 逆時針旋轉
龍捲風.mainEmitter.noiseStrength = 2.5 // ← 0

// (2) 隱形個體:承載粒子系統
let 暴風眼 = Entity()
暴風眼.name = "暴風眼"
暴風眼.position = [0, -1.0, 0]
暴風眼.components.set(龍捲風)
內容.add(暴風眼)

// (3) 隱形個體:調節「暴風眼」的運動路徑
let 圓心 = Entity()
圓心.name = "圓心"
圓心.position = [0, -1, -2]
內容.add(圓心)

// (4) 令「暴風眼」繞著「圓心」轉動
let 繞圈轉 = OrbitEntityAction(
pivotEntity: .entityNamed("圓心"),
revolutions: 1.0)
if let 動畫 = try? AnimationResource.makeActionAnimation(
for: 繞圈轉,
duration: 51.0,
bindTarget: .transform,
repeatMode: .repeat
) {
暴風眼.playAnimation(動畫)
}

// (5) 令「圓心」在直線上來回移動
let 位移 = FromToByAction(by: Transform(translation: [-2, 0, -1]))
if let 動畫2 = try? AnimationResource.makeActionAnimation(
for: 位移,
duration: 19.0,
bindTarget: .transform,
repeatMode: .autoReverse
) {
圓心.playAnimation(動畫2)
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(粒子系統())

程式用到一個新的物件 Entity(),這其實是 ModelEntity 的父類別(用 class 定義,會有階層關係),是最簡單的個體,只包含 Transform 與 SynchronizationComponent 兩個必要元件。

為什麼用 Entity() 而不用 ModelEntity() 呢?

實際上,所有個體都可以從 Entity() 加上不同元件而來:加上 ModelComponent 就變成模型個體,可顯示在場景中;加上 CollisionComponent 跟 PhysicsBodyComponent 就得到物理模擬效果。因此,用 Entity() 加上元件(參考補充(23) RealityKit 元件列表),我們可以任意組合新的個體。

範例中,我們用 Entity() 加上粒子元件(ParticleEmitterComponent),就變成最簡單的粒子特效,因為沒有 ModelComponent,所以個體是隱形的,只能看到粒子特效;而且因為 Entity() 包含座標變換元件,所以可配合動作動畫。

💡註解
  1. 作業:程式中有一行 “龍捲風.fieldSimulationSpace = .local”,請在前面加上 // 將這行 remark 起來,看看執行結果有何不同。


**下週有活動,暫停更新,預計七月中開始寫「第13課 物理力場」**
第13課 物理力場

RealityKit 去(2024)年新增了物理力場元件:ForceEffectComponent,除了可自行定義力場規則之外,也內建6種常見力場:

1. ConstantForceEffect 定向力場
2. DragForceEffect 粘滯力場
3. VortexForceEffect 渦流力場
4. TurbulenceForceEffect 擾流力場
5. RadialForceEffect 向心力場(似彈簧)
6. ConstantRadialForceEffect 向心力場(固定強度)

什麼是力場呢?簡單地說,就是空間中任何一個位置(點座標)都有力的作用,每個位置的力有其方向及大小,可能相同,也可能不同。若用數學函數來描述,力就是空間位置的函數:力 = f(點座標)。

因為力在某個範圍內無所不在,所以稱為力場(Force field,field = 場,意思是一定範圍的空間)。在宇宙中,常見的力場有重力場與電磁場;在地球大氣或海水中,常出現擾流、渦流等力場。

在程式中要增加物理力場,需要三個步驟,對應的程式碼如下:
// 增加力場
let 斜向力 = ConstantForceEffect(
strength: 0.1,
direction: [-1, 1, 0])
let 力場 = ForceEffect(effect: 斜向力)
let 力場元件 = ForceEffectComponent(effect: 力場)

第一步,先從6個內建力場中,挑選最簡單的定向力場(ConstantForceEffect),顧名思義,這種力場不管在什麼位置,力的強度與方向都是固定的。此例強度設為 0.1,方向為 [-1, 1, 0]。

第二步,再將定向力場組合成 ForceEffect (力場或力場效應)物件,此物件其實有7個參數,可進一步調整力場特性,下一節再詳細解說。

第三步最終組合物理力場元件 ForceEffectComponent,就能附加到個體之中。

需要每個個體都加入力場元件嗎?並不需要,只要將力場元件附掛到場景中任何一個個體,以此個體為中心的一定空間範圍內,其他所有動態(.dynamic)物理本體便自動受到影響。

那麼,力場影響的空間範圍有多大呢?預設為無窮大,可用第二步的 ForceEffect 縮限空間範圍。

以上三個步驟的圖解如下:

將物理力場元件附掛到個體的程式碼很簡單,上一節用過的隱形個體 Entity() 正合適:
let 力場中心 = Entity()
力場中心.components.set(力場元件)
內容.add(力場中心)

完整範例程式如下:
// 6-13a 物理力場
// Created by Heman Lu on 2025/07/09
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

struct 物理力場: View {
var body: some View {
RealityView { 內容 in
內容.add(座標軸())

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

// 氣泡模型,設定為動態(.dynamic)才會受力場影響
let 氣泡 = MeshResource.generateSphere(radius: 0.1)
let 氣泡模型 = ModelEntity(mesh: 氣泡, materials: [玻璃材質])
氣泡模型.physicsBody = .init(mode: .dynamic)
氣泡模型.physicsBody?.isAffectedByGravity = false
氣泡模型.generateCollisionShapes(recursive: false)
內容.add(氣泡模型)

// 增加力場
let 斜向力 = ConstantForceEffect(
strength: 0.1,
direction: [-1, 1, 0])
let 力場 = ForceEffect(effect: 斜向力)
let 力場元件 = ForceEffectComponent(effect: 力場)

let 力場中心 = Entity()
力場中心.components.set(力場元件)
內容.add(力場中心)

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(物理力場())

這裡面有一行程式碼比較特殊:
氣泡模型.physicsBody?.isAffectedByGravity = false

這行作用是取消重力的影響,若沒有這行,氣泡(動態本體)將會受到重力影響而往下掉。

範例借用6-11e 氣泡戳戳樂的透明氣泡來擔任動態本體,受到力場影響後,會往左上方[-1, 1, 0] 的方向飄去,畫面如下:


💡註解

  1. ForceEffect 可譯為力場或力場效應,但較常用的詞彙其實是 Force field (力場)或 Field effect (場效應),例如場效應電晶體(FET, Field Effect Transistor),這種電晶體原理是利用在小空間內的電磁力場所產生的效應。
  2. 作業1:請增加2個透明氣泡,分散在不同初始位置,觀察力場對不同位置的影響。
  3. 作業2:請將其中一個氣泡模型改為可動本體(.kinematic),觀察力場的影響。
6-13b 調整力場參數

上一節提到物理力場的3個步驟,對應3個物件:ForceEffectComponent, ForceEffect, XXXForceEffect(內建的6種力場),這3個物件都有些參數或屬性,可調整力場特性,彙整如下圖:

(1) ConstantForceEffect

從最右邊開始第一步,以定向力場(ConstantForceEffect)為例,除了物件初始化必要的強度(strength)與方向(direction)之外,還有第3個屬性:力場模式(forceMode) — 用來改變強度(strength)所代表的物理量,有4種模式:

1. acceleration:強度(strength)表示「加速度」,單位 m/sec²
2. force:強度(strength)表示「力」,單位牛頓,此為預設模式
3. impulse:強度(strength)表示「衝量」,單位牛頓秒 (N⋅s)
4. velocity:強度(strength)表示「速度」,單位 m/sec (參考註解1)

例如,若我們想改變定向力場的強度,將固定力改為固定加速度(類似重力),就可寫成:
// 將強度(strength)值改為加速度
var 斜向力 = ConstantForceEffect(
strength: 0.1,
direction: [-1, 1, 0])
斜向力.forceMode = .acceleration

不過要注意,因為物理主體的預設質量都是1Kg,根據 f = ma,在此情況下(m = 1),力(f) = 加速度(a),效果並未改變,所以若要改就得連同質量一起改。

另外,衝量(impulse)是力與時間的乘積,在 RealityKit 框架中,每幀畫面耗時1/60秒,因此力場中,衝量 = 力 x 1/60,也就是,力 = 衝量 x 60,故若我們改成「斜向力.forceMode = .impulse」,此時力的強度將是預設(forceMode = .force)的60倍。

最右邊這6個內建力場,上層共同規範為 ForceEffectProtocol,若想要自己做一個客製化力場,方法就是寫一個符合 ForceEffectProtocol 規範的物件,不過這稍有難度,在此不多介紹。

(2) ForceEffect

第二步,ForceEffect 物件一共有7個參數,除了第一個之外,其他6個都有預設值:
ForceEffect(
effect: ForceEffectType,
strengthScale: Double = 1.0,
spatialFalloff: SpatialForceFalloff? = nil,
timedFalloff: TimedForceFalloff? = nil,
position: SIMD3<Float> = SIMD3<Float>(0, 0, 0),
orientation: simd_quatf = simd_quaternion(0, 0, 0, 1),
mask: CollisionGroup = .all
)
參考:官方文件

這7個參數說明如下:

1. effect: 取第一步做好的內建力場
2. strengthScale: 對力場強度(strength)的縮放比例
3. spatialFalloff: 力場強度依照距離的衰減指數
4. timedFalloff: 力場強度依照時間的衰減指數
5. position: 相對所附掛個體(區域座標)的位置
6. orientation: 相對所附掛個體(區域座標)的旋轉角度
7. mask: 空間中物理本體的過濾條件(符合「碰撞分組」的才受影響)

這其中,比較特殊的是 spatialFalloff 與 timedFalloff,這兩個參數又包成另一個物件,稍嫌麻煩,使用範例如下:
let 力場 = ForceEffect(
effect: 斜向力,
spatialFalloff: .init(
bounds: .sphere(radius: 0.5), // 衰減距離:0.5米(任何方向)
rate: 2.0, // 衰減指數:每隔0.5米後強度變1/2
distanceOffset: 0.1), // 生效位移:距圓心0.1米起開始衰減
timedFalloff: .init(
duration: 1.0, // 衰減週期1.0秒
rate: 2.0) // 每次衰減週期後,衰減為1/2
)
通常這兩個參數不會同時使用,要嘛用空間衰減,要嘛用時間衰減。

(3) ForceEffectComponent

第三步,力場元件(ForceEffectComponent)也有3個參數,可加入一個力場(effect),或是多個力場(effects) — 此時用陣列 [ ] 來容納,多個力場會同時生效。

力場元件的第3個參數 simulationState 可用來控制力場的暫停、繼續或重新開始(pause, resume, start),類似6-10e 動畫控制器的作用。

以上總結,第一步可改變物理量(力、衝量、加速度、速度),第二步可縮放強度、衰減範圍與過濾條件,第三步可加入一或多個力場及設定暫停。

善用這些設定,就可組合出各種變化。以下我們並用定向力場(ConstantForceEffect)與黏滯力場(DragForceEffect),讓氣泡浮起,但在中途改變行為。

DragForceEffect 黏滯力場

DragForceEffect 是一個相當特殊的力場,會對任移動中的個體產生反向的黏滯力,而且速度越快,粘滯力越強。黏滯力場可想像成一個減速器,讓進入此力場的個體迅速減速。

黏滯力場最令人印象深刻的例子,就是電影「駭客任務(The Matrix)」第一集末尾的高潮,主角 Neo 覺醒後,面對射過來的子彈,不慌不忙伸出手掌讓子彈減速至停止,這種超能力就像黏滯力場。

先看看實際執行的效果:

三個步驟的關鍵程式碼如下,兩個力場均有調整參數,最後一起加入物理力場元件中:
// 增加力場
var 浮力 = ConstantForceEffect(
strength: 0.01,
direction: [0, 1, 0])
浮力.forceMode = .velocity
let 力場1 = ForceEffect(effect: 浮力)

let 黏滯力 = DragForceEffect(strength: 10.0)
let 力場2 = ForceEffect(
effect: 黏滯力,
spatialFalloff: .init(
bounds: .sphere(radius: 0.5),
rate: 2.0))

let 力場元件 = ForceEffectComponent(effects: [力場1, 力場2])
第1個力場是上升浮力,強度0.01,模式為定速(.velocity);第2個力場為黏滯力,強度10,衰減半徑0.5公尺。

最後附上完整範例程式:
// 6-13b 物理力場參數
// Created by Heman Lu on 2025/07/12
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

struct 物理力場: View {
@State var 還原 = false

var body: some View {
RealityView { 內容 in
內容.add(座標軸())

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

// 氣泡模型,設定為動態(.dynamic)才會受力場影響
let 氣泡 = MeshResource.generateSphere(radius: 0.1)
let 氣泡模型 = ModelEntity(mesh: 氣泡, materials: [玻璃材質])
氣泡模型.name = "氣泡"
氣泡模型.position.y = -2.0
氣泡模型.physicsBody = .init(mode: .dynamic)
氣泡模型.physicsBody?.isAffectedByGravity = false
氣泡模型.generateCollisionShapes(recursive: false)
內容.add(氣泡模型)

// 增加力場
var 浮力 = ConstantForceEffect(
strength: 0.01,
direction: [0, 1, 0])
浮力.forceMode = .velocity
let 力場1 = ForceEffect(effect: 浮力)

let 黏滯力 = DragForceEffect(strength: 10.0)
let 力場2 = ForceEffect(
effect: 黏滯力,
spatialFalloff: .init(
bounds: .sphere(radius: 0.5),
rate: 2.0))

let 力場元件 = ForceEffectComponent(effects: [力場1, 力場2])

let 力場中心 = Entity()
力場中心.components.set(力場元件)
內容.add(力場中心)

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
} update: { 內容 in
for 個體 in 內容.entities where 個體.name == "氣泡" {
個體.position.y = 還原 ? -2.0 : -1.5
}
}
.realityViewCameraControls(.orbit)
.overlay(alignment: .bottom) {
Button("還原", systemImage: "arrow.trianglehead.clockwise") {
還原.toggle()
}
.buttonStyle(.borderedProminent)
.padding()
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(物理力場())

💡註解
  1. 請觀察執行時,氣泡浮上來的速度。注意浮力的 forceMode = .velocity,強度為 0.01,照實際上浮的速度看來,絕對不止 0.01 m/sec,有可能是每個 frame 移動 0.01 m,這樣實際速度就是 0.01 x 60 = 0.6 m/sec。
  2. forceMode 每個物理量的單位,在官方文件中並未提及,且不同力場,強度也可能不同,只能自己摸索嘗試。
  3. “Drag” 常譯為「拖曳」(如 DragGesture 拖曳手勢),本意是往後拖(力量與前進方向相反)、扯後腿的意思。
  4. 作業:請調整黏滯力的強度,觀察不同強度的減速效果。
  • 6
內文搜尋
X
評分
評分
複製連結
Mobile01提醒您
您目前瀏覽的是行動版網頁
是否切換到電腦版網頁呢?