• 8

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

補充(29) 改善後的四輪車

在前面準備 RealityKit 教材的過程中,筆者藉助於 AI(如ChatGPT或Gemini)的機會並不多,一來因為所用元件多數是去(2024)年才發佈,目前的 AI 版本還來不及學;二來自己得先掌握基礎知識,熟悉官方文件之後,才好對 AI 提問,才能判斷 AI 回答的內容哪些正確、哪些錯誤。

上一節遇到的問題,是個求助 AI 的好機會,一方面我們對 RealityKit 已有基礎,對物理關節也初步熟悉,即使AI回答錯誤,我們也有能力糾正過來。

我跟 ChatGPT 描述問題,並提供程式碼之後,ChatGPT 回答:

我幫你看過你的程式碼,車子「不受控制地翻滾」主要是由幾個物理上的問題造成的:

1. 重心太高

你的車體是 `0.5 × 0.3 × 1.2` 的長方體,厚度(高度)0.3 其實不算小,加上輪胎只有 0.2 半徑,車體重心會懸在比較高的位置 → 一加速就容易翻滾。

2. 輪胎方向與關節軸心不一致

你建立輪胎的時候用了 cylinder,然後用 `.orientation = .init(angle: .pi*0.5, axis: ...)` 去轉,接點(pin)又再加了一個 orientation。這樣容易導致「旋轉軸心」不是完全水平 → 輪子在滾動時會往上或往旁邊推,造成車身翻滾。

3. 施加的扭矩不對稱

你在 `SceneEvents.Update` 裡加了:

    左前輪.addTorque([0, 80, 0], relativeTo: 左前輪)
右前輪.addTorque([0, -20, 0], relativeTo: 右前輪)
左後輪.addTorque([0, 80, 0], relativeTo: 左後輪)
右後輪.addTorque([0, -20, 0], relativeTo: 右後輪)

→ 左輪 +80,右輪 -20,不平衡,車子自然會失控翻掉。

4. 缺乏摩擦與阻尼設定

輪胎和地板都用了 `generateCollisionShapes`,但 RealityKit 預設的碰撞摩擦係數很小,等於在冰面上開車,很容易打滑 → 一打滑車身就會翻。

不得不說,AI 對程式的理解相當充分,分析問題的邏輯也是有理有據。

根據這幾則判斷,ChatGPT 提供了改善建議,並且附帶程式碼:

✅ 改進建議:

(A) 調整車體與輪胎比例

- 車體高度降低,例如 `height: 0.15`,讓重心更低。
- 輪胎半徑加大到 `0.3 ~ 0.4`,避免「車子比輪胎高」。

(B) 確保 RevoluteJoint 軸心正確

在設定 pin 的 orientation 時,確定「旋轉軸」就是車子左右方向 (X 軸),例如:
let 左前輪接點 = 左前輪.pins.set(
named: "左前輪接點",
position: [0, 0, 0],
orientation: simd_quatf(angle: .pi/2, axis: [0, 0, 1])) //保證輪子繞X軸旋轉

你可能不需要同時在 輪胎 entitypin 都轉向,只要固定一個地方就好。

(C) 扭矩施加要對稱

如果只是要讓車子往前跑,四輪都加相同方向的扭矩:
左前輪.addTorque([0, 50, 0], relativeTo: 左前輪)
右前輪.addTorque([0, 50, 0], relativeTo: 右前輪)
左後輪.addTorque([0, 50, 0], relativeTo: 左後輪)
右後輪.addTorque([0, 50, 0], relativeTo: 右後輪)

如果想模擬轉向,可以之後再調整前輪的方向,不要一開始就不平衡。

(D) 增加摩擦與阻尼

你可以透過 `PhysicsMaterialResource` 設定摩擦:
let 輪胎材質物理 = try! PhysicsMaterialResource.generate(
friction: 1.0,
restitution: 0.0
)
輪胎模型.physicsBody = .init(
PhysicsBodyComponent(
shapes: 輪胎模型.collision!.shapes,
mass: 1.0,
material: 輪胎材質物理,
mode: .dynamic
)
)

地板也同樣設一個高摩擦的材質。

這其中,(B)與(D)是不錯的建議,對問題有改善,可惜還是沒有完全解決問題。因此,我繼續鑽入原廠文件(參考6-11b 物理模擬預設屬性),最後找到一個關鍵屬性:旋轉慣量(inertia),用法如下:
車體模型.physicsBody?.massProperties = .init(mass: 300, inertia: [300, 300, 300])
這行設定車身質量300Kg,轉動慣量在X/Y/Z軸均為300(單位為 Kg⋅m²)。

轉動慣量(inertia)的物理定義為:
對於一個繞著定軸轉動的質點,其轉動慣量I 的公式為 "I = mr²",其中 "m" 是質點的質量,而 "r" 是質點到旋轉軸的距離。

所以轉動慣量300,相當於300公斤的物體距離轉動軸心1公尺。

這個物理量代表物體旋轉的難易程度,轉動慣量愈大,物體越不容易旋轉。上節程式的問題是車身被四個輪子帶動旋轉,如果我們希望車身保持穩定,那就把轉動慣量設大一點,車身就不會亂轉了。

那轉動慣量與質量的用途有何差別呢?質量與牛頓第二運動定律(f = ma)有關,應用於直線運動,會影響「個體.addForce()」施力所產生的加速度;而轉動慣量適用於物體旋轉,對應「個體.addTorque()」,影響力矩所產生的角速度變化。

之後再經過不斷調整,最後終於將四輪車穩定下來,可以開出去了,前後花兩星期,真是不容易!

執行結果如下:

一開始先暫停地心引力的影響,觀察四輪轉動的情況。之後再恢復引力,讓車子落地,經由四輪帶動往前走。

附上完整的原始程式:
// 補充(29) 四輪車
// Created by Heman Lu on 2025/08/25
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

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

var 金屬材質 = PhysicallyBasedMaterial()
金屬材質.roughness = 0.1
金屬材質.metallic = 0.9

// 小球:用於覆蓋關節部位
let 球體 = MeshResource.generateSphere(radius: 0.05)
let 小球 = ModelEntity(mesh: 球體, materials: [金屬材質])

var 車體材質 = PhysicallyBasedMaterial()
車體材質.metallic = 0.2
車體材質.roughness = 0.8
車體材質.baseColor.tint = .orange

// 車體
let 車體 = MeshResource.generateBox(
width: 0.5,
height: 0.3,
depth: 1.2,
cornerRadius: 0.1)
let 車體模型 = ModelEntity(mesh: 車體, materials: [車體材質])
車體模型.physicsBody = .init(mode: .dynamic)
車體模型.generateCollisionShapes(recursive: false)
車體模型.physicsBody?.massProperties = .init(mass: 300, inertia: [300, 300, 300])
車體模型.physicsBody?.angularDamping = 300.0
車體模型.physicsBody?.material = .generate(restitution: 0.01)
// 車體模型.physicsBody?.isAffectedByGravity = false
車體模型.components.set(GroundingShadowComponent(castsShadow: true))
內容.add(車體模型)

// 裝飾小球(覆蓋連接點)
let 左前輪軸 = 小球.clone(recursive: false)
左前輪軸.position = [0.25, 0, 0.4]
車體模型.addChild(左前輪軸)
let 右前輪軸 = 小球.clone(recursive: false)
右前輪軸.position = [-0.25, 0, 0.4]
車體模型.addChild(右前輪軸)
let 左後輪軸 = 小球.clone(recursive: false)
左後輪軸.position = [0.25, 0, -0.4]
車體模型.addChild(左後輪軸)
let 右後輪軸 = 小球.clone(recursive: false)
右後輪軸.position = [-0.25, 0, -0.4]
車體模型.addChild(右後輪軸)
let 車頭標誌 = 小球.clone(recursive: false)
車頭標誌.position = [0, 0, 0.6]
車體模型.addChild(車頭標誌)

// 車體連接點x4
let 車體左前接點 = 車體模型.pins.set(
named: "車體左前",
position: [0.35, 0, 0.4])
let 車體右前接點 = 車體模型.pins.set(
named: "車體右前",
position: [-0.35, 0, 0.4])
let 車體左後接點 = 車體模型.pins.set(
named: "車體左後",
position: [0.35, 0, -0.4])
let 車體右後接點 = 車體模型.pins.set(
named: "車體右後",
position: [-0.35, 0, -0.4])

// 輪胎
let 邊數: Int = 18
var 輪胎材質 = PhysicallyBasedMaterial()
輪胎材質.metallic = 0.7
輪胎材質.roughness = 0.3
輪胎材質.baseColor.tint = .gray
let 材質陣列: [PhysicallyBasedMaterial] = .init(
repeating: 輪胎材質,
count: 邊數 * 2 + 2)

// 共享程式 6-9d 多角柱體
if let 外型 = try? await MeshResource.多角柱體(邊數,
底半徑: 0.2,
頂半徑: 0.2,
高: 0.1) {
// let 外型2 = MeshResource.generateCylinder(height: 0.1, radius: 0.2)
// 共享程式 6-9d 挑戰題:多角星體
// if let 外型 = try? await MeshResource.多角星體(邊數,
// 底半徑: 0.2,
// 頂半徑: 0.2,
// 次頂點比例: 0.9,
// 高: 0.1) {
// 輪胎模型母版
let 輪胎模型 = ModelEntity(mesh: 外型, materials: 材質陣列)
輪胎模型.physicsBody = .init(mode: .dynamic)
輪胎模型.generateCollisionShapes(recursive: false)
輪胎模型.physicsBody?.isAffectedByGravity = false
輪胎模型.physicsBody?.angularDamping = 3.0
輪胎模型.physicsBody?.massProperties = .init(mass: 1.0, inertia: [1, 1, 1])
輪胎模型.physicsBody?.material = .generate(friction: 0.9, restitution: 0.01)
let 輪圈 = 小球.clone(recursive: false)
輪圈.position.y = 0.05
輪胎模型.addChild(輪圈)

// 輪胎(1)左前輪
let 左前輪 = 輪胎模型.clone(recursive: true)
內容.add(左前輪)

let 左前輪接點 = 左前輪.pins.set(
named: "左前輪接點",
position: [0, 0.0, 0],
orientation: .init(angle: .pi * 0.5, axis: [0, 0, 1]))
let 左前關節 = PhysicsRevoluteJoint(
pin0: 車體左前接點,
pin1: 左前輪接點)
try? 左前關節.addToSimulation()

// 輪胎(2)右前輪
let 右前輪 = 輪胎模型.clone(recursive: true)
內容.add(右前輪)

let 右前輪接點 = 右前輪.pins.set(
named: "右前輪接點",
position: [0, 0.0, 0],
orientation: .init(angle: .pi * 0.5, axis: [0, 0, -1]))
let 右前關節 = PhysicsRevoluteJoint(
pin0: 車體右前接點,
pin1: 右前輪接點)
try? 右前關節.addToSimulation()

// 輪胎(3)左後輪
let 左後輪 = 輪胎模型.clone(recursive: true)
內容.add(左後輪)

let 左後輪接點 = 左後輪.pins.set(
named: "左後輪接點",
position: [0, 0.0, 0],
orientation: .init(angle: .pi * 0.5, axis: [0, 0, 1]))
let 左後關節 = PhysicsRevoluteJoint(
pin0: 車體左後接點,
pin1: 左後輪接點)
try? 左後關節.addToSimulation()

// 輪胎(4)右後輪
let 右後輪 = 輪胎模型.clone(recursive: true)
內容.add(右後輪)

let 右後輪接點 = 右後輪.pins.set(
named: "右後輪接點",
position: [0, 0.0, 0],
orientation: .init(angle: .pi * 0.5, axis: [0, 0, -1]))
let 右後關節 = PhysicsRevoluteJoint(
pin0: 車體右後接點,
pin1: 右後輪接點)
try? 右後關節.addToSimulation()

// 加入事件處理:SceneEvents.Update
let 強度: Float = 150
let _ = 內容.subscribe(to: SceneEvents.Update.self) { 事件 in
左前輪.addTorque([0, 強度, 0], relativeTo: 左前輪)
右前輪.addTorque([0, -強度, 0], relativeTo: 右前輪)
左後輪.addTorque([0, 強度, 0], relativeTo: 左後輪)
右後輪.addTorque([0, -強度, 0], relativeTo: 右後輪)
}
}

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

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(物理關節())
第15課 骨骼動畫(Skeleton)

上一課「物理關節」有個地方寫錯(已刪):

還記得補充(26)看到的蝴蝶飛翔嗎?蝴蝶拍動翅膀的動畫,就是用物理關節模擬出來的。想要利用 RealityKit 做出會走路的機器人或任何仿生模型,都得用到物理關節。

實際上,補充(26)蝴蝶飛翔是透過骨骼動畫製作的,不是用物理關節。

老實說,筆者原以為3D模型的關節只有一種,就是物理關節(PhysicsJoint),沒想到還有另外一種,放在 MeshResource 裡面,全名為 MeshResource.Skeleton.Joint,是屬於 3D模型的一部分,在此稱「模型關節」。

兩者有何差別呢?物理關節可單獨使用,也就是說,只用一個物理關節就能做出鉸鏈、轉軸、滑軌等效果,驅動方式透過物理模擬 — 靠施力(addForce 或 addTorque)或力場(ForceEffect)來產生動作。

模型關節通常包含在模型檔案裡面 — 目前只能靠外部軟體(如 Blender, Unity, Maya…等)製作。每個關節之間有父子階層關係,所有關節形成一個樹狀結構,稱為骨骼系統(Skeleton),可用動作動畫來驅動,故稱骨骼動畫。

下表歸納物理關節與模型關節之間的差異:
# 特性比較 物理關節(PhysicsJoint) 模型關節(MeshResource.Skeleton.Joint)
1. 產出方式 可由 RealityKit 程式產生 需靠外部軟體(如 Blender)製作
2. 階層關係 關節彼此獨立,無階層關係 關節之間有階層關係
3. 驅動方式 依賴物理模擬,靠施力或力場驅動 不需物理模擬,直接用座標變換或動作動畫來控制

