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

6-5c 粒子系統+物理力場

粒子系統在預設情況下,是不受外力作用,也不受重力或其他力場影響,行為完全由參數控制。但實際上,即使是火焰,也會受到氣流(流體力學的力場)的影響,因此必要時可開啟物理模擬,讓效果更逼真。開啟方式如下:
粒子系統.isAffectedByGravity = true        // 受重力影響
// 或
粒子系統.isAffectedByPhysicsFields = true // 受其他力場影響

重力與(其他)力場可分別設定,一旦設定,整個粒子系統都會受到影響。SceneKit 場景中預設會有重力,所以一旦開啟受重力影響(isAffectedByGravity),會立刻生效(粒子往下掉);若開啟受其他力場影響(isAffectedByPhysicsFields),則還需另外設定力場。在上一課物理模擬中,我們尚未用過力場,本節就來試試看。

設定物理力場要用 SCNPhysicsField 物件,其中的 Field 譯為「場」,意思是一片區域或空間,例如操場、曬穀場,在此指受到某些力持續作用的範圍,故稱力場。半導體中有個名詞叫「場效電晶體(FET)」, F代表Field,同樣的意思。

SceneKit 支援以下幾種物理力場,使用 SCNPhysicsField 的類型方法來產出:

1. SCNPhysicsField.drag() — 拖曳力場(流體或空氣阻力)
2. SCNPhysicsField.vortex() — 旋轉力場、渦流場
3. SCNPhysicsField.radialGravity() — 放射狀重力場
4. SCNPhysicsField.linearGravity() — 線性重力場
5. SCNPhysicsField.noiseField() — 雜訊力場
6. SCNPhysicsField.turbulenceField() — 亂流、紊流場
7. SCNPhysicsField.spring() — 彈力場
8. SCNPhysicsField.electric() — 電場
9. SCNPhysicsField.magnetic() — 磁場
10. SCNPhysicsField.customField() — 客製化力場

力場產出之後,還需調整相關屬性,所有屬性都有預設值,說明如下表:
# 力場屬性 預設值 說明
1 halfExtent (無限, 無限, 無限) 力場半徑(以節點位置為中心,但預設力場範圍並非圓球形,而是立方形)。例如,若節點位於原點(0, 0, 0),halfExtent = (1, 1, 1),則力場有效範圍為(-1, -1, -1) ~ (1, 1, 1)
2 scope .insideExtent 指定力場作用於halfExtent範圍內或範圍外
.insideExtent 範圍內
.outsideExtent 範圍外
3 usesEllipsoidalExtent false 是否改為橢圓球或圓球形範圍(預設為立方形範圍,較好計算)
4 offset (0, 0, 0) 力場中心偏移(相對於節點中心)
5 direction (0, -1, 0) 力場中心軸(適用部分力場)
6 strength 1 (依力場種類而異) 力場強度(倍數)
7 falloffExponent 0-2 (依力場種類而異) 衰減指數(數值越大,衰減越快),僅對某些會衰減的力場有效
8 minimumDistance 近乎0 力場開始衰減的距離(minimumDistance以內不衰減,僅對某些力場有效)
9 isActive true 力場是否生效
10 isExclusive false 是否獨佔,若true,則排除其他力場作用
11 categoryBitMask -1 (所有類別) 適用於同類的物理模擬節點(參考補充6碰撞處理說明)

不同力場,會對粒子系統產生不同的視覺效果,本節以渦流SCNPhysicsField.vortex()來示範,做一個類似龍捲風的效果。vortex 是漩渦、渦流或旋風的意思,和 vertex 幾何頂點英文很像,請勿搞混。

力場的程式碼比起粒子系統簡單多了,只要調整一兩個參數,然後將力場附加到某個節點即可:
let 螺旋力場 = SCNPhysicsField.vortex()
螺旋力場.strength = 0.5
暴風眼節點.physicsField = 螺旋力場

自此,該節點附近(預設為無限遠)的粒子系統或物理節點都會持續受力場影響,產生一定的加速度,作用越久,速度越快。

最後做出來的效果如下,形成漏斗狀的文字風暴,像是某些秘笈或寶藏從地底噴發出來的感覺:


完整範例程式如下:
// 6-5c 文字風暴:粒子系統+物理模擬
// Created by Heman, 2024/05/16
import SceneKit
import SwiftUI

