# Metal Text Pipeline

> Decide HOW LOW to go for a bare-metal 2D/text rendering pipeline on Apple GPUs, and pay the right costs. Covers the layered choice — pure objc2-metal + hand-written MSL (own command queue, glyph atlas, frame pacing) vs wgpu (Metal under the hood, cross-platform) vs Vello-on-wgpu (compute vector renderer you don't have to write) — plus CAMetalLayer/CADisplayLink frame pacing, ProMotion 120Hz, the glyph-atlas / signed-distance-field text problem, CPU-GPU sync, and honest cost accounting of "pure Metal" vs standing on Linebender. Activate on: "objc2-metal", "objc2", "CAMetalLayer", "CADisplayLink", "Metal command queue", "glyph atlas", "SDF text", "ProMotion frame pacing", "bare-metal Rust rendering on macOS", "pure Metal vs wgpu", "MTLDrawable". NOT for: writing the MSL shaders themselves (use metal-shader-expert), the Vello/Parley high-level API (use vello-parley-rendering), 3D engines (use wgpu/bevy), or iOS UIKit drawing.

- Skill: `curiositech/metal-text-pipeline` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add curiositech/metal-text-pipeline`
- Raw SKILL.md: https://api.skillmd.com/api/skills/curiositech/metal-text-pipeline/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- License: Apache-2.0
- Author: curiositech (https://skillmd.com/u/curiositech)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/curiositech/metal-text-pipeline

---


# The Metal Text / Render Pipeline (how low to go on Apple GPUs)

There are three rungs on the ladder for a custom 2D + text surface on macOS, and
the expensive mistake is picking the wrong rung. This skill is the decision and
the cost accounting — **not** the MSL itself (`metal-shader-expert`) and **not**
the Vello API (`vello-parley-rendering`).

```
Rung 3 (lowest):  objc2 + objc2-metal, hand-written MSL.
                  You own: CAMetalLayer, command queue, render passes, a glyph
                  atlas (rasterize glyphs -> texture -> quads), AA, frame pacing.
Rung 2 (middle):  wgpu. Metal under the hood on macOS; portable. You still write
                  pipelines/shaders, but no Obj-C, no CAMetalLayer plumbing.
Rung 1 (highest): Vello on wgpu. The compute vector renderer + Parley text are
                  written FOR you. You build a Scene; the GPU does the rest.
```

The honest headline, measured on this repo's `core/pd-timeline-proto` (Apple
M4 Max, Metal): a full timeline frame — beziers, dozens of markers, shaped text —
costs **~0.5–2 ms of GPU build+submit**, vsync-bound otherwise. That performance
came from **Rung 1**. You do not need Rung 3 to hit 120fps ProMotion for a 2D
operator UI. Rung 3 is justified only by specific constraints below.

## When To Use (this decision skill)

- You're choosing between pure `objc2-metal` and a higher-level renderer for a
  Rust macOS app and want the costs spelled out before you commit.
- You already went bare-metal and need the frame-pacing / glyph-atlas / CPU-GPU
  sync patterns done right.
- Someone proposed "let's write it in pure Metal for performance" and you need to
  pressure-test that against the measured ceiling.

## NOT For

- Writing the actual MSL vertex/fragment/compute shaders → `metal-shader-expert`.
- The Vello + Parley high-level API and its gotchas → `vello-parley-rendering`.
- 3D engines / scene graphs → `wgpu` directly, `bevy`.
- iOS UIKit/CoreGraphics 2D drawing, or SwiftUI Canvas → those are different
  stacks (`swiftui-data-flow-expert`, etc.).

## Decision Points

### 1. Which rung?
```
Do you need a CUSTOM 2D vector + text surface on macOS in Rust?
├─ Yes, and you want it shipped this quarter
│   └─ Rung 1: Vello + Parley on wgpu.  (default — proven 120fps for 2D UI)
├─ Yes, AND you have a HARD reason to control the metal directly:
│   ├─ Need a specific Metal feature wgpu doesn't expose
│   │   (e.g. tile shaders / imageblocks, MTLHeap aliasing tricks,
│   │    raster order groups, custom MSL compute you must hand-tune)
│   ├─ Must share one MTLCommandQueue with existing Obj-C/Swift Metal code
│   ├─ Sub-100µs frame budgets where even wgpu's abstraction is measurable
│   └─ then → Rung 3: objc2 + objc2-metal, and bring metal-shader-expert
└─ You also want portability (Vulkan/DX12/GL) or 3D
    └─ Rung 2: wgpu directly.
```
Default to Rung 1. Drop a rung only when you can NAME the constraint that forces
it. "It'll be faster" is not a constraint until you've measured Rung 1 missing
the budget — and for 2D UI it won't.

### 2. Frame pacing on ProMotion (all rungs)
```
Target 120Hz on ProMotion displays?
├─ Rung 3 (CAMetalLayer): drive with CADisplayLink; set
│   layer.maximumDrawableCount = 3 (triple buffer); set
│   CAMetalLayer.displaySyncEnabled = true; request the 120Hz cadence via
│   the display link's preferredFrameRateRange.
├─ Rung 1/2 (wgpu): PresentMode::AutoVsync caps to the display automatically;
│   you only need to request a redraw every frame to actually hit 120.
└─ Common trap: animation looks "60fps" because you only redraw on input.
    The renderer isn't the limit — your event loop is. Request redraw each frame.
```

### 3. Text: glyph atlas vs Parley
```
How are you drawing text?
├─ Rung 1: Parley shapes+lays out; Vello rasterizes glyphs on GPU. Done.
├─ Rung 3: you own the whole thing:
│   1. Shape with a HarfBuzz/HarfRust/CoreText pass (don't reinvent shaping).
│   2. Rasterize each needed glyph once into a texture ATLAS (CPU raster via
│      Swash/CoreText, upload to MTLTexture). Cache by (glyph_id, size, subpixel).
│   3. Emit a textured quad per glyph; sample the atlas in the fragment shader.
│   4. For crisp scaling, consider SDF/MSDF atlases (one raster, many sizes) —
│      trades a fancier shader for not re-rasterizing per size.
└─ NEVER: a fixed bitmap font. It throws away shaping, hinting, variable fonts,
    RTL/complex scripts, and HiDPI crispness.
```

### 4. CPU-GPU synchronization (Rung 3)
```
Avoiding stalls and races on a hand-rolled queue?
├─ Triple-buffer your per-frame uniform/vertex buffers (3 in flight).
├─ Gate frame N+3 on a dispatch_semaphore signaled in the command buffer's
│   completion handler (the canonical Metal pattern).
├─ Never write a buffer the GPU might still be reading. The semaphore is what
│   makes that safe without a full stall.
└─ Rung 1/2 (wgpu) handles this for you; this whole box is a cost of Rung 3.
```

## Failure Modes

### 1. "Let's go pure Metal for speed" with no measurement
- **Symptom**: weeks spent on CAMetalLayer/atlas plumbing to render a 2D UI that
  Vello did in an afternoon at 0.6ms/frame.
- **Root cause**: assuming the abstraction is the bottleneck without profiling.
- **Fix**: build the Rung-1 version FIRST, measure with the GPU frame capture
  (Xcode Metal debugger) or a simple submit-time log. Only drop rungs against a
  named, measured miss.

### 2. Reinventing the vector rasterizer
- **Symptom**: hand-writing anti-aliased bezier fill/stroke in MSL.
- **Root cause**: not realizing that's *exactly* what Vello's compute pipeline is
  (coarse rasterization + fine per-tile AA). It's a research-grade renderer.
- **Fix**: unless your constraint is "I can't link Vello," don't. This is the
  single biggest cost of Rung 3 and the main reason this prototype chose Rung 1.

### 3. ProMotion locked to 60
- **Symptom**: 120Hz display, app renders 60.
- **Root cause**: not requesting the high frame-rate range (Rung 3:
  `preferredFrameRateRange`; all rungs: `Info.plist`
  `CADisableMinimumFrameDurationOnPhone` / display-link cadence) OR only
  redrawing on input.
- **Fix**: request the next frame unconditionally while animating; set the
  display link / present mode to the display cadence.

### 4. Glyph atlas thrash
- **Symptom**: frame time spikes whenever new text appears.
- **Root cause**: rasterizing+uploading glyphs mid-frame, or an atlas too small so
  it evicts and re-rasters.
- **Fix**: size the atlas generously, cache by (glyph, size, subpixel offset),
  rasterize off the hot path, and consider an SDF atlas so one raster serves many
  sizes.

### 5. objc2 memory-management surprises
- **Symptom**: crashes / leaks at the Rust↔Obj-C boundary.
- **Root cause**: mishandling retain/release, autorelease pools, or `&` vs
  `Retained<…>` in `objc2`.
- **Fix**: use `objc2`'s typed `Retained<T>` and the framework crates
  (`objc2-metal`, `objc2-quartz-core`, `objc2-foundation`) rather than raw
  message sends; wrap frame work in an `autoreleasepool`. This boilerplate is a
  Rung-3-only tax.

## Worked Example: the rung decision for this repo

**Context**: a Track-B "Voyage Timeline" operator surface — horizontal tracks,
timestamped markers, smooth causal bezier threads, shaped text labels, a
scrubable playhead, target 120fps ProMotion.

**Considered Rung 3** (pure objc2-metal): would have meant owning a CAMetalLayer,
a command queue, a glyph atlas + shaping pass, an anti-aliased bezier
rasterizer in MSL, and triple-buffered CPU-GPU sync. Weeks of work, most of it
re-implementing Vello.

**Chose Rung 1** (winit + wgpu + Vello + Parley): days, not weeks. wgpu lowers to
**Metal** on macOS (confirmed at runtime: `backend: Metal, Apple M4 Max`), so we
keep "bespoke GPU vector rendering" — the beziers and text are genuinely GPU
vector, fully under our control via the `Scene` API — without writing the
rasterizer.

**Measured result**: ~0.5–2 ms GPU build+submit per frame; vsync-bound otherwise;
uncapped benchmark logged 750–900 FPS. Conclusion: for a 2D operator UI, Rung 1
clears 120fps ProMotion with ~99% of the frame budget to spare. **Bare-metal
(Rung 3) is not worth it here**; reserve it for a future surface that needs a
specific Metal feature wgpu/Vello don't expose.

See `references/objc2-metal-skeleton.md` for the Rung-3 skeleton (what you'd own
if you did drop down) and `references/cost-ledger.md` for the side-by-side cost
accounting.

## Anti-Patterns

### Anti-Pattern 1: Equating "lower level" with "faster" for 2D UI
The GPU work for a 2D vector+text frame is tiny at every rung. The win from Rung 3
is *control of specific Metal features*, not raw 2D throughput. If you can't name
the feature, you're paying for nothing.

### Anti-Pattern 2: Hand-rolling shaping
CoreText / HarfRust exist. Writing your own shaper (kerning, ligatures, cluster
mapping, bidi) is a multi-year project you will get wrong. Even at Rung 3, shape
with a real shaper and only own the atlas+raster.

### Anti-Pattern 3: Single-buffering a hand-rolled Metal frame
One per-frame buffer the GPU is still reading = tearing/races or a full stall.
Triple-buffer + a completion-handler semaphore. (Free at Rung 1/2.)

### Anti-Pattern 4: Skipping the Rung-1 measurement
"We'll go straight to Metal" with no baseline means you can never prove the lower
rung was warranted. Always have the Rung-1 number in hand.

## References

- `references/objc2-metal-skeleton.md` — the minimal Rung-3 pipeline you'd own:
  CAMetalLayer + command queue + glyph-atlas sketch + the triple-buffer
  semaphore, in `objc2`/`objc2-metal` terms.
- `references/cost-ledger.md` — side-by-side cost accounting (Rung 1 vs Rung 3):
  what you write, what you maintain, what you measure.

## Provenance

Decision and measurements are from `core/pd-timeline-proto/` in this repo (Rung 1:
winit+wgpu+Vello+Parley), run on Apple M4 Max with the Metal backend.

<!-- BEGIN BUNDLE INDEX (auto: index_references.py) -->

## Skill Bundle Index

*Every file in this skill, and when to open it. Auto-generated; run `scripts/index_references.py --fix`.*

**`references/`**
- [`references/cost-ledger.md`](references/cost-ledger.md) — Cost ledger: Rung 1 (Vello/Parley) vs Rung 3 (pure objc2-metal) — Side-by-side of what you actually write, maintain, and measure.
- [`references/objc2-metal-skeleton.md`](references/objc2-metal-skeleton.md) — Rung-3 skeleton: what you own with pure objc2-metal — This is the pipeline you'd hand-write if you dropped below wgpu/Vello.

<!-- END BUNDLE INDEX -->

