Source shaders
The shaders that read a layer instead of inventing one, and how to give them something to read.
Fourteen shaders in the catalogue read a layer and do something to it, and one render graph — Blueprint — reads a layer too. The rest invent their picture from nothing but the uniform block, or, in the simulation family, step one forward on the GPU each frame.
| Family | Shaders | What they do |
|---|---|---|
| Optical | Glass, Lens, Bloom, Neon Sign | Refract it, bend it through a lens, bleed light out of it |
| Distortion | Shockwave, Swirl, Flow, Shatter, Melt | Push it around |
| Image effects | Duotone, Edges, Mosaic, ASCII | Recolour it, trace it, tile it |
| Transitions | Reveal | Wipe it in over a colour ground |
| Drafting | Blueprint | Measure it, and draw it up as a technical plate |
Ask any shader whether it wants a layer, and how it draws:
import { shaderKind, isSourceShader } from "shadertown";
shaderKind("glass"); // "source"
shaderKind("aurora"); // "generative"
shaderKind("blueprint"); // "graph" — several passes, one of them reads the layer
isSourceShader("edges"); // true
isSourceShader("blueprint"); // trueisSourceShader is the question to ask before handing over a source. shaderKind says how
the shader is driven, and a graph can read a layer too.
Giving it something to read
The source prop takes six shapes.
import { ShaderCanvas } from "shadertown/react";
// A URL — fetched and uploaded once.
<ShaderCanvas shader="glass" source="/hero.jpg" />
// Anything the browser can copy into a texture: an <img>, a <canvas>,
// an ImageBitmap, an OffscreenCanvas.
<ShaderCanvas shader="duotone" source={imageElement} />
// A <video>. Re-uploaded each frame while it plays, and left alone while
// it is paused.
<ShaderCanvas shader="edges" source={videoElement} />
// Another shadertown shader, rendered offscreen one pass ahead.
<ShaderCanvas shader="lens" source={{ shader: "neon-grid", params: { speed: 0.4 } }} />
// A line of text, rendered once to an offscreen canvas. Newlines break
// lines; font is one CSS shorthand.
<ShaderCanvas shader="neon-sign" source={{ text: "OPEN" }} />
// An SVG — markup or the URL of a file — rasterised once at the width you
// choose, as a white silhouette on black.
<ShaderCanvas shader="blueprint" source={{ svg: "/logo.svg" }} />Text is what the sign-and-glyph shaders are for. neon-sign bends the
letterforms into tube, shatter breaks them from wherever the pointer lands,
melt runs them downhill, and ascii works the other way — it turns any
layer into characters. A text source leaves half an em of margin all round,
so a shader that spreads light or displacement has somewhere to put it.
<ShaderCanvas shader="melt" source={{ text: "SALE\nENDS SOON", font: "800 200px Inter" }} />An SVG is what blueprint is for: a logo or a wordmark, measured and drawn up
as a plate. The browser rasterises it through an <img> at the width you
ask for (1024 by default; the height follows the viewBox), so the layer is as
sharp as the canvas it lands on rather than as sharp as the file says. Every
opaque pixel is filled with color — white unless you say otherwise — so a
coloured logo arrives as the same bright-on-dark silhouette a text source
produces. Pass color: null to keep the drawing's own colours, and
background to change the mat behind it. The same eighth-of-a-side margin
the text source leaves goes round it.
<ShaderCanvas shader="blueprint" source={{ svg: "/mark.svg", width: 1600 }} />
<ShaderCanvas shader="glass" source={{ svg: logoMarkup, color: null, background: "#fff" }} />The imperative form is the same option:
const renderer = createShaderRenderer(canvas, {
shader: "mosaic",
source: "/portrait.jpg",
});
// Swap the layer without rebuilding anything.
renderer.setSource("/landscape.jpg");Omit source entirely and the shader falls back to the one named in its
registry entry, so a preview is never a blank canvas.
import { defaultSourceOf } from "shadertown";
defaultSourceOf("glass"); // "neon-grid"Handing a source to a generative shader is ignored rather than an error, so
swapping the shader behind one <ShaderCanvas> keeps working.
Fitting
A photograph is rarely the shape of the canvas it lands on. The source is covered onto the canvas: scaled to fill it and cropped on the long axis, never stretched. A shader backdrop is the exception: its offscreen target tracks the canvas pixel for pixel, so there is nothing to fit and no resampling.
The bindings
On top of the shared Params block, a source shader declares three more:
| Binding | Type | What it is |
|---|---|---|
@group(0) @binding(1) | texture_2d<f32> | The layer being read |
@group(0) @binding(2) | sampler | Linear, clamped to the edge |
@group(0) @binding(3) | Source uniform | Its pixel size, and the fit mode |
They are additive. A generative shader declares none of them, so Params at
binding 0 has the same layout it has always had: a shader vendored before
source shaders existed runs on exactly the same ABI as one vendored today.
Inside the WGSL, the prelude gives you:
| Helper | What it does |
|---|---|
st_src(uv) | Sample in canvas uv, the ordinary read |
st_srcRgb(uv) | The same, without the alpha |
st_srcAt(srcUv) | Sample in source uv, for kernels walking neighbours |
st_srcUv(uv) | Canvas uv to source uv |
st_srcUvInset(uv, margin) | The same, with room left for a displacement to run into |
st_srcWarp(offset) | A canvas-space displacement, expressed in source uv |
st_srcTexel() | One source texel, in source uv |
st_srcInside(srcUv) | 1 inside the source, 0 just outside it |
st_srcSoften(uv, r) | Four-tap blur, cheap enough to run per channel |
st_srcBlur(uv, r) | Thirteen-tap spiral blur |
st_srcWarp exists because a displacement should move the same distance
sideways as it does up. Dividing by the aspect ratio is what makes a shockwave
circular on a wide canvas instead of an ellipse.
st_srcUvInset exists because the sampler clamps: a shader that pushes its
reads past the border gets the last row of texels smeared across the gap.
Reading a slightly smaller crop gives the displacement somewhere to go, at the
cost of a few per cent of the picture.
Cost
A source shader costs at least one texture read per pixel, and the ones that
blur cost a lot more: bloom takes sixteen taps, glass seven. A shader
backdrop adds a second full-screen pass on top of that.
If you are stacking two shaders and the result feels slow, cap the pixel ratio before you touch anything else. See Performance.