Rendering Reference¶
Pulp's rendering pipeline is GPU-accelerated via Dawn (WebGPU) and Skia Graphite. This page covers the rendering infrastructure added for visual parity with purpose-built GPU UI frameworks.
HDR Float Color¶
Color uses 4x float channels (0.0–1.0, >1.0 for HDR). Supports multiple color spaces:
auto c = Color::rgba(0.5f, 0.8f, 1.0f); // sRGB float
auto c = Color::rgba8(128, 200, 255); // from uint8
auto c = Color::hex(0x3B82F6); // from hex
auto hsv = c.to_hsv(); // Hue-Saturation-Value
auto hsl = c.to_hsl(); // Hue-Saturation-Lightness
auto lch = c.to_oklch(); // OKLCH (CSS Color Level 4)
auto hdr = c.with_hdr_intensity(2.0f); // HDR overbright
auto mid = a.interpolate(b, 0.5f); // Smooth blending
SDF Shape Primitives¶
14 GPU-accelerated shapes via SkSL shaders with pixel-perfect anti-aliasing:
rect, circle, rounded_rect, arc, diamond, squircle, triangle, ring, stadium, cross, flat_segment, rounded_segment, flat_arc, quadratic_bezier
Canvas::SDFStyle style;
style.fill_color = Color::rgba(0.2f, 0.6f, 1.0f);
style.corner_radius = 8.0f;
canvas.draw_sdf_shape(Canvas::SDFShape::squircle, x, y, w, h, style);
Retained Paths — pulp::canvas::Path¶
Path is a geometry value. Before it, a shape could only exist during a
paint, because it was built by calling the canvas directly — so it could not be
measured before it was drawn, transformed, hit-tested, cached, or handed to
anything else.
#include <pulp/canvas/path.hpp>
pulp::canvas::Path icon;
icon.move_to(0, 0).line_to(10, 0).cubic_to(12, 4, 12, 8, 10, 10).close();
icon.scale_to_fit(0, 0, 64, 64, /*preserve_proportions=*/true);
if (icon.contains(mouse)) { … } // hit-test, no canvas needed
canvas.fill_path(icon, FillRule::nonzero); // draw it
Copy is O(1) (copy-on-write) with real value semantics: assigning a Path is not
sharing one.
Three details that are easy to get silently wrong, so the API makes them explicit:
bounds()is TIGHT — computed from each curve's extrema, not its control hull. The two differ for nearly every real curve, because a cubic's control points usually fall outside the curve they steer. Usecontrol_bounds()when you want the cheap conservative hull instead (a repaint rect, where over-estimating costs a few redrawn pixels and under-estimating is an artifact).contains(p, rule)takes aFillRule.nonzeroandevenoddgenuinely disagree about a region enclosed twice, and that disagreement is the entire reason the rule is a parameter rather than a constant.Canvas::fill_pathtakes one too.scale_to_fit()declines on a degenerate path. Scaling a zero-width path (a vertical line, a single point) to a non-zero width is a division by zero, and the "result" is a path whose every coordinate is NaN — which renders as nothing at all, from anywhere, forever. It returns identity instead, leaving the path unchanged. Withpreserve_proportions, the slack on whichever axis did not bind is centred, not dumped at one edge.
Supporting types: Point2D, AffineTransform (pulp/canvas/affine_transform.hpp),
FillRule, StrokeStyle.
pulp::view::Rect gained encloses(other) for whole-rect containment. It is
deliberately not an overload of contains(): Rect has default member
initializers, so a braced {50, 30} is a valid two-field Rect as well as a valid
Point — and a contains(const Rect&) overload therefore makes the idiomatic
r.contains({50, 30}) ambiguous at every call site. contains(Point) and
contains(x, y) are unchanged.
Point2Dis named that, and notPoint, on purpose: Apple'sMacTypes.hdeclares a global CarbonPoint(andRect), and apulp::canvas::Pointbecomes ambiguous with it in every Objective-C++ translation unit in the view layer. Don't rename it back.
Compositing Layers¶
Proper CSS opacity and filter:blur() via save_layer():
// Subtree paints into offscreen buffer, composited as single unit
canvas.save_layer(x, y, w, h, opacity, blur_radius);
// ... draw subtree ...
canvas.restore();
Works on both Skia (SkCanvas::saveLayer) and CoreGraphics (CGContextBeginTransparencyLayer).
Cacheable layers — begin_layer() / end_layer()¶
save_layer() is a scope: the only way back into it is to re-run the drawing
that filled it, which defeats the point of caching it. begin_layer() /
end_layer() return a LayerHandle you can keep across frames and
re-composite without redrawing its contents. Query
canvas.supports(CanvasCapability::retained_layer_cache) before selecting
this path on an arbitrary backend. Always call layer_valid() as shown:
explicit invalidation, a backing-scale change, or GPU context loss can retire
the cached texture.
if (!canvas.layer_valid(cached_)) {
canvas.begin_layer(bounds, /*cacheable=*/true);
paint_expensive_subtree(canvas);
cached_ = canvas.end_layer(); // seals it; keep the handle
}
canvas.draw_layer(cached_); // later frames: composite, don't redraw
draw_layer(handle, alpha = 1.0f, mode = BlendMode::normal) composites it;
draw_layer_fitted(handle, dest) scales it into a destination rect.
Use it for a subtree whose pixels are expensive and rarely change. save_layer()
is unchanged and remains correct for the one-shot opacity/blur case.
Effect, Mask, And Composite Vocabulary¶
Pulp keeps three rendering concepts separate in importer output, view APIs, and paint backends:
- Effects modify pixels produced by a view or by content behind it. CSS
filter,filter: blur(...), andbackdrop-filterlive here. - Masks and clips constrain where pixels are visible. CSS
clip-path,mask,mask-image, andmask-sizelive here. Some mask values may be stored before every backend can paint them. - Composition controls how a finished subtree is blended back into its
parent. CSS/RN
mix-blend-mode, CanvasglobalCompositeOperation, subtree opacity, and layer restore behavior live here.
This split matters for design import and renderer work. A blur should not be
modeled as a mask, a mask should not be treated as a blend mode, and
mix-blend-mode should remain a layer-composition decision rather than a
per-draw color transform. When adding CSS or design-tool coverage, first decide
which bucket the feature belongs to, then wire it through the matching view
state and backend path.
Post-Processing Effects¶
view.set_effect(std::make_shared<GpuBlurEffect>()); // Gaussian blur
view.set_effect(std::make_shared<GpuBloomEffect>()); // Glow approximation
view.set_effect(std::make_shared<EffectChain>()); // Compose multiple
An effect pushes layer_count() compositing layers (one for a simple
effect; EffectChain pushes one per child) and View::paint_all pops
exactly that many.
GpuBloomEffect is a glow approximation, not a true bloom: it blurs the
subtree uniformly by radius * intensity and composites it back normally.
There is no bright-pass and no additive composite, so nothing gets brighter
and dark pixels smear as much as light ones. It is not HDR-aware either —
every Pulp surface is 8-bit unorm + sRGB, so there is no headroom above 1.0
to threshold against. Use it for soft glow on light-on-dark content.
Arbitrary SkSL as a view post-effect is not supported — filtering a subtree's rendered pixels needs a child-shader compositor Pulp does not have. SkSL reaches widgets as a body shader instead; see the Shader Reference.
DirtyTracker¶
Partial repaint optimization — only repaints changed regions:
DirtyTracker tracker;
tracker.set_viewport(width, height, 0.6f); // Full repaint if >60% dirty
tracker.invalidate(x, y, w, h); // Mark region dirty
if (tracker.needs_full_repaint()) { /* repaint all */ }
else { for (auto& rect : tracker.dirty_rects()) { /* repaint rect */ } }
tracker.clear(); // After frame submit
Draw-call batching¶
Pulp does not interpose an extra batcher between Canvas calls and the
GPU. Skia (raster and Graphite) already coalesces compatible draw calls
inside the active SkCanvas / Recorder — adding a Pulp-level batcher
on top would compete with, not improve on, Skia's own analysis.
If you need to measure effective batching for a frame, the right place
to hook in is the Skia recorder / GPU stats once the
pulp-inspect HUD wiring lands (tracked separately); raw
SkCanvas does not expose per-frame batch counts.
Atlas Systems¶
- ImageAtlas: Packs small images into shared GPU texture with ref counting
- GradientAtlas: Caches evaluated gradient ramps by hash
- GlyphAtlas: Per-font-size glyph cache (supplements Skia's internal cache)
- PathAtlas: Caches rasterized vector paths
- BufferPool\<T>: Reuses std::vector allocations in hot paths
GPU Visualization¶
Waveform and spectrum views use GPU shaders for anti-aliased rendering:
Canvas::WaveformStyle style;
style.line_color = wave_color;
style.fill_color = fill_color;
style.line_thickness = 2.0f;
canvas.draw_waveform(samples, count, x, y, w, h, style);
GPU-Assisted Audio Analysis¶
GPU-assisted audio work is an offline/background analysis capability, not a
live audio-thread DSP primitive. Any path that wants a GPU backend for offline
render analysis should first call
audio::evaluate_offline_render_compute_policy().
That policy helper accepts GPU work only from OfflineAnalysis or
BackgroundAnalysis scopes. RealtimeAudioThread requests are rejected even
when a GPU is available, because GPU submission, queue progress, resource
mapping, and fallback behavior are not bounded enough for the audio callback.
When the GPU is unavailable, callers must either take the explicit CPU fallback
decision or fail the offline analysis request; silent live fallback is not part
of the contract.
For a fixed policy input, the decision is deterministic: repeated offline or
background analysis evaluations return the same accepted/backend/fallback/reason
tuple and do not inspect live render state.
GPU Render Time¶
Pulp can report true GPU-side render time alongside the CPU wall-time the inspector has always shown. It is exposed on the Skia surface:
auto* skia = /* pulp::render::SkiaSurface* */;
if (skia->gpu_render_timing_available()) {
double ms = skia->gpu_render_time_ms(); // 0 until the first sample lands
}
This is whole-recording GPU render time, not per-pass attribution. That distinction is deliberate and load-bearing:
- The number comes from Skia Graphite's own GPU-stats API
(
InsertRecordingInfo::fGpuStatsFlags = kElapsedTime), which measures the GPU elapsed time of the recording Pulp submits each frame — not Pulp's logical render passes (background / content / effects / overlay / post). Skia Graphite owns the Dawn command encoder and every render-pass descriptor, so Pulp cannot inject per-passtimestampWrites. True per-pass GPU attribution would require Skia to expose that granularity (or Pulp to own more of the render graph). - WebGPU timestamp queries can measure command timing, but the API in use here surfaces recording-level elapsed time, not detailed per-pass attribution. See the WebGPU/Chromium discussion of this exact distinction: https://groups.google.com/a/chromium.org/g/blink-dev/c/dtYJ0MQYMlU.
- On the Metal backend (Apple platforms) Skia disables Dawn command-buffer
timestamps and falls back to render/compute-pass timestamp writes, so the
measurement spans first-pass-begin → last-pass-end of the recording and
excludes non-pass work (texture uploads/copies) and
Present().
Availability and honesty rules:
- Requires the Dawn
timestamp-queryfeature. Pulp requests it only when the adapter advertises it; otherwise GPU render time is reported unavailable. gpu_render_timing_available()reflects device/feature support; an unsupported platform reports unavailable rather than a fake0.- A failed sample or a zero elapsed time is treated as "no sample" — the last
good value is retained, never overwritten with a misleading
0.
Design rationale and the per-pass feasibility analysis live in
planning/2026-05-21-gpu-timestamp-readback-proposal.md.
Gradients¶
// Conic (sweep) gradient — CSS conic-gradient equivalent
canvas.set_fill_gradient_conic(cx, cy, start_angle, colors, positions, count);
// FillStyle unifies solid, linear, radial, conic
FillStyle fill(ConicGradient{cx, cy, 0, stops});
fill.set_tile_mode(GradientTileMode::repeat);
SpriteStrip Animation¶
Designer-created filmstrip knob/fader skins:
auto strip = std::make_shared<SpriteStrip>();
strip->load(data, size, width, height, frame_count);
knob.set_sprite_strip(strip); // Value selects frame
Viewport-Relative Dimensions¶
auto d = Dimension::parse("50vw");
float px = d.resolve(parent_size, viewport_w, viewport_h, dpi_scale);
// Units: px, %, vw, vh, vmin, vmax, auto
ThemeEditor¶
Live theme editing widget:
ThemeEditor editor;
editor.set_theme(Theme::dark());
editor.on_color_changed = [](const std::string& token, Color c) { /* update */ };
auto json = editor.export_json();
Global Undo¶
EditHistory history;
history.perform([&]{ value = 42; }, [&]{ value = 0; }, "set value");
history.undo(); // value = 0
history.redo(); // value = 42
// Coalescing: rapid changes with same description merge automatically
Integrates with parameter Bindings:
binding.set_edit_history(&history);
binding.begin_gesture(); // Captures start value
// ... user drags knob ...
binding.end_gesture(); // Pushes undo action
Platform Features¶
window->set_mouse_relative_mode(true); // Infinite knob drag
window->set_client_decoration(true); // Custom title bar
window->set_fixed_aspect_ratio(16.0f/9); // Constrained resize
window->set_always_on_top(true); // Floating window
float dpi = window->dpi_scale(); // HiDPI
auto monitors = window->get_monitors(); // Multi-monitor enumeration
PBR / 3D Pipeline¶
Compute pipeline for Three.js PBR materials:
- Compute dispatch: JS→C++ bridge creates Dawn compute pipelines and dispatches workgroups
- Storage buffers: Bind group serialization with GPU buffer creation
- Cube textures: 6-face textures with mip levels for environment maps
- DRACO: Native C++ mesh decoder (Apache 2.0, optional via
PULP_ENABLE_DRACO) - KTX2: Texture header parser and native-gap classifier; Basis Universal payload transcoding remains deferred
- Binary transfer: Native buffer registration for zero-copy GPU upload
Asset Embedding¶
Bundled fonts: Inter Regular, JetBrains Mono Regular (SIL OFL 1.1). The exact versions, hashes, and fallback order are tracked in Text Shaping Determinism.