Skip to content

egui — the backend receives geometry, not commands

Category: backend gets geometry. Last reviewed: August 23, 2026. Pinned at 97603fc0; types per the epaint::Shape reference.

The far end of the design space, and on the list to bound it. If Slint tells a backend what a thing is and Qt tells it what shape to fill, egui tells it where the triangles are.

FieldValue
LanguageRust
LicenseMIT OR Apache-2.0 (Cargo.toml, LICENSE-MIT, LICENSE-APACHE)
Repositoryemilk/egui
Documentationdocs.rs/epaint, docs.rs/egui, ARCHITECTURE.md
Categorybackend gets geometry
Pinned revision97603fc082aa4eecfeb7feccbcb6c2507dffaf28
Target rangeGPU (and CPU rasterizers that accept triangles) — native and web; no cell target is reachable
Backends shippedegui-wgpu, egui_glow, and eframe's web painter — all consuming ClippedPrimitive
The seamVec<ClippedPrimitive> — a clip rect plus a Mesh or a PaintCallback
The intermediateepaint::Shape, a 12-variant Rust enum, tessellated to meshes before any backend sees it

Overview

What it solves

An immediate-mode toolkit that re-emits its whole UI every frame needs a per-frame representation cheap enough to build, transform and cull, and a render seam narrow enough that native GPU, WebGL and a headless test target all implement it. egui's answer is to make painting produce values (Shape), and to lower those values to triangles inside the toolkit, so the render backend's entire job is "draw these meshes under these clip rects".

Design philosophy

The Shape enum states its own role and its lifetime expectations (shapes/shape.rs):

A paint primitive such as a circle or a piece of text. Coordinates are all screen space points (not physical pixels).

You should generally recreate your Shapes each frame, but storing them should also be fine with one exception: Shape::Text depends on the current pixels_per_point (dpi scale) and so must be recreated every time pixels_per_point changes.

And the seam itself is described in two lines (lib.rs):

A Mesh or PaintCallback within a clip rectangle.

Everything is using logical points.

Two structures, one of which is an escape hatch. That is the whole contract a render backend implements.

Q1 — the backend never sees text at all

epaint::Shape has twelve variants — Noop, Vec, Circle, Ellipse, LineSegment, Path, Rect, Text, Mesh, QuadraticBezier, CubicBezier, Callback — and the one that matters here is Text.

It carries a Galley, not a string. A Galley is "[t]ext that has been laid out, ready for painting" (text/text_layout_types.rs) — already shaped, laid out and measured; the colour is the only thing left to substitute (TextShape::fallback_color replaces "[a]ny Color32::PLACEHOLDER in the galley", and override_text_color replaces glyph colour outright, shapes/text_shape.rs). Shapes are then tessellated, so what a render backend actually consumes is meshes and a texture id.

So egui does not merely keep measurement off the backend — it resolves text completely before the backend exists, and hands over triangles. A backend cannot measure text, because by the time it is involved there is no text.

That is the most emphatic of the subjects that put measurement somewhere other than the painter, and it leaves F1 with no closer dissent here. Friction §1 is the same decision taken the other way: Size measure(const(char)[]) is one of the five methods isCanvas requires, and its answer is denominated in cells — so SkiaCanvas.measure returns cellsOf(text), and the backend with real shaping, kerning and fallback is structurally forbidden from using any of it.

Q2 — no capability negotiation, because there is nothing to negotiate

Triangles and a texture are the floor of any GPU backend, so there is nothing for a backend to decline. egui pays for this by making the toolkit responsible for everything above triangles — shaping, tessellation, culling — which is precisely why it cannot target a terminal.

This is the honest cost of the geometry model and the reason it is not available to us: sparkles:ui's cell backend has no triangles.

Q3 — geometry, not widgets

Primitives rather than widgets. Nothing in Shape names a scrollbar, a text input or a shadow-as-intent; a Shadow exists as a helper that produces shapes, not as a variant a backend must interpret. egui therefore sits with Qt and against Slint on this axis, and further along than Qt: even the primitive vocabulary is gone by the time the seam is reached, since ClippedPrimitive carries a mesh.

On F4's axis — where the lowering lives — egui puts every lowering above the seam, in the producer. sparkles:ui splits the same axis: fillRect, textRun, glyph and line are primitives, but the optional scrollbar hands a backend content extent, viewport extent, offset, an edge and two fallback glyphs, precisely so a cell target can decide for itself what a scrollbar degrades to (friction §3). egui never faces that case, because it admits one target class and can therefore afford to decide everything once.

