Swift[第6單元] AR擴增實境與空間運算

第2課 3D動畫效果

上一課4個範例程式,在執行時都可以翻轉畫面,看到3D模型的各個角度。實際上,並不是模型在轉動,而是整個場景都在動,從範例6-1b內建12種幾何模型,看得最清楚。更精確來講,也不是場景在動,真正動的是代表使用者視角的鏡頭(camera) — 這就是程式碼 options: [.allowsCameraControl] 的作用。

那麼,如何讓個別3D物體移動或旋轉呢?這就是本課主題 — 動畫效果。

若還記得第4單元「動畫與繪圖」的內容,學起本課會相當容易,大部分觀念是一樣的,差別只是語法上的不同,以及使用的動畫物件,本課必須用 CABasicAnimation — 較早期的 Core Animation 核心動畫套件內的物件(命名以 CA 開頭)。

首先要注意一點,實際套用動畫效果的,並不是3D模型,而是節點,在6-1c節點屬性圖中,可以清楚看出,位置、旋轉、縮放(position/rotation/scale)都是屬於節點的屬性。這個觀念非常重要,在後面還會遇到。

6-2a 旋轉地球

底下我們用一個簡單範例 — 旋轉地球,來學習 CABasicAnimation 如何使用。執行程式之前,要先從美國太空總署(NASA) “Visible Earth” 網站找一張自己喜歡的地球全景圖,下載儲存為 “earth.jpg”,然後匯入到 Swift Playgrounds 中(請參考6-1c操作步驟)。

接下來就可以執行範例程式:
// 6-2a 旋轉地球
// Created by Heman, 2024/03/12
import SceneKit
import SwiftUI

