Skip to content

Instantly share code, notes, and snippets.

@unixzii
Last active March 30, 2024 08:36
Show Gist options
  • Star 24 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save unixzii/aeefe8edbd6a685cb3e230b5b30841db to your computer and use it in GitHub Desktop.
Save unixzii/aeefe8edbd6a685cb3e230b5b30841db to your computer and use it in GitHub Desktop.
GPU particle system using Metal.
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: "🔥")
}
}
}
}
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
}
}
#include <metal_stdlib>
using namespace metal;
struct Particle {
float2 position;
float2 velocity;
float life;
};
struct Vertex {
float4 position [[position]];
float2 uv;
float opacity;
};
vertex Vertex particleVertex(const device Vertex *vertices [[buffer(0)]],
const device float2 &resolution [[buffer(1)]],
const device Particle *particles [[buffer(2)]],
const device float2 &targetFrameSize [[buffer(3)]],
unsigned int vid [[vertex_id]],
unsigned int particleId [[instance_id]]) {
int row = particleId / int(targetFrameSize.x);
int col = particleId % int(targetFrameSize.x);
Vertex v = vertices[vid];
Particle p = particles[particleId];
v.position.x = ((v.position.x + p.position.x) - resolution.x / 2) / (resolution.x / 2);
v.position.y = -((v.position.y + p.position.y) - resolution.y / 2) / (resolution.y / 2);
v.uv.x = float(col) / targetFrameSize.x;
v.uv.y = float(row) / targetFrameSize.y;
v.opacity = 1.0 - (p.life / 1000.0);
return v;
}
fragment float4 particleFragment(Vertex in [[stage_in]],
const texture2d<float> texture [[texture(0)]]) {
constexpr sampler samplr;
return texture.sample(samplr, in.uv);
}
kernel void updateParticles(device Particle *particles,
unsigned int index [[thread_position_in_grid]]) {
particles[index].position += particles[index].velocity;
particles[index].velocity.y += 0.19;
particles[index].life = min(particles[index].life + 2, 1000.0);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment