CSS paint API: Being predictably random
Take a look at this:
If you're using a browser that supports the CSS paint API, the element will have a 'random' pixel-art gradient in the background. But it turns out, doing random in CSS isn't as easy as it seems…
Initial implementation
This isn't a full tutorial on the CSS paint API, so if the below isn't clear or you want to know more, check out the resources on houdini.how, and this great talk by Una.
First up, register a paint worklet:
CSS.paintWorklet.addModule(`worklet.js`);
Painting happens in a worklet so it doesn't block the main thread. Here's the worklet:
class PixelGradient {
static get inputProperties() {
// The CSS values we're interested in:
return ['--pixel-gradient-color', '--pixel-gradient-size'];
}
paint(ctx, bounds, props) {
// TODO: We'll get to this later
}
}
// Give our custom painting a name
// (this is how CSS will reference it):
registerPaint('pixel-gradient', PixelGradient);
And some CSS:
/* The end colour of the gradient */
@property --pixel-gradient-color {
syntax: '<color>';
initial-value: black;
inherits: true;
}
/* The size of each block in the gradient */
@property --pixel-gradient-size {
syntax: '<length>';
initial-value: 8px;
inherits: true;
}
.pixel-gradient {
--pixel-gradient-color: #9a9a9a;
background-color: #8a8a8a;
/* Tell the browser to use our worklet
for the background image */
background-image: paint(pixel-gradient);
}
@property
tells the browser the format of the custom properties. This is great as it means values can animate, and things like --pixel-gradient-size
can be specified in em
, %
, vw
etc etc – they'll be converted to pixels for the paint worklet.
Right ok, now let's get to the main bit, the painting of the element. The input is:
ctx
: A subset of the 2d canvas API.bounds
: The width & height of the area to paint.props
: The computed values for ourinputProperties
.
Here's the body of the paint
method to create our random gradient:
// Get styles from our input properties:
const size = props.get('--pixel-gradient-size').value;
ctx.fillStyle = props.get('--pixel-gradient-color');
// Loop over columns
for (let x = 0; x < bounds.width; x += size) {
// Loop over rows
for (let y = 0; y < bounds.height; y += size) {
// Convert our vertical position to 0-1
const pos = (y + size / 2) / bounds.height;
// Only draw a box if a random number
// is less than pos
if (Math.random() < pos) ctx.fillRect(x, y, size, size);
}
}
So we've created a blocky gradient that's random, but there's a higher chance of a block towards the bottom of the element. Job done? Well, here it is:
One of the things I love about the paint API is how easy it is to create animations. Even for animating the block size, all I had to do is create a CSS transition on --pixel-gradient-size
. Anyway, play with the above or try resizing your browser. Sometimes the pattern in the background changes, sometimes it doesn't.
The paint API is optimised with determinism in mind. The same input should produce the same output. In fact, the spec says if the element size and inputProperties
are the same between paints, the browser may use a cached copy of our paint instructions. We're breaking that assumption with Math.random()
.
I'll try and explain what I see in Chrome:
Why does the pattern change while animating width / height / colour / box size? These change the element size or our input properties, so the element has to repaint. Since we use Math.random()
, we get a new random result.
Why does it stay the same while changing the text? This requires a repaint, but since the element size and input remain the same, the browser uses a cached version of our pattern.
Why does it change while animating box-shadow? Although the box-shadow change means the element needs repainting, box-shadow doesn't change the element size, and box-shadow
isn't one of our inputProperties
. It feels like the browser could use a cached version of our pattern here, but it doesn't. And that's fine, the spec doesn't require the browser to use a cached copy here.
Why does it change twice when animating blur? Hah, well, animating blur happens on the compositor, so you get an initial repaint to lift the element onto its own layer. But, during the animation, it just blurs the cached result. Then, once the animation is complete, it drops the layer, and paints the element as a regular part of the page. The browser could use a cached result for these repaints, but it doesn't.
How the above behaves may differ depending on the browser, multiplied by version, multiplied by display/graphics hardware.
I explained this to my colleagues, and they said "So what? It's fun! Stop trying to crush fun Jake". Well, I'm going to show you that you can create pseudorandom effects with paint determinism and smooth animation while having fun. Maybe.
Making random, not random
Computers can't really do random. Instead, they take some state, and do some hot maths all over it to create a number. Then, they modify that state so the next number seems unrelated to the previous ones. But the truth is they're 100% related.
If you start with the same initial state, you'll get the same sequence of random numbers. That's what we want – something that looks random, but it's 100% reproducible. The good news is that's how Math.random()
works, the bad news is it doesn't let us set the initial state.
Instead, let's use another implementation that does let us set the initial state:
function mulberry32(a) {
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Using an initial state of 123456:
const rand = mulberry32(123456);
rand(); // 0.38233304349705577
rand(); // 0.7972629074938595
rand(); // 0.9965302373748273
This gist has a great collection of random number generators. I picked mulberry32
because it's simple, and good enough for visual randomness. I want to stress that I'm only recommending this for visual randomness. If you're implementing your own cryptography, this is the only piece of advice I'm qualified to give: don't.
I'm not saying mulberry32
is bad either. I'm just saying, if all your buttcoins get stolen because you were influenced by this article, don't come crying to me.
Anyway, here's mulberry32
in action:
Notice how for a given seed, the sequence of seemingly random numbers is the same every time.
Are you having fun yet??
Let's put mulberry32
to work here…
Making paint deterministic
We'll add another custom property for the seed:
@property --pixel-gradient-seed {
syntax: '<number>';
initial-value: 1;
inherits: true;
}
…which we'll also add to our inputProperties
. Then, we can modify our paint code:
const size = props.get('--pixel-gradient-size').value;
ctx.fillStyle = props.get('--pixel-gradient-color');
// Get the seed…
const seed = props.get('--pixel-gradient-seed').value;
// …and create a random number generator:
const rand = mulberry32(seed);
for (let x = 0; x < bounds.width; x += size) {
for (let y = 0; y < bounds.height; y += size) {
const pos = (y + size / 2) / bounds.height;
// …and use it rather than Math.random()
if (rand() < pos) ctx.fillRect(x, y, size, size);
}
}
And here it is:
And now animating width, colour, shadow, and blur isn't glitching! Buuuuut we can't say the same for animating height and block size. Let's fix that!
So much fun, right??
Handling rows and columns
Right now we're calling rand()
for every block. Take a look at this:
Let's say each square is a block, and the numbers represent the number of times rand()
is called. When you animate width, the numbers stay in the same place, but when you animate height, they move (aside from the first column). So, as the height changes, the randomness of our pixels changes, which makes it look like the noise is animating. Instead, we want something more like this:
…where our random values have two dimensions. Thankfully, we already have two dimensions to play with, the number of times rand()
is called, and the seed.
Have you ever had this much fun??
Being randomly predictable in two dimensions
This time, we'll reseed our random function for each column:
const size = props.get('--pixel-gradient-size').value;
ctx.fillStyle = props.get('--pixel-gradient-color');
let seed = props.get('--pixel-gradient-seed').value;
for (let x = 0; x < bounds.width; x += size) {
// Create a new rand() for this column:
const rand = mulberry32(seed);
// Increment the seed for next time:
seed++;
for (let y = 0; y < bounds.height; y += size) {
const pos = (y + size / 2) / bounds.height;
if (rand() < pos) ctx.fillRect(x, y, size, size);
}
}
And here it is:
Now height and block size animate in a more natural way! But there's one last thing to fix. By incrementing the seed by 1 for each column we've introduced visual predictability into our pattern. You can see this if you 'increment seed' – instead of producing a new random pattern, it shifts the pattern along (until it gets past JavaScript's maximum safe integer, at which point spooky things happen). Instead of incrementing the seed by 1, we want to change it in some way that feels random, but is 100% deterministic. Oh wait, that's what our rand()
function does!
In fact, let's create a version of mulberry32
that can be 'forked' for multiple dimensions:
function randomGenerator(seed) {
let state = seed;
const next = () => {
state |= 0;
state = (state + 0x6d2b79f5) | 0;
var t = Math.imul(state ^ (state >>> 15), 1 | state);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
next,
// Instead of incrementing, set the seed
// to a 'random' 32 bit value:
fork: () => randomGenerator(next() * 2 ** 32),
};
}
We use a random 32 bit value, since that's the amount of state mulberry32
works with. Then our paint method can use that:
const size = props.get('--pixel-gradient-size').value;
ctx.fillStyle = props.get('--pixel-gradient-color');
const seed = props.get('--pixel-gradient-seed').value;
// Create our initial random generator:
const randomXs = randomGenerator(seed);
for (let x = 0; x < bounds.width; x += size) {
// Then fork it for each column:
const randomYs = randomXs.fork();
for (let y = 0; y < bounds.height; y += size) {
const pos = (y + size / 2) / bounds.height;
if (randomYs.next() < pos) ctx.fillRect(x, y, size, size);
}
}
And here it is:
Now changing the seed produces an entirely new pattern.
Bringing back the fun
Ok, I admit that the animated noise effect was cool, but it was out of our control. Some folks react badly to flashing images and randomly changing visuals, so it's definitely something we do want to have under our control.
However, now we have --pixel-gradient-seed
defined as a number, we can animate it to recreate the animated noise effect:
@keyframes animate-pixel-gradient-seed {
from {
--pixel-gradient-seed: 0;
}
to {
--pixel-gradient-seed: 4294967295;
}
}
.animated-pixel-gradient {
background-image: paint(pixel-gradient);
animation: 60s linear infinite animate-pixel-gradient-seed;
}
/* Be nice to users who don't want
that kind of animation: */
@media (prefers-reduced-motion: reduce) {
.animated-pixel-gradient {
animation: none;
}
}
And here it is:
Now we can choose to animate the noise when we want, but keep it stable at other times.
But what about random placement?
Some CSS paint effects work with random placement of objects rather than random pixels, such as confetti/firework effects. You can use similar principles there too. Instead of placing items randomly around the element, split your elements up into a grid:
// We'll split the element up
// into 300x300 cells:
const gridSize = 300;
const density = props.get('--confetti-density').value;
const seed = props.get('--confetti-seed').value;
// Create our initial random generator:
const randomXs = randomGenerator(seed);
for (let x = 0; x < bounds.width; x += gridSize) {
// Fork it for each column:
const randomYs = randomXs.fork();
for (let y = 0; y < bounds.height; y += gridSize) {
// Fork it again for each cell:
const randomItems = randomYs.fork();
for (let _ = 0; _ < density; _++) {
const confettiX = randomItems.next() * gridSize + x;
const confettiY = randomItems.next() * gridSize + y;
// TODO: Draw confetti at
// confettiX, confettiY.
}
}
}
This time we have 3 dimensions of randomness – rows, columns, and density. Another advantage of using cells is the density of confetti will be consistent no matter how big the element is.
Like this:
And now the density can be changed/animated without creating a whole new pattern each time! See, that was fun, right? Right? RIGHT????
If you want to create your own stable-but-random effects, here's a gist for the randomGenerator
function.