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: .single(.yellow), end: .single(.red))
粒子.mainEmitter.color = .evolving(start: .random(a: .blue, b: .purple), end: .random(a: .cyan, b: .white))

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(粒子系統())
文章分享
評分
評分
複製連結

今日熱門文章 網友點擊推薦!