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 機器人原始設計的座標尺度為公分。如果用反矩陣乘積,將全域坐標投影到機器人局部座標,應該也會自動轉換,參考補充(27) 子個體與局部座標
  4. 作業1:將頭部約束條件改為盯著小球轉動。
  5. 作業2:請將小球加上動畫,在小範圍內來回移動,讓機器人雙手自動跟隨(參考下圖)。

文章分享
評分
評分
複製連結
請輸入您要前往的頁數(1 ~ 7)

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