DEV Community

Kir
Kir

Posted on

Building ambient desktop particles for macOS with CAEmitterLayer

I recently shipped a free macOS app called Wisp — a menu bar utility that
adds gentle, glowing particles between your wallpaper and your desktop icons.
Nothing revolutionary, just a visual texture I missed. But building it turned
into a tour of some underused Apple APIs I want to share.

Wisp screenshot

🌐 wisp.gruffix.ru · 💾 GitHub · MIT

The challenge

I wanted ~1,000 particles drifting smoothly on a 4K display at roughly 1 % CPU
idle. A naive approach — a Timer that loops over particle structs and draws
into a CALayer each frame — would collapse on any real-world Mac. So I went
straight for CAEmitterLayer, Apple's GPU-accelerated particle system. Everybody
uses it on iOS for confetti; almost nobody talks about using it on macOS for
ambient backgrounds.

Anatomy of a CAEmitter

CAEmitterLayer spawns CAEmitterCell instances and animates each on the GPU.
You configure the cell once, and the layer handles birth rate, lifetime, velocity
with variance, scale, color, alpha, rotation — all interpolated per particle.
No per-frame work from your side.

Here's a minimal 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)
Enter fullscreen mode Exit fullscreen mode

That's already better than any timer loop. But one uniform cell looks artificial.

Five-tier layering

The trick to a natural "atmosphere" look is running multiple cells with very
different parameters
in the same emitter. Each cell represents a depth layer:

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 brighten naturally into warm/cool color zones — the same effect
photographers get with anamorphic lens flares.

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),
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

Procedural particle textures

The emitter needs bitmap contents per cell. Shipping five PNGs per theme
would bloat the app. Instead I generate them at launch with CGContext:

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()!
    }
}
Enter fullscreen mode Exit fullscreen mode

Four single-channel soft radial gradients — .additive blending colors them
in at runtime from each cell's color property. Zero bundle weight, infinite
palette variations.

Placing the window below icons

This was the fiddly part. Wisp needs to render above the wallpaper but
below the desktop icons, so icons pass through the effect cleanly. macOS
exposes a window level just for this:

window.level = NSWindow.Level(rawValue:
    Int(CGWindowLevelForKey(.desktopWindow)) + 1)

window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
window.ignoresMouseEvents = true
window.backgroundColor    = .clear
window.isOpaque           = false
Enter fullscreen mode Exit fullscreen mode

desktopWindow + 1 sits between the wallpaper and the icon layer. Combined
with canJoinAllSpaces, the window follows you across every Space and
full-screen without re-spawning.

For multi-monitor, I create one window per NSScreen and re-create them on
didChangeScreenParametersNotification.

Being a respectful macOS citizen

A few touches the audience of this blog will appreciate:

  • Pause on battery / sleep / screen lock — subscribe to NSWorkspace.screensDidSleepNotification and sessionDidResignActiveNotification, call emitter.birthRate = 0. Free ~1 % CPU back.
  • Respect NSWindow.SharingType.none — lets users opt to exclude the particles from screen recordings and screenshots. One line.
  • Zero sandboxing footprint — no filesystem access, no network, no entitlements beyond the default. The only network call the app makes is to Sparkle's appcast for update checks.

The result

~1,200 particles at 1 % CPU idle on an M2 Pro. No frame drops. No heat. The
whole app bundle is 4 MB because there are no shipped images — everything is
generated.

If you want to poke at the source, it's MIT on GitHub:
github.com/GruFFix/Wisp

If you just want the app:
wisp.gruffix.ru (free, macOS 14+).

Top comments (0)