# Vello Parley Rendering

> Render GPU-accelerated 2D vector graphics (bezier paths, fills, strokes, gradients) and high-quality shaped text in Rust with the Linebender stack: Vello (compute-based vector renderer on wgpu) + Parley (text shaping/layout) + kurbo (geometry) + peniko (brushes/color). Covers winit+wgpu surface setup, building a Scene every frame, feeding Parley glyph runs into Vello, the retained-vs-immediate question, vsync/frame pacing, and the version-pinning minefield between vello/wgpu/parley. Activate on: "Vello", "Parley", "Linebender", "GPU vector rendering in Rust", "render text with wgpu", "kurbo bezier", "peniko brush", "draw_glyphs", "Scene::fill", "custom 2D GPU canvas". NOT for: 3D rendering (use wgpu directly or bevy), Metal shader authoring (use metal-text-pipeline / metal-shader-expert), web canvas (use Canvas2D/WebGL), or native OS widgets (use gpui/egui/iced).

- Skill: `curiositech/vello-parley-rendering` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add curiositech/vello-parley-rendering`
- Raw SKILL.md: https://api.skillmd.com/api/skills/curiositech/vello-parley-rendering/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/vello-parley-rendering

---


# Vello + Parley GPU Vector Rendering (Rust)

Vello is a **compute-shader 2D vector renderer**: you hand it a `Scene` (a list
of filled/stroked paths, images, glyph runs) and it rasterizes the whole thing
on the GPU in a few compute passes. Parley is the **text** half: it shapes and
lays out Unicode runs (via HarfRust + Swash/Skrifa) and yields positioned
glyphs you stream straight into the same Vello `Scene`. Together they give you a
**pixel-perfect, fully custom 2D canvas** with no widget model in the way —
which is the entire reason you'd reach for them over gpui/egui.

This skill is the worked, version-pinned, gotcha-annotated path from "blank
window" to "anti-aliased beziers + crisp shaped text at 120fps." The worked
example is real: `core/pd-timeline-proto/` in this repo renders a scrubable
timeline (tracks, event markers, causal bezier threads, a playhead) this way.

## When To Use

- You need a **bespoke 2D visualization** (timeline, node graph, waveform, map,
  diagram) where every pixel is yours and a retained widget tree gets in the way.
- You want **resolution-independent vector** output (smooth zoom, crisp on HiDPI)
  without managing your own tessellation/rasterization.
- You need **high-quality text** (real shaping, ligatures, variable fonts, RTL)
  rather than a bitmap-font hack.
- You're already on `wgpu` and want a 2D layer that composes with your 3D, or you
  want one renderer that runs on Metal/Vulkan/DX12/GL from one codebase.

## NOT For

- 3D scenes, meshes, lighting → use `wgpu` directly, or `bevy`.
- Authoring the shaders themselves → `metal-shader-expert` (MSL) or wgpu WGSL.
- The "should I even leave gpui?" decision and pure-Metal costs →
  `metal-text-pipeline`.
- Native OS controls, accessibility trees, text input widgets → `gpui`, `egui`,
  `iced`. Vello draws; it does not give you a button or a focus ring.
- Web targets → Canvas2D / WebGL / WebGPU bindings, not native Vello (Vello does
  have a web path, but if web is your primary target start there).

## Decision Points

### 1. Pick the stack layer you actually need
```
What are you drawing?
├─ Arbitrary 2D vector + text, custom layout
│   └─ Vello (Scene) + Parley (text) + kurbo (geom) + peniko (brush)   ← this skill
├─ 2D but you want widgets/layout/events handed to you
│   └─ Xilem (Linebender's UI framework, built on Vello+Parley) or egui/iced
├─ Just static SVG → raster once
│   └─ resvg / usvg (CPU) is simpler; skip the GPU entirely
└─ 3D / GPGPU
    └─ wgpu directly; Vello is the wrong tool
```

### 2. Windowing + GPU surface
```
Need a window?
├─ Cross-platform desktop  → winit 0.30 (ApplicationHandler model)
│   └─ GPU via wgpu; Vello ships `vello::util::RenderContext` to manage
│      Instance/Adapter/Device/Surface for you — USE IT, don't hand-roll wgpu init
├─ Embedding in an existing wgpu app
│   └─ Construct `Renderer` against your existing Device; render to your texture
└─ Headless render-to-PNG (tests, thumbnails, CI)
    └─ Vello can render to a wgpu texture you read back; no surface needed
```

### 3. Retained vs immediate (Vello's real model)
Vello is **immediate from your side, retained on the GPU side**. You rebuild a
`Scene` each frame with `scene.reset()` then re-`fill`/`stroke`/`draw_glyphs`.
This sounds wasteful; it isn't — scene encoding is CPU-cheap (microseconds for
thousands of primitives) and the GPU does the heavy raster.
```
Rebuilding the scene every frame is the NORMAL pattern.
├─ Static content, rare changes → still rebuild; it's fine. Don't over-engineer caching.
├─ Huge scenes (100k+ paths) → consider sub-scenes / append() of cached fragments
└─ NEVER try to "diff" or mutate a Scene in place — that's not the API.
```

### 4. Anti-aliasing config
```
RendererOptions.antialiasing_support + RenderParams.antialiasing_method must AGREE.
├─ Cheapest, great for UI/vector   → AaSupport::area_only() + AaConfig::Area
├─ MSAA8/MSAA16 → heavier, only if Area shows artifacts on thin strokes
└─ Mismatch (support says Area, params ask Msaa16) → panic at render time.
```

### 5. Frame pacing
```
PresentMode?
├─ AutoVsync (default, recommended) → tear-free, caps to display (60/120 ProMotion)
├─ AutoNoVsync → uncapped; use ONLY to benchmark raw GPU headroom, not to ship
└─ On ProMotion you get 120fps free IF you request a redraw every frame.
    winit only redraws on request — call window.request_redraw() to keep animating.
```

## Failure Modes

### 1. Version skew between vello / wgpu / parley (the #1 time sink)
- **Symptoms**: `expected wgpu::Device, found wgpu::Device` (two wgpu majors in
  the tree); `RenderSurface` field/method not found; trait bound errors on
  `Brush`.
- **Root cause**: `vello` re-exports a specific `wgpu` major. If your `Cargo.toml`
  pulls a different `wgpu`, you have two incompatible `wgpu` crates.
- **Fix**: Let `vello` own the wgpu version. Either depend on `vello` only and use
  `vello::wgpu::…`, or pin your direct `wgpu` to **exactly** the major vello uses
  (vello 0.3 → wgpu 22; check `cargo tree -i wgpu`). Same discipline for parley:
  vello 0.3 pairs with parley 0.2 / peniko 0.2 / kurbo 0.11.
- **Detect**: `cargo tree -d` (the `-d` finds duplicate crates). Two `wgpu`
  entries = you will fail to compile or get cryptic type errors.

### 2. Glyph normalized-coords type mismatch
- **Symptoms**: `expected &[F2Dot14], found &[i16]` when calling
  `.normalized_coords(...)` on `draw_glyphs`.
- **Root cause**: Parley yields variation coords as `&[i16]`; Vello/Skrifa wants
  `&[NormalizedCoord]` (a `#[repr(transparent)]` wrapper over the same i16 bits,
  F2Dot14).