Q4 — a sum type

Unlike Slint and Qt, egui's paint vocabulary is reified as a sum type rather than dispatched through virtual methods. Shape is an enum whose payload varies per variant — the same encoding DrawOp uses, a SumType over eight per-kind payloads dispatched by match!, and the one sparkles.input.events already argues for.

Worth noting what this buys egui that inheritance does not: shapes are values, so they can be collected, transformed, culled and replayed. Those are exactly the properties RecordingCanvas and the op-stream parity harness are built on, which is the case F3 makes for reifying the stream at all and F12 makes for keeping the reified value inspectable rather than treating it as a golden. What egui leaves unsettled is what F3 leaves unsettled: a closed sum rules out illegal combinations and keeps values comparable, while how much each variant costs in storage is a separate question the sum does not answer.

Shape::Callback is the escape hatch: backend-specific painting, for the cases the vocabulary cannot express. A closed sum type with one explicit door — and the door is typed, since PaintCallbackInfo hands the callback its viewport, clip rect, pixels_per_point and screen size (paint_callback.rs).

Two encoding details are worth copying: Shape::Vec(Vec<Self>) makes the stream a tree where nesting helps and is documented as the slower path ("[f]or performance reasons it is better to avoid it"), and Shape::Mesh is Arc-wrapped explicitly "to minimize the size of Shape" — the variant's payload size is treated as a design parameter. DrawOp states the same parameter outright, as static assert(DrawOp.sizeof <= 64) with TextRun as the widest payload; egui meets it by moving the one wide payload behind a pointer, which is a second way of buying the same discipline.

Q5 — sub-unit placement

Continuous coordinates; no sub-unit problem. Shape's own documentation fixes the unit — "[c]oordinates are all screen space points (not physical pixels)" — and ClippedPrimitive repeats it ("[e]verything is using logical points"), so the device-pixel conversion is the backend's and the toolkit never names a position within a cell.

That is the relocation F6 describes, not a dissolution: what a hairline lands on still has to be decided, and egui decides it in the backend, where pixels_per_point lives. sparkles:ui decides it in the vocabulary instead — rule names an edge (RuleEdge.top, centerX, …) and each backend chooses what a band along that edge means, so SkiaCanvas draws one device pixel where GridCanvas fills a whole cell. Six predefined positions is the whole spelling, which is friction §5.

Q6 — resolved or semantic styling

Fully resolved — colours are concrete by tessellation time. The one deferred piece is deliberately narrow: Color32::PLACEHOLDER inside a Galley marks "colour not yet chosen", resolved at paint time by TextShape::fallback_color. That is a sentinel value inside an otherwise-resolved payload rather than a second, semantic representation carried alongside — one of the seven cheaper encodings F9 counts, none of which our seam takes. Six of the eight DrawOp payloads store a Slot beside the resolved fields their primitive paints from, so a pixel backend reads the resolved half while the HTML interpreter re-resolves from the role to emit class names, and every operation pays for both (friction §6). Reconstructing Visual on demand through visualOf keeps that hedge cheap without making it a decision; egui shows that for colour a sentinel can retire the second channel entirely.

Q7 — payload ownership

Shape::Mesh is wrapped in Arc, and a Galley is likewise shared (TextShape::galley: Arc<Galley>), so payloads are reference-counted rather than borrowed. A shape can outlive the frame that built it without the toolkit interning anything.

DrawOp answers the same question by borrowing. CmdBuffer.textRun copies the run into a frame arena and the operation holds a const(char)[] into it, under a rule stated on the type — an operation is valid while the buffer that built it is alive and unreset — which is what makes it enforceable rather than advisory. It is still a borrow: the operation cannot cross a thread and cannot be retained past the frame, and it needs the private launder cast to escape dip1000 at all. That is friction §7, and the retain boundary UI-O4 holds open. F8 counts eight ownership mechanisms across the survey and no subject that borrows a payload across a frame; egui's refcount is the cheapest of the eight to adopt.

The caveat egui states explicitly is a validity rule, not a lifetime one: a stored Shape::Text must be recreated when pixels_per_point changes or when the font atlas is rebuilt. Refcounting answers "may I keep this?"; it does not answer "is it still correct?", and the second question needs its own stated invalidation rule.

Q8 — extent query