struct 旋轉地球: View {
let 外太空 = SCNScene()
var body: some View {
SceneView(scene: 外太空, options: [.allowsCameraControl])
.onAppear {
let 地球材質 = SCNMaterial()
地球材質.diffuse.contents = UIImage(named: "earth.jpg")

let 地球 = SCNSphere(radius: 1.0)
地球.materials = [地球材質]

let 地球節點 = SCNNode(geometry: 地球)
地球節點.rotation = SCNVector4(0, 1, 0, 0)

let 旋轉動畫 = CABasicAnimation(keyPath: "rotation.w")
旋轉動畫.toValue = 2.0 * .pi
旋轉動畫.duration = 30
旋轉動畫.repeatCount = .infinity
地球節點.addAnimation(旋轉動畫, forKey: nil)

外太空.rootNode.addChildNode(地球節點)
外太空.background.contents = UIColor.darkGray
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(旋轉地球())

範例中,將場景命名為「外太空」,下載的地球全景圖(earth.jpg)做為球體的材質貼圖,設定為「地球節點」,接下來如何製作動畫,讓地球轉動呢?

主要依靠以下幾行程式碼。

首先設定地球節點沿Y軸(0, 1, 0)轉動,轉動度數為0°:
地球節點.rotation = SCNVector4(0, 1, 0, 0)
這行程式看似無用,但其實對接下來的動畫效果很關鍵,其目的是在指定轉動軸與初始角度。

SceneKit 動畫第一步是產出物件:CABasicAnimation(keyPath: "rotation.w"),這裡的 keyPath 我們最早曾在第2單元2-8b註解提到過,key指的是物件的屬性名稱。此例要操作的是 rotation (節點的屬性),rotation 又有4個參數屬性:(x, y, z, w),其中 (x, y, z) 代表旋轉軸向量,w 是旋轉角度(單位為弧度或稱弳度),所以參數(keyPath: “rotation.w”)表示我們要操作 rotation 的 w 屬性,也就是旋轉角度。
let 旋轉動畫 = CABasicAnimation(keyPath: "rotation.w")
旋轉動畫.toValue = 2.0 * .pi
旋轉動畫.duration = 30
旋轉動畫.repeatCount = .infinity
地球節點.addAnimation(旋轉動畫, forKey: nil)

接下來設定 w 屬性的起始值(fromValue,已設為0°,故省略)、終止值(toValue,360° 換算弧度為 2𝜋),動畫時間(duration) 30秒,並且無限循環(repeatCount = .infinity)。這樣的效果,就是每30秒轉一圈。

設定好動畫之後,就可加入到地球節點.addAnimation(旋轉動畫, forKey: nil),第2個參數 forKey 可將此動畫命名為新屬性(之後可重複套用),nil 表示不需要。

這樣動畫就完成了!背後原理是不是跟第4單元4-1a操作文字位移很像?



💡 註解
  1. 地球自轉是由西向東轉,所以太陽總是從東邊出來,西邊落下;若從北極上空往下看,則會看到地球沿逆時針方向轉動。
  2. 若再仔細觀察,會發現程式寫得還不夠真實,因為地球南北極的自轉軸線並非垂直,應與公轉平面(稱為黃道面)垂線傾斜約23.5°。怎麼做出傾斜23.5°的自轉呢?
補充(2) 程式即時下載地球全景圖

我們在第3單元學過網路程式,第4單元4-9a曾即時下載圖片轉成視圖,同樣原理,能否用程式從NASA下載貼圖,做成材質後,更新到地球節點?這樣就不需要手動匯入到Swift Playgrounds了。

當然沒問題,不過實際動手後發現,下載貼圖做成材質不難,但材質怎麼更新到地球節點呢?當然也可以等貼圖下載後,再做出地球節點,但這樣一來,下載之前會有幾秒畫面空白,還不如先做出地球節點,等下載完再動態更新材質。

在底下程式, .appear { } 會先執行,也就是場景中會有尚未貼圖的地球節點,並已加旋轉動畫;.task { } 是非同步模式,會放到背景由另一顆CPU核心執行,等貼圖下載完成,再做成材質更新地球節點。
// 6-2a 旋轉地球v2
// Created by Heman, 2024/03/12
import SceneKit
import SwiftUI

struct 旋轉地球v2: View {
let 外太空 = SCNScene()
let 地球全景圖網址 = "https://eoimages.gsfc.nasa.gov/images/imagerecords/57000/57735/land_ocean_ice_cloud_2048.jpg"
// 備用網址:
// "https://eoimages.gsfc.nasa.gov/images/imagerecords/147000/147190/eo_base_2020_clean_tn.jpg"
// "https://eoimages.gsfc.nasa.gov/images/imagerecords/73000/73963/gebco_08_rev_bath_720x360_color.jpg"

var body: some View {
SceneView(scene: 外太空, options: [.allowsCameraControl, .autoenablesDefaultLighting])
.task {
if let myURL = URL(string: 地球全景圖網址) {
do {
let (內容, 回應碼) = try await URLSession.shared.data(from: myURL)
if let 地球全景圖 = UIImage(data: 內容) {
let 地球材質 = SCNMaterial()
地球材質.diffuse.contents = 地球全景圖
let 地球節點 = 外太空.rootNode.childNode(withName: "地球", recursively: true)
地球節點?.geometry?.materials = [地球材質]
}
} catch {
print("無法下載圖片")
}
}
}
.onAppear {
// let 地圖材質 = SCNMaterial()
// 地圖材質.diffuse.contents = UIImage(named: "earth.jpg")

let 地球 = SCNSphere(radius: 1.0)
// 地球.materials = [地圖材質]

let 地球節點 = SCNNode(geometry: 地球)
地球節點.name = "地球"
地球節點.rotation = SCNVector4(0, 1, 0, 0)

let 旋轉動畫 = CABasicAnimation(keyPath: "rotation.w")
旋轉動畫.toValue = 2.0 * .pi
旋轉動畫.duration = 30
旋轉動畫.repeatCount = .infinity
地球節點.addAnimation(旋轉動畫, forKey: nil)


外太空.rootNode.addChildNode(地球節點)
外太空.background.contents = UIColor.darkGray
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(旋轉地球v2())

在 .task { } 中如何更新已存在的節點呢?答案是經由「外太空」場景的根節點,用childNode(withName: "地球", recursively: true)去搜尋,這種情況下,節點必須先設定名稱 — 地球節點.name = "地球",之後才能搜尋。搜尋到之後,將模型(geometry)的材質(materials)設定為新的地球材質即可。
地球節點.name = "地球"
...
let 地球節點 = 外太空.rootNode.childNode(withName: "地球", recursively: true)
地球節點?.geometry?.materials = [地球材質]

修改後執行結果(套用第一個備用網址)如下:


至於說手動下載圖片匯入Swift Playgrounds,或用程式下載並動態更新材質,哪種方法比較好?其實各有優缺點,讓程式自動下載並動態更新,似乎比較省事,但缺點是依賴網路資源,如果以後NASA服務關閉或網址變更,那程式就無法正確執行了。

相對來說,人工匯入就不必擔心網站圖片會跑掉,同時也不需要每次執行就下載一次,節省頻寬。因此,哪種方法比較好,端看實際需求,因地制宜。
6-2b 疊加動畫:傾斜23.5°的地球自轉

在學校有看過「地球儀」嗎?如果還有印象的話,應該記得地球儀的轉軸並不會做成垂直,而是故意傾斜一個角度,老師通常就會解釋為什麼傾斜23.5度。

我們如何用 SceneKit 做出傾斜23.5度,同時會自轉的3D地球呢?過程很簡單,先用 SCNTube 設計一個實心長管狀的轉軸軸心,SCNTube() 剛產出時,中心點與座標原點重合,高度(height)沿Y軸垂直上下拉伸(參考範例6-1b)。我們將它沿Z軸順時針旋轉23.5°:
let 軸心 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 3)
let 軸心節點 = SCNNode(geometry: 軸心)
let 傾斜度: Float = -23.5 / 180.0 * .pi
軸心節點.rotation = SCNVector4(x: 0, y: 0, z: 1, w: 傾斜度)

注意這裡傾斜度是 -23.5°,因為從我們人眼(Z軸)觀察,逆時針旋轉時角度為正,而我們希望軸心往右傾斜,也就是順時針旋轉,所以傾斜度用負值。

接著關鍵點來了,我們將上一節做好的地球節點,設定為軸心節點的子節點(參考下圖或6-1a場景示意圖),而不是置於根節點之下。這個動作有個非常重要的意義:子節點座標將由父節點決定,也就是說,子節點的座標原點,來自於父節點的中心(內部原點),X/Y/Z軸也會對齊父節點的內部座標。
軸心節點.addChildNode(地球節點)

因此,當父節點移動、旋轉或縮放時,子節點會跟著照做。也就是說,地球節點會跟著軸心節點傾斜23.5°,而對地球節點來說,並不知道自己傾斜,它的X/Y/Z軸還是對齊軸心節點的左右上下前後方向,當地球節點套用上一節的旋轉動畫時,仍會沿著軸心節點旋轉。


這種情況類似太陽系,對太陽系所有行星而言,太陽是不動的參考點,所有行星繞著太陽公轉,太陽就相當於所有行星的父節點。若以更大尺度的銀河系來看,太陽其實是繞著銀河中心運動,而且連帶著所有行星一起繞著銀河中心運動,但是對行星而言,完全看不出來太陽也在動。

這種情況,在下一節加入太陽與公轉動畫之後,會更清楚。

本節完整範例程式如下:
// 6-2b 旋轉地球v3(傾斜23.5º)
// Created by Heman, 2024/03/15
import SceneKit
import SwiftUI

struct 旋轉地球v3: View {
let 外太空 = SCNScene()
var body: some View {
SceneView(scene: 外太空, options: [.allowsCameraControl, .autoenablesDefaultLighting])
.onAppear {
let 地球材質 = SCNMaterial()
地球材質.diffuse.contents = UIImage(named: "earth.jpg")

let 地球 = SCNSphere(radius: 1.0)
地球.materials = [地球材質]

let 地球節點 = SCNNode(geometry: 地球)
地球節點.name = "地球"
地球節點.rotation = SCNVector4(0, 1, 0, 0)

let 旋轉動畫 = CABasicAnimation(keyPath: "rotation.w")
旋轉動畫.toValue = .pi * 2.0
旋轉動畫.duration = 30
旋轉動畫.repeatCount = .infinity
地球節點.addAnimation(旋轉動畫, forKey: nil)

let 軸心 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 3)
let 軸心節點 = SCNNode(geometry: 軸心)
let 傾斜度: Float = -23.5 / 180.0 * .pi
軸心節點.rotation = SCNVector4(x: 0, y: 0, z: 1, w: 傾斜度)

軸心節點.addChildNode(地球節點)
外太空.rootNode.addChildNode(軸心節點)
外太空.background.contents = UIColor.darkGray
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(旋轉地球v3())

執行結果如下:


💡 註解
  1. 父節點可決定子節點的座標系,這個觀念非常重要,與第2單元2-4c介紹過視圖階層中,父視圖決定子視圖的版面配置(也就是螢幕位置與尺寸),非常類似,同樣都基於Apple原廠特意設計。
  2. 第4單元第5課4-5a註解曾介紹過相對座標的概念,從本節範例也可看出,每個節點內部有自己的區域座標以及外界的全域座標,整個場景的全域座標又稱為世界座標(World Coordinate),可與 AR 的真實世界互相對齊。
6-2c 地球繞太陽公轉

我們已經做出與黃道面垂線傾斜23.5°的地球自轉,若能繼續加入太陽,並讓地球公轉,相信接下來做整個太陽系也就水到渠成了。

太陽的3D模型做法跟地球類似,搜尋 “Full sun map”,或在 NASA “Scientific Visualization Studio” 網站有太陽及各大行星的全景圖,這次我們只需紀錄網址,透過程式自動抓圖,省掉手動匯入Swift Playgrounds的麻煩。

接下來的重頭戲是如何讓地球繞著太陽公轉。

前一節所用的旋轉動畫,從節點.rotate(x, y, z, w) 參數可以看出,轉軸似乎僅用一個點座標 (x, y, z)來指定,其實這是從原點出發的向量值,表示轉軸方向。這裡的原點是節點的區域座標原點,通常是3D物件的中心點。

也就是說,旋轉動畫的轉軸,應該會經過3D物件的中心點;而地球繞太陽公轉時,我們需要的是以太陽南北極為軸的旋轉動畫,旋轉軸並不經過地球核心,怎麼辦呢?

雖然有難度,但至少有三種解決辦法:
  1. 仿照前一節做法,先做一個旋轉的太陽核心(藏在太陽中心點),作為地球(及自轉軸)的父節點,這樣即使地球距離很遠,仍然會與太陽核心同步轉動。
  2. 第4單元第6課4-6a曾經做過圓周運動,同樣可以用TimelineView來做地球公轉。
  3. SceneKit 每個節點都有個 pivot 屬性,可用來變更旋轉軸心到任何位置。

第1種做法相對比較單純,就當作業,請自行練習;本節示範用TimelineView做法,進一步結合SceneKit與SwiftUI;至於第3種做法,語法最簡單,但背後牽涉到變換矩陣的數學原理,反而最不容易懂,下一課再說明。

在實際做公轉動畫之前,我們先來做一個空間座標系,畫出場景中的 X, Y, Z 軸,並加上公轉軌道,以便觀察地球與太陽的相對運動。

呈現的效果如下圖:


空間座標系的程式定義為函式,先用 SCNTube 做出3個長軸,預設是沿著Y軸,所以X軸、Z軸要分別旋轉90度;接著再用SCNTube做一個大圓,當作地球公轉的軌道;然後將X/Y/Z軸及公轉軌道都透過 addChildNode 綁在座標原點,最後以座標原點的節點當作函式回傳值。
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 公轉軌道 = SCNTube(innerRadius: 尺寸半徑, outerRadius: 尺寸半徑 + 0.01, height: 0.01)
let 軌道節點 = SCNNode(geometry: 公轉軌道)

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

return 座標原點
}

至於實際的公轉動畫,還記得第4單元第6課的圓周運動嗎?其中的流程很簡單,用TimelineView送一個時間參數給另一個視圖(如旋轉地球),而視圖(旋轉地球)在 .onChange { } 裡面每次增加一點圓心角,然後計算圓周(即地球在公轉軌道的)位置即可。

具體寫法如下:
.onChange(of: 時間) { _ in
圓心角 += 0.2
if 圓心角 > 360.0 { 圓心角 = 0.0 }
let 地球節點 = 外太空.rootNode.childNode(withName: "地球軸心", recursively: true)
地球節點?.position.x = Float(公轉半徑) * sin(圓心角 / 180.0 * .pi)
地球節點?.position.z = Float(公轉半徑) * cos(圓心角 / 180.0 * .pi)
}

圓心角每次增加0.2度,然後找出地球軸心節點,重新計算在軌道上的位置。這裡用到子節點的搜尋,記得對軸心節點加上名稱(.name)屬性。

另外還有一行關鍵的程式碼要改,原來設定外太空場景 “let 外太空 = SCNScene()”,必須改為 “@State var 外太空 = SCNScene()”,否則 TimelineView 每次更新時間送入「地球公轉()」時,「外太空」場景會被重新初始化,導致畫面會一片空白。

完整的程式碼如下,程式稍長,但大部分是已講解過的語法:
// 6-2c 地球公轉
// Created by Heman, 2024/03/18
import SceneKit
import SwiftUI

struct 地球公轉: View {
@State var 外太空 = SCNScene()
@State var 圓心角: Float = 0.0
let 時間: Date
let 地球半徑: CGFloat = 1.0 // 6371000m (6,371Km)
let 太陽半徑: CGFloat = 3.0 // 696340000m (696,340Km)
let 公轉半徑: CGFloat = 10.0 // 149590000000m (橢圓平均半徑 149,590,000km)
let 自轉時間 = 2.0 // 86400s (1天)
let 公轉時間 = 30.0 // 31556926s (365天)
let 太陽全景圖網址 = "https://svs.gsfc.nasa.gov/vis/a030000/a030300/a030362/euvi_aia304_2012_carrington_print.jpg"

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 公轉軌道 = SCNTube(innerRadius: 尺寸半徑, outerRadius: 尺寸半徑 + 0.01, height: 0.01)
let 軌道節點 = SCNNode(geometry: 公轉軌道)

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

return 座標原點
}

var body: some View {
SceneView(scene: 外太空, options: [.allowsCameraControl])
.onChange(of: 時間) { _ in
圓心角 += 0.2
if 圓心角 > 360.0 { 圓心角 = 0.0 }
let 地球節點 = 外太空.rootNode.childNode(withName: "地球軸心", recursively: true)
地球節點?.position.x = Float(公轉半徑) * sin(圓心角 / 180.0 * .pi)
地球節點?.position.z = Float(公轉半徑) * cos(圓心角 / 180.0 * .pi)
}
.task {
if let myURL = URL(string: 太陽全景圖網址) {
do {
let (內容, 回應碼) = try await URLSession.shared.data(from: myURL)
if let 太陽全景圖 = UIImage(data: 內容) {
let 太陽材質 = SCNMaterial()
太陽材質.diffuse.contents = 太陽全景圖
let 太陽節點 = 外太空.rootNode.childNode(withName: "太陽", recursively: true)
太陽節點?.geometry?.materials = [太陽材質]
}
} catch {
print("無法下載圖片")
}
}
}
.onAppear {
let 太陽 = SCNSphere(radius: 太陽半徑)
let 太陽節點 = SCNNode(geometry: 太陽)
太陽節點.name = "太陽" // 動態更新貼圖材質

let 地球材質 = SCNMaterial()
地球材質.diffuse.contents = UIImage(named: "earth.jpg")
let 地球 = SCNSphere(radius: 地球半徑)
地球.materials = [地球材質]
let 地球節點 = SCNNode(geometry: 地球)
地球節點.rotation = SCNVector4(0, 1, 0, 0)

let 自轉動畫 = CABasicAnimation(keyPath: "rotation.w")
自轉動畫.toValue = 2.0 * .pi
自轉動畫.duration = 自轉時間
自轉動畫.repeatCount = .infinity
地球節點.addAnimation(自轉動畫, forKey: nil)

let 傾斜度: Float = -23.5 / 180.0 * .pi
let 地球軸心 = SCNTube(innerRadius: 0, outerRadius: 0.01, height: 3)
let 地軸節點 = SCNNode(geometry: 地球軸心)
地軸節點.name = "地球軸心" // 動態計算座標位置
地軸節點.position = SCNVector3(0, 0, 公轉半徑)
地軸節點.rotation = SCNVector4(x: 0, y: 0, z: 1, w: 傾斜度)

地軸節點.addChildNode(地球節點)

外太空.rootNode.addChildNode(空間座標系(尺寸半徑: 公轉半徑))
外太空.rootNode.addChildNode(地軸節點)
外太空.rootNode.addChildNode(太陽節點)
外太空.background.contents = UIColor.darkGray
}
}
}

struct 時間軸: View {
var body: some View {
TimelineView(.periodic(from: Date(), by: 0.03)) { 時間參數 in
地球公轉(時間: 時間參數.date)
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(時間軸())

執行結果如下:


🖖 作業
  1. 用內文提到的方法1(加個太陽核心)做出地球公轉動畫。
  2. 從其他網站找太陽全景圖的替換網址,如Solar System Scope
  3. 將空間座標系的X/Y/Z軸加上箭頭。
  4. 試著將“@State var 外太空 = SCNScene()”改回“let 外太空 = SCNScene()”,看看有何變化,想一想為什麼會這樣?
第3課 節點動作(SCNAction)

學習本課之前,先來看一下筆者用 Adobe 公司旗下Mixamo網站的免費3D人物與動作,做出來的側滾翻動作,是不是感覺很真實呢?


這個動作背後的原理跟上一課的動畫(Animation)類似,本質上,Animation 不過就是物件屬性(位置、旋轉、縮放、透明…等等)對於時間的變化,動作也是。看起來比較真實的原因,是因為這些動作並非程式計算的,而是從真人身上「捕捉」而來,將捕捉來的動作數據儲存起來,再套用到任何3D虛擬人物身上。

本課要學習的 SCNAction 未必能一下子做出上面的效果,但可以讓我們了解複雜的動作是如何構成的,為什麼動作可以捕捉及儲存。

6-3a 節拍器

從程式的角度來看,SCNAction 就是組合過的動畫,從簡單的旋轉、移動、縮放等動畫,建構出連貫的動作。在下面範例中,我們用兩個簡單的動畫「左擺」與「右擺」,組合成節拍器的來回擺動:
let 左擺 = SCNAction.rotateBy(x: 0, y: 0, z: .pi / 2, duration: 60.0 / 節拍速度)
let 右擺 = 左擺.reversed()
let 來回擺動 = SCNAction.sequence([左擺, 右擺])
搖桿節點.runAction(.repeatForever(來回擺動))

基本的位移、旋轉、縮放等動畫,都可以用 SCNAction 的「類型方法」(還記得什麼是 “Type method” 嗎?)做出來。SCNAction 包含以下20種類型方法(只有 reversed() 不是類型方法):

1. SCNAction.move() 位移
2. SCNAction.moveBy() 位移
3. SCNAction.rotate() 旋轉
4. SCNAction.rotateBy() 旋轉
5. SCNAction.scale() 縮放
6. SCNAction.fadeIn() 漸入(從無到有)
7. SCNAction.fadeOut() 漸出(從有到無)
8. SCNAction.fadeOpacity() 調整透明度
9. SCNAction.hide() 完全隱藏
10. SCNAction.unhide() 完全顯現
11. SCNAction.playAudio() 發出聲響
12. SCNAction.group() 群組(同時執行)
13. SCNAction.sequence() 序列(依序執行)
14. reversed() 倒轉 — 這個必須先有實例才能用
15. SCNAction.repeat() 重複n次
16. SCNAction.repeatForever() 無限重複
17. SCNAction.wait() 等候n秒
18. SCNAction.run() 執行
19. SCNAction.customAction() 客製化動作
20. SCNAction.removeFromParentNode() 脫離父節點

節點用來控制動作的方法或屬性包括:

1. 節點.runAction()
2. 節點.removeAction()
3. 節點.removeAllActions()
4. 節點.isPaused

這些類型方法都會傳回 SCNAction 物件實例,因此,可以經過各種組合之後,交給節點的 runAction() 來執行,就如上面的程式範例。

底下我們來做個音樂課會用到的節拍器,場景就命名為「音樂教室」:
// 6-3a 節拍器(Metronome) SCNAction
// Created by Heman, 2024/03/23
import SceneKit
import SwiftUI

struct 節拍器: View {
let 音樂教室 = SCNScene()
let 節拍速度 = 72.0 // tempo in bpm (beats per minute)

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.05))
座標原點.addChildNode(x軸節點)
座標原點.addChildNode(y軸節點)
座標原點.addChildNode(z軸節點)

return 座標原點
}

var body: some View {
SceneView(scene: 音樂教室, options: [.allowsCameraControl, .autoenablesDefaultLighting])
.onAppear {
let 搖桿 = SCNBox(width: 0.1, height: 2.0, length: 0.05, chamferRadius: 0.01)
let 搖桿節點 = SCNNode(geometry: 搖桿)
搖桿節點.pivot = SCNMatrix4MakeTranslation(0, -1.0, 0)
搖桿節點.rotation = SCNVector4(0, 0, 1, .pi / -4.0)

let 左擺 = SCNAction.rotateBy(x: 0, y: 0, z: .pi / 2, duration: 60.0 / 節拍速度)
let 右擺 = 左擺.reversed()
let 來回擺動 = SCNAction.sequence([左擺, 右擺])
搖桿節點.runAction(.repeatForever(來回擺動))

let 基座 = SCNCone(topRadius: 0.2, bottomRadius: 0.5, height: 2.0)
let 基座節點 = SCNNode(geometry: 基座)
基座節點.position.y = 0.5

音樂教室.rootNode.addChildNode(空間座標系(尺寸半徑: 2.0))
音樂教室.rootNode.addChildNode(基座節點)
音樂教室.rootNode.addChildNode(搖桿節點)
音樂教室.background.contents = UIColor.green
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(節拍器())

這裡用到節點的 pivot 將擺動的軸心放在搖桿下端(而不是預設的中間),我們下一節再說明。先看看執行的結果:


💡 註解
  1. 日常用語中,動作組合成動畫比較合理,不過Apple的軟體框架中,最基本的動作就稱為 Animation,Action 是一或多個 Animation 的組合。
  2. 在Adobe Mixamo 網站,則稱連續動作為 Animation,可見 Animation 和 Action 並沒有明確區別,在不同語境可能有不同含意。
  3. 通常物件的方法或屬性,都是必須先產出實例(”Instance”,或稱個體),才能取用。類型方法(Type method)與類型屬性(Type property)則不需要先產出實例,就能直接取用。
  4. 類型方法通常也是用來產出新的物件實例,例如第4單元的 Animation.linear()、本節的 SCNAction.rotateBy()等;而一般實例的方法,則用來操作或改變個體屬性,如節點.addAnimation()。
  5. 類型屬性則是整個物件類型通用,預設好的屬性值,不隨著個體實例而變,例如 Color.red、UIColor.green、URLSession.shared 等。
補充(3) 三度空間的變換矩陣

我們曾在第4單元第7課提過,每個 Canvas 畫布的圖層(GraphicsContext)都有一個 transform 屬性,做為該圖層的變換矩陣,既然 transform 是屬性而非方法,就表示這個變換矩陣是時時都在發生效用的。

每個 SceneKit 的節點(SCNNode)也都有個 transform 屬性,乃同樣道理。唯一差別是 SceneKit 節點是在3度空間中,所以是3度空間的變換矩陣。

3度空間的變換矩陣一般(數學上)是3x3,例如:


斜對角是1,其他位置都是0,這樣的變換矩陣又稱為單位矩陣,英文稱為 Identity matrix,Identity 是自身、本身的意思,因為任何3D座標(x, y, z)乘以單位矩陣的結果仍是自身,就跟整數1的性質類似,所以中文稱為單位矩陣。

有些數學課本,變換矩陣的寫法是3x3矩陣在前,3D座標在後:


兩種寫法是等效的,但矩陣內容的排列不一樣,SceneKit 兩種都支援,對應不同屬性名稱,節點的 transform 適用第一種寫法,另一個屬性 simdTransform 用第二種寫法。

此外,3x3矩陣只能用來操作旋轉與縮放,若要加上位移,則須擴充為4x4矩陣,如下:


這時候3D座標(x, y, z)後面會填上常數1,作為第4個參數,注意這個參數和旋轉 rotation 的值 SCNVector4(x, y, z, w) 其中 w 參數意義不同,切勿混淆。

4x4才是SceneKit實際要用的變換矩陣,這種可同時操作位移、旋轉、縮放(包含鏡像)的矩陣又稱為仿射變換矩陣。

我們寫個小程式來觀察SceneKit的變換矩陣是如何運作的:
// 6-3 補充(3):變換矩陣
// Created by Heman, 2024/03/27
import SwiftUI
import SceneKit

struct 變換矩陣: View {
let 空間 = SCNScene()
@State var 變形 = false

var body: some View {
SceneView(scene: 空間, options: [.allowsCameraControl])
.onTapGesture {
變形.toggle()
let 地球 = 空間.rootNode.childNode(withName: "地球", recursively: true)
地球?.scale.x = 變形 ? 2.0 : 1.0
print(地球?.transform)
}
.onAppear {
let 地球 = SCNSphere(radius: 1.0)
let 地球材質 = SCNMaterial()
地球材質.diffuse.contents = UIImage(named: "earth.jpg")
地球.materials = [地球材質]
let 地球節點 = SCNNode(geometry: 地球)
地球節點.name = "地球"
print(地球節點.transform)

空間.rootNode.addChildNode(地球節點)
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(變換矩陣())

程式內容很簡單,做一個地球,輕點(Tap)後X軸放大兩倍(scale.x = 2),Y/Z軸不變,執行結果如下,原本是圓圓的小玉西瓜,變成橢圓形的花蓮大西瓜:


從主控台可以觀察到幾個重點:

1. transform 類型是 SCNMatrix4 (四階矩陣)
2. SCNMatrix4 共有4x4=16個參數,名稱是 m11, m12,… 到 m44
3. transform 預設值是4x4單位矩陣(名為SCNMatrix4Identity)
4. 當輕點螢幕之後,scale.x = 2.0,順X軸放大2倍,這時候 transform 矩陣也會立刻變化


從這個小程式可以看出來,節點的位置(position)、旋轉(rotation)、縮放(scale)三個屬性有任何改變,都會直接反映到變換矩陣(transform);反之亦然,若直接改變 transform,position/rotation/scale 也會隨之變化。

🖖 作業
  1. 將上一節寫的「空間座標系()」加進來,以便觀察位置變化。
  2. 直接變更 transform 參數,觀察 position/rotation/scale 是否改變?例如,在 .onTapGesture { } 加入:
        地球?.transform.m42 = 變形 ? 1.0 : 0.0
    print(地球?.position)

  3. 如何知道 transform 變換矩陣的16個參數如何對應到 position/rotation/scale 呢?
補充(4) 變換矩陣如何轉換座標

SceneKit 節點的 transform 變換矩陣,如何與位移(position)、旋轉(rotation)、縮放(scale)等操作對應及換算呢?其中位移與縮放較單純,只用到加法與乘法;旋轉則用到三角函數,且依X/Y/Z軸轉動而有所不同,分別說明如下。

位移
節點.position = SCNVector3(dx, dy, dz)

這行程式碼將節點位置移到(dx, dy, dz)座標,產生以下變換矩陣:


該節點內容在顯示前,3D物件的每個座標都會經過變換,根據上面的公式,新的座標為:


相當於兩向量相加,也就是原座標(x, y, z)加上位移(dx, dy, dz)。

縮放(及鏡像)
節點.scale = SCNVector3(a, b, c)

這行程式碼會產生以下變換矩陣:


結果等於:


這個變換矩陣可以對3D物件產生放大、縮小、鏡像三種操作。當a為負值時,X座標值正變負或負變正,會對Y-Z平面產生鏡像;b負值則對X-Z平面產生鏡像;c負值對X-Y平面產生鏡像。若a/b/c絕對值小於1.0,就是沿X/Y/Z軸縮小;a/b/c絕對值大於1.0,則是沿X/Y/Z軸放大。

對X軸旋轉𝜃弧度
節點.rotation = SCNVector4(1, 0, 0, 𝜃)

這行程式碼會產生以下變換矩陣:


結果等於(x值不變):


對Y軸旋轉𝜃弧度
節點.rotation = SCNVector4(0, 1, 0, 𝜃)

這行程式碼會產生以下變換矩陣:


結果等於(y值不變):


對Z軸旋轉𝜃弧度
節點.rotation = SCNVector4(0, 0, 1, 𝜃)

這行程式碼會產生以下變換矩陣:


結果等於(z值不變):


座標系變換

從變換矩陣的操作可以看出,空間中任何一點經過矩陣變換之後,會轉換到另一點,看似用來轉換點座標,事實上是整個空間都被轉換,用數學語言來講,是轉換了整個座標系。

所以說,一個變換矩陣會將原有座標系轉換成另一個座標系,可能只是平移,原點位置改變,但X/Y/Z軸方向不變;也可能是X/Y/Z軸轉了一個方向,但是長寬高尺度不變;或者是原點位置(position)、X/Y/Z軸方向(rotation)、長寬高尺度(scale)全都改變。

從SceneKit程式的角度來看,每個節點內部有自己的座標系,外界還有一個繼承自父節點的座標系,一開始,兩者是對齊一致的,原點重合,X/Y/Z軸方向與尺度都相同。當節點的變換矩陣(transform)開始變化,不再等於單位矩陣時,內部座標系就脫離父節點座標系,自成一個空間。

我們寫個小程式來展示這個概念:
// 6-3 補充(4):變換矩陣
// Revised by Heman, 2024/03/31
import SwiftUI
import SceneKit

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.05))
座標原點.addChildNode(x軸節點)
座標原點.addChildNode(y軸節點)
座標原點.addChildNode(z軸節點)

return 座標原點
}

var body: some View {
SceneView(scene: 幾何場景, options: [.autoenablesDefaultLighting, .allowsCameraControl])
.onAppear {
let 球體 = SCNSphere(radius: 0.1)
let 球體節點 = SCNNode(geometry: 球體)
球體節點.position.y = 0.618 + 0.1

let 三角錐 = SCNPyramid(width: 1.0, height: 0.618, length: 1.0)
let 三角錐節點 = SCNNode(geometry: 三角錐)
let 旋轉 = SCNAction.rotateBy(x: .pi/3, y: .pi * 6.0, z: 0, duration: 5.0)
let 縮小 = SCNAction.scale(by: 0.1, duration: 5.0)
let 位移 = SCNAction.move(by: SCNVector3(1, 1, 0), duration: 5.0)
let 同時動作 = SCNAction.group([旋轉, 縮小, 位移])
let 反覆動作 = SCNAction.sequence([同時動作, 同時動作.reversed(), .wait(duration: 1)])
三角錐節點.runAction(.repeatForever(反覆動作))

三角錐節點.addChildNode(球體節點)
三角錐節點.addChildNode(空間座標系(尺寸半徑: 1.0))
幾何場景.rootNode.addChildNode(三角錐節點)
幾何場景.rootNode.addChildNode(空間座標系(尺寸半徑: 1.0))
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(顯示幾何模型())

借用第一課的三角錐與小球,將兩者連成一體,並且同時執行旋轉、縮放、位移三個動作,觀察三角錐座標的變化。執行結果如下:


三角錐節點一開始的座標位置,來自於父節點(根節點),座標原點位於螢幕正中,當變換矩陣開始變化時,三角錐節點的座標系統就會脫離父節點,自成體系。

💡 註解
  1. 不要小看變換矩陣,能夠改變空間的力量都非常強大,根據愛因斯坦的相對論,黑洞由於具有超大重力,導致附近時空(4D座標空間)會受到扭曲。這樣看來,黑洞像不像一個變換矩陣?
  2. 有興趣的同學,可以看一下這篇討論黑洞的文章,其中提到了4階矩陣。在天文物理學中,用來描述時空的主要是張量與曲率,而不是位置與旋轉角度。
  3. 如果仔細思考範例程式的每個步驟,會發現執行過程中,三角錐的長(1.0)、寬(1.0)、高(0.618),屬性本身並沒有任何改變。雖然我們看到位置、角度、大小等變化,但本質上,變換矩陣從未改變節點內的3D物件屬性,改變的是其所處的空間(座標系統)。
  4. 我們在螢幕上所看到的,是節點座標投射到上層(父節點)座標空間,一層一層往上投射,最後透過根節點投影到(2D的)螢幕座標。
6-3b 變換樞紐(pivot)

前兩則補充(3)(4)對於了解樞紐(pivot)的作用非常重要。若仔細觀察補充(4)的執行動畫,三角錐內部座標原點,也就是底部的中心,在移動過程中,是唯一不受旋轉與縮放影響的點,此點稱為空間變換的樞紐(pivot)。

樞紐就像葡萄串的蒂頭,只要了解樞紐,整個座標變換就能輕易掌控。

樞紐不一定與座標原點重合,當樞紐離開原點時,原點同樣會受到旋轉與縮放的影響。這時候的變換矩陣,會與補充(4)看到的不同。

在SceneKit中,樞紐(pivot)是節點的屬性,但並不是一個座標點,而是另一個變換矩陣,預設值同樣是單位矩陣。當樞紐位置離開座標原點時,這個(樞紐)變換矩陣會與原來的 transform 變換矩陣共同改變座標空間。

在6-3a範例程式中,我們用一行程式碼變更節點的樞紐:
// 6-3a 節拍器(SCNAction) Metronome
搖桿節點.pivot = SCNMatrix4MakeTranslation(0, -1.0, 0)

將樞紐位置從(節點內部座標)原點更改到(0, -1, 0),也就是搖桿的下端。當節點內部座標投射到父節點時,這個樞紐點會取代內部座標原點,與上一層(父節點)的座標原點重合。

SCNMatrix4MakeTranslation() 是以位移向量(translation)為參數產出對應的 SCNMatrix4 變換矩陣,pivot 與 transform 都是 SCNMatrix4 資料類型。

所以上面這行程式相當於:
搖桿節點.pivot.m42 = -1.0

我們將6-3a範例程式稍加改寫,將 pivot 變更移到輕點螢幕 .onTapGesture { } 之內,以便觀察 pivot 變更前後的差異:
// 6-3b 節點的樞紐(pivot)
// Revised by Heman, 2024/04/02
import SceneKit
import SwiftUI

struct 節拍器v2: View {
let 音樂教室 = SCNScene()
let 節拍速度 = 72.0 // tempo in bpm (beats per minute)
@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.05))
座標原點.addChildNode(x軸節點)
座標原點.addChildNode(y軸節點)
座標原點.addChildNode(z軸節點)

return 座標原點
}

var body: some View {
SceneView(scene: 音樂教室, options: [.allowsCameraControl, .autoenablesDefaultLighting])
.onTapGesture {
變更樞紐.toggle()
let 搖桿 = 音樂教室.rootNode.childNode(withName: "搖桿", recursively: true)
搖桿?.pivot = 變更樞紐 ? SCNMatrix4MakeTranslation(0, -1.0, 0) : SCNMatrix4Identity
print(搖桿?.transform)
print(搖桿?.pivot)
}
.onAppear {
let 搖桿 = SCNBox(width: 0.1, height: 2.0, length: 0.05, chamferRadius: 0.01)
let 搖桿節點 = SCNNode(geometry: 搖桿)
搖桿節點.name = "搖桿"
// 搖桿節點.pivot = SCNMatrix4MakeTranslation(0, -1.0, 0)
搖桿節點.rotation = SCNVector4(0, 0, 1, .pi / -4.0)

let 左擺 = SCNAction.rotateBy(x: 0, y: 0, z: .pi / 2, duration: 60.0 / 節拍速度)
let 右擺 = 左擺.reversed()
let 來回擺動 = SCNAction.sequence([左擺, 右擺])
搖桿節點.runAction(.repeatForever(來回擺動))

let 基座 = SCNCone(topRadius: 0.2, bottomRadius: 0.5, height: 2.0)
基座.materials.first?.fillMode = .lines
let 基座節點 = SCNNode(geometry: 基座)
基座節點.position.y = 0.5

音樂教室.rootNode.addChildNode(空間座標系(尺寸半徑: 2.0))
音樂教室.rootNode.addChildNode(基座節點)
音樂教室.rootNode.addChildNode(搖桿節點)
// 音樂教室.background.contents = UIColor.green
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(節拍器v2())

執行結果如下,請觀察樞紐位置的變化:

為了便於觀察,我們將基座改為透明,只畫線框,不渲染外觀。所謂線框(wireframe)是指3D物件的幾何構造,所有3D模型都是由多邊形(大多採用三角形)構成其外形輪廓,多邊形包含頂點與連接線,很容易用數學座標來表示。較精細的3D物件(如人形、太空船)可能超過10萬個多邊形。

如何畫出3D物件的線框呢?在 SceneKit 裡面非常簡單,只要一行程式:
基座.materials.first?.fillMode = .lines

將第一個材質的塗色模式 “fillMode” 改成 .lines 即可。

💡 註解
  1. 大家有沒有注意到3D模型的材質屬性一直是複數(.materials),內容為材質陣列,參考6-1c屬性結構圖。為什麼是複數而非單數呢?畢竟我們在目前為止,都只用到一種材質。
  2. 實際上,如果模型包含多個元件 (elements,或稱元素、單元,類型為SCNGeometryElement),就可用到多種材質。如範例中,搖桿是立方體(SCNBox),外形由300個三角形構成,最多可分為6個元件,每個元件代表一面,若提供6種材質,則每一面可呈現不同外觀;基座是圓錐體(SCNCone),由192個三角形構成,包含3個元件(頂面、側面、底面)。
  3. 【作業】試著修改補充(4)範例程式,令三角錐的樞紐(pivot)往下位移1公尺,觀察結果與原來有何不同。
    // 6-3 補充(4):變換矩陣
    let 三角錐節點 = SCNNode(geometry: 三角錐)
    三角錐節點.pivot = SCNMatrix4MakeTranslation(0, -1.0, 0) //插入這行

  4. 【作業】請用 pivot 改寫第2課6-2c地球公轉的範例程式。
6-3c 播放空間音響 playAudio()

前兩節(6-3a, 6-3b)的節拍器感覺少了一點東西,就是節拍聲,沒有聲音怎能叫節拍器呢?所以本節就用 playAudio() 將節拍聲加上去,順便學習如何暫停進行中的動作。

首先要準備一個音效檔,自己用手機錄也行,或是 Google 搜尋 “free sound effects” 可找到非常多免費音效網站,本節範例程式選用Pixabay的水滴聲,下載後用 Mac 的 Quicktime Player 剪輯(只抓開頭0.1秒鐘即可),另存為 "waterdrop.m4a” 並匯入 Swift Playgrounds。

在 SceneKit 程式中,使用 SCNAudioSource() 來讀取音效檔,注意檔名大小寫要一致:
let 水滴聲 = SCNAudioSource(named: "waterdrop.m4a")

讀入音效檔之後,就可以用 playAudio() 加入到 SCNAction 動作中,讓搖桿節點在左擺、右擺時發出聲響:
let 聲響 = SCNAction.playAudio(水滴聲, waitForCompletion: false)
let 來回擺動 = SCNAction.sequence([左擺, 聲響, 右擺, 聲響])
搖桿節點.runAction(.repeatForever(來回擺動))

這樣音效就完成了!

讓動作暫停也很簡單,用「節點.isPaused」來控制即可:
.onTapGesture {
let 搖桿節點 = 音樂教室.rootNode.childNode(withName: "搖桿", recursively: true)
搖桿節點?.isPaused.toggle()
}

以下是完整範例程式:
// 6-3c 節拍器v3 SCNAction.playAudio()
// Created by Heman, 2024/04/05
// waterdrop.m4a: 先下載mp3音效,再用Quicktime Player剪輯
// 下載網址: https://pixabay.com/sound-effects/sound-of-a-drop-of-water-131023/
import SceneKit
import SwiftUI

struct 節拍器v3: View {
let 音樂教室 = SCNScene()
let 節拍速度 = 90.0 // tempo in bpm (beats per minute)

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.05))
座標原點.addChildNode(x軸節點)
座標原點.addChildNode(y軸節點)
座標原點.addChildNode(z軸節點)

return 座標原點
}

var body: some View {
SceneView(scene: 音樂教室, options: [.allowsCameraControl, .autoenablesDefaultLighting])
.onTapGesture {
let 搖桿節點 = 音樂教室.rootNode.childNode(withName: "搖桿", recursively: true)
搖桿節點?.isPaused.toggle()
}
.onAppear {
let 搖桿 = SCNBox(width: 0.1, height: 2.0, length: 0.05, chamferRadius: 0.01)
let 搖桿節點 = SCNNode(geometry: 搖桿)
搖桿節點.name = "搖桿"
搖桿節點.pivot = SCNMatrix4MakeTranslation(0, -1.0, 0)
搖桿節點.rotation = SCNVector4(0, 0, 1, .pi / -4.0)

let 左擺 = SCNAction.rotateBy(x: 0, y: 0, z: .pi / 2, duration: 60.0 / 節拍速度)
let 右擺 = 左擺.reversed()
if let 水滴聲 = SCNAudioSource(named: "waterdrop.m4a") {
let 聲響 = SCNAction.playAudio(水滴聲, waitForCompletion: false)
let 來回擺動 = SCNAction.sequence([左擺, 聲響, 右擺, 聲響])
搖桿節點.runAction(.repeatForever(來回擺動))
} else {
let 來回擺動 = SCNAction.sequence([左擺, 右擺])
搖桿節點.runAction(.repeatForever(來回擺動))
}

let 基座 = SCNCone(topRadius: 0.2, bottomRadius: 0.5, height: 2.0)
基座.materials.first?.fillMode = .lines
let 基座節點 = SCNNode(geometry: 基座)
基座節點.position.y = 0.5

音樂教室.rootNode.addChildNode(空間座標系(尺寸半徑: 2.0))
音樂教室.rootNode.addChildNode(基座節點)
音樂教室.rootNode.addChildNode(搖桿節點)
音樂教室.background.contents = UIColor.green
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(節拍器v3())

執行結果相當符合預期,流程還算平順(記得開啟喇叭):

💡 註解
  1. playAudio 播放聲音應該不需要取得授權才對,但在 macOS 上會跳出請求麥克風授權畫面(iPadOS不會),在官方文件中找不到解釋,雖然不影響執行結果,卻難免影響使用體驗。
  2. 【作業】寫一個SwiftUI控制器(如Slider或手勢),控制節拍器的速度。
雪白西丘斯
抱歉,原來的版本沒注意到 isPaused 屬性可用,重新改寫。
第4課 SceneKit物理模擬

前3課我們學習了空間運算的基礎程式,包括 SceneKit 3D幾何模型、燈光、材質、動畫與動作組合等,程式寫起來雖然輕鬆,但背後牽涉到眾多理論,從空間座標到變換矩陣、光照模型、材質理論…等,其實是計算機圖學過去50年累積下來的寶貴成果。

接下來進一步的物理模擬(第4課)與粒子系統(第5課),是筆者認為 SceneKit 空間運算最精彩的部分,若虛擬空間也具備現實世界的物理規則,會讓App顯得更真實,更有沈浸感。

不過,在虛擬空間創建物理規則並不容易,SceneKit 的物理模擬主要模擬現實世界的力場,包括重力、電磁力、扭力、牛頓力學等作用,以及物體之間的碰撞、摩擦、反彈等行為。

在沒有物理模擬之前,例如前3課所創造出來的3D物件,似乎都沒有重量,可以在空間中自由漂浮移動;物體之間也不會發生碰撞,彼此會穿透而過,感覺有形無質,完全脫離現實世界。

6-4a 物理本體(physicsBody)

要想讓物件具有質量,受到地球重力影響,並且能發生碰撞,只要在節點加上一行程式:
節點.physicsBody = .dynamic()

physicsBody 我們稱為「物理本體」。加入這行之後,節點就會啟動物理模擬,以9.8 m/sec²重力加速度往下掉,直到平面或地面為止,碰到其他物理模擬的物件也會彈開。

節點的物理本體(節點.physicsBody)有三種類型可選:
  1. .dynamic() 動態本體:參與碰撞,具有質量的物件(不論大小,預設值均為1Kg),會受外力影響
  2. .static() 靜態本體:參與碰撞,不具質量,不受外力影響,固定或可移動的物件(如地面、牆壁、桌面等)
  3. .kinematic() 活動本體:參與碰撞,不具質量,不受外力影響,但可自主活動的物件(如人物、機關、門窗等)

要注意 .static() 並非一定是靜止或固定,這三種物理本體仍可執行前兩課的動畫(或動作),事實上,動畫與物理模擬可並存,物理模擬不會改變節點的變換矩陣(transform),而是在座標變換之後,3D物件渲染顯示之前,透過物理公式計算其相對位置,計算結果會放在一個暫存節點(屬性名稱為「節點.presentation」)中,最終位置由變換矩陣與物理模擬共同決定。

以下我們用一個範例程式來觀察這3種物理本體的差異,場景命名為「物理教室」:
// 6-4a 物理模擬 physicsBody
// Created by Heman, 2024/04/09
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()
}
.onAppear {
let 小球 = SCNSphere(radius: 0.05)
let 小球節點 = SCNNode(geometry: 小球)
小球節點.name = "小球"
小球節點.position = SCNVector3(0.1, 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(物理模擬())

在程式中,我們做了4個物件,各具有不同特性:

1. 空間座標系:一般節點(非物理本體),會被其他物件穿透
2. 小球節點:初始設定為一般節點,輕觸螢幕後變為動態(dynamic)本體
3. 三角錐節點:物理本體設定為活動(kinematic),底部距離地面20cm
4. 地面節點:物理本體設定為靜態(static)


當輕觸螢幕時,小球節點會執行「小球節點?.physicsBody = .dynamic()」,變成動態本體,開始受地球重力影響往下掉,穿透空間座標的X軸,直到與三角錐節點碰撞,反彈後沿斜面滾下至地面。

三角錐節點設定為活動本體(kinematic, 不受重力影響),穿透座標系的Y軸,距離地面20cm(但不會往下掉),執行「三角錐節點.runAction(.repeatForever(轉動))」持續繞Y軸轉動。

地面(SCNFloor)節點設定為靜態本體,SCNFloor 預設長寬無限大、會反光,因此通常當做樓板或地面使用,承接掉下來的物件(否則會掉到螢幕下方消失不見)。

實際執行結果如下:


💡 註解
  1. 牛頓之前的古典力學細分為靜力學(statics)、動力學(dynamics)以及運動學(kinematics),三種物理本體的名稱由此而來。
  2. 三種物理本體中,活動(kinematic)本體比較不容易理解,什麼時候該用活動本體呢?基本是針對運動過程都由程式邏輯所控制,而非由物理模擬的外力所推動,例如瑪利歐遊戲的主角、可以開關的門窗等。
  3. 為什麼重複輕觸螢幕時,小球會一再從原處掉下來呢?因為 .dynamic() 其實是一個類型方法,每次呼叫就會產出一個新的物理本體,以節點的變換矩陣所得結果當做初始位置,開始進行物理模擬,因為物理模擬不會改變變換矩陣,因此每次都回到原來位置。
  4. 在動畫、動作以及物理模擬之下,SceneKit 節點每秒會產出60幀畫面(預設60 fps),中間會經過哪些步驟呢?若要對個別畫面額外處理,該如何做呢?這對於程式設計師相當重要,請參考原廠SCNSceneRendererDelegate文件
  5. 【作業】靜態本體的地面能否執行 runAction 移動呢?若在最後加上一行程式會如何:
    // 地面節點.physicsBody = .static() 之後:
    地面節點.runAction(.moveBy(x: 0, y: 1, z: 0, duration: 10))

  6. 【作業】在 .onTapGesture { } 中加入以下兩行,輕觸螢幕多次,觀察主控台輸出結果:
    // 小球節點?.physicsBody = .dynamic() 之後:
    print("變換矩陣", 小球節點?.transform)
    print("物理模擬", 小球節點?.presentation.transform)

  7. 【挑戰題】修改範例程式,做一個各自旋轉的五層塔,從上面一次掉落50顆小球,觀察其運動軌跡是否有不自然之處。
關閉廣告
文章分享
評分
評分
複製連結

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