Glyph Particles

22 September, 2026

Glyph Particles on an iPhone rendered on a green grid

This started as a small Metal experiment.

Most text effects fade the glyph as particles appear. Timing makes the two look connected, but they’re separate effects.

I wanted to see what happened if the particles and glyph shared the same coverage.

Material handoff

Core Text lays out the glyphs, and Core Graphics rasterizes their paths into antialiased masks.1 Alpha gives the coverage at each pixel, including fractional values at the edges.

The shared release field controls when each pixel gives up coverage, using its position, interior distance, and a fixed hash.

ReleaseField.swift
let interior = min(interiorDistance[roiIndex] / maxDistance, 1)let sweep = localU * 0.72 + (1 - uv.y) * 0.28let breakup = hash2D(x: x, y: y, salt: 17)let interiorDelay = interiorlet release = min(max(    0.06 + sweep * 0.48 + breakup * 0.24 + interiorDelay * 0.28,    0.04), 0.96)releasePixels[index] = SIMD4<Float>(release, interior, 0, Float(glyph.index))

The solid fragment shader uses that field to subtract transferred coverage from the mask.

SolidTransfer.metal
float visualTransfer = massTransfer(    visualRelease, uniforms.releaseProgress, true, uniforms.solidVisualSoftness);float remaining = coverage * (1.0 - visualTransfer);return float4(contentWhite(uniforms), remaining);

Glyph compression

Each particle draws coverage from a group of mask pixels. A simple sampling grid can miss thin strokes, so seeds are sampled in proportion to alpha, with total coverage setting the target count. Every covered pixel is assigned to the nearest seed's Voronoi cell.

VoronoiSources.swift
let targetCount = sourceTargetCount(    validCoveredArea: validCoveredArea,    coveredPixelCount: pixels.count)var rng = StableRNG(seed: 0xC0FFEE)let prefixMass = makePrefixMass(pixels)let seeds = makeVoronoiSeeds(    pixels: pixels, prefixMass: prefixMass, count: targetCount, rng: &rng)// In makeVoronoiParticles, after building the spatial bins.var accumulators = seeds.map { VoronoiAccumulator(seed: $0) }for pixel in pixels {    let nearest = nearestSeedIndex(        to: pixel.localPixel, seeds: seeds, bins: bins,        gridWidth: gridWidth, gridHeight: gridHeight,        binSize: binSize, mode: mode    )    accumulators[nearest].add(pixel)}

Each nonempty cell becomes a source at its coverage-weighted center. Its mass is the sum of pixel coverage, rather than physical mass. Eight bins store coverage grouped by release value.

SourceBins.swift
mutating func add(_ pixel: CoveredPixel) {    mass += pixel.coverage    weightedLocalUV += pixel.localUV * pixel.coverage    weightedReleaseNormalUV += pixel.releaseNormalUV * pixel.coverage    weightedInterior += pixel.interior * pixel.coverage    let bin = min(max(Int(pixel.release * 8), 0), 7)    if bin < 4 {        releaseMass0[bin] += pixel.coverage    } else {        releaseMass1[bin - 4] += pixel.coverage    }}

The bins preserve total coverage but approximate release timing, using each bin's midpoint in place of its pixels' release values. Optional debug checks compare this approximation with the original mask.2

Incremental transfer

Elapsed time maps to lifecycleProgress through a cubic ease-out, then to releaseProgress through a power curve. This lets release timing change without altering the base curve. The renderer saves both values with each submitted frame; if either goes backward, the compute pass resets particle state.

The GPU adds only the coverage released since the previous frame.

FrameTransfer.metal
float transferredMass = 0.0;for (uint index = 0; index < 8; ++index) {    float release = (float(index) + 0.5) * 0.125;    float currentTransfer = massTransfer(        release, uniforms.releaseProgress, isActiveGlyph, uniforms.releaseSoftness    );    float previousTransfer = massTransfer(        release, uniforms.previousReleaseProgress, isActiveGlyph, uniforms.releaseSoftness    );    transferredMass += releaseBinMass(source, index)        * max(currentTransfer - previousTransfer, 0.0);}return transferredMass;

Shape continuity

The glyph loses its shape if particles spread too soon, but restricting their motion for too long makes them look pinned to the mask.

The release field's negative gradient points toward areas that release coverage earlier, giving particles an initial direction.

A coverage-weighted average within each cell gives releaseNormalUV, which the compute shader combines with wind, curl, and variation from a fixed seed.

A particle may already be moving when more coverage arrives. The shader blends its velocity with the incoming velocity, weighted by their coverage. It also pulls the particle toward its source, with the strength set by the incoming fraction of total mass and the particle's age.

