• 5

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

6-6c 基本座標變換:位移、縮放、旋轉

第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”者,做以下運算…」。

操作影片如下:

💡註解
  1. 作業:請在範例程式加入 x/y/z 軸的鏡像功能。
  2. 作業:有沒有注意到樓梯板(generateBox)六個面的材質可以不一樣?內文沒有解釋如何做到,請自行研究,將六面改成六種顏色。
  3. enum 與 switch 的用法請參考第1單元1-10d enum(列舉)
  4. Picker 的用法在第4單元第10課 App-2: 芝加哥藝術博物館v2介紹過。
  5. Slider 滑竿的用法很簡單,雖然之前沒有正式介紹過,但應該一看就懂。
  6. 從參數名稱 make: 與 update: 可以猜到,RealityView 可能是以 ARView 的 makeUIView() 與 updateUIView() 做出來的,更新機制也大同小異。
  7. 要注意 RealityView 第一個匿名函式(make:)與第二個匿名函式(update:)還有個重要差別,就是make: 匿名函式是 async (非同步),執行時會整個移到背景(支線);而 update: 匿名函式是同步的,必須在主線(main thread)上執行。這個差異在後面課程會遇到實際的影響。
6-6d 旋轉樓梯

在本課一開始認識 RealityKit ECS 架構之後,接下來最重要的兩個物件,就是 RealityView 的「內容」與「模型個體」(ModelEntity),到現在應該已相當熟悉。

RealityView「內容」的屬性

「內容」囊括所有虛擬個體,本身也是個物件,內含若干屬性與方法,我們已經用過「內容.add()」,其他屬性與方法並不多,但都相當重要,如下表:
# 內容的屬性/方法 說明 初次使用章節
1 add() 加入個體 6-6a
2 remove() 移除個體 -
3 subscribe() 訂閱(某些事件發布) 6-?
4 entities 所有已加入個體(陣列) 6-6c
5 camera 設定鏡頭(虛擬或實景) 6-?
6 environment 環景(Image-Based Lighting) 6-7c
7 renderingEffects 場景渲染效果選項 -
8 audioListener 空間音效的聆聽位置(預設同鏡頭) -
本節有兩個目標:一是用預設的幾何模型,組成一個實用場景「旋轉樓梯」,其中需要四元數 simd_quatf 計算旋轉角度;二是觀察「內容.entities」有哪些資訊。

仿照6-6b 座標軸的做法,將「旋轉樓梯()」寫成一個共享函式,傳回模型個體。在左側欄「第6單元共享程式」之下,新增一個「6-6d 旋轉樓梯.swift」檔案,敲入以下程式:
// 6-6d 共享程式:旋轉樓梯
// Created by Heman, 2025/02/12
// Test Environment: iMac 2019 (macOS 15.3) + Swift Playground 4.6.1
import RealityKit

public func 旋轉樓梯(圓柱半徑 r: Float = 0.3,
高 h: Float = 2.0,
樓梯寬 w: Float = 0.5,
樓梯數 n: Int = 20) -> ModelEntity {
let 圓柱 = MeshResource.generateCylinder(height: h, radius: r)
let 水泥材質 = SimpleMaterial(color: .gray, roughness: 1.0, isMetallic: false)
let 圓柱體 = ModelEntity(mesh: 圓柱, materials: [水泥材質])
圓柱體.name = "旋轉樓梯"

let 橫板 = MeshResource.generateBox(size: [w, w * 0.08, w * 0.4], cornerRadius: w * 0.02)
let 橘色材質 = SimpleMaterial(color: .orange, roughness: 0.2, isMetallic: false)
let 樓梯板 = ModelEntity(mesh: 橫板, materials: [橘色材質])
樓梯板.position.y = -h * 0.5
圓柱體.addChild(樓梯板)

let 樓梯間距 = h / Float(n) // 樓梯間距 0.1m
for i in 0...n {
let 新樓板 = 樓梯板.clone(recursive: false)
新樓板.name = "樓板#\(i)"
樓梯板.addChild(新樓板)

let 旋轉弧度 = Float.pi * 0.1 * Float(i) // 旋轉間距 .pi * 0.1 = 18°
新樓板.orientation = simd_quatf(angle: 旋轉弧度, axis: [0, 1, 0])
新樓板.position.x = cos(旋轉弧度) * (r + w * 0.4) // 樓梯寬*0.4:一半再小一點
新樓板.position.y = 樓梯間距 * Float(i)
新樓板.position.z = -sin(旋轉弧度) * (r + w * 0.4)
}
return 圓柱體
}

參數標籤(argument label) v.s. 參數名稱(parameter name)

在上面宣告「旋轉樓梯()」時,我們用了一個新語法:參數標籤(label),也就是在函式的參數名稱前面再加一個名字:
public func 旋轉樓梯(圓柱半徑 r: Float = 0.3, 
高 h: Float = 2.0,
樓梯寬 w: Float = 0.5,
樓梯數 n: Int = 20) -> ModelEntity { }
圓柱半徑、高、樓梯寬、樓梯數是參數標籤,用來充分表達參數的意義,可以讓其他模組看到;而 r, h, w, n 等參數名稱則只用於函式內部,講求簡單方便,自己看懂就行。

一旦用了參數標籤,當我們呼叫此函式時,只能用參數標籤,不能再用參數名稱:
let 一號梯 = 旋轉樓梯(高: 4.5)
// 不能用:
let 一號梯 = 旋轉樓梯(h: 4.5) // 語法錯誤!
所以參數標籤又稱為外部名稱。若參數標籤為底線 “_”,表示呼叫時前置標籤可省略,這從第1單元就已經用過。

個體.clone(): class v.s. struct

另外,有一行程式碼相當重要,需要特別說明:
let 新樓板 = 樓梯板.clone(recursive: false)
因為所有樓板(ModelEntity)外觀都一樣,就不必再初始化一次,只需呼叫 .clone() 複製一份即可,參數 recursive: 是指要不要包含子個體(如果有的話)一起複製。

這裡為什麼不能簡單寫成:
let 新樓板 = 樓梯板     // 可以指定給新物件嗎?
如果「樓梯板」是 struct 物件,這樣是可以的,指定句會複製「樓梯板」內容,再指定給「新樓板」。不過可惜的是,「樓梯板」(ModelEntity)是 class 物件,若單用指定句,新樓板與樓梯板會指到同一個物件實例,而不是兩個物件。

這個觀念相當重要,請參考第5單元語法說明:class 與 struct 異同

SceneKit幾乎完全採用 class 物件(除了後來的SceneView),而 RealityKit 則是大部分用 struct,少數用 class,兩者會經常混用。上述情況如果寫錯,語法仍是正確的,但執行結果就可能天差地遠,所以只能自己小心(多參考Apple原廠文件,如下圖)。

共享函式切出去之後,主程式就簡潔許多:
// 6-6d 旋轉樓梯
// Created by Heman Lu, 2025/02/12
// Test Environment: iMac 2019 (macOS 15.3) + Swift Playground 4.6.1
//
import SwiftUI
import RealityKit

struct 顯示旋轉樓梯: View {
let 總高度: Float = 1.8
var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b
內容.add(旋轉樓梯(高: 總高度)) // 共享程式6-6d

let 地面 = ModelEntity(mesh: .generatePlane(width: 2.0, depth: 2.0))
地面.position.y = 總高度 * -0.5
內容.add(地面)

print(內容.entities)
}
.realityViewCameraControls(.orbit)
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(顯示旋轉樓梯())
主程式除了顯示組合出來的旋轉樓梯之外,最後有一行 print(內容.entities) 是本節另一個重點。不過,先來看看 Swift Playground 實際操作與執行過程:

觀察「內容」與「個體」的內部結構

