Swift[第6單元(上)] SceneKit 空間運算

補充(5) 物理本體(physicsBody)屬性

上一節(6-4a)提到SceneKit物理模擬主要模擬力學以及碰撞,力學有各種物理定律可遵循,而碰撞則靠「碰撞偵測」演算法,並沒有想像中簡單。想想看,兩個3D物件如何判斷發生碰撞呢?若是球體最簡單,只要判斷球心距離是否小於或等於半徑和即可;但若不是球體,如長方體、角錐體甚至不規則形狀呢?

基於目前的演算法,SceneKit碰撞偵測較擅長平面或凸形輪廓的剛性物體,也就是說,對於有凹洞(像被咬一口的蘋果)或變形的物體(如麻花),比較難準確偵測到碰撞。所謂剛性物體是指碰撞之後不會發生形變,維持固定的形狀輪廓。

單純用 SceneKit程式也較難做出內凹模型,例如缺一斜角的長方體、梯形體、彎月體等,這是因為SceneKit並不支援幾何結構的「減法」。這類物體必須借助第三方3D繪圖軟體(如Tinkercad, Blender)來製作。

至於在力學方面的模擬則還算完整,包括牛頓力學、電磁學、流體力學、重力、扭力等,都可透過各種物理屬性加以調整,目前 physicsBody 的物理屬性及預設值如下:
# physicsBody.屬性名稱 預設值 說明
1 isAffectedByGravity true 是否受重力影響(僅適用於動態本體)
2 mass 1.0 質量,預設為 1Kg
3 centerOfMassOffset (0, 0, 0) 重心偏移(相對於內部座標原點)
4 charge 0.0 帶電量(單位庫倫C),若帶電則受電磁力影響
5 friction 0.5 (滑動)摩擦力(0~1.0)
6 rollingFriction 0.0 滾動摩擦力(0~1.0)
7 restitution 0.5 回彈係數,0 (完全不反彈) ~ 1.0 (完全不損失動能)
8 damping 0.1 (流體或氣體的)阻尼系数,0(無阻尼,保持原速) ~ 1.0 (阻尼大到無法動彈)
9 angularDamping 0.1 (流體或氣體的)旋轉阻尼0~1.0
10 momentOfInertia (0, 0, 0) 轉動慣量,數值越大越不容易轉動(受扭力影響)
11 velocity (0, 0, 0) 目前在空間中的前進速度(SCNVector3),單位「米/秒」(m/sec)
12 angularVelocity (0, 0, 0, 0) 目前的角速度(SCNVector4),單位「弧度/秒」(rad/sec)
13 velocityFactor (1, 1, 1) 速度倍數
14 angularVelocityFactor (1, 1, 1) 角速度倍數
15 isResting false 目前是否靜止
16 allowsResting true 是否允許靜止狀態
17 linearRestingThreshold 0.1 視為靜止的(線性)速率門檻
18 angularRestingThreshold 0.1 視為靜止的角速率門檻
19 applyForce() 帶入參數 對某個方向施力(單位:牛頓)
20 applyTorque() 帶入參數 依某個軸心施以扭力(讓物體旋轉)
21 clearAllForces() 無參數 清除所有外力

例如,對於上一節的範例程式,我們可以調整小球的物理特性,增加反彈(restitution)係數,並施以外力(applyForce):
// 小球節點與physicsBody都是Optional類型,所以變數名稱後面會加上問號?
.onTapGesture {
let 小球節點 = 物理教室.rootNode.childNode(withName: "小球", recursively: true)
小球節點?.physicsBody = .dynamic()
小球節點?.physicsBody?.restitution = 0.9
小球節點?.physicsBody?.applyForce(SCNVector3(0.3, 4, -0.3), asImpulse: true)
}

反彈係數從預設的0.5增加到0.9,這樣在落下時,反彈效果較明顯;另外加上(0.3, 4, -0.3)的外力,此值表示力的向量,往東北方(x=0.3, z=-0.3)向上(y=4)施力,單位是牛頓。根據公式 f = ma:
👉 1 newton = 1Kg x 1m/sec²

往垂直上方施以4牛頓的力,可以產生 4m/sec² 往上的加速度(小球預設質量恰為1Kg)。第二個參數 "asImpulse: true” 表示以衝量(Impulse)施力1秒鐘,得到垂直向上4m/sec的初始速度。加了這兩行程式,就可看到小球沿拋物線運動的結果:


修改後的完整程式如下:
// 補充(5) 物理屬性 physicsBody
// Revised by Heman, 2024/04/12
import SceneKit
import SwiftUI

struct 物理模擬: View {
let 物理教室 = SCNScene()

func 空間座標系(尺寸半徑: CGFloat) -> SCNNode {
let x軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let x軸節點 = SCNNode(geometry: x軸)
x軸節點.rotation = SCNVector4(x: 0, y: 0, z: 1, w: .pi / -2.0)

let y軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let y軸節點 = SCNNode(geometry: y軸)

let z軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let z軸節點 = SCNNode(geometry: z軸)
z軸節點.rotation = SCNVector4(x: 1, y: 0, z: 0, w: .pi / 2.0)

let 座標原點 = SCNNode(geometry: SCNSphere(radius: 0.02))
座標原點.addChildNode(x軸節點)
座標原點.addChildNode(y軸節點)
座標原點.addChildNode(z軸節點)

return 座標原點
}

var body: some View {
SceneView(scene: 物理教室, options: [.allowsCameraControl, .autoenablesDefaultLighting])
.onTapGesture {
let 小球節點 = 物理教室.rootNode.childNode(withName: "小球", recursively: true)
小球節點?.physicsBody = .dynamic()
小球節點?.physicsBody?.restitution = 0.9
小球節點?.physicsBody?.applyForce(SCNVector3(0.3, 4, -0.3), asImpulse: true)
}
.onAppear {
let 小球 = SCNSphere(radius: 0.05)
let 小球節點 = SCNNode(geometry: 小球)
小球節點.name = "小球"
小球節點.position = SCNVector3(0, 0.5, 0)
// 小球節點.physicsBody = .dynamic()

let 金字塔 = SCNPyramid(width: 1.6, height: 0.2, length: 1.0)
let 金字塔節點 = SCNNode(geometry: 金字塔)
金字塔節點.position.y = -0.8
金字塔節點.physicsBody = .kinematic()

let 轉動 = SCNAction.rotateBy(x: 0, y: .pi * 2.0, z: 0, duration: 10)
金字塔節點.runAction(.repeatForever(轉動))

let 地面節點 = SCNNode(geometry: SCNFloor())
地面節點.position.y = -1.0
地面節點.physicsBody = .static()

物理教室.rootNode.addChildNode(空間座標系(尺寸半徑: 1.0))
物理教室.rootNode.addChildNode(小球節點)
物理教室.rootNode.addChildNode(金字塔節點)
物理教室.rootNode.addChildNode(地面節點)
物理教室.background.contents = UIColor.green
}
}
}

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

💡 註解
  1. 用 applyForce() 或 applyTorque() 對物理本體施力,僅對動態(dynamic)本體有效。
  2. 【作業】仔細觀察小球與金字塔碰撞反彈的位置是否精確。
  3. 【作業】更改applyForce第二個參數為”asImpulse: false”,看結果如何?(提示:僅作用於單幀,也就是1/60秒,故作用力需放大60倍才能得到同樣效果,或用於 SCNSceneRendererDelegate)
6-4b 模擬關節 SCNPhysicsHingeJoint

SceneKit 物理模擬除了基本的力場與碰撞之外,還有兩個相當有趣的模擬應用,一是「關節」(Joint),可以將兩個物理本體連結在一起,組合成一個活動物件;另一個是「車輛」(Vehicle),模擬路面上行駛的車輛行為。

本節先來學習如何使用「關節」,SceneKit 模擬的關節有4種:

1. SCNPhysicsHingeJoint 鉸鍊關節:只能在某一固定方向轉動的關節
2. SCNPhysicsSliderJoint 滑動關節:可以在兩個物件之間滑動的鉸鏈關節
3. SCNPhysicsBallSocketJoint 球型關節:所有方向均可轉動的關節
4. SCNPhysicsConeTwistJoint 圓錐型關節:能限定轉動角度的球型關節

第5單元5-3b我們曾學過,利用人工智慧辨識人體姿態,基本上會找出人體的19個特徵點,特別是關節的位置,追蹤這些關鍵位置,就可以操控虛擬人物來模仿真人的肢體動作。而虛擬人物的關節,則通常用球型關節,或更精確的圓錐型關節來模擬。

接下來我們用鉸鏈關節做一個單擺,其他關節種類語法類似,就留給讀者自行探索。

單擺可分成三部分:固定點、擺線、擺錘,固定點與擺錘都可用圓球(SCNSphere)來模擬,固定點設為 static(),擺錘設為 dynamic(),兩者用鉸鏈關節組合在一起。

但是擺線怎麼做呢?這是個問題,SceneKit 似乎沒有模擬繩子的能力。想了半天,最後是仿座標軸,用半徑0.3公分的圓管(SCNTube)做成擺線,並且透過 addChildNode 和擺錘綁在一起,這樣擺錘跟擺線就會一起動作。要注意擺線不可設為物理本體。

初始條件是固定點置於座標原點,擺錘放在左側一公尺(-1, 0, 0)位置,然後用鉸鏈關節組合在一起:


鉸鏈關節(SCNPhysicsHingeJoint)的用法如下,共有6個參數,包括兩個物件的物理本體、固定方向的旋轉軸(均設為Z軸)以及旋轉錨點,其中比較重要的是旋轉錨點(錨點相當於第3課提到的樞紐),在指定錨點位置時,是以節點的內部座標為準,擺錘的錨點設在右方1公尺,故座標為(1, 0, 0) — 預設擺長1公尺。
let 單擺 = SCNPhysicsHingeJoint(
bodyA: 固定節點.physicsBody!,
axisA: SCNVector3(0, 0, 1),
anchorA: SCNVector3Zero,
bodyB: 擺錘節點.physicsBody!,
axisB: SCNVector3(0, 0, 1), // 可依Z軸轉動
anchorB: SCNVector3(擺長, 0, 0)) // 錨點在擺錘右方1公尺

從這個例子可以看出,鉸鏈關節連結兩個物件的距離,可長可短,不像實體的鉸鏈有一定的大小限制 — 用 SceneKit 寫空間運算程式,需要良好的空間概念與想像力。

做好鉸鏈關節之後,必須通知SceneKit的場景,我們的場景命名為「物理教室」,其中屬性 physicsWorld 用來存放物理模擬的全域參數(例如重力加速度,預設值9.8m/s²),在此必須用 addBehavior() 加入做好的關節:
物理教室.physicsWorld.addBehavior(單擺)    // 放在最後

這行程式最好放在其他「場景.rootNode.addChildNode()」之後,也就是說,場景的節點都佈置好之後,再加入模擬行為,以避免產生一些難以預期的問題。

物理世界(physicsWorld)是場景的屬性,物理世界之下又有許多屬性及方法,目前我們只用到「加入行為」(addBehavior),其他重要屬性(僅部分)可參考下圖:


這樣做出模擬單擺的效果非常自然,比起用圓周運動,要計算重力加速度與張力,顯得簡單多了:


完整程式碼如下:
// 6-4b 單擺 SCNPhysicsHingeJoint
// Created by Heman, 2024/04/14
import SceneKit
import SwiftUI

