Building ambient desktop particles for macOS with CAEmitterLayer

Published: (April 19, 2026 at 11:52 AM EDT)
4 min read
Source: Dev.to

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

CellBirth RateSizeLifetimePurpose
aura2big14 sDiffuse background glow
near5medium8 sForeground blobs
mote14small10 sMid‑depth sparkle
spark22tiny12 sBright points
dot35pixel16 sStarfield 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.screensDidSleepNotification and sessionDidResignActiveNotification, then set emitter.birthRate = 0 to 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+)
0 views
Back to Blog

Related posts

Read more »