Created
July 13, 2025 21:08
-
-
Save dgerrells/5dd94afe82f1ebae7dbdc3da14bb21d8 to your computer and use it in GitHub Desktop.
getting swifty
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
// 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() | |
} |
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
// 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() | |
} |
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
// 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() | |
} |
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 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() | |
} |
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 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