在第4單元4-7d 仿射變換(CGAffineTransform)提過,畫布(Canvas)的圖層有一個 transform 屬性(3x3矩陣),用於2D圖層中所有畫筆的座標變換,讓圖案能夠隨意位移、縮放與旋轉,是功能最強的一個屬性。
在3D世界中,Transform 座標變換也是同樣強大且重要,只不過變成4x4矩陣。不管是 SceneKit 節點或 RealityKit 個體,都具備 transform 屬性,因為是屬性(而非方法),所以只要修改屬性值,就會立刻發生作用,不需要呼叫任何函式。
通常我們不必直接修改個體的 transform 屬性,而是透過另外三個屬性來操作:
1. .position 位置,包含 x/y/z 座標(三元數)
2. .scale 縮放或鏡像的尺度,包含 x/y/z 各方向(三元數)
3. .orientation 旋轉的面向(四元數)
如果這三個屬性有任何變動,transform會自動計算,反過來也一樣,若修改 transform,其他三個屬性也會自動變更。
要注意 position, scale, orientation 的操作並不是疊加,而是指定值。例如:
個體.postion.x = 2.0
....
個體.postion.x = 3.0
將個體位置變更兩次之後,最終位置並不會疊加,而是在最後 x = 3.0 座標位置。所以 RealityKit 的屬性命名用 position 位置、scale 尺度、orientation 面向,與之前 Canvas 用函式 translateBy() 位移、scaleBy() 縮放、rotation(by:) 旋轉等操作有所不同。
若真想要疊加位移,也就是類似 Canvas 的 translateBy(),應該寫成:
個體.position.x += 3.0
// 相當於
個體.position.x = 個體.position.x + 3.0
以下我們設計一個 SwiftUI 介面,來實際動手操作個體的位移、縮放與旋轉。
// 6-6c 座標變換:位移、縮放(鏡像)、旋轉
// Created by Heman, 2025/02/08
// Test Environment: iMac 2019 (macOS 15.3) + Swift Playground 4.6.1
//
import SwiftUI
import RealityKit
enum 座標變換選項 {
case x軸位移
case y軸位移
case z軸位移
case x軸縮放
case y軸縮放
case z軸縮放
case x軸旋轉
case y軸旋轉
case z軸旋轉
}
struct 基本座標變換: View {
@State var 數值: Float = 0.0 //由Slider控制
@State var 功能選擇: 座標變換選項 = .x軸位移 //由Picker控制
var body: some View {
// (1) 虛擬場景
RealityView { 內容 in
內容.add(座標軸()) // 座標軸()放在共享程式區
let 橫板 = MeshResource.generateBox(width: 0.5, height: 0.04, depth: 0.3, splitFaces: true)
let 橘色材質 = SimpleMaterial(color: .orange, isMetallic: false)
let 藍色材質 = SimpleMaterial(color: .blue, isMetallic: false)
let 樓梯板 = ModelEntity(mesh: 橫板, materials: [藍色材質, 橘色材質, 藍色材質, 橘色材質, 藍色材質, 藍色材質])
樓梯板.name = "樓梯板" // 更新時會用到
內容.add(樓梯板)
} update: { 內容 in
print("updated: \(Date.now)")
for 個體 in 內容.entities where 個體.name == "樓梯板" {
print("太好了:\(個體)")
switch 功能選擇 {
case .x軸位移:
個體.position.x = 數值
case .y軸位移:
個體.position.y = 數值
case .z軸位移:
個體.position.z = 數值
case .x軸縮放:
個體.scale.x = 數值
case .y軸縮放:
個體.scale.y = 數值
case .z軸縮放:
個體.scale.z = 數值
case .x軸旋轉:
個體.orientation = simd_quatf(angle: 數值, axis: [1, 0, 0])
case .y軸旋轉:
個體.orientation = simd_quatf(angle: 數值, axis: [0, 1, 0])
case .z軸旋轉:
個體.orientation = simd_quatf(angle: 數值, axis: [0, 0, 1])
default: break
}
}
}
.realityViewCameraControls(.orbit)
// (2) 座標變換選單
HStack {
Picker("座標變換選項", selection: $功能選擇) {
Text("X軸位移").tag(座標變換選項.x軸位移)
Text("Y軸位移").tag(座標變換選項.y軸位移)
Text("Z軸位移").tag(座標變換選項.z軸位移)
Text("X軸縮放").tag(座標變換選項.x軸縮放)
Text("Y軸縮放").tag(座標變換選項.y軸縮放)
Text("Z軸縮放").tag(座標變換選項.z軸縮放)
Text("X軸旋轉").tag(座標變換選項.x軸旋轉)
Text("Y軸旋轉").tag(座標變換選項.y軸旋轉)
Text("Z軸旋轉").tag(座標變換選項.z軸旋轉)
}
.onChange(of: 功能選擇) {
switch 功能選擇 {
case .x軸縮放, .y軸縮放, .z軸縮放:
數值 = 1.0
default:
數值 = 0.0
}
}
Button("重置", systemImage: "arrow.clockwise") {
switch 功能選擇 {
case .x軸縮放, .y軸縮放, .z軸縮放:
數值 = 1.0
default:
數值 = 0.0
}
}
}
// (3) 數值滑竿
switch 功能選擇 {
case .x軸位移, .y軸位移, .z軸位移:
HStack(spacing: 0) {
Text(String(format: "數值 = %.2f ", 數值))
Slider(value: $數值, in: -1.0...1.0, step: 0.01)
.tint(.red)
}.padding()
case .x軸縮放, .y軸縮放, .z軸縮放:
HStack(spacing: 0) {
Text(String(format: "數值 = %.2f ", 數值))
Slider(value: $數值, in: 0.1...4.0, step: 0.01)
.tint(.green)
}.padding()
case .x軸旋轉, .y軸旋轉, .z軸旋轉:
HStack(spacing: 0) {
Text(String(format: "數值 = %.2f ", 數值))
Slider(value: $數值, in: -Float.pi...Float.pi, step: 0.01)
.tint(.blue)
}.padding()
default:
Text("未知選項")
}
}
}
import PlaygroundSupport
PlaygroundPage.current.setLiveView(基本座標變換())
SwiftUI 介面主要用 Picker 做一個操作選單,包含X/Y/Z軸的位移、縮放與旋轉(共9種操作),每一種操作都用 Slider 滑竿來選擇數量,然後分別指定給 position, scale 與 orientation。
當Picker選單或Slider滑竿有所變動時,對應的狀態變數(@State var)就會改變,這時候 RealityView 的內容並不會 refresh (重新計算),而是觸發 RealityView 另一個匿名函式 — update:,這是怎麼回事呢?
原來完整的 RealityView 有三個匿名函式當作參數(若是 RealityView for visionOS,還有第4個參數 attachments:),RealityView 完整句型如下:
// RealityView 完整參數:make, update, placeholder(, attachments)
struct 視圖: View {
var body: some View {
RealityView { 內容 in
...
個體.name = "XXX"
內容.add(個體)
...
} update: { 內容 in
// 先找出需要更新的個體
for 個體 in 內容.entities where 個體.name == "XXX" {
// 開始變更目標個體的屬性
...
}
// 或是搜尋階層之下所有個體
for 個體 in 內容.entities {
if let 目標 = 個體.findEntity(named: "XXX") {
// 開始變更目標個體的屬性
...
}
}
} placeholder: {
// 預設為 ProessView()
}
// attachments: { } // for visionOS only
}
}
什麼時候會用到 update: 匿名函式?就是在整個視圖的狀態變數(@State var)有所變化時。相對的,RealityView 的第一個匿名函式(參數名稱為 make:,名稱可省略)只會執行一次,並不隨著狀態改變而重新執行。
至於 placeholder: 則是在虛擬場景還在準備時,暫時補位的視圖,預設是我們熟悉的 ProgressView()。
另外,值得一提的是,此範例第一次用到 for 迴圈的 where 子句:
for 個體 in 內容.entities where 個體.name == "XXX" {
// 開始變更目標個體的屬性
...
}
where 接一個條件運算式,相當於一個過濾條件,只有符合條件的,才會進入 { } 執行。這個子句仿照資料庫的 SQL 語言,可以快速篩選出目標,非常好用。
上面這句 for 迴圈可以理解為:「對內容的所有個體,取其中名為”XXX”者,做以下運算…」。
操作影片如下:
💡註解
- 作業:請在範例程式加入 x/y/z 軸的鏡像功能。
- 作業:有沒有注意到樓梯板(generateBox)六個面的材質可以不一樣?內文沒有解釋如何做到,請自行研究,將六面改成六種顏色。
- enum 與 switch 的用法請參考第1單元1-10d enum(列舉) 。
- Picker 的用法在第4單元第10課 App-2: 芝加哥藝術博物館v2介紹過。
- Slider 滑竿的用法很簡單,雖然之前沒有正式介紹過,但應該一看就懂。
- 從參數名稱 make: 與 update: 可以猜到,RealityView 可能是以 ARView 的 makeUIView() 與 updateUIView() 做出來的,更新機制也大同小異。
- 要注意 RealityView 第一個匿名函式(make:)與第二個匿名函式(update:)還有個重要差別,就是make: 匿名函式是 async (非同步),執行時會整個移到背景(支線);而 update: 匿名函式是同步的,必須在主線(main thread)上執行。這個差異在後面課程會遇到實際的影響。