上一課的 RealityKit 心智圖更新如下,多了一個骨骼動畫:

6-15a 載入骨骼模型

在學習本課內容之前,須先下載一個包含骨骼系統的3D模型。

本課範例使用的骨骼模型來自兩個網站:

1. Adobe Mixamo
2. Apple Quick Look Gallery - Augmented Reality

Adobe Mixamo 網站提供高品質的角色模型與動作,可自由使用(不限於 Adobe 產品),註冊帳號後就能免費下載,範例中我們使用最簡單的模型 “Y Bot”(用其他模型當然也行)。

從 Adobe Mixamo 下載檔案後,還須透過 Blender 軟體轉換為 .usdz 格式才能在程式裡面用;Apple 的模型則已提供 .usdz 格式,不必下載,可直接用程式去抓。

請到 Adobe Mixamo 網站註冊登入,選擇 “Y Bot” 或其他角色,按右上方 “DOWNLOAD” 下載,檔案格式選 “FBX Binary (.fbx)”,姿勢選 “T-pose”。下載的”Y Bot.fbx” 檔案很小,只有2MB:

接下來開啟 Blender 軟體(可參考補充(19) 用 Blender 製作3D模型),匯入 “Y Bot.fbx”,再匯出為 “Y Bot.usdz”,詳細操作步驟如下:

1. 開啟 Blender,按 ESC鍵取消啟動畫面,再按 “x” 將預設的立方體刪除
2. 左上方選單「檔案」→「匯入(Import)」→ 格式選倒數第二個 FBX (.fbx)

3. (可略過)成功匯入後,檢查一下骨骼系統(Armature)

4. 左上角選單「檔案」→「匯出」→格式選 “Universal Scene Description (.usd*)”

5. 存檔前,選擇 “Convert Orientation”,將 Blender 預設的 Z軸向上,改為 RealityKit 座標系 Y軸向上;檔名改為 “Y Bot.usdz”(不要用預設的 .usdc):

6. (可略過)匯出的檔案 “Y Bot.usdz” 也只有3MB,先用「預覽程式」檢查看看

7. 將 “Y Bot.usdz” 匯入 Swift Playground:

接下來,仿照第7課 6-7a 外部資源:USDZ、紋理貼圖、天空盒,將模型加入 RealityView 場景中,注意檔案名稱(“Y Bot.usdz”)與匯入檔案須完全一致(含大小寫、空格):
if let 機器人模型 = try? await ModelEntity(named: "Y Bot.usdz") {
機器人模型.position = [0, -0.9, 0]
內容.add(機器人模型)
print("機器人關節名稱\(機器人模型.jointNames.count)")
print(機器人模型.jointNames)
}

最後兩行 print() 是本課主要目的,要列印出所有 “Y Bot” 模型的關節名稱。至於這些名稱如何使用,下一節再介紹。

另外,我們再參考6-7b 下載網路.usdz模型,用程式直接抓 Apple 官方提供的骨骼模型「變色龍」:
let 網址 = "https://developer.apple.com/augmented-reality/quick-look/models/chameleon/chameleon_anim_mtl_variant.usdz"
if let fileURL = try? await 檔案下載(網址), // 共享程式6-7b
let 網路模型 = try? await ModelEntity(contentsOf: fileURL) {
網路模型.scale = [1.5, 1.5, 1.5]
網路模型.position = [0.5, 0.5, 0]
內容.add(網路模型)
print("變色龍關節名稱\(網路模型.jointNames.count)")
print(網路模型.jointNames)

網路模型.availableAnimations.forEach { 動畫 in
網路模型.playAnimation(動畫.repeat())
}
}

「變色龍」檔案大小15MB,除了有材質貼圖、骨骼系統之外,還加入動畫,上面最後兩行程式會將動畫重複播放。原尺寸的變色龍有點小,我們放大1.5倍,放在機器人模型旁邊一起展示。

執行結果如下圖(動畫請自行測試),右側主控台列印出機器人與變色龍的所有關節名稱:

注意機器人 “Y Bot” 的身高約1.8公尺,內部座標原點在腳底(相當於地面高度),範例中將位置降低0.9公尺,以方便檢視。

最後,附上完整的範例程式:
// 6-15a 骨骼動畫:載入模型檔
// Created by Heman Lu, 2025/08/25
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

