Last active
July 16, 2025 20:47
-
-
Save Matt54/a9d12234abdb64a76b3ea233215bae0d to your computer and use it in GitHub Desktop.
Burn Fade Animation for USDZ Models in RealityKit using LowLevelMesh, ShaderGraphMaterial & 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
import Metal | |
import RealityKit | |
import SwiftUI | |
struct BurnFadeModelView: View { | |
@State var lowLevelMesh: LowLevelMesh? | |
@State var timer: Timer? | |
@State var isForward: Bool = true | |
@State var morphProgress: Float = 0.0 | |
@State var dwellCounter: Int = 0 | |
@State var isDwelling: Bool = false | |
@State var burnScale: Float = 8.0 | |
@State var hueRotate: Float = 0 | |
@State var edgeWidth: Float = 0.08 | |
@State var emberRange: Float = 0.15 | |
// Store original vertices to preserve colors | |
@State var originalVertices: [VertexDataWith4ChannelColor] = [] | |
let modelURL = URL(string: "https://matt54.github.io/Resources/StatueOfBuddha.usdz")! | |
let timerUpdateDuration: TimeInterval = 1/120.0 | |
let dwellDuration: Int = 30 | |
var morphProgressRate: Float = 0.005 | |
let device: MTLDevice | |
let commandQueue: MTLCommandQueue | |
let computePipelineState: MTLComputePipelineState | |
init() { | |
self.device = MTLCreateSystemDefaultDevice()! | |
self.commandQueue = device.makeCommandQueue()! | |
let library = device.makeDefaultLibrary()! | |
let kernelFunction = library.makeFunction(name: "burnFadeVertices")! | |
self.computePipelineState = try! device.makeComputePipelineState(function: kernelFunction) | |
} | |
var body: some View { | |
VStack { | |
RealityView { content in | |
let model = try! await loadModelEntity(url: modelURL) | |
content.add(model) | |
let lowLevelMesh = try! createMesh(from: model) | |
// swap out model mesh with our LowLevelMesh | |
model.model?.mesh = try! await MeshResource(from: lowLevelMesh) | |
let originalMaterial = model.model?.materials.first as! PhysicallyBasedMaterial | |
let originalTexture = originalMaterial.baseColor.texture!.resource | |
var shaderMaterial = try! await loadMaterial() | |
try! shaderMaterial.setParameter(name: "ImageTexture", value: .textureResource(originalTexture)) | |
model.model?.materials = [shaderMaterial] | |
self.lowLevelMesh = lowLevelMesh | |
} | |
VStack { | |
HStack { | |
Text("Burn Scale: \(burnScale, specifier: "%.2f")") | |
Spacer() | |
Slider(value: $burnScale, in: 1...40) | |
.frame(width: 300) | |
} | |
HStack { | |
Text("Burn Hue: \(hueRotate, specifier: "%.2f")") | |
Spacer() | |
Slider(value: $hueRotate, in: 0...(.pi*2)) | |
.frame(width: 300) | |
} | |
HStack { | |
Text("Edge Width: \(edgeWidth, specifier: "%.3f")") | |
Spacer() | |
Slider(value: $edgeWidth, in: 0.01...0.2) | |
.frame(width: 300) | |
} | |
HStack { | |
Text("Ember Range: \(emberRange, specifier: "%.3f")") | |
Spacer() | |
Slider(value: $emberRange, in: 0.05...0.4) | |
.frame(width: 300) | |
} | |
} | |
.frame(width: 500) | |
.padding() | |
.glassBackgroundEffect() | |
} | |
.onAppear { startTimer() } | |
.onDisappear { stopTimer() } | |
} | |
enum MeshCreationError: Error { | |
case modelNotFound, meshPartNotFound | |
} | |
} | |
// MARK: Animation Loop | |
extension BurnFadeModelView { | |
func startTimer() { | |
timer = Timer.scheduledTimer(withTimeInterval: timerUpdateDuration, repeats: true) { timer in | |
if isDwelling { | |
// Count dwell time | |
dwellCounter += 1 | |
if dwellCounter >= dwellDuration { | |
// Finished dwelling, switch direction and resume morphing | |
isDwelling = false | |
dwellCounter = 0 | |
isForward.toggle() | |
} | |
} else { | |
// Update Morph Progress | |
if isForward { | |
morphProgress += morphProgressRate | |
} else { | |
morphProgress -= morphProgressRate | |
} | |
// Handle bounds | |
if morphProgress >= 1.0 { | |
morphProgress = 1.0 | |
isDwelling = true | |
dwellCounter = 0 | |
} else if morphProgress <= 0.0 { | |
morphProgress = 0.0 | |
isDwelling = true | |
dwellCounter = 0 | |
} | |
} | |
updateMesh() | |
} | |
} | |
func stopTimer() { | |
timer?.invalidate() | |
timer = nil | |
} | |
} | |
// MARK: Get Online Shader Graph Material | |
extension BurnFadeModelView { | |
func loadMaterial() async throws -> ShaderGraphMaterial { | |
let baseURL = URL(string: "https://matt54.github.io/Resources/")! | |
let fullURL = baseURL.appendingPathComponent("ImageTexturePlus4ChannelColor.usda") | |
let data = try Data(contentsOf: fullURL) | |
let materialFilenameWithPath: String = "/Root/ImageTexturePlus4ChannelColorMaterial" | |
return try await ShaderGraphMaterial(named: materialFilenameWithPath, from: data) | |
} | |
} | |
// MARK: Download model | |
extension BurnFadeModelView { | |
func loadModelEntity(url: URL) async throws -> ModelEntity { | |
let (downloadedURL, _) = try await URLSession.shared.download(from: url) | |
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! | |
let destinationURL = documentsDirectory.appendingPathComponent("downloadedModel.usdz") | |
if FileManager.default.fileExists(atPath: destinationURL.path) { | |
try FileManager.default.removeItem(at: destinationURL) | |
} | |
try FileManager.default.moveItem(at: downloadedURL, to: destinationURL) | |
let entity = try await ModelEntity.init(contentsOf: destinationURL) | |
try FileManager.default.removeItem(at: destinationURL) | |
return entity | |
} | |
} | |
// MARK: Mesh functions | |
extension BurnFadeModelView { | |
func createMesh(from modelEntity: ModelEntity) throws -> LowLevelMesh { | |
guard let model = modelEntity.model | |
else { throw MeshCreationError.modelNotFound } | |
guard let meshPart = model.mesh.contents.models.first?.parts.first | |
else { throw MeshCreationError.meshPartNotFound} | |
let positions = meshPart[MeshBuffers.positions]?.elements ?? [] | |
let normals = meshPart[MeshBuffers.normals]?.elements ?? [] | |
let textureCoordinates = meshPart[MeshBuffers.textureCoordinates]?.elements ?? [] | |
let triangleIndices = meshPart.triangleIndices?.elements ?? [] | |
let lowLevelMesh = try VertexDataWith4ChannelColor.initializeMesh(vertexCapacity: positions.count, | |
indexCapacity: triangleIndices.count) | |
// Copy vertex data | |
lowLevelMesh.withUnsafeMutableBytes(bufferIndex: 0) { rawBytes in | |
let vertexBuffer = rawBytes.bindMemory(to: VertexDataWith4ChannelColor.self) | |
for i in 0..<positions.count { | |
vertexBuffer[i] = VertexDataWith4ChannelColor(position: positions[i], normal: normals[i], uv: textureCoordinates[i], color: SIMD4(x: 0, y: 0, z: 0, w: 1.0)) | |
originalVertices.append(vertexBuffer[i]) | |
} | |
} | |
// Copy index data | |
lowLevelMesh.withUnsafeMutableIndices { buffer in | |
let indices = buffer.bindMemory(to: UInt32.self) | |
for (index, triangleIndex) in triangleIndices.enumerated() { | |
indices[index] = UInt32(triangleIndex) | |
} | |
} | |
let bounds = model.mesh.bounds | |
lowLevelMesh.parts.replaceAll([ | |
LowLevelMesh.Part( | |
indexCount: triangleIndices.count, | |
topology: .triangle, | |
bounds: bounds | |
) | |
]) | |
return lowLevelMesh | |
} | |
func updateMesh() { | |
guard let mesh = lowLevelMesh, | |
let commandBuffer = commandQueue.makeCommandBuffer(), | |
let computeEncoder = commandBuffer.makeComputeCommandEncoder() else { return } | |
let newVertexBuffer = mesh.replace(bufferIndex: 0, using: commandBuffer) | |
computeEncoder.setComputePipelineState(computePipelineState) | |
let originalVertexBuffer = device.makeBuffer( | |
bytes: originalVertices, | |
length: originalVertices.count * MemoryLayout<VertexDataWith4ChannelColor>.size, | |
options: .storageModeShared | |
) | |
computeEncoder.setBuffer(originalVertexBuffer, offset: 0, index: 0) // Read from original | |
computeEncoder.setBuffer(newVertexBuffer, offset: 0, index: 1) // Write to new | |
var params = BurnFadeParams( | |
progress: morphProgress, | |
scale: burnScale, | |
hueRotate: hueRotate, | |
edgeWidth: edgeWidth, | |
emberRange: emberRange | |
); | |
computeEncoder.setBytes(¶ms, length: MemoryLayout<BurnFadeParams>.size, index: 2) | |
let vertexCount = mesh.vertexCapacity | |
let threadsPerGrid = MTLSize(width: vertexCount, height: 1, depth: 1) | |
let threadsPerThreadgroup = MTLSize(width: 64, height: 1, depth: 1) | |
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup) | |
computeEncoder.endEncoding() | |
commandBuffer.commit() | |
} | |
} | |
#Preview { | |
BurnFadeModelView() | |
} |
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 BurnFadeParams_h | |
#define BurnFadeParams_h | |
struct BurnFadeParams { | |
float progress; | |
float scale; | |
float hueRotate; | |
float edgeWidth; | |
float emberRange; | |
}; | |
#endif /* BurnFadeParams_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 Foundation | |
import RealityKit | |
extension VertexDataWith4ChannelColor { | |
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)!), | |
.init(semantic: .uv2, format: .float4, offset: MemoryLayout<Self>.offset(of: \.color)!) | |
] | |
static var vertexLayouts: [LowLevelMesh.Layout] = [ | |
.init(bufferIndex: 0, bufferStride: MemoryLayout<Self>.stride) | |
] | |
static var descriptor: LowLevelMesh.Descriptor { | |
var desc = LowLevelMesh.Descriptor() | |
desc.vertexAttributes = VertexDataWith4ChannelColor.vertexAttributes | |
desc.vertexLayouts = VertexDataWith4ChannelColor.vertexLayouts | |
desc.indexType = .uint32 | |
return desc | |
} | |
@MainActor static func initializeMesh(vertexCapacity: Int, | |
indexCapacity: Int) throws -> LowLevelMesh { | |
var desc = VertexDataWith4ChannelColor.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 VertexDataWith4ChannelColor_h | |
#define VertexDataWith4ChannelColor_h | |
struct VertexDataWith4ChannelColor { | |
simd_float3 position; | |
simd_float3 normal; | |
simd_float2 uv; | |
simd_float4 color; // rgb + alpha | |
}; | |
#endif /* VertexDataWith4ChannelColor_h */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment