Skip to content
Back to skills

Metal Text Pipeline

ASecurity

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 "pur...

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 24, 2026
toolsrustgoswiftbashnodeawsapifrontendbackendperformance

Works with

  • api

Security analysis

A100/100

Pro scans all 7 files and shows the line behind each finding

Scanned September 24, 2026

npx -y skills add curiositech/port-daddy --skill metal-text-pipeline --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Metal Text Pipeline?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Metal Text Pipeline
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/curiositech-metal-text-pipeline-port-daddy/badge)](https://www.skillsdirectory.com/skills/curiositech-metal-text-pipeline-port-daddy)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
license: Apache-2.0
name: metal-text-pipeline
description: |
  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:
  hand-authoring the MSL shaders themselves, the Vello/Parley high-level
  API (use vello-parley-rendering), 3D engines (use wgpu/bevy), or iOS UIKit
  drawing.
allowed-tools: Read,Write,Edit,Bash,Glob,Grep,WebSearch,WebFetch
metadata:
  category: Frontend & UI
  tags:
    - metal
    - objc2
    - objc2-metal
    - rust
    - gpu
    - text-rendering
    - glyph-atlas
    - frame-pacing
    - promotion
    - apple
  pairs-with:
    - skill: vello-parley-rendering
      reason: The high-level path this skill decides whether to drop below.
    - skill: gpui-shaders
      reason: If the surface lives inside a gpui pane (pd-console), this is where a per-pixel wgpu/WGSL fragment pass gets wired into the element tree — a different question from the Rung 1/2/3 choice this skill makes.
    - skill: rust-gpui-motion
      reason: Covers gpui's own paint/Vello/wgpu surfaces and animation primitives; consult it when the custom 2D/text surface is embedded in a gpui pane rather than a standalone winit window.
    - skill: rust-app-distribution
      reason: Signing/notarizing the resulting macOS binary.
  provenance:
    kind: first-party
    owners: [port-daddy]
  authorship:
    maintainers: [port-daddy]
  io-contract:
    kind: deliverable
    consumes:
      - kind: surface-requirement
        format: markdown
      - kind: rung-decision-plan
        format: json
    produces:
      - kind: rung-recommendation
        format: markdown
      - kind: cost-accounting
        format: markdown
      - kind: rung-decision-audit
        format: json
---

# 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** hand-authoring the MSL itself 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 — a separate
  shader-authoring skill this repo does not yet have; see
  `references/objc2-metal-skeleton.md` for where those shaders plug into the
  pipeline this skill decides on.
- 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 entirely, not covered by this skill family.

## 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 hand-author the MSL yourself
└─ 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.

## Auditing a Rung Decision Plan

Before committing to a rung, write the decision as a JSON plan matching
`schemas/rung-plan.schema.json` and run it through the deterministic auditor:

```bash
node scripts/rung_decision_audit.mjs --input examples/sample-input.json
```

`auditRungDecision(plan)` (in `scripts/rung_decision_audit.mjs`) turns this
skill's core thesis and Failure Modes into machine-checkable rules over
structured fields — no keyword matching: dropping to Rung 3 with no named,
measured constraint (Failure Mode 1); reinventing the vector rasterizer for a
`2d-vector-text` surface at Rung 3 (Failure Mode 2); a ProMotion target that
never requests the 120Hz cadence or never redraws every frame (Failure Mode
3); a fixed bitmap font (Anti-Pattern under Decision Point 3); single-
buffering a hand-rolled Metal frame (Anti-Pattern 3); and wgpu/objc2 code that
isn't gated out of the default Linux CI matrix. It returns
`{ pass, score, findings, recommendations }` so a reviewer or CI gate can
reject an unjustified Rung 3 proposal without re-deriving the reasoning by
hand. `examples/sample-input.json` is a well-justified Rung-1 plan (`pass:
true`); a plan with `chosenRung: 3` and no named constraint or measurement
scores `pass: false`.

## 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.
- `schemas/rung-plan.schema.json` — the JSON shape a rung-decision plan must
  match to be audited.
- `scripts/rung_decision_audit.mjs` — the deterministic auditor; exports
  `auditRungDecision(plan)` and runs standalone via `--input <plan>.json`.
- `examples/sample-input.json` — a verified `pass: true` rung-decision plan.

## 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`.*

**`examples/`**
- [`examples/sample-input.json`](examples/sample-input.json) — sample input (data/schema)

**`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.

**`schemas/`**
- [`schemas/rung-plan.schema.json`](schemas/rung-plan.schema.json) — rung plan.schema (data/schema)

**`scripts/`**
- [`scripts/rung_decision_audit.mjs`](scripts/rung_decision_audit.mjs)

<!-- END BUNDLE INDEX -->

Files in this skill

  • CHANGELOG.md368 B
  • SKILL.md14.8 KB
  • examples/sample-input.json276 B
  • references/cost-ledger.md3.5 KB
  • references/objc2-metal-skeleton.md4.1 KB
  • schemas/rung-plan.schema.json3 KB
  • scripts/rung_decision_audit.mjs7.7 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…