Function

@pattern

A small language for drawing on a grid of cells, compiled to a GPU shader. Every cell runs the same rules, and each picks its own color with fill.

Syntax

  • @pattern(<body>)
  • @pattern<width>x<height>(<body>)

The body is a list of name: value; statements, run in source order. Digits after the name set the image size in pixels (@pattern800 is square). Without them the image takes the size of the element and redraws when it resizes.

Put it in @content to draw a live <canvas>. It also works as a background, but then every frame is encoded into a new image, which is much slower when the pattern animates.

Grid and fill

grid sets the columns, or n x m. Each cell knows its column x and row y, both counted from 1 at the top-left. fill gives the cell its color:

@grid: 1 / 60vmin;
@content: @pattern(
  grid: 64;
  fill: #fff;
  match((x - 1) & (y - 1)) {
    fill: #000;
  }
);

A fill is a CSS color, a number for gray, a vec3 / vec4, or a list of 3 or 4 channels. The last fill that runs wins, and a pixel with no fill is transparent.

Choosing cells with match

The rules inside match(condition) { … } only run in the cells where the condition holds:

@grid: 1 / 60vmin;
@content: @pattern(
  grid: 119;
  match(x*x*y*y % 120 < 24) {
    fill: #000
  }
);

match(a), match(b) { … } runs where either holds. For an else chain, list the tests in one block; the first test that holds wins:

@grid: 1 / 60vmin;
@content: @pattern(
  grid: 32;
  match {
    x = y or x + y = 33 { fill: #fff }
    dc % 3 < 1 { fill: #000 }
    else { fill: #fff }
  }
);

Inside a value, match(test, a, b) picks a or b. There is no ?:.

@grid: 1 / 60vmin;
@content: @pattern(
  grid: 32;
  fill: match(y > 16 + 6sin(x / 3 + t), #000, #fff);
);

Shape and size

shape masks each cell: square, circle, diamond, none, or a distance written with du and dv, the offsets inside the cell. size scales the mask from 0 to 1; on its own it masks with a square. With t, the time in seconds, the pattern animates:

@grid: 1 / 60vmin;
@content: @pattern800(
  grid: 32;
  size: sin(4*log(dr) - 2t - da);
  fill: #000;
);

Pixels and variables

Without a grid the whole image is one cell, so uv and pos address each pixel. Any name that is not a keyword is a variable:

@grid: 1 / 60vmin;
@content: @pattern(
  n: fbm(uv.x*6, uv.y*6 - t*4);
  h: clamp(2n * pow(1 - uv.y, 1.2), 0, 1);
  fill: vec3(1.9h, 1.5h*h, pow(h, 2)*.8)
);

ramp(t, stop, …) maps a value onto stops written as in a CSS gradient, colors or numbers, each with an optional position. A repeated position is a hard edge:

@grid: 1 / 60vmin;
@content: @pattern(
  fill: ramp(length(pos), #f99a53 .28, #006b50 .28, #00055f .5)
);

$name inserts the text of the cell's custom property --name when the pattern is made, so each cell can draw something different. A name that is not defined skips the pattern:

@grid: 3 / 60vmin _2px;
--k: @i;
background: @pattern(
  shape: circle;
  size: $k / 9;
  fill: #000;
);

Loops with repeat

repeat(n as k) { … } runs its body n times, with k counting from 0. Extra arguments stop the loop early when they all hold. Here it iterates a Julia set:

@grid: 1 / 60vmin;
@content: @pattern(
  z: 2.8pos;
  c: vec2(-.8, .156);
  n: 0;
  repeat(96, dot(z, z) > 256) {
    z: vec2(z.x * z.x - z.y * z.y, 2 * z.x * z.y) + c;
    n: n + 1;
  }
  fill: match(n = 96, #000, hsl(n / 96, .75, .45));
);

n is at most 1024, and nested loops 65536 steps in all. fill, shape, size and grid are not allowed inside a loop; collect a value in a variable and fill after it.

escape(c) is the same loop starting from 0, so the Mandelbrot set is one call:

@grid: 1 / 60vmin;
@content: @pattern(
  n: escape(3pos - vec2(.7, 0));
  fill: -cos(2π(min(3n, 1) + vec3(0, .1, .2)));
);

Drawing with distances

box and segment give the distance to a shape, and shape(d, w) turns a distance into ink. shape(abs(d), w) is a stroke of width w along the edge:

@grid: 1 / 60vmin;
@content: @pattern(
  a: 0;
  repeat(12 as k) {
    d: box(rot(pos, k * .12sin(t)), .04 + .036k);
    a: max(a, shape(abs(d), .012));
  }
  fill: mix(#fff, #000, a);
);

Lines with segment. Point k on a circle joins point k × m, and m grows over time:

@grid: 1 / 60vmin;
@content: @pattern(
  m: 2 + .2t;
  a: 0;
  repeat(90 as k) {
    p: .45 * vec2(cos(k * π / 45), sin(k * π / 45));
    q: .45 * vec2(cos(k * m * π / 45), sin(k * m * π / 45));
    a: max(a, shape(segment(pos, p, q), .008));
  }
  fill: mix(#fff, #000, a);
);

Doodle textures

A section named texture… holds doodle code. It is rendered to an image, and texture(texture0, uv) reads it. Here fract() tiles it 4 × 4:

@grid: 1 / 60vmin;
@content: @pattern(
  texture0 {
    @shape: heart;
    background: #000;
  }
  fill: texture(texture0, fract(4uv));
);

Reference

Variables

NameValue
x, y, icolumn, row and index of the cell, from 1, row 1 at the top
X, Y, Inumber of columns, rows and cells
dx … dbthe cell's offset and distance from the center, as in @dx
du, dvoffset inside the cell, −0.5 to 0.5
uvposition in the image, 0 to 1 from the bottom-left corner
poscentered position, the shorter side from −0.5 to 0.5
ttime in seconds; using it animates the pattern

Operators

Expressions are GLSL, with a few shortcuts. A number next to a name multiplies (2t, 2π), and ** is a power. a < b < c tests both sides. and, or, not, =, ≤, ≥ and ≠ may stand for &&, ||, !, ==, <=, >= and !=. Hex colors and color names can be used as values.

Functions

GLSL functions work, plus these:

  • ramp(t, stop, …) t mapped onto gradient stops, colors or numbers
  • hsl(h, s, l) color, channels 0 to 1
  • hsv(h, s, v) color, channels 0 to 1
  • noise(a, b) value noise, 0 to 1
  • fbm(a, b) six octaves of noise
  • voronoi(a, b) distance to the nearest point
  • rand(n) seeded random, 0 to 1
  • rot(p, a) rotate p by a radians
  • box(p, b) signed distance to a box of half-size b
  • segment(p, a, b) distance to the segment from a to b
  • shape(d, size) 1 inside the distance size / 2, 0 outside
  • smin(a, b, k) smooth minimum of a and b
  • ngon(p, n) distance with an n-gon as the unit
  • spiral(dx, dy) index on a square spiral from the center
  • dither(x, y) 4×4 Bayer threshold, 0 to 1
  • match(test, value, …) the value after the first true test
  • escape(c) 0 where z → z² + c stays bounded, otherwise how soon it leaves (96 steps)

See also