struct 粒子系統3: View {
let 化學教室 = SCNScene()
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: [.autoenablesDefaultLighting, .allowsCameraControl], delegate: 助教)
.onAppear {
let 地面 = SCNFloor()
地面.reflectivity = 0.0
地面.materials.first?.diffuse.contents = UIImage(named: "格線")
let 地面節點 = SCNNode(geometry: 地面)

let 文字風暴 = SCNParticleSystem()
文字風暴.birthRate = 1000
文字風暴.particleLifeSpan = 5
文字風暴.particleImage = UIImage(named: "百字碑")
文字風暴.imageSequenceRowCount = 10
文字風暴.imageSequenceColumnCount = 10
文字風暴.imageSequenceInitialFrame = 50
文字風暴.imageSequenceInitialFrameVariation = 100
文字風暴.particleColor = UIColor.orange
文字風暴.particleSize = 0.2
文字風暴.particleSizeVariation = 0.38
文字風暴.emitterShape = SCNSphere(radius: 0.3)
文字風暴.emissionDuration = 1.0
文字風暴.particleVelocity = 1.0
文字風暴.isAffectedByPhysicsFields = true

let 暴風眼 = SCNSphere(radius: 0.1)
let 暴風眼節點 = SCNNode(geometry: 暴風眼)
暴風眼節點.position.y = 0.5
暴風眼節點.addParticleSystem(文字風暴)

let 螺旋力場 = SCNPhysicsField.vortex()
print(螺旋力場.direction, 螺旋力場.strength)
螺旋力場.strength = 0.5
暴風眼節點.physicsField = 螺旋力場

文字風暴.acceleration = SCNVector3(0, 2.5, 0)

化學教室.rootNode.addChildNode(空間座標系(尺寸半徑: 10))
化學教室.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(粒子系統3())

為了讓效果更好,除螺旋力場之外,還對粒子系統多加一個向上的加速度(反重力的效果),「文字風暴.acceleration = SCNVector3(0, 2.5, 0)」,可將這行程式碼取消,看看效果有何變化。

💡 註解
  1. 本節圖片用到前一節(6-5b)的「百字碑.png」以及上一課(6-4c)的「格線.png」。
  2. 物理力場的「力」,可分為超距力(如重力、電力、磁力)以及非超距力(如拉力、彈力、渦流、紊流),後者又稱接觸力,不過SceneKit只是模擬力學數據,並不需要實際接觸或介質。
  3. vortex 渦流、vertex 頂點、cortex 大腦皮質,三個英文單字很像,都不是常見的名詞,偶爾看到會容易搞混。字尾 tex 有編織品(textile)、紋理(texture)的意思。
  4. 第4課6-4a提過,物理模擬的節點有三種本體,其中只有動態本體(dynamic)會受到力場影響,靜態(static)與活動(kinematic)本體不受力場影響。
上半單元結語

呼,終於將SceneKit寫完。

筆者半年前規劃第6單元時,還在猶豫要不要跳過SceneKit,直接用RealityKit來寫AR,畢竟前者已經超過10年,有點老掉牙,後者相對較新,有後來者優勢。

最後決定還是先寫SceneKit,主要著眼於空間運算。在Apple定義的空間運算中,將3D + AI + AR完美融合在一起,讓虛擬空間無縫擴展到實體空間,但如此一來,軟體開發難度變得非常高,光是3D程式難度與內容成本,就已是2D程式10倍以上。

3D程式是空間運算的起步,單就3D程式而言,SceneKit 比 RealityKit更適合入門,SceneKit主要用「節點(Node)」概念,相當直觀,比起 RealityKit 用 ECS(Entity-Component-System)架構,容易許多。因此希望藉由SceneKit當作3D程式的踏腳石,先奠定基礎,再進一步邁入空間運算。

開始寫以後,發現SceneKit相當有趣,雖老卻不過時,用節點概念來佈局3D場景,既簡單又可無限擴充,而物理模擬與粒子系統更是令人激賞。原本想精簡的內容,最後還是無法割捨,於是就多了10篇補充說明,變相地壓縮到5課之內,實際上可能用整個單元篇幅更合適。

無論如何,上半單元還是先到此告一段落,雖然SceneKit 有些地方(例如操控人物動作的SCNSkinner與變換表情的SCNMorpher)並未接觸,或輕輕帶過(如力場與約束條件),但相信讀者對3D程式與空間運算,已有基本而清晰的概念,未來繼續探索不成問題。

接下來要正式進入AR,課程輪廓雖已有初步想法,但還是等6月中旬 WWDC 舉辦後,再來決定。Apple 的 AR 其實很多功能整合了AI,但筆者對今年 WWDC 的願望清單,第一個並不是生成式AI,而是小小的期盼 RealityView 能延伸到 macOS與 iOS,不只用在 visionOS。

否則的話,就只好照原先計畫用 RealityKit (ARView) + ARKit,如此一來,很多地方得混用 UIKit 與 SwiftUI,感覺不夠漂亮。而且 RealityKit 還有些功能,例如粒子系統,目前也只支援 visionOS,難免有些缺憾。

若WWDC有重大更新,影響到課程架構,可能等到年底Swift Playgrounds更新後,再繼續寫。

最後附上一個像透明玻璃的質感,算是對第1課光照模型與材質的補充。這是參考Andy Jazz寫的程式碼,關鍵參數是補充(10)提到的菲涅耳係數(fresnelExponent):


玻璃材質並不好做,所幸有菲涅耳公式才能做出如此逼真的倒影。不過若仔細觀察,會發現其實環面上並不是地面格線的倒影,而是來自四面八方,從程式碼可得知,原來只是「格線.png」圖案的映射,在此故意選用地面相同圖案。

完整程式碼如下:
// 透明反光 fresnelExponent
// Tested by Heman, 2024/05/26
// Ref: https://stackoverflow.com/questions/61124619/how-to-show-metallic-and-transparent-textures-in-scenekit/61130300#61130300
import SceneKit
import SwiftUI

struct 六字真言: View {
let 化學教室 = SCNScene()

var body: some View {
SceneView(scene: 化學教室, options: [.autoenablesDefaultLighting, .allowsCameraControl])
.onAppear {
let material = SCNMaterial()

material.lightingModel = .phong
material.diffuse.contents = UIColor(white: 0.2, alpha: 1)
material.diffuse.intensity = 0.9
material.specular.contents = UIColor(white: 1, alpha: 1)
material.specular.intensity = 1.0
material.reflective.contents = UIImage(named: "格線.png")
material.reflective.intensity = 2.0

material.transparencyMode = .dualLayer
material.fresnelExponent = 2.2
material.isDoubleSided = true
material.blendMode = .alpha
material.shininess = 100
material.transparency.native = 0.7
material.cullMode = .back

let 透明環 = SCNTorus(ringRadius: 10.0, pipeRadius: 5.0)
透明環.materials = [material]
let 透明環節點 = SCNNode(geometry: 透明環)
透明環節點.position = SCNVector3(0, 40, 0)
let 旋轉 = SCNAction.rotate(by: .pi * 2, around: SCNVector3(1, 0, 0), duration: 5)
透明環節點.runAction(.repeatForever(旋轉))

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

let 文字風暴 = SCNParticleSystem()
文字風暴.birthRate = 3000
文字風暴.particleLifeSpan = 5
文字風暴.particleImage = UIImage(named: "六字真言.png")
文字風暴.imageSequenceRowCount = 1
文字風暴.imageSequenceColumnCount = 6
文字風暴.imageSequenceInitialFrame = 3
文字風暴.imageSequenceInitialFrameVariation = 6
文字風暴.particleColor = UIColor.orange
文字風暴.particleSize = 0.2
文字風暴.particleSizeVariation = 0.38
文字風暴.emitterShape = SCNSphere(radius: 0.3)
文字風暴.emissionDuration = 1.0
文字風暴.particleVelocity = 1.0
文字風暴.isAffectedByPhysicsFields = true

let 暴風眼 = SCNSphere(radius: 0.1)
let 暴風眼節點 = SCNNode(geometry: 暴風眼)
暴風眼節點.position.y = 0.5
暴風眼節點.addParticleSystem(文字風暴)

let 螺旋力場 = SCNPhysicsField.vortex()
print(螺旋力場.direction, 螺旋力場.strength)
螺旋力場.strength = 0.5
暴風眼節點.physicsField = 螺旋力場

文字風暴.acceleration = SCNVector3(0, 2.5, 0)

化學教室.rootNode.addChildNode(透明環節點)
化學教室.rootNode.addChildNode(地面節點)
化學教室.rootNode.addChildNode(暴風眼節點)
化學教室.background.contents = UIColor.black
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(六字真言())

程式用到兩個圖案,一是地面的「格線.png」(參考6-4c),另一個是「六字真言.png」如下:


💡 註解
  1. 觀察執行結果(GIF動畫),環面下方也沒有文字風暴的倒影。因為要做出周遭事物的精確倒影,必須用到「光線追蹤(Ray Tracing)」技術,非常耗費算力,若沒有GPU硬體加速,光靠軟體目前無法做到即時運算(也就是在16.67毫秒內完成)。
  2. Apple GPU直到去(2023)年的M3以及A17 Pro晶片才開始支援光線追蹤,輝達(NVIDIA)則是在 2018年發布RTX系列全面支援Ray Tracing硬體加速。
  3. SceneKit 還是有些基礎內容被省略,例如從外部檔案載入3D模型或場景,其實只要一行程式:
    let 外部場景 = SCNScene(named: "model.usdz") 

    Apple官網有些3D模型,檔案結尾是 .usdz,可下載自行嘗試看看。
好消息:RealityView 支援多平台

哈哈,我的小小心願實現了。今天在WWDC 2024發表的影片中看到,原本只支援visionOS的RealityView,未來也能用於 iOS, iPadOS, macOS,真是太棒了!




來源:WWDC 2024 Discover RealityKit APIs for iOS, macOS and visionOS

為什麼這件事情如此重要呢?我們下半單元要用的 RealityKit,包含兩種顯示AR的視圖,一個是2019年發布的 ARView,配合 UIKit;另一個是去(2023)年推出的 RealityView,配合SwiftUI。

RealityView 除了搭配 SwiftUI 之外,還有一些優於 ARView 之處,特別是完全採用 async/await 處理非同步工作,例如載入外部模型檔;此外,在手勢互動、虛實整合、內容控制方面,也都比較容易。

從WWDC 2024很明顯可以看出,RealityKit + SwiftUI 就是未來 AR 程式與空間運算的主流,不但可支援多種設備(iPhone, iPad, Vision Pro),而且 RealityKit 底層 ARKit, Metal 也會不斷更新功能,伴隨硬體的升級,例如Apple GPU/NPU 晶片在3D繪圖與AI方面的強化,可以確保未來會跟著水漲船高。

不過,跨平台的 RealityView 必須配合新版 iOS 18/iPadOS 18/macOS 15,而且我們還要等Swift Playgrounds App更新之後才能用,預計在(2024)年底11月左右,屆時我們再繼續寫下半單元。

在這之前,筆者可能會先寫一些 SceneKit + ARKit 的內容,當作前菜,藉以學習 AR 基本觀念。
Swift 6 即將到來

目前Swift程式語言版本已來到 Swift 5.10,而在這次WWDC 2024,介紹了新版Swift 6 重大更新,並預告將於今(2024)年下半年正式發布Swift 6,其中最重要的特色之一,就是要完全去除資料衝突的可能(”Data-race safety”)。


來源:WWDC24 What’s new in Swift

何謂資料衝突(data-race)呢?在第5單元的語法說明:actor曾介紹過,資料衝突是並行計算(Concurrent computing)的致命要害,很容易發生但很難除錯。隨著硬體(CPU/GPU)多核心成為主流,軟體採用並行計算也越來越普遍,所以若能徹底解決資料衝突的問題,對未來軟體開發,特別像人工智慧(AI)、遊戲動畫、空間運算、區塊鏈、網路應用...等需要高速計算領域,非常有幫助。

Swift 6 採行若干機制,以保證所有資料衝突問題均可在編譯時期(Compile time)檢查出來,其中一個關鍵是 Actor 類型,Actor 主要作用是「隔離所有可能被同時修改的屬性」(Data isolation for ”shared mutable states”),所有共享屬性由 Actor 隔離控制,令讀、寫操作能依序(serialized)進行,這樣就確保不會發生資料衝突。

這也是為什麼使用 Actor 類型時,不管是讀取或寫入,都需要加上 await 的原因。第一次接觸 Actor 的同學,請參考第5單元的語法說明:actor

不過對初學者來說,Actor 可能短期內不會用到,可以先從 async/await 開始學起,請參考第3單元第7課 async/await,熟悉 async/await 之後,未來才能順利提升到 Actor 與進階並行計算。

整體來說,Swift 採行新一代並行計算的路線,從2021年async/await 開始,到今年Swift 6發布,歷經了3年多,算是完整告一段落。想進一步了解 Swift 並行計算的同學,可以從 WWDC 2021 影片看起:


💡 註解
  1. 並行計算的資料衝突(data race)問題,已經困擾軟體行業數十年,早期(筆者唸書時)稱為 Race condition。第一個(號稱)在編譯期能完全排除資料衝突問題的程式語言,應該是2010年發布的 Rust,這也是近年 Rust 受到追捧(尤其是深受資料衝突之苦的C++程式設計師)的主要原因。
  2. 有趣的是,Rust 程式語言的作者Graydon Hoare,在2016年轉到 Apple 任職,加入 Swift 程式語言的核心團隊(Core Team),剛好替補Swift 之父 Chris Lattner(2017年離開Apple)的位置。
  3. Swift 的並行運算架構,主要由 Chris Lattner 設計,並於2017年發表Swift並行運算宣言。在文章最後,點評了幾種程式語言的並行設計,包含Rust。
  4. 新的並行運算架構如何實現,乃根據2020年發表的Swift並行運算路線圖,分兩階段進行,並確認引進Actor類型。
補充(11) SceneKit + ARKit

在第1課(6-1a)曾經提過,在Apple平台寫 AR 程式,主要有兩種框架組合,一是SceneKit + ARKit;二是RealityKit + ARKit。不管哪種組合,寫出來的 AR 程式都可在 iPhone, iPad 以及 Vision Pro 上執行。概念如下圖:


AR 程式對硬體要求較高,除了核心晶片(CPU/GPU/NPU)與相機鏡頭之外,還須配備加速度計(或稱加速規)、陀螺儀、磁力計等感測元件,因此無法在 Mac 或 Macbook 中執行,只支援 iPhone, iPad, Vision Pro 等產品,且若干功能還對軟硬體版本有額外要求。

本單元前5課介紹SceneKit,在熟悉SceneKit之後,只要再加上ARKit框架,就能做出AR程式。以最簡單的講法來說,SceneKit負責建構虛擬場景,而ARKit則負責融入實景場域。

不過,要動手寫 SceneKit + ARKit 程式,還有個小問題,就是只能使用 UIView 顯示,因為 ARKit 無法搭配 SceneView (如下圖)。以下我們會透過 UIViewRepresentable,將 UIView 轉換到 SwiftUI 格式。


第一步先來寫一個最簡化的AR程式,只要求開啟相機鏡頭:
// 補充(11a) 最簡AR程式:SceneKit + ARKit
// Tested by Heman, 2024/07/08
import SwiftUI
import SceneKit
import ARKit

struct 最簡AR: UIViewRepresentable {
func makeUIView(context: Context) -> SCNView {
let AR視圖 = ARSCNView()

if ARWorldTrackingConfiguration.isSupported {
print("支援AR")
let 設定 = ARWorldTrackingConfiguration()
AR視圖.session.run(設定)
} else {
print("不支援AR")
}
return AR視圖
}

func updateUIView(_ uiView: SCNView, context: Context) {
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(最簡AR())

這個程式若在 iPad (透過Swift Playgrounds App)執行,會要求相機權限,以便開啟相機鏡頭,如下圖:


若在 macOS 執行,只會看到一個全白(或全黑,若深色模式)畫面,而不會開啟鏡頭。

程式中 UIViewRepresentable 規範用來將 UIView 轉成 SwiftUI 的格式(也就是View),規範要求建構兩個函式:makeUIView() 與 updateUIView()。

第一個函式 makeUIView() 在一開始會被呼叫一次,用來創造初始畫面(即回傳值),回傳值須符合 UIView 類型(或子類型);第二個函式 updateUIView() 目前暫時用不到,可保持空白但不可省略。

在 makeUIView() 函式中,我們準備回傳 ARSCNView — 這是由 ARKit 產出、繼承自 SCNView 的 UIView 視圖物件。要開啟相機只需設定好AR任務:AR視圖.session.run(設定),此處「設定」有幾種選擇,最常用的是 ARWorldTrackingConfiguration (實體世界追蹤,參考註解2):
let AR視圖 = ARSCNView()
let 設定 = ARWorldTrackingConfiguration()
AR視圖.session.run(設定)

AR視圖的 session 屬性,是屬於 ARSession 類型,代表一個 AR 任務階段,也就是說,一個 AR 任務的開始與結束,由這個 ARSession 掌控,與第3單元(3-1b) URLSession 或第5單元(5-6a) AVAudioSession的概念相同。

範例程式在「設定」之前,會先用 ARWorldTrackingConfiguration.isSupported 檢查設備是否支援 AR,再往下執行。

學會第一步開啟相機鏡頭,有了實景之後,接下來就可在虛擬場景中添加3D物品。以下程式會在座標原點(初始畫面中央)增加一個旋轉的甜甜圈:
// 補充(11b) ARKit 介紹
// Created by Heman, 2024/07/08
import SwiftUI
import SceneKit
import ARKit

struct 簡單AR: UIViewRepresentable {
func makeUIView(context: Context) -> SCNView {
let AR視圖 = ARSCNView()

if ARWorldTrackingConfiguration.isSupported {
print("支援AR")
let 設定 = ARWorldTrackingConfiguration()
AR視圖.session.run(設定)
let 甜甜圈 = SCNTorus(ringRadius: 0.2, pipeRadius: 0.1)
let 甜甜圈節點 = SCNNode(geometry: 甜甜圈)
let 旋轉 = SCNAction.rotateBy(x: .pi, y: .pi * 2.0, z: 0, duration: 5)
甜甜圈節點.runAction(.repeatForever(旋轉))
AR視圖.scene.rootNode.addChildNode(甜甜圈節點)
} else {
print("不支援AR")
let 提示 = SCNText(string: "☹︎設備不支援擴增實境", extrusionDepth: 1.0)
let 提示節點 = SCNNode(geometry: 提示)
let 虛擬場景 = SCNScene()
虛擬場景.rootNode.addChildNode(提示節點)
AR視圖.scene = 虛擬場景
}

AR視圖.backgroundColor = .gray
AR視圖.allowsCameraControl = true
AR視圖.autoenablesDefaultLighting = true
AR視圖.showsStatistics = true

return AR視圖
}

func updateUIView(_ uiView: SCNView, context: Context) {
print("呼叫 updateUIView(), \(Date.now)")
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(簡單AR())

若是在 macOS 中執行,則會在畫面中出現3D文字「☹︎設備不支援擴增實境」,不會開啟相機。

在iPad 執行結果如下:


💡 註解
  1. 為什麼 AR 程式需要加速規、陀螺儀、磁力計等感測元件呢?
  2. AR 程式開啟鏡頭後,即時影像會當作虛擬場景(SCNScene)的背景,並以感測器(加速規、陀螺儀、磁力計)偵測追蹤設備的面向、位移與轉動,同步轉換虛擬物件的大小與角度,如此才能將虛景與實景融合在一起。
  3. 為什麼 ARKit 只能搭配 SCNView (UIView),不能配合 SceneView (SwiftUI) 呢?
  4. updateUIView() 會在物件「狀態改變」時被呼叫,也就是當狀態變數(@State var)或綁定變數(@Binding var)被更改時,我們要寫入相對應的程式碼來手動更新視圖。
  5. 為什麼不需要寫 updateUIView() 內容?因為一旦創造初始畫面,後續ARKit會自動更新視圖,不需要透過 updateUIView() 手動更新。
補充(12) AR 世界座標與遮擋問題

上一節提到AR的虛實融合,一方面是靠SceneKit建構虛擬場景,另一方面則靠ARKit將實景帶入,並追蹤感測設備的任何動作。ARKit 會自動轉換3D物件的大小與方向,讓3D物件與實景的相對位置不變,以達到虛實融合的效果。

因此,AR程式有以下幾個重要特性:

〖特性1〗實體環境的「世界座標」與虛擬場景的3D座標必須重合
〖特性2〗實體鏡頭與虛擬鏡頭必須重疊且同步
〖特性3〗即時影像當作背景,虛景在前、實景在後 ⇒ 產生遮擋問題

若將iPad 垂直擺正放在眼前,啟動AR程式時 SceneKit 會以螢幕中央為座標原點,往右為X軸正向、往上為Y軸正向、往使用者方向為Z軸正向,這是前5課學過的。

所謂虛、實座標重合〖特性1〗,就是當AR程式啟動時,實體場景的「世界座標」會對齊 SceneKit 座標,世界座標原點放在「啟動時」的螢幕中央,X/Y/Z軸相同,之後不管設備如何移動或旋轉,兩個座標系都會保持不動,直到AR任務階段(ARSession)結束為止。

AR程式對於實體世界的了解,都是透過相機鏡頭,相機鏡頭隨設備而動,可以上下(Y軸)、左右(X軸)、前後(Z軸)移動,或俯仰點頭(Pitch, 依X軸轉)、左右轉動(Yaw, 依Y軸轉)、左擺右擺(Roll, 依Z軸轉),稱為6個自由度(6 DoF, Degree of Freedom)。

這6個自由度的變化,可透過實體設備的傳感器即時追蹤,並經由虛擬場景的座標變換(transform),讓3D物件與實體環境同步變化。

我們可以加幾行程式碼來觀察實體的世界座標,做法與第4課(6-4c)的代理程式一樣,寫個 ARSCNViewDelegate 代理程式:
class AR後台助手: NSObject, ARSCNViewDelegate {
// 每秒呼叫60次
public func renderer(_ renderer: any SCNSceneRenderer, updateAtTime time: TimeInterval) {
// print("呼叫後台助手 \(Date.now)")
renderer.debugOptions = [.showWorldOrigin, .showBoundingBoxes]
}
}

透過代理程式「AR後台助手」可開啟除錯功能(debugOptions),在此加入兩個選項:

.showWorldOrigin — 顯示實體「世界座標」的原點與X/Y/Z座標
.showBoundingBoxes — 顯示3D物件的外框

世界座標的原點,可以在底下影片中看到。此外,還可觀察到〖特性3〗,這是AR很奇特的現象:遮擋問題(Occlusion problem)。


為什麼會有遮擋問題?因為實體環境透過鏡頭拍攝,總是放在虛擬場景的背後(當作背景),雖然在我們的認知中,實體環境是立體的,但經過鏡頭拍攝後,放在背景的其實只是2D影像,並沒有深度。

在影片中,座標原點與甜甜圈原本在椅子前面,當移動iPad到椅子背後時,可看到甜甜圈並未被椅子遮擋,這與真實世界明顯不一致。甚至退到門後,透過牆壁看過去時,還是看得到甜甜圈,而不像椅子會被牆壁擋住(這才正常啊)。

遮擋問題在某些條件下,可以利用AI技術解決,不過大多無法避免,這是AR的普遍問題,因此在設計AR應用時,盡可能不要露出馬腳讓使用者發現(例如限制移動範圍或設計實物擺放位置),以免影響效果。

以下是完整的範例程式,由於AR需要的效能較高,執行前記得關閉「啟用結果」:
// 補充(12) 世界座標
// Created by Heman, 2024/07/11
// 執行前必須關閉「啟用結果」(在「▶︎執行我的程式碼」左邊)
import SwiftUI
import SceneKit
import ARKit

struct 世界座標: UIViewRepresentable {
let 小幫手 = AR後台助手()

func makeUIView(context: Context) -> SCNView {
let AR視圖 = ARSCNView()

if ARWorldTrackingConfiguration.isSupported {
print("支援AR")
let 設定 = ARWorldTrackingConfiguration()
AR視圖.session.run(設定)
let 甜甜圈 = SCNTorus(ringRadius: 0.2, pipeRadius: 0.1)
let 甜甜圈節點 = SCNNode(geometry: 甜甜圈)
let 旋轉 = SCNAction.rotateBy(x: .pi, y: .pi * 2.0, z: 0, duration: 5)
甜甜圈節點.runAction(.repeatForever(旋轉))
AR視圖.scene.rootNode.addChildNode(甜甜圈節點)
AR視圖.delegate = 小幫手
} else {
print("不支援AR")
let 提示 = SCNText(string: "☹︎設備不支援擴增實境", extrusionDepth: 1.0)
let 提示節點 = SCNNode(geometry: 提示)
let 虛擬場景 = SCNScene()
虛擬場景.rootNode.addChildNode(提示節點)
AR視圖.scene = 虛擬場景
}

AR視圖.allowsCameraControl = true
AR視圖.backgroundColor = .gray
AR視圖.autoenablesDefaultLighting = true
AR視圖.showsStatistics = true

return AR視圖
}

func updateUIView(_ uiView: SCNView, context: Context) {
}
}

class AR後台助手: NSObject, ARSCNViewDelegate {
// 每秒呼叫60次
public func renderer(_ renderer: any SCNSceneRenderer, updateAtTime time: TimeInterval) {
// print("呼叫後台助手 \(Date.now)")
renderer.debugOptions = [.showWorldOrigin, .showBoundingBoxes]
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(世界座標())

💡 註解
  1. 請動手觀察〖特性2〗:由於設定「AR視圖.allowsCameraControl = true」,當程式執行時,可滑動螢幕操控甜甜圈,然後iPad左右轉動時,會發現甜甜圈脫離實景,不再虛實融合,這是因為一旦手動控制虛擬鏡頭,就會與實體鏡頭分道揚鑣。在螢幕輕點兩下會重新還原。
  2. 人工智慧(AI)除了協助解決遮擋問題,也能增加實體世界追蹤的精確度,雖然鏡頭實景只是2D影像,但透過AI視覺仍可大致估算深度、距離、尺寸以及光照,讓虛實融合更平順。
  3. 「啟用結果」是Swift Playgrounds 電子書模式下獨有特色,可以幫助初學者了解程式執行過程,不過會佔用相當多效能,在執行AR程式時,盡量關閉。App模式下沒有這個問題。
補充(13) 何時呼叫updateUIView()?

要透過 UIViewRepresentable 將 UIView 轉成 SwiftUI View,除了寫 makeUIView() 建構初始畫面之外,還需要一個 updateUIView(),意思是更新視圖畫面,但什麼時候會用到呢?

答案是在視圖的「狀態改變」時,在 SwiftUI 架構下,若設定狀態變數(@State var)或綁定變數(@Binding var),系統就會監控這兩種變數,當變數值有所變化(與前值不同)時,就會觸發「狀態改變」,自動呼叫 updateUIView()。

至於 updateUIView() 的內容要寫什麼,就端看程式設計師的意思,主要邏輯是反應狀態變數(或綁定變數)改變後,畫面相對應的變化。以下用個簡單範例來進一步說明。

範例程式加入一個綁定變數「顏色」,然後用另外一個 SwiftUI 視圖來控制,當輕觸(Tap)螢幕時,「顏色」會隨機變化,以觸發狀態改變,呼叫 updateUIView(),在函式裡面將甜甜圈的材質顏色改為新值。

程式碼變更的部份如下,makeUIView() 甜甜圈節點要加上名稱,以便讓 updateUIView() 搜尋子節點,也就是用 childNode() 找到這個節點,然後更改材質顏色:
struct 更新AR視圖: UIViewRepresentable {
@Binding var 顏色: UIColor

func makeUIView(context: Context) -> SCNView {
//...
甜甜圈節點.name = "甜甜圈"
}

func updateUIView(_ uiView: SCNView, context: Context) {
if let 目標節點 = uiView.scene?.rootNode.childNode(withName: "甜甜圈", recursively: true) {
目標節點.geometry?.materials.first?.diffuse.contents = 顏色
}
}
}

另外再寫一段 SwiftUI 程式來控制這個視圖:
struct 手動更新視圖: View {
@State var 隨機色彩 = UIColor.orange
var body: some View {
更新AR視圖(顏色: $隨機色彩)
.onTapGesture {
隨機色彩 = UIColor(red: .random(in: 0..<1), green: .random(in: 0..<1), blue: .random(in: 0..<1), alpha: 1)
}
}
}

當「更新AR視圖(顏色: $隨機色彩)」產出物件實例時,兩個視圖的狀態變數會雙向綁定在一起,使用者輕觸螢幕時,會指定新的「隨機色彩」值,另一邊「顏色」就跟著改變,觸發狀態變化而呼叫 updateUIView()。

完整範例程式如下:
// 補充(13) updateUIView 手動更新視圖
// Created by Heman, 2024/07/13
// 執行前必須關閉「啟用結果」(在「▶︎執行我的程式碼」左邊),否則可能不會變色
import SwiftUI
import SceneKit
import ARKit

struct 更新AR視圖: UIViewRepresentable {
let 小幫手 = AR後台助手()
@Binding var 顏色: UIColor

func makeUIView(context: Context) -> SCNView {
let AR視圖 = ARSCNView()

print("呼叫 makeUIView(), \(Date.now)")
if ARWorldTrackingConfiguration.isSupported {
print("支援AR")
let 設定 = ARWorldTrackingConfiguration()
AR視圖.session.run(設定)
let 甜甜圈 = SCNTorus(ringRadius: 0.2, pipeRadius: 0.1)
let 甜甜圈節點 = SCNNode(geometry: 甜甜圈)
甜甜圈節點.name = "甜甜圈"
let 旋轉 = SCNAction.rotateBy(x: .pi, y: .pi * 2.0, z: 0, duration: 5)
甜甜圈節點.runAction(.repeatForever(旋轉))
AR視圖.scene.rootNode.addChildNode(甜甜圈節點)
AR視圖.delegate = 小幫手
} else {
print("不支援AR")
let 提示 = SCNText(string: "☹︎設備不支援擴增實境", extrusionDepth: 1.0)
let 提示節點 = SCNNode(geometry: 提示)
let 虛擬場景 = SCNScene()
虛擬場景.rootNode.addChildNode(提示節點)
AR視圖.scene = 虛擬場景
AR視圖.allowsCameraControl = true // 移到此處,避免干擾AR效果
}

AR視圖.backgroundColor = .gray
AR視圖.autoenablesDefaultLighting = true
AR視圖.showsStatistics = true

return AR視圖
}

func updateUIView(_ uiView: SCNView, context: Context) {
print("呼叫 updateUIView(), \(Date.now)")
if let 目標節點 = uiView.scene?.rootNode.childNode(withName: "甜甜圈", recursively: true) {
print("找到目標節點(甜甜圈)")
目標節點.geometry?.materials.first?.diffuse.contents = 顏色
}
}
}

class AR後台助手: NSObject, ARSCNViewDelegate {
// 每秒呼叫60次
public func renderer(_ renderer: any SCNSceneRenderer, updateAtTime time: TimeInterval) {
// print("呼叫後台助手 \(Date.now)")
renderer.debugOptions = [.showWorldOrigin, .showBoundingBoxes]
}
}

struct 手動更新視圖: View {
@State var 隨機色彩 = UIColor.orange
var body: some View {
更新AR視圖(顏色: $隨機色彩)
.onTapGesture {
隨機色彩 = UIColor(red: .random(in: 0..<1), green: .random(in: 0..<1), blue: .random(in: 0..<1), alpha: 1)
// print("塗色:\(隨機色彩)")
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(手動更新視圖())

執行結果如下:


💡 註解
  1. 若觀察控制台輸出,會發現一開始(初始畫面)makeUIView() 與 updateUIView() 都會被呼叫一次,初始畫面完成之後,才會開始監控狀態變化。
  2. 本節範例程式執行時,初始畫面總是橘色甜甜圈,而非預設白色材質(如補充12, 13),為什麼呢?
  3. 在 Swift Playgrounds 中,必須將「啟用結果」(在「▶︎執行我的程式碼」左邊)關閉,否則會影響執行結果(不會變色),筆者使用 iPad 6 (2018)、iPad Pro 11” (2018)、iPad Pro M4 (2024) 測試均同樣情況。
補充(14) AR人臉追蹤 (ARFaceTrackingConfiguration)

在補充(11)提到,要透過 ARKit 啟動擴增實境(相機鏡頭),最簡單的指令就是設定AR任務(ARSession)並加以執行(run):
// 補充(11a)
let AR視圖 = ARSCNView()
let 設定 = ARWorldTrackingConfiguration()
AR視圖.session.run(設定)

此例中,一旦AR任務設定為「世界追蹤(ARWorldTrackingConfiguration)」,就會啟用設備(iPad/iPhone)的後相機,將實景帶入畫面中,並開始追蹤實體世界的座標變化。

除了「世界追蹤」之外,ARKit 還有其他不同的設定,用來指定AR任務的基本模式與功能。以下是目前ARKit 可追蹤的8種任務設定:
# AR設定 名稱 鏡頭 說明
1 ARWorldTrackingConfiguration 世界追蹤 後相機 追蹤一般實體場景
2 ARFaceTrackingConfiguration 人臉追蹤 前相機 追蹤使用者臉型
3 ARBodyTrackingConfiguration 人體追蹤 後相機 追蹤任何人體動作
4 ARImageTrackingConfiguration 影像追蹤 後相機 追蹤特定靜態影像
5 ARObjectScanningConfiguration 3D物件追蹤 後相機 追蹤特定3D物件
6 ARGeoTrackingConfiguration 地理位置追蹤 後相機 追蹤GPS地理位置
需配合高精地圖,目前僅支援美、英、澳、加、日等部分國家及城市,參考
原廠文件
7 ARPositionalTrackingConfiguration 設備位移追蹤 後相機 僅追蹤設備位移(3 DoF)
8 AROrientationTrackingConfiguration 設備旋轉追蹤 後相機 僅追蹤設備旋轉(3 DoF)

這8種是 ARKit 基本運作模式,因此不管配合SceneKit或RealityKit,功能都一樣(但用法有差異)。這其中,只有人臉追蹤採用前相機(會左右鏡像,有效距離3米),其餘均使用解析度較高的後相機。

人臉追蹤除了一般AR功能之外,還應用人工智慧辨識臉部輪廓,額外要求硬體必須含A12晶片以上(+iPadOS/iOS 14.0以上版本)。可執行人臉追蹤的最低配備如下:

- iPad 8 (2020)
- iPad Air 3 (2019)
- iPad mini 5 (2019)
- iPad Pro 11” (2018)
- iPhone XS (2018)

只要 iPad 款式等於或高於上述型號,均可以透過 Swift Playgrounds 執行本節範例程式;至於 iPhone XS (或更新)也能執行,但必須透過 Mac + Xcode,且以USB連接 iPhone,這部分請自行參考網路相關文章

要設定AR任務為人臉追蹤,程式寫法與世界追蹤類似,只要將設定名稱改為ARFaceTrackingConfiguration 即可:
// 補充(14)
let AR視圖 = ARSCNView()
let 設定 = ARFaceTrackingConfiguration()
AR視圖.session.run(設定)

這樣就會開啟前鏡頭,將使用者加入畫面中,就像自拍。

人臉追蹤有什麼用呢?答案是可以將鏡頭前的人臉即時掃描為3D物件,類似下圖,如同戴上一個3D活面具,會隨不同表情而變化:

如何擷取這個3D面具?這就得透過代理程式(命名為「AR後台助手」),ARSCNViewDelegate 規範有多種 renderer() 函式可選用:

1. renderer(_:nodeFor:) ← 本節會用到
2. renderer(_:didAdd:for:)
3. renderer(_:willUpdate:for:)
4. renderer(_:didUpdate:for:) ← 本節會用到
5. renderer(_:didRemove:for:)

而且因為 ARSCNViewDelegate 繼承自 SCNSceneRendererDelegate,後者(親代)所規範的函式也照樣可用:

6. renderer(_:updateAtTime:) — 補充(12), (13)用過
7. renderer(_:didApplyAnimationsAtTime:)
8. renderer(_:didSimulatePhysicsAtTime:)
9. renderer(_:willRenderScene:atTime:)
10. renderer(_:didRenderScene:atTime:)

根據這幾種函式的不同參數,被呼叫的時機與功能也略有差別,在補充(8)已說明過。函式第一個參數(可省略名稱)一律是該視圖的渲染器(renderer),也就是在背後將虛擬場景的3D模型呈現出來的程式;第二個或第三個參數,則各自不同,詳細用法可參考ARSCNViewDelegate官方文件SCNSceneRendererDelegate官方文件

擷取臉部3D面具會用到第一種 — renderer(_:nodeFor:),這個函式可傳回臉部3D模型節點(SCNNode),具體程式碼如下:
class AR後台助手: NSObject, ARSCNViewDelegate {
var myGPU: MTLDevice? = nil // 從外部 ARSCNView().device 傳入

func renderer(_ renderer: any SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
if myGPU == nil {
return nil
} else {
let 臉型 = ARSCNFaceGeometry(device: myGPU!)
let 臉型節點 = SCNNode(geometry: 臉型)
臉型節點.geometry?.materials.first?.fillMode = .lines

return 臉型節點
}
}
}

要取得即時掃描人臉的結果,必須透過一個特別物件:MTLDevice,MTL 是 Metal 的簡寫,Metal 是底層可直接控制GPU(繪圖)與NPU(神經網路)晶片的程式框架,ARKit 掃描人臉後須透過 GPU產出3D模型,關鍵程式碼如下:
let 臉型 = ARSCNFaceGeometry(device: myGPU!)

ARSCNFaceGeometry() 會產出臉部的3D模型,所需的參數是可用的運算設備(device),也就是GPU,要從哪裡來呢?

答案是在AR視圖(ARSCNView)的屬性裏面,ARSCNView() 初始化時,就會取得硬體GPU的參數,放在 device 裏面:
struct 人臉追蹤: UIViewRepresentable {
let 小幫手 = AR後台助手()

func makeUIView(context: Context) -> SCNView {
let AR視圖 = ARSCNView()

if ARFaceTrackingConfiguration.isSupported {
...
AR視圖.delegate = 小幫手 // ARSCNViewDelegate
小幫手.myGPU = AR視圖.device // 臉型追蹤設備(GPU)
...
}
}

擷取到臉部3D模型,並傳回SCNNode節點之後,還必須即時更新,隨臉部表情變化更新3D模型,需要另一個(第4種) renderer() 函式:
// class AR後台助手
func renderer(_ renderer: any SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
if let 臉部錨點 = anchor as? ARFaceAnchor,
let 臉型 = node.geometry as? ARSCNFaceGeometry {
臉型.update(from: 臉部錨點.geometry)
}
}

這裡要注意一個新名詞:AR錨點(ARAnchor),錨(讀音同毛)指船錨 — 在海中固定船舶位置的裝置,同樣的,AR錨點就是在場景中用來固定3D模型的座標位置,類似 SceneKit 父節點的角色(參考第2課6-2b)。

在AR程式中,必須先有錨點,才能放置SCNNode節點(除非有其他父節點)與3D模型。

當我們在第一個 renderer(_:nodeFor:) 函式取得臉部3D模型前,經由臉部掃描會自動產生一個錨點(位置大約在鼻子下方的「人中」),讓臉部3D模型附掛(並當作座標原點)。當錨點移動或旋轉時,就會呼叫第二個 renderer() 函式,即時更新臉部3D模型。

以上結合起來,完整程式如下:
// 補充(14) AR人臉追蹤 - ARKit + SceneKit
// Created by Heman, 2024/07/15
// 執行前請關閉「啟用結果」(在「▶︎執行我的程式碼」左邊)
import SceneKit
import SwiftUI
import ARKit

struct 人臉追蹤: UIViewRepresentable {
let 小幫手 = AR後台助手()

func makeUIView(context: Context) -> SCNView {
let AR視圖 = ARSCNView()

if ARFaceTrackingConfiguration.isSupported {
print("支援人臉追蹤")
let 人臉追蹤設定 = ARFaceTrackingConfiguration()
AR視圖.session.run(人臉追蹤設定)
AR視圖.delegate = 小幫手 // ARSCNViewDelegate
小幫手.myGPU = AR視圖.device // 臉型追蹤設備(GPU)
print(AR視圖.device)
} else {
print("不支援人臉追蹤")
let 提示 = SCNText(string: "☹︎設備不支援臉部追蹤", extrusionDepth: 1.0)
let 提示節點 = SCNNode(geometry: 提示)
let 虛擬場景 = SCNScene()
虛擬場景.rootNode.addChildNode(提示節點)
AR視圖.scene = 虛擬場景
AR視圖.allowsCameraControl = true // 移到此處,避免干擾AR效果
}

AR視圖.backgroundColor = .gray
AR視圖.autoenablesDefaultLighting = true
AR視圖.showsStatistics = true

return AR視圖
}

func updateUIView(_ uiView: SCNView, context: Context) { }
}

class AR後台助手: NSObject, ARSCNViewDelegate {
var myGPU: MTLDevice? = nil // 從外部 ARSCNView().device 傳入

func renderer(_ renderer: any SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
if myGPU == nil {
return nil
} else {
let 臉型 = ARSCNFaceGeometry(device: myGPU!)
let 臉型節點 = SCNNode(geometry: 臉型)
臉型節點.geometry?.materials.first?.fillMode = .lines

return 臉型節點
}
}

func renderer(_ renderer: any SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
if let 臉部錨點 = anchor as? ARFaceAnchor,
let 臉型 = node.geometry as? ARSCNFaceGeometry {
臉型.update(from: 臉部錨點.geometry)
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(人臉追蹤())

下圖是在 iPad Pro M4 的執行結果,可觀察到兩點:(1) 照片左右相反(看衣服字樣,實際閉左眼) (2) 同樣有遮擋問題(看食指尖)


💡 註解
  1. AR錨點可視為虛、實場景的連接點,就像連接兩個宇宙的蟲洞,是AR非常重要的觀念。
  2. 錨點(anchor)類似SceneKit的父節點,用來附掛子節點,當錨點移動或旋轉時,子節點就跟著移動或旋轉。但錨點並不是SCNNode節點,而僅是一個座標(包含位置與方向)。
  3. AR錨點(ARAnchor)是經由相機鏡頭,在實體場景中找到特定的「特徵點」座標,由於AR是虛實融合,因此AR錨點也同樣是虛擬場景的座標。
  4. 在前兩節(補充12, 13)世界追蹤剛啟動時,預設只有一個AR錨點,就是世界座標原點,即啟動時的螢幕中心點。
補充(15) 臉部表情偵測(blendShapes)

AR人臉追蹤(ARFaceTrackingConfiguration)結合人工智慧,除了持續掃描人臉,即時產出3D面具模型之外,還會記錄臉部表情 — 如同臉部的動作捕捉(Motion Capture)。

有了臉部表情的資料,我們就可以根據表情來控制設備 — 有聽過「眨眼拍照」嗎?拍照時只要透過表情就可以控制相機自動拍照,不必任何按鈕。

本節範例將用眨眼來控制3D面具的外觀:正常顯示框線(Wireframe),眨眼時切換到預設的白色材質,程式執行結果如下:



程式的關鍵在判斷臉部表情,如何取得表情資料呢?答案在臉部錨點(ARFaceAnchor)的屬性:blendShapes,blend 是混合、混雜之意,Shapes 指臉部形狀,為什麼叫「混合形狀」?因為 blendShapes 一個屬性就混雜了52種臉部表情的資料(後面詳述)。

先來看如何使用 blendShapes,與上一節補充(14)相比,只要在renderer(_:didUpdate:for:)函式中增加6行程式碼即可:
func renderer(_ renderer: any SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
if let 臉部錨點 = anchor as? ARFaceAnchor,
let 臉型 = node.geometry as? ARSCNFaceGeometry {
臉型.update(from: 臉部錨點.geometry)

let 表情 = 臉部錨點.blendShapes
if 表情[.eyeBlinkLeft] as! Double > 0.5 || 表情[.eyeBlinkRight] as! Double > 0.5 {
node.geometry?.firstMaterial?.fillMode = .fill
} else {
node.geometry?.firstMaterial?.fillMode = .lines
}
}
}

臉部錨點會在每次畫面更新時,將即時的表情資料(blendShapes)帶進來,我們只需抓其中「眨左眼」與「眨右眼」的資料。

blendShapes的資料類型是一種「字典」(參考[第5單元]語法說明),也就是可用任何類型為索引的陣列,上例中用「表情[.eyeBlinkLeft]」來取得眨左眼的表情內容,索引是 .eyeBlinkLeft (眨左眼),得到的內容值是0.0~1.0之間的實數 — 表情的動作幅度,0表示正常睜眼,1.0表示完全閉眼。

程式邏輯很簡單,若左右眼眨眼幅度超過0.5 (50%)就顯示預設材質(.fill),否則顯示框線(.lines)。複雜的部分,ARKit 在背後幫我們做完了,程式寫起來就非常輕鬆。

上面提到 ARKit 將臉部表情細分為52個獨立的動作,混雜放入 blendShapes 字典中,用不同索引即可取得對應的表情資料,索引名稱如下:

1. eyeBlinkLeft (眨左眼)
2. eyeLookDownLeft (左眼往下看)
3. eyeLookInLeft (左眼往右看)
4. eyeLookOutLeft (左眼往左看)
5. eyeLookUpLeft (左眼往上看)
6. eyeSquintLeft (左眼微瞇)
7. eyeWideLeft (左眼睜大)
8. eyeBlinkRight (眨右眼)
9. eyeLookDownRight (右眼往下看)
10. eyeLookInRight (右眼往左看)
11. eyeLookOutRight (右眼往右看)
12. eyeLookUpRight (右眼往上看)
13. eyeSquintRight (右眼微瞇)
14. eyeWideRight (右眼睜大)
15. jawForward (下巴前凸,戽斗)
16. jawLeft (下巴左移,歪嘴)
17. jawRight (下巴右移,歪嘴)
18. jawOpen (驚掉下巴,張嘴)
19. mouthClose (閉嘴,嘴唇閉合)
20. mouthFunnel (O型嘴)
21. mouthPucker (嘟嘴,親吻狀)
22. mouthLeft (雙唇閉合左移)
23. mouthRight (雙唇閉合右移)
24. mouthSmileLeft (微笑,雙唇左上翹)
25. mouthSmileRight (微笑,雙唇右上翹)
26. mouthFrownLeft (嘴角左下彎)
27. mouthFrownRight (嘴角右下彎)
28. mouthDimpleLeft (左嘴角後縮,露出酒窩)
29. mouthDimpleRight (右嘴角後縮,露出酒窩)
30. mouthStretchLeft (左嘴角左移)
31. mouthStretchRight (右嘴角右移)
32. mouthRollLower (下唇微收,咬嘴唇)
33. mouthRollUpper (上唇微收,抿嘴)
34. mouthShrugLower (下唇微凸,想哭)
35. mouthShrugUpper (上唇微凸)
36. mouthPressLeft (左嘴角緊閉)
37. mouthPressRight (右嘴角緊閉)
38. mouthLowerDownLeft (下唇左下開)
39. mouthLowerDownRight (下唇右下開)
40. mouthUpperUpLeft (左上唇微張)
41. mouthUpperUpRight (右上唇微張)
42. browDownLeft (皺左眉)
43. browDownRight (皺右眉)
44. browInnerUp (皺雙眉)
45. browOuterUpLeft (左眉角上揚)
46. browOuterUpRight (右眉角上揚)
47. cheekPuff (鼓頰,雙頰鼓氣)
48. cheekSquintLeft (左頰上移,瞇眼時)
49. cheekSquintRight (右頰上移,瞇眼時)
50. noseSneerLeft (左鼻癢,打噴嚏狀)
51. noseSneerRight (右鼻癢,打噴嚏狀)
52. tongueOut (吐舌頭)

本節主要就是了解這52種表情,ARKit 對臉部表情的辨識資料非常完善,若能充分運用,不但可以豐富使用者的體驗,還能做出獨特的App出來。

以下是完整範例程式:
// 補充(15) 臉部表情偵測 (blendShapes)
// Created by Heman, 2024/07/20
// 執行前請關閉「啟用結果」(在「▶︎執行我的程式碼」左邊)
import SceneKit
import SwiftUI
import ARKit

struct 人臉追蹤: UIViewRepresentable {
let 小幫手 = AR後台助手()

func makeUIView(context: Context) -> SCNView {
let AR視圖 = ARSCNView()

if ARFaceTrackingConfiguration.isSupported {
print("支援人臉追蹤")
let 人臉追蹤設定 = ARFaceTrackingConfiguration()
AR視圖.session.run(人臉追蹤設定)
AR視圖.delegate = 小幫手 // ARSCNViewDelegate
小幫手.myGPU = AR視圖.device // 臉型追蹤設備(GPU)
print(AR視圖.device)
} else {
print("不支援人臉追蹤")
let 提示 = SCNText(string: "☹︎設備不支援臉部追蹤", extrusionDepth: 1.0)
let 提示節點 = SCNNode(geometry: 提示)
let 虛擬場景 = SCNScene()
虛擬場景.rootNode.addChildNode(提示節點)
AR視圖.scene = 虛擬場景
AR視圖.allowsCameraControl = true // 移到此處,避免干擾AR效果
}

AR視圖.backgroundColor = .gray
AR視圖.autoenablesDefaultLighting = true
AR視圖.showsStatistics = true

return AR視圖
}

func updateUIView(_ uiView: SCNView, context: Context) { }
}

class AR後台助手: NSObject, ARSCNViewDelegate {
var myGPU: MTLDevice? = nil // 從外部 ARSCNView().device 傳入

func renderer(_ renderer: any SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
if myGPU == nil {
return nil
} else {
let 臉型 = ARSCNFaceGeometry(device: myGPU!)
let 臉型節點 = SCNNode(geometry: 臉型)
臉型節點.geometry?.materials.first?.fillMode = .lines

return 臉型節點
}
}

func renderer(_ renderer: any SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
if let 臉部錨點 = anchor as? ARFaceAnchor,
let 臉型 = node.geometry as? ARSCNFaceGeometry {
臉型.update(from: 臉部錨點.geometry)

let 表情 = 臉部錨點.blendShapes
if 表情[.eyeBlinkLeft] as! Double > 0.5 || 表情[.eyeBlinkRight] as! Double > 0.5 {
node.geometry?.firstMaterial?.fillMode = .fill
} else {
node.geometry?.firstMaterial?.fillMode = .lines
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(人臉追蹤())

💡 註解
  1. blendShapes 除了應用在表情控制之外,還可以將表情記錄下來,套用在另一個虛擬人物上面,這也是虛擬網紅常用的做法,有聽過 Miquela 嗎?
  2. 在影片中,當手指經過臉部時,會出現遮擋問題(3D面具在手指前方),讓AR效果看起來不太自然。
  3. 【動手測試】由於 ARKit 使用AI辨識臉型,在第5單元學過,目前AI視覺大多針對平面人像,因此若用2D照片放在鏡頭前,ARKit 人臉追蹤也會產出一個3D面具,同學們可以試試看。
很少在蘋果版上看見的技術文,推一下
雪白西丘斯
感謝劍心桑鼓勵!
文章分享
評分
評分
複製連結

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