Lume — the rendering engine

Vel's from-scratch GPU 2D renderer on Dawn/WebGPU.

Lume

Lume is the GPU 2D rendering engine that powers every pixel Vel draws. It lives in engine/ of the Vel repo and depends on no framework types — it is a self-contained 2D renderer that the framework happens to use.

The full story of why I built it and what it does differently from Skia is in the blog post From Skia to Lume. This page is the architecture reference.

Architecture

Four layers, each depending only on the one below:

L1  platform/    → CAMetalLayer attach (macOS). Future: ANativeWindow, HWND, canvas.
L2  gpu::Device  → Dawn instance + adapter + device + queue (singleton).
L2  gpu::Surface → wgpu::Surface bound to the window's native layer.
L3  paint/       → DawnPainterImpl: four WGSL pipelines, glyph atlas,
                   per-instance state for shape/line/text/image, submission-
                   order draw segments.
L4  Painter API  → public surface: fill, roundedFill, stroke, polyline,
                   arc, image, text, pushClip, pushTransform, and so on.

engine/include/vel/ is the public surface (Painter, Font, Image, FontManager, Types). engine/src/ is the implementation, about 3,000 lines.

The four pipelines

Lume’s pipeline count is intentionally small. Impeller has about a dozen. Skia has hundreds. Lume has four:

  1. Shape — analytic SDF rounded-rect. fill, roundedFill, stroke, circles, lines, shadowRect all collapse to this.
  2. Line — per-segment rotated quad with butt caps for polylines.
  3. Text — textured quad sampling an R8 glyph atlas.
  4. Image — textured quad sampling RGBA8 with corner-radius mask.

Every shape in the Vel showcase is one of those four. A roundedFill is the shape pipeline with strokeWidth=0, radius=R. A shadowRect is the same pipeline with blur>0, which switches the fragment shader to a smoothstep falloff instead of the AA clamp. A circleStroke is a shape with radius=w/2. Instance attributes do the heavy lifting; the GPU just rasterizes.

Three details worth knowing

Glyph atlas keyed on physical pixel size

When you ask for 14 px text on a 2× DPR display, FreeType rasterizes at 28 px. Lume’s atlas cache key includes that physical size, so a window dragged to a 1× external monitor doesn’t render upsampled-blurry text — it rasterizes a second 14 px entry and uses that. The destination rect stays in logical pixels; the GPU samples the physical atlas 1:1.

Submission-order draw segments

Originally Lume batched all shapes, then all lines, then all text per frame. This broke the Table widget’s sticky header — the header background was drawn before the row text, so row text overdrew the header, and rows became visible through the header during scroll. The fix was to track a small DrawCmd list ({kind, firstInstance, count}) in submission order and emit one draw call per segment. Same-kind cmds fuse. Z-order is preserved for free.

Drag capture that survives reactive rebuilds

This one lives in the framework, not Lume, but it’s worth knowing because it shows how the two layers cooperate. Slider drag used to die mid-gesture because the slider widget instance was being replaced by a signal-driven rebuild. Fix: EventDispatcher::captureDrag(handler) registers a callable that closes over the slider’s geometry plus the long-lived owning component’s this. Mouse-move and mouse-up route to the captured handler directly, bypassing the widget tree. Drag continues across any number of rebuilds.

Skia / Impeller / Lume comparison

Skia (Vel v1)Impeller (Flutter)Lume (today)
Shader compilationJIT, at first-draw timeAOT, build-timeWGSL precompiled by Dawn at device init
Shape renderingCPU tessellation → GPU trianglesCompute + tessellation hybridAnalytic SDF in the fragment shader
Pipeline counthundreds~124
TextCoreText / FreeType per platformManual rasterizer → MTLTexture atlasFreeType → R8 atlas, OS/2 typo metrics
Idle frame costAlways paintsAlways paints~0
Cross-platform reachGL/Vulkan/Metal/D3D11Metal + VulkanDawn handles Metal/Vulkan/D3D12/WebGPU
libvel.dylib size~30 MBn/a11 MB

What Lume doesn’t do yet

  • Compute-shader Gaussian blur — current shadow is a smoothstep falloff, perceptually identical to a Gaussian for radii under 16 px.
  • Complex-script text shaping — HarfBuzz is linked, not driven yet. Latin, Cyrillic, Greek render correctly; Arabic, Devanagari, vertical text are next.
  • Non-macOS platform surfaces — Dawn supports Vulkan and D3D12, so the underlying portability is real. The Windows and Linux window-to-surface glue isn’t written yet.

Roadmap: native arcs and dashed strokes via additional pipelines, then HarfBuzz, then compute blur, then Web via Dawn + Emscripten, then Windows and Linux surface layers, then partial-repaint damage rects.