• 5

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

6-8b 擠出成型:甜甜圈與彈簧

還記得在第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. 作業1:請將甜甜圈材質改成線框,觀察分段 n = 20 的實際作用。
  2. 作業2:請仿照本節範例,寫一個「MeshResource.製作彈簧()」共享程式,並實際顯示出來(範例如上圖)。
  3. 挑戰題:請配合 TimelineView (參考第4單元第6課 Canvas + TimelineView),設計一個彈簧伸縮的動畫,範例如下。
6-8c 用貝茲曲線製作花瓶

上一節提到,擠出成型(Extrusion)就是2D形狀在空間中的運動軌跡,由此可變化出無數3D造型,可玩的花樣非常多。

變化方式一則是透過各式各樣的2D形狀 — 只要符合 Shape 規範的都可當母版;二是空間中的行進路線,例如直線位移、繞圓旋轉、大小縮放等,只要是座標變換(Transform)所控制的都行。

2D形狀是由線段、圓弧或曲線所構成,其中貝茲曲線是製作2D形狀的絕妙工具,在第4單元第7課曾學過,本節就結合貝茲曲線與繞圓旋轉來製作3D花瓶模型。

製作出來的花瓶如下圖,造型十分優美,即是歸功於貝茲曲線:

右上角的方框,顯示所用的2D輪廓,這個輪廓是兩條(幾乎平行的)貝茲曲線所構成,有一定的寬度(對應花瓶的厚度)。

將這個曲線輪廓在空間中繞Y軸旋轉一圈,就得到3D花瓶,原理非常簡單。程式仿照上一節範例,做一個類型方法:「MeshResource.製作花瓶()」,以下程式碼請放在共享區:
// 6-8c 共享程式:製作花瓶
// Created by Heman Lu on 2025/03/20
// 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 製作花瓶(
輪廓 path: Path,
分段 n: Int = 20
) async throws -> MeshResource {
if n < 0 { throw 錯誤碼.參數不在有效範圍 }

var 變換矩陣陣列: [simd_float4x4] = []
for i in 0...n { // 邊界檢查:若 n < 0 會造成閃退
let 弧度: Float = 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(變換矩陣陣列)
選項.boundaryResolution = .uniformSegmentsPerSpan(segmentCount: n)

let 結果 = try await MeshResource(
extruding: path,
extrusionOptions: 選項)
return 結果
}
}

「製作花瓶()」與上一節的主要差異,在參數部分 — 主要參數是「輪廓」,也就是上圖右上角的形狀,取形狀的畫筆(Path)物件當參數,如此一來,透過參數送進不同輪廓,就能產出各式造型的花瓶。

由於輪廓變成參數,因此我們要在主程式中做出「花瓶曲線」,再提供給 「MeshResource.製作花瓶()」,主程式如下,注意其中有兩條相鄰的貝茲曲線(addCurve),且方向相反,第一條往上畫,第二條往下畫:
// 6-8c 貝茲曲線製作花瓶
// Created by Heman Lu on 2025/03/20
// Tested on iMac 2019 (macOS 15.3.2) + Swift Playground 4.6.3
import SwiftUI
import RealityKit

// 花瓶右半輪廓曲線
struct 花瓶曲線: Shape {
func path(in 尺寸: CGRect) -> Path {
let 寬 = 尺寸.width
let 高 = 尺寸.height

var 畫筆 = Path()
畫筆.move(to: .zero)
// 正規化尺寸
畫筆.addLine(to: CGPoint(x: 0.2, y: 0.0))
畫筆.addCurve(to: CGPoint(x: 0.3, y: 0.98),
control1: CGPoint(x: 1.0, y: 0),
control2: CGPoint(x: -0.2, y: 0.9))
畫筆.addLine(to: CGPoint(x: 0.3, y: 1.0))
畫筆.addCurve(to: CGPoint(x: 0.2, y: 0.02),
control1: CGPoint(x: -0.3, y: 1.0),
control2: CGPoint(x: 0.9, y: 0.02))
畫筆.addLine(to: CGPoint(x: 0.0, y: 0.02))

畫筆.closeSubpath()
// 恢復原尺寸
let 縮放矩陣 = CGAffineTransform(scaleX: 寬, y: 高)
return 畫筆.applying(縮放矩陣)
}
}

