• 6

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

6-13c 向心力場 RadialForceEffect

向心力場(RadialForceEffect)特色在於力的方向總是朝力場中心(也就是附掛元件的個體座標原點),強度會依距離而變化,距離越遠,拉力越強,就像彈簧的虎克定律,可想成力場中心與每個動態本體之間,有一個彈簧相連接。

Radial 是放射狀、輻射狀的意思。向心力場的力其實有兩個方向,若將強度(strength)改成負值,向心力就變成離心力,往外放射,束縛力場轉為排斥力場,讓所有動態本體遠離力場中心。

實際使用時,向心力場有兩個必要參數,除了第一個強度(strength)之外,並不需要指定方向。第二個參數是 restDistance,表示彈簧不受力(或不做功、休息 rest)時的長度:
let 向心力 = RadialForceEffect(strength: 5.0, restDistance: 0.0)
當 restDistance = 0.0,動態本體會往力場中心來回震盪,初始距離越遠,震盪越快。執行結果如下:

這樣的來回震盪,其實就是高中物理學過的簡諧運動。

若 restDistance 大於 0,例如 restDistance = 1.0:
let 向心力 = RadialForceEffect(strength: 5.0, restDistance: 1.0)

此時可想像彈簧不受力時的長度為1米,一端固定在力場中心,另一端牽引著動態本體。彈簧拉長時會產生拉力(拉向力場中心),壓縮時會有推力(推離力場中心),於是動態本體將會在距離中心1米的平衡點來回震盪,效果如下:

利用這樣的特性,本節範例做出12個氣泡,散佈在不同位置,讓他們在距離中心1米左右來回震盪,並增加一個「開始/暫停」開關,以便控制與觀察:

有沒有看出來,一開始,每個震盪幅度比較大的氣泡,不管遠近,幾乎都會同時到達力場中心而發生碰撞,這是為什麼呢?答案請參考註解(作業4)。

完整範例程式如下:
// 6-13c 向心力場
// Created by Heman Lu on 2025/07/15
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