TransferMotion.metal
float totalMass = state.mass + transferredMass;float ratio = transferRatio(transferredMass, totalMass);bool isTransferFrame = transferredMass > 0.00001;if (isTransferFrame) {    // After calculating direction, injectedSpeed, and birth visibility.    state.velocityUV = (        state.velocityUV * state.mass        + direction * injectedSpeed * transferredMass    ) / max(totalMass, 0.0001);    state.mass = totalMass;    state.positionUV = mix(        state.positionUV, source.localUV, birthAnchorStrength(ratio, state.age)    );    state.alive = 1.0;}

For surviving particles, state.mass tracks cumulative transfer. Alpha and radius fade separately, so this value does not measure rendered opacity.

Each source has a travel budget based on interior distance and coverage. The shader measures displacement along and across the release direction in screen pixels.

ShapeTravel.metal
float2 scalePx = max(source.motionScaleUV * uniforms.rectSize, float2(0.0001));float2 deltaPx = (positionUV - source.localUV) * scalePx;float2 normalPx = normalize(source.releaseNormalUV * scalePx + float2(0.0001, 0.0));float2 tangentPx = float2(-normalPx.y, normalPx.x);float normalBudget = max(source.shapeTravelBudgetPx, 0.0001);float tangentBudget = normalBudget * 1.35;float normalRatio = abs(dot(deltaPx, normalPx)) / normalBudget;float tangentRatio = abs(dot(deltaPx, tangentPx)) / tangentBudget;return max(normalRatio, tangentRatio);

Approaching the budget increases shapeBrake. Once most of the source's coverage has transferred, visualCoherence decays and weakens the brake, letting the particle drift farther.

ShapeBrake.metal
float cumulativeMass = cumulativeTransferredMass(source, uniforms, sourceIsActiveGlyph);float sourceReleaseRatio = saturate(cumulativeMass / max(source.mass, 0.0001));float sourceReleased = smoothstep(    releaseCompletionStart(uniforms), releaseCompletionEnd(uniforms), sourceReleaseRatio);// In the surviving-particle branch; shapeBrake was declared before transfer.float coherenceDecay = mix(0.0, coherenceDecayReleased(uniforms), sourceReleased);state.visualCoherence *= exp(-coherenceDecay * dt);shapeBrake = smoothstep(0.72, 1.0, shapeEscapeRatio(source, state.positionUV, uniforms));float coherentShapeBrake = shapeBrake * state.visualCoherence;

Particle lifetime

The last occupied histogram bin determines sourceReleaseEnd, when the source finishes releasing coverage. Each particle starts fading shortly afterward. Its fade duration includes the time until the glyph's last source finishes releasing, plus a small, repeatable variation. At visibleEnd, the compute pass clears the particle's state and returns it to its source position.

The prepared asset sets visibleDuration to the later of the solid glyph's disappearance and the last particle's visibleEnd.

VisibleDuration.swift
let solidTransferEnd = solidTransferDuration(solidMaxRelease: solidMaxRelease)let particleVisibleEnd = sources.reduce(TimeInterval(0)) { maxEnd, source in    max(maxEnd, TimeInterval(source.visibleEnd))}return max(solidTransferEnd, particleVisibleEnd)

Deletion lifecycle

For text deletion, the renderer keeps the old glyph's data after the app commits the new text so the coverage transfer and particle animation can finish. Each AmountEditTransaction has an ID so callbacks can distinguish edits even when the same text reappears.

Asset preparation

A background queue prepares the old glyph data, release texture, source buffer, and timing in a PreparedDeletionAsset. Because another edit may arrive before preparation ends, the result must still match the pending text, glyph range, and generation.

AssetReadiness.swift
// After creating and storing the pending request.assetPipeline.requestPreparedDeletionAsset(    displayGlyphAsset: displayGlyphAsset, glyphRange: targetRange) { [weak self] asset in    guard let self, self.pendingDeletionRequest == request else { return }    self.pendingDeletionRequest = nil    guard self.canUseReadyDeletionAsset(asset) else { return }    self.readyDeletionAsset = asset    self.preparePendingDeletionIfNeeded()}

Committing the edit

Before accepting the edit, the renderer checks that the app's text and displayed glyph asset match fromText. The prepared asset must also match that text and affectedRange.

CommitChecks.swift
guard pendingEdit?.id == transaction.id,      canonicalText == transaction.fromText,      presentationGlyphAsset?.text == transaction.fromText else { return }guard let preparedAsset = readyDeletionAsset,      preparedAsset.text == transaction.fromText,      preparedAsset.glyphRange == glyphRange else { return }

If text remains, its glyph asset and layout plan must be ready. The renderer creates an ActiveDeletionRun that owns the prepared asset, particle state buffer, clock state, and transaction ID, then calls onEditAccepted.

SwiftUI checks the transaction ID and old text before applying the edit.

Animation start

The animation clock waits for SwiftUI to pass back the committed text and transaction ID. When both match the run, the renderer resets startedAt and lastFrameTime together, keeping preparation and SwiftUI delays out of the animation.

VisualClock.swift
for index in deletionRuns.indiceswhere !deletionRuns[index].isCanonicalCommitted    && deletionRuns[index].destinationText == text    && deletionRuns[index].transactionID == transactionID {    deletionRuns[index].isCanonicalCommitted = true    deletionRuns[index].startedAt = now    deletionRuns[index].lastFrameTime = now}

GPU resource lifetime

While the solid glyph fades, the run draws it alongside its particles. Other glyphs stay where they are until visibleDuration, then shift into the destination layout.

The GPU may still be using the run's data when its duration ends. The renderer waits for successful command buffer completion before retiring the solid draw or removing the run.3

GPUCompletion.swift
if run.phase == .extinction, elapsed >= run.asset.visibleDuration {    deletionRuns[index].phase = .completing    let id = run.id    commandBuffer.addCompletedHandler { [weak self] completedBuffer in        guard completedBuffer.status == .completed else { return }        DispatchQueue.main.async {            self?.completeExtinction(for: id, run: run)        }    }}
DeletionPhases.txt
transferringSolid → solidTransferDuration reached → committingSolid→ command buffer .completed → main-queue callback → extinction→ visibleDuration reached → completing→ command buffer .completed → main-queue callback → remove ActiveDeletionRun

The callback captures run to keep its asset and state buffer alive. Cached assets can remain after completeExtinction removes the run.

Result

Deletion finally works as intended, with particles drawing their coverage from the disappearing glyph.

Pretty happy with how this turned out.