Building ambient desktop particles for macOS with CAEmitterLayer
Source: Dev.to
Introduction
I recently shipped a free macOS app called Wisp – a menu‑bar utility that renders ambient desktop particles. The goal was to have about 1,000 particles drifting smoothly on a 4K display while using roughly 1 % CPU. A timer‑driven loop that updates CALayers each frame would be far too heavy, so I turned to CAEmitterLayer, Apple’s GPU‑accelerated particle system.
Minimal CAEmitterLayer Setup
import QuartzCore
let emitter = CAEmitterLayer()
emitter.emitterShape = .rectangle
emitter.emitterMode = .surface
emitter.emitterPosition = CGPoint(x: screen.width / 2, y: screen.height / 2)
emitter.emitterSize = screen.size
emitter.renderMode = .additive // layered glow
let cell = CAEmitterCell()
cell.contents = particleTexture // a soft radial gradient CGImage
cell.birthRate = 14
cell.lifetime = 10
cell.lifetimeRange = 3
cell.velocity = 8
cell.velocityRange = 18
cell.emissionRange = .pi * 2 // spawn in any direction
cell.yAcceleration = -1 // drift upward
cell.scale = 0.05
cell.scaleRange = 0.02
cell.alphaSpeed = -0.058 // fade out
cell.color = UIColor(...) // main particle color
emitter.emitterCells = [cell]
hostLayer.addSublayer(emitter)
A single uniform cell looks artificial. The trick to a natural “atmosphere” is to run multiple cells with different properties in the same emitter.
Depth Layers
| Cell | Birth Rate | Size | Lifetime | Purpose |
|---|---|---|---|---|
| aura | 2 | big | 14 s | Diffuse background glow |
| near | 5 | medium | 8 s | Foreground blobs |
| mote | 14 | small | 10 s | Mid‑depth sparkle |
| spark | 22 | tiny | 12 s | Bright points |
| dot | 35 | pixel | 16 s | Starfield texture |
Because all five run in parallel with .additive blending, overlapping particles create a richer look.
Building Cells Programmatically
func buildCells(for palette: [Color]) -> [CAEmitterCell] {
palette.flatMap { color in
[
makeCell(.aura, color: color, texture: softBlob),
makeCell(.near, color: color, texture: glow),
makeCell(.mote, color: color.lighter(0.3), texture: glow),
makeCell(.spark, color: color, texture: core),
makeCell(.dot, color: color.lighter(0.5), texture: hardDot),
]
}
}
Particle Images
Each cell needs a bitmap texture. I generate five single‑channel soft radial gradients at build time:
enum ParticleImage {
static let glow: CGImage = radial(size: 64, falloff: 1.0)
static let core: CGImage = radial(size: 32, falloff: 0.4)
static let blob: CGImage = radial(size: 96, falloff: 1.0)
static let dot: CGImage = radial(size: 8, falloff: 0.25)
private static func radial(size: Int, falloff: CGFloat) -> CGImage {
let ctx = CGContext(
data: nil,
width: size,
height: size,
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue
| CGBitmapInfo.byteOrder32Little.rawValue
)!
let center = CGPoint(x: CGFloat(size) / 2, y: CGFloat(size) / 2)
let gradient = CGGradient(
colorsSpace: nil,
colors: [
CGColor(red: 1, green: 1, blue: 1, alpha: 1),
CGColor(red: 1, green: 1, blue: 1, alpha: 0)
] as CFArray,
locations: [0, falloff]
)!
ctx.drawRadialGradient(
gradient,
startCenter: center, startRadius: 0,
endCenter: center, endRadius: CGFloat(size) / 2,
options: []
)
return ctx.makeImage()!
}
}
The .additive blend mode colors these grayscale textures using each cell’s color property.
Window Configuration
Wisp needs to render above the wallpaper but below desktop icons, allowing icons to pass through cleanly. This is achieved by creating a borderless window at level desktopWindow + 1:
window.level = NSWindow.Level(
rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1
)
window.collectionBehavior = [
.canJoinAllSpaces,
.stationary,
.ignoresCycle
]
window.ignoresMouseEvents = true
window.backgroundColor = .clear
window.isOpaque = false
For multi‑monitor setups, a separate window is created per NSScreen, and they are re‑created when didChangeScreenParametersNotification fires.
Power‑Saving and Privacy Touches
- Pause on battery / sleep / screen lock – subscribe to
NSWorkspace.screensDidSleepNotificationandsessionDidResignActiveNotification, then setemitter.birthRate = 0to drop CPU usage back to ~0 %. - Respect
NSWindow.SharingType.none– lets users exclude the particles from screen recordings and screenshots with a single line. - Zero sandbox footprint – no filesystem access, no network (aside from Sparkle’s appcast for update checks), and only the default entitlements.
Performance
- ~1,200 particles at ~1 % CPU idle on an M2 Pro.
- No frame drops, no noticeable heat.
Source & Download
- Source code (MIT) –
- Download the app (free, macOS 14+) –