- **Fix**: reinterpret the slice — they are layout-identical:
  `let coords: &[skrifa::raw::types::F2Dot14] = unsafe { std::slice::from_raw_parts(raw.as_ptr().cast(), raw.len()) };`
  (Both are 2-byte; this is the idiom the parley examples use.)

### 3. API churn across minor versions
- **Symptoms**: `Layout::align` "takes 2 arguments but 3 supplied";
  `with_alpha_factor` deprecated; `RenderContext::create_surface` signature drift.
- **Root cause**: Pre-1.0 Linebender crates move fast.
- **Fix**: pin exact minors and read THAT version's `examples/` (clone the tagged
  repo or open docs.rs at the pinned version). Don't trust a blog post for a
  different minor. `peniko::Color::with_alpha_factor` → `multiply_alpha`;
  `Layout::align(width, alignment)` dropped its trailing bool.

### 4. Borrow-checker fights in the winit ApplicationHandler
- **Symptoms**: "cannot borrow `*self` as immutable because it is also borrowed
  as mutable" inside `window_event`.
- **Root cause**: holding `let state = self.state.as_mut()` across calls to other
  `&self`/`&mut self` helpers and disjoint fields.
- **Fix**: pull the `Arc<Window>` + dims out up front into locals, drop the
  `state` binding, then take short, scoped borrows. Access **disjoint struct
  fields directly** (e.g. `&self.context`, `self.renderers[i]`, `&self.scene`) so
  the borrow checker can split the `&mut self` — don't funnel them through one
  intermediate `&mut state`.

