Skip to content

Instantly share code, notes, and snippets.

@dgerrells
Created July 13, 2025 21:08
Show Gist options
  • Save dgerrells/5dd94afe82f1ebae7dbdc3da14bb21d8 to your computer and use it in GitHub Desktop.
Save dgerrells/5dd94afe82f1ebae7dbdc3da14bb21d8 to your computer and use it in GitHub Desktop.
getting swifty
import SwiftUI
@main
struct HowFastIsSwiftApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
// V1
import SwiftUI
struct Particle {
var x: Float32;
var y: Float32;
var dx: Float32;
var dy: Float32;
}
final class ParticleSimulation: ObservableObject {
@Published var image: CGImage?
private(set) var width: Int
private(set) var height: Int
private var pixelData: [UInt8]
private var particles: [Particle]
private var bitmapContext: CGContext?
var pullTarget: CGPoint?
#if os(iOS)
private var displayLink: CADisplayLink?
#elseif os(macOS)
private var timer: Timer?
#endif
let particleCount = 2_000_000
init(width: Int, height: Int) {
self.width = width
self.height = height
self.pixelData = []
self.particles = []
self.resize(width: width, height: height)
self.generateParticles(count: self.particleCount)
self.image = bitmapContext?.makeImage()
startFrameLoop()
}
private func startFrameLoop() {
#if os(iOS)
displayLink?.invalidate()
displayLink = CADisplayLink(target: self, selector: #selector(step))
displayLink?.add(to: .main, forMode: .default)
#elseif os(macOS)
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in
self?.step()
}
#endif
}
deinit {
#if os(iOS)
displayLink?.invalidate()
#elseif os(macOS)
timer?.invalidate()
#endif
}
@objc private func step() {
tick()
render()
self.image = bitmapContext?.makeImage()
}
func tick() {
for i in particles.indices {
var p = particles[i]
if let target = pullTarget {
let targetX = Float32(target.x)
let targetY = Float32(target.y)
let dx = targetX - p.x
let dy = targetY - p.y
let distance = sqrt(dx*dx + dy*dy)
let attractionStrength: Float32 = 0.5 // Adjust this value to control pull strength
if distance > 1.0 { // Avoid division by zero and extreme forces when very close
p.dx += (dx / distance) * attractionStrength
p.dy += (dy / distance) * attractionStrength
}
}
p.x += p.dx
p.y += p.dy
p.dx *= 0.99
p.dy *= 0.99
if p.x < 0 || p.x >= Float(width) {
p.dx = -p.dx
p.x = max(0, min(Float(width - 1), p.x))
}
if p.y < 0 || p.y >= Float(height) {
p.dy = -p.dy
p.y = max(0, min(Float(height - 1), p.y))
}
particles[i] = p
}
}
func render() {
pixelData.withUnsafeMutableBytes { bufferPtr in
guard let ptr = bufferPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return }
ptr.initialize(repeating: 0, count: width * height * 4)
}
for p in particles {
let xi = Int(p.x)
let yi = Int(p.y)
if xi >= 0 && xi < width && yi >= 0 && yi < height {
let r = UInt8(min(255, max(0, p.x / Float(width) * 255)))
let g = UInt8(min(255, max(0, p.y / Float(height) * 255)))
let b = UInt8(min(255, max(0, (1.0 - p.x / Float(width)) * 255)))
let a: UInt8 = 255
let index = (yi * width + xi) * 4
pixelData.withUnsafeMutableBytes { bufferPtr in
guard let ptr = bufferPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return }
ptr[index] = r
ptr[index + 1] = g
ptr[index + 2] = b
ptr[index + 3] = a
}
}
}
}
func resize(width: Int, height: Int) {
self.width = width
self.height = height
self.pixelData = [UInt8](repeating: 0, count: width * height * 4)
let bitsPerComponent = 8
let bytesPerRow = width * 4
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue)
self.bitmapContext = CGContext(data: &pixelData,
width: width,
height: height,
bitsPerComponent: bitsPerComponent,
bytesPerRow: bytesPerRow,
space: colorSpace,
bitmapInfo: bitmapInfo.rawValue)
generateParticles(count: self.particleCount)
if self.image != nil {
self.image = bitmapContext?.makeImage()
}
}
func generateParticles(count: Int) {
var ps: [Particle] = []
for _ in 0..<count {
let x = Float(Int.random(in: 0..<width))
let y = Float(Int.random(in: 0..<height))
let dx = Float.random(in: -3...3)
let dy = Float.random(in: -3...3)
ps.append(Particle(x: x, y: y, dx: dx, dy: dy))
}
self.particles = ps
}
// New method to set the pull target
func setPullTarget(_ point: CGPoint?) {
self.pullTarget = point
}
}
struct ContentView: View {
@StateObject private var sim = ParticleSimulation(width: 100, height: 100)
var body: some View {
GeometryReader { geometry in
VStack {
if let img = sim.image {
Image(decorative: img, scale: 1)
.interpolation(.none)
.resizable()
.scaledToFit()
// Add gesture recognizer here
.gesture(
DragGesture(minimumDistance: 0) // minimumDistance 0 allows for taps
.onChanged { value in
// Set the pull target to the current touch location
sim.setPullTarget(value.location)
}
.onEnded { _ in
// Clear the pull target when the touch ends
sim.setPullTarget(nil)
}
)
}
}
.onChange(of: geometry.size) { (oldSize, newSize) in
let newWidth = Int(newSize.width)
let newHeight = Int(newSize.height)
if newWidth > 0 && newHeight > 0 && (newWidth != sim.width || newHeight != sim.height) {
sim.resize(width: newWidth, height: newHeight)
}
}
}
}
}
#Preview {
ContentView()
}
// V2 Metal
import SwiftUI
import MetalKit
import simd
struct QuadVertex {
var position: SIMD2<Float>
var texCoord: SIMD2<Float>
}
struct Particle {
var x: Float32;
var y: Float32;
var dx: Float32;
var dy: Float32;
}
class MetalRenderer: NSObject, MTKViewDelegate {
let device: MTLDevice
let commandQueue: MTLCommandQueue
var texture: MTLTexture?
var pixelData: [UInt8]
var pipelineState: MTLRenderPipelineState?
var vertexBuffer: MTLBuffer?
var particles: [Particle]
let particleCount = 1_000_000
var width: Int
var height: Int
var pullTarget: CGPoint?
init?(mtkView: MTKView) {
guard let device = MTLCreateSystemDefaultDevice() else { return nil }
self.device = device
self.commandQueue = device.makeCommandQueue()!
self.width = max(1, Int(mtkView.drawableSize.width))
self.height = max(1, Int(mtkView.drawableSize.height))
self.pixelData = []
self.particles = []
super.init()
mtkView.device = device
mtkView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
mtkView.colorPixelFormat = .rgba8Unorm
mtkView.delegate = self
mtkView.isPaused = false
createTexture(width: self.width, height: self.height)
setupRenderPipeline(view: mtkView)
}
func setupRenderPipeline(view: MTKView) {
let shaderSource = """
#include <metal_stdlib>
using namespace metal;
struct QuadVertex {
float2 position [[attribute(0)]];
float2 texCoord [[attribute(1)]];
};
struct VertexOut {
float4 position [[position]];
float2 texCoord;
};
vertex VertexOut vertex_main(QuadVertex in [[stage_in]]) {
VertexOut out;
out.position = float4(in.position, 0.0, 1.0);
out.texCoord = in.texCoord;
return out;
}
fragment float4 fragment_main(VertexOut in [[stage_in]],
texture2d<float> imageTexture [[texture(0)]]) {
constexpr sampler s(address::clamp_to_edge, filter::linear);
float4 color = imageTexture.sample(s, in.texCoord);
return color;
}
"""
do {
let library = try device.makeLibrary(source: shaderSource, options: nil)
let vertexFunction = library.makeFunction(name: "vertex_main")
let fragmentFunction = library.makeFunction(name: "fragment_main")
guard let vertFunc = vertexFunction, let fragFunc = fragmentFunction else {
print("Failed to find Metal functions.")
return
}
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertFunc
pipelineDescriptor.fragmentFunction = fragFunc
pipelineDescriptor.colorAttachments[0].pixelFormat = view.colorPixelFormat
let vertexDescriptor = MTLVertexDescriptor()
vertexDescriptor.attributes[0].format = .float2
vertexDescriptor.attributes[0].offset = 0
vertexDescriptor.attributes[0].bufferIndex = 0
vertexDescriptor.attributes[1].format = .float2
vertexDescriptor.attributes[1].offset = MemoryLayout<SIMD2<Float>>.stride
vertexDescriptor.attributes[1].bufferIndex = 0
vertexDescriptor.layouts[0].stride = MemoryLayout<QuadVertex>.stride
vertexDescriptor.layouts[0].stepFunction = .perVertex
pipelineDescriptor.vertexDescriptor = vertexDescriptor
pipelineState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
let vertices: [QuadVertex] = [
QuadVertex(position: [-1.0, -1.0], texCoord: [0.0, 1.0]), // Bottom-left
QuadVertex(position: [ 1.0, -1.0], texCoord: [1.0, 1.0]), // Bottom-right
QuadVertex(position: [-1.0, 1.0], texCoord: [0.0, 0.0]), // Top-left
QuadVertex(position: [-1.0, 1.0], texCoord: [0.0, 0.0]), // Top-left
QuadVertex(position: [ 1.0, -1.0], texCoord: [1.0, 1.0]), // Bottom-right
QuadVertex(position: [ 1.0, 1.0], texCoord: [1.0, 0.0]) // Top-right
]
vertexBuffer = device.makeBuffer(bytes: vertices, length: MemoryLayout<QuadVertex>.stride * vertices.count, options: [])
} catch {
print("Failed to create render pipeline state or compile shader: \(error)")
}
}
func createTexture(width: Int, height: Int) {
self.width = width
self.height = height
let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: .rgba8Unorm,
width: width,
height: height,
mipmapped: false
)
textureDescriptor.usage = [.shaderRead, .renderTarget]
self.texture = device.makeTexture(descriptor: textureDescriptor)
self.pixelData = [UInt8](repeating: 0, count: width * height * 4)
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
createTexture(width: Int(size.width), height: Int(size.height))
if particles.isEmpty {
generateParticles(count: self.particleCount)
}
}
func draw(in view: MTKView) {
tick()
render()
copyPixelsToTexture()
guard let drawable = view.currentDrawable,
let renderPassDescriptor = view.currentRenderPassDescriptor,
let texture = texture else { return }
renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 1)
renderPassDescriptor.colorAttachments[0].loadAction = .clear
renderPassDescriptor.colorAttachments[0].storeAction = .store
let commandBuffer = commandQueue.makeCommandBuffer()
if let renderEncoder = commandBuffer?.makeRenderCommandEncoder(descriptor: renderPassDescriptor) {
renderEncoder.setRenderPipelineState(pipelineState!)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
renderEncoder.setFragmentTexture(texture, index: 0)
renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
renderEncoder.endEncoding()
}
commandBuffer?.present(drawable)
commandBuffer?.commit()
}
func tick() {
let w = Float(self.width)
let h = Float(self.height)
let hasTouch = pullTarget != nil;
let targetX = pullTarget != nil ? Float(pullTarget!.x) : 0
let targetY = pullTarget != nil ? Float(pullTarget!.y) : 0
let attractionStrength: Float32 = 0.5
particles.withUnsafeMutableBufferPointer { particleBuffer in
guard let ptr = particleBuffer.baseAddress else { return }
for i in 0..<particleCount {
var p = ptr[i]
if hasTouch {
let dx = targetX - p.x
let dy = targetY - p.y
let distance = sqrt(dx*dx + dy*dy)
if distance > 1.0 {
p.dx += (dx / distance) * attractionStrength
p.dy += (dy / distance) * attractionStrength
}
}
p.x += p.dx
p.y += p.dy
p.dx *= 0.99
p.dy *= 0.99
// if p.x < 0 || p.x >= w {
// p.dx = -p.dx
// p.x = max(0, min(w-1, p.x))
// }
// if p.y < 0 || p.y >= h {
// p.dy = -p.dy
// p.y = max(0, min(h-1, p.y))
// }
if p.x < 0 {
p.x = w
} else if p.x > w {
p.x = 0
}
if p.y < 0 {
p.y = h
} else if p.y > h {
p.y = 0
}
ptr[i] = p
}
}
}
func render() {
pixelData.withUnsafeMutableBytes { bufferPtr in
guard let ptr = bufferPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return }
ptr.initialize(repeating: 0, count: width * height * 4)
let fw = Float(width)
let fh = Float(height)
let scaleX = 255.0 / fw
let scaleY = 255.0 / fh
let iw = self.width
particles.withUnsafeBufferPointer { buffer in
guard let pPtr = buffer.baseAddress else { return }
for i in 0..<particleCount {
let p = pPtr[i]
if p.x < 0 || p.x >= fw || p.y < 0 || p.y >= fh { continue }
let colorfX = p.x * scaleX
let colorfY = p.y * scaleY
let r = UInt8(clamping: Int(colorfX))
let g = UInt8(clamping: Int(colorfY))
let b = UInt8(clamping: Int((1.0 - p.x / fw) * 255))
let index = (Int(p.y) * iw + Int(p.x)) * 4
ptr[index + 0] = r
ptr[index + 1] = g
ptr[index + 2] = b
// ptr[index + 3] = 255 // Alpha is typically 255 for opaque
}
}
}
}
func copyPixelsToTexture() {
guard let texture = texture else { return }
let region = MTLRegion(origin: MTLOrigin(x: 0, y: 0, z: 0),
size: MTLSize(width: width, height: height, depth: 1))
pixelData.withUnsafeBytes { ptr in
texture.replace(region: region,
mipmapLevel: 0,
withBytes: ptr.baseAddress!,
bytesPerRow: width * 4)
}
}
func generateParticles(count: Int) {
var ps: [Particle] = []
for _ in 0..<count {
let x = Float(Int.random(in: 0..<width))
let y = Float(Int.random(in: 0..<height))
let dx = Float.random(in: -3...3)
let dy = Float.random(in: -3...3)
ps.append(Particle(x: x, y: y, dx: dx, dy: dy))
}
self.particles = ps
}
func handleTouch(_ point: CGPoint?) {
self.pullTarget = point
}
}
struct MetalView: View {
var body: some View {
#if os(iOS)
iOSMetalView()
#elseif os(macOS)
macOSMetalView()
#else
Text("Metal is not supported on this platform.")
#endif
}
}
#if os(iOS)
private struct iOSMetalView: UIViewRepresentable {
func makeUIView(context: Context) -> MTKView {
let mtkView = MTKView()
mtkView.device = MTLCreateSystemDefaultDevice()
mtkView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
mtkView.colorPixelFormat = .rgba8Unorm
mtkView.delegate = context.coordinator
mtkView.isPaused = false
context.coordinator.renderer = MetalRenderer(mtkView: mtkView)
let pressGesture = NSPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePress(_:)))
pressGesture.minimumPressDuration = 0
mtkView.addGestureRecognizer(pressGesture)
return mtkView
}
func updateUIView(_ uiView: MTKView, context: Context) {
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, MTKViewDelegate {
var renderer: MetalRenderer?
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
renderer?.mtkView(view, drawableSizeWillChange: size)
}
func draw(in view: MTKView) {
renderer?.draw(in: view)
}
@objc func handlePress(_ gesture: NSClickGestureRecognizer) {
guard let view = gesture.view else { return }
switch gesture.state {
case .began, .changed:
let location = gesture.location(in: view)
let flippedLocation = CGPoint(x: location.x * 2, y: (view.bounds.height - location.y) * 2)
renderer?.handleTouch(flippedLocation)
case .ended, .cancelled:
renderer?.handleTouch(nil)
default:
break
}
}
}
}
#elseif os(macOS)
private struct macOSMetalView: NSViewRepresentable {
func makeNSView(context: Context) -> MTKView {
let mtkView = MTKView()
mtkView.device = MTLCreateSystemDefaultDevice()
mtkView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
mtkView.colorPixelFormat = .rgba8Unorm
mtkView.delegate = context.coordinator
mtkView.isPaused = false
context.coordinator.renderer = MetalRenderer(mtkView: mtkView)
let pressGesture = NSPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePress(_:)))
pressGesture.minimumPressDuration = 0
mtkView.addGestureRecognizer(pressGesture)
return mtkView
}
func updateNSView(_ nsView: MTKView, context: Context) {
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, MTKViewDelegate {
var renderer: MetalRenderer?
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
renderer?.mtkView(view, drawableSizeWillChange: size)
}
func draw(in view: MTKView) {
renderer?.draw(in: view)
}
@objc func handlePress(_ gesture: NSClickGestureRecognizer) {
guard let view = gesture.view else { return }
switch gesture.state {
case .began, .changed:
let location = gesture.location(in: view)
let flippedLocation = CGPoint(x: location.x * 2, y: (view.bounds.height - location.y) * 2)
renderer?.handleTouch(flippedLocation)
case .ended, .cancelled:
renderer?.handleTouch(nil)
default:
break
}
}
}
}
#endif
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
MetalView()
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}
#Preview {
ContentView()
}
// V3 metal buff
import SwiftUI
import MetalKit
import simd
struct QuadVertex {
var position: SIMD2<Float>
var texCoord: SIMD2<Float>
}
struct Particle {
var x: Float32;
var y: Float32;
var dx: Float32;
var dy: Float32;
}
class MetalRenderer: NSObject, MTKViewDelegate {
let device: MTLDevice
let commandQueue: MTLCommandQueue
var texture: MTLTexture?
var pixelBuffer: MTLBuffer?
var pipelineState: MTLRenderPipelineState?
var vertexBuffer: MTLBuffer?
var particles: [Particle]
let particleCount = 1_000_000
var width: Int
var height: Int
var lastFrameTime: CFAbsoluteTime
var pullTarget: CGPoint?
init?(mtkView: MTKView) {
guard let device = MTLCreateSystemDefaultDevice() else { return nil }
self.device = device
self.lastFrameTime = CFAbsoluteTimeGetCurrent()
self.commandQueue = device.makeCommandQueue()!
self.width = max(4, Int(mtkView.drawableSize.width))
self.height = max(4, Int(mtkView.drawableSize.height))
self.particles = []
super.init()
mtkView.device = device
mtkView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
mtkView.colorPixelFormat = .rgba8Unorm
mtkView.delegate = self
mtkView.isPaused = false
createTextureAndBuffer(width: self.width, height: self.height)
setupRenderPipeline(view: mtkView)
}
func setupRenderPipeline(view: MTKView) {
let shaderSource = """
#include <metal_stdlib>
using namespace metal;
struct QuadVertex {
float2 position [[attribute(0)]];
float2 texCoord [[attribute(1)]];
};
struct VertexOut {
float4 position [[position]];
float2 texCoord;
};
vertex VertexOut vertex_main(QuadVertex in [[stage_in]]) {
VertexOut out;
out.position = float4(in.position, 0.0, 1.0);
out.texCoord = in.texCoord;
return out;
}
fragment float4 fragment_main(VertexOut in [[stage_in]],
texture2d<float> imageTexture [[texture(0)]]) {
constexpr sampler s(address::clamp_to_edge, filter::linear);
float4 color = imageTexture.sample(s, in.texCoord);
return color;
}
"""
do {
let library = try device.makeLibrary(source: shaderSource, options: nil)
let vertexFunction = library.makeFunction(name: "vertex_main")
let fragmentFunction = library.makeFunction(name: "fragment_main")
guard let vertFunc = vertexFunction, let fragFunc = fragmentFunction else {
print("Failed to find Metal functions.")
return
}
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertFunc
pipelineDescriptor.fragmentFunction = fragFunc
pipelineDescriptor.colorAttachments[0].pixelFormat = view.colorPixelFormat
let vertexDescriptor = MTLVertexDescriptor()
vertexDescriptor.attributes[0].format = .float2
vertexDescriptor.attributes[0].offset = 0
vertexDescriptor.attributes[0].bufferIndex = 0
vertexDescriptor.attributes[1].format = .float2
vertexDescriptor.attributes[1].offset = MemoryLayout<SIMD2<Float>>.stride
vertexDescriptor.attributes[1].bufferIndex = 0
vertexDescriptor.layouts[0].stride = MemoryLayout<QuadVertex>.stride
vertexDescriptor.layouts[0].stepFunction = .perVertex
pipelineDescriptor.vertexDescriptor = vertexDescriptor
pipelineState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
let vertices: [QuadVertex] = [
QuadVertex(position: [-1.0, -1.0], texCoord: [0.0, 1.0]),
QuadVertex(position: [ 1.0, -1.0], texCoord: [1.0, 1.0]),
QuadVertex(position: [-1.0, 1.0], texCoord: [0.0, 0.0]),
QuadVertex(position: [-1.0, 1.0], texCoord: [0.0, 0.0]),
QuadVertex(position: [ 1.0, -1.0], texCoord: [1.0, 1.0]),
QuadVertex(position: [ 1.0, 1.0], texCoord: [1.0, 0.0])
]
vertexBuffer = device.makeBuffer(bytes: vertices, length: MemoryLayout<QuadVertex>.stride * vertices.count, options: [])
} catch {
print("Failed to create render pipeline state or compile shader: \(error)")
}
}
func createTextureAndBuffer(width: Int, height: Int) {
self.width = width
self.height = height
// let bytesPerPixel = 4
// let rowBytes = width * bytesPerPixel
// let length = height * rowBytes
let bytesPerPixel = 4
let alignment = 16
var rowBytes = width * bytesPerPixel
rowBytes = (rowBytes + alignment - 1) & ~(alignment - 1)
var length = height * rowBytes
length = (length + alignment - 1) & ~(alignment - 1)
pixelBuffer = device.makeBuffer(length: length, options: [.storageModeShared])
let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: .rgba8Unorm,
width: width,
height: height,
mipmapped: false
)
textureDescriptor.usage = [.shaderRead, .renderTarget]
textureDescriptor.storageMode = .shared
self.texture = pixelBuffer?.makeTexture(descriptor: textureDescriptor, offset: 0, bytesPerRow: rowBytes)
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
createTextureAndBuffer(width: Int(size.width), height: Int(size.height))
if particles.isEmpty {
generateParticles(count: self.particleCount)
} else {
}
}
func draw(in view: MTKView) {
let currentTime = CFAbsoluteTimeGetCurrent()
let deltaTime = currentTime - lastFrameTime
lastFrameTime = currentTime
tick(dt: Float(deltaTime))
render()
guard let drawable = view.currentDrawable,
let renderPassDescriptor = view.currentRenderPassDescriptor,
let texture = texture else { return }
renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 1)
renderPassDescriptor.colorAttachments[0].loadAction = .clear
renderPassDescriptor.colorAttachments[0].storeAction = .store
let commandBuffer = commandQueue.makeCommandBuffer()
if let renderEncoder = commandBuffer?.makeRenderCommandEncoder(descriptor: renderPassDescriptor) {
renderEncoder.setRenderPipelineState(pipelineState!)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
renderEncoder.setFragmentTexture(texture, index: 0)
renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
renderEncoder.endEncoding()
}
commandBuffer?.present(drawable)
commandBuffer?.commit()
}
func tick(dt: Float) {
let w = Float(self.width)
let h = Float(self.height)
let hasTouch = pullTarget != nil;
let targetX = pullTarget != nil ? Float(pullTarget!.x) : 0
let targetY = pullTarget != nil ? Float(pullTarget!.y) : 0
let attractionStrength: Float32 = 2000 * dt;
let friction: Float32 = pow(0.99, dt * 60.0)
particles.withUnsafeMutableBufferPointer { particleBuffer in
guard let ptr = particleBuffer.baseAddress else { return }
for i in 0..<particleCount {
var p = ptr[i]
if hasTouch {
let dx = targetX - p.x
let dy = targetY - p.y
let distance = sqrt(dx*dx + dy*dy)
if distance > 1.0 {
p.dx += (dx / distance) * attractionStrength
p.dy += (dy / distance) * attractionStrength
}
}
p.x += p.dx * dt
p.y += p.dy * dt
p.dx *= friction
p.dy *= friction
if p.x < 0 {
p.x = w - 1
} else if p.x > w {
p.x = 0
}
if p.y < 0 {
p.y = h - 1
} else if p.y > h {
p.y = 0
}
ptr[i] = p
}
}
}
func render() {
guard let bufferPointer = pixelBuffer?.contents() else { return }
let ptr = bufferPointer.assumingMemoryBound(to: UInt8.self)
ptr.initialize(repeating: 0, count: width * height * 4)
let fw = Float(width)
let fh = Float(height)
let scaleX = 255.0 / fw
let scaleY = 255.0 / fh
let iw = self.width
particles.withUnsafeBufferPointer { particleBuffer in
guard let pPtr = particleBuffer.baseAddress else { return }
for i in 0..<particleCount {
let p = pPtr[i]
// if p.x < 0 || p.x >= fw || p.y < 0 || p.y >= fh { continue }
let r = UInt8(clamping: 25 + Int(p.x * scaleX))
let g = UInt8(clamping: 25 + Int(p.y * scaleY))
let b = UInt8(clamping: 25 + Int((1.0 - p.x / fw) * 255))
let index = (Int(p.y) * iw + Int(p.x)) * 4
ptr[index + 0] = r
ptr[index + 1] = g
ptr[index + 2] = b
ptr[index + 3] = 255
}
}
}
func generateParticles(count: Int) {
var ps: [Particle] = []
for _ in 0..<count {
let x = Float(Int.random(in: 0..<width))
let y = Float(Int.random(in: 0..<height))
let dx = Float.random(in: -3...3)
let dy = Float.random(in: -3...3)
ps.append(Particle(x: x, y: y, dx: dx, dy: dy))
}
self.particles = ps
}
func handleTouch(_ point: CGPoint?) {
self.pullTarget = point
}
}
struct MetalView: View {
var body: some View {
#if os(iOS)
iOSMetalView()
#elseif os(macOS)
macOSMetalView()
#else
Text("Metal is not supported on this platform.")
#endif
}
}
#if os(iOS)
private struct iOSMetalView: UIViewRepresentable {
func makeUIView(context: Context) -> MTKView {
let mtkView = MTKView()
mtkView.device = MTLCreateSystemDefaultDevice()
mtkView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
mtkView.colorPixelFormat = .rgba8Unorm
mtkView.delegate = context.coordinator
mtkView.isPaused = false
context.coordinator.renderer = MetalRenderer(mtkView: mtkView)
let pressGesture = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePress(_:)))
pressGesture.minimumPressDuration = 0
mtkView.addGestureRecognizer(pressGesture)
return mtkView
}
func updateUIView(_ uiView: MTKView, context: Context) {
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, MTKViewDelegate {
var renderer: MetalRenderer?
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
renderer?.mtkView(view, drawableSizeWillChange: size)
}
func draw(in view: MTKView) {
renderer?.draw(in: view)
}
@objc func handlePress(_ gesture: UILongPressGestureRecognizer) {
guard let view = gesture.view else { return }
switch gesture.state {
case .began, .changed:
let location = gesture.location(in: view)
let flippedLocation = CGPoint(x: location.x * 2, y: (view.bounds.height - location.y) * 2)
renderer?.handleTouch(flippedLocation)
case .ended, .cancelled:
renderer?.handleTouch(nil)
default:
break
}
}
}
}
#elseif os(macOS)
private struct macOSMetalView: NSViewRepresentable {
func makeNSView(context: Context) -> MTKView {
let mtkView = MTKView()
mtkView.device = MTLCreateSystemDefaultDevice()
mtkView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
mtkView.colorPixelFormat = .rgba8Unorm
mtkView.delegate = context.coordinator
mtkView.isPaused = false
context.coordinator.renderer = MetalRenderer(mtkView: mtkView)
let pressGesture = NSPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePress(_:)))
pressGesture.minimumPressDuration = 0
mtkView.addGestureRecognizer(pressGesture)
return mtkView
}
func updateNSView(_ nsView: MTKView, context: Context) {
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, MTKViewDelegate {
var renderer: MetalRenderer?
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
renderer?.mtkView(view, drawableSizeWillChange: size)
}
func draw(in view: MTKView) {
renderer?.draw(in: view)
}
@objc func handlePress(_ gesture: NSClickGestureRecognizer) {
guard let view = gesture.view else { return }
switch gesture.state {
case .began, .changed:
let location = gesture.location(in: view)
let flippedLocation = CGPoint(x: location.x * 2, y: (view.bounds.height - location.y) * 2)
renderer?.handleTouch(flippedLocation)
case .ended, .cancelled:
renderer?.handleTouch(nil)
default:
break
}
}
}
}
#endif
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
MetalView()
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}
#Preview {
ContentView()
}
import SwiftUI
import MetalKit
import simd
import Foundation
struct QuadVertex {
var position: SIMD2<Float>
var texCoord: SIMD2<Float>
}
struct Particle {
var position: SIMD2<Float>
var velocity: SIMD2<Float>
}
class MetalRenderer: NSObject, MTKViewDelegate {
let device: MTLDevice
let commandQueue: MTLCommandQueue
var texture: MTLTexture?
var pixelBuffer: MTLBuffer?
var pipelineState: MTLRenderPipelineState?
var vertexBuffer: MTLBuffer?
var particles: [Particle]
let particleCount = 40_000_000
var width: Int
var height: Int
var lastFrameTime: CFAbsoluteTime
var frameCount: Int
var pullTarget: CGPoint?
let numberOfThreads: Int
init?(mtkView: MTKView) {
guard let device = MTLCreateSystemDefaultDevice() else { return nil }
self.device = device
self.lastFrameTime = CFAbsoluteTimeGetCurrent()
self.commandQueue = device.makeCommandQueue()!
self.width = max(4, Int(mtkView.drawableSize.width))
self.height = max(4, Int(mtkView.drawableSize.height))
self.frameCount = 0;
self.particles = Array(repeating: Particle(position: SIMD2(0,0), velocity: SIMD2(0,0)), count: particleCount)
self.numberOfThreads = ProcessInfo.processInfo.activeProcessorCount
super.init()
mtkView.device = device
mtkView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
mtkView.colorPixelFormat = .rgba8Unorm
mtkView.delegate = self
mtkView.isPaused = false
createTextureAndBuffer(width: self.width, height: self.height)
setupRenderPipeline(view: mtkView)
}
func setupRenderPipeline(view: MTKView) {
let shaderSource = """
#include <metal_stdlib>
using namespace metal;
struct QuadVertex {
float2 position [[attribute(0)]];
float2 texCoord [[attribute(1)]];
};
struct VertexOut {
float4 position [[position]];
float2 texCoord;
};
vertex VertexOut vertex_main(QuadVertex in [[stage_in]]) {
VertexOut out;
out.position = float4(in.position, 0.0, 1.0);
out.texCoord = in.texCoord;
return out;
}
float3 hsl2rgb(float h, float s, float l) {
float c = (1.0 - fabs(2.0 * l - 1.0)) * s;
float hp = h * 6.0;
float x = c * (1.0 - fabs(fmod(hp, 2.0) - 1.0));
float3 rgb1;
if (0.0 <= hp && hp < 1.0) rgb1 = float3(c, x, 0.0);
else if (1.0 <= hp && hp < 2.0) rgb1 = float3(x, c, 0.0);
else if (2.0 <= hp && hp < 3.0) rgb1 = float3(0.0, c, x);
else if (3.0 <= hp && hp < 4.0) rgb1 = float3(0.0, x, c);
else if (4.0 <= hp && hp < 5.0) rgb1 = float3(x, 0.0, c);
else if (5.0 <= hp && hp < 6.0) rgb1 = float3(c, 0.0, x);
else rgb1 = float3(0.0, 0.0, 0.0);
float m = l - 0.5 * c;
return rgb1 + m;
}
fragment float4 fragment_main(VertexOut in [[stage_in]],
texture2d<float> imageTexture [[texture(0)]]) { // Change uchar to float
constexpr sampler s(address::clamp_to_edge, filter::nearest);
float pixelValueNormalized = imageTexture.sample(s, in.texCoord).r;
if (pixelValueNormalized > 0) {
return float4(in.texCoord.x, in.texCoord.y, 1.0, 1.0);
}
return float4(0.0,0.0,0.0,0.0 );
}
"""
do {
let library = try device.makeLibrary(source: shaderSource, options: nil)
let vertexFunction = library.makeFunction(name: "vertex_main")
let fragmentFunction = library.makeFunction(name: "fragment_main")
guard let vertFunc = vertexFunction, let fragFunc = fragmentFunction else { return }
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertFunc
pipelineDescriptor.fragmentFunction = fragFunc
pipelineDescriptor.colorAttachments[0].pixelFormat = view.colorPixelFormat
let vertexDescriptor = MTLVertexDescriptor()
vertexDescriptor.attributes[0].format = .float2
vertexDescriptor.attributes[0].offset = 0
vertexDescriptor.attributes[0].bufferIndex = 0
vertexDescriptor.attributes[1].format = .float2
vertexDescriptor.attributes[1].offset = MemoryLayout<SIMD2<Float>>.stride
vertexDescriptor.attributes[1].bufferIndex = 0
vertexDescriptor.layouts[0].stride = MemoryLayout<QuadVertex>.stride
vertexDescriptor.layouts[0].stepFunction = .perVertex
pipelineDescriptor.vertexDescriptor = vertexDescriptor
pipelineState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
let vertices: [QuadVertex] = [
QuadVertex(position: [-1.0, -1.0], texCoord: [0.0, 1.0]),
QuadVertex(position: [ 1.0, -1.0], texCoord: [1.0, 1.0]),
QuadVertex(position: [-1.0, 1.0], texCoord: [0.0, 0.0]),
QuadVertex(position: [-1.0, 1.0], texCoord: [0.0, 0.0]),
QuadVertex(position: [ 1.0, -1.0], texCoord: [1.0, 1.0]),
QuadVertex(position: [ 1.0, 1.0], texCoord: [1.0, 0.0])
]
vertexBuffer = device.makeBuffer(bytes: vertices, length: MemoryLayout<QuadVertex>.stride * vertices.count, options: [])
} catch {
print("Failed to create render pipeline state or compile shader: \(error)")
}
}
func createTextureAndBuffer(width: Int, height: Int) {
self.width = width
self.height = height
let bytesPerPixel = 1
let alignment = 16
var rowBytes = width * bytesPerPixel
rowBytes = (rowBytes + alignment - 1) & ~(alignment - 1)
var length = height * rowBytes
length = (length + alignment - 1) & ~(alignment - 1)
pixelBuffer = device.makeBuffer(length: length, options: [.storageModeShared])
let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: .r8Unorm,
width: width,
height: height,
mipmapped: false
)
textureDescriptor.usage = [.shaderRead, .renderTarget]
textureDescriptor.storageMode = .shared
self.texture = pixelBuffer?.makeTexture(descriptor: textureDescriptor, offset: 0, bytesPerRow: rowBytes)
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
createTextureAndBuffer(width: Int(size.width), height: Int(size.height))
generateParticles(count: self.particleCount)
}
func draw(in view: MTKView) {
let currentTime = CFAbsoluteTimeGetCurrent()
let deltaTime = currentTime - lastFrameTime
lastFrameTime = currentTime
frameCount += 1
if frameCount % 10 == 0 {
print("FPS: \(String(format: "%.2f", 1/deltaTime))")
}
tick(dt: Float(deltaTime))
render()
guard let drawable = view.currentDrawable,
let renderPassDescriptor = view.currentRenderPassDescriptor,
let texture = texture else { return }
renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 1)
renderPassDescriptor.colorAttachments[0].loadAction = .clear
renderPassDescriptor.colorAttachments[0].storeAction = .store
let commandBuffer = commandQueue.makeCommandBuffer()
if let renderEncoder = commandBuffer?.makeRenderCommandEncoder(descriptor: renderPassDescriptor) {
renderEncoder.setRenderPipelineState(pipelineState!)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
renderEncoder.setFragmentTexture(texture, index: 0)
renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
renderEncoder.endEncoding()
}
commandBuffer?.present(drawable)
commandBuffer?.commit()
}
func tick(dt: Float) {
let currentPullTarget = self.pullTarget
let hasTouch = currentPullTarget != nil
let targetX = hasTouch ? Float(currentPullTarget!.x) : 0
let targetY = hasTouch ? Float(currentPullTarget!.y) : 0
let attractionStrength: Float32 = 1600 * dt
let friction: Float32 = pow(0.9975, dt * 60.0)
let numParticles = self.particleCount
let particlesPerThread = (numParticles + numberOfThreads - 1) / numberOfThreads
particles.withUnsafeMutableBufferPointer { particleBuffer in
guard let particlePtr = particleBuffer.baseAddress else { return }
DispatchQueue.concurrentPerform(iterations: numberOfThreads) { threadIndex in
let startIndex = threadIndex * particlesPerThread
let endIndex = min(startIndex + particlesPerThread, numParticles)
let w = Float(self.width)
let h = Float(self.height)
// let bounds = SIMD2<Float>(w, h)
// let invBounds = 1.0 / bounds
for i in startIndex..<endIndex {
var p = particlePtr[i]
if hasTouch {
let dx = targetX - p.position.x
let dy = targetY - p.position.y
let distance = sqrt(dx*dx + dy*dy)
if distance > 1.0 {
p.velocity.x += (dx / distance) * attractionStrength
p.velocity.y += (dy / distance) * attractionStrength
}
}
// let magnitudeSquared = dot(p.velocity, p.velocity)
// if magnitudeSquared > 1_000_000 {
// let magnitude = sqrt(magnitudeSquared)
// p.velocity = 1_000 * (p.velocity / magnitude)
// }
p.position += p.velocity * dt
p.velocity *= friction
// p.position -= floor(p.position * invBounds) * bounds
particlePtr[i] = p
}
}
}
}
func render() {
guard let bufferPointer = pixelBuffer?.contents() else { return }
let ptr = bufferPointer.assumingMemoryBound(to: UInt8.self)
let totalLength = width * height * 1
ptr.initialize(repeating: 0, count: totalLength)
let fw = Float(width)
let fh = Float(height)
let iw = self.width
let numParticles = self.particleCount
let particlesPerThread = (numParticles + numberOfThreads - 1) / numberOfThreads
particles.withUnsafeBufferPointer { particleBuffer in
guard let particlePtr = particleBuffer.baseAddress else { return }
DispatchQueue.concurrentPerform(iterations: numberOfThreads) { threadIndex in
let startIndex = threadIndex * particlesPerThread
let endIndex = min(startIndex + particlesPerThread, numParticles)
for i in startIndex..<endIndex {
let p = particlePtr[i]
let clampedX = Int(max(0, min(fw - 1, p.position.x)))
let clampedY = Int(max(0, min(fh - 1, p.position.y)))
//
// if (clampedX < 0 || clampedY < 0 || clampedX >= self.width || clampedY >= self.height) {
// continue
// }
let index = (clampedY * iw + clampedX)
ptr[index] = 1 // Set the byte to 1 to indicate a particle is present
}
}
}
}
func generateParticles(count: Int) {
if frameCount > 10 {
return
}
self.particles.withUnsafeMutableBufferPointer { particleBuffer in
guard let particlePtr = particleBuffer.baseAddress else { return }
for i in 0..<count {
particlePtr[i].position.x = Float(Int.random(in: 0..<width))
particlePtr[i].position.y = Float(Int.random(in: 0..<height))
particlePtr[i].velocity.x = Float.random(in: -3...3)
particlePtr[i].velocity.y = Float.random(in: -3...3)
}
}
}
func handleTouch(_ point: CGPoint?) {
self.pullTarget = point
}
}
struct MetalView: View {
var body: some View {
#if os(iOS)
iOSMetalView()
#elseif os(macOS)
macOSMetalView()
#else
Text("Metal is not supported on this platform.")
#endif
}
}
#if os(iOS)
private struct iOSMetalView: UIViewRepresentable {
func makeUIView(context: Context) -> MTKView {
let mtkView = MTKView()
mtkView.device = MTLCreateSystemDefaultDevice()
mtkView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
mtkView.colorPixelFormat = .rgba8Unorm
mtkView.delegate = context.coordinator
mtkView.isPaused = false
context.coordinator.renderer = MetalRenderer(mtkView: mtkView)
let pressGesture = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePress(_:)))
pressGesture.minimumPressDuration = 0
mtkView.addGestureRecognizer(pressGesture)
return mtkView
}
func updateUIView(_ uiView: MTKView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, MTKViewDelegate {
var renderer: MetalRenderer?
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
renderer?.mtkView(view, drawableSizeWillChange: size)
}
func draw(in view: MTKView) {
renderer?.draw(in: view)
}
@objc func handlePress(_ gesture: UILongPressGestureRecognizer) {
guard let view = gesture.view else { return }
switch gesture.state {
case .began, .changed:
let location = gesture.location(in: view)
// Fixed 2x scaling as requested
let flippedLocation = CGPoint(x: location.x * 2, y: (view.bounds.height - location.y) * 2)
renderer?.handleTouch(flippedLocation)
case .ended, .cancelled:
renderer?.handleTouch(nil)
default:
break
}
}
}
}
#elseif os(macOS)
private struct macOSMetalView: NSViewRepresentable {
func makeNSView(context: Context) -> MTKView {
let mtkView = MTKView()
mtkView.device = MTLCreateSystemDefaultDevice()
mtkView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
mtkView.colorPixelFormat = .rgba8Unorm
mtkView.delegate = context.coordinator
mtkView.isPaused = false
context.coordinator.renderer = MetalRenderer(mtkView: mtkView)
let pressGesture = NSPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePress(_:)))
pressGesture.minimumPressDuration = 0
mtkView.addGestureRecognizer(pressGesture)
return mtkView
}
func updateNSView(_ nsView: MTKView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, MTKViewDelegate {
var renderer: MetalRenderer?
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
renderer?.mtkView(view, drawableSizeWillChange: size)
}
func draw(in view: MTKView) {
renderer?.draw(in: view)
}
@objc func handlePress(_ gesture: NSPressGestureRecognizer) {
guard let view = gesture.view else { return }
switch gesture.state {
case .began, .changed:
let location = gesture.location(in: view)
// Fixed 2x scaling as requested
let flippedLocation = CGPoint(x: location.x * 2, y: (view.bounds.height - location.y) * 2)
renderer?.handleTouch(flippedLocation)
case .ended, .cancelled:
renderer?.handleTouch(nil)
default:
break
}
}
}
}
#endif
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
MetalView()
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}
#Preview {
ContentView()
}
import SwiftUI
import MetalKit
import GameplayKit
import simd
import Foundation
struct QuadVertex {
var position: SIMD2<Float>
var texCoord: SIMD2<Float>
}
struct Particle {
var position: SIMD2<Float>
var velocity: SIMD2<Float>
}
class MetalRenderer: NSObject, MTKViewDelegate {
let device: MTLDevice
let commandQueue: MTLCommandQueue
var textures: [MTLTexture?] = [nil, nil]
var pixelBuffers: [MTLBuffer?] = [nil, nil]
var currentBufferIndex = 0
var pipelineState: MTLRenderPipelineState?
var vertexBuffer: MTLBuffer?
var positions: UnsafeMutablePointer<SIMD2<Float>>?
var velocities: UnsafeMutablePointer<SIMD2<Float>>?
var threadPixelBuffers: [UnsafeMutablePointer<UInt8>] = []
let particleCount = 20_000_000
var width: Int
var height: Int
var invScale = 1
var lastFrameTime: CFAbsoluteTime
var frameCount: Int
var pullTarget: CGPoint?
let numberOfThreads: Int
init?(mtkView: MTKView) {
guard let device = MTLCreateSystemDefaultDevice() else { return nil }
self.device = device
self.lastFrameTime = CFAbsoluteTimeGetCurrent()
self.commandQueue = device.makeCommandQueue()!
self.width = max(4, Int(mtkView.drawableSize.width))
self.height = max(4, Int(mtkView.drawableSize.height))
self.frameCount = 0
self.positions = UnsafeMutablePointer<SIMD2<Float>>.allocate(capacity: particleCount)
self.velocities = UnsafeMutablePointer<SIMD2<Float>>.allocate(capacity: particleCount)
self.positions?.initialize(repeating: SIMD2(0,0), count: particleCount)
self.velocities?.initialize(repeating: SIMD2(0,0), count: particleCount)
self.numberOfThreads = ProcessInfo.processInfo.activeProcessorCount
super.init()
mtkView.device = device
mtkView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
mtkView.colorPixelFormat = .rgba8Unorm
mtkView.delegate = self
mtkView.isPaused = false
createTextureAndBuffer(width: self.width, height: self.height)
setupRenderPipeline(view: mtkView)
}
deinit {
positions?.deinitialize(count: particleCount)
positions?.deallocate()
velocities?.deinitialize(count: particleCount)
velocities?.deallocate()
for ptr in threadPixelBuffers {
ptr.deinitialize(count: width * height)
ptr.deallocate()
}
}
func setupRenderPipeline(view: MTKView) {
let shaderSource = """
#include <metal_stdlib>
using namespace metal;
struct QuadVertex {
float2 position [[attribute(0)]];
float2 texCoord [[attribute(1)]];
};
struct VertexOut {
float4 position [[position]];
float2 texCoord;
};
vertex VertexOut vertex_main(QuadVertex in [[stage_in]]) {
VertexOut out;
out.position = float4(in.position, 0.0, 1.0);
out.texCoord = in.texCoord;
return out;
}
float3 hsl2rgb(float h, float s, float l) {
float c = (1.0 - fabs(2.0 * l - 1.0)) * s;
float hp = h * 6.0;
float x = c * (1.0 - fabs(fmod(hp, 2.0) - 1.0));
float3 rgb1;
if (0.0 <= hp && hp < 1.0) rgb1 = float3(c, x, 0.0);
else if (1.0 <= hp && hp < 2.0) rgb1 = float3(x, c, 0.0);
else if (2.0 <= hp && hp < 3.0) rgb1 = float3(0.0, c, x);
else if (3.0 <= hp && hp < 4.0) rgb1 = float3(0.0, x, c);
else if (4.0 <= hp && hp < 5.0) rgb1 = float3(x, 0.0, c);
else if (5.0 <= hp && hp < 6.0) rgb1 = float3(c, 0.0, x);
else rgb1 = float3(0.0, 0.0, 0.0);
float m = l - 0.5 * c;
return rgb1 + m;
}
fragment float4 fragment_main(VertexOut in [[stage_in]],
texture2d<float> imageTexture [[texture(0)]]) {
constexpr sampler s(address::clamp_to_edge, filter::nearest);
float pixelValueNormalized = imageTexture.sample(s, in.texCoord).r;
int pixelValue = int(pixelValueNormalized * 255.0);
float3 color = float3(0.0, 0.0, 0.0);
if (pixelValue > 0) {
float3 baseRGBColor;
switch(pixelValue) {
case 1: baseRGBColor = float3(52.0/255.0, 116.0/255.0, 51.0/255.0); break;
case 2: baseRGBColor = float3(255.0/255.0, 193.0/255.0, 7.0/255.0); break;
case 3: baseRGBColor = float3(255.0/255.0, 111.0/255.0, 60.0/255.0); break;
case 4: baseRGBColor = float3(178.0/255.0, 34.0/255.0, 34.0/255.0); break;
case 5: baseRGBColor = float3(70.0/255.0, 130.0/255.0, 180.0/255.0); break;
default: baseRGBColor = float3(0.0, 0.0, 0.0); break;
}
color = baseRGBColor;
}
return float4(color, 1.0);
}
"""
do {
let library = try device.makeLibrary(source: shaderSource, options: nil)
let vertexFunction = library.makeFunction(name: "vertex_main")
let fragmentFunction = library.makeFunction(name: "fragment_main")
guard let vertFunc = vertexFunction, let fragFunc = fragmentFunction else { return }
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertFunc
pipelineDescriptor.fragmentFunction = fragFunc
pipelineDescriptor.colorAttachments[0].pixelFormat = view.colorPixelFormat
let vertexDescriptor = MTLVertexDescriptor()
vertexDescriptor.attributes[0].format = .float2
vertexDescriptor.attributes[0].offset = 0
vertexDescriptor.attributes[0].bufferIndex = 0
vertexDescriptor.attributes[1].format = .float2
vertexDescriptor.attributes[1].offset = MemoryLayout<SIMD2<Float>>.stride
vertexDescriptor.attributes[1].bufferIndex = 0
vertexDescriptor.layouts[0].stride = MemoryLayout<QuadVertex>.stride
vertexDescriptor.layouts[0].stepFunction = .perVertex
pipelineDescriptor.vertexDescriptor = vertexDescriptor
pipelineState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
let vertices: [QuadVertex] = [
QuadVertex(position: [-1.0, -1.0], texCoord: [0.0, 1.0]),
QuadVertex(position: [ 1.0, -1.0], texCoord: [1.0, 1.0]),
QuadVertex(position: [-1.0, 1.0], texCoord: [0.0, 0.0]),
QuadVertex(position: [-1.0, 1.0], texCoord: [0.0, 0.0]),
QuadVertex(position: [ 1.0, -1.0], texCoord: [1.0, 1.0]),
QuadVertex(position: [ 1.0, 1.0], texCoord: [1.0, 0.0])
]
vertexBuffer = device.makeBuffer(bytes: vertices, length: MemoryLayout<QuadVertex>.stride * vertices.count, options: [])
} catch {
print("Failed to create render pipeline state or compile shader: \(error)")
}
}
func createTextureAndBuffer(width: Int, height: Int) {
self.width = width / invScale
self.height = height / invScale
let bytesPerPixel = 1
let alignment = 16
var rowBytes = self.width * bytesPerPixel
rowBytes = (rowBytes + alignment - 1) & ~(alignment - 1)
var length = self.height * rowBytes
length = (length + alignment - 1) & ~(alignment - 1)
let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: .r8Unorm,
width: self.width,
height: self.height,
mipmapped: false
)
textureDescriptor.usage = [.shaderRead, .renderTarget]
textureDescriptor.storageMode = .shared
for i in 0..<2 {
pixelBuffers[i] = device.makeBuffer(length: length, options: [.storageModeShared])
textures[i] = pixelBuffers[i]?.makeTexture(descriptor: textureDescriptor, offset: 0, bytesPerRow: rowBytes)
}
let bufferSize = self.width * self.height
for ptr in threadPixelBuffers {
ptr.deinitialize(count: bufferSize)
ptr.deallocate()
}
threadPixelBuffers.removeAll()
for _ in 0..<numberOfThreads {
let ptr = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
ptr.initialize(repeating: 0, count: bufferSize)
threadPixelBuffers.append(ptr)
}
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
createTextureAndBuffer(width: Int(size.width), height: Int(size.height))
setParticlePositions(count: self.particleCount)
}
func draw(in view: MTKView) {
let currentTime = CFAbsoluteTimeGetCurrent()
let deltaTime = currentTime - lastFrameTime
lastFrameTime = currentTime
frameCount += 1
if frameCount % 10 == 0 {
print("FPS: \(String(format: "%.2f", 1/deltaTime))")
print(self.width, self.height)
}
tick(dt: Float(deltaTime))
guard let drawable = view.currentDrawable,
let renderPassDescriptor = view.currentRenderPassDescriptor,
let textureToDisplay = textures[currentBufferIndex] else { return }
renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 1)
renderPassDescriptor.colorAttachments[0].loadAction = .clear
renderPassDescriptor.colorAttachments[0].storeAction = .store
let commandBuffer = commandQueue.makeCommandBuffer()
if let renderEncoder = commandBuffer?.makeRenderCommandEncoder(descriptor: renderPassDescriptor) {
renderEncoder.setRenderPipelineState(pipelineState!)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
renderEncoder.setFragmentTexture(textureToDisplay, index: 0)
renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
renderEncoder.endEncoding()
}
commandBuffer?.present(drawable)
commandBuffer?.commit()
currentBufferIndex = (currentBufferIndex + 1) % 2
}
func tick(dt: Float) {
guard let positionPtr = positions,
let velocityPtr = velocities else { return }
guard let bufferPointer = pixelBuffers[currentBufferIndex]?.contents() else { return }
let pixelDataPtr = bufferPointer.assumingMemoryBound(to: UInt8.self)
pixelDataPtr.initialize(repeating: 0, count: width * height)
let fw = Float(width)
let fh = Float(height)
let iw = self.width
let fw_minus_1 = fw - 1.0
let fh_minus_1 = fh - 1.0
let currentPullTarget = self.pullTarget
let hasTouch = currentPullTarget != nil
let target_simd = SIMD2<Float>(Float(currentPullTarget?.x ?? 0), Float(currentPullTarget?.y ?? 0))
let attractionStrength: Float32 = 600 * dt
let friction: Float32 = pow(0.997, dt * 60.0)
let numParticles = self.particleCount
let particlesPerThread = (numParticles + numberOfThreads - 1) / numberOfThreads
DispatchQueue.concurrentPerform(iterations: numberOfThreads) { threadIndex in
let localPtr = threadPixelBuffers[threadIndex]
localPtr.initialize(repeating: 0, count: width * height)
var i = threadIndex * particlesPerThread
let endIndex = min(i + particlesPerThread, numParticles)
let bounds = SIMD2<Float>(fw, fh)
let invBounds = 1.0 / bounds
while i < endIndex {
var currentPosition = positionPtr[i]
var currentVelocity = velocityPtr[i]
if hasTouch {
let diff = target_simd - currentPosition
let distance = length(diff)
if distance > 1.0 {
let attractionVector = (diff / distance) * attractionStrength
currentVelocity += attractionVector
}
}
currentPosition += currentVelocity * dt
currentVelocity *= friction
// fast wrap around
// currentPosition -= floor(currentPosition * invBounds) * bounds
positionPtr[i] = currentPosition
velocityPtr[i] = currentVelocity
let clampedX = Int(max(0.0, min(fw_minus_1, currentPosition.x)))
let clampedY = Int(max(0.0, min(fh_minus_1, currentPosition.y)))
let index = (clampedY * iw + clampedX)
// faster to directly update pixelDataPtr but flickers
localPtr[Int(index)] = UInt8((i % 5) + 1)
i += 1
}
}
// can commit this out if updating pixelDataPtr above
let pixelCount = width * height
let pixelsPerThread = (pixelCount + numberOfThreads - 1) / numberOfThreads
DispatchQueue.concurrentPerform(iterations: numberOfThreads) { threadIndex in
let start = threadIndex * pixelsPerThread
let end = min(start + pixelsPerThread, pixelCount)
for i in start..<end {
for localThreadIndex in 0..<numberOfThreads {
let color = threadPixelBuffers[localThreadIndex][i]
pixelDataPtr[i] = color
if color != 0 {
break
}
}
}
}
}
func randomizeParticles(count: Int) {
if frameCount > 10 {
return
}
guard let positionPtr = positions,
let velocityPtr = velocities else { return }
let randomNumberSource = GKMersenneTwisterRandomSource()
for i in 0..<count {
positionPtr[i].x = Float(randomNumberSource.nextInt(upperBound: width))
positionPtr[i].y = Float(randomNumberSource.nextInt(upperBound: height))
velocityPtr[i].x = Float(randomNumberSource.nextUniform() * 6.0 - 3.0)
velocityPtr[i].y = Float(randomNumberSource.nextUniform() * 6.0 - 3.0)
}
}
func setParticlePositions(count: Int) {
if frameCount > 20 {
return
}
guard let positionPtr = positions,
let velocityPtr = velocities else { return }
let randomNumberSource = GKMersenneTwisterRandomSource()
let paletteSize = 5
let clustersPerColor = 160
let minRadius: Float = 100.0
let maxRadius: Float = 400.0
var clusterData: [[(center: CGPoint, radius: Float)]] = Array(repeating: [], count: paletteSize)
for colorType in 0..<paletteSize {
for _ in 0..<clustersPerColor {
let randomX = Float(randomNumberSource.nextInt(upperBound: width))
let randomY = Float(randomNumberSource.nextInt(upperBound: height))
let randomRadius = minRadius + (maxRadius - minRadius) * randomNumberSource.nextUniform()
clusterData[colorType].append((center: CGPoint(x: CGFloat(randomX), y: CGFloat(randomY)), radius: randomRadius))
}
}
let spiralTurns: Float = 8.0
for i in 0..<count {
let colorType = i % paletteSize
let selectedClusterIndex = randomNumberSource.nextInt(upperBound: clustersPerColor)
let cluster = clusterData[colorType][selectedClusterIndex]
let center = cluster.center
let radius = cluster.radius
let maxAngle = spiralTurns * 2.0 * .pi
let b = radius / maxAngle
let t = Float(i) / Float(count)
let angle = t * maxAngle
let dist = b * angle
var newX = Float(center.x) + dist * cos(angle)
var newY = Float(center.y) + dist * sin(angle)
newX = max(0.0, min(newX, Float(width) - 1.0))
newY = max(0.0, min(newY, Float(height) - 1.0))
positionPtr[i].x = newX
positionPtr[i].y = newY
// uncomment this if you want the particles to have little movement
// velocityPtr[i].x = Float(randomNumberSource.nextUniform() * 6.0 - 3.0)
// velocityPtr[i].y = Float(randomNumberSource.nextUniform() * 6.0 - 3.0)
}
}
func handleTouch(_ point: CGPoint?) {
if let unwrappedPoint = point {
self.pullTarget = CGPoint(
x: unwrappedPoint.x / CGFloat(self.invScale),
y: unwrappedPoint.y / CGFloat(self.invScale)
)
} else {
self.pullTarget = point
}
}
}
struct MetalView: View {
var body: some View {
#if os(iOS)
iOSMetalView()
#elseif os(macOS)
macOSMetalView()
#else
Text("Metal is not supported on this platform.")
#endif
}
}
#if os(iOS)
private struct iOSMetalView: UIViewRepresentable {
func makeUIView(context: Context) -> MTKView {
let mtkView = MTKView()
mtkView.device = MTLCreateSystemDefaultDevice()
mtkView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
mtkView.colorPixelFormat = .rgba8Unorm
mtkView.delegate = context.coordinator
mtkView.isPaused = false
context.coordinator.renderer = MetalRenderer(mtkView: mtkView)
let pressGesture = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePress(_:)))
pressGesture.minimumPressDuration = 0
mtkView.addGestureRecognizer(pressGesture)
return mtkView
}
func updateUIView(_ uiView: MTKView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, MTKViewDelegate {
var renderer: MetalRenderer?
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
renderer?.mtkView(view, drawableSizeWillChange: size)
}
func draw(in view: MTKView) {
renderer?.draw(in: view)
}
@objc func handlePress(_ gesture: UILongPressGestureRecognizer) {
guard let view = gesture.view else { return }
switch gesture.state {
case .began, .changed:
let location = gesture.location(in: view)
let flippedLocation = CGPoint(x: location.x * 2, y: (view.bounds.height - location.y) * 2)
renderer?.handleTouch(flippedLocation)
case .ended, .cancelled:
renderer?.handleTouch(nil)
default:
break
}
}
}
}
#elseif os(macOS)
private struct macOSMetalView: NSViewRepresentable {
func makeNSView(context: Context) -> MTKView {
let mtkView = MTKView()
mtkView.device = MTLCreateSystemDefaultDevice()
mtkView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
mtkView.colorPixelFormat = .rgba8Unorm
mtkView.delegate = context.coordinator
mtkView.isPaused = false
context.coordinator.renderer = MetalRenderer(mtkView: mtkView)
let pressGesture = NSPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePress(_:)))
pressGesture.minimumPressDuration = 0
mtkView.addGestureRecognizer(pressGesture)
return mtkView
}
func updateNSView(_ nsView: MTKView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, MTKViewDelegate {
var renderer: MetalRenderer?
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
renderer?.mtkView(view, drawableSizeWillChange: size)
}
func draw(in view: MTKView) {
renderer?.draw(in: view)
}
@objc func handlePress(_ gesture: NSPressGestureRecognizer) {
guard let view = gesture.view else { return }
switch gesture.state {
case .began, .changed:
let location = gesture.location(in: view)
let flippedLocation = CGPoint(x: location.x * 2, y: (view.bounds.height - location.y) * 2)
renderer?.handleTouch(flippedLocation)
case .ended, .cancelled:
renderer?.handleTouch(nil)
default:
break
}
}
}
}
#endif
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
MetalView()
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}
#Preview {
ContentView()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment