Your First WGSL Pass in a View¶
This guide draws a custom WGSL pass on Pulp's shared GPU device and composites
the result into a View. It uses the typed API in
<pulp/render/gpu_pass.hpp>: no raw void* handles and no Dawn headers in
your code.
Use this path when a fragment shader over a rectangle (Canvas::draw_with_sksl,
see Custom Shaders) is not enough: you need your
own vertex stage, several passes, depth, or an intermediate texture.
The pieces¶
| Type | What it is |
|---|---|
GpuDevice |
Typed handle to the device a GpuSurface owns. GpuDevice::from_surface(surface) |
GpuShaderModule |
Compiled WGSL. A compile error returns line/column diagnostics |
GpuRenderPipeline, GpuComputePipeline |
Pipelines with automatic (layout: auto) bind group layouts |
GpuBuffer, GpuTexture, GpuSampler, GpuBindGroup |
Resources and their bindings |
GpuTarget |
Offscreen color texture (plus optional depth) with CPU readback |
GpuFrame |
One command encoder: open passes, record, submit() |
GpuRenderPass, GpuComputePass |
An open pass inside a frame |
GpuSubmission |
Non-blocking completion status of a submitted frame |
draw_gpu_texture() |
Composites a texture through the Skia canvas |
Every call that can fail returns GpuResult<T> (or GpuStatus) carrying a
GpuError with a kind (shader_compilation, validation, device_lost, ...)
and a message.
1. Write the shader¶
Put the WGSL in a file next to your sources, shaders/glow.wgsl:
struct Params {
color: vec4f,
time: f32,
};
@group(0) @binding(0) var<uniform> params: Params;
@vertex
fn vs_main(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {
// One triangle that covers the whole target.
let corners = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
return vec4f(corners[index], 0.0, 1.0);
}
@fragment
fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
let pulse = 0.5 + 0.5 * sin(params.time * 3.0 + position.x * 0.05);
// Premultiplied: the canvas composites premultiplied alpha.
return vec4f(params.color.rgb * pulse, 1.0);
}
Embed it, and validate it at build time:
That generates glow_wgsl.hpp with
inline constexpr std::string_view my_plugin::shaders::glow_wgsl. A shader that
does not compile fails the build with glow.wgsl:12:5: error: .... See
pulp_add_wgsl_shaders for when
validation runs.
2. Create the device objects once¶
The host owns the GpuSurface: WindowHost::gpu_surface() in an app,
PluginViewHost::gpu_surface() in a plug-in editor. Build everything that does
not change per frame when the surface becomes available, not in paint().
#include <pulp/render/gpu_pass.hpp>
#include "glow_wgsl.hpp"
using namespace pulp::render;
struct GlowGpu {
GpuDevice device;
GpuRenderPipeline pipeline;
GpuBuffer params;
GpuBindGroup bindings;
GpuTarget target;
};
GpuResult<GlowGpu> make_glow(GpuSurface& surface, uint32_t width, uint32_t height) {
GlowGpu gpu;
auto device = GpuDevice::from_surface(surface);
if (!device) return runtime::Err(device.error());
gpu.device = device.value();
auto shader = gpu.device.create_shader_module(my_plugin::shaders::glow_wgsl, "glow.wgsl");
if (!shader) return runtime::Err(shader.error()); // error().describe() lists line:column
GpuRenderPipelineDesc desc;
desc.module = shader.value();
desc.color_targets = {{GpuFormat::rgba8_unorm, GpuBlend::replace}};
auto pipeline = gpu.device.create_render_pipeline(desc);
if (!pipeline) return runtime::Err(pipeline.error());
gpu.pipeline = pipeline.value();
auto params = gpu.device.create_buffer({.size = 32}); // uniform | copy_dst
if (!params) return runtime::Err(params.error());
gpu.params = params.value();
auto bindings = gpu.device.create_bind_group(gpu.pipeline, 0, {GpuBinding::of(0, gpu.params)});
if (!bindings) return runtime::Err(bindings.error());
gpu.bindings = bindings.value();
auto target = gpu.device.create_target({.width = width, .height = height});
if (!target) return runtime::Err(target.error());
gpu.target = target.value();
return runtime::Ok(std::move(gpu));
}
GpuDevice::capabilities() reports the device's limits, its renderable and
storage formats and the sample counts each format allows.
pick_tier(capabilities().facts) reduces them to none, basic, standard
or high, which is a simple way to choose between a full and a reduced
effect. It is a pure function, so a test can cover that choice without a GPU.
3. Record, submit, composite¶
void GlowView::paint(pulp::canvas::Canvas& canvas) {
if (!gpu_) { paint_fallback(canvas); return; }
struct { float color[4]; float time; float pad[3]; } params{{0.2f, 0.6f, 1.0f, 1.0f}, time_};
gpu_->device.write_buffer(gpu_->params, 0, ¶ms, sizeof(params));
{
GpuFrame frame(gpu_->device, "glow");
auto pass = frame.render_pass(GpuRenderPassDesc::for_target(gpu_->target));
pass.set_pipeline(gpu_->pipeline);
pass.set_bind_group(0, gpu_->bindings);
pass.draw(3);
pass.end();
if (auto submitted = frame.submit(); !submitted)
pulp::runtime::log_error("glow: {}", submitted.error().describe());
}
const auto b = bounds();
if (!draw_gpu_texture(canvas, gpu_->target.color(), 0, 0, b.width, b.height))
paint_fallback(canvas); // a CPU or recording canvas
}
Rules that keep it correct¶
- Submit before you composite.
draw_gpu_texture()refuses a texture that an unsubmittedGpuFramewrites. The shared queue runs submissions in order, and Skia submits the UI frame afterpaint()returns, so a frame submitted insidepaint()is complete before Skia samples its output. - Do not draw on the canvas while a
GpuFrameis open. A frame opens WebGPU error scopes when it begins and closes them atsubmit(). Errors raised in between are reported as this frame's errors, which is what keeps a mistake in your pass from reaching the device's uncaptured-error handler (other users of the device, such as GPU audio, treat that handler as fatal to their own work). - One pass at a time. End a pass before opening the next; a second open
pass makes
submit()fail and nothing from that frame is submitted. - Texture lifetime. The image Skia wraps around the texture retains the
Dawn texture until Skia is done with it, so dropping your handle right after
draw_gpu_texture()is safe. - Surface lifetime. Release the
GpuDeviceand every object made from it before theGpuSurfaceis destroyed. Rebuild them when the host recreates the surface. - Device loss. Every call checks for a lost device and fails with
GpuError::Kind::device_lost. Drop the objects and rebuild from the new surface. - Threading. Create, record and submit on the thread that paints. Never touch this API from the audio thread.
- Formats you can composite:
rgba8_unorm,bgra8_unormandrgba16_float, single-sample. Resolve a multisampled pass into one of these withGpuColorAttachment::resolve.
Reading pixels back¶
GpuTarget::read_pixels() copies the color texture to the CPU and waits for
it. It handles WebGPU's 256-byte row alignment and returns tightly packed rows.
It blocks, so use it in tests and tools, not per frame:
auto pixels = target.read_pixels();
REQUIRE(pixels);
auto rgba = pixels->rgba8_at(10, 5); // BGRA targets are swizzled to RGBA
test/test_gpu_pass.cpp draws a fullscreen triangle into an offscreen target
and checks known pixels this way; the cases skip on hosts without a native GPU
adapter.
Completion¶
frame.submit() returns a GpuSubmission. status() reads the current state
without blocking, poll() runs pending callbacks once, and wait(timeout)
blocks. A submission that completes while the device is lost reports failed.