接下來看主控台輸出的結果:
RealityViewEntityCollection(entity: ▿ '' : AnchorEntity, children: 3
⟐ AnchoringComponent
⟐ Transform
⟐ SynchronizationComponent
最前面這幾行,顯示「內容」之下的根節點是一個錨點個體(AnchorEntity),其下有3個子個體。

這個 AnchorEntity 是個虛擬錨點,錨定在虛擬空間的座標原點,後面加進來的個體,初始位置都會在此。AnchorEntity 包含三個元件,除了必備的座標變換與同步元件之外,還包含錨定元件(AnchoringCopoment),用來將虛擬個體錨定在實體空間。

在真實的 AR 環境中,我們可以將虛擬個體固定在實體空間的某個地方,例如實體地板、牆面、或是某個特徵點,這時需要另一個錨點個體來當父個體,才能將錨定的虛擬個體加到「內容」中。

所以在AR環境中,「內容」可能包含多個錨點個體。相對的,在純虛擬空間中(不啟動AR),通常就只會有一個錨點個體,當作所有虛擬個體的根節點。

此例中,錨點個體之下包含三個子個體,顯然就是我們用「內容.add()」加入的「座標軸」、「旋轉樓梯」以及「地面」。

其中,座標軸與地面都沒有命名,預設名稱為空字串 “”。「旋轉樓梯」與下層子個體「樓板」在程式中有加以命名,未來若需要更新(update:)時,就會用到這些名稱。
  ▿ '旋轉樓梯' : ModelEntity, children: 1
⟐ Transform
⟐ SynchronizationComponent
⟐ ModelComponent
▿ '' : ModelEntity, children: 21
⟐ Transform
⟐ SynchronizationComponent
⟐ ModelComponent
▿ '樓板#0' : ModelEntity
⟐ Transform
⟐ SynchronizationComponent
⟐ ModelComponent
最後,從上面可以看到,模型個體只包含座標變換、同步元件、模型元件等三個元件,在6-6a提到的碰撞偵測、物理模擬等元件,目前並未啟用。

💡註解
  1. 一個參數用兩個名稱有必要嗎?如果函式是寫給自己用的,其實就沒有必要,簡單就好;但如果是設計函式或API 給別人用,那麼可讀性就非常重要,參數標籤主要目的就是增加可讀性。
  2. Apple 官方框架的函式大量使用參數標籤,供外部呼叫,不過平常我們不會特別區分參數標籤與參數名稱,一律通稱為參數名稱。
  3. 挑戰題:Google 搜尋 “Spiral Staircase 3D Model” 可找到不少類似的模型,例如Sketchfab旋轉樓梯。參考這些樓梯,將範例6-6d加上「扶手」。
第7課 外部資源:USDZ、紋理貼圖、天空盒

不管是 SceneKit 或 RealityKit,內建的幾何模型都寥寥可數,即使能用程式加以組合,還是不夠方便,實際應用時,常需導入外部資源。

事實上,設計3D程式就應該善用外部資源。為什麼呢?

自從1995年第一部全3D動畫電影「玩具總動員」上映之後,全世界3D電影特效、3D遊戲開始蓬勃發展,很多人投入3D內容創作,累積至今,不論是3D模型、材質紋理、人物表情、動作、視覺特效…,大量作品在網上買賣或免費下載,已形成龐大數位內容產業。

除了3D模型之外,材質(material)與紋理(texture)也需要外部資源,一般使用 .jpg 或 .png 格式,稱為紋理貼圖;最後,我們還會學習如何更換環境圖案,稱為天空盒(skybox),用到一種高動態範圍的 .exr 環景圖片。

本節先學習如何載入模型檔案。

6-7a 載入本機.usdz模型檔

RealityKit 支援的 3D 檔案格式稱為 USD — 全名為「通用場景描述檔」(Universal Scene Description),由製作「玩具總動員」的皮克斯(Pixar)動畫工作室所制定並開放授權,又細分為四種檔案格式:.usd 原始格式、.usda 文字格式、.usdc 二進位格式、.usdz ZIP包裹格式(未壓縮)。

USD 可用來儲存整個虛擬場景(Scene),也就是一群個體(Entity)的集合,除了3D模型、材質貼圖、虛擬燈光、鏡頭視角之外,還可容納動畫、物理模擬、粒子系統…等特效,檔案大小從1KB簡單模型,到10GB電影場景,都能涵括。

在開始本節程式之前,請先下載一個3D模型檔案“Blender_ex1.usdz”,大小只有67KB,是筆者用 Blender 軟體練習的作品 ,下載後再導入 Swift Playground,如下圖:

檔案導入之後,要在 RealityKit 中加入 USDZ 檔案就非常簡單,只要一行程式:
let 本機模型 = try await ModelEntity(named: "Blender_ex1.usdz")
這行程式會將 USDZ 檔案轉換成我們熟悉的模型個體(ModelEntity),之後就可透過程式進一步操作。

要注意這是非同步(await)語法,通常要用 Task { } 包起來(整個移到背景執行),不過剛好 RealityView 的第一個匿名函式(make:)就是非同步模式,因此不需要再加Task。

但是 try 的部分仍須配合 do-try-catch 句型,所以要改寫如下:
RealityView { 內容 in
do {
let 本機模型 = try await ModelEntity(named: "Blender_ex1.usdz")
內容.add(本機模型)
} catch {
print("有問題:\(error)")
}
}
這段程式還可稍加精簡:
RealityView { 內容 in
if let 本機模型 = try? await ModelEntity(named: "Blender_ex1.usdz") {
內容.add(本機模型)
}
}
也就是說,將 do-try-catch 句型改成 Optional 類型,再用 if let (或 guard let) 解開,這樣可省略麻煩的 catch 子句。

對有些初學者來說,會拋出錯誤的函式(throwing function)、do-try-catch 句型、Optional 類型,這些寫起來可能不太順,感覺有些彆扭。但其實習慣就好,這些句型非常重要,是讓App安全可靠的關鍵,省略不得。

除了載入 .usdz 模型之外,我們另外再加一盞燈光,看看補光的效果,完整程式如下:
// 6-7a 載入.usdz模型檔 + 點光源 PointLight
// Created by Heman Lu, 2025/02/16
// Tested on iMac 2019 (macOS 15.3.1) + Swift Playground 4.6.2

import SwiftUI
import RealityKit

struct 載入本機模型: View {
@State var 數值: Float = 26963.76

var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b
do {
let 本機模型 = try await ModelEntity(named: "Blender_ex1.usdz")
內容.add(本機模型)
} catch {
print("有問題:\(error)")
}

// 亮度(lumen, 流明)請參考原廠文件:
// https://developer.apple.com/documentation/realitykit/pointlightcomponent
let 燈光 = PointLight()
燈光.name = "燈光"
燈光.light.intensity = 數值 //default 26963.76 lumens
燈光.light.color = .green
燈光.light.attenuationRadius = 50
燈光.position = [0, -3, 1]
內容.add(燈光)

print(內容.entities)
} update: { 內容 in
for 個體 in 內容.entities where 個體.name == "燈光" {
if let 燈光 = 個體 as? PointLight {
燈光.light.intensity = 數值
}
}
}
.realityViewCameraControls(.orbit)
.backgroundStyle(.black)

HStack() {
Text("亮度 = \(Int(數值)) ")
Slider(value: $數值, in: 1000...500000, step: 100)
.tint(.green)
}.padding()
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(載入本機模型())

第6課 RealityKit ECS 簡介提過,RealityKit 內建三種燈光個體,但預設都沒用到,完全依賴環境來計算光照(稱為 IBL, Image-Based Lighting)。某些情況下,若要對個別模型加強補光,就需要添加燈光個體。

燈光也是一種個體(Entity),可以綁在其他個體上(成為子個體),用法與模型個體類似,產出後加入「內容」即可生效:
let 燈光 = PointLight()
...
內容.add(燈光)
中間要調整燈光個體的參數,其中最重要的是強度(intensity),光源強度的單位稱為「流明」(lumen),根據原廠文件,適當的流明值範圍在10~100,000之間。

範例中,用一個滑桿來動態調整流明值,不過要注意,這次 update: 裡面的程式,必須先將找到的個體用 as? 設定為 PointLight 類型,才能調整光照強度:
if let 燈光 = 個體 as? PointLight { ... }
這種 as? 語法,曾經在第5單元(如5-1b QR Code 掃描)用過,不過當時並未解釋,其實背後原理不難理解,說明如下。

當環境變數(@State var)變更,進入 update: 子句時,照例先搜尋符合條件的個體:
for 個體 in 內容.entities where 個體.name == "燈光"
這時候,不管是什麼條件,搜尋到的個體類型一定是 Entity,而不是 PointLight,Entity 是所有個體的父類型(parent class),只有基本的座標變換與同步等屬性。

但這次要變更的屬性不是座標變換,而是光源強度 — 只有燈光個體才有。因此,必須先轉換成 PointLight,才能進一步操作。轉換類型通常用 as 即可,這裡用 as? 是同時轉成 Optional (也就是PointLight?) 類型,以防轉換失敗的情況(萬一有別的個體也叫”燈光”)。

有沒有發現 Swift 程式語言裡面,問號 “?” 經常出現?而且設計很巧妙,直接附在關鍵字後面。這些 “?” 都跟 Optional 類型有關,可見 Optional 的觀念實在很重要。

程式執行結果如下(如果沒看到模型,通常是導入檔案名稱不一致):

最後,順便看一下 print(內容.entities) 主控台輸出的結果:
RealityViewEntityCollection(entity: ▿ '' : AnchorEntity, children: 3
⟐ AnchoringComponent
⟐ Transform
⟐ SynchronizationComponent
▿ '' : ModelEntity, children: 6
⟐ Transform
⟐ SynchronizationComponent
⟐ ModelComponent
...
▿ 'root' : ModelEntity
⟐ SynchronizationComponent
⟐ ModelComponent
⟐ Transform
▿ '燈光' : PointLight
⟐ Transform
⟐ SynchronizationComponent
⟐ PointLightComponent
根節點同樣是錨點個體,三個子個體分別是「座標軸」、「本機模型」(Blender_ex1.usdz)以及「燈光」,注意本機模型的名稱是 “root”,這是 Blender 輸出成 .usdz 預設的名稱,代表整個 USD 場景的根節點。

其中「燈光」個體有三個元件:座標變換、同步元件,以及點光源(PointLightComponent)。從執行影片中,即使轉到另一面,我們也看不到燈光的存在,也就是說,虛擬場景中的「燈光」只供光照計算,而非真的有一盞燈。

💡註解
  1. 作業1:請將範例程式 do-try-catch 改成 try? 語法。
  2. 作業2: 請搜尋 “usdz download”,找出免費下載的3D模型(副檔名必須是.usdz),例如:Sketchfab旋轉樓梯,匯入Swift Playground中,然後修改範例程式,將匯入的模型顯示出來。
  3. 對 do-try-catch 不熟悉的同學,請參考第3單元第8課 錯誤處理(Error Handling)3-8a
  4. 或許有人會問:「什麼時候該用 do-try-catch,什麼時候該用 Optional 類型呢」?對有些程式設計經驗的人來講,這是個好問題,但答案並非越簡單越好。
  5. 筆者個人經驗是 do-try-catch 比較囉唆,通常用來應付「需要提醒使用者」或「使用者可以修復的問題」,例如須更改權限(如相機)、開啟WiFi…等等,可以透過 catch 子句提醒使用者。
  6. 其他大部分問題,如果不需要使用者介入,就可以用 Optional 跳過提醒,萬一出錯就直接忽略(例如取消外部模型)或用替代方案即可。
  7. 對3D作品的市場有興趣的讀者,可參考這篇市場分析,光是 EPIC Games 旗下就有 Artstation、Sketchfab、Quixel、Unreal Egine Marketplace 等市集。
補充(19) 用 Blender 製作3D模型

製作3D模型的軟體,除了電影與遊戲需求之外,另一個主要用途是「電腦輔助設計」(Computer-Aided Design, 簡稱 CAD),其中最著名的是歐特克(Autodesk)公司1982年推出的 AutoCAD,其應用非常廣泛,從建築、汽車、家電、玩具甚至香水瓶,幾乎所有產品造型都可用AutoCAD設計。

歐特克公司除了 AutoCAD 之外,1990年又發行 3ds Max(或稱3dmax)用以製作3D遊戲與電影特效,2002年併購Revit,2005年併購Maya,在3D建模與特效上更進一步,是目前3D領域最大的軟體公司。

除了歐特克公司之外,另一個大咖是 Adobe,從 2D繪圖的 Photoshop、Illustrator 起家,近年也推出 Aero, Mixamo 與 Substance 擴展到 AR 及 3D繪圖。

此外,還有上百種免費或商業的3D軟體,例如:

1. Blender — 自由軟體,永久免費且開放原始碼,支援macOS, Windows, Linux
2. TinkerCAD — 歐特克公司推出的免費網頁版軟體,易學易用
3. Shapr3D — 專為 iPad 與觸控筆設計的3D軟體,用法非常直覺
4. SketchUp — 擅長建築與室內設計,現為美國Trimble(天寶導航)公司產品
5. Cinema 4D — 德國Maxon公司產品,台灣有代理商
6. ZBrush — 德國Maxon公司產品,塑造人物非常逼真
7. NVIDIA Omniverse — 輝達2021年推出的免費軟體,結合3D與AI,可惜只支援配備NVIDIA RTX顯卡的 Windows 電腦。

其中,完全免費且功能強大的 Blender 最合適學生,唯一的缺點是無法在 iPad 上執行,只能用 Mac 或 Windows 操作,而且大量使用鍵盤快速鍵與滑鼠(最好有滾輪)。

Blender 簡介

Blender 3D軟體最早是在1994年開發,作者Ton Roosendaal 是荷蘭人(Python語言作者也是荷蘭人),Blender 主要用於作者自己的動畫工作室,後來融資成立公司推廣,可惜時運不濟,2001年全球網路泡沫風暴,導致公司倒閉。

2002年作者改弦易轍,決定開放原始碼,並成立非營利的Blender基金會,號召用戶社群參與開發維護Blender軟體,迴響相當熱烈,讓Blender重新復活,並擴展到全球。

如今,Blender由300多位程式設計師共同發展維護(約200萬行C++程式碼),功能逐年增加,而且還有許多「外掛」(稱為 extension「附加元件」)與免費素材,網路上教學影片非常豐富,中文網頁、書籍也不少。

Blender 可以做什麼?太多了,說來話長,不如直接看影片。

自2006年開始,Blender基金會每一兩年會推出一部完全以Blender製作的動畫電影,最初片長約10分鐘,去(2024)年第16部動畫影片 “Flow”《喵的奇幻漂流》片長85分鐘,在(2025年)1月剛榮獲金球獎「最佳動畫」,2月全台上映,3月獲得2025年奧斯卡金像獎「最佳動畫電影」。

這部影片完全以 Blender 製作,畫質雖然比不上巨資投入的皮克斯(Pixar)或漫威(Marvel)電影那麼精緻,但勝在角色動作流暢,故事發人省思。

想知道Blender還能做出哪些作品嗎?可以到Blender Nation或是Art Station網站觀摩一下,有許多3D內容創作者在上面交流。

下載並安裝 Blender

Blender 軟體並未上架至 App Store,必須到blender.org官網下載,好處是永久免費且無需註冊,也可離線執行。

下載後,請參考以下影片,從安裝、設定中文介面,只要5分鐘就能做出上一節6-7a所需的 Blender_ex1.usdz 模型檔案。


Blender 功能太豐富,使用介面有點複雜,初次上手可能不太容易,最好能參考教學影片、文章或購買入門書籍,筆者推薦以下幾個教學影片與文章,幫助大家學習:

1. 英文教學影片
2. 中文教學影片,有完整教學系列
3. Blender入門介紹文章

最後附帶一提,“Blender” 字面意思是混合器、攪拌機或常見的果汁機。作者Ton Roosendaal 說之所以挑這個名字,源自Yello樂團1991年發行的一首歌(歌名就叫 “Blender”)。

💡註解
  1. 2001年公司倒閉時,Blender軟體版權屬於公司股東,2002年Ton Roosendaal募款11萬歐元,從股東手上重新買回版權,並開放原始碼,才有如今免費好用的Blender。
  2. 也就是說,Blender 軟體版權在2001年僅值11萬歐元(約300多萬台幣),如今至少升值1,000倍。
  3. Blender 目前版本更新節奏相當快,每4個月(每年3, 7, 11月)推出新版,每年(7月)有一個長期支援(LTS, Long-Term Support, 維護期2年)的版本,為節省人力,除了LTS版本之外,其他舊版即使有bug也不再維護(直接修正到新版)。
  4. [2025/03/03更新] 恭喜由 Blender 基金會贊助的動畫影片 "Flow" 贏得奧斯卡金像獎「最佳動畫電影」
6-7b 下載網路.usdz模型

製作3D模型是非常有趣的事,如果有興趣,可能一兩星期就能熟悉 Blender 操作,再參考模仿別人的作品,半年就能做出精美的3D模型,在未來,「3D動畫設計師」 在 AI 的輔助下,可能是非常好的職業。

完全用 RealityKit 製作的花瓶 (c)2025 Heman Lu

若不想自己動手做模型,網路上也可找到不少現成的 USDZ 模型檔案,例如:

1. Apple 原廠AR Quick Look
2. 美國太空總署NASA
3. 美國史密森學會自然史博物館
4. 輝達NVIDIA USD Example Datasets (非常多素材)
5. Poly Haven (免費3D模型與材質)
6. SideFX Houdini 製作的免費模型(很逼真)
7. 皮克斯 OpenUSD 官網
8. Sketchfab(市集網站,需註冊)
9. Turbosquid (市集網站,需註冊)

上列前三者都不需要註冊,直接就能下載 USDZ 模型。我們可以利用程式 URLSession 下載、暫存到檔案中,再用 RealityView 加到虛擬場景。

第5單元5-4b曾經用過一個檔案下載的函式,正好符合需求,稍微改一下,加到共享程式區:
// 6-7b 公享程式:檔案下載
// Revised by Heman, 2025/02/19
// Based on 5-4b AI模型(with CoreML), 2023/03/17
import Foundation

private enum 錯誤碼: Error {
case 無法取得檔案路徑
case 網址字串有誤
case 其他錯誤
}

// 第6段
public func 檔案下載(_ 網址: String) async throws -> URL {
guard let myURL = URL(string: 網址) else {
throw 錯誤碼.網址字串有誤
}
let 檔案名稱 = myURL.lastPathComponent
let 檔案管理員 = FileManager()
guard let 目錄 = 檔案管理員.urls(
for: .cachesDirectory,
in: .userDomainMask).first else {
throw 錯誤碼.無法取得檔案路徑
}
let 存檔路徑 = 目錄.path + "/" + 檔案名稱
print("下載目標:\(myURL)\n存檔標的:\(存檔路徑)")
if 檔案管理員.fileExists(atPath: 存檔路徑) {
print("檔案已下載過")
} else {
let (原始資料, 錯誤碼) = try await URLSession.shared.data(from: myURL)
print("下載成功:\(原始資料)", 錯誤碼)
_ = 檔案管理員.createFile(atPath: 存檔路徑, contents: 原始資料)
}
let 回傳值 = 目錄.appendingPathComponent(檔案名稱)
print("回傳模型位址:\(回傳值)")
return 回傳值
}

當時(5-4b)函式宣告是傳回 URL?,現在我們知道,throwing function 與 Optional 都是錯誤處理的方式,只要二選一即可,兩種並用就像疊床架屋,沒有必要,因此改傳回 URL:
// 5-4b:
func 下載模型(_ 網址: String) async throws -> URL?
// 6-7b 改為:
func 檔案下載(_ 網址: String) async throws -> URL

這時如果函式遇到意外或錯誤(如網路中斷或網址錯誤),就不能再回傳 nil,而是得拋出錯誤並立刻返回,因此在前面要定義一些錯誤碼,這些錯誤碼只用在本節範例,所以加上 private 以縮小有效範圍(與往後程式才不會衝突):
private enum 錯誤碼: Error {
case 無法取得檔案路徑
case 網址字串有誤
case 其他錯誤
}

對於命名的有效範圍,Swift 程式語言定義了幾種權限,由最開放到最封閉,依次如下(我們只會用到 public 和 private 兩種):
- open          // 可跨不同模組
- public // 可跨不同模組
- internal // 限同一模組,可跨檔案,此為預設值
- fileprivate // 限同一檔案內
- private // 限同一檔案內
上面的錯誤碼我們只用在同一個檔案中,因此用 fileprivate 或 private 都可以。

另外,我們用 guard let 取代之前常用的 if let,目的都是解開 Optional 類型以取值(就像包子取出肉餡)。主要差別是 guard let 所定義的常數,有效範圍較大,else { } 之後仍然可用;而 if let 定義的常數名稱,只能在尾隨的 { } 內有效。

變數、常數的有效範圍實在太重要了,無時不刻影響程式的正確性,請務必充分理解。

有了「檔案下載()」共享程式,再結合上一節6-7a載入.usdz的範例,就能直接抓取網路模型並顯示出來,完整主程式如下:
// 6-7b 下載網路.usdz模型
// Created by Heman Lu, 2025/02/19
// Tested on iMac 2019 (macOS 15.3.1) + Swift Playground 4.6.2
import SwiftUI
import RealityKit

// 備用網址:
// 1. https://developer.apple.com/augmented-reality/quick-look/models/drummertoy/toy_drummer_idle.usdz
// 2. https://developer.apple.com/augmented-reality/quick-look/models/vintagerobot2k/robot_walk_idle.usdz
// 3. https://assets.science.nasa.gov/content/dam/science/psd/solar/2023/09/c/Curiosity_static.usdz
// 4. https://assets.science.nasa.gov/content/dam/science/psd/solar/2023/09/h/Hubble.usdz
// 5. https://3d-api.si.edu/content/document/3d_package:341c96cd-f967-4540-8ed1-d3fc56d31f12/resources/woolly-mammoth-150k.usdz
// 6. https://3d-api.si.edu/content/document/3d_package:2e7bbd69-c355-473e-a024-d468c05d5a9f/resources/usnm_1127949-150k.usdz
struct 下載網路3D模型: View {
@State var 啟動 = false
let 網址 = "https://developer.apple.com/augmented-reality/quick-look/models/drummertoy/toy_drummer_idle.usdz"

var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b
if let fileURL = try? await 檔案下載(網址), // 共享程式6-7b
let 網路模型 = try? await ModelEntity(contentsOf: fileURL) {
網路模型.name = "網路模型"
網路模型.scale = [0.1, 0.1, 0.1]
內容.add(網路模型)
print(內容.entities)
}
} update: { 內容 in
for 個體 in 內容.entities where 個體.name == "網路模型" {
個體.availableAnimations.forEach { 動畫 in
if 啟動 {
個體.playAnimation(動畫.repeat(), transitionDuration: 10, startsPaused: false)
} else {
個體.stopAllAnimations()
}
}
}
} placeholder: {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Waiting...")
ProgressView()
}
}
.realityViewCameraControls(.dolly)
.overlay(alignment: .bottom) {
Button("動作", systemImage: 啟動 ? "pause.fill" : "play.fill") {
啟動.toggle()
}
.buttonStyle(.bordered)
.font(.title)
.padding()
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(下載網路3D模型())

注意程式中,連用了兩個 try? await,會依次執行,這個語法在上一節6-7a提過,相當好用,在我們已熟悉 do-try-catch 標準句型之後,可以變化一下。

在備用網址中,Apple 原廠的 USDZ 模型會包含一些動畫,因此我們在最後一段加了「動作」按鈕(Button),用來開關狀態變數「啟動」,然後在 update: 更新的匿名函式中,啟動動畫。

這裡用來啟動、停止動畫的方法是「個體.playAnimation()」、「個體.stopAllAnimations()」,在後面課程專門介紹「動畫」時還會遇到,此處暫不解說。

操作與執行的過程如下:

最後還有兩個地方值得注意。

這次 RealityView { } 後面的視圖修飾語稍微不同,控制虛擬相機(視角)的方式改用 .dolly:
.realityViewCameraControls(.dolly)

RealityView 控制視角的參數,仿照拍電影時的運鏡方式,選項包括:

- .dolly — 利用軌道車將鏡頭前後(dolly-in/out)或左右(dolly-left/right)移動。
- .orbit — 鏡頭沿圓形軌道運動
- .pan — 橫搖(鏡頭位置不動,角度左右變化)
- .tilt — 直搖(鏡頭位置不動,角度上下變動)

改用 .dolly 的原因,是因為下載的 .usdz 模型可大可小,有些會超出螢幕,若用 .orbit 可能無法縮放(若有觸控板,還是能用手勢縮放,若只有滑鼠則不行)。

第二個要注意的地方,是下載的「網路模型」,包含了「動畫庫元件」(AnimationLibraryComponent),因此才有動畫可以啟動。
  ▿ '網路模型' : ModelEntity
⟐ SynchronizationComponent
⟐ ModelComponent
⟐ SkeletalPosesComponent
⟐ AnimationLibraryComponent
⟐ Transform
賈伯斯的遺產(2): 皮克斯(Pixar)

1995年聖誕節黃金檔期,全世界第一部完全由電腦製作的3D動畫電影,由迪士尼發行的「玩具總動員」(Toy Story)全球上映,在許多國家造成萬人空巷,首週票房就超過3.6億美金(約100億新台幣)。

這是賈伯斯(Steve Jobs)第一次擔任製作人的電影,一夕之間翻轉動畫產業的製作方式,由傳統手工繪製,全面改由電腦製作。賈伯斯藉此在好萊塢闖出名號,有了這個名聲,日後(2001年) Apple 推出 iPod 時,才能順利打入音樂產業。

在第4單元番外篇「賈伯斯的遺產」曾提過,賈伯斯在1985年被蘋果董事會踢出公司,賈伯斯憤而創立另一家公司 NeXT,想做出更棒、更人性化的電腦,其中的作業系統 NeXTSTEP,後來轉變為 OS X,成為今日 macOS/iOS 的前身。

除此之外,賈伯斯還留下另一筆遺產,不但影響3D繪圖與AR,也改變整個電影動畫產業,那就是「玩具總動員」的原產地:皮克斯(Pixar)。

上一節(6-7b)所下載的 .usdz 模型,就是仿自「玩具總動員」裡面的角色風格。能夠在檔案裡面同時儲存3D模型與動作,是不是很神奇?這裡面儲存的動作,並不像電影是連續畫面,而是動畫參數,因此檔案很小(相對於影片檔)。

有了 USD 格式,就可將整個虛擬場景(包含所有3D物件、燈光、材質、動畫、特效等),打包存入檔案中,方便不同軟體的資料互通,在電影工業與3D領域很受歡迎。

2023年皮克斯進一步聯合蘋果(Apple)、歐特克(Autodesk)、Adobe、輝達(NVIDIA)等重量級公司,共同成立OpenUSD聯盟,致力於3D場景與動畫特效的互通。支援的軟體有 NVIDIA OmniverseAutodesk MayaAdobe Substance 3D等數十種。

為什麼說皮克斯是賈伯斯的遺產呢?

皮克斯前身是以《星際大戰》、《法櫃奇兵》等賣座電影聞名的導演喬治・盧卡斯(George Lucas),其製片公司的「電腦動畫部門」(Graphics Group of Lucasfilm Computer Division),1986年賈伯斯以5百萬美金買下來(再加5百萬美元資本額,合計投入1千萬美元),更名為皮克斯。

賈伯斯1985年離開蘋果,創立 NeXT 電腦公司之外,竟然還去買一家動畫公司,似乎與 「做出更棒、更人性化的電腦」毫無相關,是想改行拍電影嗎?還是為什麼?

原因是賈伯斯還在蘋果之時,曾經拜訪過這個動畫部門,之後強烈建議蘋果加以併購,可惜當時董事會並未採納。賈伯斯說:「他們這群人在動畫領域超前世人非常非常多」[引用來源]。
“These guys were way ahead of us on graphics, way ahead. They were way, way ahead of anybody. I just knew in my bones that this was going to be very important.”

有多超前呢?我們從1986年皮克斯的第一支動畫短片《頑皮跳跳燈》,稍可看出端倪:

1986年原始影片:[Pixar Shorts Collection Luxo Jr 1986 YouTube]

2022年改編為電影《頑皮跳跳燈》

能想像這是40年前的電腦動畫技術嗎?我們在學過 SceneKit 與 RealityKit 之後,更可看出這些3D模型的材質、光影等效果的逼真程度,動作流暢自然(特別注意關節部位),即使放在今天也是相當創新的作品。

為什麼皮克斯的電腦動畫技術這麼厲害?

這與其中一位創始人卡特姆(Edwin E. Catmull)有關,卡特姆師出名門,是第1課(6-1d)提過電腦圖學之父 — 伊凡・蘇澤蘭(Ivan Sutherland)指導的博士生,當時1970年代的猶他大學是研究3D繪圖的重鎮,也是VR/AR技術的開拓者。

卡特姆在1974年拿到博士學位之後,與幾位同學一起受邀到紐約技術學院(NYIT),創立電腦動畫實驗室(Computer Graphics Lab),開發電影用的電腦特效,受到好萊塢關注,1979年被喬治・盧卡斯挖角到其製片公司(Lucasfilm),成為電腦動畫部門主管,然後1986年遇到真正的伯樂 — 賈伯斯。

皮克斯初期因為迪士尼的支持,才得以生存,但某個程度來說,皮克斯也拯救了迪士尼,如果迪士尼沒有在一開始就與皮克斯合作,隨著電腦動畫成為主流,擅長手工2D動畫的迪士尼可能很快被淘汰出局。

迪士尼於2006年以74億美金正式將皮克斯併購進來,賈伯斯(靠最初1千萬美元的投資)成為迪士尼最大個人股東。從1995年到2023年為止,皮克斯共製作了27部電影,給迪士尼帶來超過150億美金營收,可說是一筆雙贏的併購交易。

💡 註解
  1. 電影從製作到上映,通常分成製作、發行、院線三個部分,由不同公司分工合作,以「玩具總動員」為例,製作公司是皮克斯,所以賈伯斯擔任製作人,發行公司是迪士尼,由迪士尼將影片分派到各地院線上映。
  2. 「跳跳燈」之後成為皮克斯的招牌吉祥物。
  3. 2025年2月Apple公布第一款機器人開發框架,原型靈感即來自「跳跳燈」。
  4. OpenUSD 未來發展潛力如何?可以看看 NVIDIA Omniverse 的示範影片,並且和1986年的《跳跳燈》比較一下,就可知道40年來動畫技術進步多少。
  5. 支援USD檔案格式的軟體目前有48個(2025年2月)。
6-7c 材質、紋理、天空盒

學習3D繪圖基本觀念,首先要理解空間座標,以及空間中的位移、旋轉、縮放;其次是觀察並思考模型的外形、明暗、色彩是如何呈現。

影響模型外觀的因素有好幾個,包括:
1. 幾何外型,也就是 mesh 參數
2. 材質,由 materials 參數所控制
3. 材質又細分為顏色(color/tint)、紋理(texture)、粗糙度(roughness)…等10幾個屬性
4. 照在模型表面的燈光(Light)或環境景象(IBL, Image-Based Lighting)
5. 模型所在位置與面向哪個角度
6. 使用者視角(也就是虛擬鏡頭的位置與角度)

以上每個因素都必須經過CPU或GPU計算,簡單地說,螢幕上呈現的每一個像素顏色,都是上面所有因素的計算結果,這個計算過程稱為渲染(Rendering)。

在第6課的範例中,我們大多採用簡單材質(SimpleMaterial),簡單材質雖然也是基於物理渲染,但只有色調(color)、金屬性(metallic)、粗糙度(roughness)、線框(triangleFillMode)等屬性。

相對於簡單材質,物理材質(PhysicallyBasedMaterial)的屬性比較完整,包括:
# 屬性名稱 子屬性 說明 章節
1 baseColor tint, texture 基礎色調 6-6a
2 roughness scale, texture 粗糙度 6-7c
3 metallic scale, texture 金屬性質(反光特性) 6-6a
4 normal texture 法線貼圖 6-7c
5 emissiveColor color 發光色調
發光強度預設值1.0
材質.emissiveIntensity = 1.0
6-7c
6 ambientOcclusion texture 漫射遮罩 -
7 specular scale, texture 高光 -
8 clearcoat scale, texture 透明漆(清漆) -
9 clearcoatRoughness scale, texture 透明漆+粗糙度 -
10 clearcoatNormal texture 透明漆+法線貼圖 -
11 name - 賦予材質名稱 -
12 opacityThreshold - 透明度門檻 -
13 blending .opaque
.transparent()
與背景混合方式(透明、不透明) 6-7c
14 triangleFillMode .fill
.lines
顯示線框(lines)或渲染材質 6-7c
子屬性中,tint(色調)、color(顏色)需要輸入 UIColor 值,scale (大小尺度)是浮點數(通常是正規化到0.0~1.0之間)。

texture(紋理)類型比較特殊,要先匯入圖檔,再用 TextureResource 產出,這種方式稱為「貼圖」或「貼皮」,參考以下範例(材質4、材質5)。

在執行範例程式之前,請先下載兩個檔案:

1. 皮革紋理(點選「下載」、1280x853,存檔名稱為 “background-1838494_1280.jpg”)
2. 威尼斯清晨(會出現「雲端硬碟無法為這個檔案掃描病毒」,選「仍要下載」,存檔名稱為”威尼斯清晨.realityenv”)

下載後,匯入Swift Playground,如下圖:


完整程式碼如下:
// 6-7c 材質、紋理、天空盒
// Created by Heman Lu, 2025/02/25
// Tested on iMac 2019 (macOS 15.3.1) + Swift Playground 4.6.2
//
import SwiftUI
import RealityKit

// 皮革紋理: https://pixabay.com/zh/photos/background-leather-brown-close-up-1838494/
// 威尼斯清晨: https://drive.usercontent.google.com/download?id=1y-8hbbZ5viZ86YubAJWgfRhguzJOCLxM
struct 材質紋理天空盒: View {
var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b

let 圓球 = MeshResource.generateSphere(radius: 0.25)

var 基本材質 = PhysicallyBasedMaterial()
基本材質.roughness = 0.1
基本材質.metallic = 0.9

var 材質1 = 基本材質
材質1.triangleFillMode = .lines
var 材質2 = 基本材質
材質2.blending = .transparent(opacity: 0.5)
var 材質3 = 基本材質
材質3.emissiveIntensity = 0.9
材質3.emissiveColor.color = .yellow
var 材質4 = 基本材質
var 材質5 = UnlitMaterial()
if let 紋理 = try? await TextureResource(named: "background-1838494_1280.jpg") {
材質4.normal.texture = .init(紋理)
材質5.color.texture = .init(紋理)
}

let 圓球模型1 = ModelEntity(mesh: 圓球, materials: [材質1])
圓球模型1.position = [-1.0, 0.25, 0]
let 圓球模型2 = ModelEntity(mesh: 圓球, materials: [材質2])
圓球模型2.position = [-0.5, 0.25, 0]
let 圓球模型3 = ModelEntity(mesh: 圓球, materials: [材質3])
圓球模型3.position = [0, 0.25, 0]
let 圓球模型4 = ModelEntity(mesh: 圓球, materials: [材質4])
圓球模型4.position = [0.5, 0.25, 0]
let 圓球模型5 = ModelEntity(mesh: 圓球, materials: [材質5])
圓球模型5.position = [1.0, 0.25, 0]
內容.add(圓球模型1)
內容.add(圓球模型2)
內容.add(圓球模型3)
內容.add(圓球模型4)
內容.add(圓球模型5)

let 底座 = ModelEntity(mesh: .generateBox(width: 2.5, height: 0.04, depth: 0.5))
var 材質6 = 材質5
材質6.color.tint = .gray
底座.model?.materials = [材質6]
底座.position.y = -0.02
底座.name = "底座"
內容.add(底座)

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

print(內容.entities)
} .realityViewCameraControls(.orbit)
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(材質紋理天空盒())

範例中有5種材質,執行結果如下:

這5種材質設定如下,前3種用內建的材質屬性,分別渲染出線框(triangleFillMode = lines)、透明玻璃(blending = .transparent())、發光燈罩(emissiveColor.color = .yellow),注意這三者反光的建築物都相當清楚,因為粗糙度 roughness = 0.1、金屬性 metallic = 0.9 的關係。
var 基本材質 = PhysicallyBasedMaterial()
基本材質.roughness = 0.1
基本材質.metallic = 0.9

var 材質1 = 基本材質
材質1.triangleFillMode = .lines
var 材質2 = 基本材質
材質2.blending = .transparent(opacity: 0.5)
var 材質3 = 基本材質
材質3.emissiveIntensity = 0.9
材質3.emissiveColor.color = .yellow
var 材質4 = 基本材質
var 材質5 = UnlitMaterial()
if let 紋理 = try? await TextureResource(named: "background-1838494_1280.jpg") {
材質4.normal.texture = .init(紋理)
材質5.color.texture = .init(紋理)
}

最後兩個(材質4、材質5)我們加入紋理貼圖,是不是變得很不一樣?因為是用真實照片,因此貼圖可讓模型更近似真實物體,程式寫法也很簡單,語法幾乎和前兩節的 try? await ModelEntity() 沒什麼兩樣。

比較特別是「材質4」所用的屬性(normal.texture)稱為「法線貼圖」,在三度空間中,法線是指與某平面垂直的向量,法線貼圖會根據圖片灰階度決定向量長度,因此出現表面高低起伏的樣子,不再是平滑表面,所以反光的建築顯得模糊不清。

另外,「材質5」表面完全看不到反光的影子,這是因為用了 UnlitMaterial()。lit 是點燃(蠟燭)、點亮(電燈)的動詞,unlit 就是熄滅、不亮的意思,UnlitMaterial 是不反光材質,完全不參與光照計算,只根據本身貼圖或顏色來渲染外觀。

還有個語法要提醒,物理材質(PhysicallyBasedMaterial)是 struct 物件,用於指定句時,內容值會複製一份再指定給新變數,此例正好與上一課6-6d 旋轉樓梯的 class 物件必須用 .clone() 來複製新變數,兩者成明顯對比。

最後,背景換成真實場景,似乎讓整個畫面活過來,做法卻非常簡單,只要兩三行:
if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}

在虛擬空間中的環境影像稱為「天空盒」(Skybox),內容是一張720°環景照片,涵蓋前後左右上下四方,因此從任何角度看,都像處在一個真實場景的空間中,和擴增實境(AR)的效果相近。

比較麻煩是匯入的檔案「威尼斯清晨.realityenv」如何製作,這是筆者經過一番嘗試才成功,尤其對 Swift Playground 而言,原廠文件或是網路搜尋都找不到資料,本範例應該是全世界第一個將天空盒(skybox)用在 Swift Playground 的案例。

下節再補充說明天空盒製作過程,先來看看天空盒產生的效果,是不是感覺身臨其境?


💡註解
  1. 紋理(Texture)除了貼圖之外,另一種是用 Metal 框架(透過GPU計算)的低階紋理(LowLevelTexture),難度較高,不在本單元介紹範圍。
  2. 天空盒所需背景「威尼斯清晨」環景照片,取自Poly Haven 網站,作者Greg Zaal & Rico Cilliers
補充(20) 如何製作天空盒(Skybox)

關於天空盒的製作,Apple 原廠文件說明相當簡略,很多人做不出來,筆者也是如此,經過一番摸索與嘗試才成功。

To add an environment resource to your Xcode project, make a folder with a name that ends in .skybox and place a single image inside. Ensure that the image is an environment map of equirectangular projection, also known as a latitude-longitude projection. Drag the folder into the Project navigator. In the options pane, choose to create a folder reference (not a group), and add the folder to your app’s targets. At build time, Xcode compiles the image for use as an environment resource and inserts the result into the app bundle.

RealityKit supports the same input formats as Image I/O, such as .png and .jpg However, to achieve rich, vibrant lighting, use a .exr or .hdr format, which support a wide dynamic range.

要製作天空盒,必須使用 Xcode,並分成幾個步驟:

(1) 在 Xcode 中新增一個檔案夾,名稱以 .skybox 結尾
(2) 檔案夾內放入一個720°環景圖檔(可以是 .png, .jpg, .exr, .hdr)
(3) 預覽時 Xcode 會將檔案夾編譯成環境資源(也就是 .realityenv 檔)

詳細過程參考以下範例,筆者所用軟硬體為 Xcode 16.2 + iMac 2019 (macOS 15.3.1)。

1. 開啟 Xcode,”Create New Project…”
2. 選擇 “macOS” → “App”


3. 取個名稱,選擇 SwiftUI,按 “Next”,選擇放置目錄(隨意)


4. 等候ContentView.swift範例執行


5. 打開瀏覽器,到Poly Haven網站下載一張全景圖(venice_dawn_2_1k.exr)


6. 在 Xcode 新增檔案夾 “威尼斯清晨.skybox”,並加入全景圖”venice_dawn_2_1k.exr”(必須在「威尼斯清晨.skybox」目錄之下」



7. 更換程式 ContentView.swift 內容如下,預覽結果應該會正確顯示天空盒(需要Xcode 16以上版本):
//
// ContentView.swift
// Skybox
//
// Created by Heman Lu on 2025/3/12.
//

import SwiftUI
import RealityKit

struct ContentView: View {
var body: some View {
VStack {
RealityView { 內容 in
if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
}
.realityViewCameraControls(.orbit)
}
}
}

#Preview {
ContentView()
}



8. 對 Xcode 來說,這樣就完成天空盒了。但這個做法,不適用於 Swift Playground,無論是電子書模式或是App模式都不行(因為沒有編譯天空盒的工具),必須繼續以下步驟。

9. 接下來,將 “venice_dawn_2_1k.exr” 檔案刪除,更換另一個圖檔(隨便一張圖),這時候會出現錯誤訊息,如下圖:


10. 點選錯誤訊息,裡面有我們要的目錄位置,將目錄位置拷貝下來(每個人的目錄都不同,類似這樣 — ”/Users/hemanlu/Library/Developer/Xcode/DerivedData/Skybox-ewzfycsmfowmqsaxxztprmwguaxg/Build/Products/Debug/Skybox.app/Contents/Resources/威尼斯清晨.realityenv”)
按滑鼠右鍵,選 “Services” → “顯示於Finder”


11. 就可找到「威尼斯清晨.realityenv」。如果沒有顯示檔案,可以在 Finder 選單中,按「前往」→「前往檔案夾」,將目錄貼上去:


12. 將「威尼斯清晨.realityenv」拉到桌面,再匯入 Swift Playground 就可以用了(參考上一節 6-7c 材質、紋理、天空盒)。

至於720°環景照片哪裡找,可以 Google 搜尋 “.exr panorama download”,例如:

1. PolyHaven.com
2. Textures.com
3. NVIDIA 高解析度環景圖(16GB)

另外,如果想自己拍照製作環景圖,其實也不難,可以參考這篇文章「製作720度全景影像」、這篇「360°全景、720°全景、VR有什麼差別?」。

💡註解
  1. 步驟9, 10是利用 Xcode 的一個小瑕疵 — 不能更換 .skybox 目錄下的環景圖,否則會出錯。如果想變更環景圖,必須新增一個不同名稱的 .skybox 檔案夾。
第8課 客製化幾何模型

RealityKit 內建模型種類雖然不多,但提供了外部導入及客製化模型的做法,才能滿足各種應用的需求,本課就來學習如何製作客製化模型,透過程式產生各式各樣的模型。

RealityKit 客製化模型的做法,目前主要分成兩種:
  1. 擠出成型(Extrusion):利用2D形狀(Shape),在空間中透過座標變換(平移、旋轉、縮放)的軌跡來製作3D物件。
  2. 網格描述(MeshDescriptor):在空間座標中,用一個個三角形幾何座標描繪出3D物件的骨架外型。
兩種都可以製作出非常多樣(理論上是無限多)的模型,本課先介紹擠出成型,下一課再介紹網格描述。

6-8a 用擠出成型(Extrusion)做客製化模型

擠出成型(Extrusion)一詞最早用於橡膠與塑膠工業,後來也延伸到金屬或陶瓷製品,方法是用熔融的原料(橡膠、塑膠微粒、金屬、陶瓷漿料),透過高壓擠出原料,經由預先設計好的模孔,塑造成各類容器或物品。

RealityKit 預設的立體文字就是用擠出成型,將2D的文字字型,沿Z軸擠出一定深度,而產生3D文字。預設的擠出深度(extrusionDepth)為0.25公尺(字體大小的單位也是公尺):
let 立體字 = MeshResource.generateText("LOVE", extrusionDepth: 0.25)

除了用2D字型,RealityKit 也支援其他2D圖形,只要符合 Shape 規範(參考第4單元第8課 正多邊形 — Shape ),均可擠出成型。

還記得在第6課(如6-6d 旋轉樓梯)我們用組合的方式來製作新模型,組合模型有個缺點,就是只能做模型「疊加」,RealityKit 目前不能「相減」,所以無法用組合方式做出凹陷或有孔洞的模型。

相對的,用擠出成型做空心物品就簡單很多,本節先來做一個簡單的「空心管」,從以下圖解可看出,擠出成型是如何將2D形狀變成3D模型,與6-6c的z軸縮放效果很像,但背後原理不同。等充分理解本節之後,再想想看有何不同?


程式的寫法,首先,利用第4單元第8課學過的形狀(Shape)與畫筆(Path),畫一個「同心圓」,將這部分程式放在共享區:
// 6-8a 共享程式:同心圓
// Created by Heman Lu, 2025/03/05
// Tested on iMac 2019 (macOS 15.3.1) + Swift Playground 4.6.3
import SwiftUI

public struct 同心圓: Shape {
private let 內徑比: CGFloat // 內徑比 = 內徑 / 外徑

public init(內徑比 r: CGFloat = 0.7) {
self.內徑比 = r
}

public func path(in 畫框: CGRect) -> Path {
let 寬 = 畫框.width
let 高 = 畫框.height
let 原點 = 畫框.origin // 空間座標的原點左下角
let 外徑 = min(寬, 高) / 2
let 內徑 = 外徑 * 內徑比 // 若 內徑比≤0 或 內徑比>1 如何處理?
let 中心 = CGPoint(x: 原點.x + 寬/2, y: 原點.y + 高/2)

var 畫筆 = Path()
畫筆.addArc(center: 中心,
radius: 外徑,
startAngle: .degrees(0),
endAngle: .degrees(360),
clockwise: false) // 逆時針
if 內徑 > 0 {
畫筆.addArc(center: 中心,
radius: 內徑,
startAngle: .degrees(0),
endAngle: .degrees(360),
clockwise: true) // 順時針,內、外圈方向須相反
}
畫筆.closeSubpath()

return 畫筆
}
}

這個形狀的畫法很簡單,就是畫兩個同心圓,在兩個圓之間可填上顏色,當作空心管的管壁。

要注意這兩個圓的畫筆方向必須相反(一個逆時針、另一個順時針),以遵循 SwiftUI 的「填色規則」,如果兩個圓是相同方向,填色時會覆蓋整個大圓與小圓,這樣擠出成型時就變成實心管了。

這個2D形狀「同心圓」可以寫個小程式來確認一下填色部分:
// Tested by Heman, 2025/03/05
import SwiftUI

struct 顯示同心圓: View {
var body: some View {
同心圓() // 共享程式6-8a
.fill(.orange)
}
}
import PlaygroundSupport
PlaygroundPage.current.setLiveView(顯示同心圓())

這段程式執行結果應該如下圖。


這裡正好複習一下 SwiftUI 設計原則:在設計視圖(View)或形狀(Shape)時,盡量不要一開始就決定尺寸大小。例如上面(共享程式6-8a)的 struct 同心圓,並未指定實際的寬或高,而是到最後,父視圖給多少空間,就畫多大,因此視圖或形狀可大可小,這是 SwiftUI 能適應不同尺寸設備的重要原因。

相對的,RealityKit 或 UIKit 通常先決定好尺寸,再計算內容的大小、位置。

接下來,主程式如下,分成兩段,擠出成型的函式「製作空心管()」做成 MeshResource 的類型方法,要用時就仿照 MeshResource.generateText(),改呼叫 “MeshResource.製作空心管()”:
// 6-8a 空心管
// Created by Heman Lu, 2025/03/05
// Tested on iMac 2019 (macOS 15.3.1) + Swift Playground 4.6.3
//
import SwiftUI
import RealityKit

extension MeshResource {
static func 製作空心管(
長 l: Float = 1.0,
外徑 r1: CGFloat = 0.5,
內徑 r2: CGFloat = 0.4
) async throws -> MeshResource {
var 變換矩陣陣列: [simd_float4x4] = []

let 分段 = 20
for i in 0...分段 {
let 位移 = -l * 0.5 + l * Float(i) / Float(分段)
let 變換矩陣 = Transform(translation: [0, 0, 位移])
變換矩陣陣列.append(變換矩陣.matrix)
}

var 選項 = MeshResource.ShapeExtrusionOptions()
選項.extrusionMethod = .traceTransforms(變換矩陣陣列)

// 原點在左下角
let 外框 = CGRect(x: -r1, y: -r1, width: r1*2.0, height: r1*2.0)
let 形狀 = 同心圓(內徑比: r2 / r1) // 共享程式6-8a

let 結果 = try await MeshResource(
extruding: 形狀.path(in: 外框),
extrusionOptions: 選項)
return 結果
}
}

struct 顯示空心管: View {
var body: some View {
同心圓()
.fill(.gray)
.frame(height: 80)
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b

var PVC材質 = PhysicallyBasedMaterial()
PVC材質.baseColor.tint = .gray
PVC材質.roughness = 0.2
PVC材質.metallic = 0.1
if let 空心管 = try? await MeshResource.製作空心管(
長: 1.2, 外徑: 0.1, 內徑: 0.08) {
let 模型 = ModelEntity(mesh: 空心管, materials: [PVC材質])
內容.add(模型)
}
if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
}
.realityViewCameraControls(.orbit)
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(顯示空心管())

設定「類型方法」很簡單,只要用 static func 來宣告即可,static 是靜態的、固定的意思。類型方法通常用來產出該類型的物件實例,宣告的句型如下,先用 extension 延伸該類型,再用 static func 增加一個類型方法,傳回同一類型的物件:
extension MeshResource {
static func 產出空心圓柱() async throws -> MeshResource {
...
}
}

這裡因為最後的擠出成型 let 結果 = try await MeshResource() 是非同步且可能拋出錯誤,所以函式宣告也跟著用 async throws,以省掉自己處理非同步與錯誤的麻煩。

接著,重點來了,擠出成型的原理,就在底下這幾行程式:
var 變換矩陣陣列: [simd_float4x4] = []
let 分段 = 20
for i in 0...分段 {
let 位移 = -l * 0.5 + l * Float(i) / Float(分段) // l = 1.0
let 變換矩陣 = Transform(translation: [0, 0, 位移])
變換矩陣陣列.append(變換矩陣.matrix)
}

2D形狀「同心圓」的初始位置,會放在空間座標的X-Y平面(z=0)。想擠出1公尺長的空心管,我們可從 z=-0.5 到 z=0.5 之間,分成20段,做20+1次座標變換(位移),相當於在21個不同位置生成同一個形狀,再將這些形狀一段一段串起來,就變成3D模型了。

組成3D模型的程式如下,最後一行用 MeshResource() 產出結果,這裡回傳幾何網格(MeshResource),而不是模型個體(ModelEntity) ,這樣材質就可之後再決定。

MeshResource() 擠出成型需要兩個參數,一是2D形狀的Path(與外框位置)當作母版,二是擠出成型的選項,內含座標變換的軌跡(陣列)。
var 選項 = MeshResource.ShapeExtrusionOptions()
選項.extrusionMethod = .traceTransforms(變換矩陣陣列)
// 空間座標:外框原點在XY平面左下角
let 外框 = CGRect(x: -r1, y: -r1, width: r1*2.0, height: r1*2.0)
let 結果 = try await MeshResource(extruding: shape.path(in: 外框), extrusionOptions: 選項)

最後執行結果如下,空心管沿Z軸擠出成型,中心位置對齊空間座標原點:


由此例可以看出,擠出成型的原理非常簡單,但用途很多,因為任何形狀(Shape)都可當母版,擠成3D模型;另一方面,座標變換的軌跡也非常多變,除了位移,還有旋轉與縮放可利用,下一節我們就用旋轉來擠出成型,看能做出什麼有趣的模型。

💡註解
  1. 客製化模型是「透過程式產生多樣的模型」,類似用生成式人工智慧產出3D模型,但背後的方法完全不同,客製化模型做出來的3D模型比較精確,但種類與變化較生成式人工智慧少。
  2. 2024年(iOS/iPadOS 18 或 macOS 15)之後,RealityKit 還有第三種客製化模型的做法,稱為低階網格(LowLevelMesh),可直接配置記憶體與GPU(透過 Metal 框架)。
  3. 牙膏也是擠出成型的例子,擠出來的牙膏通常呈長條狀(圓柱體);另一個例子是麵條,麵條的製作是將麵團擠壓經過孔洞,所以麵條橫截面與孔洞形狀相同。
  4. 本課所用的 Shape/Path 須符合「填色規則」(原廠文件稱為 “even-odd winding rule”),且不可有交叉區域。
  5. 作業1:將「MeshResource.製作空心管()」改為共享程式。
  6. 作業2:請添加一個按鈕,切換線框模式(triangleFillMode = .lines)與填色模式(triangleFillMode = .fill),觀察線框是否分成20段。
  7. 作業3:請修改範例6-6c,將「樓梯板」改成「空心管」。
上一節(6-8a)完成的作業範例
  • 5
內文搜尋
X
評分
評分
複製連結
Mobile01提醒您
您目前瀏覽的是行動版網頁
是否切換到電腦版網頁呢?