skills/metal-text-pipeline/SKILL.md
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.
npx skillsauth add curiositech/windags-skills metal-text-pipelineInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
objc2-metal and a higher-level renderer for a
Rust macOS app and want the costs spelled out before you commit.metal-shader-expert.vello-parley-rendering.wgpu directly, bevy.swiftui-data-flow-expert, etc.).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.
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.
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.
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.
preferredFrameRateRange; all rungs: Info.plist
CADisableMinimumFrameDurationOnPhone / display-link cadence) OR only
redrawing on input.& vs
Retained<…> in objc2.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.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.
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.
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.
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.)
"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/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.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.
Every file in this skill, and when to open it. Auto-generated; run scripts/index_references.py --fix.
references/
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 — Rung-3 skeleton: what you own with pure objc2-metal — This is the pipeline you'd hand-write if you dropped below wgpu/Vello.data-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.