@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, or of the ::before or ::after it is set on, 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.
A later fill paints over the one before it, so an opaque one replaces it and fill: red, .5 tints it.
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
}
);
Tests combine with and, or and not, so match(a or 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,
and more pairs make a chain: match(t1, v1, t2, v2, …, else).
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. It keeps the type of its first value,
and names starting with g_ are reserved:
@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)
);
A swizzle on the left writes only those channels of a vector, as in v.xz: ….
A built-in such as pos is copied first, so pos.x: abs(pos.x) mirrors everything after it:
@grid: 1 / 60vmin;
@content: @pattern(
pos.x: abs(pos.x);
n: fbm(6pos + 2) - length(pos);
fill: match(n > .2, #000, #fff);
); 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, unless a texture declares it:
@grid: 3 / 60vmin _2px;
--k: @i;
background: @pattern(
shape: circle;
size: $k / 9;
fill: #000;
); $name(…) calls a function defined with --name().
Its parameters read bare, and a body that assigns result gives a value.
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 a whole number up to 1024, or a variable set to one and never changed.
Nested loops run at most 65536 steps in all.
A fill inside a loop paints over the one from the step before;
shape, size and grid are not allowed there.
escape(z, c) runs the same loop from z, so the Julia set above is also escape(2.8pos, vec2(-.8, .156)).
With only c it starts from 0, and 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, segment and arc give the distance to a shape, and shape() turns a distance into ink.
shape(d) is 1 where d is below 0, and shape(d, w) where it is below w / 2,
so shape(abs(d), w) is a stroke of width w along the edge:
@grid: 1 / 60vmin;
@content: @pattern(
fill: #fff;
repeat(12 as k) {
d: box(rot(pos, k * .12sin(t)), .04 + .036k);
fill: #000, shape(abs(d), .012);
}
);
Widths are in the units of pos, so a stroke grows with the image.
px is one CSS pixel in those units, and shape(abs(d), 2px) stays two pixels wide at any size.
arc(p, r, a, b) is the distance to an arc of radius r from angle a to b,
in radians counterclockwise. Its stroke has round ends:
@grid: 1 / 60vmin;
@content: @pattern(
fill: #fff;
repeat(8 as k) {
a: k + t * (k - 3.5) / 4;
fill: #000, shape(arc(pos, .08 + .045k, a, a + π), .03);
}
); 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;
fill: #fff;
repeat(90 as k) {
p: .45 * vec2(cos(k * π / 45), sin(k * π / 45));
q: .45 * vec2(cos(k * m * π / 45), sin(k * m * π / 45));
fill: #000, shape(segment(pos, p, q), .008);
}
); Doodle textures
A section named texture… holds doodle code. It is rendered to an image,
and texture(texture0, uv) reads it. The doodle sees the cell's custom properties.
The image repeats past its edges, so 4uv tiles it 4 × 4:
@grid: 1 / 60vmin;
@content: @pattern(
texture0 {
@shape: heart;
background: #000;
}
fill: texture(texture0, 4uv);
);
The doodle may have a @pattern of its own as a background. It is drawn once, as a still image with 8 bits a channel,
which suits a field read many times, such as a height read at neighboring points for a slope.
Reference
Variables
| Name | Value |
|---|---|
x, y, i | column, row and index of the cell, from 1, row 1 at the top |
X, Y, I | number of columns, rows and cells |
dx … db | the cell's offset and distance from the center, as in @dx |
du, dv | offset inside the cell, −0.5 to 0.5, with dv growing downward like y |
uv | position in the image, 0 to 1 from the bottom-left corner |
pos | centered position, the shorter side from −0.5 to 0.5, pos.y upward |
t | time in seconds; using it animates the pattern |
size | the current size, 1 until set |
px | one CSS pixel in the units of pos, so 2px is two |
π, PI | pi |
There is no pointer position; @shaders has u_mouse.
Operators
Expressions are GLSL, with a few shortcuts:
| Write | Means |
|---|---|
2t, 2π | 2 * t, 2 * π |
x ** 2 | x to the power 2 |
a < b < c | a < b && b < c |
and, or, not | &&, ||, ! |
=, ≠, ≤, ≥ | ==, !=, <=, >= |
hsl(h .75 .65) | hsl(h, .75, .65), a space between arguments works as a comma |
#f80, red | the color, as a vec3, or a vec4 when it has alpha |
^ is a bitwise xor, not a power as in @shape.
It and the other bitwise operators work on whole numbers.
A name that is neither a variable nor a color is reported and reads 0.
Functions
GLSL functions work, plus these.
A point is one vec2 or two numbers, so fbm(uv * 6) is fbm(uv.x * 6, uv.y * 6).
rand, noise, fbm and voronoi follow the doodle's seed,
so another seed gives another pattern.
-
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(p)value noise, 0 to 1 -
fbm(p)six octaves of noise -
voronoi(p)distance to the nearest point -
rand(p)seeded random, 0 to 1 -
rand(p, k)another random number for p at each k -
rand()0 to 1, different in each cell, each repeat step and each place it is written -
rot(p, a)the point p turned a radians counterclockwise, not a matrix -
box(p, b)signed distance to a box of half-size b -
segment(p, a, b)distance to the segment from a to b -
arc(p, r, a, b)distance to the arc of radius r from angle a to b -
shape(d)1 where d < 0, 0 outside, antialiased -
shape(d, size)1 where d < 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 -
pick(k: a, b, …)item k from 1, wrapping around -
escape([z,] c)0 where z → z² + c stays bounded within 96 steps, otherwise how soon it leaves; z starts at 0 when left out