-
-
Save gnattu/dc7c7f23725af4f3858e5221eaced4d6 to your computer and use it in GitHub Desktop.
GPU particle system using Metal.
This file contains 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 | |
struct SmashableView: NSViewRepresentable { | |
typealias NSViewType = _SmashableNSView | |
let text: String | |
class _SmashableNSView: NSView { | |
private let label: NSTextField | |
var text: String { | |
get { | |
return label.stringValue | |
} | |
set { | |
label.stringValue = newValue | |
} | |
} | |
override var isFlipped: Bool { | |
return true | |
} | |
override var intrinsicContentSize: NSSize { | |
return label.fittingSize | |
} | |
override init(frame frameRect: NSRect) { | |
label = .init(labelWithString: "") | |
label.font = .systemFont(ofSize: 64) | |
super.init(frame: frameRect) | |
addSubview(label) | |
} | |
required init?(coder: NSCoder) { | |
fatalError("Not implemented") | |
} | |
override func layout() { | |
super.layout() | |
let bounds = self.bounds | |
label.sizeToFit() | |
let labelSize = label.frame.size | |
label.frame = .init(origin: .init(x: (bounds.width - labelSize.width) / 2, | |
y: (bounds.height - labelSize.height) / 2), | |
size: labelSize) | |
} | |
override func mouseUp(with event: NSEvent) { | |
let rep = bitmapImageRepForCachingDisplay(in: bounds)! | |
cacheDisplay(in: bounds, to: rep) | |
guard let windowContentView = window?.contentView else { | |
return | |
} | |
let particleView = ParticleView(frame: windowContentView.bounds) | |
windowContentView.addSubview(particleView) | |
particleView.setImageAndStart(rep.cgImage!, targetFrame: convert(bounds, to: nil)) | |
self.isHidden = true | |
} | |
} | |
func makeNSView(context: Context) -> _SmashableNSView { | |
return .init() | |
} | |
func updateNSView(_ nsView: _SmashableNSView, context: Context) { | |
nsView.text = text | |
} | |
} | |
struct ContentView: View { | |
var body: some View { | |
Grid { | |
GridRow { | |
SmashableView(text: "This") | |
SmashableView(text: "is") | |
SmashableView(text: "Fun") | |
} | |
GridRow { | |
SmashableView(text: "😂") | |
SmashableView(text: "😅") | |
SmashableView(text: "🔥") | |
} | |
} | |
} | |
} |
This file contains 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 | |
class ParticleView: NSView { | |
private class Renderer: NSObject, MTKViewDelegate { | |
private struct Particle { | |
var position: simd_float2 | |
var velocity: simd_float2 | |
var life: simd_float1 | |
} | |
private struct Vertex { | |
var position: simd_float4 | |
var uv: simd_float2 | |
var opacity: simd_float1 | |
} | |
private var isPrepared = false | |
private var renderPipeline: MTLRenderPipelineState! | |
private var computePipeline: MTLComputePipelineState! | |
private var vertexBuffer: MTLBuffer! | |
private var particleBuffer: MTLBuffer! | |
private var particleCount: Int = 0 | |
private var texture: MTLTexture! | |
private var targetFrameSize: simd_float2 = .zero | |
private var commandQueue: MTLCommandQueue! | |
func prepareResources(with device: MTLDevice, image: CGImage, targetFrame: CGRect) { | |
guard !isPrepared else { | |
return | |
} | |
let integralTargetFrame = targetFrame.integral | |
guard let library = device.makeDefaultLibrary() else { | |
fatalError("Failed to initialize Metal library") | |
} | |
let particleVertexFunction = library.makeFunction(name: "particleVertex")! | |
let particleFragmentFunction = library.makeFunction(name: "particleFragment")! | |
let updateParticlesFunction = library.makeFunction(name: "updateParticles")! | |
let renderPipelineDescriptor = MTLRenderPipelineDescriptor() | |
renderPipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm | |
renderPipelineDescriptor.colorAttachments[0].isBlendingEnabled = true | |
renderPipelineDescriptor.colorAttachments[0].rgbBlendOperation = .add | |
renderPipelineDescriptor.colorAttachments[0].alphaBlendOperation = .add | |
renderPipelineDescriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha | |
renderPipelineDescriptor.colorAttachments[0].sourceAlphaBlendFactor = .sourceAlpha | |
renderPipelineDescriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha | |
renderPipelineDescriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha | |
renderPipelineDescriptor.vertexFunction = particleVertexFunction | |
renderPipelineDescriptor.fragmentFunction = particleFragmentFunction | |
renderPipeline = try! device.makeRenderPipelineState(descriptor: renderPipelineDescriptor) | |
computePipeline = try! device.makeComputePipelineState(function: updateParticlesFunction) | |
let vertices: [Vertex] = [ | |
.init(position: .init(0, 0, 0, 1), uv: .init(0, 0), opacity: .zero), | |
.init(position: .init(1, 0, 0, 1), uv: .init(1, 0), opacity: .zero), | |
.init(position: .init(0, 1, 0, 1), uv: .init(0, 1), opacity: .zero), | |
.init(position: .init(1, 1, 0, 1), uv: .init(1, 1), opacity: .zero), | |
] | |
let vertexBuffer = vertices.withUnsafeBytes { pointer in | |
return device.makeBuffer(bytes: pointer.baseAddress!, | |
length: MemoryLayout<Vertex>.stride * vertices.count, | |
options: .storageModeManaged) | |
} | |
self.vertexBuffer = vertexBuffer! | |
var particles = [Particle]() | |
let targetFrameHeight = Float(integralTargetFrame.height) | |
for y in 0..<Int(targetFrameHeight) { | |
for x in 0..<Int(integralTargetFrame.width) { | |
let emitAngle = Float.random(in: 0..<Float.pi) - .pi | |
let emitSpeed = Float.random(in: 1.0..<((1.0 - Float(y) / targetFrameHeight) * 5.0 + 2.0)) | |
particles.append(.init(position: .init(Float(integralTargetFrame.minX + CGFloat(x)), Float(integralTargetFrame.minY + CGFloat(y))), | |
velocity: .init(emitSpeed * cos(emitAngle), emitSpeed * sin(emitAngle)), | |
life: simd_float1(0))) | |
} | |
} | |
particleCount = particles.count | |
let particleBuffer = particles.withUnsafeBytes { pointer in | |
return device.makeBuffer(bytes: pointer.baseAddress!, | |
length: MemoryLayout<Particle>.stride * particles.count, | |
options: .storageModeManaged) | |
} | |
self.particleBuffer = particleBuffer! | |
let textureLoader = MTKTextureLoader(device: device) | |
texture = try! textureLoader.newTexture(cgImage: image) | |
targetFrameSize = .init(Float(integralTargetFrame.width), Float(integralTargetFrame.height)) | |
commandQueue = device.makeCommandQueue()! | |
isPrepared = true | |
} | |
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { | |
// Since the view is not subject to resize, this will leave no-op. | |
} | |
func draw(in view: MTKView) { | |
guard isPrepared else { | |
return | |
} | |
let viewCGSize = view.frame.size | |
var viewSize = simd_float2(Float(viewCGSize.width), Float(viewCGSize.height)) | |
let threadgroupSize = min(computePipeline.maxTotalThreadsPerThreadgroup, particleCount) | |
let computeCommandBuffer = commandQueue.makeCommandBuffer()! | |
let computeCommandEncoder = computeCommandBuffer.makeComputeCommandEncoder()! | |
computeCommandEncoder.setComputePipelineState(computePipeline) | |
computeCommandEncoder.setBuffer(particleBuffer, offset: 0, index: 0) | |
computeCommandEncoder.dispatchThreads(.init(width: particleCount, height: 1, depth: 1), | |
threadsPerThreadgroup: .init(width: threadgroupSize, height: 1, depth: 1)) | |
computeCommandEncoder.endEncoding() | |
computeCommandBuffer.commit() | |
let renderCommandBuffer = commandQueue.makeCommandBuffer()! | |
let renderPassDescriptor = view.currentRenderPassDescriptor! | |
renderPassDescriptor.colorAttachments[0].loadAction = .clear | |
renderPassDescriptor.colorAttachments[0].clearColor = .init(red: 0, green: 0, blue: 0, alpha: 0) | |
let renderCommandEncoder = renderCommandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)! | |
renderCommandEncoder.setRenderPipelineState(renderPipeline) | |
renderCommandEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) | |
withUnsafeBytes(of: &viewSize) { pointer in | |
renderCommandEncoder.setVertexBytes(pointer.baseAddress!, | |
length: MemoryLayout<simd_float2>.size, | |
index: 1) | |
} | |
renderCommandEncoder.setVertexBuffer(particleBuffer, offset: 0, index: 2) | |
withUnsafeBytes(of: &targetFrameSize) { pointer in | |
renderCommandEncoder.setVertexBytes(pointer.baseAddress!, | |
length: MemoryLayout<simd_float2>.size, | |
index: 3) | |
} | |
renderCommandEncoder.setFragmentTexture(texture, index: 0) | |
renderCommandEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4, instanceCount: particleCount) | |
renderCommandEncoder.endEncoding() | |
renderCommandBuffer.present(view.currentDrawable!) | |
renderCommandBuffer.commit() | |
} | |
} | |
private var device: MTLDevice! | |
private var metalView: MTKView! | |
private var renderer = Renderer() | |
override var isFlipped: Bool { | |
return true | |
} | |
override init(frame frameRect: NSRect) { | |
super.init(frame: frameRect) | |
commonInit() | |
} | |
required init?(coder: NSCoder) { | |
super.init(coder: coder) | |
commonInit() | |
} | |
func setImageAndStart(_ image: CGImage, targetFrame: CGRect) { | |
let localTargetFrame = convert(targetFrame, from: nil) | |
renderer.prepareResources(with: device, image: image, targetFrame: localTargetFrame) | |
} | |
private func commonInit() { | |
guard let device = MTLCreateSystemDefaultDevice() else { | |
fatalError("Failed to create Metal device") | |
} | |
self.device = device | |
metalView = MTKView(frame: .zero, device: device) | |
metalView.layer?.isOpaque = false | |
metalView.delegate = renderer | |
addSubview(metalView) | |
} | |
override func layout() { | |
super.layout() | |
metalView.frame = bounds | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment