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

6-16d AR應用:手工花瓶

從以上3節,大致了解擴增實境的原理與基本用法之後,就可以開始製作 App 應用。至於內容要放什麼呢?前面第6課~第15課學過的素材,幾乎都可以放進來,不管是外部做好的模型、場景、視覺特效、音效,或是透過程式製作的模型與動畫。

例如,在第8課6-8d做過一個可以手工調整曲線的花瓶,就很適合。經過稍加修改,加入擴增實境之後,執行畫面如下,是不是感覺既真實又虛幻?

這樣的應用很適合家具用品,在購買之前,就可先擺進自己家中,看看顏色、尺寸是否搭配。瑞典家具大廠 IKEA 前幾年就曾發表過類似 App。

這個範例程式如果從零開始寫的話,會發現9成以上的工作,是在製作虛擬物件,也就是前面所學的內容,擴增實境的程式碼佔不到一成。

實務上,大多數 AR App 製作前,會先利用3D製作軟體(如 Blender, Maya, …)做好虛擬物件,這樣在材質、外觀、造型、場景等地方可以更逼真,再導入 RealityKit 加上擴增實境,RealityKit 程式碼的角色,用來黏合所有內容,並控制背後所需的流程與邏輯。

話說回來,本單元學習重點在於 RealityKit 程式設計,而非3D模型製作,因此,呈現概念原型(prototype)即可,之前(6-8d)做好的模型就夠用了。

完整程式碼如下,將近300行,看似很多,但其實並沒有新東西,全部都是學過的。前2/3包括「花瓶曲線」、「曲線繪圖板」是從6-8d複製過來,完全不需要修改;主視圖合併到「擴增實境」中,僅小幅修改,就不一一說明,請自行參考:
// 6-16d AR手工花瓶
// Created by Heman Lu, 2025/10/25
// Minimum Requirement: iPadOS 18 + Swift Playground 4.6 (AR)
// Alternative: macOS 15 + Swift Playground 4.6 (non-AR)

import SwiftUI
import RealityKit

// 花瓶右半邊輪廓曲線
struct 花瓶曲線: Shape {
let 厚度: CGFloat = 0.001 // 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] // 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: 繪板高)
}
}

struct 擴增實境: View {
let 外框 = CGRect(x: 0.0, y: 0.0, width: 0.1, height: 0.16)
@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 {
RealityView { 內容 in
var 材質 = PhysicallyBasedMaterial()
材質.baseColor.tint = .orange
材質.roughness = 0.8
材質.metallic = 0.1

let 曲線 = 花瓶曲線(曲線: 輪廓曲線).path(in: 外框)

// 共享程式6-8c:製作花瓶()
if let 花瓶 = try? await MeshResource.製作花瓶(輪廓: 曲線) {
let 花瓶模型 = ModelEntity(mesh: 花瓶, materials: [材質])
花瓶模型.name = "花瓶"
花瓶模型.components.set(GroundingShadowComponent(castsShadow: true))

#if targetEnvironment(macCatalyst) //macOS + Swift Playground
花瓶模型.scale = [3, 3, 3]
內容.add(花瓶模型)
#else // iPadOS + Swift Playground
// (1) 平面錨點 + 花瓶模型
let 平面錨點 = AnchorEntity(plane: .any) // 任何平面
平面錨點.name = "平面錨點"
平面錨點.addChild(花瓶模型)
內容.add(平面錨點)
#endif
}


#if targetEnvironment(macCatalyst) // for Mac + Swift Playground
內容.camera = .virtual // 只顯示虛擬空間
內容.add(座標軸(0.7)) // 共享程式6-6b:座標軸()
#else // for iPad + Swift Playground
內容.camera = .spatialTracking // 開啟AR相機(預設為後鏡頭)
let AR設定 = SpatialTrackingSession.Configuration(
tracking: [.camera, .world, .plane, .object, .image, .face, .body],
sceneUnderstanding: [.shadow, .occlusion, .collision, .physics],
camera: .back
)
let AR任務階段 = SpatialTrackingSession()
if let 回傳結果 = await AR任務階段.run(AR設定) {
print("不支援:", 回傳結果)
} else {
print("AR設定正常")
}
#endif
} 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 曲線 = 花瓶曲線(曲線: 輪廓曲線).path(in: 外框)
Task {
if let 新造型 = try? await MeshResource.製作花瓶(輪廓: 曲線, 分段: 60) {
// print("更新外型網格")
模型.model?.mesh = 新造型
}
}
}
}
}
#if targetEnvironment(macCatalyst)
.realityViewCameraControls(.orbit)
#endif
.overlay(alignment: .bottomTrailing) {
HStack(alignment: .bottom) {
Text("(c)2025 Heman Lu")
.italic()
.font(.caption)
.foregroundStyle(.gray)
.padding()
Spacer()
Button("線框", systemImage: "globe") {
線框開關.toggle()
}
.buttonStyle(.borderedProminent)
.padding()
RoundedRectangle(cornerRadius: 8)
.frame(width: 160, height: 210)
.padding()
.opacity(0.3)
.overlay {
曲線繪圖板(繪板寬: 150, 繪板高: 200, 手拉曲線: $輪廓曲線)
.frame(width: 150, height: 200)
}
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(擴增實境())

因為程式用了 ”#if targetEnvironment(macCatalyst)” 條件編譯,故可分別在 macOS 與 iPadOS 中執行,呈現出不同效果。

若在 macOS 中執行,只會看到虛擬場景的部分,與6-8d類似,但花瓶底部置於原點:

在 iPadOS 中,則可看到虛實整合的內容,執行過程如下:

效果看起來還不錯,也能動手玩。唯一缺憾是程式做的花瓶質感不夠逼真,若能找到適當的材質貼圖,將會更完美。

💡註解
  1. 作業1:「花瓶曲線」、「曲線繪圖板」兩部分的程式碼,與6-8d完全一樣,請移到共享區,看看會發生什麼問題?然後再進一步修正。
  2. 作業2:請挑選前面任一課程內容(例如6-12e龍捲風),加入擴增實境。
  3. 作業3:執行畫面中,「線框」按鈕有點突兀,請將按鈕取消,改用輕點(TapGesture)花瓶以切換線框材質。可參考6-11c 手勢互動(InputTargetComponent)
文章分享
評分
評分
複製連結
請輸入您要前往的頁數(1 ~ 8)

今日熱門文章 網友點擊推薦!