### 5. Black / empty window
- **Symptoms**: window opens, renders nothing.
- **Checklist**: (a) did you `request_redraw()` after resume/resize? winit won't
  redraw on its own. (b) `RenderParams.width/height` must match the surface
  config, not the logical size. (c) surface format from
  `surface.config.format` must be what the `Renderer` was created with. (d) you
  called `surface_texture.present()`. (e) `scene.reset()` then actually pushed
  primitives.

### 6. HiDPI text/geometry at half/double size
- **Root cause**: mixing logical and physical pixels. winit gives physical
  surface sizes; your layout is usually in logical points.
- **Fix**: pass `window.scale_factor()` into Parley's layout (`ranged_builder`
  takes a `scale`) AND into your geometry transform, consistently. Decide one
  coordinate space and convert at the boundary.

## Worked Example

A minimal-but-complete loop (the shape `core/pd-timeline-proto` uses). Pinned to
vello 0.3 / wgpu 22 / parley 0.2 / peniko 0.2 / kurbo 0.11 / winit 0.30.

```rust
// Cargo.toml: vello="0.3"  wgpu="22"  winit="0.30"  parley="0.2"
//             peniko="0.2"  kurbo="0.11"  pollster="0.4"

use vello::util::{RenderContext, RenderSurface};
use vello::{AaConfig, AaSupport, Renderer, RendererOptions, RenderParams, Scene};
use vello::peniko::{Color, Fill};
use vello::wgpu;                  // <- use vello's re-exported wgpu, never a second one
use kurbo::{Affine, Circle, Point, Stroke, Line};

// 1. Surface (winit Window -> wgpu Surface), let Vello's RenderContext drive wgpu init.
let mut cx = RenderContext::new();
let surface: RenderSurface =
    pollster::block_on(cx.create_surface(window.clone(), w, h, wgpu::PresentMode::AutoVsync))?;

// 2. One Renderer per device. AA support must match the AA you ask for at render time.
let mut renderer = Renderer::new(
    &cx.devices[surface.dev_id].device,
    RendererOptions {
        surface_format: Some(surface.format),
        use_cpu: false,
        antialiasing_support: AaSupport::area_only(),   // pairs with AaConfig::Area below
        num_init_threads: None,
    },
)?;

// 3. Per frame: rebuild the scene from scratch.
let mut scene = Scene::new();
scene.reset();
scene.stroke(&Stroke::new(1.0), Affine::IDENTITY, Color::rgb8(0x2a,0x32,0x3c), None,
             &Line::new(Point::new(0.0, 100.0), Point::new(800.0, 100.0)));
scene.fill(Fill::NonZero, Affine::IDENTITY, Color::rgb8(0x4f,0xd1,0x6b), None,
           &Circle::new(Point::new(120.0, 100.0), 5.0));
// (text via Parley -> scene.draw_glyphs(...); see references/parley-glyph-runs.md)

// 4. Render to the surface.
let tex = surface.surface.get_current_texture()?;
let dev = &cx.devices[surface.dev_id];
renderer.render_to_surface(&dev.device, &dev.queue, &scene, &tex, &RenderParams {
    base_color: Color::rgb8(0x0d,0x11,0x17),
    width: w, height: h,
    antialiasing_method: AaConfig::Area,                // must agree with AaSupport above
})?;
tex.present();
window.request_redraw();   // keep animating; winit won't redraw on its own
```

