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

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

網格描述並不一定都得用三角形,實際上也可以用其他多邊形,RealityKit 會自動將多邊形轉換成三角形。上一節的正十二面體,我們其實是用五邊形來產出網格:

var 網格描述 = MeshDescriptor()
網格描述.positions = .init(單面頂點陣列)
網格描述.primitives = .polygons([5], [0, 1, 2, 3, 4])  //一個5邊形,5個頂點索引

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

截圖 2025-04-13 晚上10.09.19.png

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

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

我們希望每一面的UV映射都分開,因此 n 邊形的多角柱體就需要 n+2 個材質,仍然用上一節介紹的 ImageRenderer 來做:

// 6-9d 共享程式:多角柱體
// Created by Heman Lu on 2025/04/10
// Tested with iMac 2019 (macOS 15.4) + Swift Playground 4.6.3

import RealityKit
import CoreGraphics

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

func 正規化頂點座標(_ n: Int) -> [simd_float2] {    //用來計算UV映射的正規化座標
    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 = 0.4, 
        高: 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 with 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(顯示多角柱體())

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

截圖 2025-04-14 上午9.18.03.png