Custom Rendering¶
Pulp provides four layers of custom rendering, from re-skinning the stock widgets to full GPU shader access. Choose the layer that matches your needs.
When to Use Each Layer¶
| Layer | API | Language | GPU-Accelerated | Best For |
|---|---|---|---|---|
| A — Paint / metrics delegates | WidgetPainter, WidgetMetrics |
C++ | Yes (via Skia) | Re-skinning the stock widgets without subclassing them |
| B — CanvasWidget | JS bridge canvas functions | JavaScript | Yes (via Skia) | Custom meters, visualizations, simple graphics |
| C — Canvas API | Canvas abstract interface |
C++ | Yes (via Skia) | Custom View subclasses, complex procedural drawing |
| C+ — Dawn/WebGPU | WebGPU render pipeline | C++ / WGSL | Direct GPU | Shader-driven visuals, particle systems, spectrograms |
Start with Layer A if the widget you want already exists and you only want it to look different. Move to Layer B for a bespoke visual. Move to Layer C when you need C++ performance or complex state, and to C+ when you need custom shaders.
Layer A: skin the stock widgets — WidgetPainter / WidgetMetrics¶
Two independent delegates let you restyle and re-size Pulp's stock widgets without subclassing any of them:
| Delegate | Answers | Installed with |
|---|---|---|
WidgetPainter (pulp/view/widget_painter.hpp) |
what pixels? | View::set_painter(...) |
WidgetMetrics (pulp/view/widget_metrics.hpp) |
how big? | View::set_metrics(...) |
They are deliberately separate objects, because they run at different times. Metrics is consulted during layout — with no canvas, possibly many times per frame. Painting happens once, afterwards. Fusing them would drag every skin that only wants to restyle into the layout pass.
struct MySkin : pulp::view::WidgetPainter {
bool paint_rotary(pulp::canvas::Canvas& c,
const pulp::view::RotaryPaintState& s,
pulp::view::View& v) override {
// s.position is 0..1
draw_my_knob(c, s.bounds, s.position, s.hovered, s.enabled);
return true; // claimed — the stock look does NOT also draw
}
// every other hook is left alone, and keeps the stock look
};
panel.set_painter(std::make_shared<MySkin>()); // applies to the whole subtree
Three properties make this usable, and all three are load-bearing:
- It cascades. A delegate installed on a view applies to that view and its whole subtree, so you can skin a panel in one call. A descendant that installs its own wins for its own subtree; clearing falls back to the nearest ancestor.
- Every hook declines by default. Each returns
false— "no opinion, use the stock look" — so a delegate that restyles one knob is never conscripted into reimplementing the entire widget set. Returntrueto claim a hook, and the widget will not also draw over you. - The units are the ones each shape actually reasons in, and mixing them up renders plausibly while being wrong at every value:
- a rotary hook gets a normalized
position(0..1) — it reasons in sweep angle; - a linear / scroll-bar hook gets
thumb_posandthumb_sizein pixels, plus the value and its range in your own units.
WidgetMetrics works identically, and feeds the same intrinsic_width() /
intrinsic_height() the layout engine already calls — there is no second measure
protocol. A widget that positions itself rather than being laid out (a
ContextMenu) asks the delegate directly, and ContextMenu::layout() will compute
its whole geometry with no canvas and no paint, so you can ask a menu how big it
wants to be before it is ever shown.
SliderCore — the value engine¶
If you are building a custom continuous control and want Pulp's exact behaviour,
use pulp::view::SliderCore (pulp/view/slider_core.hpp) rather than
reimplementing it: range, quantization interval, skew (including
set_skew_from_midpoint), the absolute drag law, fine-drag, default/reset, and
edge-triggered gesture bracketing.
Its writes take a notification mode:
core.set_value(v, Notify::none); // change the value, tell nobody
core.set_value(v, Notify::sync); // fire on_value_change now (default)
core.set_value(v, Notify::async); // defer to flush_async_notifications()
Notify::none is what stops a programmatic sync from echoing a change back at
whatever just made it. Gesture begin/end is edge-triggered, so a widget that
funnels two event sources into one handler still records one host edit, not two.
Layer B: CanvasWidget from JS¶
Create a CanvasWidget and issue draw commands from JavaScript. The widget queues commands that are executed on the render thread by the Skia backend.
Setup¶
const canvas = createCanvas("viz", "root");
setFlex("viz", "width", 300);
setFlex("viz", "height", 200);
Basic Shapes¶
// Clear previous frame
canvasClear("viz");
// Filled rectangle
canvasRect("viz", 10, 10, 100, 50, "#2a2a4a");
// Stroked rectangle
canvasStrokeRect("viz", 10, 10, 100, 50, "#6666aa", 2);
// Filled circle
canvasFillCircle("viz", 80, 120, 30, "#44ccff");
// Line
canvasStrokeLine("viz", 0, 100, 300, 100, "#333333", 1);
// Text
canvasSetFont("viz", "Inter", 14);
canvasFillText("viz", "Hello", 10, 180, 14, "#ffffff");
Paths¶
// Bezier curve
canvasBeginPath("viz");
canvasMoveTo("viz", 10, 150);
canvasCubicTo("viz", 60, 50, 150, 200, 290, 80);
canvasStrokePath("viz");
// Filled polygon
canvasSetFillColor("viz", "#ff6b6b");
canvasBeginPath("viz");
canvasMoveTo("viz", 150, 10);
canvasLineTo("viz", 180, 60);
canvasLineTo("viz", 120, 60);
canvasClosePath("viz");
canvasFillPath("viz");
State Management¶
canvasSave("viz"); // Push state (color, transform, clip)
canvasTranslate("viz", 50, 50);
canvasRotate("viz", 0.3); // Radians
canvasRect("viz", -20, -20, 40, 40, "#44ff44");
canvasRestore("viz"); // Pop state — transform reset
Example: Custom Level Meter¶
function drawMeter(peak, rms) {
canvasClear("viz");
const w = 300, h = 200;
// Background
canvasRect("viz", 0, 0, w, h, "#0a0a12");
// RMS bar
const rmsH = rms * h;
canvasRect("viz", 20, h - rmsH, 60, rmsH, "#225533");
// Peak bar
const peakH = peak * h;
const color = peak > 0.9 ? "#ff4444" : peak > 0.7 ? "#ffaa00" : "#44ff44";
canvasRect("viz", 20, h - peakH, 60, peakH, color);
// Peak hold line
canvasStrokeLine("viz", 15, h - peakH, 85, h - peakH, "#ffffff", 2);
// dB grid lines
[-6, -12, -24, -48].forEach((db) => {
const y = h - Math.pow(10, db / 20) * h;
canvasStrokeLine("viz", 0, y, w, y, "#222222", 1);
canvasFillText("viz", db + " dB", 100, y + 4, 10, "#555555");
});
}
Making a canvas control interactive¶
A canvas is hit-testable and receives pointer events with no opt-in — subscribing
with on(id, "pointerdown"| "pointermove"| "pointerup", fn) is all the wiring it
needs. The canvas owns its own value: bindWidgetToParam drives the stock
value widgets (knob, fader, slider, toggle, progress) and has nothing to write on
a canvas, so a custom-drawn control reads with getParam and writes with
setParam from its own handlers.
The gesture shape is the usual one — latch the origin on press, apply the delta on move:
const knob = createCanvas("drive-knob", "root");
const st = { val: getParam("drive"), dragging: false, y0: 0, v0: 0 };
on(knob, "pointerdown", (e) => {
st.dragging = true;
st.y0 = e.clientY; // where the gesture started
st.v0 = st.val; // and the value it started from
});
on(knob, "pointermove", (e) => {
if (!st.dragging) return;
// 150px of vertical travel spans the full range.
st.val = Math.max(0, Math.min(1, st.v0 + (st.y0 - e.clientY) / 150));
setParam("drive", st.val);
drawKnob(st.val);
});
on(knob, "pointerup", () => { st.dragging = false; });
pointerdown and pointerup are the gesture edges — they fire once each, so
latching on pointerdown is safe. Every sample in between arrives as
pointermove, whether or not a button is held.
Headless tests drive this with View::simulate_drag, which delivers through the
same routing the platform hosts use.
Layer C: Canvas API in C++¶
Create a custom View subclass and override paint() to draw directly to a Canvas. This is the same API that all built-in widgets use internally.
Custom View¶
#include <pulp/view/view.hpp>
#include <pulp/canvas/canvas.hpp>
class SpectrumDisplay : public pulp::view::View {
public:
void set_data(const std::vector<float>& magnitudes) {
magnitudes_ = magnitudes;
set_needs_repaint();
}
void paint(pulp::canvas::Canvas& canvas) override {
auto [w, h] = bounds().size();
// Background
canvas.set_fill_color(resolve_color("surface", Color::hex(0x1a1a2e)));
canvas.fill_rect(0, 0, w, h);
if (magnitudes_.empty()) return;
// Draw spectrum as filled path
canvas.set_fill_color(Color::rgba(88, 166, 255, 80));
canvas.begin_path();
canvas.move_to(0, h);
float bar_width = w / static_cast<float>(magnitudes_.size());
for (size_t i = 0; i < magnitudes_.size(); ++i) {
float x = i * bar_width;
float y = h - magnitudes_[i] * h;
canvas.line_to(x, y);
}
canvas.line_to(w, h);
canvas.close_path();
canvas.fill_current_path();
// Stroke the top edge
canvas.set_stroke_color(Color::rgba(88, 166, 255, 200));
canvas.set_line_width(1.5f);
canvas.begin_path();
for (size_t i = 0; i < magnitudes_.size(); ++i) {
float x = i * bar_width;
float y = h - magnitudes_[i] * h;
if (i == 0) canvas.move_to(x, y);
else canvas.line_to(x, y);
}
canvas.stroke_current_path();
}
private:
std::vector<float> magnitudes_;
};
Canvas API Highlights¶
// Gradients
canvas.set_fill_gradient_linear(0, 0, 0, h,
{Color::hex(0x58a6ff), Color::hex(0x0f0f1a)}, // colors
{0.0f, 1.0f}, // positions
2); // count
// Rounded rectangles
canvas.fill_rounded_rect(x, y, w, h, 8.0f);
// Arcs
canvas.stroke_arc(cx, cy, radius, start_angle, end_angle);
// Blend modes
canvas.set_blend_mode(BlendMode::screen);
// Opacity
canvas.set_opacity(0.5f);
// Text metrics
auto metrics = canvas.measure_text_full("Hello");
// metrics.width, metrics.ascent, metrics.descent, metrics.line_height
// SDF shapes (GPU-accelerated signed distance field rendering)
canvas.draw_sdf_shape(SDFShape::rounded_rect, x, y, w, h, {
.fill_color = Color::hex(0x1a1a2e),
.stroke_color = Color::hex(0x333333),
.stroke_width = 1.0f,
.corner_radius = 8.0f,
});
// Backdrop blur (frosted glass effect)
canvas.draw_blurred_backdrop(x, y, w, h,
12.0f, // blur radius
8.0f, // corner radius
Color::rgba(0, 0, 0, 80)); // tint color
// GPU-accelerated waveform
canvas.draw_waveform(samples, count, x, y, w, h, {
.line_color = Color::hex(0x58a6ff),
.fill_color = Color::rgba(88, 166, 255, 40),
.line_thickness = 1.5f,
.show_fill = true,
.fill_center = 0.5f,
});
Testing with RecordingCanvas¶
Verify draw commands without a GPU:
#include <pulp/canvas/recording_canvas.hpp>
TEST_CASE("SpectrumDisplay draws correctly") {
SpectrumDisplay display;
display.set_bounds({0, 0, 300, 200});
display.set_data({0.5f, 0.8f, 0.3f, 0.6f});
pulp::canvas::RecordingCanvas canvas;
display.paint(canvas);
// Verify draw commands were issued
REQUIRE(canvas.command_count() > 0);
REQUIRE(canvas.count(RecordingCanvas::Type::fill_rect) >= 1);
REQUIRE(canvas.count(RecordingCanvas::Type::fill_current_path) >= 1);
}
Layer C+: Custom Shaders and GPU Compute¶
When the Canvas primitives aren't enough — a per-pixel effect, a spectrogram,
or an offline GPU DSP kernel — Pulp exposes three escalating drop-down paths.
There is no RenderContext façade; you reach for the real interface that fits
the job.
Custom fragment shaders on the canvas¶
The supported "custom shader" path is Canvas::draw_with_sksl
(core/canvas/include/pulp/canvas/canvas.hpp). It fills a rectangle with a
Skia runtime effect (SkSL, fragment-only) and passes the standard
Canvas::ShaderUniforms (value, time, and up to five named theme colors).
GPU backends (SkiaCanvas) render it; CPU backends draw a fallback rect.
void paint(pulp::canvas::Canvas& canvas) override {
const auto b = bounds();
pulp::canvas::Canvas::ShaderUniforms u;
u.value = value_; // widget value, 0..1
u.time = animation_time_; // seconds (FrameClock-fed)
u.accent_color = pulp::canvas::Color::rgba(0.2f, 0.6f, 1.0f, 1.0f);
// Fills the rect with the SkSL effect. Returns false and draws a
// flat-color fallback rect on non-GPU canvases.
canvas.draw_with_sksl(kFragmentSksl, b.x, b.y, b.width, b.height, u);
}
Validate a shader without drawing it via the static
Canvas::compile_sksl(source) (returns an error string; empty on success).
The shader receives uniform float2 resolution, float value, float time,
and layout(color) float4 accentColor/bgColor/trackColor/fillColor/thumbColor.
See docs/reference/shaders.md for the full uniform
contract and the JS-side compileShader / setWidgetShader API.
GPU compute kernels¶
For non-rendering GPU work — spectral analysis, batch convolution, FFT,
matmul, neural inference — use pulp::render::GpuCompute
(core/render/include/pulp/render/gpu_compute.hpp). It runs WebGPU compute
(not fragment shaders) on a Dawn device that can be shared with the render
surface, and is explicitly not for the audio callback (upload/readback
latency is too high — do it on a background/offline path).
auto gpu = pulp::render::GpuCompute::create();
if (gpu && gpu->initialize_standalone()) {
gpu->batch_magnitude(complex_frames, magnitude_frames, num_frames, num_bins);
}
Read the header for the full primitive surface before calling — don't wrap it in a convenience layer that doesn't exist.
Raw Dawn / WebGPU¶
The lowest escape hatch is pulp::render::GpuSurface
(core/render/include/pulp/render/gpu_surface.hpp), which owns the Dawn
instance, adapter, device, queue, and native surface. It exposes the device
through opaque handles (dawn_device_handle(), dawn_queue_handle(),
dawn_instance_handle(), current_texture_handle()) that you cast to the
Dawn types yourself when you need native device access for a custom render
pass. This is a deliberate escape hatch — most UIs never touch it.
Passing audio data to visuals¶
Move data from the audio thread to the UI thread with the real meter bridge,
pulp::view::AudioBridge
(core/view/include/pulp/view/audio_bridge.hpp) — a lock-free triple buffer:
// Audio thread, in process():
bridge.analyze_and_push(channels, num_channels, num_samples); // peak/RMS
// or push a pre-computed MeterData with bridge.push_meter(data);
// UI thread, in paint():
pulp::view::MeterData meter;
if (bridge.pop_latest_meter(meter)) {
// meter.peak[ch] / meter.rms[ch] are per-channel linear levels ...
}
For a full magnitude spectrum, feed a SpectrogramView
(core/view/include/pulp/view/widgets.hpp) directly with
push_spectrum(const float* magnitudes_db, int num_bins) from the UI thread;
the widget owns the scroll/paint of the spectrogram.
Performance Guidelines¶
| Technique | When to Use | Overhead |
|---|---|---|
canvasRect / canvasFillCircle |
Simple shapes, few per frame | Very low |
| Canvas paths | Complex shapes, curves | Low |
draw_sdf_shape |
Anti-aliased shapes at any size | Very low (GPU) |
draw_waveform |
Audio waveforms | Very low (GPU batch) |
draw_blurred_backdrop |
Frosted glass effects | Medium (GPU blur pass) |
| Custom WGSL shader | Per-pixel effects, particles | Depends on shader complexity |
Tips¶
- Minimize
canvasClear+ redraw. Only redraw what changed. The Canvas queues commands — unchanged regions are cheap.
GPU Capabilities: What Is Real Today¶
Pulp uses the GPU in three distinct ways. Do not conflate them:
| Capability | Status | What It Does |
|---|---|---|
| Dawn/Skia Graphite rendering | Shipped | All UI drawing goes through the GPU via Skia Graphite on a Dawn wgpu::Device. This is the rendering pipeline, not compute. |
| SkSL runtime effects | Shipped | Fragment shaders for visual effects (SDF shapes, blur, gradients). These run per-pixel during rendering. Not compute shaders. |
| WebGPU compute for audio | Experimental | WGSL compute shaders for batch spectral processing. Viable for large offline workloads (>64K elements). Not viable for real-time per-buffer audio. See docs/reports/webgpu-compute-feasibility.md. |
The first two are production rendering features. The third is a separate compute pipeline that shares the same Dawn device but operates independently of rendering. It does NOT run in the audio callback.
-
Use SDF shapes over path-based shapes when possible. SDF rendering is resolution-independent and faster for rounded rectangles, circles, and arcs.
-
Use
draw_waveformover manual line drawing for audio displays. It batches all samples into a single GPU draw call. -
Avoid per-frame shader compilation. Compile once in
initialize(), reuse the pipeline. -
Keep shader uniforms small. A few floats and a texture reference — don't upload entire audio buffers every frame. Use storage buffers for large data.