struct 骨骼動畫: View {
let 網址 = "https://developer.apple.com/augmented-reality/quick-look/models/chameleon/chameleon_anim_mtl_variant.usdz"

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

if let 機器人模型 = try? await ModelEntity(named: "Y Bot.usdz") {
機器人模型.position = [0, -0.9, 0]
內容.add(機器人模型)
print("機器人關節名稱\(機器人模型.jointNames.count)")
print(機器人模型.jointNames)
}

if let fileURL = try? await 檔案下載(網址), // 共享程式6-7b
let 網路模型 = try? await ModelEntity(contentsOf: fileURL) {
網路模型.scale = [1.5, 1.5, 1.5]
網路模型.position = [0.5, 0.5, 0]
內容.add(網路模型)
print("變色龍關節名稱\(網路模型.jointNames.count)")
print(網路模型.jointNames)

網路模型.availableAnimations.forEach { 動畫 in
網路模型.playAnimation(動畫.repeat())
}
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(骨骼動畫())

💡註解
  1. 物理關節可以用動作動畫來驅動嗎?理論上可以,但因為物理模擬與動作動畫每幀畫面(預設1/60秒)都會將運算結果寫入座標變換(transform)屬性中,因此若兩者並用,將會產生無法預期的效果。
  2. 骨骼系統與模型關節(MeshResource.Skeleton.Joint)也是去(2024)年才加入 RealityKit,網路上資料寥寥可數,幾乎完全靠自行摸索,也和ChatGPT討論過(有幫助)。
  3. 並不是所有3D模型都包含骨骼系統,搜尋時最好加上 “skeleton”, “armature”, “rig”, “animation” 等關鍵字,例如 “free 3d model with armature”。
  4. “armature” 字面意思是支架、骨架,也有盔甲的意思。骨骼系統/關節在 RealityKit 用 “skeleton/joint” 來稱呼,在 Blender 則用 “armature/bone”,作用完全一樣。
  5. “rig” 當動詞是操控、裝配,名詞則有很多意思。在 3D 模型中,”rig” 是指可用真人(透過手勢、姿態、表情)來操控虛擬角色。
  6. 作業:請到Mixamo網站選擇一個角色,加入動作,一起下載(含動畫),同樣用 Blender 轉成 .usdz 格式,再匯入 Swift Playground,用 RealityKit 播放動畫。
6-15b 關節名稱與座標變換

上一節範例程式列印到主控台的資訊,包含 Y Bot 機器人模型的所有關節名稱,共有65個關節,內容如下:
機器人關節名稱65
[
"mixamorig_Hips",
"mixamorig_Hips/mixamorig_Spine",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_Neck",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_Neck/mixamorig_Head",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_Neck/mixamorig_Head/mixamorig_HeadTop_End",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandThumb1",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandThumb1/mixamorig_LeftHandThumb2",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandThumb1/mixamorig_LeftHandThumb2/mixamorig_LeftHandThumb3",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandThumb1/mixamorig_LeftHandThumb2/mixamorig_LeftHandThumb3/mixamorig_LeftHandThumb4",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandIndex1",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandIndex1/mixamorig_LeftHandIndex2",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandIndex1/mixamorig_LeftHandIndex2/mixamorig_LeftHandIndex3",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandIndex1/mixamorig_LeftHandIndex2/mixamorig_LeftHandIndex3/mixamorig_LeftHandIndex4",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandMiddle1",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandMiddle1/mixamorig_LeftHandMiddle2",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandMiddle1/mixamorig_LeftHandMiddle2/mixamorig_LeftHandMiddle3",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandMiddle1/mixamorig_LeftHandMiddle2/mixamorig_LeftHandMiddle3/mixamorig_LeftHandMiddle4",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandRing1",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandRing1/mixamorig_LeftHandRing2",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandRing1/mixamorig_LeftHandRing2/mixamorig_LeftHandRing3",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandRing1/mixamorig_LeftHandRing2/mixamorig_LeftHandRing3/mixamorig_LeftHandRing4",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandPinky1",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandPinky1/mixamorig_LeftHandPinky2",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandPinky1/mixamorig_LeftHandPinky2/mixamorig_LeftHandPinky3",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_LeftShoulder/mixamorig_LeftArm/mixamorig_LeftForeArm/mixamorig_LeftHand/mixamorig_LeftHandPinky1/mixamorig_LeftHandPinky2/mixamorig_LeftHandPinky3/mixamorig_LeftHandPinky4",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandThumb1",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandThumb1/mixamorig_RightHandThumb2",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandThumb1/mixamorig_RightHandThumb2/mixamorig_RightHandThumb3",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandThumb1/mixamorig_RightHandThumb2/mixamorig_RightHandThumb3/mixamorig_RightHandThumb4",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandIndex1",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandIndex1/mixamorig_RightHandIndex2",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandIndex1/mixamorig_RightHandIndex2/mixamorig_RightHandIndex3",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandIndex1/mixamorig_RightHandIndex2/mixamorig_RightHandIndex3/mixamorig_RightHandIndex4",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandMiddle1",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandMiddle1/mixamorig_RightHandMiddle2",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandMiddle1/mixamorig_RightHandMiddle2/mixamorig_RightHandMiddle3",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandMiddle1/mixamorig_RightHandMiddle2/mixamorig_RightHandMiddle3/mixamorig_RightHandMiddle4",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandRing1",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandRing1/mixamorig_RightHandRing2",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandRing1/mixamorig_RightHandRing2/mixamorig_RightHandRing3",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandRing1/mixamorig_RightHandRing2/mixamorig_RightHandRing3/mixamorig_RightHandRing4",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandPinky1",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandPinky1/mixamorig_RightHandPinky2",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandPinky1/mixamorig_RightHandPinky2/mixamorig_RightHandPinky3",
"mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandPinky1/mixamorig_RightHandPinky2/mixamorig_RightHandPinky3/mixamorig_RightHandPinky4",
"mixamorig_Hips/mixamorig_LeftUpLeg",
"mixamorig_Hips/mixamorig_LeftUpLeg/mixamorig_LeftLeg",
"mixamorig_Hips/mixamorig_LeftUpLeg/mixamorig_LeftLeg/mixamorig_LeftFoot",
"mixamorig_Hips/mixamorig_LeftUpLeg/mixamorig_LeftLeg/mixamorig_LeftFoot/mixamorig_LeftToeBase",
"mixamorig_Hips/mixamorig_LeftUpLeg/mixamorig_LeftLeg/mixamorig_LeftFoot/mixamorig_LeftToeBase/mixamorig_LeftToe_End",
"mixamorig_Hips/mixamorig_RightUpLeg",
"mixamorig_Hips/mixamorig_RightUpLeg/mixamorig_RightLeg",
"mixamorig_Hips/mixamorig_RightUpLeg/mixamorig_RightLeg/mixamorig_RightFoot",
"mixamorig_Hips/mixamorig_RightUpLeg/mixamorig_RightLeg/mixamorig_RightFoot/mixamorig_RightToeBase",
"mixamorig_Hips/mixamorig_RightUpLeg/mixamorig_RightLeg/mixamorig_RightFoot/mixamorig_RightToeBase/mixamorig_RightToe_End"
]

這是從「print(機器人模型.jointNames)」得到的內容,可以看得出來:

1. jointNames 資料類型是「字串陣列」
2. 字串內容實際上是完整的關節路徑,路徑包含所有上層關節,名稱用 “/” 分開
3. 所有關節形成一個樹狀目錄,最上層的根節點是 mixamorig_Hips (臀部)
4. 整個骨骼系統包含65個關節,對應65個路徑名稱

若將關節名稱加註到圖片中,會更容易看清楚,如下:

從關節分布可看得出來,手掌關節最精細,兩手共46個關節(佔總數70%);其他頭、頸、肩、脊椎、腿、腳掌合計才19個關節,相對簡略。由此推論,這個機器人手部動作最靈活,肢體動作也還行,但無法開口、眨眼或做任何臉部表情。

如何操縱這些關節呢?

我們得先了解關節的屬性(參考原廠文件):
// MeshResource.Skeleton.Joint
init(
name: String,
parentIndex: Int?, // nil for root joint
inverseBindPoseMatrix: simd_float4x4,
restPoseTransform: Transform
)

模型關節只有4個屬性,除了名稱(name)、父關節索引(parentIndex)之外,restPoseTransform 是靜止姿勢的座標變換,inverseBindPoseMatrix 是目前姿勢的反矩陣。為什麼叫 Pose (姿勢)下節說明。

還記得上一課的幾何連接點(GeometricPin)嗎?幾何連接點的主要屬性(position, orientation)就是一個座標變換,用來設定連接點的位置與旋轉面向。

模型關節跟幾何連接點類似,主要屬性也是座標變換,可以控制關節的位移與旋轉,模型關節顯然也不需要縮放(因為沒有實體尺寸)。

因此,要操縱關節,本質上就是操作其座標變換,每個模型個體(ModelEntity)都有兩個成對屬性:jointNames 與 jointTransforms,兩者都是複數,資料類型分別是字串陣列[String]、座標變換陣列[Transform],預設值均為空陣列。

以 Y Bot 機器人模型為例,65個關節就表示 jointNames 陣列中有65個字串、jointTransforms 中有65個座標變換。從這兩個陣列,就能推算整個骨骼系統所有關節的4個屬性。

關節之間除了形成樹狀結構之外,還有兩個規則:對同一個關節,jointNames 與 jointTransforms 的「陣列索引」必須相同;不同關節的名稱不可重複。

我們先寫個小程式來熟悉一下這些概念:
// 6-15b(v1) 測試關節座標變換陣列(jointTransforms)
// Created by Heman Lu, 2025/09/02
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

struct 骨骼動畫: View {
var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b

if let 機器人模型 = try? await ModelEntity(named: "Y Bot.usdz") {
機器人模型.position = [0, -0.9, 0]
內容.add(機器人模型)

for 關節名稱 in 機器人模型.jointNames where 關節名稱.contains("RightArm") {
print("找到右手關節:\(關節名稱)")
if let 關節索引 = 機器人模型.jointNames.firstIndex(of: 關節名稱) {
print("關節索引 = \(關節索引)")
機器人模型.jointTransforms[關節索引].rotation = .init(angle: .pi/4, axis: [1, 0, 0])
}
}
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(骨骼動畫())

拿上節的範例程式為基礎,只留 Y Bot 機器人,並增加一段 for 迴圈:從 jointNames 陣列中找出含 “RightArm” 的字串與陣列索引,再將 jointTransforms 陣列中對應的座標變換旋轉45度。

執行結果如下,從右上臂到右指尖,有23個關節,每一段都旋轉45度:

旋轉45度的程式碼如下,先用 firstIndex(of:) 找出第一個符合關節名稱的陣列索引,然後用「jointTransforms[關節索引]」取得該關節的座標變換,再將旋轉(rotation)設為繞X軸45度角:
if let 關節索引 = 機器人模型.jointNames.firstIndex(of: 關節名稱) {
print("關節索引 = \(關節索引)")
機器人模型.jointTransforms[關節索引].rotation = .init(angle: .pi/4, axis: [1, 0, 0])
}

這裡的座標軸方向是根據關節的內部座標(與幾何連接點類似,參考補充(27) 子個體與局部座標 ),其中一個關鍵是怎麼知道要繞X軸,而不是繞其他軸呢?

第一種方法是用嘗試錯誤法,因為我們不知道Y Bot 模型的原始設計,且經過座標轉換為 .usdz 格式,該繞哪個座標軸已經很難判斷,直接測試比較快。

第二種方法是藉助 Blender,依照下圖4個步驟,開啟顯示每個關節的內部座標軸,就可看出右手關節的座標軸方向,的確是繞X軸往下彎曲。

Blender 的骨骼顯示為角錐狀(八面體,兩個四角錐反向相疊),底部是關節所在,錐尖是+Y軸方向,採用右手座標系(拇指X軸、食指Y軸、中指Z軸)。

接下來,我們進一步挑戰,用 FromToByAction() 改成動作動畫,這裡會遇到一個新的綁定屬性 bindTarget: .jointTransforms,最後成果如下:
// 6-15b(v2) 關節座標變換陣列(jointTransforms)
// Created by Heman Lu, 2025/09/02
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

struct 骨骼動畫: View {
var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b

if let 機器人模型 = try? await ModelEntity(named: "Y Bot.usdz") {
機器人模型.position = [0, -0.9, 0]
內容.add(機器人模型)

var 座標變換矩陣 = 機器人模型.jointTransforms
for 關節名稱 in 機器人模型.jointNames where 關節名稱.contains("RightArm") {
print("找到右手關節:\(關節名稱)")
if let 關節索引 = 機器人模型.jointNames.firstIndex(of: 關節名稱) {
print("關節索引 = \(關節索引)")
// 機器人模型.jointTransforms[關節索引].rotation = .init(angle: .pi/4, axis: [1, 0, 0])
座標變換矩陣[關節索引].rotation = .init(angle: .pi/4, axis: [1, 0, 0])
}
}

// 官方範例:https://developer.apple.com/documentation/realitykit/jointtransforms
let 動作 = FromToByAction(to: JointTransforms(座標變換矩陣))
if let 動畫 = try? AnimationResource.makeActionAnimation(
for: 動作,
duration: 3.0,
bindTarget: .jointTransforms,
repeatMode: .autoReverse
) {
機器人模型.playAnimation(動畫)
}
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(骨骼動畫())

FromToByAction 用法可參照6-10d 屬性動畫 ,第一個步驟最關鍵:
// 正確寫法:
let 動作 = FromToByAction(to: JointTransforms(座標變換矩陣))
「座標變換矩陣」先在前面 for 迴圈中改好,再加入FromToByAction(),原先筆者以為,可以直接用 jointTransforms 當作參數:
// 原來的錯誤寫法:
let 動作 = FromToByAction(to: 座標變換矩陣)
沒想到 Swift Playground 提示語法錯誤。再仔細看官方文件,發現參數必須用 JointTransforms 類型,而不是 [Transform],前者是集合,後者是陣列,因此多一道 “JointTransforms(座標變換矩陣)” 加以轉換。

這裡可以發現,jointTransforms 和 JointTransforms 居然不是同一類型,這實在是違反 Swift 語言命名習慣,還好至今只看到這一個例外。

最後終於正確執行,結果如下:

💡註解
  1. 作業1:若仔細觀察執行結果,會發現右手大拇指向外彎曲,很不自然,顯然大拇指的旋轉軸應該與其他手指不同。請修改程式,嘗試用其他旋轉軸,讓它往內彎45度。
  2. 作業2:請修改程式,只讓右上臂旋轉45度。
  3. 作業3:請將左手加入動畫,讓左右手同步動作。
  4. Apple 官方文件提到,FromToByAction 並不支援 JointTransforms,應該改用 FromToByAnimation。本節範例的寫法可能並不恰當。
    FromToByAction doesn’t support JointTransforms or BlendShapeWeights types. Use FromToByAnimation to animate these types.
  5. 作業4:請參考JointTransforms文件,用 FromToByAnimation 來改寫本節範例。
  6. 作業5:請改用上一節的「變色龍」模型,針對舌頭或尾巴製作一個動畫。
6-15c 轉換姿勢(FromToByAnimation)

前兩節,我們初步了解骨骼系統的構造,以及如何透過座標變換來控制骨骼姿勢,本節來做一個更逼真的動作動畫:

這是從原來的T字形站姿,轉換到拳擊姿勢,怎麼做的呢?

從上一節的練習,我們知道Y Bot機器人有65個關節,任何一個關節的座標變換若有變動,機器人的姿勢就跟著改變。反過來說,一組關節的座標變換陣列資料,就定義了一個骨骼姿勢。

因此,骨骼模型的每個姿勢,必定反映在完整的關節座標變換陣列(jointTransforms)中。

也就是說,我們可以從 Mixamo 網站找一個拳擊姿勢,將對應的座標變換陣列抓出來,寫入程式,就能將新姿勢套用到原來的基礎模型(“Y Bot.usdz”)了。

驗證想法的完整程式如下:
// 6-15c 轉換姿勢(FromToByAnimation)
// Created by Heman Lu, 2025/09/06
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

let 拳擊姿勢: [Transform] =
[
Transform(
rotation: simd_quatf(real: 0.946, imag: simd_float3(-0.030, -0.322, -0.011)),
translation: simd_float3(-0.537, 92.86, 0.269)),
Transform(
rotation: simd_quatf(real: 0.995, imag: simd_float3(-0.003, 0.096, -0.009)),
translation: simd_float3(0.0, 9.923, -1.227)),
Transform(
rotation: simd_quatf(real: 1.0, imag: simd_float3(0.0239, 0.037, 0.002)),
translation: simd_float3(0.0, 11.732, 0.0)),
Transform(
rotation: simd_quatf(real: 0.996, imag: simd_float3(0.081, 0.038, 0.0)),
translation: simd_float3(0.0, 13.459, 0.0)),
Transform(
rotation: simd_quatf(real: 0.997, imag: simd_float3(0.074, -0.005, 0.009)),
translation: simd_float3(0.0, 15.028, 0.878)),
Transform(
rotation: simd_quatf(real: 0.996, imag: simd_float3(0.047, 0.049, -0.059)),
translation: simd_float3(0.0, 10.322, 3.142)),
Transform(
rotation: simd_quatf(real: 1.0, imag: simd_float3(0.0, 0.0, 0.0)),
translation: simd_float3(0.0, 18.475, 6.636)),
Transform(
rotation: simd_quatf(real: -0.425, imag: simd_float3(-0.453, -0.547, 0.561)),
translation: simd_float3(6.106, 9.106, 0.757)),
Transform(
rotation: simd_quatf(real: 0.799, imag: simd_float3(0.379, -0.155, 0.440)),
translation: simd_float3(0.0, 12.922, 0.0)),
Transform(
rotation: simd_quatf(real: 0.494, imag: simd_float3(0.0, 0.0, 0.869)),
translation: simd_float3(0.0, 27.404, 0.0)),
Transform(
rotation: simd_quatf(real: 0.964, imag: simd_float3(-0.223, 0.048, 0.134)),
translation: simd_float3(0.0, 27.614, 0.0)),
Transform(
rotation: simd_quatf(real: 0.96, imag: simd_float3(0.238, -0.031, 0.146)),
translation: simd_float3(-3.003, 3.789, 2.167)),
Transform(
rotation: simd_quatf(real: 0.940, imag: simd_float3(0.038, -0.158, -0.299)),
translation: simd_float3(0.0, 4.745, 0.0)),
Transform(
rotation: simd_quatf(real: 0.821, imag: simd_float3(-0.163, -0.08, -0.542)),
translation: simd_float3(0.0, 4.382, 0.0)),
Transform(
rotation: simd_quatf(real: 0.99, imag: simd_float3(0.009, 0.126, 0.072)),
translation: simd_float3(0.0, 3.459, 0.0)),
Transform(
rotation: simd_quatf(real: 0.730, imag: simd_float3(0.677, 0.008, -0.090)),
translation: simd_float3(-2.822, 12.267, 0.232)),
Transform(
rotation: simd_quatf(real: 0.506, imag: simd_float3(0.856, 0.0, -0.103)),
translation: simd_float3(0.0, 3.892, 0.0)),
Transform(
rotation: simd_quatf(real: 0.587, imag: simd_float3(0.804, 0.0, -0.097)),
translation: simd_float3(0.0, 3.415, 0.0)),
Transform(
rotation: simd_quatf(real: 1.0, imag: simd_float3(0.0, 0.004, 0.0)),
translation: simd_float3(0.0, 3.078, 0.0)),
Transform(
rotation: simd_quatf(real: 0.724, imag: simd_float3(0.685, 0.0, -0.083)),
translation: simd_float3(0.0, 12.776, 0.0)),
Transform(
rotation: simd_quatf(real: 0.563, imag: simd_float3(0.820, 0.0, -0.099)),
translation: simd_float3(0.0, 3.614, 0.0)),
Transform(
rotation: simd_quatf(real: 0.576, imag: simd_float3(0.812, 0.0, -0.098)),
translation: simd_float3(0.0, 3.46, 0.0)),
Transform(
rotation: simd_quatf(real: 1.0, imag: simd_float3(0.0, 0.005, 0.0)),
translation: simd_float3(0.0, 3.680, 0.0)),
Transform(
rotation: simd_quatf(real: 0.708, imag: simd_float3(0.703, -0.023, -0.062)),
translation: simd_float3(2.217, 12.147, -0.01)),
Transform(
rotation: simd_quatf(real: 0.588, imag: simd_float3(0.803, 0.0, -0.097)),
translation: simd_float3(0.0, 3.601, 0.0)),
Transform(
rotation: simd_quatf(real: 0.615, imag: simd_float3(0.783, 0.0, -0.094)),
translation: simd_float3(0.0, 3.307, 0.0)),
Transform(
rotation: simd_quatf(real: 1.0, imag: simd_float3(0.0, 0.007, 0.0)),
translation: simd_float3(0.0, 3.66, 0.0)),
Transform(
rotation: simd_quatf(real: 0.680, imag: simd_float3(0.73, -0.04, -0.05)),
translation: simd_float3(4.726, 10.908, 0.226)),
Transform(
rotation: simd_quatf(real: 0.646, imag: simd_float3(0.76, 0.027, -0.069)),
translation: simd_float3(0.0, 4.14, 0.0)),
Transform(
rotation: simd_quatf(real: 0.466, imag: simd_float3(0.879, 0.0, -0.106)),
translation: simd_float3(0.0, 2.595, 0.0)),
Transform(
rotation: simd_quatf(real: 1.0, imag: simd_float3(0.0, 0.004, 0.0)),
translation: simd_float3(0.0, 2.924, 0.0)),
Transform(
rotation: simd_quatf(real: -0.470, imag: simd_float3(-0.470, 0.541, -0.514)),
translation: simd_float3(-6.106, 9.106, 0.757)),
Transform(
rotation: simd_quatf(real: 0.761, imag: simd_float3(0.397, 0.188, -0.477)),
translation: simd_float3(0.0, 12.922, 0.0)),
Transform(
rotation: simd_quatf(real: 0.500, imag: simd_float3(0.0, 0.0, -0.866)),
translation: simd_float3(0.0, 27.405, 0.0)),
Transform(
rotation: simd_quatf(real: 0.953, imag: simd_float3(-0.251, 0.0981, -0.140)),
translation: simd_float3(0.0, 27.614, 0.0)),
Transform(
rotation: simd_quatf(real: 0.949, imag: simd_float3(0.277, 0.074, -0.132)),
translation: simd_float3(3.003, 3.789, 2.167)),
Transform(
rotation: simd_quatf(real: 0.942, imag: simd_float3(0.014, -0.034, 0.333)),
translation: simd_float3(0.0, 4.74, 0.0)),
Transform(
rotation: simd_quatf(real: 0.860, imag: simd_float3(-0.025, 0.051, 0.506)),
translation: simd_float3(0.0, 4.382, 0.0)),
Transform(
rotation: simd_quatf(real: 0.989, imag: simd_float3(0.009, -0.127, -0.072)),
translation: simd_float3(0.0, 3.459, 0.0)),
Transform(
rotation: simd_quatf(real: 0.730, imag: simd_float3(0.676, -0.018, 0.102)),
translation: simd_float3(2.822, 12.267, 0.232)),
Transform(
rotation: simd_quatf(real: 0.506, imag: simd_float3(0.856, 0.0, 0.104)),
translation: simd_float3(0.0, 3.89, 0.0)),
Transform(
rotation: simd_quatf(real: 0.587, imag: simd_float3(0.804, 0.0, 0.0979)),
translation: simd_float3(0.0, 3.415, 0.0)),
Transform(
rotation: simd_quatf(real: 1.0, imag: simd_float3(0.0, -0.007, 0.001)),
translation: simd_float3(0.0, 3.078, 0.0)),
Transform(
rotation: simd_quatf(real: 0.724, imag: simd_float3(0.685, 0.0, 0.083)),
translation: simd_float3(0.0, 12.776, 0.0)),
Transform(
rotation: simd_quatf(real: 0.564, imag: simd_float3(0.82, 0.0, 0.1)),
translation: simd_float3(0.0, 3.614, 0.0)),
Transform(
rotation: simd_quatf(real: 0.576, imag: simd_float3(0.812, 0.0, 0.099)),
translation: simd_float3(0.0, 3.46, 0.0)),
Transform(
rotation: simd_quatf(real: 1.0, imag: simd_float3(0.0, -0.007, -0.002)),
translation: simd_float3(0.0, 3.68, 0.0)),
Transform(
rotation: simd_quatf(real: 0.693, imag: simd_float3(0.718, 0.033, 0.055)),
translation: simd_float3(-2.217, 12.147, -0.01)),
Transform(
rotation: simd_quatf(real: 0.630, imag: simd_float3(0.77, 0.0, 0.0939)),
translation: simd_float3(0.0, 3.601, 0.0)),
Transform(
rotation: simd_quatf(real: 0.615, imag: simd_float3(0.783, 0.0, 0.095)),
translation: simd_float3(0.0, 3.307, 0.0)),
Transform(
rotation: simd_quatf(real: 1.0, imag: simd_float3(0.0, -0.008, 0.0)),
translation: simd_float3(0.0, 3.66, 0.0)),
Transform(
rotation: simd_quatf(real: 0.678, imag: simd_float3(0.731, 0.066, 0.027)),
translation: simd_float3(-4.726, 10.908, 0.226)),
Transform(
rotation: simd_quatf(real: 0.646, imag: simd_float3(0.758, 0.0, 0.092)),
translation: simd_float3(0.0, 4.137, 0.0)),
Transform(
rotation: simd_quatf(real: 0.466, imag: simd_float3(0.879, 0.0, 0.107)),
translation: simd_float3(0.0, 2.596, 0.0)),
Transform(
rotation: simd_quatf(real: 1.0, imag: simd_float3(0.0, -0.008, 0.002)),
translation: simd_float3(0.0, 2.924, 0.0)),
Transform(
rotation: simd_quatf(real: -0.081, imag: simd_float3(-0.001, 0.234, 0.969)),
translation: simd_float3(9.124, -6.657, -0.055)),
Transform(
rotation: simd_quatf(real: 0.96, imag: simd_float3(-0.28, 0.004, 0.016)),
translation: simd_float3(0.0, 40.599, 0.0)),
Transform(
rotation: simd_quatf(real: 0.846, imag: simd_float3(0.529, -0.038, -0.057)),
translation: simd_float3(0.0, 42.099, 0.0)),
Transform(
rotation: simd_quatf(real: 0.973, imag: simd_float3(0.228, -0.033, -0.015)),
translation: simd_float3(0.0, 15.722, 0.0)),
Transform(
rotation: simd_quatf(real: 1.0, imag: simd_float3(0.0, 0.0, 0.0)),
translation: simd_float3(0.0, 10.0, 0.0)),
Transform(
rotation: simd_quatf(real: 0.0728, imag: simd_float3(-0.131, -0.059, 0.987)),
translation: simd_float3(-9.125, -6.656, -0.055)),
Transform(
rotation: simd_quatf(real: 0.967, imag: simd_float3(-0.25, 0.039, -0.017)),
translation: simd_float3(0.0, 40.599, 0.0)),
Transform(
rotation: simd_quatf(real: 0.688, imag: simd_float3(0.721, 0.046, 0.063)),
translation: simd_float3(0.0, 42.1, 0.0)),
Transform(
rotation: simd_quatf(real: 0.971, imag: simd_float3(0.236, 0.032, 0.015)),
translation: simd_float3(0.0, 15.722, 0.0)),
Transform(
rotation: simd_quatf(real: 1.0, imag: simd_float3(0.0, 0.0, 0.0)),
translation: simd_float3(0.0, 1.0, 0.0))
]

struct 骨骼動畫: View {
var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b

if let 機器人模型 = try? await ModelEntity(named: "Y Bot.usdz") {
機器人模型.position = [0, -0.9, 0]
內容.add(機器人模型)
print("機器人關節座標變換\(機器人模型.jointTransforms.count)")
print(機器人模型.jointTransforms)

let 動畫 = FromToByAnimation(
jointNames: 機器人模型.jointNames,
to: JointTransforms(拳擊姿勢),
duration: 0.5,
bindTarget: .jointTransforms,
repeatMode: .autoReverse,
fillMode: .both,
trimStart: -0.5,
trimEnd: 1.5
)
try? 機器人模型.playAnimation(.generate(with: 動畫))
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(骨骼動畫())
最前面是從別的機器人模型(參考註解一)抓出來的一組65筆關節 Transform 資料,代表一個拳擊姿勢。

先觀察第一筆 Transform 資料,乃對應骨骼系統的根節點(mixamorig_Hips,臀部關節),從位移 translation.y = 92.86 可看出,機器人原始設計的座標單位是公分(而不是公尺),臀部關節離地面(內部座標原點)高 92.86 公分。

此範例執行時的主控台,會列印T字形站姿的座標陣列,其中根節點位移 translation.y = 99.79,兩相比較就可得知,從T字形站姿轉換到拳擊姿勢時,重心往下移約7公分,相當符合真實情境。

第二筆 Transform 資料,對應 “mixamorig_Hips/mixamorig_Spine”,也就是脊椎關節,是臀部關節的子節點,其位移 translation.y = 9.923,表示相對於臀部關節高出9.923公分。其他關節類推。

接下來,仿照上一節的動作動畫,讓機器人從T字形站姿,在0.5秒內轉變為拳擊姿勢。

這次我們改用原廠文件建議的 FromToByAnimation,來建構骨骼動畫:
let 動畫 = FromToByAnimation(
jointNames: 機器人模型.jointNames,
to: JointTransforms(拳擊姿勢),
duration: 0.5,
bindTarget: .jointTransforms,
repeatMode: .autoReverse,
fillMode: .both,
trimStart: -0.5,
trimEnd: 1.5
)
try? 機器人模型.playAnimation(.generate(with: 動畫))
FromToByAnimation 參數比較多,相當於結合 FromToByAction、makeActionAnimation 兩者的參數,加上骨骼系統特有的參數。

後面有3個首次用到的參數 — fillMode, trimStart, trimEnd。

trimStart 是起算時間,設為-0.5秒,表示動畫開始之前的0.5秒,先顯示第一幀靜止畫面;接下來到0秒時播放動畫,長度(duration)0.5秒;結束後顯示最後一幀靜止畫面,直到 trimEnd 1.5秒(實際暫停1.0秒),然後返回起點(autoReverse)。

trim 是動詞「剪裁、修剪」之意,在此 trimStart, trimEnd 不僅可剪裁動畫時間,還能往外延伸動畫長度,很有彈性。

fillMode 用來設定延伸動畫時,要填入的畫面。“fillMode: .both” 表示 trimStart 開始先放第一幀畫面,動畫結束後放最後一幀畫面直到 trimEnd。

要播放 FromToByAnimation 動畫,不能直接用 playAnimation(動畫),必須多加一道 AnimationResource.generate(with: 動畫) ,轉成可播放的 AnimationResource 類型。

從這個簡短程式可以看出,有時資料比程式碼還長(就如AI機器學習的語料、遊戲的資料包)。如果要寫成App,通常會將程式碼與資料分開,放在不同檔案中,以方便動態讀取。

在此程式,實際資料只有最前、最後兩個姿勢,中間都是程式用內插法得到的數據,這種動畫也稱為「補間動畫」,而前、後兩幀畫面稱為關鍵幀(keyframe)。

本單元所學的動畫,當然都是補間動畫。

另外一種動畫,則是每一幀畫面都是由數據產生,這就得靠動作捕捉技術,一幀一幀擷取資料,Mixamo 網站的動畫就是如此。

程式執行結果:

💡註解
  1. 取得「拳擊姿勢」座標變換數據詳細過程:(1)從 Mixamo 網站下載一個包含動畫的機器人模型(動畫請搜尋 “Boxing” 或 “Jab Cross”),(2)用 Blender 轉換成 .usdz,(3)匯入 Swift Playground 中,(4)用程式將「機器人.jointTransforms」列印出來,就得到65個關節的座標變換陣列。取出姿勢數據之後,匯入的模型檔案就可刪除。
  2. 注意觀察拳擊姿勢,手指動作是最難捕捉的,有時必須佩戴專用手套,此範例握拳動作非常自然,由此可見Mixamo網站所提供模型與動畫的優異品質。
  3. 作業:請計算範例程式的動畫播放一輪(從T字形站姿回到T字形站姿,包含autoReverse)週期是幾秒?
6-15d 骨骼姿勢元件(SkeletalPosesComponent)

上一節提到,骨骼系統的姿勢,是由所有關節的座標變換所定義,也就是說,一組 jointTransforms 陣列值,就代表一個骨骼姿勢。

實際上,RealityKit 對骨骼姿勢正式定義了一個物件,就稱為 SkeletalPose,同時還有個「骨骼姿勢元件」(SkeletalPosesComponent),元件內的姿勢是複數(”Poses”),代表元件內可儲存多組骨骼姿勢。

與骨骼系統相關的元件,除了骨骼姿勢元件之外,還有一個 IKComponent,其中 IK 代表 Inverse Kinematics,中文可譯為「反向運動學」,同樣用於骨骼動畫,下一節會介紹。

SkeletalPose 的用法如下:
// 姿勢1: T字形
let 預設姿勢 = 機器人模型.jointNames.indices.map { i in
return (機器人模型.jointNames[i], 機器人模型.jointTransforms[i])
}
let 姿勢1 = SkeletalPose(id: "T字形姿勢", joints: 預設姿勢)
前兩行基本上就是將關節名稱(jointNames)與對應的座標變換(jointTransforms)陣列元素拆開來,一一組成「二元組」,也就是「(關節名稱, 關節座標變換)」,再合成一個新的陣列,最後加上一個字串(id)當做此姿勢的名稱。

因此,SkeletalPose 有3個屬性:id, jointNames, jointTransforms,如上圖所示。

本節範例程式,我們將做出3個姿勢:

1. 姿勢1:T字形姿勢
2. 姿勢2:雙手斜下45度
3. 姿勢3:蹲下

姿勢1由上面幾行程式,從機器人模型檔案中抓出;姿勢2仿照6-15b,直接操作左右手關節旋轉45度角;姿勢3參考上一節,從 Mixamo 網站抓出蹲下姿勢的65個關節座標變換值。

這3個姿勢可進一步儲存到關節姿勢元件(SkeletalPosesComponent)中,當作模型檔案的一部分,以後讀取模型檔案時,就能直接使用這3個姿勢:
// 加入 SkeletalPosesComponent
let 姿勢元件 = SkeletalPosesComponent(poses: [姿勢1, 姿勢2, 姿勢3])
機器人模型.components.set(姿勢元件)
骨骼姿勢元件內含多組姿勢,其實就是一個骨骼姿勢的集合(Set),而一個骨骼姿勢,則是(關節名稱, 關節座標變換)二元組的陣列(Array)。

從骨骼姿勢元件中,透過 id 當作索引,就可取出某個姿勢:
// 動畫1: 姿勢1 -> 姿勢2
let 動畫1 = FromToByAnimation(
jointNames: 姿勢元件.poses["T字形姿勢"]!.jointNames,
from: 姿勢元件.poses["T字形姿勢"]!.jointTransforms,
to: 姿勢元件.poses["雙手斜下45度"]!.jointTransforms,
duration: 0.3,
bindTarget: .jointTransforms,
trimStart: -0.5
)
這樣就能從預設的「T字形姿勢」轉換到「雙手斜下45度」。

對於程式與資料在同一個檔案而言,「姿勢元件.poses["T字形姿勢"]」其實就等於「姿勢1」,「姿勢元件.poses["雙手斜下45度"]」相當於「姿勢2」。

同樣的方法,動畫2從「雙手斜下45度」轉換到「蹲下姿勢」:
// 動畫2: 姿勢2 -> 姿勢3
let 動畫2 = FromToByAnimation(
jointNames: 姿勢元件.poses["T字形姿勢"]!.jointNames,
from: 姿勢元件.poses["雙手斜下45度"]!.jointTransforms,
to: 姿勢元件.poses["蹲下姿勢"]!.jointTransforms,
duration: 0.5,
bindTarget: .jointTransforms,
fillMode: .forwards,
trimEnd: 1.5,
delay: 0.8
)
這裡多了一個 “delay: 0.8” 參數,目的是延遲0.8秒再開始動畫,為什麼要延遲呢?因為我們要將這兩個動畫放入 AnimationGroup 組合在一起,兩個動畫會同時啟動,但因為動畫2延遲0.8秒,所以剛好接在動畫1完成之後:
// 動畫3: 動畫1 + 動畫2
let 動畫3 = AnimationGroup(
group: [動畫1, 動畫2],
repeatMode: .autoReverse)
透過這個小技巧(參考自AnimationGroup官方文件),可將多個動畫前後銜接在一起。

完成後的動畫執行結果如下:

就這樣,可建構多個靜態姿勢,當作關鍵幀,再一一銜接起來成為一個完整的補間動畫。

最後附上完整的範例程式:
// 6-15d 骨骼姿勢元件(SkeletalPosesComponent)
// Created by Heman Lu, 2025/09/12
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

let 蹲下姿勢: [Transform] =
[
Transform(
rotation: simd_quatf(angle: 0.528, axis: [-0.409, 0.824, 0.391]),
translation: [1.405, 42.844, 1.271]),
Transform(
rotation: simd_quatf(angle: 0.072, axis: [0.533, -0.593, -0.604]),
translation: [0.001, 9.923, -1.227]),
Transform(
rotation: simd_quatf(angle: 0.343, axis: [0.934, -0.266, -0.238]),
translation: [0, 11.732, 0]),
Transform(
rotation: simd_quatf(angle: 0.452, axis: [0.962, -0.213, -0.169]),
translation: [0, 13.459, 0]),
Transform(
rotation: simd_quatf(angle: 0.193, axis: [-0.823, 0.451, -0.344]),
translation: [0, 15.028, 0.878]),
Transform(
rotation: simd_quatf(angle: 0.400, axis: [-0.996, 0.069, -0.060]),
translation: [0, 10.322, 3.142]),
Transform(
rotation: simd_quatf(angle: 0, axis: [-0.062, -0.994, -0.093]),
translation: [0, 18.475, 6.636]),
Transform(
rotation: simd_quatf(angle: 2.124, axis: [0.610, 0.517, -0.601]),
translation: [6.106, 9.106, 0.757]),
Transform(
rotation: simd_quatf(angle: 1.102, axis: [0.847, -0.003, 0.531]),
translation: [0, 12.922, 0]),
Transform(
rotation: simd_quatf(angle: 1.622, axis: [0.003, -0.004, 1.000]),
translation: [0, 27.405, 0]),
Transform(
rotation: simd_quatf(angle: 1.323, axis: [0.484, 0.852, -0.199]),
translation: [0, 27.614, 0]),
Transform(
rotation: simd_quatf(angle: 0.365, axis: [0.433, -0.364, 0.824]),
translation: [-3.003, 3.789, 2.167]),
Transform(
rotation: simd_quatf(angle: 0.129, axis: [-0.366, 0.003, -0.931]),
translation: [0, 4.745, 0]),
Transform(
rotation: simd_quatf(angle: 0, axis: [0.981, -0.018, -0.193]),
translation: [0, 4.382, 0]),
Transform(
rotation: simd_quatf(angle: 0.291, axis: [0.063, 0.866, 0.497]),
translation: [0, 3.459, 0]),
Transform(
rotation: simd_quatf(angle: 0.225, axis: [0.830, 0.030, -0.557]),
translation: [-2.822, 12.267, 0.232]),
Transform(
rotation: simd_quatf(angle: 0.454, axis: [0.999, -0.003, -0.035]),
translation: [0, 3.892, 0]),
Transform(
rotation: simd_quatf(angle: 0, axis: [0.975, 0.217, -0.054]),
translation: [0, 3.415, 0]),
Transform(
rotation: simd_quatf(angle: 0.008, axis: [0.001, 1.000, 0.006]),
translation: [0, 3.078, 0]),
Transform(
rotation: simd_quatf(angle: 0.465, axis: [0.958, -0.013, -0.285]),
translation: [0, 12.776, 0]),
Transform(
rotation: simd_quatf(angle: 0.405, axis: [0.995, -0.004, 0.100]),
translation: [0, 3.614, 0]),
Transform(
rotation: simd_quatf(angle: 0, axis: [1.000, -0.026, -0.015]),
translation: [0, 3.460, 0]),
Transform(
rotation: simd_quatf(angle: 0.009, axis: [0, 0.990, -0.141]),
translation: [0, 3.680, 0]),
Transform(
rotation: simd_quatf(angle: 0.565, axis: [0.967, -0.070, -0.244]),
translation: [2.217, 12.147, -0.010]),
Transform(
rotation: simd_quatf(angle: 0.485, axis: [0.991, 0.005, 0.136]),
translation: [0, 3.601, 0]),
Transform(
rotation: simd_quatf(angle: 0, axis: [0.682, 0.493, 0.540]),
translation: [0, 3.307, 0]),
Transform(
rotation: simd_quatf(angle: 0.015, axis: [0.001, 0.995, 0.104]),
translation: [0, 3.660, 0]),
Transform(
rotation: simd_quatf(angle: 0.489, axis: [0.957, -0.118, -0.267]),
translation: [4.726, 10.908, 0.226]),
Transform(
rotation: simd_quatf(angle: 0.685, axis: [0.978, 0.023, 0.209]),
translation: [0, 4.137, 0]),
Transform(
rotation: simd_quatf(angle: 0, axis: [0.865, 0.267, 0.425]),
translation: [0, 2.595, 0]),
Transform(
rotation: simd_quatf(angle: 0.008, axis: [-0.001, 0.990, -0.139]),
translation: [0, 2.924, 0]),
Transform(
rotation: simd_quatf(angle: 2.122, axis: [0.609, -0.518, 0.600]),
translation: [-6.106, 9.106, 0.757]),
Transform(
rotation: simd_quatf(angle: 1.257, axis: [0.738, -0.295, -0.607]),
translation: [0, 12.922, 0]),
Transform(
rotation: simd_quatf(angle: 0.927, axis: [0.005, -0.006, -1.000]),
translation: [0, 27.405, 0]),
Transform(
rotation: simd_quatf(angle: 0.760, axis: [-0.500, -0.714, -0.490]),
translation: [0, 27.614, 0]),
Transform(
rotation: simd_quatf(angle: 0.714, axis: [0.897, 0.187, -0.400]),
translation: [3.003, 3.789, 2.167]),
Transform(
rotation: simd_quatf(angle: 0.123, axis: [-0.432, -0.044, 0.901]),
translation: [0, 4.745, 0]),
Transform(
rotation: simd_quatf(angle: 0, axis: [-0.313, 0.875, 0.369]),
translation: [0, 4.382, 0]),
Transform(
rotation: simd_quatf(angle: 0.294, axis: [0.063, -0.867, -0.494]),
translation: [0, 3.459, 0]),
Transform(
rotation: simd_quatf(angle: 0.676, axis: [0.981, 0.019, 0.191]),
translation: [2.822, 12.267, 0.232]),
Transform(
rotation: simd_quatf(angle: 0.309, axis: [0.998, 0.003, 0.065]),
translation: [0, 3.892, 0]),
Transform(
rotation: simd_quatf(angle: 0, axis: [-0.907, 0.403, -0.126]),
translation: [0, 3.415, 0]),
Transform(
rotation: simd_quatf(angle: 0.014, axis: [-0.011, -0.980, 0.201]),
translation: [0, 3.078, 0]),
Transform(
rotation: simd_quatf(angle: 0.522, axis: [0.965, 0.059, 0.254]),
translation: [0, 12.776, 0]),
Transform(
rotation: simd_quatf(angle: 0.144, axis: [0.994, 0.019, -0.112]),
translation: [0, 3.614, 0]),
Transform(
rotation: simd_quatf(angle: 0, axis: [-1.000, 0, -0.003]),
translation: [0, 3.460, 0]),
Transform(
rotation: simd_quatf(angle: 0.014, axis: [-0.007, -0.969, -0.247]),
translation: [0, 3.680, 0]),
Transform(
rotation: simd_quatf(angle: 0.424, axis: [0.938, 0, 0.347]),
translation: [-2.217, 12.147, -0.010]),
Transform(
rotation: simd_quatf(angle: 0.333, axis: [0.984, -0.001, -0.180]),
translation: [0, 3.601, 0]),
Transform(
rotation: simd_quatf(angle: 0, axis: [-0.333, 0.688, 0.645]),
translation: [0, 3.307, 0]),
Transform(
rotation: simd_quatf(angle: 0.015, axis: [-0.008, -0.999, -0.039]),
translation: [0, 3.660, 0]),
Transform(
rotation: simd_quatf(angle: 0.254, axis: [0.762, -0.094, 0.641]),
translation: [-4.726, 10.908, 0.226]),
Transform(
rotation: simd_quatf(angle: 0.404, axis: [0.945, -0.016, -0.327]),
translation: [0, 4.137, 0]),
Transform(
rotation: simd_quatf(angle: 0, axis: [0.701, -0.542, -0.463]),
translation: [0, 2.595, 0]),
Transform(
rotation: simd_quatf(angle: 0.018, axis: [-0.009, -0.968, 0.251]),
translation: [0, 2.924, 0]),
Transform(
rotation: simd_quatf(angle: 3.213, axis: [0.208, 0.681, 0.702]),
translation: [9.124, -6.657, -0.055]),
Transform(
rotation: simd_quatf(angle: 3.983, axis: [0.999, 0.009, 0.052]),
translation: [0, 40.599, 0]),
Transform(
rotation: simd_quatf(angle: 1.605, axis: [0.975, 0.076, 0.207]),
translation: [0, 42.099, 0]),
Transform(
rotation: simd_quatf(angle: 0.465, axis: [0.988, -0.139, -0.067]),
translation: [0, 15.722, 0]),
Transform(
rotation: simd_quatf(angle: 0, axis: [0.206, 0.599, -0.774]),
translation: [0, 10, 0]),
Transform(
rotation: simd_quatf(angle: 2.815, axis: [0.057, 0.426, 0.903]),
translation: [-9.125, -6.656, -0.055]),
Transform(
rotation: simd_quatf(angle: 3.587, axis: [1.000, -0.023, -0.014]),
translation: [0, 40.599, 0]),
Transform(
rotation: simd_quatf(angle: 1.816, axis: [0.995, 0.005, 0.105]),
translation: [0, 42.099, 0]),
Transform(
rotation: simd_quatf(angle: 1.382, axis: [0.999, 0.032, 0.012]),
translation: [0, 15.722, 0]),
Transform(
rotation: simd_quatf(angle: 0, axis: [-0.539, 0.842, 0]),
translation: [0, 10, 0])
]

struct 骨骼動畫: View {
var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b

if let 機器人模型 = try? await ModelEntity(named: "Y Bot.usdz") {
機器人模型.position = [0, -0.9, 0]
內容.add(機器人模型)

// 姿勢1: T字形
let 預設姿勢 = 機器人模型.jointNames.indices.map { i in
return (機器人模型.jointNames[i], 機器人模型.jointTransforms[i])
}
let 姿勢1 = SkeletalPose(id: "T字形姿勢", joints: 預設姿勢)

// 姿勢2: 雙手斜下
var 姿勢2 = 姿勢1
姿勢2.id = "雙手斜下45度"

if let 索引 = 機器人模型.jointNames.indices.first(where: { i in
機器人模型.jointNames[i].contains("RightArm")
}) {
let 字串 = 機器人模型.jointNames[索引]
姿勢2[字串]?.rotation = simd_quatf(
angle: .pi/4,
axis: [1, 0, 0])
}

if let 索引 = 機器人模型.jointNames.indices.first(where: { i in
機器人模型.jointNames[i].contains("LeftArm")
}) {
let 字串 = 機器人模型.jointNames[索引]
姿勢2[字串]?.rotation = simd_quatf(
angle: .pi/4,
axis: [1, 0, 0])
}

// 姿勢3: 蹲下
var 姿勢3 = 姿勢1
姿勢3.id = "蹲下姿勢"
姿勢3.jointTransforms = JointTransforms(蹲下姿勢)

// 加入 SkeletalPosesComponent
let 姿勢元件 = SkeletalPosesComponent(poses: [姿勢1, 姿勢2, 姿勢3])
機器人模型.components.set(姿勢元件)

// 動畫1: 姿勢1 -> 姿勢2
let 動畫1 = FromToByAnimation(
jointNames: 姿勢元件.poses["T字形姿勢"]!.jointNames,
from: 姿勢元件.poses["T字形姿勢"]!.jointTransforms,
to: 姿勢元件.poses["雙手斜下45度"]!.jointTransforms,
duration: 0.3,
bindTarget: .jointTransforms,
trimStart: -0.5
)
// 動畫2: 姿勢2 -> 姿勢3
let 動畫2 = FromToByAnimation(
jointNames: 姿勢元件.poses["T字形姿勢"]!.jointNames,
from: 姿勢元件.poses["雙手斜下45度"]!.jointTransforms,
to: 姿勢元件.poses["蹲下姿勢"]!.jointTransforms,
duration: 0.5,
bindTarget: .jointTransforms,
fillMode: .forwards,
trimEnd: 1.5,
delay: 0.8
)
// 動畫3: 動畫1 + 動畫2
let 動畫3 = AnimationGroup(
group: [動畫1, 動畫2],
repeatMode: .autoReverse)

try? 機器人模型.playAnimation(.generate(with: 動畫3))
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(骨骼動畫())
補充(30) 語法說明:陣列映射(map)

上一節有個小錯誤:
// 加入 SkeletalPosesComponent
let 姿勢元件 = SkeletalPosesComponent(poses: [姿勢1, 姿勢2, 姿勢3])
機器人模型.components.set(姿勢元件)
骨骼姿勢元件內含多組姿勢,其實就是一個骨骼姿勢的「集合」,原先誤寫為陣列。

老實說,陣列(Array)與集合(Set)的確很容易混淆,因為兩者可互相轉換,例如上面第一行程式碼,「poses: [姿勢1, 姿勢2, 姿勢3]」就是以陣列為參數,轉換為集合。

陣列(Array)、集合(Set)、字典(Dictionary)曾在第5單元語法說明:字典(Dictionary)資料類型比較過,這三者都屬於資料合輯(Collection),也就是由多筆資料所組成。其中最常用的是「陣列+for 迴圈」,這是 Swift 程式語言最強語法之一,從第1單元課程就經常用到。

資料合輯(Collection)其實是個規範(protocol),也就是陣列、集合、字典上層的大類型。任何符合 Collection 規範的,都可以配合 for 迴圈,語法也都一樣:
for 元素 in 某個陣列 { ... }
for 元素 in 某個集合 { ... }
for 元素 in 某個字典 { ... }

除了用 for 迴圈,資料合輯也定義了許多屬性與方法,讓陣列、集合、字典都能通用。例如,上面的 for 迴圈可以改寫成 forEach():
某個陣列.forEach { 元素 in ... }
某個集合.forEach { 元素 in ... }
某個字典.forEach { 元素 in ... }
兩種寫法功能完全一樣。

至於哪一種比較好?端看個人習慣,前者 for 迴圈是傳統思考模式,比較直覺,初學者容易理解,且幾乎所有程式語言都會具備;後者 forEach() 是物件導向模式,參數用匿名函式(closure),語法比較簡潔、多樣,習慣之後會非常喜歡,但不是每種程式語言都有。

符合 Collection 規範的資料類型,除了 Array, Set, Dictionary 之外,還有 Tuple(多元組)、Range (範圍)、String(字串)… 等數十種,資料合輯的物件屬性與方法都可通用。

事實上,在 Collection 規範之上,還有一個上層規範,稱為序列(Sequence),部分屬性與方法是在 Sequence 中定義。

在上一節用到 map() 也是資料合輯通用的物件方法:
// 姿勢1: T字形
let 預設姿勢 = 機器人模型.jointNames.indices.map { i in
return (機器人模型.jointNames[i], 機器人模型.jointTransforms[i])
}
let 姿勢1 = SkeletalPose(id: "T字形姿勢", joints: 預設姿勢)
此例用在關節名稱陣列的索引 indices (可視為一個陣列 — 註解3)。

map() 與 reduce() 是資料合輯最常用的兩個方法,map() 將資料合輯內的元素,一對一「映射」為另一種元素,有點座標變換的味道。若原來的資料合輯有n個元素,映射後還是n個元素,但元素值或類型可能改變。

例如,上面程式碼將65個關節名稱索引(類型為整數),逐一映射為65個 (關節名稱, 座標變換) 二元組,以便骨骼姿勢(SkeletalPose)採用。

reduce() 則是將 n 個元素的資料合輯,「歸納」為1個值(資料類型與元素類型可能相同,也可能不同)。歸納的方法可以加總、平均、摘要、取最大值…,取決於後面匿名函式如何設計。

早期(2003年) Google 曾發布一個程式工具,就稱為 “MapReduce”,用於大數據的平行運算,因為在 map() 一對一映射過程中,每個元素的映射與其他元素無關,因此非常適合平行運算。通常經過一或多次的映射後,最後再進入 reduce() 運算,這也是機器學習的主要方法之一。

除此之外,資料合輯定義許多好用的物件屬性與方法,摘錄部分比較常用的,整理如下表。
# 物件屬性或方法 用途
1 startIndex 第1個索引
2 endIndex 最後1個索引的下個索引值(註解4)
3 indices 所有索引的合輯
4 count 元素個數
5 first 第1個元素
6 isEmpty 是否為空(0個元素)
7 contains() 是否包含某些元素
8 first() 傳回第1個元素
9 firstIndex() 傳回符合條件的第1個索引
10 min() 傳回元素最小值
11 max() 傳回元素最大值
12 prefix() 傳回前面若干個元素
13 suffix() 傳回後面若干個元素
14 dropFirst() 移除第1個元素
15 dropLast() 移除最後1個元素
16 filter() 過濾出符合條件的元素
17 map() 元素一對一映射到新元素
18 reduce() 將所有元素歸納到一個值
19 forEach() 對每個元素逐一操作
20 split() 拆成兩個或多個資料合輯
21 sorted() 傳回已經排序後的資料合輯
22 shuffled() 傳回已隨機排列後的資料合輯

💡註解
  1. 對初學者而言,很少直接用到 Collection 與 Sequence 規範,目前不必太深入理解。
  2. 從這裡可以看出規範(protocol)好用的地方,將若干類型的共同特性萃取出來,就不必在個別類型中重複這些屬性與方法;日後若我們用 extension 加以擴充,下層的子類型自然就具備同樣的擴充。
  3. 如果在上一節範例程式中,插入一行:
    print(機器人模型.jointNames.indices)
    得到的結果是 “0..<65”。也就是說,索引 indices 並不是陣列,而是範圍(Range),所幸不管是陣列還是範圍,都適用 map() 映射。
  4. endIndex 很容易讓人誤以為是最後一個索引,這個誤解甚至會導致閃退,在第4單元4-3c 中文斷句修正版提到過。
6-15e 反向運動學元件(IKComponent)

骨骼動畫最後一節是反向運動學元件(IKComponent),什麼是反向運動學?為什麼要使用反向運動學呢?

前兩節提到,骨骼姿勢與任一關節的座標變換有關,以右手為例,從上到下包括肩、肘、腕、手指等關節,若肩關節轉動,以下的肘、腕、指關節與上臂、下臂、手掌等位置也會跟著改變。

這種由上而下的關節運動方式,稱為正向運動學(Forward Kinematics);反過來說,由下而上的關節運動方式,則稱為反向運動學(Inverse Kinematics,縮寫為IK)。什麼時候會用到反向運動學?

設想一個人要拿桌上杯子,手臂的運動方式,是先考慮肩膀關節如何運動,再依次帶動手肘、手腕,最後再移動手指去拿杯子嗎?

當然不是!我們會直接以手指接近杯子,然後手腕、肘、肩、脊椎等關節自然配合移動,也就是說,我們的肢體運動方式其實是反向運動學。

反向運動學模仿人體關節的運動方式,顯得比較自然,因此,在3D人物動畫或機器人領域,反向運動學不可或缺。相對的代價是,不像正向運動學由上而下的座標變換容易計算,反向運動學在數學上相當於解一組複雜方程式,相當困難。

Apple 大約在2014年 (iOS 8),於 SpriteKit (for 2D) 與 SceneKit (3D) 首次引入物理模擬與反向運動學,RealityKit 則是在2024年 (iOS 18)才加入反向運動學元件(IKComponent)。

老實說,RealityKit 的 IKComponent 程式並不好寫,筆者一開始毫無頭緒,原廠文件像迷宮一樣,繞來繞去完全看不懂,奮鬥了半個月,才終於寫出能動的範例程式,感覺比物理關節還艱辛。

反向運動學程式非常特別,要分成兩個階段來寫:

(一)先對骨骼模型設定反向運動學元件(IKComponent)
(二)再對部分關節設定約束條件與要求,反向求解

範例程式的第一階段細分為7個步驟,程式碼如下:
// 1. 載入 Y-Bot 機器人模型
if let 機器人模型 = try? await ModelEntity(named: "Y Bot.usdz"),
// 2. 從模型中取得骨骼系統(skeleton)
let 骨骼系統 = 機器人模型.model?.mesh.contents.skeletons.first,
// 3. 建立反向操作器(IKRig)
var 操作器 = try? IKRig(for: 骨骼系統) {
...
}
前3步都用 Optional 條件句:1. 載入機器人模型,2. 取得骨骼系統,3. 建立 IKRig 操作器。三個條件會依序執行,上一步完成才會進行下一步,若任一步未取得內容值(傳回nil),則跳出 if { } 條件句,不會載入機器人模型。

注意第2步,從模型裡面取出骨骼系統,位於 model?.mesh.contents 內,這個 contents 是第1步讀入外部檔案 “Y Bot.usdz” 的內容,並設為唯讀(只能讀取,不能修改覆寫)。

拿到骨骼系統之後,第3步產生 IKRig 操作器,這是設定 IKComponent 的必要物件,在設定元件之前,要先調整好操作器參數,IKRig 共有5個參數,如下:

1. maxIterations 與演算法有關,預設最多120次迴圈或疊代(疊代數越高,求解越精確)
2. globalFkWeight 正向運動學(來自骨骼姿勢)權重,預設值1.0 (100%)
3. globalLimitsWeight 反向運動學(來自約束條件)權重,預設值1.0 (100%)
4. constraints 對某些關節的約束條件
5. joints 所有關節(含關節名稱、座標變換)的集合,也就是第2步取得的骨骼系統

在下面程式,先將 maxIterations 降為30次,減少計算量。再將正向運動學(若有的話)的權重降為0.02 (2%),反向運動學權重維持100%。

接下來,第4, 5兩步加入兩個約束條件(constraints),分別針對頭部關節與右手食指關節,關節路徑名稱很長,要仔細查6-15b 關節名稱與座標變換 ,不可出錯。約束條件的定義對第二階段非常重要:
操作器.maxIterations = 30     // default 120
操作器.globalFkWeight = 0.02 // default 1.0

// 4. 指定要操作的關節
let 頭部關節路徑 = "mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_Neck/mixamorig_Head"
let 右手關節路徑 = "mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandIndex1/mixamorig_RightHandIndex2/mixamorig_RightHandIndex3/mixamorig_RightHandIndex4"

// 5. 加入約束條件:頭部與右手
操作器.constraints = [
.parent(
named: "頭部條件",
on: 頭部關節路徑,
positionWeight: [1, 1, 1],
orientationWeight: [1, 1, 1]),
.point(
named: "右手條件",
on: 右手關節路徑,
positionWeight: [1, 1, 1])
]

// 6. 設定 IKComponent
if let 資源參數 = try? IKResource(rig: 操作器) {
機器人模型.components.set(IKComponent(resource: 資源參數))
}
第6步,將修改好的操作器(IKRig)參數寫入 IKComponent 元件中。

最後第7步,做一個小球,當作第二階段關節運動的目標物:
// 7. 建立一顆小球,當作手的目標
let 小球 = ModelEntity(mesh: .generateSphere(radius: 0.05),
materials: [SimpleMaterial(color: .red, isMetallic: false)])
小球.name = "小球"
小球.position = [-0.5, 0.8, 0.4] // 放在 Y-Bot 右手可觸及之處
內容.add(小球)
這樣就完成第一階段。

此時,機器人模型會動態產出反向運動學元件,不過螢幕上沒有什麼變化,Y Bot 機器人仍維持 T 字形站姿。

要讓機器人根據反向運動學動起來,必須靠第二階段,這階段可以寫在 update 子句內,分為6個步驟,其中最關鍵的在於第4, 5兩步,對定義約束條件的關節設定運動目標:
// IKComponent 第二階段
update: { 內容 in
// 1. 找到目標小球
guard let 小球 = 內容.entities.first(where: { 個體 in
個體.name == "小球"
}) else { return }

// 2. 找到機器人模型
for 機器人 in 內容.entities where 機器人.name == "機器人" {
// 3. 取得機器人的反向運動學元件
if let 元件 = 機器人.components[IKComponent.self] {
// 4. 設定頭部約束條件的目標
元件.solvers.first?.constraints["頭部條件"]?.target.rotation = .init(angle: .pi/4, axis: [0, -1, 0])
元件.solvers.first?.constraints["頭部條件"]?.animationOverrideWeight.rotation = 1.0

// 5. 設定右手食指的目標
// 位移尺度由公尺改為公分
let 位移 = 小球.position(relativeTo: 機器人) * 100
元件.solvers.first?.constraints["右手條件"]?.target.translation = 位移
元件.solvers.first?.constraints["右手條件"]?.animationOverrideWeight.position = 0.95

// 6. 元件須覆寫更新後才會生效
機器人.components.set(元件)
}
}
}
注意第4, 5步驟突然出現一個「元件.solvers」物件,這是在第一階段不曾出現的,也是 IKComponent 最令人困惑的眉角之一,這個solvers其實是在第一階段第6步,產出 IKComponent 時自動生成的。

solvers 可譯為「求解器」,也就是在眾多約束條件下,求得最佳或最近似解。solvers 類型是 IKComponent.Solver 的集合,每個 Solver 包含5個屬性,對應第一階段的 IKRig 類型:

1. id 求解器的辨識名稱
2. maxIterations 最高疊代次數
3. globalFkWeight 正向運動學的權重值
4. constraints 約束條件的集合
5. joints 所有關節(座標名稱、座標變換)

也就是說,我們在第一階段定義的 IKRig 操作器物件,進入 IKComponent 元件之後,就轉成 Solver 求解器物件,準備求解。

在第一階段中,我們只要定義哪些關節需要綁定約束條件,其中約束條件的名稱(named)會用在第二階段,當作 constraints[] 的索引。

第一階段定義約束條件時,可選的類型有5種,包括:

1. .point() 可設定位移條件
2. .orient() 可設定轉向條件
3. .parent() 可設定位移與轉向條件
4. .lookAtAbsolute() 可設定面向(個體視角)條件
5. .lookAtAdditive() 可設定相對視角條件

在第二階段程式中,我們設定頭部轉向條件的目標是右轉45度角;設定右手食指位移條件是接觸小球。

在約束條件中,可以設定不同權重,globalFkWeight 與 globalLimitsWeight 是針對整個操作器(IKRig)的權重,預設值為1.0 (100%)。其他的 positionWeight 或 orientationWeight 則是針對個別關節的位移或轉向權重,預設也都是1.0 (100%)。

第二階段的第5步,要設定機器人右手食指移動到小球位置,座標位置以機器人的局部座標計算,也就是兩個個體的相對位置再乘以100,將小球的尺度(公尺)改到機器人的尺度(公分):
// 位移尺度由公尺改為公分
let 位移 = 小球.position(relativeTo: 機器人) * 100

計算結果是[-50.0, 170.0, 40.0],機器人座標原點在兩腳掌中間,小球在170公分高的位置(參考註解3),比機器人眼睛略高。

若對右手食指設定動畫權重為1.0 (100%),表示食指與小球球心重合,改權重為0.95 表示移動到95%的位置,稍碰到小球即可。
元件.solvers.first?.constraints["右手條件"]?.animationOverrideWeight.position = 0.95

終於完成了,看看最後成果,雖然動作不大,但真是摧肝瀝血之作:

注意觀察右手的每個關節,除了食指,其他腕、肘、肩等關節也都順勢移動,彎曲弧度比較平滑,不會像前幾節用正向運動學那樣生硬。

附上完整的範例程式:
// 6-15e 反向運動學元件(IKComponent)
// Created by Heman Lu, 2025/09/22
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

struct 骨骼動畫: View {
var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b

// 1. 載入 Y-Bot 機器人模型
if let 機器人模型 = try? await ModelEntity(named: "Y Bot.usdz"),
// 2. 從模型中取得骨骼系統(skeleton)
let 骨骼系統 = 機器人模型.model?.mesh.contents.skeletons.first,
// 3. 建立反向操作器(IKRig)
var 操作器 = try? IKRig(for: 骨骼系統) {
機器人模型.name = "機器人"
機器人模型.position = [0, -0.9, 0]
內容.add(機器人模型)

操作器.maxIterations = 30 // default 120
操作器.globalFkWeight = 0.02 // default 1.0

// 4. 指定要操作的關節
let 頭部關節路徑 = "mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_Neck/mixamorig_Head"
let 右手關節路徑 = "mixamorig_Hips/mixamorig_Spine/mixamorig_Spine1/mixamorig_Spine2/mixamorig_RightShoulder/mixamorig_RightArm/mixamorig_RightForeArm/mixamorig_RightHand/mixamorig_RightHandIndex1/mixamorig_RightHandIndex2/mixamorig_RightHandIndex3/mixamorig_RightHandIndex4"

// 5. 加入約束條件:頭部與右手
操作器.constraints = [
.parent(
named: "頭部條件",
on: 頭部關節路徑,
positionWeight: [1, 1, 1],
orientationWeight: [1, 1, 1]),
.point(
named: "右手條件",
on: 右手關節路徑,
positionWeight: [1, 1, 1])
]

// 6. 設定 IKComponent
if let 資源參數 = try? IKResource(rig: 操作器) {
機器人模型.components.set(IKComponent(resource: 資源參數))
}
}

// 7. 建立一顆小球,當作手的目標
let 小球 = ModelEntity(mesh: .generateSphere(radius: 0.05),
materials: [SimpleMaterial(color: .red, isMetallic: false)])
小球.name = "小球"
小球.position = [-0.5, 0.8, 0.4] // 放在 Y-Bot 右手可觸及之處
內容.add(小球)

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
} update: { 內容 in
print("Update at \(Date.now)")

// 1. 找到目標小球
guard let 小球 = 內容.entities.first(where: { 個體 in
個體.name == "小球"
}) else { return }
print("找到小球:\(小球)")

// 2. 找到機器人模型
for 機器人 in 內容.entities where 機器人.name == "機器人" {
print("找到機器人:\(機器人)")

// 3. 取得機器人的反向運動學元件
if let 元件 = 機器人.components[IKComponent.self] {
print("有IKComponent: \(元件.solvers.count) Solver \(元件.solvers.first?.id)")
print("找到約束條件", 元件.solvers.first?.constraints.count)

// 4. 設定頭部約束條件的目標
元件.solvers.first?.constraints["頭部條件"]?.target.rotation = .init(angle: .pi/4, axis: [0, -1, 0])
元件.solvers.first?.constraints["頭部條件"]?.animationOverrideWeight.rotation = 1.0

// 5. 設定右手食指的目標
// 位移尺度由公尺改為公分
let 位移 = 小球.position(relativeTo: 機器人) * 100
print("位移:\(位移)")
元件.solvers.first?.constraints["右手條件"]?.target.translation = 位移
元件.solvers.first?.constraints["右手條件"]?.animationOverrideWeight.position = 0.95

// 6. 元件須覆寫更新後才會生效
機器人.components.set(元件)
}
}
}
.realityViewCameraControls(.orbit)
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(骨骼動畫())

範例程式通常要求精簡,但此例還是將近100行,包含十幾個初次使用的物件,可見 IKComponent 之難度,想要熟悉 IKComponent 的話,還需要多加練習。

💡註解
  1. Inverse Kinematics 可能是本單元難度最高的一節,不管是背後理論還是程式實作上。
  2. 有關Inverse Kinematics的演算法,可參考劉安齊的文章,這位年輕工程師功底相當紮實。不過,該文將IK譯為「反向動力法」並不恰當,因為 kinematics 只觀察運動方式,不牽涉「力」;涉及「力」的通常稱為 dynamics (動力學)或 mechanics(力學)。但文章瑕不掩瑜,值得推薦。
  3. 6-15c 轉換姿勢(FromToByAnimation)曾提到,Y Bot 機器人原始設計的座標尺度為公分。
  4. 作業1:將頭部約束條件改為盯著小球轉動。
  5. 作業2:請將小球加上動畫,在小範圍內來回移動,讓機器人雙手自動跟隨(參考下圖)。

第16課 AR擴增實境

按原先規劃,本單元最後,將利用 RealityKit 來寫擴增實境(AR)程式。

很多人會以為 RealityKit 不就是專門用來寫 AR 程式嗎?其實並非如此,若仔細區分,RealityKit 主要功能在「虛擬空間」程式,而「實體空間」場景追蹤由 ARKit 負責,兩者結合在一起,才是AR程式。

只不過,RealityKit(包括ARView 與 RealityView)已和 ARKit 高度整合,透過 RealityKit 並不需要 import ARKit 就能開發AR程式。

接下來的課程,必須使用 iPad + Swift Playground 才能執行AR程式,iPad 硬體規格不拘,只要能安裝 iPadOS 18,以及 Swift Playground 4.6 以上版本,便可執行以下範例程式。

6-16a 開啟AR相機

寫AR程式的第一步,當然是開啟實體相機鏡頭。用 RealityView 開啟鏡頭超級簡單,只需多一行程式:
// 6-16a 開啟AR相機
// Created by Heman Lu, 2025/10/07
// Minimum Requirement: iPadOS 18 + Swift Playground 4.6 (AR)

import SwiftUI
import RealityKit

struct 擴增實境: View {
var body: some View {
RealityView { 內容 in
內容.camera = .spatialTracking // 開啟AR相機
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(擴增實境())

RealityView 的「內容」包含一個 camera 屬性,平常預設值是 .virtual (虛擬鏡頭),只要改成 .spatialTracking (空間追蹤),就能開啟實體鏡頭。

在 iPad + Swift Playground 裡面,第一次執行這行程式時,會要求相機使用許可:

若用 Mac + Swift Playground,同樣程式會出現以下錯誤訊息而無法執行:

錯誤訊息:’spatialTracking’ is unavailable in Mac Catalyst.

這個錯誤是在編譯階段發生,類似語法錯誤,所以無法進入執行階段。

這個錯誤訊息也告訴我們,Mac + Swift Playground 底層有個模擬器,透過 Mac Catalyst 模擬 iOS 作業系統(註解1)。

如此一來,在 Mac + Swift Playground 似乎就無法寫 AR 程式,只能在 iPad + Swift Playground 裡打字,有點不方便。這也是我們第一次遇到跨平台的問題,怎麼解決呢?

要避開這個問題,可以利用 Swift 「條件編譯」功能,讓 Mac + Swift Playground 不會產生語法錯誤,如此一來,就可在 Mac 上打字寫程式,然後在 iPad 上執行。

所謂「條件編譯」,是用來告訴編譯器(註解4, 5),選擇性的改變編譯內容,這是很多程式語言都具備的功能,Swift 語法如下:
// Swift 條件編譯
#if 條件1
條件1成立時,要編譯的程式內容
#elseif 條件2
條件1不成立,但條件2成立時,要編譯的程式內容
#else
條件1與條件2都不成立時,要編譯的程式內容
#endif
中間 #elseif 以及 #else 可省略,最簡單的用法,是一個 #if 搭配一個 #endif(兩者必須成對出現)。

這裡可用的「條件」包括:
# Platform condition Valid arguments
1 os() macOS, iOS, watchOS, tvOS, visionOS, Linux, Windows
2 arch() arm, arm64, i386, wasm32, x86_64,
3 swift() >= or < followed by a version number
4 compiler() >= or < followed by a version number
5 canImport() A module name
6 targetEnvironment() simulator, macCatalyst
我們需要的,就是以 targetEnvironment(macCatalyst) 當作條件(註解6),如果在 Mac Catalyst 環境下,就設定「內容.camera = .virtual」,否則就「內容.camera = .spatialTracking」開啟相機。

修正後的版本如下:
// 6-16a2 開啟AR相機v2
// Created by Heman Lu, 2025/10/07
// Minimum Requirement: iPadOS 18 + Swift Playground 4.6 (AR)
// Alternative: macOS 15 + Swift Playground 4.6 (non-AR)

import SwiftUI
import RealityKit

struct 擴增實境: View {
var body: some View {
RealityView { 內容 in
內容.add(座標軸(0.5))

#if targetEnvironment(macCatalyst)
// for Mac + Swift Playground
內容.camera = .virtual // 只顯示虛擬空間
#else
// for iPad + Swift Playground
內容.camera = .spatialTracking // 開啟AR相機
#endif

}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(擴增實境())
這樣一來,就能在 Mac + Swift Playground 環境下執行,不會產生語法錯誤。額外的好處,是可以順便確認虛擬空間的物件是否運作正常:

同樣程式在 iPad 裡面執行,就會編譯不同內容,開啟鏡頭,同時顯示虛擬座標軸與實體場景:

這樣,就成功踏出AR程式的第一步。當然,後面還有很多內容要學,敬請期待。

💡註解
  1. 嚴格來說,Mac Catalyst 並非模擬器(Simulator),也不是虛擬機(Virtural Machine),而是 Apple 獨家技術,讓 iPadOS/iOS 可執行檔藉由即時轉譯,也能在 macOS 上執行。
  2. 開啟AR相機的方式,其實有好幾種,包括ARSCNView, ARView, SceneView 等,RealityView 是最新的方法,2023年才發佈。
  3. AR程式必須搭配加速度計、陀螺儀等傳感器晶片,才能做到空間追蹤,目前只能在 iPad, iPhone 或 Vision Pro 等實體設備上執行。其他如 Mac, Apple Watch, TV 等設備,即使有鏡頭,也無法運作。
  4. Swift Playground 不就是編譯器嗎?事實上,Swift Playground 整合編譯器、編輯器、連接器、程式庫、執行環境等,這類軟體稱為「整合開發環境」(IDE, Integrated Development Environment)。
  5. 在macOS「終端機」命令列可單獨呼叫 Swift 編譯程式,名稱就叫 swift:
  6. 注意這裡不能用 #if os(iOS) 當作條件,因為不管是在 Mac 或 iPad 上,Swift Playground 都是模擬成 iOS 作業系統。
  7. 另一個類似 #if os(iOS) 句型是 if #avaialable(),兩者有很大區別。前者(#if)是在編譯階段檢查;後者(if #available)則是在執行階段檢查,如同一般條件句,而非條件編譯。
    下面這行例子,在程式執行時才會判斷底層作業系統是否 iOS 18.0 或以上版本:
        if #available(iOS 18.0, *) {
    ...
    }
  8. 本課 AR 範例程式主要在 iPad Pro 11” (2018) + iPadOS 26.0.1 + Swift Playground 4.6.4 中執行。
6-16b 錨點個體(AnchorEntity)

初次接觸擴增實境(AR)時,會認為 AR 只是將3D模型與實景混合而已。但實際上並非這麼簡單,AR 目標是虛實整合,也就是虛擬物件要「融入」實體場景中,令人分不清虛實,甚至在虛實之間互動,才是理想的AR。

如何讓虛擬物件融入實景呢?關鍵就在「錨點個體」(AnchorEntity)。

當 AR 程式開啟相機後,底層的 ARKit 會針對相機帶進來的場景,即時建構一個「世界座標系」,並與 RealityKit 的虛擬空間座標(簡稱虛擬座標)完全對齊 — 在開啟相機瞬間,相機位置會當作世界座標原點,與虛擬座標原點重合,兩者座標軸方向對齊、尺度一致。

接下來,在整個AR工作階段,若實體設備有任何移動,ARKit 會藉由感測器追蹤並估算位移及方向,讓世界座標與虛擬座標一直保持對齊狀態(註解1)。

那麼,錨點個體有何作用呢?就像船錨用來固定船舶位置,錨點個體會將虛擬物件錨定在實體空間的某個「位置」,這個位置可能是在實景中的地板、桌面、牆面、某個物體或人物、特定圖片…等,ARKit 稱為「特徵點」。

也就是說,我們不必提供座標點,而是指定某種特徵點,讓錨點個體的原點及面向與之對齊,然後加入虛擬物件成為錨點個體的子個體。

其實,錨點個體與模型個體很像,主要差別只是將模型元件(ModelComponent)換成錨定元件(AnchoringComponent),兩者都具備座標變換 — 也就是位置、旋轉面向、縮放尺度等屬性,也可動態加載其他(如物理模擬等)元件。

錨點個體的用法很簡單,與其他個體類似:
let 錨點個體 = AnchorEntity(plane: .horizontal)    // 水平面
內容.add(錨點個體)

if let 機器人模型 = try? await ModelEntity(named: "Y Bot.usdz") {
錨點個體.addChild(機器人模型) // 綁定錨點
}

先初始化一個水平面上的錨點個體,加入RealityView「內容」中,然後把要錨定的機器人模型加入,成為錨點個體的子個體即可。

要特別注意的是,當AR程式開啟相機時,機器人模型並不會馬上顯示出來,因為實景畫面中不見得有水平面,需要移動或轉動相機,等偵測到水平面,才會自動錨定,將機器人顯示在螢幕上:

上圖錨點在地板,錨點對齊機器人模型的內部座標原點(在腳底)。筆者身高約1.8米,與機器人模型等高。從此例可看出,ARKit 估算實際尺寸相當準確。

將模型縮小為0.1倍(18cm高),放在桌面上:

在初始化錨點個體的時候,須透過參數指定「特徵點」種類,這些種類基本包括:

1. plane 平面
2. face 人臉
3. body 人體
4. image 特定圖片
5. object 特定物件

可錨定的類別有十幾種,詳細用法可參考下表:
# AnchorEntity() 初始化參數 ARAnchor 類型 說明
1 plane: .horizontal/.vertical/.any ARPlaneAnchor 偵測(水平或垂直)平面,並以平面中心為錨點
1 plane: .horizontal/.vertical/.any,
classification: .any,
minimumBounds: [0, 0]
ARPlaneAnchor 分類(classification)包括: .wall 牆面
.floor 地板
.ceiling 天花板
.table 桌面
.seat 椅面
.any 任何平面(預設值)
1 .plane(.horizontal,
classification: .any,
minimumBounds: [x, y])
ARPlaneAnchor 偵測(水平或垂直)平面,可限制種類與最小尺寸
2 world: [x, y, z] ARAnchor 錨定實體世界座標的[x, y, z]位置
2 .world(transform: float4x4) ARAnchor 錨定實體世界座標,同時設定變換矩陣(含位移、旋轉、縮放)
3 .camera ARAnchor 以目前實體相機位置為錨點
4 .anchor ARAnchor 以其他ARAnchor為錨點
5 .face ARFaceAnchor 偵測人臉,並以人中(鼻子下方)為錨點
6 .body ARBodyAnchor 偵測人體,錨點位置在髖關節(Hip joint)
7 .head ? 偵測頭部
for visionOS only
8 .hand(.left, location: .indexFingerTip) HandAnchor 偵測手掌
for visionOS only
9 .image(group:, name:) ARImageAnchor 偵測特定(2D)圖片,須配合Xcode
10 .object(group: name:) ARObjectAnchor 偵測其他特定(3D)物體,須配合Xcode
11 .referenceImage(from:) ARImageAnchor 指定圖片檔案爲參考圖片,以圖片中心為錨點
12 .referenceObject(from:) ARObjectAnchor 指定3D模型檔案爲參考物件
13 raycastResult ARAnchor 以使用者手指觸控所點到的物件位置為錨點
除此之外,ARKit 還有一些額外的錨點類別,目前 RealityKit 似乎尚未支援,包括:

1. ARAppClipCodeAnchor
2. AREnvironmentProbeAnchor
3. ARGeoAnchor
4. ARParticipantAnchor
5. ARMeshAnchor

最後附上完整範例程式:
// 6-16b 錨定個體(AnchorEntity)
// Created by Heman Lu, 2025/10/12
// Minimum Requirement: iPadOS 18 + Swift Playground 4.6 (AR)
// Alternative: macOS 15 + Swift Playground 4.6 (non-AR)

import SwiftUI
import RealityKit

struct 擴增實境: View {
var body: some View {
RealityView { 內容 in
let 錨點個體 = AnchorEntity(plane: .horizontal) // 水平面
內容.add(錨點個體)

if let 機器人模型 = try? await ModelEntity(named: "Y Bot.usdz") {
// 機器人模型.scale = [0.1, 0.1, 0.1]
錨點個體.addChild(機器人模型) // 綁定錨點
}

#if targetEnvironment(macCatalyst)
// for Mac + Swift Playground
內容.camera = .virtual // 只顯示虛擬空間
內容.add(座標軸(0.7))
#else // for iPad + Swift Playground
內容.camera = .spatialTracking // 開啟AR相機
#endif
}
#if targetEnvironment(macCatalyst)
.realityViewCameraControls(.orbit)
#endif
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(擴增實境())
注意範例程式的寫法:
  • 本例先指定錨點個體,再開啟AR相機。或是順序反過來,先開啟AR相機,再指定錨點個體,也沒問題。

  • #if 不一定放在行首,Swift 語法屬於 free-form style,可隨意用空格或Tab修飾,不會對語法造成影響。相對而言,Python 有嚴格的縮排規定,不能隨意加空白字元。

💡註解
  1. AR 要做到「虛擬座標的1公尺等同實體世界的1公尺」並不容易。一般相機鏡頭只能擷取2D畫面,ARKit 是如何從2D畫面估算實際尺寸,以建構世界坐標系呢?
  2. ARKit 追蹤世界座標的技術稱為 SLAM,過去曾是軍事或太空才會用到的高深技術。詳細說明請參閱「大神解釋,為什麼蘋果的ARKit毫無對手」。
  3. 為什麼 ARKit 僅使用一個鏡頭,便能估算出實景距離?從上篇文章知,鏡頭必須移動,ARKit才能透過硬體(IMU)與軟體(VIO)估算距離,鏡頭若保持靜止,即使場景有變動,AR也無法運作。
  4. 光達(LiDAR)鏡頭對 AR 有幫助,但並非必要。目前手機或平板上的 LiDAR 有效距離約5米,適合室內使用。
  5. 注意錨點個體(AnchorEntity)與錨定元件(AnchoringComponent)英文部分,一個用名詞 Anchor (船錨,錨點),一個用動名詞 Anchoring (錨定)。
  6. 2025年(iOS 26) RealityKit 新增一個 ARAnchoringComponent,可以取得底層 ARAnchor 的相關資料,如 ARFaceAnchor 的臉部模型。
6-16c 臉部錨點:前後鏡頭切換

上一節提到,錨點個體(AnchorEntity)用來錨定實景中的某些特徵點,例如平面、臉部、特定圖片…等,本節來試試臉部錨點。

臉部錨點適用於手機或平板(不適用Vision Pro),會辨識實景中的人臉,並錨定在鼻子下方的人中位置,須配合前鏡頭,但預設AR相機是後鏡頭,如何切換到前鏡頭呢?

當我們設定 RealityView 內容的相機模式為空間追蹤時,會自動開啟AR相機(預設為後鏡頭):
// (1) 開啟相機
內容.camera = .spatialTracking // 開啟AR相機

此時,底層會自動產生一個 SpatialTrackingSession (空間追蹤任務)物件,用來控制 AR 任務階段,若要變更任務階段的設定,可以用 SpatialTrackingSession.Configuration() 變更設定:
// (2) 修改設定選項
let AR設定 = SpatialTrackingSession.Configuration(
tracking: [.camera, .world, .plane, .object, .image, .face, .body],
sceneUnderstanding: [.occlusion, .shadow, .collision, .physics],
camera: .front)

SpatialTrackingSession.Configuration 物件包含三個參數:
  1. tracking: 要錨定追蹤的特徵點種類,完整選項包含 [.camera, .world, .plane, .object, .image, .face, .body, .hand, .accessory],共9個選項,可複選,前6項為 iOS 預設值,.hand 與 .accessory 僅限於 visionOS。
  2. sceneUnderstanding: 場景分析選項,含 [.occlusion, .shadow, .collision, .physics],可複選,預設全部開啟
  3. camera: 實體鏡頭,單選 .back 或 .front

只有開啟適當的錨定追蹤選項(tracking:)後,對應的錨點個體才有機會錨定實景。

sceneUnderstanding 參數可省略,表示4個選項全部關閉(空集合)。

對初學者來說,當然可以開啟所有選項,若有硬體不支援的,自然會過濾掉。不過,開啟越多,需要的效能也越多(每1/60秒會追蹤運算一次),因此在實際應用時,記得將不必要的選項移除。

設定好之後,必須再執行以下指令,才會生效:
// (3)設定生效
let AR任務階段 = SpatialTrackingSession()
if let 回傳結果 = await AR任務階段.run(AR設定) {
print("不支援:", 回傳結果)
} else {
print("AR設定正常")
}

每個 RealityView 只會有一個 SpatialTrackingSession 任務階段,因此,若重複設定,後面的設定會蓋掉前面的設定,任務階段會重新開始。

第2行「await AR任務階段.run(AR設定)」會傳回設定結果,若有硬體不支援的選項,則列印出來,否則就列印"AR設定正常"。

若要關閉 AR 相機(只顯示虛擬場景),可執行:
// (4) 停止AR任務,關閉相機
let AR任務階段 = SpatialTrackingSession()
await AR任務階段.stop()

在以下範例程式,我們利用 SpatialTrackingSession.Configuration() 切換前後鏡頭,後鏡頭錨定平面,放置一個 Y Bot 機器人模型;前鏡頭錨定人臉,放置一個透明氣泡。

執行結果如下:

從以上執行過程,可以觀察到:
  • 在錨定前(機器人尚未顯示),所有錨點個體、模型個體已先準備好,放在 RealityView 內容中,錨點與其子個體都是動態顯示。

  • 主控台顯示後鏡頭不支援人體追蹤(.body)與場景遮擋(.occlusion)等效果。

  • 每次切換,AR任務階段會重啟,再次切換到後鏡頭時,機器人錨定位置未必相同。

  • 只有前鏡頭會錨定人臉(.face),用2D人臉照片也可錨定。

  • 前鏡頭畫面會左右鏡像,後鏡頭正常。

一般情況下,虛擬物件總是在實景之前,即使實景中的物體更靠近鏡頭,除非啟用場境分析(sceneUnderstanding:)的遮擋[.occlusion]效果。

場景分析(sceneUnderstanding:)與人臉(.face)、人體(.body)等追蹤背後是透過人工智慧,需要硬體配備A12或以上晶片;若啟用人體追蹤(.body),只能額外再加一個選項。

對擴增實境程式而言,鏡頭帶進來的實景只是一幀幀2D照片(每秒60幀),用來當作 RealityView 的背景。也就是說,底層 ARKit 並不會建構實景的3D模型,其追蹤的世界座標僅僅是估算X/Y/Z軸位移與角度而已,總帶有誤差,錨定位置並不會很精確。

人臉辨識是結合人工智慧,也就是第5單元 Vision 框架的功能。

以下是完整範例程式:
// 6-16c 臉部錨點:前後鏡頭切換
// Created by Heman Lu, 2025/10/16
// Minimum Requirement: iPadOS 18 + Swift Playground 4.6 (AR)
// Alternative: macOS 15 + Swift Playground 4.6 (non-AR)

import SwiftUI
import RealityKit

struct 擴增實境: View {
@State var 鏡頭切換 = false // false: 後鏡頭; true: 前鏡頭

var body: some View {
RealityView { 內容 in
// (1) 平面錨點 + 機器人模型
let 平面錨點 = AnchorEntity(plane: .any) // 任何平面
平面錨點.name = "平面錨點"
內容.add(平面錨點)

if let 機器人模型 = try? await ModelEntity(named: "Y Bot.usdz") {
機器人模型.name = "機器人模型"
機器人模型.scale = [0.1, 0.1, 0.1]
平面錨點.addChild(機器人模型) // 綁定錨點
}

// (2) 臉部錨點 + 透明氣泡
let 臉部錨點 = AnchorEntity(.face)
臉部錨點.name = "臉部錨點"
內容.add(臉部錨點)

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

let 氣泡 = MeshResource.generateSphere(radius: 0.1)
let 氣泡模型 = ModelEntity(mesh: 氣泡, materials: [玻璃材質])
氣泡模型.name = "氣泡模型"
臉部錨點.addChild(氣泡模型)

print(內容.entities)

#if targetEnvironment(macCatalyst)
// for Mac + Swift Playground
內容.camera = .virtual // 只顯示虛擬空間
內容.add(座標軸(0.7))
#else // for iPad + Swift Playground
內容.camera = .spatialTracking // 開啟AR相機(預設為後鏡頭)
let AR設定 = SpatialTrackingSession.Configuration(
tracking: [.camera, .world, .plane, .object, .image, .face, .body],
sceneUnderstanding: [.shadow, .occlusion, .collision, .physics],
camera: 鏡頭切換 ? .front : .back
)
let AR任務階段 = SpatialTrackingSession()
if let 回傳結果 = await AR任務階段.run(AR設定) {
print("不支援:", 回傳結果)
} else {
print("AR設定正常")
}
#endif
} update: { 內容 in
#if !targetEnvironment(macCatalyst)
print("Update: \(Date.now)")
let AR設定 = SpatialTrackingSession.Configuration(
tracking: [.camera, .world, .plane, .object, .image, .face, .body],
sceneUnderstanding: [.shadow, .occlusion, .collision, .physics],
camera: 鏡頭切換 ? .front : .back
)
Task {
let AR任務階段 = SpatialTrackingSession()
if let 結果 = await AR任務階段.run(AR設定) {
print("不支援:", 結果)
} else {
print("AR設定正常")
}
}
#endif
}
#if targetEnvironment(macCatalyst)
.realityViewCameraControls(.orbit)
#endif
.overlay(alignment: .bottom) {
Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90")
.font(.system(size: 36))
.foregroundStyle(鏡頭切換 ? .blue : .primary)
.padding()
.onTapGesture {
鏡頭切換.toggle()
print("\n鏡頭切換為:", 鏡頭切換 ? "前鏡頭" : "後鏡頭")
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(擴增實境())

💡註解
  1. SpatialTrackingSession 與第3單元 URLSession 類似,在此 Session 是指一個執行時間稍長或包含多個工作的任務,中文可譯為「任務階段」。
  2. 實際上,AR 任務階段是由底層 ARKit 的 ARSession 控制,RealityView 透過 SpatialTrackingSession 設定 ARSession。
  3. 作業1:在執行影片中,觀察臉部錨點的透明氣泡,上面所反射的倒影是來自實景還是虛景?
  4. 作業2:請檢查主控台輸出,共有幾個個體(Entity)?
  5. 作業3:請增加一個 SwiftUI 按鈕,用來開、關 AR 相機。

附錄:主控台輸出
RealityViewEntityCollection(entity: ▿ '' : AnchorEntity, children: 2
⟐ AnchoringComponent
⟐ Transform
⟐ SynchronizationComponent
▿ '平面錨點' : AnchorEntity, children: 1
⟐ AnchoringComponent
⟐ Transform
⟐ SynchronizationComponent
▿ '機器人模型' : ModelEntity
⟐ SynchronizationComponent
⟐ ModelComponent
⟐ SkeletalPosesComponent
⟐ Transform
▿ '臉部錨點' : AnchorEntity, children: 1
⟐ AnchoringComponent
⟐ Transform
⟐ SynchronizationComponent
▿ '氣泡模型' : ModelEntity
⟐ Transform
⟐ SynchronizationComponent
⟐ ModelComponent
)
不支援: anchor: [body], sceneUnderstanding: [occlusion, collision, physics]
Update: 2025-10-23 04:12:42 +0000
不支援: anchor: [body], sceneUnderstanding: [physics, collision, occlusion]

鏡頭切換為: 前鏡頭
Update: 2025-10-23 04:13:00 +0000
不支援: anchor: [object, body, plane, image], sceneUnderstanding: [occlusion, physics, shadow, collision]

鏡頭切換為: 後鏡頭
Update: 2025-10-23 04:13:18 +0000
不支援: anchor: [body], sceneUnderstanding: [physics, occlusion, collision]
  • 8
內文搜尋
X
評分
評分
複製連結
請輸入您要前往的頁數(1 ~ 8)
Mobile01提醒您
您目前瀏覽的是行動版網頁
是否切換到電腦版網頁呢?