**Text (the Parley half), the part everyone gets stuck on:** build a `Layout`,
`break_all_lines`, `align`, then for each `PositionedLayoutItem::GlyphRun`, call
`scene.draw_glyphs(font).brush(..).transform(..).font_size(..).normalized_coords(coords).draw(Fill::NonZero, glyphs)`
where `glyphs` is an iterator of `vello::Glyph { id, x, y }` you accumulate by
advancing `x += g.advance`. See `references/parley-glyph-runs.md` for the exact,
compile-checked function.

## Anti-Patterns

### Anti-Pattern 1: Pulling your own `wgpu` next to Vello's
```toml
# ❌ two wgpu majors in the tree -> type mismatches everywhere
vello = "0.3"
wgpu  = "0.20"   # vello 0.3 wants wgpu 22
```
```toml
# ✅ either use vello::wgpu, or pin the SAME major vello uses
vello = "0.3"
wgpu  = "22"     # matches; verify with `cargo tree -d`
```

### Anti-Pattern 2: Caching/diffing the Scene to "save work"
Rebuilding the scene each frame is the intended model and costs microseconds.
Building a retained scene-graph diff layer on top is wasted complexity and fights
the API. Only reach for `Scene::append` of cached sub-scenes when you have
genuinely huge (100k+ primitive) static regions, measured.

### Anti-Pattern 3: Bitmap fonts / texture-atlas-by-hand for text
Vello + Parley already do GPU glyph rendering with real shaping. Hand-rolling a
bitmap atlas throws away ligatures, hinting, variable fonts, RTL/complex scripts,
and HiDPI crispness. If you think you need a manual atlas, you're below the right
layer — see `metal-text-pipeline` for when that's actually justified.

### Anti-Pattern 4: Ignoring `request_redraw` and wondering why it's static
winit is event-driven. No redraw request → no frame. Animations, scrubbing, and
auto-play all require you to request the next redraw (from `resumed`, after input,
and at the end of a frame while animating).

### Anti-Pattern 5: Trusting a tutorial for a different crate version
These crates are pre-1.0 and rename things between minors. A snippet for vello
0.2 will not compile against 0.3. Always read the `examples/` in the *tagged*
release matching your pin.

## References

- `references/version-matrix.md` — known-good vello/wgpu/parley/peniko/kurbo/winit
  version combinations and how to verify with `cargo tree -d`.
- `references/parley-glyph-runs.md` — the exact, compile-checked
  `render_glyph_run` function that feeds Parley output into a Vello `Scene`,
  including the `i16 -> F2Dot14` coords reinterpret.

## Provenance

Worked example and gotchas verified against `core/pd-timeline-proto/` in this
repo (a winit+wgpu+Vello+Parley "Voyage Timeline" window), built and run on
Apple M4 Max (Metal backend) at sub-millisecond per-frame GPU cost.

<!-- 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/parley-glyph-runs.md`](references/parley-glyph-runs.md) — Feeding Parley glyph runs into a Vello Scene — The single function everyone gets stuck writing.
- [`references/version-matrix.md`](references/version-matrix.md) — Vello / wgpu / Parley version matrix — Pre-1.0 Linebender crates move in lockstep clusters.

<!-- END BUNDLE INDEX -->

