shadertown

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.

FamilyShadersWhat they do
OpticalGlass, Lens, Bloom, Neon SignRefract it, bend it through a lens, bleed light out of it
DistortionShockwave, Swirl, Flow, Shatter, MeltPush it around
Image effectsDuotone, Edges, Mosaic, ASCIIRecolour it, trace it, tile it
TransitionsRevealWipe it in over a colour ground
DraftingBlueprintMeasure 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"); // true

isSourceShader 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:

BindingTypeWhat it is
@group(0) @binding(1)texture_2d<f32>The layer being read
@group(0) @binding(2)samplerLinear, clamped to the edge
@group(0) @binding(3)Source uniformIts 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:

HelperWhat 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.