ClippedPrimitive carries its own clip rect; extent falls out of the primitives, the way skia-canvas-render.d folds op.rect across a stream because nothing on CmdBuffer or the display list reports one — CmdBuffer exposes an operation count and a run's cell extent, and no more (friction §8). There is no "how big is the scene" query on the seam, because the frame is always painted into a surface whose size the host already set.

That is F7's split in miniature: the surface question is answered by the host, the ink question is derived by a scan, and neither is maintained at construction.

Strengths

  • The narrowest seam in the survey. One struct — clip rect plus mesh — so a new backend is a rasterizer and nothing else.
  • Commands are values. Collectable, transformable, cullable, PartialEq — every property a parity harness wants, with no recorder type.
  • Payload size is a stated design parameter. Arc<Mesh> exists explicitly to keep the enum small.
  • Refcounted payloads remove the "must outlive the frame" question entirely.
  • One typed escape hatch. Shape::Callback makes "the vocabulary cannot express this" a visible, contained case with a documented context struct.
  • The deferred-colour trick (Color32::PLACEHOLDER) gets late theming without a parallel semantic channel.

Weaknesses

  • Structurally single-target. Everything above triangles lives in the toolkit, so a device that cannot rasterize a mesh cannot be a backend at all.
  • All fidelity decisions are made before the backend sees anything — a target with different capabilities cannot do better, only differently.
  • Refcounting is not validity. A retained Shape::Text silently goes stale on a dpi change or atlas rebuild; the rule is prose in a doc comment.
  • Shape::Vec makes the stream a tree whose flattening cost is real enough that the docs discourage it.
  • Shape::Callback is unportable by construction — a frame that uses one is not backend-neutral.
  • Nothing states which backend behaviours are required, because the question is assumed away rather than answered.

Key design decisions and trade-offs

DecisionRationaleTrade-off
Resolve text to a Galley before it can be paintedShaping and layout are the toolkit's problem exactly once, and the result is cacheable and sharedThe backend cannot improve on the toolkit's shaping, and the galley goes stale on dpi/atlas changes
Tessellate inside the toolkit; the seam carries meshesThe narrowest possible backend contract; identical output across wgpu, glow and WebGLExcludes every non-triangle target — a cell grid can never be a backend
A 12-variant enum, not virtual dispatchShapes are values: collect, transform, cull, compareThe vocabulary is closed; a new primitive is a breaking change to the enum
Arc<Mesh> / Arc<Galley> rather than borrowed slicesNo lifetime obligation; a payload may outlive its frameRefcount traffic per frame, and sharing hides staleness
One escape hatch (Shape::Callback) with a typed contextBackend-specific painting stays possible without widening the vocabularyA frame containing one is not portable, and parity testing cannot cover it
Logical points at the seam, device pixels in the backendLayout stays resolution-independent; the backend owns pixels_per_pointAnything pixel-exact (hairlines, snapping) is not expressible at the seam
Late colour via Color32::PLACEHOLDERTheming without carrying a second, semantic style channelA magic sentinel inside the payload; nothing type-checks that it was substituted
No capability negotiation at allTriangles are the floor of every target egui admitsThe model gives no guidance whatsoever for a survey subject like ours that spans unlike devices

Bearing on the proposal

  1. A sum type is the right encoding for a reified command — egui is a second subject standing where DrawOp stands, and it shows the reification itself is worth keeping, which RecordingCanvas and the parity harness depend on (F3, F12).
  2. Reference-counted payloads are a lighter answer to §7 than a frame arena: share the payload, and the retain question stops being a question without a lifetime rule the type system cannot express.
  3. One explicit escape hatch (Callback) is a visible, contained case where "the vocabulary cannot express this" is otherwise a silence. Our four optional primitives — rule, scrollbar, pushClip, popClip — each name a degradation, but they are discovered by __traits(compiles) at the interpreter's call sites rather than declared anywhere a backend author can read (friction §2). F5 says the declaration is cheap.
  4. The geometry model itself is not available to us, and the reason is worth recording: it moves all fidelity decisions above the seam, which a terminal backend cannot honour.
  5. A shared payload needs a stated invalidation rule, not just a refcount. If sparkles:ui takes shared text or image payloads for §7, it must also say what makes one stale — egui names dpi changes and atlas rebuilds, and that rule lives in prose, which is where ours would rot. F11 is the counterweight: state the contract on the type, the way the buffer-alive-and-unreset rule is stated, and derive the rest.

Sources

Every path verified to resolve at 97603fc082aa4eecfeb7feccbcb6c2507dffaf28 over raw.githubusercontent.com.