struct 單擺系統: View {
let 物理教室 = SCNScene()
let 擺長 = 1.0

func 空間座標系(尺寸半徑: CGFloat) -> SCNNode {
let x軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let x軸節點 = SCNNode(geometry: x軸)
x軸節點.rotation = SCNVector4(x: 0, y: 0, z: 1, w: .pi / -2.0)

let y軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let y軸節點 = SCNNode(geometry: y軸)

let z軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let z軸節點 = SCNNode(geometry: z軸)
z軸節點.rotation = SCNVector4(x: 1, y: 0, z: 0, w: .pi / 2.0)

let 座標原點 = SCNNode(geometry: SCNSphere(radius: 0.02))
座標原點.addChildNode(x軸節點)
座標原點.addChildNode(y軸節點)
座標原點.addChildNode(z軸節點)

return 座標原點
}

var body: some View {
SceneView(scene: 物理教室, options: [.allowsCameraControl, .autoenablesDefaultLighting])
.onAppear {
let 擺線 = SCNTube(innerRadius: 0, outerRadius: 0.003, height: 擺長)
let 擺線節點 = SCNNode(geometry: 擺線)
擺線節點.rotation = SCNVector4(0, 0, 1, .pi / 2.0) // 轉為水平
擺線節點.position.x = Float(擺長) / 2

let 擺錘 = SCNSphere(radius: 0.06)
let 擺錘節點 = SCNNode(geometry: 擺錘)
擺錘節點.addChildNode(擺線節點) // 將擺線加入子節點
擺錘節點.position.x = Float(-擺長) // 擺錘+擺線左移1公尺
擺錘節點.physicsBody = .dynamic()

let 固定點 = SCNSphere(radius: 0.02)
let 固定節點 = SCNNode(geometry: 固定點)
固定節點.physicsBody = .static()

let 單擺 = SCNPhysicsHingeJoint(
bodyA: 固定節點.physicsBody!,
axisA: SCNVector3(0, 0, 1),
anchorA: SCNVector3Zero,
bodyB: 擺錘節點.physicsBody!,
axisB: SCNVector3(0, 0, 1), // 可依Z軸轉動
anchorB: SCNVector3(擺長, 0, 0)) // 錨點在擺錘右方1公尺

物理教室.rootNode.addChildNode(空間座標系(尺寸半徑: 擺長))
物理教室.rootNode.addChildNode(固定節點)
物理教室.rootNode.addChildNode(擺錘節點)
物理教室.background.contents = UIColor.blue
物理教室.physicsWorld.addBehavior(單擺) // 放在最後
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(單擺系統())

💡 註解
  1. 早期有款全球爆紅遊戲叫“Cut the Rope” (割繩子、怪物吃糖果,2010年發行),繩子做得惟妙惟肖,不知道怎麼做到的。
  2. 單擺運動是古典運動學(Kinematics)的經典題目,西方研究了上千年,不但藉此發明時鐘(鐘擺),更發現地球自轉的奧秘(傅科擺),伽利略、惠更斯和牛頓都曾仔細研究過單擺運動。
  3. 牛頓擺並非牛頓所發明,而是後人紀念牛頓而命名。
  4. 【作業】單擺的週期與擺長有關,但與擺垂質量、擺幅角度無關,請更改程式驗證看看。
  5. 【作業】對固定節點加入左右移動的動作,單擺會跟著移動嗎?
        let 右移 = SCNAction.moveBy(x: 0.5, y: 0, z: 0, duration: 5)
    let 左移 = SCNAction.moveBy(x: -1.0, y: 0, z: 0, duration: 10)
    let 左右移動 = SCNAction.sequence([右移, 左移, 右移])
    固定節點.runAction(.repeatForever(左右移動))

  6. 【作業】將吊繩節點加上物理模擬,觀察結果如何?
        吊繩節點.physicsBody = .kinematic()
    // 或
    吊繩節點.physicsBody = .static()

  7. 【挑戰題】用多個單擺組合成牛頓擺,實體運作可參考台中科博館孫維新館長的示範,請用SCNPhysicsHingeJoint製作一個5顆小球的牛頓擺。
補充(6) 碰撞處理SCNPhysicsContactDelegate

物理模擬的兩個基本功能包括力場模擬與碰撞偵測,光是用這兩個功能就可以做出非常好玩的遊戲,例如可以觀察到二項式分佈(常態分佈)的高爾頓板(Galton Board)。


用SceneKit製作高爾頓板非常簡單,因為物理模擬已將最困難的部分解決了,我們只要做出多層排列的固定柱以及不斷生成的鋼珠,加上背板、隔板、地面等輔助物件即可,除了鋼珠設為動態(dynamic)主體之外,其他均可設為靜態(static)。

生成的鋼珠從最上方掉落,與固定柱碰撞後,會隨機向左或向右掉落,最終落到地面,由隔板分開,若鋼珠數量夠多的話,掉落在隔板中的數目會接近常態分佈,其中還隱藏著巴斯卡三角形的奧秘。


本節重點除了做出高爾頓板之外,還有個任務是「碰撞處理」,所謂碰撞處理是指兩個物理本體接觸(碰撞發生)時,加入額外的程式邏輯,例如,發出碰撞音。但希望只在鋼珠碰撞到固定柱瞬間發出聲響;落到地面後,鋼珠與鋼珠,或鋼珠與背板、隔板、地面會一直保持接觸,這時就不發聲響(否則會很吵)。

程式執行時,什麼時候會發生碰撞?其實並無法預期,這種不知道何時會發生的事件在作業系統稱為非同步事件,若是SwiftUI通常用 .onChange, .onTapGuesture 之類的視圖修飾語來處理;對較早的 UIKit 或 SceneKit 來說,則是用「代理程式」“Delegate” 來接收並處理非同步事件。

當非同步事件發生時,作業系統會負責通知代理程式(通常是一個物件),並呼叫對應的函式(即物件方法)。所以我們要寫的碰撞處理程式並非由我們主動呼叫,而是被動地等作業系統呼叫,這種反向呼叫又稱為Callback函式,背後機制是第3單元介紹過的發行者-訂閱者(Publisher-Subscriber)通訊模式。

在SceneKit中負責碰撞處理的稱為SCNPhysicsContactDelegate,其中關鍵字是 Contact Delegate,意思是接觸(碰撞)代理者。這是一個規範(protocol),其中規定3個函式可被呼叫,函式內容由我們自行定義:
// (1) 碰撞開始時,會呼叫:
func physicsWorld(_: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact)

// (2) 持續碰撞時,會呼叫:
func physicsWorld(_: SCNPhysicsWorld, didUpdate contact: SCNPhysicsContact)

// (3) 碰撞結束後,會呼叫:
func physicsWorld(_: SCNPhysicsWorld, didEnd contact: SCNPhysicsContact)

這3個函式分別會在碰撞開始、持續發生、結束後被呼叫,函式包含兩個參數,第一個參數是發生碰撞的物理世界,第二個參數是發生碰撞(接觸)的雙方(兩個節點)。兩個參數會由作業系統自動帶進來。

我們的需求是在碰撞一開始發出聲響即可,另兩個函式就無須理會。至於如何發出聲響,可以仿照第3課6-3c,使用 SCNAction.playAudio(),並沿用水滴聲:
class 碰撞處理: NSObject, SCNPhysicsContactDelegate {
var 水滴聲 = SCNAudioSource(named: "waterdrop.m4a")

func physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact) {
print("碰撞開始: \(Date())", contact.nodeA.name, contact.nodeB.name)
if 水滴聲 != nil {
let 聲響 = SCNAction.playAudio(水滴聲!, waitForCompletion: false)
contact.nodeA.runAction(聲響)
}
}
}

第一行的 class 與 NSObject 曾在第5單元第9課介紹過,不再贅述。

設計好碰撞處理程式(物件)之後,該怎麼用呢?很簡單,先產出一個物件實例,然後在場景「物理教室」的物理世界(physicsWorld)中指定給 contactDelegate 即可:
struct 高爾頓板: View {
let 物理教室 = SCNScene()
let 助手 = 碰撞處理()

var body: some View {
SceneView(scene: 物理教室, options: [.allowsCameraControl, .autoenablesDefaultLighting])
.onAppear {
// ...
物理教室.physicsWorld.contactDelegate = 助手
}
}
}

碰撞處理除了要準備代理程式之外,還需要額外做些設定,否則預設情況下,碰撞處理程式並不會被呼叫。

前面提過,我們希望鋼珠碰到固定柱時發出音效,但鋼珠與鋼珠、鋼珠與地面接觸時,則保持靜音,這該如何做呢?SceneKit提供一個分組配對的方法,每個物理本體,都有3個遮罩屬性:

1. categoryBitMask: 類別遮罩(dynamic/kinematic 預設1,static 預設2)
2. contactTestBitMask: 接觸遮罩(預設均為0)
3. collisionBitMask: 碰撞遮罩(預設所有bit均為1)

其中所謂的“BitMask”(位元遮罩)是分組配對機制:這3個屬性都是64位元整數,每個位元代表一組,若遮罩兩兩比較有相同位元同時為1,則視為同一組,故最多可分64組。

這其中用到二進位算術:

- 二進位 000…0001 == 十進位 1 (2⁰)
- 二進位 000…0010 == 十進位 2 (2¹)
- 二進位 000…0100 == 十進位 4 (2²)
- 二進位 000…1000 == 十進位 8 (2³)

… 以此類推

位元遮罩的好處是彈性高且比對速度快,在程式設計經常用到。若某節點同時屬於兩組,只要將遮罩相加即可,例如同時屬於1 (0001)組與2 (0010)組,則設定為3 (0011)即可;若每一組都要參加,則將所有位元均設為1。

不過要注意,配對時並不是相同屬性互相比較,而是用類別遮罩(categoryBitMask)與其他兩個比較。若A的類別遮罩(categoryBitMask)與B的接觸遮罩(contactTestBitMask)有同組位元為1時,則發出接觸事件,此時會呼叫上面的碰撞(接觸)處理程式。

例如,若A是動態本體,類別(categoryBitMask)預設為1,而B是靜態本體,類別預設為2,因此只要將B的接觸遮罩(contactTestBitMask)設為1,或將A的接觸遮罩設為2,就能產生接觸事件。

至於碰撞遮罩(collisionBitMask),並不會影響碰撞處理程式是否被呼叫,而是影響碰撞後,兩者會互相彈開,還是彼此穿透。預設值是所有物理本體與其他本體會發生碰撞並彈開,若將動態A的碰撞遮罩設為0,則A會穿透所有物體,掉到螢幕下消失不見。

說起來很複雜,但實際設定很簡單,程式只要增加一行。由於所有物理本體的接觸遮罩(contactTestBitMask)預設值均為0,所以預設情況下,都不會觸發接觸事件,我們只須修改固定柱節點的接觸遮罩即可:
固定柱節點.physicsBody?.contactTestBitMask = 1

這樣就只有固定柱與鋼珠接觸時會發出音效,鋼珠與地板、背板、隔板碰撞則不會,而且鋼珠與鋼珠之間碰撞也不會觸發事件。

接下來就先完成最基本的程式架構,加以驗證。在執行前,請記得關閉Swift Playgrounds的「啟用結果」(在「執行我的程式碼」左邊),否則容易與音效衝突導致程式卡住。
// 補充(6) 碰撞處理 SCNPhysicsContactDelegate
// 高爾頓板 Galton Board v0.1
// Created by Heman, 2024/04/18
// Swift Playgrounds App: 執行前請關閉「啟用結果」
import SceneKit
import SwiftUI

struct 高爾頓板: View {
let 物理教室 = SCNScene()
let 間距 = 0.2 // 最小間距0.1
let 助手 = 碰撞處理()

func 空間座標系(尺寸半徑: CGFloat) -> SCNNode {
let x軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let x軸節點 = SCNNode(geometry: x軸)
x軸節點.rotation = SCNVector4(x: 0, y: 0, z: 1, w: .pi / -2.0)

let y軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let y軸節點 = SCNNode(geometry: y軸)

let z軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let z軸節點 = SCNNode(geometry: z軸)
z軸節點.rotation = SCNVector4(x: 1, y: 0, z: 0, w: .pi / 2.0)

let 座標原點 = SCNNode(geometry: SCNSphere(radius: 0.02))
座標原點.addChildNode(x軸節點)
座標原點.addChildNode(y軸節點)
座標原點.addChildNode(z軸節點)

return 座標原點
}

var body: some View {
SceneView(scene: 物理教室, options: [.allowsCameraControl, .autoenablesDefaultLighting])
.onTapGesture {
var 微小亂數: Float { .random(in: -0.01 ... 0.01) }
let 鋼珠 = SCNSphere(radius: 間距 * 0.3)
let 鋼珠節點 = SCNNode(geometry: 鋼珠)
鋼珠節點.name = "鋼珠"
鋼珠節點.position.x = 微小亂數
鋼珠節點.position.y = Float(間距) * 2 + 微小亂數
鋼珠節點.position.z = Float(間距) * 0.5
鋼珠節點.physicsBody = .dynamic()
物理教室.rootNode.addChildNode(鋼珠節點)
}
.onAppear {
let 固定柱 = SCNCylinder(radius: 間距 * 0.1, height: 間距)
let 固定柱節點 = SCNNode(geometry: 固定柱)
固定柱節點.name = "柱子"
固定柱節點.rotation = SCNVector4(1, 0, 0, .pi * -0.5)
固定柱節點.position.z = Float(間距) * 0.5
固定柱節點.physicsBody = .static()
固定柱節點.physicsBody?.contactTestBitMask = 1

let 地面 = SCNFloor()
let 地面節點 = SCNNode(geometry: 地面)
地面節點.position.y = Float(間距) * -2.0
地面節點.physicsBody = .static()

物理教室.rootNode.addChildNode(空間座標系(尺寸半徑: 間距 * 2.0))
物理教室.rootNode.addChildNode(固定柱節點)
物理教室.rootNode.addChildNode(地面節點)
物理教室.background.contents = UIColor.blue

物理教室.physicsWorld.contactDelegate = 助手
}
}
}

class 碰撞處理: NSObject, SCNPhysicsContactDelegate {
var 水滴聲 = SCNAudioSource(named: "waterdrop.m4a")

func physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact) {
print("碰撞開始: \(Date())", contact.nodeA.name, contact.nodeB.name)
if 水滴聲 != nil {
let 聲響 = SCNAction.playAudio(水滴聲!, waitForCompletion: false)
contact.nodeA.runAction(聲響)
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(高爾頓板())

這段程式執行結果如下:


💡 註解

  1. 早期電動玩具最受歡迎的實體機台就是「彈珠台」,比高爾頓板稍微複雜一些,用 SceneKit 同樣做得出來。
  2. Delegate 是被委任者、代理者、代表的意思,在UIKIt中通常以規範(protocol)的形式出現,所以實際上是要做一個符合規範的物件,由物件中的方法(函式)來處理非同步事件。
  3. 為什麼位元遮罩不用同一屬性(A的接觸遮罩與B的接觸遮罩)來比較就好?因為如此一來,同一類(例如鋼珠)之間,就一定會產生接觸事件。用兩組遮罩交叉比對,會有更多彈性,才能符合遊戲場景的需求。
  4. 【作業】若是將鋼珠或固定柱的類別遮罩(categoryBitMask)設為0,會如何?
  5. 【作業】若是修改鋼珠節點的接觸遮罩contactTestBitMask = 2,會產生碰撞音效嗎?
    鋼珠節點.physicsBody?.contactTestBitMask = 2

  6. 【作業】怎樣才能設計出完美的高爾頓板呢?根據二項式分佈的原理,應該讓鋼珠在每一層都進行一次左右判定,因此鋼珠的彈性不能太好,否則會跳過好幾層;也不能一次灑太多鋼珠。想想看,還有其他哪些因素?
補充(7) 高爾頓板Galton Board 完整版

用SceneKit製作高爾頓板,除了物理模擬與碰撞處理之外,其他就剩下位置座標計算,這部分考驗耐心與細心,並沒有特別困難之處。筆者是先在紙上畫好草圖,再逐步用程式測試、修正。

由於SceneKit物件剛產出時,幾何中心均位於座標原點,必須適度加以旋轉(如固定柱)、位移到預定位置,因此先畫出X/Y/Z座標軸,在測試過程中會很有幫助,等全部測試好再將座標軸隱藏起來。

各物件的寬度(X座標)、深度(Z座標)位置計算相對單純,在此不多討論。比較費心的是高度(Y座標),先設定兩個常數 — (1)層高、(2)層數,其他高度由這兩個常數導出,包括:
(3) 全高:整個固定柱三角形的高度
(4) 半高:固定柱三角形的一半高度
(5) 固定柱間距:層高 x √2,固定柱之間成正方形排列
(6) 隔板高度:設定為半高,會隨著「層數」而增加
(7) 隔板與固定柱之間相隔一層高
(8) 鋼珠初始位置與最高固定柱間隔4層高,保持固定高度

將座標原點放在固定柱三角形的中央,其他所有物件位移到預定位置。


物件之中,固定柱的數量比較大,我們用兩層 for 迴圈來產出,產出之前,先做最上層的一根固定柱,當做其他固定柱的父節點,這樣就能一起旋轉、位移。
let 根柱 = SCNCylinder(radius: 間距 * 0.04, height: 間距)
let 根柱節點 = SCNNode(geometry: 根柱)
根柱節點.name = "柱子"
根柱節點.rotation = SCNVector4(1, 0, 0, .pi / -2.0)
根柱節點.position.z = Float(間距 / -2.0)
根柱節點.position.y = Float(半高)
根柱節點.physicsBody = .static()

for i in 0 ..< 層數 {
for j in 0 ... i {
let 固定柱 = SCNCylinder(radius: 間距 * 0.05, height: 間距)
let 固定柱節點 = SCNNode(geometry: 固定柱)
固定柱節點.name = "柱子"
固定柱節點.position.x = Float(層高) * Float(j * 2 - i)
固定柱節點.position.z = Float(層高) * Float(-i)
固定柱節點.physicsBody = .static()
固定柱節點.physicsBody?.contactTestBitMask = 1 // 碰撞偵測

根柱節點.addChildNode(固定柱節點)
}
}

在 for 迴圈之中,剛產出的固定柱都是位於原點、上下垂直,因此先沿著負Z軸鋪成三角形(計算X/Z位移),最後再與根柱節點一起轉為水平(依X軸轉-90°)並移到適當高度與深度。

前後背板以及底下的隔板都是用 SCNPlane 來做,SCNPlane 產出時,預設是單面可視,正面不透明(可加顏色或材質),背面看則完全透明。我們將背板保持原狀,前板依Y軸旋轉180°,與背板前後包夾,這樣前後兩面都可看到鋼珠滾下的效果:
正面節點.rotation = SCNVector4(0, 1, 0, Float.pi)

底下隔板的 SCNPlane() 則改為雙面可視,均為半透明(透明度0.3),指令如下:
let 隔板 = SCNPlane(width: 間距, height: 半高)
隔板.materials.first?.isDoubleSided = true
let 隔板節點 = SCNNode(geometry: 隔板)
隔板節點.opacity = 0.3

鋼珠的產出可寫成一個函式,方便重複呼叫。為了讓鋼珠碰到固定柱之後,往左或往右的機率各佔一半,程式加入一個「微小亂數」,在X/Y座標稍微偏離初始位置。每種數值類型(Int, Float, Double)都有個類型方法 .random() 可以產出隨機亂數,相當好用。
func 產出鋼珠(_ 數量: Int = 1) {
var 微小亂數: Float {
.random(in: Float(層高) * -0.03 ... Float(層高) * 0.03)
}

let 金屬材質 = SCNMaterial()
金屬材質.lightingModel = .physicallyBased
金屬材質.metalness.contents = UIColor.gray
金屬材質.diffuse.contents = UIColor.cyan

if 數量 < 1 { return }
for i in 0 ..< 數量 {
let 鋼珠 = SCNSphere(radius: 層高 * 0.45)
鋼珠.materials = [金屬材質]
let 鋼珠節點 = SCNNode(geometry: 鋼珠)
鋼珠節點.name = "鋼珠"
鋼珠節點.position.x = 微小亂數
鋼珠節點.position.y = 微小亂數 + Float(層數 + 7) * Float(層高) * 0.5
鋼珠節點.position.z = Float(層高 * -0.707) // 1/√2 = 0.707
鋼珠節點.physicsBody = .dynamic()
鋼珠節點.physicsBody?.restitution = 0.7 // 反彈係數
if 數量 > 1 {
鋼珠節點.physicsBody?.categoryBitMask = 4 // 不發出聲音
}
物理教室.rootNode.addChildNode(鋼珠節點)

}
}

當程式執行時,每次輕觸螢幕就產出一顆鋼珠,從初始位置掉下來,當碰觸到固定柱時,因為預設類別遮罩等於1,就會觸發事件而發出音效(參考上一節補充6說明);若長按螢幕,則一次產出多顆鋼珠,此時將類別遮罩(categoryBitMask)改為4(二進位0100),避免觸發事件,以保持安靜。

最後在螢幕右上角做一個聲音開關,若靜音時,即便觸發事件也會取消音效:
.overlay(alignment: .topTrailing) {
Image(systemName: 靜音 ? "speaker.slash" : "speaker.wave.2")
.font(.system(size: 32))
.foregroundStyle(Color.white)
.padding(15)
.onTapGesture {
靜音.toggle()
助手.水滴聲 = 靜音 ? nil : SCNAudioSource(named: "waterdrop.m4a")
}
}

完整程式碼如下,雖然超過150行,但大多是重複的程式邏輯,比較困難的物理模擬與碰撞處理,都已講解過,閱讀起來應該不難:
// 補充(7) 碰撞處理 SCNPhysicsContactDelegate
// 高爾頓板 Galton Board v1.0
// Created by Heman, 2024/04/18
// Swift Playgrounds App: 執行前請關閉「啟用結果」
import SceneKit
import SwiftUI

struct 高爾頓板: View {
let 物理教室 = SCNScene()
let 層高 = 0.2 // 最小層高 = 0.1
let 層數 = 5 // 最小層數3
let 助手 = 碰撞處理()
@State var 靜音 = false

func 空間座標系(尺寸半徑: CGFloat) -> SCNNode {
let x軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let x軸節點 = SCNNode(geometry: x軸)
x軸節點.rotation = SCNVector4(x: 0, y: 0, z: 1, w: .pi / -2.0)

let y軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let y軸節點 = SCNNode(geometry: y軸)

let z軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let z軸節點 = SCNNode(geometry: z軸)
z軸節點.rotation = SCNVector4(x: 1, y: 0, z: 0, w: .pi / 2.0)

let 座標原點 = SCNNode(geometry: SCNSphere(radius: 0.02))
座標原點.addChildNode(x軸節點)
座標原點.addChildNode(y軸節點)
座標原點.addChildNode(z軸節點)

return 座標原點
}

func 產出鋼珠(_ 數量: Int = 1) {
var 微小亂數: Float {
.random(in: Float(層高) * -0.03 ... Float(層高) * 0.03)
}

let 金屬材質 = SCNMaterial()
金屬材質.lightingModel = .physicallyBased
金屬材質.metalness.contents = UIColor.gray
金屬材質.diffuse.contents = UIColor.cyan

if 數量 < 1 { return }
for i in 0 ..< 數量 {
let 鋼珠 = SCNSphere(radius: 層高 * 0.45)
鋼珠.materials = [金屬材質]
let 鋼珠節點 = SCNNode(geometry: 鋼珠)
鋼珠節點.name = "鋼珠"
鋼珠節點.position.x = 微小亂數
鋼珠節點.position.y = 微小亂數 + Float(層數 + 7) * Float(層高) * 0.5
鋼珠節點.position.z = Float(層高 * -0.707) // 1/√2 = 0.707
鋼珠節點.physicsBody = .dynamic()
鋼珠節點.physicsBody?.restitution = 0.7 // 反彈係數
if 數量 > 1 {
鋼珠節點.physicsBody?.categoryBitMask = 4 // 不發出聲音
}
物理教室.rootNode.addChildNode(鋼珠節點)
}
}

var body: some View {
SceneView(scene: 物理教室, options: [.allowsCameraControl, .autoenablesDefaultLighting])
.overlay(alignment: .topTrailing) {
Image(systemName: 靜音 ? "speaker.slash" : "speaker.wave.2")
.font(.system(size: 32))
.foregroundStyle(Color.white)
.padding(15)
.onTapGesture {
靜音.toggle()
助手.水滴聲 = 靜音 ? nil : SCNAudioSource(named: "waterdrop.m4a")
}
}
.onTapGesture {
產出鋼珠()
}
.onLongPressGesture {
產出鋼珠(層數)
}
.onAppear {
let 全高 = Double(層數 - 1) * 層高
let 半高 = 全高 * 0.5
let 間距 = 層高 * 1.4142 // sqrt(2)

let 背板 = SCNPlane(width: Double(層數 + 1) * 層高 * 2.0, height: Double(層數 + 5) * 層高 + 半高)
背板.materials.first?.diffuse.contents = UIImage(named: "大理石紋.png")
let 背板節點 = SCNNode(geometry: 背板)
背板節點.position.z = Float(-間距)
背板節點.position.y = Float(半高 * -0.5 + 層高 * 2)
背板節點.physicsBody = .static()

let 正面透明板 = SCNPlane(width: Double(層數 + 1) * 層高 * 2.0, height: Double(層數 + 5) * 層高 + 半高)
let 正面節點 = SCNNode(geometry: 正面透明板)
正面節點.rotation = SCNVector4(0, 1, 0, Float.pi)
正面節點.position.y = Float(半高 * -0.5 + 層高 * 2)
正面節點.physicsBody = .static()

let 字板 = SCNText(string: "Galton Board (c)2024 Heman Lu", extrusionDepth: 1.0)
字板.materials.first?.diffuse.contents = UIColor.gray
let 字板節點 = SCNNode(geometry: 字板)
let 字寬 = 字板節點.boundingBox.max.x
let 版面寬 = Float(層數) * Float(間距) * 0.75
let 比例 = 版面寬 / 字寬
字板節點.opacity = 0.1
字板節點.scale = SCNVector3(比例, 比例, 比例)
字板節點.position.x = 字寬 * 比例 * -0.5
字板節點.position.y = 字板節點.boundingBox.max.y * 比例 * -1
字板節點.position.z = Float(-間距)

let 根柱 = SCNCylinder(radius: 間距 * 0.04, height: 間距)
let 根柱節點 = SCNNode(geometry: 根柱)
根柱節點.name = "柱子"
根柱節點.rotation = SCNVector4(1, 0, 0, .pi / -2.0)
根柱節點.position.z = Float(間距 / -2.0)
根柱節點.position.y = Float(半高)
根柱節點.physicsBody = .static()

for i in 0 ..< 層數 {
for j in 0 ... i {
let 固定柱 = SCNCylinder(radius: 間距 * 0.05, height: 間距)
let 固定柱節點 = SCNNode(geometry: 固定柱)
固定柱節點.name = "柱子"
固定柱節點.position.x = Float(層高) * Float(j * 2 - i)
固定柱節點.position.z = Float(層高) * Float(-i)
固定柱節點.physicsBody = .static()
固定柱節點.physicsBody?.contactTestBitMask = 1 // 碰撞偵測

根柱節點.addChildNode(固定柱節點)
}
let 隔板 = SCNPlane(width: 間距, height: 半高)
隔板.materials.first?.isDoubleSided = true
let 隔板節點 = SCNNode(geometry: 隔板)
隔板節點.opacity = 0.3
隔板節點.rotation = SCNVector4(0, 1, 0, .pi / -2.0)
隔板節點.position.x = Float(i * 2 - 層數 + 1) * Float(層高)
隔板節點.position.y = Float(半高) * -1.5 - Float(層高)
隔板節點.position.z = Float(間距) / -2.0
隔板節點.physicsBody = .static()

物理教室.rootNode.addChildNode(隔板節點)
}

let 地面 = SCNFloor()
let 地面節點 = SCNNode(geometry: 地面)
地面節點.position.y = Float(層數) * Float(-層高)
地面節點.physicsBody = .static()

產出鋼珠()

物理教室.rootNode.addChildNode(空間座標系(尺寸半徑: 間距))
物理教室.rootNode.addChildNode(背板節點)
物理教室.rootNode.addChildNode(正面節點)
物理教室.rootNode.addChildNode(字板節點)
物理教室.rootNode.addChildNode(根柱節點)
物理教室.rootNode.addChildNode(地面節點)
物理教室.background.contents = UIColor.blue

物理教室.physicsWorld.contactDelegate = 助手
}
}
}

class 碰撞處理: NSObject, SCNPhysicsContactDelegate {
var 水滴聲 = SCNAudioSource(named: "waterdrop.m4a")

func physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact) {
print("碰撞開始: \(Date())", contact.nodeA.name, contact.nodeB.name)
if 水滴聲 != nil {
let 聲響 = SCNAction.playAudio(水滴聲!, waitForCompletion: false)
contact.nodeA.runAction(聲響)
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(高爾頓板())

執行結果如下,有幾個觀察重點:
(1) 鋼珠與固定柱接觸的位置
(2) 鋼珠向左或向右的比例
(3) 鋼珠是否每一層固定柱都有碰觸到
(4) 觸發事件與音效


💡 註解
  1. 高爾頓板不一定要放在地面上,可以用 SCNFloor() 或 SCNPlane() 製作一個底板與上蓋,這樣就可以在 AR 或 Vision Pro 中移動、傾斜或翻轉,應該會更好玩。
  2. 若使用Swift Playgrounds App電子書模式,在程式執行前,記得將「啟用結果」關閉,否則層數較多時,容易發生問題。
  3. 若仔細觀察,會發現鋼珠與固定柱並不是理想的在物件邊界碰觸反彈,鋼珠會掉落更深(有時超過圓心)才反彈。這是因為「碰撞偵測」有其精確度限制,且並非隨時在進行,若以幀率60fps計算,最多是1/60秒(約16.667毫秒)才偵測一次。
  4. 碰撞偵測精確與否,對某些遊戲影響很大(例如能否搶到金幣、撞到障礙物受傷等),如何提高碰撞精確度呢?
  5. 【作業】若想讓碰撞偵測更精確,可設定鋼珠物理本體的「連續碰撞偵測門檻」,令此門檻小於鋼珠半徑,代價是計算量較大。例如:
    鋼珠節點.physicsBody?.continuousCollisionDetectionThreshold = 層高 * 0.1



  6. 【作業】請更改層數,試試看最多能跑幾層。
6-4c 模擬車輛SCNPhysicsVehicle

上一節(6-4b)提到SceneKit物理模擬除了基本的力場與碰撞之外,還有兩個特殊應用,包括上一節的模擬關節,以及本節要介紹的模擬車輛。

相比於模擬關節,模擬車輛的程式複雜一些,也較容易出錯,網路上面可參考的資料或範例非常少(雖然此功能已發表10年),筆者經過不斷嘗試錯誤,終於寫出一個可正常運作的版本。

要做出模擬車輛,有幾個條件及步驟:

1. 先設計車身節點,啟動物理模擬,設為動態(dynamic)本體
2. 加入一個或多個輪胎節點,並設為車身的子節點
3. 輪胎節點無需設定物理模擬,只要透過SCNPhysicsVehicleWheel()設定即可
4. 車身與輪胎一起加入SCNPhysicsVehicle(),以產出模擬車輛
5. 最後將模擬車輛透過addBehavior()加入場景的物理世界(physicsWorld)

不過,按照原廠文件做出來的模擬車輛,通常像下面這樣,輪胎轉90°或甚至翻車(天啊,太危險了吧):


可見車身較單純,輪胎則有各式各樣問題。若要讓輪胎正常運作,解法之一是在車身與輪胎之間,多加一個節點「輪圈」(或輪軸、底盤等名稱),節點關係變成「車身 — 輪圈 — 輪胎」。程式碼如下:
let 輪胎 = SCNTorus(ringRadius: 0.5, pipeRadius: 0.2)
輪胎.materials = [金屬大理石]
let 輪胎節點 = SCNNode(geometry: 輪胎)
輪胎節點.rotation = SCNVector4(0, 0, 1, .pi / 2.0)

let 輪圈 = SCNBox(width: 0.3, height: 0.6, length: 0.6, chamferRadius: 0.1)
let 輪圈節點 = SCNNode(geometry: 輪圈)
輪圈節點.addChildNode(輪胎節點)

接下來再以此複製出四個車胎,注意其中用「輪圈節點.clone()」來複製節點(會包含子節點),透過SCNPhysicsVehicleWheel() 做出模擬輪胎,最後還要加上「物理輪胎.suspensionRestLength = 0.30」才不會翻車,suspensionRestLength 用來設定輪胎的懸吊系統。
var 四輪: [SCNPhysicsVehicleWheel] = []
for i in 0 ..< 4 {
let 新輪胎 = 輪圈節點.clone()
switch i {
case 0:
新輪胎.position = SCNVector3(1.2, 0, 2.0)
新輪胎.name = "左前輪"
case 1:
新輪胎.position = SCNVector3(-1.2, 0, 2.0)
新輪胎.name = "右前輪"
case 2:
新輪胎.position = SCNVector3(1.2, 0, -2.0)
新輪胎.name = "左後輪"
case 3:
新輪胎.position = SCNVector3(-1.2, 0, -2.0)
新輪胎.name = "右後輪"
default:
新輪胎.position = SCNVector3Zero
}
車身節點.addChildNode(新輪胎)
let 物理輪胎 = SCNPhysicsVehicleWheel(node: 新輪胎)
物理輪胎.suspensionRestLength = 0.30
四輪.append(物理輪胎)
}

車身與四個輪胎都準備好之後,就可以用SCNPhysicsVehicle()組成車輛:
let 四輪車 = SCNPhysicsVehicle(chassisBody: 車身節點.physicsBody!, wheels: 四輪)

最後加上地面節點,並設為靜態(static),才能承載車輛:
let 地面 = SCNFloor()
地面.materials.first?.diffuse.contents = UIImage(named: "格線.png")
let 地面節點 = SCNNode(geometry: 地面)
地面節點.physicsBody = .static()

為了讓地面能夠分辨方向,這次加上「格線.png」當作材質,圖案如下,請下載後手動匯入Swift Playgrounds(注意存檔名稱要與程式一致):


接下來還有一個難關,是如何給車輛提供動力呢?這裡就需要用到SCNSceneRendererDelegate,這個規範與補充(6)碰撞處理的SCNPhysicsContactDelegate類似,都是非同步事件的處理函式,不過此處是每幀畫面都會觸發,也就是每1/60秒(約16.67毫秒)會被呼叫一次,非常頻繁,因此要謹慎,不要寫得太囉唆,否則會影響畫面流暢。

具體程式碼如下,將代理程式命名為「行車控制系統」,包含一個屬性「載具」,會從「物理教室」場景中將做好的模擬車輛傳過來;renderer() 是規定的處理函式,其中四個輪胎都可輸出動力、控制左右方向,我們先設定前輪驅動,右轉10度,動力5.0牛頓,最高時速10Km/hr:
class 行車控制系統: NSObject, SCNSceneRendererDelegate {
var 載具: SCNPhysicsVehicle? = nil

func renderer(_ renderer: any SCNSceneRenderer, updateAtTime time: TimeInterval) {
renderer.showsStatistics = true
if 載具 != nil {
載具!.setSteeringAngle(.pi / -18, forWheelAt: 0)
載具!.setSteeringAngle(.pi / -18, forWheelAt: 1)
if 載具!.speedInKilometersPerHour < 10 {
載具!.applyEngineForce(5.0, forWheelAt: 0)
載具!.applyEngineForce(5.0, forWheelAt: 1)
}
}
}
}

其中一行 “renderer.showsStatistics = true” 可以開啟效能統計,以便觀察效能是否受到影響。

這個代理程式如何使用呢?必須在一開始,產出一個物件實例,我們命名為「控制器」,將它加到 SceneView() 的 delegate: 參數,之後等做好模擬車輛時,指定給控制器的載具:
struct 車輛系統: View {
let 物理教室 = SCNScene()
@State var 控制器 = 行車控制系統()

var body: some View {
SceneView(scene: 物理教室, options: [.allowsCameraControl, .autoenablesDefaultLighting], delegate: 控制器)
.onAppear {
//...
let 四輪車 = SCNPhysicsVehicle(chassisBody: 車身節點.physicsBody!, wheels: 四輪)
控制器.載具 = 四輪車 // for SCNSceneRendererDelegate
}
}
}

從代理程式的用法,可以看出SCNSceneRendererDelegate與SCNPhysicsContactDelegate都是作用於整個場景或物理世界,而非個別節點。兩相比較,SCNSceneRendererDelegate作用更大更廣,任何場景(不限於物理模擬)都可使用,但是代價也比較高。

幸運的話,這時車輛就會開始跑起來繞圈轉,注意其中座標原點所在與下方即時效能統計。


完整範例程式如下:
// 6-4c 模擬車輛 四輪車
// Created by Heman, 2024/04/28
import SceneKit
import SwiftUI

struct 車輛系統: View {
let 物理教室 = SCNScene()
@State var 控制器 = 行車控制系統()

func 空間座標系(尺寸半徑: CGFloat) -> SCNNode {
let x軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let x軸節點 = SCNNode(geometry: x軸)
x軸節點.rotation = SCNVector4(x: 0, y: 0, z: 1, w: .pi / -2.0)

let y軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let y軸節點 = SCNNode(geometry: y軸)

let z軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let z軸節點 = SCNNode(geometry: z軸)
z軸節點.rotation = SCNVector4(x: 1, y: 0, z: 0, w: .pi / 2.0)

let 座標原點 = SCNNode(geometry: SCNSphere(radius: 0.02))
座標原點.addChildNode(x軸節點)
座標原點.addChildNode(y軸節點)
座標原點.addChildNode(z軸節點)

return 座標原點
}

var body: some View {
SceneView(scene: 物理教室, options: [.allowsCameraControl, .autoenablesDefaultLighting], delegate: 控制器)
.onAppear {
let 金屬大理石 = SCNMaterial()
金屬大理石.lightingModel = .physicallyBased
金屬大理石.metalness.contents = UIImage(named: "大理石紋.png")

let 太空灰 = SCNMaterial()
太空灰.diffuse.contents = UIColor.darkGray

let 車身 = SCNBox(width: 2, height: 1.5, length: 5, chamferRadius: 0.2)
車身.materials = [太空灰]
let 車身節點 = SCNNode(geometry: 車身)
車身節點.physicsBody = .dynamic()

let 輪胎 = SCNTorus(ringRadius: 0.5, pipeRadius: 0.2)
輪胎.materials = [金屬大理石]
let 輪胎節點 = SCNNode(geometry: 輪胎)
輪胎節點.rotation = SCNVector4(0, 0, 1, .pi / 2.0)

let 輪圈 = SCNBox(width: 0.3, height: 0.6, length: 0.6, chamferRadius: 0.1)
let 輪圈節點 = SCNNode(geometry: 輪圈)
輪圈節點.addChildNode(輪胎節點)

var 四輪: [SCNPhysicsVehicleWheel] = []
for i in 0 ..< 4 {
let 新輪胎 = 輪圈節點.clone()
switch i {
case 0:
新輪胎.position = SCNVector3(1.2, 0, 2.0)
新輪胎.name = "左前輪"
case 1:
新輪胎.position = SCNVector3(-1.2, 0, 2.0)
新輪胎.name = "右前輪"
case 2:
新輪胎.position = SCNVector3(1.2, 0, -2.0)
新輪胎.name = "左後輪"
case 3:
新輪胎.position = SCNVector3(-1.2, 0, -2.0)
新輪胎.name = "右後輪"
default:
新輪胎.position = SCNVector3Zero
}
車身節點.addChildNode(新輪胎)
let 物理輪胎 = SCNPhysicsVehicleWheel(node: 新輪胎)
物理輪胎.suspensionRestLength = 0.30
四輪.append(物理輪胎)
}

let 四輪車 = SCNPhysicsVehicle(chassisBody: 車身節點.physicsBody!, wheels: 四輪)
控制器.載具 = 四輪車 // for SCNSceneRendererDelegate

let 地面 = SCNFloor()
地面.materials.first?.diffuse.contents = UIImage(named: "格線.png")
let 地面節點 = SCNNode(geometry: 地面)
地面節點.physicsBody = .static()

物理教室.rootNode.addChildNode(空間座標系(尺寸半徑: 5.0))
物理教室.rootNode.addChildNode(地面節點)
物理教室.rootNode.addChildNode(車身節點)
物理教室.background.contents = UIColor.green
物理教室.physicsWorld.addBehavior(四輪車)
}
}
}

class 行車控制系統: NSObject, SCNSceneRendererDelegate {
var 載具: SCNPhysicsVehicle? = nil

func renderer(_ renderer: any SCNSceneRenderer, updateAtTime time: TimeInterval) {
renderer.showsStatistics = true
if 載具 != nil {
載具!.setSteeringAngle(.pi / -18, forWheelAt: 0)
載具!.setSteeringAngle(.pi / -18, forWheelAt: 1)
if 載具!.speedInKilometersPerHour < 10 {
載具!.applyEngineForce(5.0, forWheelAt: 0)
載具!.applyEngineForce(5.0, forWheelAt: 1)
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(車輛系統())


💡 註解
  1. 在開發遊戲時,我們可先用SceneKit做出概念原型,然後用3D繪圖軟體製作逼真的車輛模型、地面或整個場景,匯入到程式中使用。
  2. SCNSceneRendererDelegate 其中關鍵字是 Renderer,意思是名詞「渲染器」,原形動詞為 Render 渲染,即將場景中各個3D模型變成實際畫面的計算過程。在幀率60fps規格下,整個渲染過程不能超過16.67毫秒。
  3. 可以只用一輪開車嗎?例如只用左前輪輸出動力和轉彎。
  4. 車子能跑在崎嶇不平的地面嗎?怎樣做出有高低起伏的地面?
  5. 有沒有辦法讓鏡頭視角跟著車子走?否則跑太遠會看不到。
  6. 能否做個操控面板,像手機遊戲一樣,左手控制方向,右手控制前進後退的速度。
  7. 【作業】試試自己做個獨輪車或兩輪車。
  8. 【作業】將「效能統計」加到補充(7)高爾頓板,測試看看多少層時,動畫幀率才會開始降到60fps以下。
補充(8) SCNSceneRendererDelegate

SceneKit 動畫預設幀率為60fps,換算每幀畫面渲染時間不超過16.67毫秒(ms),這個過程可細分為幾個步驟,包括動畫/動作、物理模擬(含粒子系統)、約束條件等運算,最後再渲染各個節點的3D模型,而在這四大步驟之間,尚可穿插客製化的代理程式(或稱Callback函式,名稱均為renderer(),以參數標籤區別),示意圖如下:


當我們設計SCNSceneRendererDelegate代理程式時,不必每個renderer()函式都用,用多了反而影響效能,若整個渲染過程太長,超過16.67毫秒,SceneKit會自動降低幀率。

想知道效能是否受影響,可以開啟效能統計,就能看出哪個部分花費較多時間,再設法加以調整、最佳化,是非常有用的工具。效能統計圖詳細說明如下:


上圖顯示的各部分,除了下一課要討論的粒子系統之外,還有個「約束條件」(Constraints)未介紹過,約束條件有何作用呢?上一節註解提到的「鏡頭追蹤」剛好用得上,我們來做個範例。

當模擬車輛在行進時,原來預設的鏡頭視角就不好用了,因為手動追蹤車輛太沒效率,比較理想的是讓鏡頭自動追蹤車輛,怎麼做呢?

透過物理模擬的「約束條件」(Constraint,或稱限制條件),可以很輕易達到鏡頭追蹤的效果。以下程式我們用SCNCamera產出一個新的鏡頭節點,對此節點加兩個約束條件:一是限制此節點與車輛距離不可超過60米,最短距離10米;二是限制鏡頭要永遠看向車輛,並且保持水平。
let 跟隨限制 = SCNDistanceConstraint(target: 車身節點)
跟隨限制.minimumDistance = 10
跟隨限制.maximumDistance = 60
let 視角限制 = SCNLookAtConstraint(target: 車身節點)
視角限制.isGimbalLockEnabled = true
let 鏡頭 = SCNCamera()
let 鏡頭節點 = SCNNode()
鏡頭節點.camera = 鏡頭
鏡頭節點.position = SCNVector3(6, 12, 6)
鏡頭節點.constraints = [跟隨限制, 視角限制]

這樣設定好,鏡頭節點就會取代預設鏡頭,變成使用者視角,跟著車跑,不管車輛左轉、右轉、跑多遠,鏡頭會一直保持車輛在一定距離的視線之內。

簡單地說,約束條件是用來制定某個節點的動畫(移動、旋轉、縮放)條件,讓節點為了滿足條件而自行移動、旋轉或縮放,功能非常強大。SceneKit可設定的約束條件包括以下幾種:

1. SCNBillboardConstraint 告示板條件:限定節點永遠面向鏡頭(使用者的視角)
2. SCNLookAtConstraint 面向條件:限定節點永遠面向另一個節點
3. SCNDistanceConstraint 距離條件:限定節點與另一個節點的最遠、最近距離
4. SCNAvoidOccluderConstraint 遮擋條件:限定節點與另一個節點之間能否有遮擋物
5. SCNAccelerationConstraint 加速條件:限定節點運動時最大加速度與最高速度
6. SCNSliderConstraint 滑動條件:限定節點與其他節點碰撞時自動滑開
7. SCNReplicatorConstraint 複製條件:限定節點複製時的條件
8. SCNIKConstraint 反向(Inverse Kinematics)條件:用反向運動學調整關節角度
9. SCNTransformConstraint 變換條件:對節點施加變換(移動、旋轉、縮放)條件

善用約束條件,可以減少我們手動計算變換矩陣,或在物理模擬多變的情況下,自動滿足所需的條件。

上例第1個約束條件規定最長與最短距離很容易理解,第2個約束條件則需要稍加說明:
let 視角限制 = SCNLookAtConstraint(target: 車身節點)
視角限制.isGimbalLockEnabled = true

SCNLookAtConstraint() 會令鏡頭視角自動調整,使得「車身節點」大約維持在畫面中央;視角限制的另一個屬性 isGimbalLockEnabled 則用來保持水平視角,Gimbal 是某種攝影器材,能讓相機在移動時保持水平(如三軸穩定器)。

做完鏡頭追蹤之後,順便做個控制面板,用來操作左右轉,讓模擬車輛更有可玩性。其中需要用到觸控手勢,在此借用第4單元4-9c的拖曳手勢,將滑動的「位移」設為雙向(@Binding)參數,以傳遞左右轉的角度:
// 6-4補充(8) 控制面板 for 車輛模擬
// Created by Heman, 2024/05/01
import SwiftUI
import SceneKit

struct 控制面板: View {
@Binding var 位移: CGSize
@State var 開始 = Date()
var 拖曳: some Gesture {
DragGesture()
.onChanged { 拖曳參數 in
if 位移 == .zero { 開始 = 拖曳參數.time }
位移 = 拖曳參數.translation
// print(拖曳開始)
// print(拖曳位移)
}
.onEnded { 拖曳參數 in
let 時間差 = 拖曳參數.time.timeIntervalSince(開始)
let 速度 = 拖曳參數.translation.width / 時間差
if abs(速度) < 300 {
位移 = .zero
}
// print(時間差, 拖曳參數.translation)
// print(速度)
}
}
var body: some View {
ZStack {
Circle()
.opacity(0.2)
Circle()
.opacity(0.1)
.scaleEffect(0.4)
Image(systemName: "location.north.fill")
.rotationEffect(.degrees(位移.width * 0.5))
HStack {
Image(systemName: "arrowshape.left")
Spacer()
Image(systemName: "arrowshape.right")
}
VStack {
Image(systemName: "arrowshape.up")
Spacer()
Image(systemName: "arrowshape.down")
}
}
.foregroundStyle(Color.green)
.frame(width: 220, height: 220)
.font(.system(size: 64))
.opacity(0.5)
.gesture(拖曳)
}
}

struct 面板測試: View {
@State var t: CGSize = .zero
var body: some View {
控制面板(位移: $t)
.onChange(of: t) {
print(t.width)
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(面板測試())

用SwiftUI做的控制面板執行結果如圖:


模擬車輛加入控制面板後,就能手動控制方向,在面板上左右滑動時,面板中間箭頭會轉動,車輛兩個前輪也跟著連動,跑起來相當滑順,底下影片還可觀察鏡頭自動追蹤的效果(讓車輛保持在畫面中央):


整合以上的程式碼如下(要另外加上「控制面板」,同時取消.allowsCameraControl選項):
// 6-4補充(8) 模擬車輛v2 鏡頭追蹤+控制左右轉
// Created by Heman, 2024/05/01
import SceneKit
import SwiftUI

struct 車輛系統v2: View {
let 物理教室 = SCNScene()
@State var 控制器 = 行車控制系統()
@State var 左右轉: CGSize = .zero

func 空間座標系(尺寸半徑: CGFloat) -> SCNNode {
let x軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let x軸節點 = SCNNode(geometry: x軸)
x軸節點.rotation = SCNVector4(x: 0, y: 0, z: 1, w: .pi / -2.0)

let y軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let y軸節點 = SCNNode(geometry: y軸)

let z軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let z軸節點 = SCNNode(geometry: z軸)
z軸節點.rotation = SCNVector4(x: 1, y: 0, z: 0, w: .pi / 2.0)

let 座標原點 = SCNNode(geometry: SCNSphere(radius: 0.02))
座標原點.addChildNode(x軸節點)
座標原點.addChildNode(y軸節點)
座標原點.addChildNode(z軸節點)

return 座標原點
}

var body: some View {
SceneView(scene: 物理教室, options: [.autoenablesDefaultLighting], delegate: 控制器)
.onChange(of: 左右轉) {
控制器.方向 = 左右轉.width
// print(左右轉.width)
}
.overlay(alignment: .bottomTrailing) {
控制面板(位移: $左右轉)
}
.onAppear {
let 金屬大理石 = SCNMaterial()
金屬大理石.lightingModel = .physicallyBased
金屬大理石.metalness.contents = UIImage(named: "大理石紋.png")

let 太空灰 = SCNMaterial()
太空灰.diffuse.contents = UIColor.darkGray

let 車身 = SCNBox(width: 2, height: 1.5, length: 5, chamferRadius: 0.2)
車身.materials = [太空灰]
let 車身節點 = SCNNode(geometry: 車身)
車身節點.physicsBody = .dynamic()

let 輪胎 = SCNTorus(ringRadius: 0.5, pipeRadius: 0.2)
輪胎.materials = [金屬大理石]
let 輪胎節點 = SCNNode(geometry: 輪胎)
輪胎節點.rotation = SCNVector4(0, 0, 1, .pi / 2.0)

let 輪圈 = SCNBox(width: 0.3, height: 0.6, length: 0.6, chamferRadius: 0.1)
let 輪圈節點 = SCNNode(geometry: 輪圈)
輪圈節點.addChildNode(輪胎節點)

var 四輪: [SCNPhysicsVehicleWheel] = []
for i in 0 ..< 4 {
let 新輪胎 = 輪圈節點.clone()
switch i {
case 0:
新輪胎.position = SCNVector3(1.2, 0, 2.0)
新輪胎.name = "左前輪"
case 1:
新輪胎.position = SCNVector3(-1.2, 0, 2.0)
新輪胎.name = "右前輪"
case 2:
新輪胎.position = SCNVector3(1.2, 0, -2.0)
新輪胎.name = "左後輪"
case 3:
新輪胎.position = SCNVector3(-1.2, 0, -2.0)
新輪胎.name = "右後輪"
default:
新輪胎.position = SCNVector3Zero
}
車身節點.addChildNode(新輪胎)
let 物理輪胎 = SCNPhysicsVehicleWheel(node: 新輪胎)
物理輪胎.suspensionRestLength = 0.30
四輪.append(物理輪胎)
}

let 四輪車 = SCNPhysicsVehicle(chassisBody: 車身節點.physicsBody!, wheels: 四輪)
控制器.載具 = 四輪車 // for SCNSceneRendererDelegate

let 地面 = SCNFloor()
地面.materials.first?.diffuse.contents = UIImage(named: "格線.png")
let 地面節點 = SCNNode(geometry: 地面)
地面節點.physicsBody = .static()

let 跟隨限制 = SCNDistanceConstraint(target: 車身節點)
跟隨限制.minimumDistance = 10
跟隨限制.maximumDistance = 60
let 視角限制 = SCNLookAtConstraint(target: 車身節點)
視角限制.isGimbalLockEnabled = true
let 鏡頭 = SCNCamera()
let 鏡頭節點 = SCNNode()
鏡頭節點.camera = 鏡頭
鏡頭節點.position = SCNVector3(6, 12, 6)
鏡頭節點.constraints = [跟隨限制, 視角限制]

物理教室.rootNode.addChildNode(空間座標系(尺寸半徑: 5.0))
物理教室.rootNode.addChildNode(地面節點)
物理教室.rootNode.addChildNode(車身節點)
物理教室.rootNode.addChildNode(鏡頭節點)
物理教室.background.contents = UIColor.green
物理教室.physicsWorld.addBehavior(四輪車)
}
}
}

class 行車控制系統: NSObject, SCNSceneRendererDelegate {
var 載具: SCNPhysicsVehicle? = nil
var 方向: CGFloat = -30

func renderer(_ renderer: any SCNSceneRenderer, updateAtTime time: TimeInterval) {
let 角度 = 方向 > 150 ? -0.75 : 方向 < -150 ? 0.75 : 方向 * -0.005
renderer.showsStatistics = true
if 載具 != nil {
載具!.setSteeringAngle(角度, forWheelAt: 0)
載具!.setSteeringAngle(角度, forWheelAt: 1)
if 載具!.speedInKilometersPerHour < 20 {
載具!.applyEngineForce(3.0, forWheelAt: 0)
載具!.applyEngineForce(3.0, forWheelAt: 1)
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(車輛系統v2())

💡 註解
  1. SCNSceneRendererDelegate 的處理函式都叫做 renderer(),並不是要我們寫一個渲染程式(這是SceneKit底層所負責),而是在渲染過程中,允許我們插入額外程式,以調整每一幀畫面。
  2. 本課程並未嚴格區分”Callback”與”Event Handler”,這兩個名詞未出現於 Swift 語言正式文件中,而是一般程式語言的術語。Swift 語言中類似功能是 "Delegate" 與 "Closure",但「代理(委派)」、「閉包(匿名函式)」這兩個名詞比較抽象,不容易懂。
  3. 用「控制面板」同樣做法,還可以控制前進、後退的速度(煞車、加速)。若在 iPad/iPhone 上,可以分成兩個控制面板,將左右轉放在左邊,前進後退放在右邊,用兩手操控。
第5課 粒子系統(SCNParticleSystem)

3D繪圖發展了50多年,如今在電影特效、遊戲製作上,已經大放異彩,加上近年人工智慧的加持,現有的虛擬攝影棚或遊戲引擎軟體,都能做出令人難辨真假的3D物件與場景,不管是人物、臉型、街道、建築、動植物…等,還有什麼做不出來呢?

若說到3D繪圖最困難之處,有些人可能會大吃一驚,竟然是火焰、雲霧、水流、風雨、毛髮等不起眼的地方,這些介於有形與無形、物質與非物質之間的東西,通常大量聚集在一起,行為毫無規則,要用3D技術來模擬,一直都非常困難,最常被採用的解法就是用粒子系統。

目前網上可找到的SceneKit粒子系統參考範例,毫無例外全都用Xcode,因為粒子系統有點複雜,Xcode提供了一個(不怎麼好用的)場景編輯器,可用來調整場景及粒子系統的參數。不過,本課將跳過此步驟,直接透過Swift Playgrounds以程式碼來說明粒子系統的用法。

6-5a 奧運火炬

SceneKit 的粒子系統可以模擬火焰、煙火、雨雪、彩帶、爆炸…等,也能自行創造各種類似的視覺效果,我們先以「火焰」來做個今年暑假即將在巴黎登場的奧運聖火,以初步了解粒子系統的運作原理。

先看一下完成後的執行結果,上方火焰騰飛的感覺是不是很真實?這就是粒子系統做出來的效果。(註:火炬外型並非巴黎奧運火炬的實際造型)


火炬主體用圓錐體(SCNCone)製作,尺寸參考最近幾屆奧運,設定長70公分,上下半徑各7公分、3公分:
let 火炬 = SCNCone(topRadius: 0.07, bottomRadius: 0.03, height: 0.7)
火炬.materials.first?.diffuse.contents = UIImage(named: "paris2024")
let 火炬節點 = SCNNode(geometry: 火炬)

火炬上的圖案截取自巴黎奧運官方Logo(請下載存檔為”paris2024.png”,再匯入Swift Playgrounds),圖案設計融合火焰與法國瑪麗安,請參考註解說明。


由於粒子系統必須附著在某個節點(的中心點),因此再做一個小球「火種」放在火炬上端,並且與火炬綁在一起:
let 火種 = SCNSphere(radius: 0.01)
let 火種節點 = SCNNode(geometry: 火種)
火種節點.position.y = 0.35

火炬節點.addChildNode(火種節點)

接下來是重頭戲,使用粒子系統分三個步驟,一、先用SCNParticleSystem()產出物件實例,二、接下來設定各種參數,三、最後呼叫「火種節點.addParticleSystem()」置入火種節點之中。
let 粒子雲 = SCNParticleSystem() 
粒子雲.birthRate = 300
粒子雲.particleLifeSpan = 0.6
粒子雲.particleImage = UIImage(named: "particleImage")
粒子雲.imageSequenceRowCount = 4
粒子雲.imageSequenceColumnCount = 4
粒子雲.particleColor = .orange
粒子雲.particleColorVariation = SCNVector4(0, 0, 1, 1)
粒子雲.particleSize = 0.03
粒子雲.particleSizeVariation = 0.02
粒子雲.particleVelocity = 0.1
粒子雲.particleVelocityVariation = 0.05
粒子雲.emitterShape = SCNSphere(radius: 0.06)
粒子雲.emissionDuration = 0.3
粒子雲.emissionDurationVariation = 0.1
粒子雲.emittingDirection.y = 1.0

火種節點.addParticleSystem(粒子雲)

粒子系統比較複雜的地方就在於第二步驟參數設定,本例共使用15個參數,而整個粒子系統有超過60個參數(包含屬性和方法),比物理模擬還多!

在此用到的15個參數是比較常用的,詳細說明如下表:
# 粒子系統參數 屬性值 說明
1 birthRate 300 每秒產出300個粒子
2 particleLifeSpan 0.6 粒子存活時間0.6秒
3 particleImage UIImage() 粒子外觀(圖案)
4 imageSequenceRowCount 4 圖片檔包含4列圖案
5 imageSequenceColumnCount 4 圖片檔包含4行圖案
6 particleColor UIColor.orange 主色:橘
7 particleColorVariation SCNVector4(0, 0, 1, 1) 主色變化,4個值分別為彩度(hue)、飽和度(saturation)、亮度(brightness)以及透明度(alpha),0表示不變化,其他數值(0 < x ≤ 1)表示變化範圍。
8 particleSize 0.03 粒子平均尺寸:寬、高3公分(預設畫框為正方形)
9 particleSizeVariation 0.02 尺寸變化範圍:±1公分(即2~4公分之間變化)
10 particleVelocity 0.1 初始速度10cm/sec
11 particleVelocityVariation 0.05 初始速度變化範圍,±2.5cm/sec,即7.5~12.5cm/sec之間變化
12 emitterShape SCNSphere(radius: 0.06) 噴射器形狀,半徑6公分,比火炬上緣(半徑7公分)稍小
13 emissionDuration 0.3 噴射持續時間平均0.3秒
14 emissionDurationVariation 0.1 噴射持續時間變化範圍,±0.05秒
15 emittingDirection.y 1 往Y軸(畫面上方)噴射

其中影響粒子外觀最主要的參數是particleImage,預設為白色正方形,並不符合火焰外觀,所以必須更換圖案。通常圖案依照需求(火焰、冰雪、濃煙…等)而定,不用太大(例如128x128)。

還可在一個圖片檔案中包含多個圖案,但必須平均分配大小,並依照動畫次序排列,本節範例使用4x4圖案,下圖取自Apple官方文件(請下載存成 “particleImage.png”,再導入Swift Playgrounds):

對應程式碼如下:
粒子雲.particleImage = UIImage(named: "particleImage")
粒子雲.imageSequenceRowCount = 4
粒子雲.imageSequenceColumnCount = 4

如此一來,每個粒子在其存活時間內,會依序以這16個圖案產生動畫,圖案對應火焰由生到滅的過程。

至於其他12個屬性如何調整呢?老實說,並沒有一定準則,最好的方法就是直接測試,邊看邊調整,慢慢累積經驗。

不過,還是有些經驗法則,秘訣之一就是要加入「隨機性」,不管是火焰、雲霧、爆炸…等,不可讓組成粒子跑得太規律,故須在粒子的顏色、尺寸、噴發速度、時間等參數加上隨機變化,參考上面說明,相關變化參數名稱會帶有Variation。

這樣就完成了。完整範例程式如下,場景命名為「化學教室」:
// 6-5a 粒子系統
// Created by Heman, 2024/05/05
import SceneKit
import SwiftUI

struct 粒子系統: View {
let 化學教室 = SCNScene()

var body: some View {
SceneView(scene: 化學教室, options: [.autoenablesDefaultLighting, .allowsCameraControl])
.onAppear {
let 火炬 = SCNCone(topRadius: 0.07, bottomRadius: 0.03, height: 0.7)
火炬.materials.first?.diffuse.contents = UIImage(named: "paris2024")
let 火炬節點 = SCNNode(geometry: 火炬)

let 火種 = SCNSphere(radius: 0.01)
let 火種節點 = SCNNode(geometry: 火種)
火種節點.position.y = 0.35

火炬節點.addChildNode(火種節點)

let 粒子雲 = SCNParticleSystem()
粒子雲.birthRate = 300
粒子雲.particleLifeSpan = 0.6
粒子雲.particleImage = UIImage(named: "particleImage")
粒子雲.imageSequenceRowCount = 4
粒子雲.imageSequenceColumnCount = 4
粒子雲.particleColor = .orange
粒子雲.particleColorVariation = SCNVector4(0, 0, 1, 1)
粒子雲.particleSize = 0.03
粒子雲.particleSizeVariation = 0.02
粒子雲.particleVelocity = 0.1
粒子雲.particleVelocityVariation = 0.05
粒子雲.emitterShape = SCNSphere(radius: 0.06)
粒子雲.emissionDuration = 0.3
粒子雲.emissionDurationVariation = 0.1
粒子雲.emittingDirection.y = 1.0

火種節點.addParticleSystem(粒子雲)

化學教室.rootNode.addChildNode(火炬節點)
化學教室.background.contents = UIColor.black
}
}
}

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

💡 註解
  1. 本屆巴黎奧運Logo的主畫面,既是火焰,也像一位女性臉孔,此設計寓意法國「瑪麗安」(Marianne,或譯為瑪麗安娜),是1789年法國大革命之後的共和國象徵,代表自由與理性。
  2. 筆者收藏多套法國瑪麗安郵票,其中最喜愛的一套,是1943年法國戴高樂臨時政府在英國倫敦委外設計、製版、印刷,1944年6月諾曼第登陸反攻後,9月在巴黎首度發行,別具意義。全套20枚,面值0.1~50法郎,Edmond Dulac設計,倫敦德納羅(De la Rue)公司印製,經過80年歲月,油墨仍亮麗如新。(下圖筆者拍攝)

  3. 【作業】請隨意調整粒子系統參數值,觀察變化結果。
補充(9) 粒子圖案 particleImage

上節提到影響粒子系統外觀最重要的參數是粒子圖案(particleImage),實際運作時圖案是如何被套用呢?值得我們做個實驗來仔細觀察。

調整上一節6-5a奧運聖火的參數,將出生率從每秒300降到1,存活時間延長為3.6秒,尺寸放大到10公分,以便近距離觀察粒子行為:
粒子雲.birthRate = 1
粒子雲.particleLifeSpan = 3.6
粒子雲.particleSize = 0.1
粒子雲.particleSizeVariation = 0.2

這樣放慢、放大之後,可觀察到每個粒子整個生命週期,從下圖看來,一旦粒子產出後,大小、顏色、外觀似乎就不再變化,這個結果與原先預期不同,原廠文件明明說會按不同圖案依序變化啊!


再仔細推敲文件說明,原來還得加上額外參數 — 每秒變化幀率 imageSequenceFrameRate (預設值0)。在上面我們將存活時間設為3.6秒,圖案共16幅,換算起來幀率約4.4 (16÷3.6),故插入一行程式:
粒子雲.imageSequenceFrameRate = 4.4

如此一來,果然每個粒子就會開始變化,這才符合文件所說的效果:


回頭重新調整奧運聖火的參數,增加一行”imageSequenceFrameRate = 32”,其他參數跟著微調:
let 粒子雲 = SCNParticleSystem() 
粒子雲.birthRate = 100
粒子雲.particleLifeSpan = 0.5
粒子雲.particleImage = UIImage(named: "particleImage")
粒子雲.imageSequenceRowCount = 4
粒子雲.imageSequenceColumnCount = 4
粒子雲.imageSequenceFrameRate = 32
粒子雲.particleColor = .orange
粒子雲.particleColorVariation = SCNVector4(0, 0, 1, 1)
粒子雲.particleSize = 0.03
粒子雲.particleSizeVariation = 0.02
粒子雲.particleVelocity = 0.2
粒子雲.particleVelocityVariation = 0.15
粒子雲.emitterShape = SCNSphere(radius: 0.06)
粒子雲.emissionDuration = 0.2
粒子雲.emissionDurationVariation = 0.1
粒子雲.emittingDirection.y = 1.0


修改後的粒子系統,只需產出原先的1/3(每秒100個粒子),效能更節省,而火焰燒得更旺更活潑,最終效果如下(左圖修改前,右圖修改後):





結論:particleImage 用一個簡單圖案,就能達到不錯的效果;若用多個圖案,則錦上添花,以更少效能達到更佳效果。
6-5b 文字雨(粒子系統)

從上一節(6-5a)了解,影響粒子外觀的主要參數包括圖案(particleImage)、尺寸(particleSize)以及顏色(particeColor),雖只用2D平面圖案,但透過其他參數的配合,整體粒子系統仍會形成3D效果。

另一組重要參數會影響粒子的產出與分佈,包括產出率(birthRate)、存活時間(particleLifeSpan)、噴射器外形(emitterShape)、噴射時間(emissionDuration)等,噴射器外形(emitterShape)決定粒子產出的位置,其他3個參數則影響粒子的數量。

其中產出數量 = 產出率 x 噴射時間,但這並非螢幕所看到的總數,噴射時間結束後,會再循環反覆(由於預設參數 loops = true),還得考慮每個粒子的存活時間(particleLifeSpan),才能決定螢幕顯示的粒子總數。

許多參數值還可加入變化(Variation),這裡的變化是指隨機變化的範圍。例如,假設存活時間(particleLifeSpan)設為1.0秒,存活時間變化(particleLifeSpanVariation)設為0.8秒,則代表粒子實際的存活時間會在 1.0 ± 0.4 秒之間隨機變動,也就是最短0.6秒~最長1.4秒(變動範圍0.8秒)。

整個SceneKit 粒子系統60多個參數,其中有16個可設定隨機變化(參數名稱都帶有”Variation”),因此粒子系統看似一個整體,但也允許個別有所差異,這是粒子系統能模擬不規則運動的火焰、雲霧、雨雪…等,做到活靈活現的主要原因。善用這些參數,還能做出令人驚嘆的數位藝術作品。

本節一共使用23個參數,其中4個會隨機變化,說明如下表(請特別注意最後3個參數):
# 粒子系統參數 參數值 說明
1 particleImage UIImage() 粒子外觀(圖案)
2 imageSequenceRowCount 10 (列) 圖片檔包含10列圖案
3 imageSequenceColumnCount 10 (行) 圖片檔包含10行圖案
4 imageSequenceFrameRate 3 (frame/sec) 每秒變換3個圖案
5 imageSequenceInitialFrame 50 (索引) 圖案陣列索引從50開始
6 imageSequenceInitialFrameVariation 98 (索引) 索引變化上下49
7 particleColor UIColor.yellow 主色:黃
8 particleSize 0.03 (米) 粒子平均尺寸:寬、高3公分
9 particleSizeVariation 0.02 (米) 尺寸變化範圍:±1公分
10 birthRate 300 (particle/sec) 每秒噴射/產出300個粒子
11 particleLifeSpan 5.0 (秒) 粒子存活時間5秒
12 emitterShape SCNPlane() 指定噴射源,從平面隨機位置產出
13 emissionDuration 0.5 (秒) 產出率 x 噴射時間 = 發出粒子數
若0.0則瞬間全部發射
14 emissionDurationVariation 0.5 (秒) 上下變化±0.25秒
15 loops false 噴射時間結束後不再循環
16 emittingDirection (0, 0, 1) 向螢幕(+Z軸)噴射,採節點區域座標。
17 spreadingAngle 90.0 (度) 隨機散佈角度範圍
18 particleVelocity 0.8 (m/sec) 粒子移動速度0.8m/s
19 particleVelocityVariation 1.0 (m/sec) 上下變動範圍±0.5m/s
20 particleAngularVelocity -60.0 (度/秒) 粒子旋轉速度(每秒-60度)
21 colliderNodes [大球節點] 粒子系統開啟與其他節點的碰撞偵測
22 particleDiesOnCollision true 碰撞發生後,粒子立刻消亡
23 systemSpawnedOnCollision 噴濺 碰撞發生後,啟動另一個粒子系統

本節要用這23個參數做出什麼效果呢?在電影「駭客任務」中,有個場景令筆者印象深刻,就是代表母體(The Matrix)的暗綠色文字雨,或稱代碼瀑布,在筆者準備第4單元課程時,曾看過有人用SwiftUI做出類似效果,由此啟發,本節就試試用粒子系統來做文字雨。

經過一番嘗試,最終得到的效果如下(左圖範例,右圖作業),雖然跟電影中的視覺效果有些不同,但也有表現更優之處,例如在3D場景中可覆蓋一片區域,能模擬雨滴碰撞屋頂後的飛濺效果(黃色粒子),而且從效能來看,粒子系統很顯然比SwiftUI (Canvas)更適合做這類效果。





場景中設計三個節點:天空、大球、地面,天空使用一個平面(SCNPlane),附加粒子系統,這樣文字雨(粒子)就能從天空均勻灑落;大球設計成棕色半透明,一部分露出地面當作(溫室)屋頂,並開啟物理模擬,讓文字雨可偵測碰撞;地面則加入格線(圖片取自上一課6-4c)。

三個節點的程式碼如下:
let 天空 = SCNPlane(width: 1, height: 1)
let 天空節點 = SCNNode(geometry: 天空)
天空節點.rotation = SCNVector4(1, 0, 0, .pi / 2.0)
天空節點.position.y = 5.0

let 大球 = SCNSphere(radius: 2.0)
大球.materials.first?.diffuse.contents = UIColor.brown
let 大球節點 = SCNNode(geometry: 大球)
大球節點.position.y = -0.5
大球節點.opacity = 0.8
大球節點.physicsBody = .static()

let 地面 = SCNFloor()
地面.reflectivity = 0.0
地面.materials.first?.diffuse.contents = UIImage(named: "格線")
let 地面節點 = SCNNode(geometry: 地面

下雨的粒子效果怎麼做呢?其實並不難,除了外觀(particleImage)之外,就是控制粒子的數量與分佈,包括產出率(birthRate)、存活時間(particleLifeSpan)、噴射器外形(emitterShape)、噴射時間(emissionDuration)等。

在我們模擬的場景「化學教室」中,天空距離地面才5公尺,因此存活時間5秒即可,在下墜速度0.8±0.5 m/s的情況下,大部分雨滴都能抵達地面;稍微要注意的是噴射方向設為Z軸,這是以粒子系統所附掛的「天空節點」區域座標來看的。
let 文字雨 = SCNParticleSystem() 
文字雨.birthRate = 300
文字雨.particleLifeSpan = 5.0
文字雨.particleImage = UIImage(named: "百字碑")
文字雨.imageSequenceRowCount = 10
文字雨.imageSequenceColumnCount = 10
文字雨.imageSequenceFrameRate = 3
文字雨.imageSequenceInitialFrame = 50
文字雨.imageSequenceInitialFrameVariation = 98
文字雨.particleSize = 0.03
文字雨.particleSizeVariation = 0.02
文字雨.particleVelocity = 0.8
文字雨.particleVelocityVariation = 1.0
文字雨.particleAngularVelocity = -60
文字雨.emitterShape = SCNPlane(width: 10, height: 10)
文字雨.emissionDuration = 0.5
文字雨.emissionDurationVariation = 0.5
文字雨.emittingDirection.z = 1.0

文字雨的粒子外觀要用文字,如何做呢?可仿照上一節圖片,筆者從網路精選100個文字,用影像處理軟體 Pixelmator Pro 排列成10x10矩陣(次序依 particleImage 所需,從左上角開始,由左至右,由上而下),顏色灰白,背景透明。

下圖請另存為”百字碑.png“,再匯入Swift Playgrounds。


粒子系統讀取圖片後,會切割成100個文字圖案,依序放入一個陣列中,陣列索引從0到99,預設情況下,每個粒子都會從0開始讀取,但這不是我們要的。我們希望隨機讀取,讓每個雨滴盡可能不同文字,因此加入兩行程式,令索引在 50±49 (也就是1~99)之間隨機變化:
文字雨.imageSequenceInitialFrame = 50
文字雨.imageSequenceInitialFrameVariation = 98

接下來比較特殊之處,在於雨滴碰撞屋頂後,水滴飛濺的效果怎麼做呢?這裡才是本節的主要學習目標。其實只需加入三行:
文字雨.colliderNodes = [大球節點]
文字雨.particleDiesOnCollision = true
文字雨.systemSpawnedOnCollision = 噴濺

這三行表示「文字雨」每個粒子將開啟碰撞偵測,這會相當耗費效能,因此只設定與「大球節點」而省略地面,碰撞後該粒子會消亡,並且在碰撞處啟用另一個粒子系統「噴濺」。透過這種方式,多個粒子系統可以串接起來,達到更驚人的視覺效果。

所以,水滴飛濺實際上是另一個粒子系統「噴濺」做出來的,只是在「文字雨」粒子系統偵測到碰撞時,才被帶進來。第二個粒子系統的程式碼如下:
let 噴濺 = SCNParticleSystem()
噴濺.birthRate = 20
噴濺.particleLifeSpan = 0.3
噴濺.emissionDuration = 0.0
噴濺.particleImage = UIImage(named: "百字碑")
噴濺.imageSequenceRowCount = 10
噴濺.imageSequenceColumnCount = 10
噴濺.imageSequenceInitialFrame = 50
噴濺.imageSequenceInitialFrameVariation = 98
噴濺.particleColor = UIColor.yellow
噴濺.particleSize = 0.02
噴濺.loops = false
噴濺.spreadingAngle = 90
噴濺.particleVelocity = 0.5

這個粒子系統就簡單多了,同樣從百字碑中隨機讀取文字圖案,粒子產出率只需20就夠,重點是噴射時間(emissionDuration)必須設為0秒,這樣才會20個粒子一次噴發出來,達到飛濺效果(爆炸效果也是類似做法)。

另外兩個重點,是「噴濺.loops = false」關閉反覆循環,同時加上散布角度「噴濺.spreadingAngle = 90」,否則會以一直線噴出(因為產出位置是一個點),效果就大打折扣了。

這樣粒子系統第二個範例就完成了,完整程式碼如下:
// 6-5b 文字雨
// Created by Heman, 2024/05/12
import SceneKit
import SwiftUI

struct 粒子系統2: View {
let 化學教室 = SCNScene()
let 助教 = 代理程式()

var body: some View {
SceneView(scene: 化學教室, options: [.autoenablesDefaultLighting, .allowsCameraControl], delegate: 助教)
.onAppear {
let 天空 = SCNPlane(width: 1, height: 1)
let 天空節點 = SCNNode(geometry: 天空)
天空節點.rotation = SCNVector4(1, 0, 0, .pi / 2.0)
天空節點.position.y = 5.0

let 大球 = SCNSphere(radius: 2.0)
大球.materials.first?.diffuse.contents = UIColor.brown
let 大球節點 = SCNNode(geometry: 大球)
大球節點.position.y = -0.5
大球節點.opacity = 0.8
大球節點.physicsBody = .static()

let 地面 = SCNFloor()
地面.reflectivity = 0.0
地面.materials.first?.diffuse.contents = UIImage(named: "格線")
let 地面節點 = SCNNode(geometry: 地面)

let 噴濺 = SCNParticleSystem()
噴濺.birthRate = 20
噴濺.particleLifeSpan = 0.3
噴濺.emissionDuration = 0.0
噴濺.particleImage = UIImage(named: "百字碑")
噴濺.imageSequenceRowCount = 10
噴濺.imageSequenceColumnCount = 10
噴濺.imageSequenceInitialFrame = 50
噴濺.imageSequenceInitialFrameVariation = 98
噴濺.particleColor = UIColor.yellow
噴濺.particleSize = 0.02
噴濺.loops = false
噴濺.spreadingAngle = 90
噴濺.particleVelocity = 0.5

let 文字雨 = SCNParticleSystem()
文字雨.birthRate = 300
文字雨.particleLifeSpan = 5.0
文字雨.particleImage = UIImage(named: "百字碑")
文字雨.imageSequenceRowCount = 10
文字雨.imageSequenceColumnCount = 10
文字雨.imageSequenceFrameRate = 3
文字雨.imageSequenceInitialFrame = 50
文字雨.imageSequenceInitialFrameVariation = 98
文字雨.particleSize = 0.03
文字雨.particleSizeVariation = 0.02
文字雨.particleVelocity = 0.8
文字雨.particleVelocityVariation = 1.0
文字雨.particleAngularVelocity = -60
文字雨.emitterShape = SCNPlane(width: 10, height: 10)
文字雨.emissionDuration = 0.5
文字雨.emissionDurationVariation = 0.5
文字雨.emittingDirection.z = 1.0
文字雨.colliderNodes = [大球節點]
文字雨.particleDiesOnCollision = true
文字雨.systemSpawnedOnCollision = 噴濺

天空節點.addParticleSystem(文字雨)

化學教室.rootNode.addChildNode(天空節點)
化學教室.rootNode.addChildNode(大球節點)
化學教室.rootNode.addChildNode(地面節點)
化學教室.background.contents = UIColor.black
}
}
}

class 代理程式: NSObject, SCNSceneRendererDelegate {
func renderer(_ renderer: any SCNSceneRenderer, updateAtTime time: TimeInterval) {
renderer.showsStatistics = true
}
}

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

💡 註解
  1. 用SwiftUI 做出文字雨類似效果,可參考The SwiftUI Lab這篇文章最後面。這是筆者認為SwiftUI最佳學習網站,該作者的 App “A Companion for SwiftUI” 是SwiftUI設計師必備工具。
  2. 本節100個文字選自呂洞賓「養氣百字碑」
  3. 【作業】將地面節點也加入粒子的碰撞範圍。
  4. 【作業】試著調整參數值,將天空提高、降雨範圍加大、增加粒子數量…,讓降雨效果更好,並觀察效能統計是否受影響。
補充(10)粒子系統屬性表(完整版)

從前兩節的範例程式可看出,粒子系統(SCNParticleSystem)產出實例時並不需要給任何參數,這表示粒子系統60多個屬性,一定都有預設值(在第5單元 class 與 struct 異同曾提過:物件初始化最重要工作就是給所有屬性初始值),或屬性為Optional類型(預設為nil)。

每個預設值對應粒子系統的某些預設行為,因此,了解這些預設值非常重要,熟悉之後,會讓我們更活用粒子系統。以下將粒子系統所有屬性分成四組分別說明:

第一組:外觀與尺寸
第二組:產出與噴發
第三組:物理模擬
第四組:細部控制與修飾函式

前兩節範例已經對外觀與尺寸、產出與噴發這兩組有初步了解,下一節將學習粒子系統加上物理模擬之後的效果。至於最後一組是進階功能,留給讀者自行探索。

第一組:外觀與尺寸
# 粒子系統屬性 中文名稱 預設值 說明
1 particleImage 粒子圖案 nil 外觀(2D)圖案,預設(nil)使用正方形
2 imageSequenceRowCount 圖案列數 1 圖片含多少(MxN)圖案
3 imageSequenceColumnCount 圖案行數 1 圖片含多少(MxN)圖案
4 imageSequenceInitialFrame 首幀索引 0 0表示左上角第一個圖案
5 imageSequenceInitialFrameVariation 首幀索引變化 0 索引變化範圍(例如10,則表示±5)
6 imageSequenceFrameRate 幀率 0.0 (frame/sec) 每秒顯示多少幀圖案
7 imageSequenceFrameRateVariation 幀率變化 0.0 (frame/sec) 幀率變化範圍(例如10,則表示±5)
8 imageSequenceAnimationMode 動畫模式 .repeat .repeat (循環)
.clamp (一次動畫)
.autoReverse (迴轉)
9 particleSize 粒子尺寸 1 (m) 預設長、寬各1米
10 particleSizeVariation 尺寸變化 0 (m) 尺寸變化範圍(例如10,則表示±5)
11 particleColor 粒子顏色 UIColor.white 粒子主色,預設白色
12 particleColorVariation 顏色變化 (0, 0, 0, 0) 主色變化,4個值分別為彩度(hue)、飽和度(saturation)、亮度(brightness)以及透明度(alpha),0表示不變化,其他數值(0 < x ≤ 1)表示變化範圍。
13 fresnelExponent 菲涅耳係數 1.0 常用於透明表面的反光,若 particleImage 為六面圖案,則粒子會透過此係數映射為透明氣泡
14 stretchFactor 伸展係數 0.0 令粒子移動時,沿運動方向伸展變形

第二組:產出與噴發
# 粒子系統屬性 中文名稱 預設值 說明
15 birthRate (噴射)產出率 0 (particle/sec) 0 表示停止產出/噴射
16 birthRateVariation 產出率變化 0 產出率變動範圍(例如10,則表示±5)
17 particleLifeSpan 存活時間 1.0 秒 粒子存活時間(或生命週期)
18 particleLifeSpanVariation 存活時間變化 0.0 秒 存活時間變動範圍(例如10,則表示±5)
19 warmupDuration 預熱時間 0.0 (秒) 初次噴射前的準備時間
20 emissionDuration 噴射時間 1.0 (秒) 產出率 x 噴射時間 = 發出粒子數
若0.0則瞬間全部發射
21 emissionDurationVariation 噴射時間變化 0.0 (秒) 噴射時間變動範圍(例如10,則表示±5)
22 loops 是否循環/迴圈 true 噴射時間結束後,是否從頭繼續
23 idleDuration 閒置時間 0.0 (秒) 噴射時間結束後的休息時間
24 idleDurationVariation 閒置時間變化 0.0 (秒) 閒置時間變動範圍(例如10,則表示±5)
25 emitterShape 噴射器外形 nil (點/噴射源) 指定噴射源,從幾何形狀表面隨機位置(SCNPlane, SCNBox, SCNSphere, SCNPyramid, SCNCone, SCNCylinder, SCNCapsule, SCNTube, and SCNTorus),預設為單點
26 birthLocation 產出點/噴射源 .surface 預設從幾何形狀表面噴射,其他選項為:
.vertex 從頂點噴射
.volume 從內部空間噴射
27 birthDirection 產出方向 .constant 預設為固定方向,其他選項有:
.surfaceNormal 法線(垂直表面)方向
.random 隨機方向
28 emittingDirection 噴射方向 (0, 1, 0) 預設向上(+Y軸)噴射,僅出生方向為 .constant 時有效。採節點區域座標。
29 spreadingAngle 分散角度 0.0 (度) (隨機)噴射角度
30 particleAngle 旋轉角度 0.0 (度) 粒子旋轉角度
31 particleAngleVariation 旋轉角度變化 0.0 (度) 旋轉角度變動範圍(例如10,則表示±5)
32 particleVelocity 移動速度 0.0 (m/sec) 粒子移動速度,預設靜止
33 particleVelocityVariation 移動速度變化 0.0 (m/sec) 移動速度變動範圍(例如10,則表示±5)
34 particleAngularVelocity 角速度 0.0 (度/秒) 粒子旋轉速度(角速度)
35 particleAngularVelocityVariation 角速度變化 0.0 (度/秒) 角速度變動範圍(例如10,則表示±5)

第三組:物理模擬
# 粒子系統屬性 中文名稱 預設值 說明
36 isAffectedByGravity 是否受重力影響 false 預設不受重力影響
37 isAffectedByPhysicsFields 是否受力場影響 false 預設不受物理力場影響
38 colliderNodes 可碰撞節點 [ ] 碰撞偵測的對象
39 particleDiesOnCollision 碰撞後消亡 false (若有碰撞對象)碰撞後粒子是否消失
40 acceleration 加速度 (0, 0, 0) X/Y/Z三方向的加速度
41 dampingFactor 阻尼係數 0.0 運動(位移)時的阻力
42 particleMass 粒子質量 1.0 (Kg) 若受重力影響才有效
43 particleMassVariation 質量變化範圍 0 (Kg) 質量變動範圍(例如10,則表示±5)
44 particleCharge 粒子電荷 0.0 (庫侖C) 受電磁力場影響時才有效
45 particleChargeVariation 電荷變化範圍 0.0 (庫侖C) 電荷變動範圍(例如10,則表示±5)
46 particleBounce 反彈係數 0.7 與物理實體碰撞後的反彈(1.0表示能量未損耗)
47 particleBounceVariation 反彈係數變化 0.0 反彈係數變動範圍(例如10,則表示±5)
48 particleFriction 摩擦係數 1.0 無摩擦力,可自由滑行
49 particleFrictionVariation 摩擦係數變化 0.0 摩擦係數變動範圍(例如10,則表示±5)
50 systemSpawnedOnCollision 碰撞後啟動另一個粒子系統 nil 當粒子偵測到碰撞後,可啟動另一個粒子系統
51 systemSpawnedOnDying 消亡後啟動另一個粒子系統 nil 當一個粒子消亡後,可啟動另一個粒子系統
52 systemSpawnedOnLiving 產出後啟動另一個粒子系統 nil 當一個粒子產出時,可啟動另一個粒子系統
53 blendMode 色彩混合模式 .additive 適用於粒子重疊時:
.additive 顏色疊加
.subtract 顏色相減
.multiply (正規化)顏色相乘
.screen 顏色過濾
.alpha 透明度相乘
.replace 顏色取代
54 orientationMode 旋轉模式 .billboardScreenAligned .billboardScreenAligned 圖案永遠面向螢幕視角
.free 圖案可任意角度
.billboardViewAligned
.billboardYAligned
55 sortingMode (渲染次序)排序模式 .none 適用於粒子重疊時:
.none 無特定次序
.projectedDepth 投影深度
.distance 距離遠者優先
.oldestFirst 最早產出者優先
.youngestFirst 最後產出者優先
56 isLightingEnabled 是否受光照影響 false 是否開啟光照渲染
57 isBlackPassEnabled 啟用全黑塗色 false 先渲染為黑色,再套用圖案(增加對比,但影響效能)
58 isLocal 是否採區域座標 false 位置、距離、速度是否採用節點的區域座標
59 speedFactor 整體速度因子 1.0 加快或減慢整體速度

第四組:細部控制與修飾函式
# 粒子系統屬性 中文名稱 預設值 說明
60 reset() 重置函式 - 所有屬性返回預設值
61 handle() 事件處理函式 - 事件包括
.birth 粒子產出時
.death 粒子消亡後
.collision 粒子發生碰撞
62 propertyControllers 屬性控制動畫 nil 對粒子某個屬性增加動畫行為
63 addModifier() 新增修飾函式 - 對粒子某個或某些屬性增加自訂的修飾程式(匿名函式),每幀畫面執行一次
64 removeModifiers() 移除修飾函式 - 移除修飾函式
65 removeAllModifiers() 移除所有修飾函式 - 移除所有修飾函式
66 orientationDirection 面對方向 (0, 0, 0) 官方文件未說明
67 particleIntensity 粒子密度 1.0 官方文件未說明
68 particleIntensityVariation 粒子密度變化 0.0 官方文件未說明
69 writesToDepthBuffer 是否寫入深度緩衝(zBuffer) false 官方文件未說明

根據這些預設值,可以設計出最精簡的粒子系統,只要調整3個參數:
// 補充(10) 最小粒子系統
// Created by Heman, 2024/05/20
import SceneKit
import SwiftUI

struct 最小粒子系統: View {
let 化學教室 = SCNScene()

func 空間座標系(尺寸半徑: CGFloat) -> SCNNode {
let x軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let x軸節點 = SCNNode(geometry: x軸)
x軸節點.rotation = SCNVector4(x: 0, y: 0, z: 1, w: .pi / -2.0)

let y軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let y軸節點 = SCNNode(geometry: y軸)

let z軸 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 尺寸半徑 * 2.0)
let z軸節點 = SCNNode(geometry: z軸)
z軸節點.rotation = SCNVector4(x: 1, y: 0, z: 0, w: .pi / 2.0)

let 座標原點 = SCNNode(geometry: SCNSphere(radius: 0.02))
座標原點.addChildNode(x軸節點)
座標原點.addChildNode(y軸節點)
座標原點.addChildNode(z軸節點)

return 座標原點
}

var body: some View {
SceneView(scene: 化學教室, options: [.autoenablesDefaultLighting, .allowsCameraControl])
.onAppear {
let 最小粒子 = SCNParticleSystem()
print("Default birthRate", 最小粒子.birthRate)
print("Default particleLifeSpan", 最小粒子.particleLifeSpan)
print("Default emissionDuration", 最小粒子.emissionDuration)
print("Default emittingDirection", 最小粒子.emittingDirection)
print("Default particleVelocity", 最小粒子.particleVelocity)
最小粒子.birthRate = 5
最小粒子.particleVelocity = 10
最小粒子.birthDirection = .random

let 小球 = SCNSphere(radius: 0.2)
let 小球節點 = SCNNode(geometry: 小球)
小球節點.addParticleSystem(最小粒子)

化學教室.rootNode.addChildNode(空間座標系(尺寸半徑: 5.0))
化學教室.rootNode.addChildNode(小球節點)
化學教室.background.contents = UIColor.black
}
}
}

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

請動手試試看執行結果如何。

💡 註解
  1. Mobile01的表格編排實在不好控制,若閱讀不便,可參考本節Notion的版本
  2. birthRate 官方文件寫的預設值為1,經實測改為0。
  3. emittingDirection 官方文件寫的預設值為(0, 0, 1),經實測改為(0, 1, 0)。文件原文為
    ”The default value is the vector {0.0, 0.0, 1.0}, specifying that particles emit in the direction of the positive z-axis.”
  4. 菲涅耳係數(fresnelExponent)常用於計算透明表面的反光,是模擬玻璃材質的關鍵參數,若想試試「氣泡效果」,可加上以下幾行程式:
    // Tested by Heman, 2024/05/22
    // 氣泡效果,圖案取自6-1c大理石紋.png
    let 圖案 = UIImage(named: "大理石紋.png")
    最小粒子.particleImage = [圖案, 圖案, 圖案, 圖案, 圖案, 圖案]
    最小粒子.particleColor = .blue
    最小粒子.fresnelExponent = 2.0


文章分享
評分
評分
複製連結

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