Custom Rendering¶
Pulp provides three layers of custom rendering, from simple JS draw commands to full GPU shader access. Choose the layer that matches your needs.
When to Use Each Layer¶
| Layer | API | Language | GPU-Accelerated | Best For |
|---|---|---|---|---|
| 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 B. Move to Layer C when you need C++ performance or complex state. Move to C+ when you need custom shaders.
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");
});
}
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+: Dawn/WebGPU Shaders¶
For fully custom GPU rendering — particle systems, spectrograms, oscilloscopes, or any effect that benefits from running per-pixel on the GPU.
Architecture¶
View::paint()
└── Dawn render pass
├── Vertex buffer (quad or custom geometry)
├── Uniform buffer (theme colors, time, audio data)
└── WGSL fragment shader (your custom effect)
Shader Widget Pattern¶
#include <pulp/render/render_context.hpp>
#include <pulp/view/view.hpp>
class ShaderWidget : public pulp::view::View {
public:
void initialize(pulp::render::RenderContext& ctx) {
// Compile shader
auto result = ctx.compile_shader(vertex_wgsl, fragment_wgsl);
pipeline_ = result.pipeline;
// Create uniform buffer for per-frame data
uniform_buffer_ = ctx.create_buffer(sizeof(Uniforms),
wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst);
}
void paint(pulp::canvas::Canvas& canvas) override {
auto* ctx = render_context();
if (!ctx || !pipeline_) return;
// Update uniforms
Uniforms u {
.time = static_cast<float>(elapsed_seconds()),
.resolution = {bounds().width, bounds().height},
.accent = theme_color_vec4("accent"),
};
ctx->write_buffer(uniform_buffer_, &u, sizeof(u));
// Draw fullscreen quad with our shader
ctx->draw_quad(pipeline_, uniform_buffer_);
}
private:
struct Uniforms {
float time;
float resolution[2];
float accent[4];
};
wgpu::RenderPipeline pipeline_;
wgpu::Buffer uniform_buffer_;
};
WGSL Fragment Shader¶
struct Uniforms {
time: f32,
resolution: vec2<f32>,
accent: vec4<f32>,
}
@group(0) @binding(0) var<uniform> u: Uniforms;
@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
// Animated gradient using theme accent color
let t = sin(u.time * 2.0 + uv.x * 6.28) * 0.5 + 0.5;
let bg = vec4<f32>(0.06, 0.06, 0.1, 1.0);
return mix(bg, u.accent, t * uv.y);
}
Validating Shaders¶
Validate shader code without running it:
// From JS
const result = compileShader(skslCode);
if (!result.success) {
console.error("Shader error: " + result.error);
}
// From C++
auto result = render_context.compile_shader(vertex_src, fragment_src);
if (!result.success) {
log::error("Shader: {}", result.error);
}
Passing Audio Data to Shaders¶
For audio-reactive visuals, push data from the audio thread to a GPU buffer:
// Audio thread → TripleBuffer → UI thread → GPU buffer
AudioBridge bridge;
// In process():
bridge.push_spectrum(fft_magnitudes, bin_count);
// In paint():
if (bridge.poll_spectrum()) {
auto& data = bridge.spectrum_data();
ctx->write_buffer(spectrum_buffer_, data.bins.data(),
data.bin_count * sizeof(float));
}
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.