用 RealityKit 做 2.5D 塔防:為什麼不用 SpriteKit

用 RealityKit 做 2.5D 塔防:為什麼不用 SpriteKit

Crystal Hangul(水晶韓語塔防)的玩法是 2D 的:敵人沿著固定路線走,玩家在路邊蓋塔。但整個場景是用 RealityKit 做的 3D 渲染:低多邊形的水晶漂浮在夜空中,塔是旋轉的幾何體,敵人死的時候碎成晶片四散。

我最初考慮過 SpriteKit,最後選了 RealityKit。以下是原因和過程中遇到的挑戰。

SpriteKit 做不到的東西

Crystal Hangul 的美術方向是「低多邊形水晶」。塔是正四面體、正八面體、正十二面體,面與面之間有銳利的折角,在光線下產生不同的明暗。敵人是隨機擾動的碎晶,每隻長得不一樣但風格一致。場景裡有漂浮島、水晶尖刺、發光的裂縫門。

這些東西在 SpriteKit 裡要用預先畫好的 2D 圖片模擬。水晶的光暈可以用半透明的 sprite 疊出來,但旋轉的時候會露餡,因為 sprite 是平的。光影變化要手動畫多幀或用 shader 模擬,工作量巨大。

RealityKit 用 PBR(Physically Based Rendering)材質系統,我只要設定 roughness、metallic、clearcoat 這些參數,光影由引擎即時算。水晶的「寶石感」用一個材質設定就能做到:

static func crystal(_ hex: UInt32, opacity: Float = 0.72, glow: Float = 0.55)
    -> PhysicallyBasedMaterial {
    var m = PhysicallyBasedMaterial()
    m.baseColor = .init(tint: UIColor(hex: hex))
    m.roughness = .init(floatLiteral: 0.12)    // 鏡面高光
    m.clearcoat = .init(floatLiteral: 1.0)     // 清漆層
    m.blending = .transparent(opacity: .init(floatLiteral: opacity))
    m.emissiveColor = .init(color: UIColor(hex: hex))
    m.emissiveIntensity = glow                  // 微自發光
    return m
}

半透明 + 低 roughness 的鏡面高光 + 清漆層 + 微自發光。轉到任何角度,光影都是對的。用 SpriteKit 達到同樣效果需要的美術資產量和 shader 工程量,遠超過一人開發能負擔的。

100% 程式生成的幾何

場景裡的所有 3D 模型都是程式生成的,沒有用任何 3D 建模軟體。CrystalMeshFactory 用頂點座標和面索引直接算出 MeshResource。

塔用柏拉圖立體:名詞塔是正四面體,動詞塔是正八面體,助詞塔是正十二面體。敵人用擾動的立方體,8 個頂點各加隨機 jitter。同一隻敵人用同一個 seed,所以每隻長得不一樣但重玩時不會變:

struct SeededRNG: RandomNumberGenerator {
    private var state: UInt64
    init(seed: UInt64) { state = seed }
    mutating func next() -> UInt64 {
        state &+= 0x9E3779B97F4A7C15
        var z = state
        z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9
        z = (z ^ (z >> 27)) &* 0x94D049BB133111EB
        return z ^ (z >> 31)
    }
}

程式生成的好處:不需要管理 3D 模型檔案,不需要 import/export 流程,改參數重新跑就好。壞處:debug 的時候沒有視覺預覽,要跑起來才知道長什麼樣子。

Flat Shading:每面獨立頂點

低多邊形風格需要 flat shading,讓每個面有統一的明暗,面與面之間有銳利的邊界。RealityKit 預設用 smooth shading(頂點法線插值),邊界會變模糊。

解法是讓每個三角形有自己獨立的頂點,不跟鄰面共用。每個頂點的法線設成該面的面法線。RealityKit 沒辦法在頂點之間插值,每個面就會是統一的顏色。代價是頂點數暴增,但在塔防遊戲同時 20-30 隻敵人的量級下不是瓶頸。

細邊線:讓低面數水晶可讀

純 flat shading 的低面數物件,遠看像一堆色塊。面與面的交界不夠清楚,玩家在戰鬥中很難一眼看出那是一顆水晶。

我在每個水晶的稜線上加了細線條。做法是把每條邊變成一根極細的方柱(thickness 0.006),所有邊的方柱合併成一個 MeshResource,用 unlit 材質渲染,只需要一次 draw call。這讓水晶的幾何結構在任何距離和混亂程度下都清晰可讀。

光暈:假 bloom

RealityKit 在 iOS 上沒有後處理 bloom。我用了一個取巧的方式:在水晶外面套一層放大 1.3 倍的半透明薄殼,同樣的幾何形狀,用 unlit 材質加 transparent blending。遠看就像水晶在發光。

static func halo(_ hex: UInt32, opacity: Float = 0.16) -> UnlitMaterial {
    var m = UnlitMaterial(color: UIColor(hex: hex))
    m.blending = .transparent(opacity: .init(floatLiteral: opacity))
    return m
}

透明度 0.16 是測了很多次才定下來的。太高會像塑膠球,太低看不出來。

RealityKit 做塔防遇到的挑戰

沒有內建的 update loop

SpriteKit 的 SKScene.update() 每幀自動呼叫。RealityKit 要自己訂閱 SceneEvents.Update

updateSubscription = arView.scene.subscribe(to: SceneEvents.Update.self) { [weak self] event in
    self?.frameUpdate(dt: Float(event.deltaTime))
}

遊戲邏輯(GameEngine)跑 30Hz,渲染每幀從引擎讀取狀態同步位置。邏輯和渲染解耦,引擎不知道 RealityKit 的存在。

ARView 的非 AR 模式

RealityKit 的渲染只能透過 ARView。塔防遊戲不需要 AR,要用 .nonAR 模式初始化,然後自己管理 PerspectiveCamera 的位置和 FOV。

暖機卡頓

RealityKit 第一次渲染某種材質會編譯 shader,造成卡頓。我在場景載入時就預先建立所有會用到的材質和 mesh,讓 shader 編譯在載入階段完成。碎片 mesh 也做了 static cache,同一種碎片形狀只算一次。

熱降載

3D 渲染在手機上比 SpriteKit 吃更多 GPU。長時間遊玩會觸發 iOS 的 thermal throttling。我偵測 ProcessInfo.processInfo.thermalState,達到 serious 等級時粒子數量減半。玩家不太會注意到粒子少了,但 GPU 壓力降了一半。

這個選擇的代價和收穫

用 RealityKit 做一個 2D 玩法的遊戲,開發時間比 SpriteKit 長,效能問題更多,debug 更難。但水晶的光影、透明感、旋轉時的折面反射、碎裂時的晶片散射,這些視覺效果用 SpriteKit 幾乎不可能達到同樣的品質。

如果你的遊戲需要即時光影和 3D 幾何,RealityKit 是 iOS 上除了 Metal 之外最直接的選擇。如果只需要 2D sprite 和簡單粒子,SpriteKit 會快得多。我選 RealityKit 是因為水晶的美術方向需要它,換一個美術方向的話我會選 SpriteKit。


水晶韓語塔防是一個用 RealityKit 做的 iOS 塔防遊戲,拼韓文句子建塔,打敗水晶怪物。App Store 下載