Created
September 25, 2025 02:50
-
-
Save Matt54/44177659cbac7e384282307f18e871f9 to your computer and use it in GitHub Desktop.
RealityKit Extruded Text to LowLevelMesh animated by 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 | |
#Preview { ExtrudedTextLowLevelMeshView() } | |
struct ExtrudedTextLowLevelMeshView: View { | |
@State var lowLevelMesh: LowLevelMesh? | |
@State var originalVerticesBuffer: MTLBuffer? | |
@State var timer: Timer? | |
@State var amplitude: Float = 0.055 | |
@State var animationRate: Float = 1.0 | |
@State var animationPhase: Float = 0.0 | |
@State var scale: Float = 12 | |
@State var offsetFactors: SIMD3<Float> = .init(x: 0.25, y: 0.75, z: 1) | |
let device: MTLDevice | |
let commandQueue: MTLCommandQueue | |
let computePipelineState: MTLComputePipelineState | |
let timerUpdateDuration: TimeInterval = 1.0 / 120.0 | |
init() { | |
self.device = MTLCreateSystemDefaultDevice()! | |
self.commandQueue = device.makeCommandQueue()! | |
let library = device.makeDefaultLibrary()! | |
let kernelFunction = library.makeFunction(name: "animateWavyTextVertices")! | |
self.computePipelineState = try! device.makeComputePipelineState(function: kernelFunction) | |
} | |
var body: some View { | |
VStack { | |
RealityView { content in | |
let entity = try! getEntity() | |
entity.position = .init(x: -0.3, y: 0.0, z: 0) | |
content.add(entity) | |
} | |
VStack { | |
HStack { | |
Text("Amplitude: \(amplitude, specifier: "%.3f")") | |
Spacer() | |
Slider(value: $amplitude, in: 0.0...0.1) | |
.frame(width: 300) | |
} | |
HStack { | |
Text("Animation Rate: \(animationRate, specifier: "%.2f")") | |
Spacer() | |
Slider(value: $animationRate, in: 0.1...2) | |
.frame(width: 300) | |
} | |
HStack { | |
Text("Phase Scale: \(scale, specifier: "%.2f")") | |
Spacer() | |
Slider(value: $scale, in: 10...50) | |
.frame(width: 300) | |
} | |
HStack { | |
Text("X Factor: \(offsetFactors.x, specifier: "%.2f")") | |
Spacer() | |
Slider(value: $offsetFactors.x, in: 0...2) | |
.frame(width: 300) | |
} | |
HStack { | |
Text("Y Factor: \(offsetFactors.y, specifier: "%.2f")") | |
Spacer() | |
Slider(value: $offsetFactors.y, in: 0...2) | |
.frame(width: 300) | |
} | |
HStack { | |
Text("Z Factor: \(offsetFactors.z, specifier: "%.2f")") | |
Spacer() | |
Slider(value: $offsetFactors.z, in: 0...2) | |
.frame(width: 300) | |
} | |
} | |
.frame(width: 500) | |
.padding() | |
.glassBackgroundEffect() | |
} | |
.onAppear { startTimer() } | |
.onDisappear { stopTimer() } | |
} | |
} | |
// MARK: Entity | |
extension ExtrudedTextLowLevelMeshView { | |
func getEntity() throws -> Entity { | |
let resource = try getMeshResource() | |
let lowLevelMesh = try createLowLevelMesh(from: resource) | |
// Capture original vertices once into a GPU buffer for the compute pass | |
lowLevelMesh.withUnsafeBytes(bufferIndex: 0) { buffer in | |
let vertices = buffer.bindMemory(to: VertexData.self) | |
let originals = Array(vertices) | |
let byteCount = originals.count * MemoryLayout<VertexData>.stride | |
self.originalVerticesBuffer = device.makeBuffer(bytes: originals, length: byteCount, options: []) | |
} | |
let swappedMeshResource = try MeshResource(from: lowLevelMesh) | |
var material = PhysicallyBasedMaterial() | |
material.baseColor.tint = .init(red: 1.0, green: 0.9, blue: 0.25, alpha: 1.0) | |
material.roughness.scale = 1.0 | |
material.metallic.scale = 0.0 | |
var material2 = UnlitMaterial() | |
material2.color.tint = .init(red: 0.125, green: 0.0, blue: 0.25, alpha: 1.0) | |
let modelComponent = ModelComponent(mesh: swappedMeshResource, materials: [material, material]) | |
let entity = Entity() | |
entity.components.set(modelComponent) | |
self.lowLevelMesh = lowLevelMesh | |
return entity | |
} | |
} | |
// MARK: MeshResource extruding AttributedString | |
extension ExtrudedTextLowLevelMeshView { | |
func getMeshResource() throws -> MeshResource { | |
var extrusionOptions = MeshResource.ShapeExtrusionOptions() | |
extrusionOptions.extrusionMethod = .linear(depth: 1.0) | |
extrusionOptions.materialAssignment = .init(front: 0, back: 0, extrusion: 1, frontChamfer: 1, backChamfer: 1) | |
extrusionOptions.chamferRadius = 0.1 | |
return try MeshResource(extruding: attributedString, extrusionOptions: extrusionOptions) | |
} | |
var attributedString: AttributedString { | |
var textString = AttributedString("LowLevelMesh") | |
textString.font = .systemFont(ofSize: 6) | |
let paragraphStyle = NSMutableParagraphStyle() | |
paragraphStyle.alignment = .center | |
let centerAttributes = AttributeContainer([.paragraphStyle: paragraphStyle]) | |
textString.mergeAttributes(centerAttributes) | |
return textString | |
} | |
} | |
// MARK: Animation | |
extension ExtrudedTextLowLevelMeshView { | |
func startTimer() { | |
timer = Timer.scheduledTimer(withTimeInterval: timerUpdateDuration, repeats: true) { _ in | |
animationPhase += animationRate * Float(timerUpdateDuration) * 3 | |
updateMesh() | |
} | |
} | |
func stopTimer() { | |
timer?.invalidate() | |
timer = nil | |
} | |
} | |
// MARK: Update Mesh - Compute Shader Pass | |
extension ExtrudedTextLowLevelMeshView { | |
func updateMesh() { | |
guard let mesh = lowLevelMesh, | |
let original = originalVerticesBuffer, | |
let commandBuffer = commandQueue.makeCommandBuffer(), | |
let computeEncoder = commandBuffer.makeComputeCommandEncoder() | |
else { return } | |
let vertexBuffer = mesh.replace(bufferIndex: 0, using: commandBuffer) | |
computeEncoder.setComputePipelineState(computePipelineState) | |
computeEncoder.setBuffer(vertexBuffer, offset: 0, index: 0) // output | |
computeEncoder.setBuffer(original, offset: 0, index: 1) // input (originals) | |
var params = WavyTextParams( | |
amplitude: amplitude, | |
phase: animationPhase, | |
phaseScale: scale, | |
offsetFactors: offsetFactors | |
) | |
computeEncoder.setBytes(¶ms, length: MemoryLayout<WavyTextParams>.size, index: 2) | |
let threadsPerGrid = MTLSize(width: mesh.vertexCapacity, height: 1, depth: 1) | |
let threadsPerThreadgroup = MTLSize(width: 64, height: 1, depth: 1) | |
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup) | |
computeEncoder.endEncoding() | |
commandBuffer.commit() | |
} | |
} | |
// MARK: MeshResource -> LowLevelMesh | |
extension ExtrudedTextLowLevelMeshView { | |
enum MeshCreationError: Error { | |
case modelNotFound, meshPartNotFound | |
} | |
// Complicated by multiple models/parts + instance transforms in MeshResource(extruding:) | |
func createLowLevelMesh(from meshResource: MeshResource) throws -> LowLevelMesh { | |
let models = meshResource.contents.models | |
let instances = meshResource.contents.instances | |
guard !models.isEmpty else { | |
throw MeshCreationError.meshPartNotFound | |
} | |
// Collect all parts and associate them with the transform of the instance that references them. | |
// If there are no instances, fall back to raw models with the identity transform. | |
var resolvedParts: [(part: MeshResource.Part, transform: simd_float4x4)] = [] | |
// Preferred: use instances | |
for instance in instances { | |
if let model = models[instance.model] { | |
for part in model.parts { | |
resolvedParts.append((part, instance.transform)) | |
} | |
} | |
} | |
// Fallback: no instances, just take the raw models | |
if resolvedParts.isEmpty { | |
for model in models { | |
for part in model.parts { | |
resolvedParts.append((part, matrix_identity_float4x4)) | |
} | |
} | |
} | |
guard !resolvedParts.isEmpty else { | |
throw MeshCreationError.meshPartNotFound | |
} | |
// Calculate total buffer capacity | |
let totalVertexCount = resolvedParts.reduce(0) { $0 + ($1.part[MeshBuffers.positions]?.elements.count ?? 0) } | |
let totalIndexCount = resolvedParts.reduce(0) { $0 + ($1.part.triangleIndices?.elements.count ?? 0) } | |
let lowLevelMesh = try VertexData.initializeMesh( | |
vertexCapacity: totalVertexCount, | |
indexCapacity: totalIndexCount | |
) | |
// Running offsets into the combined vertex/index buffers | |
var vertexOffset = 0 | |
var indexOffset = 0 | |
var finalParts: [LowLevelMesh.Part] = [] | |
for (part, transform) in resolvedParts { | |
let positions = part[MeshBuffers.positions]?.elements ?? [] | |
let normals = part[MeshBuffers.normals]?.elements ?? [] | |
let texCoords = part[MeshBuffers.textureCoordinates]?.elements ?? [] | |
let inputIndices = part.triangleIndices?.elements ?? [] | |
// Compute the normal matrix from the instance transform (upper-left 3×3 of the 4×4 matrix). | |
let normalMatrix = simd_float3x3( | |
SIMD3<Float>(transform.columns.0.x, transform.columns.0.y, transform.columns.0.z), | |
SIMD3<Float>(transform.columns.1.x, transform.columns.1.y, transform.columns.1.z), | |
SIMD3<Float>(transform.columns.2.x, transform.columns.2.y, transform.columns.2.z) | |
) | |
// Track per-part bounds (world space, after transform) | |
var boundsMin = SIMD3<Float>(repeating: .greatestFiniteMagnitude) | |
var boundsMax = SIMD3<Float>(repeating: -.greatestFiniteMagnitude) | |
// Copy vertex data into the low-level mesh buffer | |
lowLevelMesh.withUnsafeMutableBytes(bufferIndex: 0) { rawBuffer in | |
let vertexBuffer = rawBuffer.bindMemory( | |
to: (SIMD3<Float>, SIMD3<Float>, SIMD2<Float>).self | |
) | |
for i in 0..<positions.count { | |
// Transform the position into world space | |
let position4 = transform * SIMD4<Float>(positions[i], 1) | |
let worldPosition = SIMD3<Float>(position4.x, position4.y, position4.z) | |
// Transform the normal into world space (if available) | |
let localNormal = (i < normals.count) ? normals[i] : .zero | |
let worldNormal = (localNormal == .zero) ? .zero : normalize(normalMatrix * localNormal) | |
// Get UV coordinates (if available) | |
let texCoord = (i < texCoords.count) ? texCoords[i] : .zero | |
// Write final vertex into the buffer | |
vertexBuffer[vertexOffset + i] = (worldPosition, worldNormal, texCoord) | |
// Expand bounds | |
boundsMin = simd_min(boundsMin, worldPosition) | |
boundsMax = simd_max(boundsMax, worldPosition) | |
} | |
} | |
// Copy index data into the low-level mesh buffer (offsetting by base vertex index) | |
lowLevelMesh.withUnsafeMutableIndices { rawBuffer in | |
let indexBuffer = rawBuffer.bindMemory(to: UInt32.self) | |
for (localIndex, inputIndex) in inputIndices.enumerated() { | |
indexBuffer[indexOffset + localIndex] = UInt32(vertexOffset) + inputIndex | |
} | |
} | |
// Record this part in the final mesh | |
finalParts.append( | |
LowLevelMesh.Part( | |
indexOffset: indexOffset * MemoryLayout<UInt32>.stride, // in bytes | |
indexCount: inputIndices.count, | |
topology: .triangle, | |
materialIndex: part.materialIndex, | |
bounds: BoundingBox(min: boundsMin, max: boundsMax) | |
) | |
) | |
// Advance offsets | |
vertexOffset += positions.count | |
indexOffset += inputIndices.count | |
} | |
// Replace all parts in the low-level mesh with our combined list | |
lowLevelMesh.parts.replaceAll(finalParts) | |
return lowLevelMesh | |
} | |
} |
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 /* VertexData_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
#ifndef WavyTextParams_h | |
#define WavyTextParams_h | |
#include <simd/simd.h> | |
struct WavyTextParams { | |
float amplitude; | |
float phase; | |
float phaseScale; | |
simd_float3 offsetFactors; | |
}; | |
#endif /* WavyTextParams_h */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment