Skip to contents

vellum occupies the same layer of the graphics stack as grid: primitives, a coordinate and viewport model, a unit system, a retained tree of graphical objects, inherited graphical parameters, and a layout engine. It is deliberately not a grammar of graphics: no data binding, scales, stats, geoms, facets, or themes. Those belong to a layer built on top (vellumplot), exactly as ggplot2 and lattice are built on grid.

This article explains the choices that make vellum different from grid, and is a companion to the critique of grid. That article ends on one observation, which is where this one begins.

The one constraint everything follows from

Most of grid’s friction traces back to a single fact: a graphical object’s size and content cannot be known until a device and a viewport exist at draw time. Text has no measurable width before a device is open; native units need a viewport scale; flexible (null) lengths need a layout context. That one constraint is what forces lazy units, the deferred multi-method grob-drawing protocol, and the replay of the whole display list on every resize.

vellum’s central bet is that if you remove that constraint, most of the downstream complexity dissolves with it. The way to remove it is to make metrics available at construction time, which means controlling the measurement and rendering stack rather than deferring to whatever device happens to be current.

Do the work in Rust, keep R declarative

There are two things “a grid-like framework with a Rust backend” could mean. One is to be a graphics device: let R’s engine and grid stay in charge and have Rust only rasterize the primitives handed down to it. That is low-risk and the whole ecosystem renders through you for free, but it fixes none of the layout or unit problems, because grid is still doing that work upstream.

The other is to reimplement the model itself in Rust (the scene graph, units, viewports, layout, and rendering) and expose a thin declarative R API. vellum takes this second path. The scene graph, unit resolution, layout, and rendering all live in Rust; R describes what to draw. A separate, optional grid-to-vellum translator (as_vellum() / render_grid()) gives the ecosystem reach of the first approach as a secondary mode, so existing grid, ggplot2, and lattice output can render through the same backend.

Controlling the stack is what buys the properties the rest of these principles depend on: text measurable up front, layout solved ahead of drawing, and identical output across machines.

A retained, immutable scene graph

The scene is a tree of inert data nodes. A node carries identity, a local transform, geometry, resolved style, and children, but no drawing code. Nodes are immutable values: “editing” a scene produces a new tree, and only the nodes on the path to the edit are rebuilt, so the cost is the depth of the tree rather than its size. Untouched subtrees are shared by reference and keep their internal identity, which is what the repaint-boundary cache keys on when deciding what it can reuse.

library(vellum)

scene <- vl_scene(width = 6, height = 4) |>
  push(vl_viewport(xscale = c(0, 10), yscale = c(0, 100))) |>
  draw(rect_grob(name = "panel-bg", gp = vl_gpar(fill = "grey95", col = NA))) |>
  draw(lines_grob(x = vl_unit(1:9, "native"),
                  y = vl_unit(c(10, 40, 35, 80, 60, 90, 55, 70, 30), "native"),
                  gp = vl_gpar(col = "steelblue", lwd = 2)))

node_names(scene)                                    # inspect the tree
scene <- edit_node(scene, "panel-bg", gp = vl_gpar(fill = "grey90"))

This speaks to the “missing middle layer” from the critique, though the difference from grid is narrower here than elsewhere and worth stating precisely. grid also lets you list, read, and rewrite a scene (grid.ls(), grid.get(), grid.edit(), editGrob()), so addressability is not the new thing. What differs is what you are addressing: a value you built and still hold, rather than state that lives on a device and has to be recovered (grid.grab()) or materialised (grid.force()) before it can be walked. An edit returns a new scene; nothing was drawn, so nothing has to be undrawn.

No stateful viewport stack

grid navigates its regions through a mutable push/pop stack, so a drawing call’s meaning depends on invisible current state. vellum threads context functionally instead: push() and draw() pass the scene along a pipeline, and you navigate by node name rather than by a global “current viewport”. The viewport model, regions with their own coordinate systems, is kept, because it is one of grid’s best ideas; the mutable stack that made it fragile is not. A stateful convenience layer can still be built on top for interactive use, but it is not the foundation.

Eager metrics and an explicit layout pass

Because text is measurable at construction, layout can be solved as an explicit, pure pass keyed on the device size and viewport scales, and the result cached. Resizing becomes relayout plus re-raster, rather than replaying interpreted R. This is the performance story and the predictability story at once: the same inputs always produce the same layout, and the layout is a value you can compute without drawing.

“Measurable at construction” is meant literally, and it is the one claim in this article worth checking yourself:

vl_strwidth("Population growth", unit = "mm")
#> 33.8832
dev.cur()
#> null device

No device was opened, and the answer lands within a hundredth of a millimetre of what png() reports for the same string, because it comes from the same systemfonts/textshaping stack. Compare the critique’s numbers for the same measurement taken through devices (33.889 / 33.608 / 34.925 mm): the point is not that one number is better, it is that there is one number, available before anything is drawn, so layout can be a computation rather than a negotiation.

The same property is what makes why_size() possible: a named viewport’s resolved width can be reported, with what determined it, before a page exists.

A small, principled unit set, and an honest trade-off

grid’s unit system is powerful and sprawling. vellum keeps the power that matters and trims the set to a principled handful: normalized (npc), absolute (mm, cm, in, pt), user-scale (native), font-relative (char, line), object-relative (strwidth, grobwidth, …), and a proper flexible-length type for layout instead of a relative unit bolted onto the absolute one. Object-relative units resolve to millimetres at construction, using shaping metrics for text.

