還記得在第4單元第6課(如4-6c 滾動軌跡) 我們學過2D平面動畫,透過某個點或線的運動軌跡,可以畫出有趣的曲線與圖案,例如擺線、函數曲線、心臟線等。

RealityKit 擠出成型過程類似,差別只不過是從2D平面拓展到3D空間,原本是一個點或線在平面的運動軌跡,拓展成一個面在空間中的運動軌跡。
不管是2D平面或是3D空間,物體的運動(位移、旋轉)與變形(縮放、鏡像)都可以用座標變換來控制。上一節的空心管就是如此,母版是一個XY平面的同心圓,經由Z軸平移的運動軌跡(記錄在「變換矩陣陣列」中),就得到一個空心管3D模型。
本節要製作的甜甜圈仍依循此程序,先做一個XY平面的圓(假設半徑=0.5),令圓心離開座標原點一段距離(例如 [1, 0]),接著讓整個圓繞Y軸轉一圈(半徑=1),就會得到一個立體的環面(Torus),也就是甜甜圈。
我們曾在6-1b SceneKit內建的幾何模型介紹過 SceneKit 內建的甜甜圈模型,甜甜圈的內徑(ring radius)是繞行路徑的半徑,外徑(pipe radius)則是橫截面的圓半徑,兩者相加,才是甜甜圈(中心點到最外側)的半徑。下圖取自SCNTorus說明文件:

簡單地說,環面(甜甜圈)就是一個小圓(以圓心)在空間中繞另一個大圓所形成,這兩個圓(的平面)彼此垂直。
本節母版形狀是一個圓,直接用 SwiftUI 的 Circle() 即可。唯一需要計算的是母版小圓所在的外框位置,外框原點在空間座標系統中是位於左下角(若是在 SwiftUI 或 Canvas 的螢幕座標系統,原點在左上角):

根據上圖,設定外框的程式碼如下:
// 外框原點在左下角(空間座標)
let 外框 = CGRect(x: r1 - r2, y: -r2, width: r2*2.0, height: r2*2.0)
let 形狀 = Circle().path(in: 外框)
這次我們將「MeshResource.製作甜甜圈()」做成共享程式,請將以下程式碼放在共享區:
// 6-8b 共享程式:製作甜甜圈
// Created by Heman Lu on 2025/03/15
// Tested on iMac 2019 (macOS 15.3.2) + Swift Playground 4.6.3
import SwiftUI
import RealityKit
private enum 錯誤碼: Error {
case 參數不在有效範圍
case 其他錯誤
}
extension MeshResource {
public static func 製作甜甜圈(
內徑 r1: CGFloat = 0.5,
外徑 r2: CGFloat = 0.2,
分段 n: Int = 20) async throws -> MeshResource {
if r1 < 0.0 || r1 < r2 || n < 4 { throw 錯誤碼.參數不在有效範圍 }
var 變換矩陣陣列: [simd_float4x4] = []
for i in 0...n { // 分段 n = 20 by default
let 圓心角弧度 = Float(i * 2) * .pi / Float(n)
let 旋轉 = simd_quatf(angle: 圓心角弧度, axis: [0.0, -1.0, 0.0])
let 變換矩陣 = Transform(rotation: 旋轉)
變換矩陣陣列.append(變換矩陣.matrix)
}
var 選項 = MeshResource.ShapeExtrusionOptions()
選項.extrusionMethod = .traceTransforms(變換矩陣陣列)
// 外框原點在左下角(空間座標)
let 外框 = CGRect(x: r1 - r2, y: -r2, width: r2*2.0, height: r2*2.0)
let 形狀 = Circle().path(in: 外框)
let 結果 = try await MeshResource(
extruding: 形狀,
extrusionOptions: 選項)
return 結果
}
}
如果比較上一節與本節擠出成型的程式碼,會發現極其相似,只是將變換矩陣由Z軸位移改成繞Y軸旋轉:
// 6-8a 空心管
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)
}
// 6-8b 甜甜圈
var 變換矩陣陣列: [simd_float4x4] = []
for i in 0...n { // 分段 n = 20 by default
let 圓心角弧度 = Float(i * 2) * .pi / Float(n)
let 旋轉 = simd_quatf(angle: 圓心角弧度, axis: [0.0, -1.0, 0.0])
let 變換矩陣 = Transform(rotation: 旋轉)
變換矩陣陣列.append(變換矩陣.matrix)
}
主程式如下:
// 6-8b 甜甜圈
// Created by Heman Lu on 2025/03/15
// Tested on iMac 2019 (macOS 15.3.2) + Swift Playground 4.6.3
import SwiftUI
import RealityKit
struct 顯示甜甜圈: View {
var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b
var 巧克力 = PhysicallyBasedMaterial()
巧克力.baseColor.tint = .brown
巧克力.roughness = 0.2
巧克力.metallic = 0.1
// 共享程式6-8b:製作甜甜圈
if let 模型 = try? await ModelEntity(mesh: .製作甜甜圈()) {
模型.model?.materials = [巧克力]
內容.add(模型)
}
if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
}
.realityViewCameraControls(.orbit)
}
}
import PlaygroundSupport
PlaygroundPage.current.setLiveView(顯示甜甜圈())
做成類型方法最大的好處,就是使用上非常方便,凡需要 MeshResource 類型參數的地方,就可用「.製作甜甜圈()」,例如「ModelEntity(mesh: .製作甜甜圈())」。
這不只是省略類型名稱而已,對任何不熟悉的類型而言,若有類型方法或類型屬性可用,直接打 . (半形句號)就可獲得提示,對初學者特別友善。
以下是與上一節的語法比較,看得出差別嗎?兩種用法都可行,就看個人偏好:
// 6-8a 空心管
if let 空心管 = try? await MeshResource.製作空心管() {
let 模型 = ModelEntity(mesh: 空心管, materials: [PVC材質])
內容.add(模型)
}
// 6-8b 甜甜圈
if let 模型 = try? await ModelEntity(mesh: .製作甜甜圈()) {
模型.model?.materials = [巧克力]
內容.add(模型)
}
上面第二種(6-8b)用法中,”模型.model?” 是指模型元件 ModelComponent(動態載入,所以是 Optional 類型,要用時須加上問號?),我們在第6課6-6a提過,ModelComponent 才有 mesh 與 materials 兩個屬性,所以不能寫成 “模型.materials = [巧克力]”。
這樣就完成整個甜甜圈的製作了。

了解原理之後,再將甜甜圈改成「彈簧」變得很簡單,只差一步,就是母版小圓在繞Y軸旋轉的同時,加上Y軸位移,結果如下圖,看得出來怎麼做嗎?留給大家當作業,請動手試試看。

💡註解
- 作業1:請將甜甜圈材質改成線框,觀察分段 n = 20 的實際作用。
- 作業2:請仿照本節範例,寫一個「MeshResource.製作彈簧()」共享程式,並實際顯示出來(範例如上圖)。
- 挑戰題:請配合 TimelineView (參考第4單元第6課 Canvas + TimelineView),設計一個彈簧伸縮的動畫,範例如下。