struct 顯示花瓶: View {
var body: some View {
ZStack(alignment: .topTrailing) {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b

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

// 裂紋.jpg: https://pixabay.com/photos/abstract-pattern-surface-texture-1867395/
if let 紋理 = try? await TextureResource(named: "裂紋.jpg") {
材質.baseColor.texture = .init(紋理)
}

let 外框 = CGRect(x: 0.0, y: 0.0, width: 0.5, height: 0.8)
let 曲線 = 花瓶曲線().path(in: 外框)

// 共享程式6-8c:製作花瓶()
if let 花瓶 = try? await MeshResource.製作花瓶(輪廓: 曲線) {
let 花瓶模型 = ModelEntity(mesh: 花瓶, materials: [材質])
內容.add(花瓶模型)
}

// 天空盒:參考6-7c
if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
}
.realityViewCameraControls(.orbit)
花瓶曲線()
.fill(.primary)
.frame(width: 100, height: 100)
.border(.gray)
.scaleEffect(y: -1.0) // X軸鏡像(上下顛倒)
.padding(5)
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(顯示花瓶())

範例程式所用的花瓶材質取自Pixabay,請下載後更名為「裂紋.jpg」,再導入 Swift Playground,操作過程請參考第7課6-7a

主程式中,透過 ZStack 將3D的 RealityView 與2D的「花瓶曲線()」疊在一起,並且對齊右上角(alignment: .topTrailing),這樣就完成主畫面了,其中 RealityView 視圖範圍內仍可滑動,查看花瓶的各面向。

💡註解
  1. 作業1:請利用貝茲曲線的線上工具,例如 desmos.com,自行設計曲線,取得正規化(0~1)的4個點座標(如下圖),修改到程式中,看製作出來的花瓶長什麼樣。


  2. 作業2:請修改範例程式,更換成以下曲線。這個曲線可做出筆者非常喜歡的瓷器經典造型 — 玉壺春瓶,名稱取自唐代王昌齡詩:「洛陽親友如相問,一片冰心在玉壺」。

    // 花瓶右半輪廓曲線
    struct 花瓶曲線: Shape {
    let 厚度: CGFloat = 0.02 // Normalized to 0~1
    let 底座: [CGPoint] = [ // Normalized to 0~1
    CGPoint(x: 0, y: 0),
    CGPoint(x: 0.3, y: 0),
    CGPoint(x: 0.3, y: 0.02)
    ]
    let 貝茲曲線: [CGPoint] = [ //正規化座標:原點在左下角
    CGPoint(x: 0.3, y: 0.02), // start point
    CGPoint(x: 1.0, y: 0.4), // #1 control point
    CGPoint(x: -0.2, y: 0.5), // #2 control point
    CGPoint(x: 0.15, y: 1) // end point
    ]

    func path(in 尺寸: CGRect) -> Path {
    let 寬 = 尺寸.width
    let 高 = 尺寸.height

    var 畫筆 = Path()
    // (1)畫底座
    if 底座.count > 1 {
    畫筆.addLines(底座)
    }

    if 貝茲曲線.count > 3 {
    // (2)畫貝茲曲線
    畫筆.addLine(to: 貝茲曲線[0])
    畫筆.addCurve(
    to: 貝茲曲線[3],
    control1: 貝茲曲線[1],
    control2: 貝茲曲線[2])

    // (3) 畫瓶口轉折
    畫筆.addLine(to: CGPoint(
    x: 貝茲曲線[3].x - 厚度,
    y: 貝茲曲線[3].y))

    // (4)畫返回貝茲曲線
    let 控制點1 = CGPoint(
    x: 貝茲曲線[2].x - 厚度,
    y: 貝茲曲線[2].y)
    let 控制點2 = CGPoint(
    x: 貝茲曲線[1].x - 厚度,
    y: 貝茲曲線[1].y)
    let 終點 = CGPoint(
    x: 貝茲曲線[0].x - 厚度,
    y: 貝茲曲線[0].y + 厚度)
    畫筆.addCurve(to: 終點, control1: 控制點1, control2: 控制點2)

    // (5)畫返回底座
    畫筆.addLine(to: CGPoint(
    x: 貝茲曲線[0].x - 厚度,
    y: 貝茲曲線[0].y))
    畫筆.addLine(to: CGPoint(
    x: 0,
    y: 貝茲曲線[0].y))
    }
    畫筆.closeSubpath()
    // 恢復原尺寸
    let 縮放矩陣 = CGAffineTransform(scaleX: 寬, y: 高)
    return 畫筆.applying(縮放矩陣)
    }
    }
補充(21) 手動調整貝茲曲線

上一節【作業1】提到有些線上工具可手動調整貝茲曲線,其實網頁能做到的,Swift 也一樣能做到,這是因為網頁瀏覽器的功能只是電腦或手機的一部分(網頁程式通常用 Javascript 來寫,而 Swift 程式則涵蓋全電腦/全手機功能),所以 Swift 功能涵蓋面一定超過網頁程式。

本節就示範用 SwiftUI 做一個手拉曲線的繪圖板,之後還可以整合到花瓶製作中,在調整貝茲曲線時,同步更新花瓶的造型。

手拉曲線的功能主要利用拖曳手勢,在第4單元4-9c 手動輪播(一)介紹過,可透過「拖曳參數.translation」來取得滑動位移,位移多少,點座標就跟著移動多少。比較麻煩的地方,其實是螢幕座標與正規化座標之間的轉換。

程式執行的影片如下,貝茲曲線的4個點座標均可調整,移動可超出視圖範圍(讓曲線可充分調整):

這個程式稍長,但不難理解,主要由兩個物件組成:「花瓶曲線」是形狀(紅色部分),取自上一節作業2;「曲線繪圖板」是視圖,用Canvas畫出外框與4個可調整的點座標,覆蓋在花瓶曲線之上(黑色部分)。

下面的程式碼當中,「花瓶曲線」的「曲線」屬性沒有初始化,改由「曲線繪圖板」控制,透過參數送進來,在拖曳手勢調整座標時,同步畫出新的花瓶曲線。

曲線繪圖板有三個區塊:
(1)「最近索引點()」函式計算手勢起始位置最近的點,當作拖曳目標;
(2)「拖曳手勢」負責調整點座標;
(3)「畫布Canvas」畫出外框,並及時更新貝茲曲線4個點的小圓。

以下是完整的程式碼:
// 6-8(補充) 手動調整貝茲曲線
// Created by Heman Lu on 2025/03/22
// Tested on iMac 2019 (macOS 15.3.2) + Swift Playground 4.6.3
import SwiftUI
// import RealityKit

// 花瓶右半輪廓曲線
struct 花瓶曲線: Shape {
let 厚度: CGFloat = 0.02 // Normalized to 0~1
let 底座: [CGPoint] = [ // Normalized to 0.0~1.0
CGPoint(x: 0, y: 0),
CGPoint(x: 0.3, y: 0),
CGPoint(x: 0.3, y: 0.02)
]
let 曲線: [CGPoint] // 4個正規化點座標(起點、控制點1、控制點2、終點)

func path(in 尺寸: CGRect) -> Path {
let 寬 = 尺寸.width
let 高 = 尺寸.height

var 畫筆 = Path()
// (1)畫底座
if 底座.count > 1 {
畫筆.addLines(底座)
}

if 曲線.count > 3 {
// (2)畫貝茲曲線
畫筆.addLine(to: 曲線[0])
畫筆.addCurve(
to: 曲線[3],
control1: 曲線[1],
control2: 曲線[2])

// (3) 畫瓶口轉折
畫筆.addLine(to: CGPoint(
x: 曲線[3].x - 厚度,
y: 曲線[3].y))

// (4)畫返回貝茲曲線
let 控制點1 = CGPoint(
x: 曲線[2].x - 厚度,
y: 曲線[2].y)
let 控制點2 = CGPoint(
x: 曲線[1].x - 厚度,
y: 曲線[1].y)
let 終點 = CGPoint(
x: 曲線[0].x - 厚度,
y: 曲線[0].y + 厚度)
畫筆.addCurve(to: 終點, control1: 控制點1, control2: 控制點2)

// (5)畫返回底座
畫筆.addLine(to: CGPoint(
x: 曲線[0].x - 厚度,
y: 曲線[0].y))
畫筆.addLine(to: CGPoint(
x: 0,
y: 曲線[0].y))
}
畫筆.closeSubpath()
// 恢復原尺寸
let 縮放矩陣 = CGAffineTransform(scaleX: 寬, y: 高)
return 畫筆.applying(縮放矩陣)
}
}

// 手拉曲線
struct 曲線繪圖板: View {
let 繪板寬: CGFloat = 300
let 繪板高: CGFloat = 500
@State var 手拉曲線: [CGPoint] = [ //正規化(數學)座標,原點在左下角
CGPoint(x: 0.3, y: 0.02), // start point
CGPoint(x: 1.0, y: 0.2), // #1 control point
CGPoint(x: 0.01, y: 0.7), // #2 control point
CGPoint(x: 0.1, y: 1) // end point
]
@State var 手勢開始 = false
@State var 上次座標: CGPoint = .zero
@State var 目標索引: Int = 0 // 貝茲曲線的索引(0...3)

func 最近點索引(位置: CGPoint, 點陣列: [CGPoint]) -> Int {
var 索引: Int = 0
var 目前距離: CGFloat = .infinity

for i in 點陣列.indices {
let 兩點距離 = sqrt(
pow(點陣列[i].x - 位置.x, 2) +
pow(點陣列[i].y - 位置.y, 2)
)
if 兩點距離 < 目前距離 {
目前距離 = 兩點距離
索引 = i
}
}
return 索引
}

var 拖曳手勢: some Gesture {
DragGesture(minimumDistance: 5.0)
.onChanged { 參數 in
if 手勢開始 == false {
手勢開始 = true
let 轉換點座標 = 手拉曲線.map { 正規化座標 in
CGPoint(
x: 正規化座標.x * 繪板寬,
y: 繪板高 - 正規化座標.y * 繪板高
)
}
目標索引 = 最近點索引(位置: 參數.location, 點陣列: 轉換點座標)
if 目標索引 > -1 && 目標索引 < 4 { // 索引超過範圍會閃退
上次座標 = 手拉曲線[目標索引]
}
}
if 目標索引 > -1 && 目標索引 < 4 { // 索引超過範圍會閃退
手拉曲線[目標索引] = CGPoint(
x: 上次座標.x + 參數.translation.width / 繪板寬,
y: 上次座標.y - 參數.translation.height / 繪板高
)
}
if 目標索引 == 0 {
// To-do: 底座的座標要跟著變化
}
// print(貝茲曲線[目標索引])
}
.onEnded { 參數 in
// print(拖曳位移陣列)
手勢開始 = false
目標索引 = 0
}
}

var body: some View {
ZStack {
花瓶曲線(曲線: 手拉曲線)
.stroke(Color.primary)
.fill(Color.red)
.scaleEffect(y: -1)
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
// let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 上中 = CGPoint(x: 寬/2, y: 0)
let 下中 = CGPoint(x: 寬/2, y: 高)
let 左中 = CGPoint(x: 0, y: 高/2)
let 右中 = CGPoint(x: 寬, y: 高/2)
var 畫筆 = Path()

// (1) 畫外框與十字線
畫筆.addRect(CGRect(origin: .zero, size: 尺寸))
畫筆.move(to: 上中)
畫筆.addLine(to: 下中)
畫筆.move(to: 左中)
畫筆.addLine(to: 右中)
圖層.stroke(畫筆, with: .color(.gray), lineWidth: 1)

// (2) 畫控制點小圓
let 縮放矩陣 = CGAffineTransform(scaleX: 寬, y: 高)
var 實際座標: [CGPoint] = [] // 將貝茲曲線轉換到數學座標
畫筆 = Path()
for 點座標 in 手拉曲線 {
let 點座標轉換 = CGPoint(
x: 點座標.applying(縮放矩陣).x,
y: 高 - 點座標.applying(縮放矩陣).y
)
實際座標.append(點座標轉換)
// print("座標轉換:\(點座標轉換)")
畫筆.addArc(
center: 點座標轉換,
radius: 7,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false)
}
圖層.fill(畫筆, with: .color(.primary))

// (3) 畫兩條控制點線段
畫筆 = Path()
if 實際座標.count > 3 {
畫筆.move(to: 實際座標[0])
畫筆.addLine(to: 實際座標[1])
畫筆.move(to: 實際座標[2])
畫筆.addLine(to: 實際座標[3])
}
圖層.stroke(畫筆, with: .color(.primary))
}
.gesture(拖曳手勢)
}
.frame(width: 繪板寬, height: 繪板高)
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(曲線繪圖板())

💡註解
  1. 作業:請將上一節程式與本節合併,需要修改之處應該不多,目標是手拉曲線時,花瓶會同步變化。
  2. 提示:更新花瓶造型的方法,需要新增 RealityView 的 update: 部分,可參考第6課6-6c 基本座標變換:位移、縮放、旋轉
  3. 程式將近200行,不過大部分都是 Shape 或 Canvas 的畫筆操作,需要一段一段畫所以比較冗長,等熟悉之後,這部分或許可請AI代勞。
  4. 下面就請 OpenAI 畫玉壺春瓶,可惜雖然說得頭頭是道,但還是無法畫出理想的花瓶(可能需要更多學習)。OpenAI 說:「這段代碼的特點:
    ✅ 使用 QuadCurve 繪製曲線,讓線條更流暢自然。
    ✅ 對稱設計,讓瓶子左右均勻。
    ✅ 填充藍色,描邊黑色,讓瓶子清晰可見。
    ✅ 完整還原玉壺春瓶的經典輪廓,從細長瓶頸、圓潤瓶肚到收窄瓶底」。

6-8d 手拉曲線製作花瓶

上一節「補充(21)」提供可調整貝茲曲線的繪圖板,有了這個工具,我們就可以控制花瓶的輪廓曲線,從古典「玉壺春瓶」到現代可口可樂「曲線瓶」,都可藉由簡單的貝茲曲線製作出來。

先來看一下程式執行影片(執行前請關閉「啟用結果」):


附帶一提,這個程式主要用2019年的 iMac 21” (Intel i5)開發,上面影片則用2018年第一代的 iPad Pro 11” (A12X Bionic)錄製,兩台設備都已超過5年,但跑起來還是很順,這對很多學生來說,實在是莫大福音。

這得歸功於 Apple 出色的作業系統(比 Windows/Linux 還贊)與硬體整合能力,而且 Swift 寫的程式效能也令人感到滿意,即便是計算量吃重的空間運算,舊設備仍游刃有餘,實在令人讚嘆。

以下是結合前兩節(6-8c、補充21)的程式碼,除了調整局部參數之外,主要增加 RealityView update: 一段程式和線框控制開關,請自行參考:
// 6-8d 手拉曲線花瓶
// Created by Heman Lu on 2025/03/24
// Tested on iMac 2019 (macOS 15.3.2) + Swift Playground 4.6.3
import SwiftUI
import RealityKit

// 花瓶右半輪廓曲線
struct 花瓶曲線: Shape {
let 厚度: CGFloat = 0.01 // Normalized to 0~1
let 底座: [CGPoint] = [ // Normalized to 0.0~1.0
CGPoint(x: 0, y: 0),
CGPoint(x: 0.3, y: 0),
CGPoint(x: 0.3, y: 0.02)
]
let 曲線: [CGPoint] // 4個正規化點座標(起點、控制點1、控制點2、終點)

func path(in 尺寸: CGRect) -> Path {
let 寬 = 尺寸.width
let 高 = 尺寸.height

var 畫筆 = Path()
// (1)畫底座
if 底座.count > 1 {
畫筆.addLines(底座)
}

if 曲線.count > 3 {
// (2)畫貝茲曲線
畫筆.addLine(to: 曲線[0])
畫筆.addCurve(
to: 曲線[3],
control1: 曲線[1],
control2: 曲線[2])

// (3) 畫瓶口轉折
畫筆.addLine(to: CGPoint(
x: 曲線[3].x - 厚度,
y: 曲線[3].y))

// (4)畫返回貝茲曲線
let 控制點1 = CGPoint(
x: 曲線[2].x - 厚度,
y: 曲線[2].y)
let 控制點2 = CGPoint(
x: 曲線[1].x - 厚度,
y: 曲線[1].y)
let 終點 = CGPoint(
x: 曲線[0].x - 厚度,
y: 曲線[0].y + 厚度)
畫筆.addCurve(to: 終點, control1: 控制點1, control2: 控制點2)

// (5)畫返回底座
畫筆.addLine(to: CGPoint(
x: 曲線[0].x - 厚度,
y: 曲線[0].y))
畫筆.addLine(to: CGPoint(
x: 0,
y: 曲線[0].y))
}
畫筆.closeSubpath()
// 恢復原尺寸
let 縮放矩陣 = CGAffineTransform(scaleX: 寬, y: 高)
return 畫筆.applying(縮放矩陣)
}
}

// 手拉曲線
struct 曲線繪圖板: View {
let 繪板寬: CGFloat
let 繪板高: CGFloat
@Binding var 手拉曲線: [CGPoint] // 4個正規化點座標(起點、控制點1、控制點2、終點)
@State var 手勢開始 = false
@State var 上次座標: CGPoint = .zero
@State var 目標索引: Int = 0 // 貝茲曲線的索引(0...3)

func 最近點索引(位置: CGPoint, 點陣列: [CGPoint]) -> Int {
var 索引: Int = 0
var 目前距離: CGFloat = .infinity

for i in 點陣列.indices {
let 兩點距離 = sqrt(
pow(點陣列[i].x - 位置.x, 2) +
pow(點陣列[i].y - 位置.y, 2)
)
if 兩點距離 < 目前距離 {
目前距離 = 兩點距離
索引 = i
}
}
return 索引
}

var 拖曳手勢: some Gesture {
DragGesture(minimumDistance: 5.0)
.onChanged { 參數 in
if 手勢開始 == false {
手勢開始 = true
let 轉換點座標 = 手拉曲線.map { 正規化座標 in
CGPoint(
x: 正規化座標.x * 繪板寬,
y: 繪板高 - 正規化座標.y * 繪板高
)
}
目標索引 = 最近點索引(位置: 參數.location, 點陣列: 轉換點座標)
if 目標索引 > -1 && 目標索引 < 4 { // 索引超過範圍會閃退
上次座標 = 手拉曲線[目標索引]
}
}
if 目標索引 > -1 && 目標索引 < 4 { // 索引超過範圍會閃退
手拉曲線[目標索引] = CGPoint(
x: 上次座標.x + 參數.translation.width / 繪板寬,
y: 上次座標.y - 參數.translation.height / 繪板高
)
}
if 目標索引 == 0 {
// To-do: 底座的座標要跟著變化
}
// print(貝茲曲線[目標索引])
}
.onEnded { 參數 in
// print(拖曳位移陣列)
手勢開始 = false
目標索引 = 0
}
}

var body: some View {
ZStack {
花瓶曲線(曲線: 手拉曲線)
.stroke(Color.primary)
.fill(Color.red)
.scaleEffect(y: -1)
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
// let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 上中 = CGPoint(x: 寬/2, y: 0)
let 下中 = CGPoint(x: 寬/2, y: 高)
let 左中 = CGPoint(x: 0, y: 高/2)
let 右中 = CGPoint(x: 寬, y: 高/2)
var 畫筆 = Path()

// (1) 畫外框與十字線
畫筆.addRect(CGRect(origin: .zero, size: 尺寸))
畫筆.move(to: 上中)
畫筆.addLine(to: 下中)
畫筆.move(to: 左中)
畫筆.addLine(to: 右中)
圖層.stroke(畫筆, with: .color(.gray), lineWidth: 1)

// (2) 畫控制點小圓
let 縮放矩陣 = CGAffineTransform(scaleX: 寬, y: 高)
var 實際座標: [CGPoint] = [] // 將貝茲曲線轉換到數學座標
畫筆 = Path()
for 點座標 in 手拉曲線 {
let 點座標轉換 = CGPoint(
x: 點座標.applying(縮放矩陣).x,
y: 高 - 點座標.applying(縮放矩陣).y
)
實際座標.append(點座標轉換)
// print("座標轉換:\(點座標轉換)")
畫筆.addArc(
center: 點座標轉換,
radius: 7,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false)
}
圖層.fill(畫筆, with: .color(.primary))

// (3) 畫兩條控制點線段
畫筆 = Path()
if 實際座標.count > 3 {
畫筆.move(to: 實際座標[0])
畫筆.addLine(to: 實際座標[1])
畫筆.move(to: 實際座標[2])
畫筆.addLine(to: 實際座標[3])
}
圖層.stroke(畫筆, with: .color(.primary))
}
.gesture(拖曳手勢)
}
.frame(width: 繪板寬, height: 繪板高)
}
}

//import PlaygroundSupport
//PlaygroundPage.current.setLiveView(曲線繪圖板())

struct 手拉曲線花瓶: View {
@State var 輪廓曲線: [CGPoint] = [ //正規化(數學)座標,原點在左下角
CGPoint(x: 0.3, y: 0.02), // start point
CGPoint(x: 1.0, y: 0.2), // #1 control point
CGPoint(x: 0.01, y: 0.7), // #2 control point
CGPoint(x: 0.1, y: 1) // end point
]
@State var 線框開關: Bool = false

var body: some View {
ZStack(alignment: .bottomTrailing) {
RealityView { 內容 in
// 內容.add(座標軸()) // 共享程式6-6b

var 材質 = PhysicallyBasedMaterial()
材質.baseColor.tint = .tintColor
材質.roughness = 0.1
材質.metallic = 0.9
// 材質.blending = .transparent(opacity: 0.9)
// 材質.triangleFillMode = 線框開關 ? .lines : .fill

let 外框 = CGRect(x: 0.0, y: 0.0, width: 0.5, height: 0.8)
let 曲線 = 花瓶曲線(曲線: 輪廓曲線).path(in: 外框)

// 共享程式6-8c:製作花瓶()
if let 花瓶 = try? await MeshResource.製作花瓶(輪廓: 曲線) {
let 花瓶模型 = ModelEntity(mesh: 花瓶, materials: [材質])
花瓶模型.name = "花瓶"
花瓶模型.position.y = -0.3
內容.add(花瓶模型)
}

// 威尼斯清晨:https://drive.usercontent.google.com/download?id=1y-8hbbZ5viZ86YubAJWgfRhguzJOCLxM
if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
} update: { 內容 in
print("Updating at \(Date.now):\(輪廓曲線)")
for 個體 in 內容.entities {
if let 模型 = 個體.findEntity(named: "花瓶") as? ModelEntity {
// print("模型找到了")
if var 材質 = 模型.model?.materials.first as? PhysicallyBasedMaterial {
// print("取代材質")
材質.triangleFillMode = 線框開關 ? .lines : .fill
模型.model?.materials = [材質]
}
let 外框 = CGRect(x: 0.0, y: 0.0, width: 0.5, height: 0.8)
let 曲線 = 花瓶曲線(曲線: 輪廓曲線).path(in: 外框)
Task {
if let 新造型 = try? await MeshResource.製作花瓶(輪廓: 曲線, 分段: 60) {
// print("更新外型網格")
模型.model?.mesh = 新造型
}
}
}
}
}
.realityViewCameraControls(.orbit)
HStack(alignment: .bottom) {
Text("""
(c)2025 Heman Lu
Programmed by Heman Lu with background "Venice Dawn 2" by Greg Zaal & Rico Cilliers
""")
.italic()
.font(.caption)
.foregroundStyle(.gray)
.padding()
Spacer()
Button("線框", systemImage: "globe") {
線框開關.toggle()
}
.buttonStyle(.borderedProminent)
.padding()
曲線繪圖板(繪板寬: 150, 繪板高: 200, 手拉曲線: $輪廓曲線)
.frame(width: 150, height: 200)
.padding()
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(手拉曲線花瓶())
圖學大師與他們的產地

之前提到皮克斯共同創始人卡特姆(Edwin Catmull)曾是猶他大學教授伊凡・蘇澤蘭(Ivan Sutherland)的學生,第1課光照模型也介紹過裴祥風、Jim Blinn等蘇澤蘭門生。事實上,蘇澤蘭只在猶他大學任教6年左右,卻教出許多電腦圖學大師,至今仍影響著我們。

下圖列舉幾位蘇澤蘭門下的傑出代表人物,最右邊是他們所遺留至今仍在使用的技術。


蘇澤蘭門下最著名的大師是 Alan Kay,他在1969年取得博士學位,先在蘇澤蘭與同事David C. Evans(Alan Kay的博士指導教授)合創的公司“E&S (Evans & Sutherland)”工作,隔(1970)年離開鹽湖城,到舊金山加入全錄研究中心(Xerox PARC)成為首批研究員之一。

Alan Kay 在全錄研究中心開發圖形介面的電腦,發明滑鼠與Smalltalk程式語言,以及物件導向、MVC(Model-View-Controller)等概念,1979年蘋果公司賈伯斯等人來訪,雙方擦出火花,才有今日的Mac, iPhone等產品。Alan Kay 在1984年還被賈伯斯邀請到蘋果公司工作一段時間。

Nolan Bushnell 在1972年創立的雅達利(Atari),是全世界最早的電玩公司,早期風靡全球的電動玩具,包括乒乓(Pong)、小精靈(PAC-MAN)、打磚塊(Breakout)等,都是雅達利的產品,可說是當時最炙手可熱的新創公司。

賈伯斯在1976年創立蘋果之前,還曾在雅達利工作過幾年;Alan Kay 在1981-1984年曾擔任雅達利的首席科學家。

James Clark 是蘇澤蘭門下最成功的創業家,1982年創辦的視算(SGI)公司,主要產品為「圖形工作站」(相當於現在的輝達NVIDIA顯卡),曾經一度主導專業電腦繪圖市場,當時幾乎所有好萊塢電影公司,都使用SGI工作站來製作電腦特效。

目前3D渲染語言的國際標準 OpenGL 最初就是SGI於1992年制定。


James Clark 的事業巔峰,是在1994年與早期瀏覽器作者 Marc Andreessen 合創網景(Netscape)公司,引發全球電子商務與網路創業熱潮,至今還留下HTTPS安全協定與JavaScript程式語言等寶貴資產。

1982年創立的奧多比(Adobe)存活至今40多年,已成為軟體業巨擘,創辦人John Warnock 也是蘇澤蘭的學生。

早期蘋果電腦的圖形介面曾採用奧多比的向量繪圖技術 PostScript,這也是為什麼蘋果畫面總是比Windows細緻的原因之一:蘋果很早就捨棄點陣字型,全面採用向量繪圖技術。

Ed Catmull 早期(1978年)曾與 James Clark 發展出 3D 模型「細分割」(Subdivision)技術,稱為 Catmull-Clark 演算法,如今已廣泛應用於 3D 模型,幾乎所有 3D 建模軟體都可見到其身影。

蘇澤蘭與Alan Kay、Ed Catmull,共三位獲得電腦學術領域最高榮譽 — 美國計算機協會(ACM)所頒發的圖靈獎(Turing Award),一門三傑,非常不容易。


回顧這些大師,不禁令我輩肅然起敬,也讓筆者想起30年前寫的「猶他壺(Utah Teapot)」渲染程式,特地翻出壓箱底的舊硬碟,找到以下幾張圖,當時是以 DOS 版的 Turbo C 所寫。

與皮克斯「跳跳燈」比起來,這就像大師面前的小學生習作。

💡 註解
  1. 2023年IEEE有篇文章回顧了猶他大學在計算機圖學領域的貢獻,並以兩小時影片介紹這段歷史(包含裴祥風的光照模型、猶他壺等),非常值得一看。IEEE Milestone Dedication: Utah Computer Graphics
  2. Edwin Catmull 的名字 Edwin 常暱稱為 Ed,所以也常寫成 Ed Catmull;同樣的,James 暱稱為 Jim,所以 James Clark 也寫成 Jim Clark。就像 Bill Gates 與 Steve Jobs 也都是暱稱。
    本名 暱稱 範例 全名(含Middle-name)
    Edwin Ed Ed Catmull Edwin Earl Catmull
    James Jim Jim Clark James Henry Clark
    William Bill Bill Gates William Henry Gates III
    Steven Steve Steve Jobs Steven Paul Jobs
    Source: https://book.stevejobsarchive.com/photos/photo-10~1500.webp
  3. 全錄研究中心(Xerox PARC)由影印機霸主全錄公司創立於1970,2023年捐贈給史丹佛研究院(SRI),現名為帕羅奧多研究中心 PARC (Palo Alto Research Center),帕羅奧多(Palo Alto)是地名,位於舊金山以南,毗鄰史丹佛大學,是矽谷的中心。
  4. James Clark 的創業夥伴 Marc Andreessen 是早期瀏覽器 Mosaic 主要作者,在 2003年網景(Netscape)公司賣掉之後,將部分原始程式開放出來,成立 Mozilla 基金會,如今已成為開源組織(開放原始碼陣營)的中流砥柱。
第9課 網格描述(MeshDescriptor)

上一課(6-8a)提到 MeshResource 客製化幾何模型的兩種方法,除了擠出成型(Extrusion)之外,另一種就是「網格描述」(MeshDescriptor),網格描述是比較低階的方法,透過精確描述3D物體的幾何參數來產出網格,什麼意思呢?

一個3D模型網格其實是由點、線、面所構成,這裡的點線面不是一般的 point, line, plane,而是幾何的頂點(稱為”vertex”)、邊(”edge”)與多邊形面(”face”或”polygon”),網格描述就是要列舉所有頂點(座標)以及多邊形面的組成。

我們都知道最基本的幾何常識:不重合的兩點構成一直線;不共線的三點決定一平面。那麼,最少需要幾個點才能構成一個3D物體呢?答案是4個點。本節就用4個點來做個「正四面體」,示範網格描述的用法。

6-9a 正四面體

正四面體由4個頂點、6個邊、4個面(相同大小的正三角形)所構成,我們需要描述的,是其中4個頂點以及4個面的組成,如下圖:


第一步是描述正四面體的4個頂點座標,怎麼算呢?空間座標的計算比平面座標困難多了,筆者是以 AI 輔助,問ChatGPT:「一個半徑為1的單位球體,如何算出內接正四面體的每個頂點座標」,然後再參考網路文章加以驗算。

第二步是描述4個三角形面,MeshDescriptor 要求每個三角形必須以「逆時針方向」(從外面看)列出3個頂點的陣列索引,例如上圖的三角形ACB (0, 2, 1),不能寫成三角形ABC (0, 1, 2)。

為什麼不能用任意次序呢?這與光照有關,當模型在渲染(Rendering)過程中,會計算網格中每個三角形的法線(垂直於三角形面的向量),法線須指向光線的來源。

但是每個三角形的法線其實有兩個方向,如果用三角形ABC,算出來的法線會與三角形ACB的法線方向相反(指向球心),材質紋理變成在內面,從外面看,這面就會變成透明,整個模型就不完整了。

有了上述兩筆資料,對應網格描述的位置(.positions)以及多邊形(.primitives,字意是初始、原形),就可用 MeshResource.generate(from: [網格描述]) 來產出網格,進一步做出正四面體模型:
var 網格描述 = MeshDescriptor(name: "正四面體")
網格描述.positions = .init(單位正四面體頂點)
網格描述.primitives = .triangles([ // 陣列元素為三角形頂點索引
0, 2, 1, // 三角形面向外側,頂點索引以逆時針排列,內側透明
0, 1, 3, // A-B-D
0, 3, 2, // A-D-C
2, 3, 1 // C-D-B
])
if let 網格 = try? MeshResource.generate(from: [網格描述]),
let 模型 = try? ModelEntity(mesh: 網格) {
模型.model?.materials = [材質]
內容.add(模型)
}

網格描述用法就這樣,最難部分其實是頂點座標的計算,但有了 AI 輔助,其實也不難,不是嗎?

為了避免畫面單調,我們在中心與四個頂點加上小球,小球之間用根細圓柱連接,最外面再加一個半徑可調整的球體(大球)。

正四面體材質加上一些紋理,比較容易觀察,圖檔如下,是筆者在 iPad 上用付費 App “Amaziograph” 畫的。請下載圖檔後,命名為 “花紋.jpg”,再匯入 Swift Playground 備用:


以下是完整程式碼:
// 6-9a 正四面體:網格描述(MeshDescriptor)
// Created by Heman Lu on 2025/03/30
// Tested on iMac 2019 (macOS 15.3.2) + Swift Playground 4.6.3

import SwiftUI
import RealityKit

struct 球內接正四面體 : View {
let 球半徑: Float = 0.8
let 單位正四面體頂點: [simd_float3] = [
[0, 1, 0], // A點(索引=0)
[0, -1/3, -sqrt(8)/3], // B點(索引=1)
[sqrt(2.0/3.0), -1/3, sqrt(2)/3], // C點(索引=2)
[-sqrt(2.0/3.0), -1/3, sqrt(2)/3] // D點(索引=3)
]

var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b

// (1) 準備正四面體材質
var 材質 = PhysicallyBasedMaterial()
材質.baseColor.tint = .cyan
材質.blending = .transparent(opacity: 0.6)
if let 紋理 = try? await TextureResource(named: "花紋.jpg") {
print("紋理匯入成功")
材質.baseColor.texture = .init(紋理)
}

// (2) 根據球半徑算出實際頂點座標
let 實際頂點 = 單位正四面體頂點.map { 頂點 in
return 頂點 * 球半徑 // simd_float3 * Float
}

// (3) 組成網格描述
var 網格描述 = MeshDescriptor(name: "正四面體")
網格描述.positions = .init(實際頂點)
網格描述.primitives = .triangles([ // 陣列元素為三角形頂點索引
0, 2, 1, // 三角形面向外側,頂點索引以逆時針排列,內側透明
0, 1, 3, // A-B-D
0, 3, 2, // A-D-C
2, 3, 1 // C-D-B
])

// (4) 產出網格與模型(正四面體)
if let 網格 = try? MeshResource.generate(from: [網格描述]),
let 模型 = try? ModelEntity(mesh: 網格) {
模型.model?.materials = [材質]
內容.add(模型)
}

// (5) 外接球:準備材質(線框)、產出大球模型
var 線框材質 = SimpleMaterial()
線框材質.triangleFillMode = .lines
let 大球模型 = ModelEntity(mesh: .generateSphere(radius: 球半徑))
大球模型.model?.materials = [線框材質]
內容.add(大球模型)

// (6) 準備小球材質、產出小球模型
let 小球 = MeshResource.generateSphere(radius: 球半徑 * 0.1)
let 小球材質 = SimpleMaterial(color: .red, isMetallic: false)
let 小球模型 = ModelEntity(mesh: 小球, materials: [小球材質])
內容.add(小球模型)

// (7) 產出細圓柱模型
let 小柱 = MeshResource.generateCylinder(height: 球半徑, radius: 球半徑 * 0.02)
let 小柱模型 = ModelEntity(mesh: 小柱, materials: [小球材質])

// (8) 複製4個小球、細圓柱模型,並計算細圓柱角度與位置
for 頂點 in 單位正四面體頂點 {
let 模型1 = 小球模型.clone(recursive: false)
模型1.position = 頂點 * 球半徑
內容.add(模型1)

// simd_quatf(from: to:) 須使用正規化座標(不能用(2)「實際頂點」)
let 模型2 = 小柱模型.clone(recursive: false)
模型2.orientation = simd_quatf(from: [0, 1, 0], to: 頂點)
模型2.position = 頂點 * 球半徑 * 0.5
內容.add(模型2)
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(球內接正四面體())

執行結果如下圖,其中4個頂點小球與中心以細圓柱相連,像不像有機分子的化學結構?猜猜看是哪一種分子?可見空間運算不只能做 AR 或遊戲,對化學也很有幫助。


💡註解
  1. 作業1:(重要)請修改程式碼,將三角形頂點索引A-C-B (0, 2, 1) 改成A-B-C (0, 1, 2),觀察執行結果有何差別。
  2. 作業2:請參考甲烷化學結構,將中心碳原子小球改用不同顏色,且半徑加大(不必按照實際比例)。
  3. 作業3:如何讓在程式列印出兩個頂點(氫原子)與中心(碳原子)的夾角度數?會是120°嗎?
  4. 除了網格描述(MeshDescriptor)之外,其實還有個 LowLevelMesh 物件,是真正的低階網格,可直接操作底層的記憶體與CPU/GPU配置,物件比較複雜,不在本單元範圍。
  5. 有沒有注意到,產出網格時,MeshResource.generate(from: [網格描述]) 的參數為什麼是一個陣列(可多個網格描述)呢?答案是為了讓網格拆成不同部分,每個部分可套用不同材質外觀。
  6. 挑戰題:請修改程式,將正四面體拆成兩筆網格描述,分別使用不同材質。範例如下:
6-9b UV映射(UV Mapping)

如果細心觀察前兩課的執行結果,會發現紋理貼圖都有瑕疵,例如上一節正四面體,若將大球去除,並取消透明度,可明顯看出底部的紋理不正常(不是我們想要的):

為何會如此?這就牽涉到紋理貼圖的背後原理:UV映射(UV Mapping)。UV映射是3D繪圖的基本觀念之一,經常碰到,但原理卻不容易理解,上一節簡單的正四面體,很適合用來解釋此原理。

先說明一下,“UV” 並不是什麼縮寫(像紫外線 Ultraviolet 常縮寫成UV),這裡的 UV 就只是兩個字母,下面會解釋。

在我們生活中,有個常見物品是2D與3D之間轉換而來,那就是地圖。地圖的做法稱為投影法或映射法(英文”Mapping”即源自於此),不過地圖用的是經緯度映射,只適用於球體,對任意形狀的3D物體,需要更一般化的方法 — 也就是UV映射法。

為了仔細觀察紋理貼圖,筆者用 SwiftUI 做一個數字方格,用於以下貼圖實驗,請將下圖存成 “數字方格.png”,匯入 Swift Playground:

接下來修改上一節範例程式(6-9a),只需改一行:
// 6-9a
if let 紋理 = try? await TextureResource(named: "花紋.jpg") {
// 改成
if let 紋理 = try? await TextureResource(named: "數字方格") { // 副檔名可省略

最好也將大球、小球、細圓柱全部刪除,只留下正四面體和座標軸,以便我們觀察平面的數字方格,如何貼到正四面體。

觀察執行結果,正面圖案最規整,如下圖左,注意X/Y/Z軸、座標原點的位置。從左圖數字的分布,可以推論此面映射到紋理貼圖的位置,如下圖右:

背後兩個三角形就有點變形,不但形成左右相反的鏡像紋理,涵蓋面還只有一半(先忽略雙色材質,下面會提到做法),下圖右是推論的映射位置:


任何圖片都是由畫素組成,每一點代表某個RGB顏色。若仔細觀察以上兩圖,大致可以猜測,預設的貼圖(UV映射)只是將正四面體表面所有點,投射到X-Y平面(直接忽略Z座標),對應貼圖什麼位置,就用該點的顏色(再和我們設定的 baseColor.tint 混合)。

這裡有個問題:平面貼圖的大小如何與3D物體的大小對應呢?

很簡單,用正規化座標,將任何長寬的圖片對應到座標(0, 0)至(1, 1)之間,然後(想像中)鋪滿整個平面。上例中,正四面體的外接球半徑是0.8,因此最上方頂點剛好映射到XY=(0, 0.8)。

實際上,UV映射用一套獨立的座標系統,稱為 UVW座標系(為什麼叫UVW?因為是XYZ的前三個字母),但只用到UV座標,所以稱為UV映射,如下圖;而被遺棄的W(不當座標軸)則常被拿來表示空間的旋轉角度。

預設情況下,UV座標軸會與XY軸重疊,尺度也相同,因此對應的貼圖(數字方格)長寬均為一公尺。

到這裡,應該可推斷上面第一張圖的正四面體,為什麼底部會變成線條了。因為底部三角形正好垂直於XY平面,整個三角形面都會映射到UV座標的一條線上(與相鄰三角形底線重合),因此變成條紋狀,不管換什麼圖片都是如此(如下圖):

怎麼改呢?「網格描述」有個屬性 textureCoordinates (紋理座標),就是 UV映射的設定,讓我們來試著調整底部的UV映射。

第一步先將網格描述分成兩部分,單獨將底部三角形拆出:
// 6-9a 原來的網格描述
var 網格描述 = MeshDescriptor(name: "正四面體")
網格描述.positions = .init(實際頂點)
網格描述.primitives = .triangles([ // 陣列元素為三角形頂點索引
0, 2, 1, // 三角形面向外側,頂點索引以逆時針排列,內側透明
0, 1, 3, // A-B-D
0, 3, 2, // A-D-C
2, 3, 1 // C-D-B
])

// --------------------------------
// 6-9b 網格描述分成兩部分,將底部三角形單獨拆出
var 網格描述1 = MeshDescriptor(name: "正四面體")
網格描述1.positions = .init(實際頂點)
網格描述1.primitives = .triangles([ // 陣列元素為三角形頂點索引
0, 2, 1, // 三角形面向外側,頂點索引以逆時針排列,內側透明
0, 1, 3, // A-B-D
0, 3, 2 // A-D-C
])
var 網格描述2 = MeshDescriptor(name: "正四面體")
網格描述2.positions = .init(實際頂點)
網格描述2.primitives = .triangles([ // 陣列元素為三角形頂點索引
2, 3, 1 // C-D-B 將底部單獨拆出
])

第二步修改「網格描述2」的UV映射,這步要很小心,因為一旦有錯(多一筆或少一筆),整個正四面體就會消失不見(筆者在此卡了好幾天,因為原廠文件沒說清楚):
// UV座標與頂點(而不是三角形)須一一對應
網格描述2.textureCoordinates = .init([
[0.5, 0.5], // A
[0.5, 1], // B
[0, 0], // C
[1, 0] // D
])

「網格描述2」只有一個面,但包含4個頂點(ABCD),因此也必須寫出4個UV座標。令底部三角形CDB分別對應到UV座標(0, 0), (1, 0), (0.5, 1),注意在UV平面上的順序也必須是逆時針(如下圖),否則會造成左右鏡像。

下圖左是正四面體從下往上看的視角:

一旦設定,UV座標就不再與XY座標重疊,而是(似乎)與三角形面平行。這樣底部的紋理就修正過來了,如下圖。

從此例我們可以歸納幾個UV映射的特性:

1. 預設情況下,越是平行於XY平面的網格面,紋理變形越小;越接近垂直,變形越大。
2. 理論上,網格每個面都可單獨設定UV映射(若每個面都拆成一個網格描述)。
3. 圖片長寬不管大小,一律正規化成單位正方形,對應空間座標的1公尺平方。
4. 所以圖檔最好也是正方形,若長寬不一致,除非UV映射到同樣長寬比,否則就會變形。

最後,網格如何指定不同材質呢?其實也很簡單,網格描述還有另一個屬性:materials,可以設定所有面採取同一材質(索引),或每一面各自設定材質(索引)。以下為例:
網格描述1.materials = .perFace([1, 0, 1])   // 各面採用不同(索引的)材質
網格描述2.materials = .allFaces(0) // 所有面都使用第1個(索引=0)材質
...
模型.model?.materials = [材質1, 材質2]

「網格描述1」有三個面,分別用索引1, 0, 1的材質;「網格描述2」只有一面,就用索引0的材質。

索引是在之後建立模型時(參數為材質陣列)才指定,陣列的索引預設從0開始算,上例最後一行,「材質1」的索引為0,「材質2」索引為1,因此正四面體有兩面用材質1,兩面用材質2。

完整範例程式如下:
// 6-9b UV Mapping (textureCoordinates)
// Revised by Heman Lu on 2025/04/05
// Tested on iMac 2019 (macOS 15.4) + Swift Playground 4.6.3

import SwiftUI
import RealityKit

struct 球內接正四面體 : View {
let 球半徑: Float = 0.8
let 單位正四面體頂點: [simd_float3] = [
[0, 1, 0], // A點(索引=0)
[0, -1/3, -sqrt(8)/3], // B點(索引=1)
[sqrt(2.0/3.0), -1/3, sqrt(2)/3], // C點(索引=2)
[-sqrt(2.0/3.0), -1/3, sqrt(2)/3] // D點(索引=3)
]

var body: some View {
RealityView { 內容 in
內容.add(座標軸(0.9)) // 共享程式6-6b

// (1) 準備正四面體材質
var 材質1 = PhysicallyBasedMaterial()
材質1.baseColor.tint = .cyan
材質1.blending = .transparent(opacity: 0.9)
if let 紋理 = try? await TextureResource(named: "數字方格") {
print("紋理匯入成功")
材質1.baseColor.texture = .init(紋理)
}

var 材質2 = 材質1
材質2.baseColor.tint = .yellow

// (2) 根據球半徑算出實際頂點座標
let 實際頂點 = 單位正四面體頂點.map { 頂點 in
return 頂點 * 球半徑 // simd_float3 * Float
}

// (3) 組成網格描述
var 網格描述1 = MeshDescriptor(name: "正四面體")
網格描述1.positions = .init(實際頂點)
網格描述1.primitives = .triangles([ // 陣列元素為三角形頂點索引
0, 2, 1, // 三角形面向外側,頂點索引以逆時針排列,內側透明
0, 1, 3, // A-B-D
0, 3, 2 // A-D-C
])
網格描述1.materials = .perFace([1, 0, 1]) // 各面採用不同(索引的)材質

var 網格描述2 = MeshDescriptor(name: "正四面體")
網格描述2.positions = .init(實際頂點)
網格描述2.primitives = .triangles([ // 陣列元素為三角形頂點索引
2, 3, 1 // C-D-B
])
網格描述2.materials = .allFaces(0) // 所有面都使用第1個(索引=0)材質
// 手工做UV Mapping要很小心,UV座標與頂點(而不是三角形)一一對應
網格描述2.textureCoordinates = .init([
[0.5, 0.5], // A
[0.5, 1], // B
[0, 0], // C
[1, 0] // D
])

// (4) 產出網格與模型(正四面體)
if let 網格 = try? MeshResource.generate(from: [網格描述1, 網格描述2]),
let 模型 = try? ModelEntity(mesh: 網格) {
模型.model?.materials = [材質1, 材質2]
內容.add(模型)
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(球內接正四面體())

💡註解
  1. 網路上有關 RealityKit UV映射(textureCoordinates)的例子非常少,筆者只找到 WWDC 2021另一篇範例,而且想了好久才參悟,能寫出本篇很值得慶幸。
  2. 作業1:請修改程式,將正四面體背後兩面左右鏡像的紋理,改為正常紋理。
  3. 作業2:請將正四面體的四個面都改用不同顏色。
  4. 作業3:範例中,底部正三角形CDB分別對應到UV座標(0, 0), (1, 0), (0.5, 1),後者並不是正三角形,請將UV座標改為正三角形。
  5. 作業4:設定textureCoordinates時,UV座標可以超過(0, 0) ~ (1, 1)範圍嗎?請嘗試看看。
  6. 挑戰題:僅用一張貼圖(如「數字方格.png」)涵蓋正四面體的四個面。也就是說,每個幾何面映射到不同貼圖位置,讓各面的圖案(數字)都不重複。範例如下(提示:分成兩個網格描述即可):


  7. 若將上一題正四面體的所有三角形對應的貼圖位置畫出來,等於把網格完全展開成平面,並且縮放在單位正方形之內 — 任何3D模型都是由網格構成,因此都可以展開成平面。展開方法有很多種,請參考這篇 Blender 教學Blender 文件
6-9c 正十二面體

正多面體是指每一面都是全等正多邊形的多面體,是幾何的重要課題,在自然界的化學分子結構或生物體中,經常可看到。

生活中常見的足球圖案,似乎也是一個球形的正多面體,但其實不是(請參考註解一)。下圖取自Adobe Stock網站,有標註為AI生成,看得出什麼問題嗎?

答案是正六邊形無法形成正多面體(或球體)。兩千多年前的希臘數學家,就已經證明三維空間中的正多面體只有五種:

1. 正四面體:4面均為正三角形,有4個頂點
2. 正六面體:6面均為正方形,有8個頂點
3. 正八面體:8面均為正三角形,有6個頂點
4. 正十二面體:12面均為正五邊形,有20個頂點
5. 正二十面體:20面均為正三角形,有12個頂點

也就是說,只有用正三角形、正四邊形、正五邊形才能做出正多面體(或球體)。

其中筆者認為最特殊的,是正十二面體,每一面居然是正五邊形,而且是頂點數目最多。因此本節就來挑戰做個正十二面體。

第一步最難,要算出正十二面體的20個頂點座標,以及每一面的頂點索引。筆者試著請 ChatGPT 或 Gemini 協助,經過多次溝通,最後產出的結果如下,很像現代雕塑作品,但不是我們想要的:

最後還是用老辦法,透過搜尋找到著名Java講師林信良(1975-2022 已故)的網站資料,才順利做出正十二面體:

接下來第二步是調整各面材質,這是本節的主要挑戰,目標是每一面單獨貼一個紋理,而且不能變形。若依照上一節方法,12面就要寫12個網格描述,而且必須準備12個圖檔!有沒有更好的辦法呢?

先來解決貼圖的問題。前(2023)年 SwiftUI 有個新物件 ImageRenderer,可將任何視圖轉成圖形格式,而紋理素材也有一個 TextureResource(image:) 方法可用,參數 image 要求 CGImage 格式。

我們試著用這個方法,讓正十二面體各面貼一個數字(1~12),數字要貼在每一面的中央,如下圖,這樣就省卻匯入圖檔的麻煩了。


先來寫個小程式,熟悉一下 ImageRenderer 用法。以下利用 ImageRenderer 將字串轉成 CGImage 圖片:
// Tested by Heman, 2025/04/07
import SwiftUI

struct 測試文轉圖: View {
func 文字轉圖片(_ 文字: String = "", 寬高: CGFloat = 100) -> CGImage {
let 視圖 = Text(文字)
.font(.largeTitle)
.padding()
.overlay {
Circle()
.stroke(.black, lineWidth: 3)
}
.foregroundStyle(.black)
.frame(width: 寬高, height: 寬高)
.background {
Color.white
}
let 圖片 = ImageRenderer(content: 視圖)
return 圖片.cgImage!
}

var body: some View {
let 文轉圖 = 文字轉圖片("龘", 寬高: 200)
Image(uiImage: UIImage(cgImage: 文轉圖))
.resizable()
.scaledToFit()
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(測試文轉圖())

ImageRenderer 的參數是一個視圖,因此我們先做好視圖的效果,然後再讓 ImageRenderer 轉成圖片格式,圖片包含 cgImage, uiImage, nsImage 三種格式,我們取出 cgImage 當作紋理素材。

顯示結果如下,在中心顯示輸入的文字"龘",符合我們需求:

接下來,我們如何解決12個檔案描述的問題呢?從第1單元到第6單元,用過最強大的組合莫過於「for迴圈+陣列」,任何物件、資料類型都可放入陣列,因此,就利用 for迴圈產生一個包含12個網格描述的陣列。

請將以下程式放入共享區(操作請參考第6課6-6b ):
// 6-9c 共享程式:正十二面體
// Created by Heman Lu on 2025/04/07
// Tested on iMac 2019 (macOS 15.4) + Swift Playground 4.6.3

import RealityKit

// 單位正十二面體的20個頂點座標,以及12面的索引,取自以下網站(站長是已故的林信良):
// https://openhome.cc/Gossip/ComputerGraphics/VetexOfPolyhedron.htm
let sq3: Float = 1.7320508 // sqrt(3.0)
let sq5: Float = 2.236068 // sqrt(5.0)
let t1: Float = 1.618034 // (sq5 + 1) / 2
let t2: Float = 0.618034 // (sq5 - 1) / 2
public let 單位正十二面體頂點: [simd_float3] = [ // 20個頂點
[0, 1, 0],
[0, sq5 / 3, 2 / 3],
[sq3 / 3, sq5 / 3, -1 / 3],
[-sq3 / 3, sq5 / 3, -1 / 3],
[sq3 / 3, 1 / 3, sq5 / 3],
[t1 * sq3 / 3, 1 / 3, t2 * t2 / 3],
[t2 * sq3 / 3, 1 / 3, -t1 * t1 / 3],
[-t2 * sq3 / 3, 1 / 3, -t1 * t1 / 3],
[-t1 * sq3 / 3, 1 / 3, t2 * t2 / 3],
[-sq3 / 3, 1 / 3, sq5 / 3],
[0, -1, 0],
[0, -sq5 / 3, -2 / 3],
[-sq3 / 3, -sq5 / 3, 1 / 3],
[sq3 / 3, -sq5 / 3, 1 / 3],
[-sq3 / 3, -1 / 3, -sq5 / 3],
[-t1 * sq3 / 3, -1 / 3, -t2 * t2 / 3],
[-t2 * sq3 / 3, -1 / 3, t1 * t1 / 3],
[t2 * sq3 / 3, -1 / 3, t1 * t1 / 3],
[t1 * sq3 / 3, -1 / 3, -t2 * t2 / 3],
[sq3 / 3, -1 / 3, -sq5 / 3]
]

let 十二個面索引: [Int] = [ // 60個索引(12面正五邊形)
0, 1, 4, 5, 2,
0, 2, 6, 7, 3,
0, 3, 8, 9, 1,
1, 9,16,17, 4,
2, 5,18,19, 6,
3, 7,14,15, 8,
10,12,15,14,11,
10,13,17,16,12,
12,16, 9, 8,15,
10,11,19,18,13,
13,18, 5, 4,17,
11,14, 7, 6,19
]

extension MeshResource {
public static func 正十二面體(外接球半徑 r: Float = 1.0) async throws -> MeshResource {
// 用陣列重新改寫
var 網格描述陣列: [MeshDescriptor] = []
for i in 0..<12 { // 十二面對應12個檔案描述
var 單面頂點陣列: [simd_float3] = []
for j in 0..<5 { // 每一面正五邊形的5個頂點座標
let 頂點索引 = 十二個面索引[i * 5 + j]
單面頂點陣列.append(單位正十二面體頂點[頂點索引] * r)
// print("\(頂點索引),", terminator: " ")
}
// print(單面頂點陣列)
var 網格描述 = MeshDescriptor()
網格描述.positions = .init(單面頂點陣列)
網格描述.primitives = .polygons([5], [0, 1, 2, 3, 4])
網格描述.materials = .allFaces(UInt32(i)) //需要12個材質
// 取單位圓內接正五邊形頂點,由正上方(0.5, 1.0)逆時針方向
網格描述.textureCoordinates = .init([
[0.5, 1.0],
[0.02447, 0.6545],
[0.2061, 0.09549],
[0.79389, 0.09549],
[0.9755, 0.6545]
])
網格描述陣列.append(網格描述)
}
return try await MeshResource.generate(from: 網格描述陣列)
}
}
正五邊形的UV座標(textureCoordinates),可參考第4單元第8課正多邊形的計算,記得次序要取逆時針方向。

主程式如下,將前面測試好的函式「文字轉圖片()」包進來,再用 for迴圈產出12個紋理貼圖:
// 6-9c 正十二面體:網格描述(MeshDescriptor)
// Created by Heman Lu on 2025/04/07
// Tested on iMac 2019 (macOS 15.4) + Swift Playground 4.6.3

import SwiftUI
import RealityKit

struct 顯示正十二面體 : View {

func 文字轉圖片(_ 文字: String = "", 寬高: CGFloat = 200) -> CGImage {
let 視圖 = Text(文字)
.font(.largeTitle)
.padding()
.overlay {
Circle()
.stroke(.black, lineWidth: 3)
}
.foregroundStyle(.black)
.frame(width: 寬高, height: 寬高)
.background {
Color.white
}
let 圖片 = ImageRenderer(content: 視圖)
return 圖片.cgImage!
}

var body: some View {
RealityView { 內容 in
內容.add(座標軸(0.9)) // 共享程式6-6b

var 材質陣列: [PhysicallyBasedMaterial] = []
for i in 0..<12 {
var 材質 = PhysicallyBasedMaterial()
材質.baseColor.tint = .cyan
let 圖片 = 文字轉圖片("\(i)")
if let 紋理 = try? await TextureResource(image: 圖片, options: .init(semantic: .color)) {
print("紋理匯入成功#\(i)")
材質.baseColor.texture = .init(紋理)
}
材質陣列.append(材質)
}

// 共享程式 6-9c 正十二面體
if let 模型 = try? await ModelEntity(mesh: .正十二面體(外接球半徑: 0.8)) {
模型.model?.materials = 材質陣列
內容.add(模型)
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(顯示正十二面體())
這樣就完成了!

本節學到兩個很棒的工具,第一是 ImageRenderer,可將任何靜態的 SwiftUI 視圖變成紋理貼圖,大大擴展模型紋理的多樣性;第二是網格描述陣列,參考自WWDC 2021,用這種方式,可以做出很精巧的3D模型,非常有用。

💡註解
  1. 關於正多面體的解說,網路上很多相關文章值得參考,例如維基百科:正多面體台大科學教育發展中心:誰發明了足球
  2. 有些化學分子(例如甲烷CH₄、硫酸鹽SO₄)會形成正四面體結構,這背後牽涉到一些自然法則。
  3. 正四面體的四個頂點,恰好是(外接)球面上四個點彼此距離最遠、最均勻的分布。
  4. 甲烷(CH₄)的四個氫原子都受到中心碳原子的吸引而束縛在同等距離,所以四個氫原子相當於分布在同一個球面上;而氫原子之間彼此同電互斥,彼此距離會拉遠,於是就形成正四面體結構。
  5. 也就是說,若有4, 6, 8, 12, 20個點,要在球面上均勻分布,就自然會形成正多面體的結構。反過來說,除了4, 6, 8, 12, 20點以外,其他數目的點,就不可能完美均勻散布在球面。
  6. 次佳的選擇,可能是用兩種正多邊形來組合,稱為阿基米德多面體。例如足球圖案用正五邊形與正六邊形來組成,有些化學結構也是如此,如富勒烯。
  7. 作業1:請選擇12個系統圖示(SFSymbols),換掉12面的數字。範例如下圖:

  8. 挑戰題:用 SwiftUI Canvas 手繪12個圖案(例如12星座、12生肖、12個手寫字…),貼到正十二面體的表面。【提示】繪圖板做法可參考補充(21) 手動調整貝茲曲線
6-9d 多角柱體

利用上一節所學的網格描述陣列與UV映射,就能做出很多實用的3D模型,其中關鍵之處在於如何取得頂點座標以及各面的索引順序。

還記得第4單元第8課,介紹過如何計算正多邊形的頂點座標,藉此即可做出多角柱體,本節就來試試看。

網格描述並不一定都得用三角形,實際上也可以用其他多邊形,RealityKit 會自動將多邊形轉換成三角形。上一節的正十二面體,我們其實是用五邊形來產出網格:
var 網格描述 = MeshDescriptor()
網格描述.positions = .init(單面頂點陣列)
網格描述.primitives = .polygons([5], [0, 1, 2, 3, 4]) //一個5邊形,5個頂點索引

用同樣方法來製作多角柱體就相當容易,頂面與底面各為一個正多邊形(外接圓圓心在X-Z座標原點),柱面則都是四邊形,如下圖:

頂點座標的陣列索引是必要參數,因此在計算座標時,就要規劃好順序,上圖的頂點編號就是加入陣列的順序。

範例中會將頂面稍微縮小,讓角柱體有更多變化。正多邊形可透過外接圓來控制大小,因此參數有「底半徑」及「頂半徑」。

我們希望每一面的UV映射都分開,因此 n 邊形的多角柱體就需要 n+2 個材質,仍然用上一節介紹的 ImageRenderer 來做:
// 6-9d 共享程式:多角柱體
// Created by Heman Lu on 2025/04/10
// Tested on iMac 2019 (macOS 15.4) + Swift Playground 4.6.3

import RealityKit
import CoreGraphics

private enum 錯誤碼: Error {
case 參數不在有效範圍
case 其他錯誤
}

// 計算正多邊形的UV映射正規化座標
func 正規化頂點座標(_ n: Int) -> [simd_float2] {
var 座標陣列: [simd_float2] = []
for i in 0 ..< n {
let 圓心角 = Float.pi * 2 * Float(i) / Float(n)
let 頂點座標 = SIMD2( //逆時針方向
x: 0.5 - sin(圓心角) * 0.5,
y: 0.5 + cos(圓心角) * 0.5)
座標陣列.append(頂點座標)
}
return 座標陣列
}

extension MeshResource {
public static func 多角柱體(
_ n: Int = 3, // 邊數最少為3
底半徑: Float = 1.0,
頂半徑: Float = 1.0,
高: Float = 1.0) async throws -> MeshResource
{

if n < 3 { throw 錯誤碼.參數不在有效範圍 }

// (1) 計算所有(2n)頂點座標
var 頂點陣列: [simd_float3] = []
for i in 0 ..< n {
let 圓心角 = Float.pi * 2.0 * Float(i) / Float(n)
// 底部頂點 -- 索引 0, 2, 4,...
let 下頂點 = SIMD3(
x: 底半徑 * sin(圓心角),
y: -高 * 0.5,
z: 底半徑 * cos(圓心角))
頂點陣列.append(下頂點)
// 頂部頂點 -- 索引 1, 3, 5,...
let 上頂點 = SIMD3(
x: 頂半徑 * sin(圓心角),
y: 高 * 0.5,
z: 頂半徑 * cos(圓心角))
頂點陣列.append(上頂點)
}
print(頂點陣列.count, 頂點陣列)

var 網格描述陣列: [MeshDescriptor] = []

// (2) 計算「頂面」的座標、索引順序、UV映射
var 頂面網格描述 = MeshDescriptor(name: "頂面")
let 頂面頂點: [simd_float3] = (0 ..< n).map { i in
頂點陣列[i*2 + 1]
}
頂面網格描述.positions = .init(頂面頂點)
let 索引正序: [UInt32] = (0 ..< n).map { i in
UInt32(i)
}
頂面網格描述.primitives = .polygons([UInt8(n)], 索引正序)
頂面網格描述.materials = .allFaces(0)
// print(正規化頂點座標(5))
頂面網格描述.textureCoordinates = .init(正規化頂點座標(n))
網格描述陣列.append(頂面網格描述)

// (3) 計算「底面」的座標、索引順序、UV映射
var 底面網格描述 = MeshDescriptor(name: "底面")
let 底面頂點: [simd_float3] = (0 ..< n).map { i in
頂點陣列[i*2]
}
底面網格描述.positions = .init(底面頂點)
let 索引反序: [UInt32] = (0 ..< n).map { i in
UInt32(n - i - 1)
}
底面網格描述.primitives = .polygons([UInt8(n)], 索引反序)
底面網格描述.materials = .allFaces(UInt32(n+1))
底面網格描述.textureCoordinates = .init(正規化頂點座標(n))
網格描述陣列.append(底面網格描述)

// (4) 計算(n個)「柱面」的座標、索引順序、UV映射
for i in 0 ..< n {
var 柱面網格描述 = MeshDescriptor(name: "柱面\(i)")
let 柱面頂點: [simd_float3] = [
頂點陣列[i*2],
頂點陣列[i*2 + 1],
頂點陣列[(i*2 + 2) % (n*2)],
頂點陣列[(i*2 + 3) % (n*2)]
]
柱面網格描述.positions = .init(柱面頂點)
柱面網格描述.primitives = .polygons([4], [0, 2, 3, 1])
柱面網格描述.materials = .allFaces(UInt32(i)+1)
let 比例 = 頂半徑 / 底半徑
柱面網格描述.textureCoordinates = .init([
[0, 0],
[0.5 - 比例 * 0.5, 1],
[1, 0],
[0.5 + 比例 * 0.5, 1]
])
網格描述陣列.append(柱面網格描述)
}
return try await MeshResource.generate(from: 網格描述陣列)
}
}

主程式中用 ImageRenderer 來產出 n+2 個材質,程式寫法與上一節類似:
// 6-9d 多角柱體
// Created by Heman Lu on 2025/04/10
// Tested on iMac 2019 (macOS 15.4) + Swift Playground 4.6.3

import SwiftUI
import RealityKit

struct 顯示多角柱體: View {
let 邊數 = 8
func 文字轉圖片(_ 文字: String = "", 寬高: CGFloat = 200) -> CGImage {
let 視圖 = Text(文字)
.font(.system(size: 64))
.padding()
.shadow(radius: 3)
.blur(radius: 1)
.foregroundStyle(.black)
.frame(width: 寬高, height: 寬高)
.background {
Color.white
}
let 圖片 = ImageRenderer(content: 視圖)
return 圖片.cgImage!
}

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

var 材質陣列: [PhysicallyBasedMaterial] = []
let 八卦: [String] = ["☯︎", "☰", "☱", "☲", "☳", "☴", "☵", "☶", "☷", "☯︎"]
for i in 0 ..< (邊數+2) {
var 材質 = PhysicallyBasedMaterial()
材質.baseColor.tint = .orange
let 圖片 = 邊數 == 8 ? 文字轉圖片(八卦[i]) : 文字轉圖片("\(i)")
if let 紋理 = try? await TextureResource(image: 圖片, options: .init(semantic: .color)) {
print("紋理匯入成功#\(i)")
材質.baseColor.texture = .init(紋理)
}
材質陣列.append(材質)
}

if let 多角柱模型 = try? await ModelEntity(mesh: .多角柱體(邊數, 底半徑: 0.8, 頂半徑: 0.3, 高: 0.618)) {
多角柱模型.model?.materials = 材質陣列
內容.add(多角柱模型)
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(顯示多角柱體())

對於八角柱體,我們特別用太極八卦符號,其他則用數字放在各面。實際執行的畫面如下:


💡註解
  1. 3D模型的頂點座標,除了計算之外,還可用設備自動取得,例如 LiDAR 或深度相機、3D感測器、3D掃瞄器等。未來的智慧型載具,應該都會配備這類設備,以便實時產出周遭的環境模型。
  2. 另一個辦法是直接測量,最早的數位3D模型「猶他壺」就是用尺跟筆在方格紙上畫出頂點座標,再輸入到電腦中(其中曲線用貝茲曲線,只取控制點座標)。

    Source: https://graphics.cs.utah.edu/teapot/
  3. 作業1:請修改主程式的邊數或「.多角柱體()」參數,檢驗看看程式是否都正確。例如將頂半徑設為0,甚至改為負數,程式還會正常執行嗎?
  4. 作業2:若將上頂點的圓心角增加45度(弧度0.25),會有什麼效果?
  5. 挑戰題(難):請將多邊形改成多角星形(參考第4單元4-8b 漸層多角星),其他參數不變。

補充(22) 低階網格(LowLevelMesh)

低階網格(LowLevelMesh)是去(2024)年推出的新功能,必須 iOS/iPadOS 18、macOS 15以上版本,而且硬體規格須支援 Metal 3 才能執行。

低階網格的好處是速度快、能充分利用 GPU 強大的運算能力,底層使用 Metal 框架,可配合 Metal 著色語言(Metal Shading Language)調控 3D 模型的各種變化,是開發3D互動遊戲的最佳利器。

不過,缺點是技術門檻高,不易入門;低階程式通常比較繁瑣,需要注意的細節相對較多。

以下程式修改自Apple官網範例,此處將原本產出平面三角形,改為正四面體,在 iPad Pro 11” 2018 與 Mac mini M2 上測試無誤:
// 補充(22) 用低階網格(LowLevelMesh)產出正四面體
// Created by Heman, 2025/04/03
// Based on https://developer.apple.com/documentation/realitykit/lowlevelmesh
// 測試設備:iPad Pro 11” 2018 (iPadOS 18.4 + Swift Playground 4.6.3)
import SwiftUI
import RealityKit

// (1) 低階網格(LowLevelMesh)宣告
struct 低階頂點 {
var 座標: simd_float3 = .zero

static var 低階屬性: [LowLevelMesh.Attribute] = [
.init(
semantic: .position,
format: .float3,
offset: MemoryLayout<Self>.offset(of: \.座標) ?? 0)
]
static var 記憶體配置: [LowLevelMesh.Layout] = [
.init(bufferIndex: 0, bufferStride: MemoryLayout<Self>.stride)
]
static var 低階網格描述: LowLevelMesh.Descriptor = .init(
vertexAttributes: 低階屬性,
vertexLayouts: 記憶體配置,
indexType: .uint32
)
}

// (2) 用低階網格產出正四面體
extension MeshResource {
static func 低階正四面體() throws -> MeshResource {
var 幾何結構 = 低階頂點.低階網格描述 // 複製一份(struct)
幾何結構.vertexCapacity = 4 // 4個頂點
幾何結構.indexCapacity = 12 // 4個三角形 * 3個頂點 = 12

let 低階網格 = try LowLevelMesh(descriptor: 幾何結構)
低階網格.withUnsafeMutableBytes(bufferIndex: 0) { 記憶體位址 in
let 頂點陣列 = 記憶體位址.bindMemory(to: 低階頂點.self)
頂點陣列[0] = 低階頂點(座標: [0, 1, 0]) //A
頂點陣列[1] = 低階頂點(座標: [0, -1/3, -sqrt(8)/3]) //B
頂點陣列[2] = 低階頂點(座標: [sqrt(2.0/3.0), -1/3, sqrt(2)/3]) //C
頂點陣列[3] = 低階頂點(座標: [-sqrt(2.0/3.0), -1/3, sqrt(2)/3]) //D
}
低階網格.withUnsafeMutableIndices { 記憶體位址 in
let 索引陣列 = 記憶體位址.bindMemory(to: UInt32.self)
索引陣列[0] = 0 //A-C-B
索引陣列[1] = 2
索引陣列[2] = 1
索引陣列[3] = 0 //A-B-D
索引陣列[4] = 1
索引陣列[5] = 3
索引陣列[6] = 0 //A-D-C
索引陣列[7] = 3
索引陣列[8] = 2
索引陣列[9] = 1 //B-C-D
索引陣列[10] = 2
索引陣列[11] = 3
}

let 外框 = BoundingBox(min: [-1, -1, -1], max: [1, 1, 1])
let 三角形x4 = LowLevelMesh.Part(
indexOffset: 0,
indexCount: 12,
topology: .triangle,
materialIndex: 0,
bounds: 外框)
低階網格.parts.replaceAll([三角形x4])
// print(低階網格.descriptor)

let 網格資源 = try MeshResource(from: 低階網格)
return 網格資源
}
}

// (3) 顯示低階網格產出的正四面體
struct 球體內接正四面體 : View {
var body: some View {
RealityView { 內容 in
內容.add(座標軸()) // 共享程式6-6b

var 材質 = PhysicallyBasedMaterial()
材質.baseColor.tint = .cyan
材質.blending = .transparent(opacity: 0.9)
if let 紋理 = try? await TextureResource(named: "花紋.jpg") {
print("紋理匯入成功")
材質.baseColor.texture = .init(紋理)
}

if let 模型 = try? ModelEntity(mesh: .低階正四面體()) {
print("產出模型:\(模型)")
模型.model?.materials = [材質]
模型.scale = [0.8, 0.8, 0.8]
內容.add(模型)
}

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

import PlaygroundSupport
PlaygroundPage.current.setLiveView(球體內接正四面體())

從程式碼看得出來,產出低階網格的關鍵資料是「低階網格描述」(LowLevelMesh.Descriptor),與第9課的「網格描述」(MeshDescriptor)類似。

網格描述需要兩個陣列資料:(1) 頂點位置(.positions);(2) 多邊形(.primitives)索引。這兩筆資料具體描述(整體或部份)網格的結構。

低階網格描述並不能直接產出網格,而是用來描述頂點的資料。需要兩個陣列:(1) 低階屬性[LowLevelMesh.Attribute];(2) 記憶體配置 [LowLevelMesh.Layout]。

範例程式中,這段「低階網格描述」宣告為「低階頂點」的類型屬性(static var),所以要用時,就寫「低階頂點.低階網格描述」:
struct 低階頂點 {
static var 低階網格描述: LowLevelMesh.Descriptor = .init(
vertexAttributes: 低階屬性,
vertexLayouts: 記憶體配置,
indexType: .uint32
)
}
// ...
var 幾何結構 = 低階頂點.低階網格描述 // 複製一份

「低階屬性」(LowLevelMesh.Attribute)可賦予頂點各種屬性,包括:

1. position: 頂點座標
2. color: 頂點顏色
3. normal: 頂點法向量(所有共用此頂點的多邊形,其法向量加權平均)
4. tangent: 頂點切線向量
5. bitangent: 第二切線向量(餘切線或雙切線)
6. uv0~uv7: 每個頂點最多可設定8組UV座標

範例程式只用到 position 作為頂點座標。其他頂點屬性有什麼作用呢?因為低階屬性完全用於 Metal (GPU)計算,若將其他屬性也放入頂點中,就可善用GPU,加快運算速度。

「記憶體配置」(LowLevelMesh.Layout)則用來預留頂點屬性所需要的記憶體空間,範例中,座標(position)的資料類型為 simd_float3,每一筆資料為12位元組(每個float浮點數為32位元,即4位元組,3個浮點數共 4 x 3 = 12位元組)。

不過,根據官方文件,實際配置記憶體時,simd_float3 會以 16 位元組為單位進行配置,也就是說,每一筆 simd_float3 實際上會佔 16 位元組空間。MemoryLayout<Self>.stride 就是用來計算實際配置的記憶體空間。

低階網格描述宣告完成之後,實際使用時,還須利用記憶體動態配置(名稱 .withUnsafeMutableBytes() 以及 .withUnsafeMutableIndices,表明這是不安全的操作,要特別小心),用來設定頂點陣列以及索引陣列,若有 n 個頂點就需要 16 x n 位元組;有 m 個索引(描述各面頂點次序),需要動態配置 4 x m 位元組記憶體空間。

最後,產出網格之前,還須自行定義外框(BoundingBox)範圍,若網格超出外框範圍,超出部分將不會被計算,也就看不到。

產出網格之後,用法跟前面一樣。正常執行結果如下(iPad Pro 11” 2018):


下圖在 iMac 21” 2019 上執行(只支援Metal 2),只能看到背景與座標軸,看不到低階網格做的正四面體:


💡註解
  1. “Metal” 字面意思是金屬,在電腦領域通常用來指「硬體」。
  2. Apple 的 Metal 框架可直接跟硬體溝通(其他框架須透過作業系統,不能直接操控硬體),相當於微軟的 DirectX,是開發電腦遊戲的最佳選擇。
  3. 微軟 DirectX 從 1995年開始發展,因此取得先機,目前大部分電腦遊戲軟體都以 DirectX 為主。蘋果 Metal 則是 2014年才發表,起步晚了將近20年。
  4. Metal 有獨特的程式語言,專用於繪圖所需(光照、渲染、座標變換、平行運算等),稱為著色語言(Shading Language),語法由 C++ 衍伸而來,和 DirectX 類似,但與 Swift 語言大不相同(與 Swift 比較起來,C/C++ 更適合寫低階硬體程式)。
  5. 除了遊戲之外,近年 AI 機器學習也大量使用 GPU 運算,Apple 基於 Metal 開發一個機器學習的框架 MLX,可用於 Python/Swift/C++ 等程式語言。
  • 5
內文搜尋
X
評分
評分
複製連結
Mobile01提醒您
您目前瀏覽的是行動版網頁
是否切換到電腦版網頁呢?