The representation is the point, and it is worth being concrete. A stored unit is a three-field record, not an arithmetic-expression tree:

unclass(vl_unit(1, "native") + vl_unit(2, "mm"))
#> $value  1     # the position, in its base coordinate system
#> $unit   1     # the base code: npc / native / mm / in / pt / null
#> $offset 2     # an absolute displacement, in millimetres

So + and - reduce as far as they can, at construction. Two units of the same code combine. Two absolute units resolve to millimetres (vl_unit(10, "mm") + vl_unit(1, "in") becomes 35.4 mm). A position base plus an absolute becomes a compound unit that keeps the base and carries the absolute part in offset, printing as 1native+2mm; it resolves in the backend as base_px + offset_mm/25.4*dpi, so the offset is exactly 2 mm at any scale, aspect, or resolution. Scaling with * scales the base and the offset together.

The honest trade-off is narrower than it used to be, but it is real: two different position bases in one unit (vl_unit(1, "npc") + vl_unit(1, "native")) cannot reduce to a single record, so it is an error rather than a promise. grid accepts it, because deferring means never having to reduce anything. vellum declines, because the flat record is exactly what makes resolution cheap and re-runnable on resize, and what lets the backend treat a coordinate as two numbers and a code rather than a tree to walk. When you need that combination, put the normalized part in the viewport and the data part in the coordinate, or pre-resolve to absolute units.

One extension mechanism instead of four

Writing a custom grid grob can mean implementing several hooks (makeContext(), makeContent(), drawDetails(), widthDetails()) because different facts about the object become available at different times. When metrics are known up front, that machinery is unnecessary. A custom vellum element is a function that returns concrete child primitives once, at construction. There is one way to extend the system, not a protocol to learn.

Determinism and font fidelity

Rendering runs in-process against a controlled rasterizer, so a scene produces identical pixels on every operating system and in CI. That makes output reproducible and snapshot-testable, a property that is awkward to guarantee when the result depends on whichever device is current.

Text is the hard part, and fidelity here means reusing R’s font stack rather than reinventing it. vellum resolves and shapes text through the same systemfonts/textshaping machinery that ragg and svglite use, so the font chosen and the glyph positions match the rest of the ecosystem; a Rust glyph library then supplies the outlines that get filled or embedded. Matching the ecosystem was a requirement, not a nice-to-have: output that drifts from ggplot-via-ragg would not be trustworthy.

One scene, many backends, and visible degradation

Rendering is a visitor over the resolved scene behind a single trait, so PNG, SVG, and PDF share one walk over the same tree:

render(scene, "plot.png")   # raster
render(scene, "plot.svg")   # vector
render(scene, "plot.pdf")   # vector

The critique notes that device differences leak into the result. vellum cannot abolish backend differences (a raster fast path and a vector format differ), but it adopts a principle for them: where a backend cannot honour something, the render raises one warning naming the gap, rather than silently producing different output. Unsupported features fail visibly. A few intrinsic fast-path trade-offs (marker sprite snapping at high densities, a quantised glyph-bitmap cache) are documented rather than warned, because they are deliberate speed trades, not failures.

Performance is part of the model

Matching a mature C-backed system is not automatic in a retained-mode design; it has to be earned. vellum builds performance into the model: batched primitives so a thousand points are not a thousand heavyweight objects, a persistent glyph cache, a repaint-boundary sub-raster cache and an object-identity render cache so unchanged scenes and subtrees are reused rather than redrawn, and an aggregate-then-shade path (datashade()) that bins millions of points into a density raster the size of the output rather than drawing each one. The distinction that makes this work is between a semantic layer and its optimized rendering representation: a million-point scatter need not be a million objects.

What the retained model makes possible

Some capabilities are not separate features so much as consequences of keeping the scene and its resolved geometry. vellum can pick the topmost object under a point (hit_test()), which grid never offered beyond grid.locator()’s single interactive click. The pick is a colour pick-buffer compiled by the same transform, clip, and paint-order code that drew the scene, so it cannot disagree with the picture; a picker assembled from outside the engine (grobPoints() plus a point-in-polygon test would get you there) is a second geometry implementation to keep in step with the first. For the same reason, nodes can carry semantic identity that survives into SVG output, which is useful for export, testing, and accessibility independent of any interactive layer. And because every node resolves to a known transform and bounding box, the engine has the data to draw its own layout (bounding boxes, viewport regions, clip chains) as a debugging overlay, and to explain why a given node ended up the size it did. Turning the resolved scene into a coordinate that a person can see into is the most direct answer to the critique’s central complaint that introspection is archaeology.

What vellum deliberately does not do

Scope discipline is a principle in its own right. vellum does not ship a grammar of graphics: no scales, geoms, or facets; that is vellumplot’s job. It does not include a general constraint solver, because a flex solver plus measured tracks covers the layouts that arise, without a large opaque dependency. It does not implement a CSS-style selector engine, because cascading themes are a grammar concern. Each of these is a case where the more elaborate mechanism would sit at the wrong layer. Keeping the low-level layer small is what lets the grammar above it stay coherent.

The shape of the trade

None of this is free. The Rust backend is a build dependency and a maintenance commitment. The flat unit representation gives up one grid convenience (two position bases in a single unit). Reimplementing the model rather than wrapping the device means not inheriting the existing ecosystem automatically (hence the translator as a bridge). These are deliberate trades, made in service of one goal: keep the strengths the critique credits grid with (grobs, a real layout engine, nested coordinate systems, device independence) while removing the single constraint that made the rest of it hard.