Created
June 22, 2025 14:26
-
-
Save Matt54/bec2633da2cede95ff62b4f2c77f449a to your computer and use it in GitHub Desktop.
RealityKit LowLevelMesh Sphere to Circular Plane Morph with Metal Compute Shader
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#ifndef MorphingSpherePlaneParams_h | |
#define MorphingSpherePlaneParams_h | |
struct MorphingSpherePlaneParams { | |
int latitudeBands; | |
int longitudeBands; | |
float radius; | |
float morphAmount; | |
}; | |
#endif /* MorphingSpherePlaneParams_h */ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
import RealityKit | |
import Metal | |
#Preview { SphereToCircularPlaneMorphingView() } | |
struct SphereToCircularPlaneMorphingView: View { | |
@State var mesh: LowLevelMesh? | |
@State var timer: Timer? | |
@State var frameDuration: TimeInterval = 0.0 | |
@State var lastUpdateTime = CACurrentMediaTime() | |
@State var morphAmount: Float = 0 | |
@State var isMorphForward: Bool = true | |
@State var isDwelling: Bool = false | |
@State var dwellCounter: Float = 0.0 | |
var radius: Float = 0.1 | |
var latitudeBands = 120 | |
var longitudeBands = 120 | |
var morphSpeed: Float = 0.0025 | |
var morphRange: ClosedRange<Float> = 0...1 | |
var dwellDuration: Float = 180 | |
var useTexture: Bool = true | |
var topology: MTLPrimitiveType = .triangle | |
let rootEntity: Entity = Entity() | |
let device: MTLDevice | |
let commandQueue: MTLCommandQueue | |
let computePipeline: MTLComputePipelineState | |
init() { | |
self.device = MTLCreateSystemDefaultDevice()! | |
self.commandQueue = device.makeCommandQueue()! | |
let library = device.makeDefaultLibrary()! | |
let updateFunction = library.makeFunction(name: "updateSphereToCircularPlaneGeometry")! | |
self.computePipeline = try! device.makeComputePipelineState(function: updateFunction) | |
} | |
var body: some View { | |
RealityView { content in | |
let mesh = try! createMesh() | |
let meshResource = try! await MeshResource(from: mesh) | |
let material = try! await getMaterial() | |
let modelComponent = ModelComponent(mesh: meshResource, materials: [material]) | |
rootEntity.components.set(modelComponent) | |
content.add(rootEntity) | |
self.mesh = mesh | |
} | |
.onAppear { startTimer() } | |
.onDisappear { stopTimer() } | |
} | |
} | |
// MARK: Animation / Timer | |
extension SphereToCircularPlaneMorphingView { | |
func startTimer() { | |
timer = Timer.scheduledTimer(withTimeInterval: 1/120.0, repeats: true) { _ in | |
let currentTime = CACurrentMediaTime() | |
frameDuration = currentTime - lastUpdateTime | |
lastUpdateTime = currentTime | |
updateMesh() | |
stepMorphAmount() | |
} | |
} | |
func stopTimer() { | |
timer?.invalidate() | |
timer = nil | |
} | |
func stepMorphAmount() { | |
// If we're dwelling, count frames | |
if isDwelling { | |
dwellCounter += 1.0 | |
if dwellCounter >= dwellDuration { | |
isDwelling = false | |
dwellCounter = 0.0 | |
// Switch direction after dwelling | |
isMorphForward.toggle() | |
} | |
return | |
} | |
// Normal morphing behavior | |
if isMorphForward { | |
morphAmount += morphSpeed | |
if morphAmount >= morphRange.upperBound { | |
morphAmount = morphRange.upperBound | |
isDwelling = true | |
dwellCounter = 0.0 | |
} | |
} else { | |
morphAmount -= morphSpeed | |
if morphAmount <= morphRange.lowerBound { | |
morphAmount = morphRange.lowerBound | |
isDwelling = true | |
dwellCounter = 0.0 | |
} | |
} | |
} | |
} | |
// MARK: Material | |
extension SphereToCircularPlaneMorphingView { | |
func getMaterial() async throws -> RealityKit.Material { | |
var material = PhysicallyBasedMaterial() | |
material.baseColor.tint = .yellow | |
material.roughness.scale = 0.5 | |
material.metallic.scale = 1.0 | |
material.blending = .transparent(opacity: 1.0) | |
material.faceCulling = .none | |
if useTexture, | |
let url = URL(string: "https://matt54.github.io/Resources/square_uv_grid.png") { | |
// download and apply image as texture | |
let (downloadedURL, _) = try await URLSession.shared.download(from: url) | |
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! | |
let destinationURL = documentsDirectory.appendingPathComponent("square_uv_grid.png") | |
if FileManager.default.fileExists(atPath: destinationURL.path) { | |
try FileManager.default.removeItem(at: destinationURL) | |
} | |
try FileManager.default.moveItem(at: downloadedURL, to: destinationURL) | |
if let textureResource = try? await TextureResource(contentsOf: destinationURL) { | |
material.baseColor = .init(texture: .init(textureResource)) | |
material.emissiveColor = .init(texture: .init(textureResource)) | |
material.emissiveIntensity = 1.0 | |
} | |
} | |
return material | |
} | |
} | |
// MARK: Mesh | |
extension SphereToCircularPlaneMorphingView { | |
var vertexCapacity: Int { | |
return (latitudeBands + 1) * (longitudeBands + 1) | |
} | |
var indexCount: Int { | |
return latitudeBands * longitudeBands * 6 | |
} | |
func createMesh() throws -> LowLevelMesh { | |
var desc = VertexData.descriptor | |
desc.vertexCapacity = vertexCapacity | |
desc.indexCapacity = indexCount | |
let mesh = try LowLevelMesh(descriptor: desc) | |
mesh.withUnsafeMutableIndices { rawIndices in | |
let indices = rawIndices.bindMemory(to: UInt32.self) | |
var index = 0 | |
for latNumber in 0..<latitudeBands { | |
for longNumber in 0..<longitudeBands { | |
let first = (latNumber * (longitudeBands + 1)) + longNumber | |
let second = first + longitudeBands + 1 | |
indices[index] = UInt32(first) | |
indices[index + 1] = UInt32(second) | |
indices[index + 2] = UInt32(first + 1) | |
indices[index + 3] = UInt32(second) | |
indices[index + 4] = UInt32(second + 1) | |
indices[index + 5] = UInt32(first + 1) | |
index += 6 | |
} | |
} | |
} | |
return mesh | |
} | |
func updateMesh() { | |
guard let mesh = mesh, | |
let commandBuffer = commandQueue.makeCommandBuffer(), | |
let computeEncoder = commandBuffer.makeComputeCommandEncoder() else { return } | |
let vertexBuffer = mesh.replace(bufferIndex: 0, using: commandBuffer) | |
computeEncoder.setComputePipelineState(computePipeline) | |
computeEncoder.setBuffer(vertexBuffer, offset: 0, index: 0) | |
var params = MorphingSpherePlaneParams( | |
latitudeBands: Int32(latitudeBands), | |
longitudeBands: Int32(longitudeBands), | |
radius: radius, | |
morphAmount: morphAmount | |
) | |
computeEncoder.setBytes(¶ms, length: MemoryLayout<MorphingSpherePlaneParams>.size, index: 1) | |
let threadsPerGrid = MTLSize(width: vertexCapacity, height: 1, depth: 1) | |
let threadsPerThreadgroup = MTLSize(width: 64, height: 1, depth: 1) | |
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup) | |
computeEncoder.endEncoding() | |
commandBuffer.commit() | |
let meshBounds = BoundingBox(min: [-radius, -radius, -radius], | |
max: [ radius, radius, radius]) | |
mesh.parts.replaceAll([ | |
LowLevelMesh.Part( | |
indexCount: indexCount, | |
topology: topology, | |
bounds: meshBounds | |
) | |
]) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
import RealityKit | |
extension VertexData { | |
static var vertexAttributes: [LowLevelMesh.Attribute] = [ | |
.init(semantic: .position, format: .float3, offset: MemoryLayout<Self>.offset(of: \.position)!), | |
.init(semantic: .normal, format: .float3, offset: MemoryLayout<Self>.offset(of: \.normal)!), | |
.init(semantic: .uv0, format: .float2, offset: MemoryLayout<Self>.offset(of: \.uv)!) | |
] | |
static var vertexLayouts: [LowLevelMesh.Layout] = [ | |
.init(bufferIndex: 0, bufferStride: MemoryLayout<Self>.stride) | |
] | |
static var descriptor: LowLevelMesh.Descriptor { | |
var desc = LowLevelMesh.Descriptor() | |
desc.vertexAttributes = VertexData.vertexAttributes | |
desc.vertexLayouts = VertexData.vertexLayouts | |
desc.indexType = .uint32 | |
return desc | |
} | |
@MainActor static func initializeMesh(vertexCapacity: Int, | |
indexCapacity: Int) throws -> LowLevelMesh { | |
var desc = VertexData.descriptor | |
desc.vertexCapacity = vertexCapacity | |
desc.indexCapacity = indexCapacity | |
return try LowLevelMesh(descriptor: desc) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#include <simd/simd.h> | |
#ifndef VertexData_h | |
#define VertexData_h | |
struct VertexData { | |
simd_float3 position; | |
simd_float3 normal; | |
simd_float2 uv; | |
}; | |
#endif |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment