Skip to content
blotter

Guide

Custom materials

Write your own effect as a Shadertoy-style fragment function, with uniforms you control.

On this page

Why write a shader #

The bundled materials cover a lot of ground, but the moment you want a specific look you are one function away from having it. A material is a fragment shader in the Shadertoy mould: a mainImage function that is called once per pixel and returns a colour. Blotter supplies the text as a texture, the uniforms you declare, and a handful of built-ins; you supply the pixels.

If shaders are new to you, spend ten minutes on Shadertoy first. Everything there transfers, with one substitution described below.

The contract #

Your mainImage receives the pixel’s coordinate and writes its colour. Sample the text with textTexture(uv), not texture2D: it maps the canvas UV into the right region of the shared atlas, and returns transparent black for anything outside this text’s bounds. This is the identity material, and the default when you construct a Material without one.

void mainImage( out vec4 mainImage, in vec2 fragCoord ) {
  vec2 uv = fragCoord / uResolution;
  mainImage = textTexture(uv);
}

Every material also receives these uniforms, kept up to date by the renderer:

  • uResolution, the canvas size in pixels, so fragCoord / uResolution is UV.
  • uGlobalTime, seconds since the loop started, and uTimeDelta, seconds since the last frame.
  • uPixelRatio, the device pixel ratio the atlas was drawn at.
  • uBlendColor, a vec4 some materials composite against. It defaults to white.

A first material #

Start from the identity material and bend the UV before sampling. Here a 3D simplex noise, interpolated from the shaders namespace, pushes each row up and down over time. The amount is a uniform so it can be tuned while it runs.

Live · edits run as you type
import { Blotter, Material, Text, shaders } from "blotter.ts";

const text = new Text("wobble", {
  family: "'Castoro', serif",
  size: 72,
  fill: "#171717",
  paddingLeft: 40,
  paddingRight: 40,
  paddingTop: 30,
  paddingBottom: 30,
});

const material = new Material({
  mainImage: `
    ${shaders.noise3d}

    void mainImage( out vec4 mainImage, in vec2 fragCoord ) {
      vec2 uv = fragCoord / uResolution;
      uv.y += snoise(vec3(uv * 4.0, uGlobalTime)) * uAmount;
      mainImage = textTexture(uv);
    }
  `,
  uniforms: {
    uAmount: { type: "1f", value: 0.05 },
  },
});

const blotter = new Blotter(material, { texts: text });
blotter.forText(text)?.appendTo(output);

Declaring uniforms #

Uniforms are declared by name with a type and an initial value. Four types are supported, matching the GLSL vector sizes, and the value shape follows the type. Blotter validates the pair, declares the GLSL for you, and exposes each one as a live UniformInterface on material.uniforms.

const material = new Material({
  mainImage,
  uniforms: {
    uAmount: { type: "1f", value: 0.05 },        // float
    uCenter: { type: "2f", value: [0.5, 0.5] },  // vec2
    uTint:   { type: "3f", value: [1, 0.9, 0.8] }, // vec3
    uEdge:   { type: "4f", value: [0, 0, 0, 1] }, // vec4
  },
});

material.uniforms.uAmount.value = 0.1; // live, like any other uniform

ShaderMaterial #

For a one-off effect there is a shorthand. ShaderMaterial takes the mainImage string first and the rest of the options second, and is otherwise a Material.

import { ShaderMaterial } from "blotter.ts";

const material = new ShaderMaterial(mainImage, {
  uniforms: { uAmount: { type: "1f", value: 0.05 } },
});

Subclassing #

A material you will reuse deserves a name. Subclass Material and pass the shader and uniforms up through super; this is exactly how the five bundled materials are written, so blotter.ts/materials is a good place to read for patterns.

import { Material, shaders } from "blotter.ts";

const mainImage = /* glsl */ `
  ${shaders.noise3d}

  void mainImage( out vec4 mainImage, in vec2 fragCoord ) {
    vec2 uv = fragCoord / uResolution;
    uv.y += snoise(vec3(uv * 4.0, uGlobalTime)) * uAmount;
    mainImage = textTexture(uv);
  }
`;

export class WobbleMaterial extends Material {
  constructor() {
    super({
      mainImage,
      uniforms: { uAmount: { type: "1f", value: 0.05 } },
    });
  }
}

Shader snippets #

The shaders export is a namespace of GLSL strings, ready to interpolate at the top of a mainImage. They are the helpers the bundled materials use.

SnippetProvides
noise, noise2d, noise3d, noise4d Simplex noise in one to four dimensions (snoise).
random A hash-based random for a vec2 seed.
easing The Penner easing curves as GLSL functions.
blending Photoshop-style blend modes: multiply, screen, overlay and friends.
lineMath Distance and projection helpers for line segments.
map Remap a value from one range to another.
gamma sRGB to linear and back.
blinnPhongSpecular A specular term for lighting tricks.
pi, inf Constants.

Gotchas #

  • Out of bounds is transparent. textTexture returns vec4(0.0) outside the text’s own rectangle. Displace the UV too far and the glyphs simply vanish; add padding to the Text to give an effect room.
  • Built-in names win. Declaring a uniform called uResolution or uGlobalTime is allowed but pointless: the renderer overwrites those values every frame.
  • Blending against the wrong colour. uBlendColor defaults to white. A material that composites through it, such as ChannelSplitMaterial, will show pale fringes on a dark page until you set it to your background colour.