struct 物理力場: View {
var 隨機數: Float { .random(in: -1.0 ..< 1.0) }
@State var 暫停 = true

var body: some View {
RealityView { 內容 in
內容.add(座標軸())

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

// 氣泡模型,設定為動態(.dynamic)才會受力場影響
let 氣泡 = MeshResource.generateSphere(radius: 0.1)
let 氣泡模型 = ModelEntity(mesh: 氣泡, materials: [玻璃材質])
氣泡模型.name = "氣泡"
氣泡模型.physicsBody = .init(mode: .dynamic)
氣泡模型.physicsBody?.isAffectedByGravity = false
氣泡模型.generateCollisionShapes(recursive: false)

// 複製12個氣泡模型
for i in 0 ..< 12 {
let 模型 = 氣泡模型.clone(recursive: false)
模型.position = [隨機數, 隨機數, 隨機數] * 2.0
內容.add(模型)
}

// 增加力場
let 向心力 = RadialForceEffect(strength: 5.0, restDistance: 1.0)
let 力場 = ForceEffect(effect: 向心力)

let 力場元件 = ForceEffectComponent(
effects: [力場],
simulationState: .pause) // 初始狀態:暫停

let 力場中心 = Entity()
力場中心.name = "力場"
力場中心.components.set(力場元件)
內容.add(力場中心)

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
} update: { 內容 in
for 個體 in 內容.entities where 個體.name == "力場" {
個體.components[ForceEffectComponent.self]?.simulationState = 暫停 ? .pause : .start
}
}
.realityViewCameraControls(.orbit)
.overlay(alignment: .bottom) {
Button(暫停 ? "開始" : "暫停", systemImage: 暫停 ? "play.fill" : "pause.fill") {
暫停.toggle()
}
.buttonStyle(.borderedProminent)
.padding()
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(物理力場())

這裡面有一行程式值得注意:
模型.position = [隨機數, 隨機數, 隨機數] * 2.0
等號右邊,並不是任何矩陣都可以和數字相乘,只有 SIMD 類型才這麼方便,這行程式將 -1 ..< 1 之間的隨機數範圍擴大到 -2 ..< 2。另外,「隨機數」用 “computed property” 定義,所以每次取值都會不同。

最後還有一行用到新語法:
個體.components[ForceEffectComponent.self]?.simulationState = 暫停 ? .pause : .start
「個體.components」其實是一個集合(而非陣列)— 個體已載入元件的集合,要取集合中某個元素,用法為「個體.components[ForceEffectComponent.self]」,[ ] 裡面是索引,因為元件是動態載入,不一定會有這個元素,所以傳回 Optional 類型,必須加 ? 才能進一步取得元素的屬性。

此處元件集合 components 是以「元件類型」為索引,每一種元件類型最多只會有一個元素,不會重複,例如:
個體.components.set(力場元件1)
個體.components.set(粒子元件)
...
個體.components.set(力場元件2) // 力場元件1會被取代
這個用法和陣列的 append() 完全不同。

還記得集合、陣列與字典的差異嗎?請參考第5單元語法說明:字典(Dictionary)資料類型,每個集合的索引方式都可能不同。集合是比較少見的組合類型,這應該是我們第一次使用集合索引。

💡註解
  1. RadialForceEffect 照字面意思也可譯為「輻射力場」,不過這樣容易想成往外輻射,也就是強度為負值時(離心力)的方向,與習慣不符。
  2. RadialForceEffect 同樣有第3個屬性 forceMode,用法參考上一節(6-13b)。
  3. 作業1:請修改 RadialForceEffect(strength: 5.0, restDistance: 0.0),觀察不同的 restDistance 數值有何影響。
  4. 作業2:請將力場強度改為負值(如strength: -5.0),觀察效果有何變化。
  5. 作業3:請改用固定強度的 ConstantRadialForceEffect,看執行結果有何不同。
  6. 作業4:簡諧運動的週期跟什麼有關?答案是彈力係數及物體質量。向心力場的強度(strength)相當於彈力係數,而物理本體預設質量均為1Kg,因此力場中每個本體的震盪週期都相同。請改變部分氣泡的質量,觀察週期是否變化。
6-13d 擾流力場 TurbulenceForceEffect

Turbulence 意思是動亂、擾動,在物理學稱為擾流、湍流、紊流、亂流。搭過飛機的人一定都聽過機長廣播:”We're experiencing some turbulence”,我們遇到亂流,請繫好安全帶。

在 RealityKit 擾流力場(TurbulenceForceEffect)中,力的大小與方向都是隨機的,隨機範圍與物體速度有關,速度越大,隨機範圍越大,若物體完全靜止,擾流強度也降為0。所以,只有運動中的物體,才會受到擾流力場的影響。

因此,擾流力場很少單獨使用,通常配合其他力場讓動態本體先動起來,然後擾流力場才能派上用場。

TurbulenceForceEffect 的必要參數有3個:
let 擾流 = TurbulenceForceEffect(
strength: 5.0,
smoothness: 0.6, // 0 - maximum noise; 1.0 no noise
speed: 4.0) // velocity variation
  1. 強度(strength):參數值是基準,實際強度隨物體速度而變,速度越快,強度也隨著提高;
  2. 平滑性(smoothness):隨機性的相反,介於0~1之間,0 代表最大的隨機性,力的強度與方向變化最大,1.0 代表完全沒變化;
  3. 隨機速度(speed):隨時間增減隨機範圍,若 speed = 0,表示隨機範圍不變化。
除了這3個必要參數之外,當然還有額外的力場模式(forceMode)屬性,用法請參考6-13b。

用上一節的範例程式為基礎,在向心力場中加入擾流力場,效果會如何呢?向心力場會將12個氣泡束縛在一定的空間範圍內,擾流力場則增加隨機運動,結合起來效果如下:
看起來像什麼呢?筆者感覺像一群蜻蜓在眼前毫無規律的亂飛。

為了進一步增加隨機性,我們讓每個氣泡「質量」也改成隨機數,將向心力場的運動週期打亂,以降低運動規律。

完整範例程式如下,其實增加沒幾行程式,但執行效果與上一節完全不同:
// 6-13d 向心力場 + 擾流力場
// Created by Heman Lu on 2025/07/18
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

struct 物理力場: View {
var 隨機數: Float { .random(in: -1.0 ..< 1.0) }
@State var 暫停 = true

var body: some View {
RealityView { 內容 in
內容.add(座標軸())

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

// 氣泡模型,設定為動態(.dynamic)才會受力場影響
let 氣泡 = MeshResource.generateSphere(radius: 0.1)
let 氣泡模型 = ModelEntity(mesh: 氣泡, materials: [玻璃材質])
氣泡模型.name = "氣泡"
氣泡模型.physicsBody = .init(mode: .dynamic)
氣泡模型.physicsBody?.isAffectedByGravity = false
氣泡模型.generateCollisionShapes(recursive: false)

// 複製12個氣泡模型
for i in 0 ..< 12 {
let 模型 = 氣泡模型.clone(recursive: false)
模型.position = [隨機數, 隨機數, 隨機數]
模型.physicsBody?.massProperties = .init(mass: 隨機數 + 1.5)
print("#\(i) 質量 =", 模型.physicsBody?.massProperties.mass ?? 0)
內容.add(模型)
}

// 增加力場
let 向心力 = RadialForceEffect(strength: 5.0, restDistance: 1.0)
let 力場1 = ForceEffect(effect: 向心力)

let 擾流 = TurbulenceForceEffect(
strength: 5.0,
smoothness: 0.6, // 0 - maximum noise; 1.0 no noise
speed: 4.0) // velocity variation
let 力場2 = ForceEffect(effect: 擾流)

let 力場元件 = ForceEffectComponent(
effects: [力場1, 力場2],
simulationState: .pause)

let 力場中心 = Entity()
力場中心.name = "力場"
力場中心.components.set(力場元件)
內容.add(力場中心)

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
} update: { 內容 in
for 個體 in 內容.entities where 個體.name == "力場" {
個體.components[ForceEffectComponent.self]?.simulationState = 暫停 ? .pause : .start
}
}
.realityViewCameraControls(.orbit)
.overlay(alignment: .bottom) {
Button(暫停 ? "開始" : "暫停", systemImage: 暫停 ? "play.fill" : "pause.fill") {
暫停.toggle()
}
.buttonStyle(.borderedProminent)
.padding()
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(物理力場())

💡註解
  1. 沒看過蜻蜓亂飛的同學,可以參考香港網友影片台視新聞
補充(26):蝴蝶飛翔

上一節範例程式中,將氣泡模型換成蝴蝶或其他飛蟲,在擾流力場+向心力場之下,就會產生非常真實的運動效果。


蝴蝶模型取自 Apple WWDC 2024,下載其範例程式解壓縮之後,搜尋 "usdz",就會找到許多模型,其中蝴蝶模型為 "butterfly_fly_cycle.usdz",內含拍動翅膀的動畫。
6-13e 渦流力場VortexForceEffect

本課介紹 RealityKit 內建的6個力場,最後一個是渦流力場,其實在上一課粒子系統曾經看過,用於6-12e龍捲風特效,本節渦流力場與粒子系統中所用的,兩者雖然是不同物件,但背後原理是一樣的。

以下範例用定向力場來模擬上升氣流,再加上渦流力場,兩者結合就可形成龍捲風特效:
// 增加力場
let 浮力 = ConstantForceEffect(strength: 1.0, direction: [0, 1, 0])
let 力場1 = ForceEffect(effect: 浮力)

let 渦流 = VortexForceEffect(strength: 5.0, axis: [0, 1, 0])
let 力場2 = ForceEffect(effect: 渦流)

let 力場元件 = ForceEffectComponent(
effects: [力場1, 力場2],
simulationState: .pause)
注意渦流力場(VortexForceEffect)第二個參數是軸線(axis)方向,在此設為正Y軸方向 [0, 1, 0],與浮力的方向一致,這樣就會上升+旋渦同時發生。

1996年的電影Twister《龍捲風》,是筆者看過有關龍捲風(颶風、颱風)氣象知識最棒的電影,片尾高潮是主角終於成功讓一整桶兵乓球大小的感測器吸入龍捲風之中,透過感測器蒐集的數據,即時呈現出龍捲風的3D模型。

本節範例我們用1024個氣泡模型來代表這些感測器,在力場內被吸入風暴中,來模擬這一幕場景,當然,沒有電影那麼逼真,但有點味道了:

完整範例程式如下:
// 6-13e 渦流力場
// Created by Heman Lu on 2025/07/25
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

struct 物理力場: View {
var 隨機數: Float { .random(in: -1.0 ..< 1.0) }
@State var 重來 = true

var body: some View {
RealityView { 內容 in
內容.add(座標軸())

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

// 氣泡模型,設定為動態(.dynamic)才會受力場影響
let 氣泡 = MeshResource.generateSphere(radius: 0.02)
let 氣泡模型 = ModelEntity(mesh: 氣泡, materials: [玻璃材質])
氣泡模型.name = "氣泡"
氣泡模型.physicsBody = .init(mode: .dynamic)
氣泡模型.physicsBody?.isAffectedByGravity = false
氣泡模型.generateCollisionShapes(recursive: false)

// 複製1024個氣泡模型
for i in 0 ..< 1024 {
let 模型 = 氣泡模型.clone(recursive: false)
模型.position = [隨機數, 隨機數, 隨機數] * 0.5
模型.physicsBody?.massProperties = .init(mass: 隨機數 + 1.5)
print("#\(i) 質量 =", 模型.physicsBody?.massProperties.mass ?? 0)
內容.add(模型)
}

// 增加力場
let 浮力 = ConstantForceEffect(strength: 1.0, direction: [0, 1, 0])
let 力場1 = ForceEffect(effect: 浮力)

let 渦流 = VortexForceEffect(strength: 5.0, axis: [0, 1, 0])
let 力場2 = ForceEffect(effect: 渦流)

let 力場元件 = ForceEffectComponent(
effects: [力場1, 力場2],
simulationState: .pause)

let 力場中心 = Entity()
力場中心.name = "力場"
力場中心.components.set(力場元件)
內容.add(力場中心)

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
} update: { 內容 in
for 個體 in 內容.entities where 個體.name == "力場" {
個體.components[ForceEffectComponent.self]?.simulationState = 重來 ? .pause : .start
}
for 個體 in 內容.entities where 個體.name == "氣泡" {
if 重來 {
個體.position = [隨機數, 隨機數, 隨機數] * 0.5
}
if let 模型 = 個體 as? ModelEntity {
模型.physicsBody?.mode = 重來 ? .kinematic : .dynamic
}
}
}
.realityViewCameraControls(.orbit)
.overlay(alignment: .bottom) {
Button(重來 ? "開始" : "重來", systemImage: 重來 ? "play.fill" : "arrow.trianglehead.clockwise") {
重來.toggle()
}
.buttonStyle(.borderedProminent)
.padding()
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(物理力場())

💡註解
  1. 對1996年”Twister”《龍捲風》電影有興趣的同學,可參考Youtube影評,在5:50左右介紹測量龍捲風的感測器”Dorothy”。
  2. 2024年推出續集”Twisters”《龍捲風暴》,也值得一看。
  3. 6個內建力場中,本課並未正式介紹 ConstantRadialForceEffect,此與向心力場 RadialForceEffect 類似,主要差別是力的強度恆定,與距離無關,因此簡諧運動會變成單純的來回震盪。讀者可自行嘗試。
  4. 若從上往下看,渦流的旋轉方向是逆時針還是順時針呢?如何改變旋轉方向?
  5. 台灣的颱風與美國的颶風,旋轉方向會一樣嗎?
第14課 物理關節(PhysicsJoint)

若將本單元到目前為止,學過的 RealityKit 重要觀念做成心智圖,大致如下:


其中「座標變換」是後面這幾課的核心基礎,從簡單的位移、旋轉、縮放,到動作動畫、物理模擬、粒子系統、力場、關節等,背後都須透過座標變換,重要性不言而諭,難怪在 RealityKit ECS 體系中,座標變換(Transform)是所有個體的兩個必備元件之一。

本課物理關節是物理模擬的延伸之一,應用場合非常多,例如人體或動物的骨骼關節、門窗活動的轉軸、抽屜開關的承架、車輪的旋轉軸承…等。

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

物理關節非常有用,但由於物理關節元件(PhysicsJointsComponent)去(2024)年才推出,參考資料非常少,本課是筆者所知,網路上(包括英文網站)第一篇對物理關節詳細介紹的入門文章,筆者花了非常多時間摸索,可說費盡千辛萬苦,想學物理關節的同學,得好好珍惜,註解裡面的作業也不可錯過。

不過在正式開始之前,得先提醒一下,物理關節的「眉角」甚多,比起粒子系統或物理力場困難一些,需要細心與耐心才能學好。

6-14a 可轉動關節(PhysicsRevoluteJoint)

關節(Joint)是兩個骨頭連接的地方,當一根骨頭移動時,會帶動另一根骨頭;關節的另一個作用是限制骨頭的活動自由度,例如人體手臂只能往內彎,不能往外彎;貓頭鷹的脖子能轉180度,但是人的脖子最多只能轉90度,這都是因為關節的限制。

要使用物理關節,必須載入 PhysicsJointsComponent 元件,但不必所有個體都載入,只要場景中有任一個個體載入元件就行,類似上一課物理力場的情況。

另外還有一個前置條件,要用物理關節連接的個體,須先設定物理本體,也就是載入物理本體元件(PhysicsBodyComponent)以及碰撞偵測元件(CollisionComponent)兩個元件。物理本體的模式可以是動態(dynamic)、靜態(static)、可動(kinematic)三者皆可。標準寫法如下:
模型.physicsBody = .init(mode: .dynamic)       // 載入PhysicsBodyComponent
模型.generateCollisionShapes(recursive: false) // 載入CollisionComponent

物理關節相關物件如下圖:

物理關節(PhysicsJoint)可將兩個個體連結在一起,個體的連結位置稱為「幾何連接點」(GeometricPin),Pin 本意是大頭針或圖釘,常用在地圖上標定位置,在此是指連結位置的點座標(採用個體原生座標系)。

物理關節(PhysicsJoint)還可設定限制條件,限制個體活動的自由度(例如只能在Y-Z垂直面運動)、位移距離或轉動角度的範圍等等。

具體用法可分為三步,從上圖最右邊開始,第一步先設定兩個個體的連接點,第二步組成關節並設定限制條件,第三步指定一個個體載入元件。

其中第二步物理關節(PhysicsJoint)是個規範(protocol),目前遵此規範的有6種內建關節:

1. PhysicsRevoluteJoint 可轉動關節(類似鉸鏈,只有一個旋轉方向)
2. PhysicsPrismaticJoint 可滑動關節(類似線性滑軌)
3. PhysicsSphericalJoint 球型關節(X/Y/Z三軸均可旋轉)
4. PhysicsDistanceJoint 遠距關節(可限制兩連接點之間的距離範圍)
5. PhysicsFixedJoint 固定關節(或稱鎖死關節)
6. PhysicsCustomJoint 可定製關節(6個自由度 — X/Y/Z位移+X/Y/Z旋轉,均可調整)

本節先試試可轉動關節(PhysicsRevoluteJoint),藉此熟悉一下物理關節的特性。

一開始,先準備兩個模型個體,分別設定好物理本體與碰撞偵測:
// 準備工作:設定2個模型個體,並啟用物理本體與碰撞偵測
let 上臂 = ModelEntity(mesh: .generateCylinder(
height: 0.5,
radius: 0.05))
上臂.name = "上臂"
上臂.model?.materials = [SimpleMaterial()]
上臂.position.y = 0.3
上臂.physicsBody = .init(mode: .static)
上臂.generateCollisionShapes(recursive: false)
內容.add(上臂)

let 下臂 = 上臂.clone(recursive: false)
下臂.name = "下臂"
下臂.physicsBody = .init(mode: .dynamic)
內容.add(下臂)
這裏用兩個細長圓柱體模擬手臂,長度為0.5公尺,上臂設為靜態本體(.static),下臂為動態本體(.dynamic),兩者位置重疊。若在此執行程式,沒有後續物理關節的話,下臂會受到引力影響而往下掉。

接下來第一步,先設定幾何連接點(GeometricPin),每個個體(不論是否為模型個體)都有 pins 屬性,可用 set() 增加連接點,最少需要兩個參數:名稱(named)、位置(position):
// (1) 設定兩個個體的連接點
let 接點0 = 上臂.pins.set(
named: "上臂底端",
position: [0, -0.25, 0]
)
let 接點1 = 下臂.pins.set(
named: "下臂頂端",
position: [0, 0.25, 0]
)
pins 是幾何連接點的集合,用名稱字串當索引,例如「上臂.pins[”上臂底端”]」就可取用「接點0」,未來可進一步修改連接點屬性,連接點共有8個屬性,下一節再仔細討論。

上臂的連接點設在 [0, -0.25, 0],這個座標不是全域坐標,而是個體的原生座標系。要注意每個模型個體的原生座標原點與X/Y/Z方向可能不同,以圓柱體而言,這個連接點位於幾何中心的下方0.25公尺處。

第二步,設定物理關節:
// (2) 設定物理關節
let 肘關節 = PhysicsRevoluteJoint(pin0: 接點0, pin1: 接點1)

物理關節最少需要兩個幾何連接點當作參數,設定好之後,這兩個連接點將會重合在一起,pin0 的個體不動,pin1 的個體平移以對齊位置。因此原本重疊的兩個個體,下臂會往下移,讓下臂頂端與上臂底端重疊。

在此要特別注意,pin0 與 pin1 兩個參數的作用並不相同,pin0 連接點通常比較不受力,可設為 static 或 kinematic,而 pin1 活動力較強,可設定為 dynamic。但 pin0 與 pin1 也可同時設為 dynamic,這時建議將 pin0 的個體質量設定大一點,以減緩受力。

這種情況類似手臂,上臂比較不靈活,設為 pin0,下臂活動範圍較大,設為 pin1。

物理關節還能設定限制條件,後面會詳細說明。

第三步,啟動物理關節模擬,只需場景中任何一個個體加入物理關節元件(PhysicsJointsComponent)即可,此元件有個屬性 .joints (物理關節陣列),要將第二步做好的關節加進來。

在此,我們仿照物理力場的做法,用一個隱形的個體 “Entity()”,來容納物理關節元件:
// (3) 加入物理關節元件
let 隱藏個體 = Entity()
隱藏個體.name = "隱藏個體"
內容.add(隱藏個體)

var 關節元件 = PhysicsJointsComponent()
關節元件.joints.append(肘關節)
隱藏個體.components.set(關節元件)

第三步還有另一種寫法:
// (3) 另一種寫法(不需要「隱藏個體」)
do {
try 肘關節.addToSimulation()
} catch {
print("有問題:\(error)")
}
這種寫法不需要額外的個體,直接呼叫物理關節的物件方法 addToSimulation() 即可。這個方法會將物理關節元件附掛在最上層的個體(即「根節點」)。

這個寫法另外一個好處是,如果前面步驟有任何錯誤(這是「眉角」之一),例如物理模擬沒有準備好,則會在 try 時跳離,並在主控台列印錯誤訊息。

這樣物理關節就設定好了。若在此處執行程式,會發現上臂、下臂兩個圓柱體上下連接在一起,除此之外,什麼也沒有發生。所以,接下來,我們要準備一些「外力」,讓手臂動起來:
// 加入斜向力場
let 定向力 = ConstantForceEffect(
strength: 10,
direction: [1, 1, 1])
let 定向力場 = ForceEffect(effect: 定向力)
let 力場元件 = ForceEffectComponent(effect: 定向力場)
隱藏個體.components.set(力場元件)
利用上一課剛學到的定向力場,做一個斜向 [1, 1, 1] 的力場,附掛到隱藏個體中。

這個方向的力場,相當於在 +X軸、+Y軸、+Z軸三個維度各施一個外力到個體上,不過,令人驚奇的「眉角」又來了,下臂只有在 Y-Z 垂直面上轉動,並不是斜向轉動:

原來,可轉動關節(PhysicsRevoluteJoint)只有一個自由度,就是繞X軸轉動,模擬的是「鉸鏈」關節。所以可轉動關節只能以X軸為軸心,在 Y-Z 垂直面上轉動。

但是若只能繞X軸轉動,似乎通用性不夠好,能夠改變旋轉方向,例如改成繞Y軸水平轉動嗎?下一節再繼續討論。

從執行結果可看得出來,連接上臂與下臂的「肘關節」並沒有實體,只是一個位置,在此位置模擬物理關節的功能。

此外,下臂並不是一直往上轉,而是上上下下,最後停在45度左右。為什麼呢?一方面因為下臂受到重力影響,與定向力場共同作用,才會上上下下來回轉動,另外,物理模擬預設會有阻力與摩擦力,所以一段時間後會逐漸靜止。

本節完整範例程式如下:
// 6-14a 物理關節(PhysicsJoint)
// Created by Heman Lu on 2025/07/29
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

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

// 準備工作:設定2個模型個體,並啟用物理本體與碰撞偵測
let 上臂 = ModelEntity(mesh: .generateCylinder(
height: 0.5,
radius: 0.05))
上臂.name = "上臂"
上臂.model?.materials = [SimpleMaterial()]
上臂.position.y = 0.3
上臂.physicsBody = .init(mode: .static)
上臂.generateCollisionShapes(recursive: false)
內容.add(上臂)

let 下臂 = 上臂.clone(recursive: false)
下臂.name = "下臂"
下臂.physicsBody = .init(mode: .dynamic)
內容.add(下臂)

// (1) 設定兩個個體的連接點
let 接點0 = 上臂.pins.set(
named: "上臂底端",
position: [0, -0.25, 0]
// orientation: .init(angle: .pi * 0.5, axis: [0, 1, 0])
)
let 接點1 = 下臂.pins.set(
named: "下臂頂端",
position: [0, 0.25, 0]
)

// (2) 設定物理關節
let 肘關節 = PhysicsRevoluteJoint(pin0: 接點0, pin1: 接點1)

// (3) 加入物理關節元件
let 隱藏個體 = Entity()
隱藏個體.name = "隱藏個體"
內容.add(隱藏個體)

var 關節元件 = PhysicsJointsComponent()
關節元件.joints.append(肘關節)
隱藏個體.components.set(關節元件)

// (3) 另一種寫法(不需要「隱藏個體」)
// do {
// try 肘關節.addToSimulation()
// } catch {
// print("有問題:\(error)")
// }

// 加入斜向力場
let 定向力 = ConstantForceEffect(
strength: 10,
direction: [1, 1, 1])
let 定向力場 = ForceEffect(effect: 定向力)
let 力場元件 = ForceEffectComponent(effect: 定向力場)
隱藏個體.components.set(力場元件)

print(內容.entities)

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(物理關節())

💡註解
  1. 注意物件名稱的單複數,例如 PhysicsJointsComponent 元件可包含複數關節,故用”Joints”;PhysicsJoint 以及內建關節是單數,用”Joint”;每個個體可包含多個連接點,所以「個體.pins」是複數。
  2. 所有設定好的關節,要集中放在元件的屬性 joints (陣列)裡面,而不是在某個個體中,因此,「個體.pins」與「物理關節元件.joints」要分清楚。
  3. 作業1:連接點位置可以超出個體的範圍嗎?例如將接點0位置改為[0, -0.3, 0],接點1改到[0, 0.3, 0]會如何呢?若分別改成[0, -0.2, 0], [0, 0.2, 0]又是如何?請動手試試看。
  4. 作業2:上臂、下臂的物理本體模式可以隨便組合嗎?請試試 .static, .kinematic, .dynamic 任意組合,看看結果如何。
  5. 作業3:若將重力影響取消,例如加入「下臂.physicsBody?.isAffectedByGravity = false」,會有何差異?
  6. 作業4:定向力場的方向[1, 1, 1]實際有作用的是哪個方向?請修改向量值,驗證你的想法。
  7. 作業5:物理本體有兩個阻尼屬性:線性阻尼 linearDamping 與轉動阻尼 angularDamping,若加一行程式「下臂.physicsBody?.angularDamping = 0」,執行結果會有何不同?
  8. 作業6:若一開始就將下臂旋轉90度,例如「下臂.orientation = .init(angle: .pi * 0.5, axis: [1, 0, 0])」,對執行結果有影響嗎?
6-14b 幾何連接點參數(GeometricPin)

在 RealityKit 當中,要將兩個個體「綁」在一起,目前有兩種方法,方法一在第6課6-6b用過,即「父個體.addChild(子個體)」,此時子個體的座標系統是從父個體繼承下來,若父個體座標有任何變化(平移、旋轉、縮放),子個體也會跟著改變。

方法二是本課的物理關節,兩個個體之間沒有父子關係,各自的座標變換看似獨立運作,但其實受到關節的制約,一是兩個個體的幾何連接點(GeometricPin)必須重合或距離在一定範圍內,二是座標變換的「自由度」會受到關節屬性所限制。

所謂「自由度」(Degree of Freedom, DoF)是指個體能否在X/Y/Z三軸任意方向平移,或以X/Y/Z任意方向為軸心旋轉,一共有6個自由度,縮寫為 6DoF,這是3D空間中的運動物體,在沒有約束下所能擁有的自由度。例如飛行無人機,在操控上就會支援這6個自由度。

上一課提到可旋轉關節(PhysicsRevoluteJoint)只能以X軸為軸心旋轉,也就是限縮為一個自由度。

這裏的X軸並不是全域座標的X軸,而是「幾何連接點」的X軸,連接點內部有個座標變換,可藉此來變更旋轉方向。例如,要將原來垂直面旋轉,改成水平面旋轉,可以在上臂的「接點0」增加一個參數「旋轉面向」 “orientation”:
let 接點0 = 上臂.pins.set(
named: "上臂底端",
position: [0, -0.3, 0],
orientation: .init(angle: .pi * 0.5, axis: [0, 0, 1])
)

這個參數會將「接點0」繞Z軸轉90度,旋轉之後帶動下臂跟著旋轉,之後繞著下臂「接點1」的X軸旋轉,就會變成水平旋轉。

這種情況就類似鉸鏈,鉸鏈有兩片鎖螺絲的鐵片,原來方向是上下,現在改成左右,鉸鏈旋轉方向自然就跟著改變。

修改後的程式執行結果如下:

為了讓模型更美觀,我們在圓柱體兩端各加一個金屬小球,讓關節看起來滑順自然,掩蓋上一節的視覺缺陷。

此外,在下方做一個滑竿(Slider),控制施力大小。原本在上一節用定向力場(ConstantForceEffect)來施力,此處改用「個體.addTorque()」提供力矩,Torque 是力矩或扭力的意思,單位是牛頓米(Newton-meter, N·m)。

對個體施加外力很簡單,只要有載入物理模擬的個體,就可以用這兩個物件方法:
個體.addTorque([x強度, y強度, z強度], relativeTo: nil)  // 旋轉力矩
// 或
個體.addForce([x強度, y強度, z強度], relativeTo: nil) // 線性力(施於重心)
個體.addForce([x強度, y強度, z強度], at: [px, py, pz], relativeTo: nil) //施於某點
力矩是旋轉力,所以addTorque()第一個參數[x, y, z]是對各軸的旋轉強度,例如 x 代表對X軸旋轉的力矩強度;第二個參數 relativeTo 是相對於哪個個體的座標系(相對其X/Y/Z軸方向),若為 nil 則相對於世界座標系(或稱全域座標系,參考下節補充說明)。

力場(ForceEffect)與施力(addForce 或 addTorque)有個重要差異,就是力場會有持續不斷的作用力在所有個體上,而施力只針對單一個體,且只維持一瞬間(一幀畫面的時間,預設為1/60秒)。因此,在上一節定向力場的強度10,在改用施力或力矩時,(瞬間)強度要改為600,才會有同樣效果。

範例中,用滑竿(Slider)控制力矩強度,範圍在-600至600之間,負數代表反方向(從上往下看為順時針旋轉)。

完整範例程式如下:
// 6-14b 幾何連接點參數(GeometricPin)
// Created by Heman Lu on 2025/08/01
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

struct 物理關節: View {
@State var 暫停 = false
@State var 力矩: Float = 30.0

var body: some View {
RealityView { 內容 in
內容.add(座標軸(0.8))

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

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

// 準備工作:設定2個模型個體,並啟用物理本體與碰撞偵測
let 圓柱 = MeshResource.generateCylinder(height: 0.5, radius: 0.05)
let 上臂 = ModelEntity(mesh: 圓柱, materials: [SimpleMaterial()])
上臂.name = "上臂"
上臂.position.y = 0.3
上臂.physicsBody = .init(mode: .static)
上臂.generateCollisionShapes(recursive: false)
內容.add(上臂)

let 小球1 = 小球.clone(recursive: false)
小球1.position.y = 0.25
上臂.addChild(小球1)
let 小球2 = 小球.clone(recursive: false)
小球2.position.y = -0.25
上臂.addChild(小球2)

let 下臂 = 上臂.clone(recursive: true)
下臂.name = "下臂"
下臂.physicsBody = .init(mode: .dynamic)
內容.add(下臂)

// (1) 設定兩個個體的連接點
let 接點0 = 上臂.pins.set(
named: "上臂底端",
position: [0, -0.3, 0],
orientation: .init(angle: .pi * 0.5, axis: [0, 0, 1])
)
let 接點1 = 下臂.pins.set(
named: "下臂頂端",
position: [0, 0.3, 0]
)

// (2) 設定物理關節
let 肘關節 = PhysicsRevoluteJoint(pin0: 接點0, pin1: 接點1)

// (3) 加入物理關節元件
let 隱藏個體 = Entity()
隱藏個體.name = "隱藏個體"
內容.add(隱藏個體)

var 關節元件 = PhysicsJointsComponent()
關節元件.joints.append(肘關節)
隱藏個體.components.set(關節元件)

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
} update: { 內容 in
for 個體 in 內容.entities where 個體.name == "下臂" {
if let 模型個體 = 個體 as? ModelEntity {
if 暫停 {
模型個體.physicsBody?.mode = .kinematic
} else {
模型個體.physicsBody?.mode = .dynamic
模型個體.addTorque([0, 力矩, 0], relativeTo: nil)
}
}
}
}
.realityViewCameraControls(.orbit)
.overlay(alignment: .bottom) {
HStack(spacing: 10) {
Text(String(format: "力矩(瞬間) = %.1f ", 力矩))
.foregroundStyle(.white)
if 暫停 {
Slider(value: $力矩, in: -600...600, step: 30.0)
.tint(.blue)
} else {
Spacer()
}
Button(暫停 ? "施力" : "暫停", systemImage: 暫停 ? "play.fill" : "pause.fill") {
暫停.toggle()
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(物理關節())

💡註解
  1. 作業1:請修改範例程式,將 addTorque() 改成 addForce(),並適當調整參數,讓下臂旋轉起來。
  2. 作業2:將同樣的參數”orientation: .init(angle: .pi * 0.5, axis: [0, 0, 1])”改加到下臂的「接點1」,也就是「接點0」不動,「接點1」轉90度。此時下臂會轉到哪個方向?施加的力矩是否能轉動下臂?
  3. 作業3:修改作業2的角度,從90度角改為45度角,並適度修改力矩方向,觀察下臂如何轉動。
補充(27) 子個體與局部座標

前面提到,「座標變換」(Transform)是 RealityKit 所有個體的必備元件,用來操作個體的位移、旋轉、縮放,是個體在空間中任何活動 — 如動作動畫、物理模擬的關鍵元件。

上一節有個小地方沒有詳細解釋,但背後卻有非常重要的觀念:
let 接點0 = 上臂.pins.set(
named: "上臂底端",
position: [0, -0.3, 0],
orientation: .init(angle: .pi * 0.5, axis: [0, 0, 1])
)
這裏的位置(position)和旋轉面向(orientation)所含座標倒底是參考哪個座標系?原廠文件只說相對於個體(”a local transform relative to an entity”),是指個體的局部座標系嗎?這要如何理解?

如果這一關過不去,後面更複雜的情境,將會寸步難行。

座標變換包含兩個重要觀念,一是變換矩陣的運算,可參考上半單元補充(4) 變換矩陣如何轉換座標;二是空間座標系統,RealityKit 有兩種座標系統:世界坐標系(World coordinate system)與局部座標系(Local coordinate system)。

世界座標系有時也稱為全域坐標系(Global coordinate system),在 RealityView 視圖中只有唯一一個,不會移動也不會轉動,是整個虛擬空間的基準座標系統。

如同我們熟知的,世界座標原點永遠對準 RealityView 視圖中心,X/Y/Z軸方向若以使用者角度來看,X正軸往右、Y正軸往上、Z正軸指向使用者(其實是虛擬鏡頭位置),X/Y/Z三軸方向不會改變,但虛擬鏡頭方位與視角可操作變更(這是視圖修飾語 .realityViewCameraControls(.orbit) 的作用)。

局部座標系則是每個個體都有一個,是繼承父個體的內部座標系(註解一)而來,當父個體移動或轉動時,座標系原點就會跟著移動,X/Y/Z軸方向也同步轉動。如此一來,子個體與父個體的相對位置及方向,才不會隨父個體的移動或轉動而改變。

以下我們一步一步用程式來理解世界座標、局部座標與座標變換的關係。

(1) 設定父個體:
let 父個體 = Entity()
父個體.position = [0.5, 0.5, 0]
內容.add(父個體)
凡是用「內容.add()」加入的個體,所參考的座標系均繼承 RealityView 「內容」而來,也就是世界座標系。

父個體的位置(以及縮放尺度、旋轉面向)會記錄在座標變換矩陣中:

變換矩陣的左上-右下斜角前三個1 — [1, 1, 1]表示X/Y/Z縮放尺度(預設1倍),最底下一行前三個數值 — [0.5, 0.5, 0]表示位移向量,其他位置則跟X/Y/Z軸旋轉有關。

(2) 設定子個體
let 子個體 = Entity()
子個體.position = [0.4, 0.4, 0]
子個體.scale = [0.6, 0.6, 0.6]
父個體.addChild(子個體)
最後一行透過 addChild() 加入的子個體,所參考的座標系會繼承自父個體,不再是世界座標系,座標原點不在螢幕中心,而是父個體的中心,這就稱為子個體的局部座標系。

因此,「子個體.position = [0.4, 0.4, 0]」位置是相對於父個體,而且「子個體.scale = [0.6, 0.6, 0.6]」,X/Y/Z軸尺度也縮小為父個體的0.6倍。

子個體的變換矩陣同樣紀錄了位置[0.4, 0.4, 0]以及縮放尺度[0.6, 0.6, 0.6]:

此時,若要計算子個體在世界座標系的位置,可以透過矩陣乘法「子個體變換矩陣・父個體變換矩陣」算出:

計算結果,子個體在世界座標系的實際位置是 [0.9, 0.9, 0],尺度為0.6倍。如果子個體之下還有子個體(稱為孫個體),孫個體X/Y/Z尺度會先縮小0.6倍,再乘以孫個體的縮放尺度。

如果有「父—子—孫」的個體關係,想計算孫個體在世界座標的實際位置,同樣用矩陣乘法「孫矩陣・子矩陣・父矩陣」,從最底層一路乘上來即可。

反過來,若想計算世界座標某個點,投影到孫個體的內部座標,則可用「父反置矩陣・子反置矩陣・孫反置矩陣」來運算。

在程式裡面,反置矩陣可用「個體.transform.matrix.inverse」取得:

父個體與子個體相對位置如下(省略Z軸):

最後,我們用一個完整程式,來示範父個體與子個體的關係,可以觀察以下幾點:

1. 父個體不管如何位移、旋轉、縮放,都會帶動子個體一起動作。
2. 子個體可獨立移動、旋轉、縮放,不會影響父個體。
3. 子個體的位置(position),是參考子個體的局部座標系(也就是父個體內部座標)。
4. 子個體的X/Y/Z旋轉面向(orientation),似乎並不是參考局部座標系(父個體的內部座標系),更像是參考子個體自己的內部座標系在原地旋轉。這與原廠文件 “The rotation of the entity relative to its parent.” 似乎不同。

先看一下程式執行結果:

完整範例程式如下:
// 補充(27) 局部座標(local coordinate)
// Created by Heman Lu on 2025/08/05
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

struct 局部座標系: View {
@State var 父角: Float = 0.0
@State var 子角: Float = 0.0

var body: some View {
RealityView { 內容 in
內容.add(座標軸(0.8))

// 父個體
let 材質1 = SimpleMaterial(color: .cyan, isMetallic: false)
let 方盒 = MeshResource.generateBox(size: 0.5, cornerRadius: 0.03)
let 父盒 = ModelEntity(mesh: 方盒, materials: [材質1])
父盒.name = "父"
父盒.position = [0.5, 0.5, 0]
父盒.addChild(座標軸(0.5))

// 子個體
let 材質2 = SimpleMaterial(color: .orange, isMetallic: false)
let 子盒 = ModelEntity(mesh: 方盒, materials: [材質2])
子盒.name = "子"
子盒.position = [0.4, 0.4, 0]
子盒.scale = [0.6, 0.6, 0.6]
子盒.addChild(座標軸(0.5))

父盒.addChild(子盒)
內容.add(父盒)
} update: { 內容 in
for 個體 in 內容.entities where 個體.name == "父" {
個體.orientation = .init(angle: 父角, axis: [0, 1, 0])
if let 子個體 = 個體.findEntity(named: "子") {
子個體.orientation = .init(angle: 子角, axis: [0, 0, 1])
}
}
}
.realityViewCameraControls(.orbit)
.overlay(alignment: .bottom) {
VStack(spacing: 10) {
HStack(spacing: 10) {
Text(String(format: "(父繞Y軸轉)弳度 = %.1f ", 父角))
Slider(value: $父角, in: -.pi ... .pi, step: 0.314)
.tint(.blue)
} .padding(.horizontal)
HStack(spacing: 10) {
Text(String(format: "(子繞Z軸轉)弳度 = %.1f ", 子角))
Slider(value: $子角, in: -.pi ... .pi, step: 0.314)
.tint(.red)
} .padding()
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(局部座標系())

💡註解
  1. 內部座標系或稱原生座標系(這兩個名詞為筆者所擬)是 RealityKit 文件沒有提到的第三種座標系統,乃模型在設計之初,用以計算網格(mesh)各個頂點的座標系統,參考前面第9課 網格描述(MeshDescriptor)
  2. 有些模型若以「左手座標系」設計,匯入RealityKit(採右手座標系)時就必須經過Z軸鏡像轉換;有些模型設計時以公分為刻度單位,匯入RealityKit(刻度單位為公尺)就需要縮放0.01倍,100公分才會變成1公尺。
  3. 在剛產出模型個體時,個體內部座標系原點以及X/Y/Z座標軸,會對齊上一層父個體的內部座標系,若沒有父個體,則對齊世界座標系。
  4. 內文所說「子個體的局部座標繼承自父個體」,是以父個體的內部座標系作為子個體的參考座標系。
  5. 子個體的X/Y/Z旋轉面向(orientation)是相對於子個體的內部座標,還是父個體的內部座標?若仔細從變換矩陣的數學運算來思考,應該是以父個體內部座標的X/Y/Z軸為方向,但在子個體的原地旋轉(不是繞著父座標軸公轉)。這與變換矩陣「先旋轉、縮放,再位移」的運算次序有關,在第4單元 4-7d 仿射變換曾經提過。
  6. 想像一下地球與太陽的關係,地球相當於太陽的子個體,我們現在知道太陽會帶著地球繞著銀河中心公轉。但對地球而言,只能看到以太陽為中心的座標系,感受不到自己在銀河中的空間座標。
  7. 若把尺度縮小,一個人如果一輩子生活在某個城市裡,就會習慣以該城市的座標(局部座標)來指明方向與位置,感受不到整個地球的經緯座標(全域座標)。
6-14c 球型關節(PhysicsSphericalJoint)

經過補充(27)說明之後,我們再回頭看幾何連接點的參數,就更容易理解了:
let 接點0 = 上臂.pins.set(
named: "上臂底端",
position: [0, -0.3, 0],
orientation: .init(angle: .pi * 0.5, axis: [0, 0, 1])
)
其中,位置(position)以及旋轉面向(orientation)的座標,都是相對於個體本身,也就是「上臂」的內部座標,既不是局部座標,也不是世界座標。如此一來,「接點0」就相當於上臂的子個體,不管上臂如何移動或旋轉,接點0總是在上臂底端的位置。

另外,當我們用 addForce() 或 addTorque() 對個體施加外力時,總會遇到 relativeTo 參數(參考原廠文件):
addTorque(
_ torque: SIMD3<Float>,
relativeTo referenceEntity: Entity?
)
relativeTo 是相對於哪個個體,來決定力矩方向,可以是個體本身(內部座標系)、父個體(局部座標系)或其他任何個體(相對座標系),如果是 nil (不參考任何個體)的話,就採用世界座標系。

在物理中,有所謂「絕對座標系」與「相對座標系」的區別,RealityKit 的世界座標系就是絕對座標系,原點與X/Y/Z軸方向、尺度都不會改變;局部座標系與內部座標系則是相對座標系,座標原點與X/Y/Z軸方向會隨個體運動而改變。

了解這些座標系的特性對空間運算至關重要。

前面我們用可轉動關節(PhysicsRevoluteJoint)來做手肘關節,但可轉動關節只有一個自由度,靈活性不夠,人類手臂還是有三個自由度(可向內彎、上下擺動、扭轉),因此比較適合用三個自由度的球型關節來製作。

另外,本節再增加一個「肩關節」,連接上臂與肩膀,這樣我們就有兩個關節(肩、肘),可以看看關節之間如何互相影響。

準備工作先做一個肩膀模型,寬0.5公尺,並啟用物理模擬:
// 肩膀:連接左右雙臂
let 膠囊體 = MeshResource.generateBox(
width: 0.5,
height: 0.2,
depth: 0.2,
cornerRadius: 0.1)
let 肩膀 = ModelEntity(mesh: 膠囊體, materials: [塑膠材質])
肩膀.name = "肩膀"

let 右肩 = 小球.clone(recursive: false)
右肩.position = [-0.25, 0, 0]
肩膀.addChild(右肩)

肩膀.position.y = 0.4
肩膀.physicsBody = .init(mode: .kinematic)
肩膀.generateCollisionShapes(recursive: true)
內容.add(肩膀)
RealityKit 內建模型並沒有膠囊體,這裡有個小技巧,就是利用方盒(generateBox)的「倒角」 — 將邊線直角做成圓弧,圓弧半徑(cornerRadius)恰是高度與深度的一半,就形成膠囊體。

接下來物理關節的第一步,設定連結點:
let 接點2 = 肩膀.pins.set(named: "右肩", position: [-0.3, 0, 0])
let 接點3 = 上臂.pins.set(named: "上臂頂端", position: [0, 0.3, 0])

第二步設定肩關節,與肘關節都改用球型關節(PhysicsSphericalJoint):
let 肘關節 = PhysicsSphericalJoint(pin0: 接點0, pin1: 接點1)
let 肩關節 = PhysicsSphericalJoint(
pin0: 接點2,
pin1: 接點3,
angularLimitInYZ: (.pi * 0.5, .pi * 0.5),
checksForInternalCollisions: true)
球型關節有三個自由度,其中可對於Y/Z軸限制轉動角度,此例限制不超過90度。

最後一個參數 checksForInternalCollisions 是啟用內部碰撞偵測,也就是關節連接的兩個個體 — 肩膀與上臂不會重疊。此時,連接點與個體之間要留點空隙,否則將無法動彈,此例接點2, 接點3的位置是0.3米,離末端0.25米多了5公分空隙,剛好用半徑5公分的金屬小球蓋住。

為了方便比較,肘關節並未限制轉動角度,也沒開啟內部碰撞偵測,因此肘關節所連接的上下臂有可能重疊在一起,在下面執行影片中可以觀察到。

第三步,將兩個關節加入物理關節元件:
// (3) 加入物理關節元件
let 隱藏個體 = Entity()
隱藏個體.name = "隱藏個體"
內容.add(隱藏個體)

var 關節元件 = PhysicsJointsComponent()
關節元件.joints += [肘關節, 肩關節]
隱藏個體.components.set(關節元件)
利用陣列加法,一次加入兩個關節。注意,如果用「關節.addToSimulation()」的方式,N個關節就需要寫N次,反而不如陣列加法來得方便。

最後,我們透過三個狀態變數來控制力矩的方向與強度,並加入暫停按鈕。狀態變數有任何改變,就會呼叫 update 段落,裡面用 addTorque() 對下臂施加力矩:
    @State var 暫停 = false
@State var 力矩: Float = 30.0
@State var 三軸方向 = (true, false, false)

var body: some View {
RealityView { 內容 in
...
} update: { 內容 in
for 個體 in 內容.entities where 個體.name == "下臂" {
if let 模型個體 = 個體 as? ModelEntity {
if 暫停 {
模型個體.physicsBody?.mode = .kinematic
} else {
模型個體.physicsBody?.mode = .dynamic
let x = 三軸方向.0 ? 力矩 : 0
let y = 三軸方向.1 ? 力矩 : 0
let z = 三軸方向.2 ? 力矩 : 0
模型個體.addTorque([x, y, z], relativeTo: nil)
}
}
}
}
}

從影片中可看出,雖然只對下臂施力,但仍會透過關節帶動上臂移動,而由於重力影響,施力後手臂會回到下垂位置:

特別注意肩關節啟用內部碰撞偵測,因此上臂與肩膀之間絕不會碰在一起;但肘關節沒有限制,導致上下臂有可能重疊(下臂會穿過上臂)。

影片下方用 SwiftUI 滑竿(Slider)以及按鈕等控制視圖,請自行參考以下完整程式碼:
// 6-14c 球型關節(PhysicsSphericalJoint)
// Created by Heman Lu on 2025/08/08
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

struct 物理關節: View {
@State var 暫停 = false
@State var 力矩: Float = 30.0
@State var 三軸方向 = (true, false, false)

var body: some View {
RealityView { 內容 in
內容.add(座標軸(0.8))

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

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

var 塑膠材質 = PhysicallyBasedMaterial()
塑膠材質.roughness = 0.8
塑膠材質.metallic = 0.2
塑膠材質.baseColor.tint = .brown

// 肩膀:連接左右雙臂
let 膠囊體 = MeshResource.generateBox(
width: 0.5,
height: 0.2,
depth: 0.2,
cornerRadius: 0.1)
let 肩膀 = ModelEntity(mesh: 膠囊體, materials: [塑膠材質])
肩膀.name = "肩膀"

let 右肩 = 小球.clone(recursive: false)
右肩.position = [-0.25, 0, 0]
肩膀.addChild(右肩)

肩膀.position.y = 0.4
肩膀.physicsBody = .init(mode: .kinematic)
肩膀.generateCollisionShapes(recursive: true)
內容.add(肩膀)

// 準備工作:設定模型個體,並啟用物理本體與碰撞偵測
let 圓柱 = MeshResource.generateCylinder(height: 0.5, radius: 0.05)
let 上臂 = ModelEntity(mesh: 圓柱, materials: [SimpleMaterial()])
上臂.name = "上臂"

let 小球1 = 小球.clone(recursive: false)
小球1.position.y = 0.25
上臂.addChild(小球1)
let 小球2 = 小球.clone(recursive: false)
小球2.position.y = -0.25
上臂.addChild(小球2)

// 上臂.position.y = 0.3
上臂.physicsBody = .init(mode: .dynamic)
上臂.generateCollisionShapes(recursive: true)
上臂.physicsBody?.angularDamping = 0.8
內容.add(上臂)


let 下臂 = 上臂.clone(recursive: true)
下臂.name = "下臂"
下臂.physicsBody = .init(mode: .dynamic)
下臂.physicsBody?.angularDamping = 0.4
內容.add(下臂)

// (1) 設定個體的連接點
let 接點0 = 上臂.pins.set(named: "上臂底端", position: [0, -0.3, 0])
let 接點1 = 下臂.pins.set(named: "下臂頂端", position: [0, 0.3, 0])
let 接點2 = 肩膀.pins.set(named: "右肩", position: [-0.3, 0, 0])
let 接點3 = 上臂.pins.set(named: "上臂頂端", position: [0, 0.3, 0])

// (2) 設定物理關節
let 肘關節 = PhysicsSphericalJoint(pin0: 接點0, pin1: 接點1)
let 肩關節 = PhysicsSphericalJoint(
pin0: 接點2,
pin1: 接點3,
angularLimitInYZ: (.pi * 0.5, .pi * 0.5),
checksForInternalCollisions: true)

// (3) 加入物理關節元件
let 隱藏個體 = Entity()
隱藏個體.name = "隱藏個體"
內容.add(隱藏個體)

var 關節元件 = PhysicsJointsComponent()
關節元件.joints += [肘關節, 肩關節]
隱藏個體.components.set(關節元件)

if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
} update: { 內容 in
for 個體 in 內容.entities where 個體.name == "下臂" {
if let 模型個體 = 個體 as? ModelEntity {
if 暫停 {
模型個體.physicsBody?.mode = .kinematic
} else {
模型個體.physicsBody?.mode = .dynamic
let x = 三軸方向.0 ? 力矩 : 0
let y = 三軸方向.1 ? 力矩 : 0
let z = 三軸方向.2 ? 力矩 : 0
模型個體.addTorque([x, y, z], relativeTo: nil)
}
}
}
}
.realityViewCameraControls(.orbit)
.overlay(alignment: .bottom) {
VStack(alignment: .trailing) {
if 三軸方向.0 {
Button("X軸") { 三軸方向.0.toggle() }
.buttonStyle(.borderedProminent)
.padding(.horizontal)
} else {
Button("X軸") { 三軸方向.0.toggle() }
.buttonStyle(.bordered)
.padding(.horizontal)
}
if 三軸方向.1 {
Button("Y軸") { 三軸方向.1.toggle() }
.buttonStyle(.borderedProminent)
.padding(.horizontal)
} else {
Button("Y軸") { 三軸方向.1.toggle() }
.buttonStyle(.bordered)
.padding(.horizontal)
}
if 三軸方向.2 {
Button("Z軸") { 三軸方向.2.toggle() }
.buttonStyle(.borderedProminent)
.padding(.horizontal)
} else {
Button("Z軸") { 三軸方向.2.toggle() }
.buttonStyle(.bordered)
.padding(.horizontal)
}
HStack(spacing: 10) {
Text(String(format: "力矩(瞬間) = %.1f ", 力矩))
.foregroundStyle(.white)
if 暫停 {
Slider(value: $力矩, in: -600...600, step: 30.0)
.tint(.blue)
} else {
Spacer()
}
Button(暫停 ? "施力" : "暫停", systemImage: 暫停 ? "play.fill" : "pause.fill") {
暫停.toggle()
}
.buttonStyle(.borderedProminent)
}.padding()
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(物理關節())

💡註解
  1. 愛因斯坦的相對論說,宇宙中根本沒有絕對座標,一切都是相對座標,從粒子、物體、星球到星系,所有運動都是相對的,與你(觀察者)的速度以及所參考的座標系不同而變化,甚至時間也是相對的。
  2. 古典的牛頓力學認為時間與空間是絕對的(不會變動),所以 RealiytKit 的絕對座標系較適合牛頓力學,事實上,物理模擬就是根據牛頓力學設計的。
  3. 製作肩膀膠囊體的「倒角」又稱圓角(chamfer or bevel),與擠出(extrusion)、細分割(subdivision)是製作3D模型的三個重要技巧,既簡單又實用,在學習 Blender 時會經常看到。
  4. 作業1:請將「肘關節」也同樣限制旋轉角度以及內部碰撞偵測,然後對下臂施加較大的力矩,看下臂會不會再穿過上臂。
  5. 作業2:請修改上臂、下臂的轉動阻尼(angularDamping),例如加大到10或減小到0,看執行結果有何變化。官方文件只説阻尼不能用負數,但沒有提到數值範圍是否在0~1.0之間,請自行嘗試。
  6. 作業3:修改 relativeTo 參數,將「模型個體.addTorque([x, y, z], relativeTo: nil)」改成「模型個體.addTorque([x, y, z], relativeTo: 模型個體)」,執行結果有任何改變嗎?
6-14d 雙手擺動:場景更新事件(SceneEvents.Update)

前一節做好肩關節與肘關節之後,我們就得到一個簡單的機器人手臂,用同樣方式,再加上另一條手臂,就有了雙手,這樣可以做點什麼呢?本節我們想讓雙手擺動起來。

前面幾節我們透過力場或施力的方式,來驅動物理關節所連結的個體,這兩種方式各有優缺點:力場能持續影響所有個體,但是缺乏變化;施力可針對個體有所不同,但每次施力只有一瞬間,無法持久。

有沒有結合兩種優點,既對不同個體有所變化,也能連續不斷地施力的方式呢?

的確有的,但須結合 RealityKit 的事件處理。

在第11課6-11d 碰撞處理曾經列出 RealityKit 內建的事件種類,其中第7類是整個場景相關的事件,共有7種:
# 事件類別 說明 相關事件
7 SceneEvents 場景事件 .AnchoredStateChanged 錨點變更
.DidActivateEntity 個體已啟用
.DidAddEntity 個體已加入
.DidReparentEntity 變更父個體
.Update 場景更新
.WillDeactivateEntity 個體將失效
.WillRemoveEntity 個體將移除
其中 SceneEvents.Update 是場景更新事件,這種事件什麼時候會發生?答案是每幀畫面更新時,也就是每1/60秒會發生一次。

利用這個事件,我們可以對場景中任何個體做出更細緻的操控,例如對機器人手臂連續施力:
let 場景更新事件 = 內容.subscribe(to: SceneEvents.Update.self) { 事件 in
右上臂.addTorque([0, 0, -30], relativeTo: nil)
右下臂.addTorque([0, 0, -5], relativeTo: nil)
左上臂.addTorque([0, 0, 30], relativeTo: nil)
左下臂.addTorque([0, 0, 5], relativeTo: nil)
}
透過 RealityView 的「內容」,可以訂閱場景相關事件(SceneEvents),我們只訂閱場景更新事件(Update),接下來把操控關節的程式碼加入尾隨的匿名函式 { } 即可。

如此一來,每當場景更新事件發生,也就是每隔1/60秒,就會自動呼叫匿名函式。手臂會一直繞Z軸轉,直到遇阻礙為止(例如偵測到內部碰撞),不過,這並不是我們想要的。

要控制好物理關節似乎不是那麼容易,若想讓機器人雙手反覆上下擺動,像蝴蝶拍動翅膀一樣,才像機器人該有的動作,這樣做得到嗎?

雖然有點難度,但經過不斷調試後,終於做出理想的效果:

這是怎麼做到的?其實就是模仿我們雙手的動作,在每個瞬間,對雙手施力使其上浮,上臂多一些(±30,超過重力9.8),下臂少一點(±5,小於重力),讓雙手往兩側浮起,當超過肩膀高度時,停止施力,靠重力讓雙手落下,完全垂下後再重新施力,如此反覆就能模仿蝴蝶拍動翅膀的動作,看起來還蠻像的。

這段施力的程式碼須修改如下:
// 加入事件處理:SceneEvents.Update
var 施力上浮 = true
let 場景更新事件 = 內容.subscribe(to: SceneEvents.Update.self) { 事件 in
if 右上臂.position.y < 0.11 && 施力上浮 == false {
施力上浮 = true // 開始上浮
} else if 右上臂.position.y > 0.4 && 施力上浮 == true {
施力上浮 = false // 停止上浮
} else if 右上臂.position.y < 0.4 && 施力上浮 == true {
右上臂.addTorque([0, 0, -30], relativeTo: nil)
右下臂.addTorque([0, 0, -5], relativeTo: nil)
左上臂.addTorque([0, 0, 30], relativeTo: nil)
左下臂.addTorque([0, 0, 5], relativeTo: nil)
}
}
利用右上臂的Y軸位置來判斷是否超過肩膀(y > 0.4)或已經落下(y < 0.11),中間這段(0.11 < y < 0.4)需要一個額外變數「施力上浮」開關來切換上浮還是落下。

這段邏輯比較複雜,我們用一個真假值表來列舉每個狀況該做什麼:

在調整參數的過程中,發現力矩強度要與旋轉阻尼(angularDamping)配合,才能做出理想的擺動動作:
右上臂.physicsBody?.angularDamping = 4.5
左上臂.physicsBody?.angularDamping = 4.5
右下臂.physicsBody?.angularDamping = 2.2
左下臂.physicsBody?.angularDamping = 2.2

至於其他增加的程式碼,主要就是做第二條手臂與關節,但只是重複寫相同程式碼,將右手複製到左手而已。

最後整個程式將近200行,不再適合當範例,後面就不再繼續發展,原本想幫機器人加裝輪子的任務,就留給讀者當作業了。

本節完整範例程式如下:
// 6-14d 雙手擺動:場景更新事件(SceneEvents.Update)
// Created by Heman Lu on 2025/08/12
// Minimum Requirement: macOS 15 or iPadOS 18 + Swift Playground 4.6

import SwiftUI
import RealityKit

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

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

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

var 塑膠材質 = PhysicallyBasedMaterial()
塑膠材質.roughness = 0.8
塑膠材質.metallic = 0.2
塑膠材質.baseColor.tint = .brown

// 機器人身體
let 方柱 = MeshResource.generateBox(
width: 0.3,
height: 1.2,
depth: 0.3,
cornerRadius: 0.1)
let 身體 = ModelEntity(mesh: 方柱, materials: [塑膠材質])
身體.name = "身體"
身體.position.y = -0.4

// 肩膀:連接左右雙臂
let 膠囊體 = MeshResource.generateBox(
width: 0.5,
height: 0.2,
depth: 0.2,
cornerRadius: 0.1)
let 肩膀 = ModelEntity(mesh: 膠囊體, materials: [塑膠材質])
肩膀.name = "肩膀"

let 右肩 = 小球.clone(recursive: false)
右肩.position = [-0.25, 0, 0]
肩膀.addChild(右肩)
let 左肩 = 小球.clone(recursive: false)
左肩.position = [0.25, 0, 0]
肩膀.addChild(左肩)

肩膀.position.y = 0.4
肩膀.physicsBody = .init(mode: .kinematic)
肩膀.generateCollisionShapes(recursive: true)
肩膀.addChild(身體)
內容.add(肩膀)


// 準備工作:設定模型個體,並啟用物理本體與碰撞偵測
let 圓柱 = MeshResource.generateCylinder(height: 0.5, radius: 0.05)
let 右上臂 = ModelEntity(mesh: 圓柱, materials: [SimpleMaterial()])
右上臂.name = "右上臂"

let 小球1 = 小球.clone(recursive: false)
小球1.position.y = 0.25
右上臂.addChild(小球1)
let 小球2 = 小球.clone(recursive: false)
小球2.position.y = -0.25
右上臂.addChild(小球2)

右上臂.physicsBody = .init(mode: .dynamic)
右上臂.generateCollisionShapes(recursive: true)
右上臂.physicsBody?.angularDamping = 4.5
內容.add(右上臂)


let 右下臂 = 右上臂.clone(recursive: true)
右下臂.name = "右下臂"
右下臂.physicsBody?.angularDamping = 2.2
內容.add(右下臂)

let 左上臂 = 右上臂.clone(recursive: true)
左上臂.name = "左上臂"
內容.add(左上臂)

let 左下臂 = 右上臂.clone(recursive: true)
左下臂.name = "左下臂"
左下臂.physicsBody?.angularDamping = 2.2
內容.add(左下臂)

// (1) 設定個體的連接點
let 接點0 = 右上臂.pins.set(named: "右上臂底端", position: [0, -0.3, 0])
let 接點1 = 右下臂.pins.set(named: "右下臂頂端", position: [0, 0.3, 0])
let 接點2 = 肩膀.pins.set(named: "右肩", position: [-0.3, 0, 0])
let 接點3 = 右上臂.pins.set(named: "右上臂頂端", position: [0, 0.3, 0])
let 接點4 = 左上臂.pins.set(named: "左上臂底端", position: [0, -0.3, 0])
let 接點5 = 左下臂.pins.set(named: "左下臂頂端", position: [0, 0.3, 0])
let 接點6 = 肩膀.pins.set(named: "左肩", position: [0.3, 0, 0])
let 接點7 = 左上臂.pins.set(named: "左上臂頂端", position: [0, 0.3, 0])

// (2) 設定物理關節
let 右肘關節 = PhysicsSphericalJoint(pin0: 接點0, pin1: 接點1)
let 右肩關節 = PhysicsSphericalJoint(
pin0: 接點2,
pin1: 接點3,
angularLimitInYZ: (.pi * 0.5, .pi * 0.75),
checksForInternalCollisions: true)
let 左肘關節 = PhysicsSphericalJoint(pin0: 接點4, pin1: 接點5)
let 左肩關節 = PhysicsSphericalJoint(
pin0: 接點6,
pin1: 接點7,
angularLimitInYZ: (.pi * 0.5, .pi * 0.75),
checksForInternalCollisions: true)

// (3) 加入物理關節元件
let 隱藏個體 = Entity()
隱藏個體.name = "隱藏個體"
內容.add(隱藏個體)

var 關節元件 = PhysicsJointsComponent()
關節元件.joints += [右肘關節, 右肩關節, 左肘關節, 左肩關節]
隱藏個體.components.set(關節元件)

// 加入事件處理:SceneEvents.Update
var 施力上浮 = true
let 場景更新事件 = 內容.subscribe(to: SceneEvents.Update.self) { 事件 in
// print(Date.now, "右上臂高度:", 右上臂.position.y)
if 右上臂.position.y < 0.11 && 施力上浮 == false {
施力上浮 = true // 開始上浮
} else if 右上臂.position.y > 0.4 && 施力上浮 == true {
施力上浮 = false // 停止上浮
} else if 右上臂.position.y < 0.4 && 施力上浮 == true {
右上臂.addTorque([0, 0, -35], relativeTo: nil)
右下臂.addTorque([0, 0, -8], relativeTo: nil)
左上臂.addTorque([0, 0, 35], relativeTo: nil)
左下臂.addTorque([0, 0, 8], relativeTo: nil)
}
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(物理關節())

💡註解
  1. 作業1:判斷手臂超過肩膀(y > 0.4),或是完全落下(y < 0.11),這兩個數字如何得到呢?
  2. 作業2:請調整施力大小(±30, ±5),以及手臂的旋轉阻尼(4.5, 2.2),讓手臂揮得比肩膀更高,幅度更大一些,注意肩膀關節的旋轉限制:angularLimitInYZ: (.pi * 0.5, .pi * 0.75)。
  3. 作業3:參考內文「真假值表」,在手臂落下時施加反向力矩,加速落下,就像用力拍動翅膀的感覺,做得到嗎?
  • 6
內文搜尋
X
評分
評分
複製連結
Mobile01提醒您
您目前瀏覽的是行動版網頁
是否切換到電腦版網頁呢?