DEV Community

Cover image for How I painted famous programs with their own source
swastik poojari
swastik poojari

Posted on

How I painted famous programs with their own source

For International Artist Day I wanted to hang code on a museum wall, as paintings that the code itself had made.

The result is Oil on Silicon: six famous programs, from Euclid's algorithm to the fast inverse square root in Quake III, each turned into an oil painting by its own source. There's also a studio where you can paste your own code and get a painting back.

Live: https://swastik21.github.io/oil-on-silicon/
Source: https://github.com/swastik21/oil-on-silicon

It's plain JavaScript and a canvas. No libraries, no server, no build step. Here's how a program becomes a painting.

Step 1: read the code like a highlighter would

The first job is to cut the code into tokens and give each one a kind: keyword, function, type, string, name, number, operator, comment or bracket.

The tokenizer doesn't really understand any language. It walks through the text one character at a time and makes a reasonable guess. A word followed by an opening bracket is a function. A word in capitals is a constant. A # starts a comment, unless it's #include.

That sounds sloppy, but it doesn't matter. The painter never runs the code. It only needs the labels to be consistent, because each kind of token becomes a different brush. Keywords are bold strokes, strings are long washes, numbers are short dabs, and comments are wide, see-through glazes.

Step 2: make randomness repeatable

A painting needs randomness. Strokes shouldn't all be the same length or start in exactly the right place. But I wanted the same code to always give the same painting, so Math.random() is banned.

Instead the source is hashed into a 32-bit seed, and the seed drives a small generator called mulberry32:

export function rng(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
Enter fullscreen mode Exit fullscreen mode

The seed comes from the code and the composition, but not the palette:

const seed = hash(mode + '␟' + src);
Enter fullscreen mode Exit fullscreen mode

So if you switch from the oil pigments to the Dracula theme, you get the same painting in different colours.

Step 3: give every token a place

Each token gets an anchor point on the canvas. In the default layout the line number sets the height and the column sets the horizontal position, so the painting keeps the rough shape of the code. Indented blocks push paint to the right. Blank lines leave gaps.

The other compositions place tokens differently. Bloom puts them on a sunflower spiral, turning each one by the golden angle. Mosaic gives every token its own square tile. Vortex puts whirlpools wherever your functions are.

Step 4: let the strokes flow

From its anchor point, each stroke follows a flow field. That's a function that returns an angle for any point on the canvas, built from smooth gradient noise. The default one is a gentle horizon with some turbulence:

return (x, y) => tilt + Math.sin(y * waves + phase + x * 0.8) * 0.2 + n(x, y) * amp;
Enter fullscreen mode Exit fullscreen mode

A stroke is traced step by step along the field. Then a ribbon is built around that path, and its width follows brush pressure: a blunt start, a full middle, and a thin dry-brush tail.

const attack = Math.min(1, t / 0.12);
const release = t > 0.6 ? 1 - ((t - 0.6) / 0.4) * 0.82 : 1; // dry brush at the end
return (0.35 + 0.65 * Math.sqrt(attack)) * release * (1 + 0.1 * Math.sin(t * 7 + ph));
Enter fullscreen mode Exit fullscreen mode

Thick strokes also get a faint shadow and a highlight on the top-left edge, which makes the paint look raised.

Step 5: paint in passes

Real oil paintings are built up in layers, so these are too. There are five passes:

  1. Underdrawing: brackets and punctuation, as thin charcoal lines.
  2. Underpainting: a few broad, faint washes.
  3. Body colour: keywords, functions, types, names and strings.
  4. Glazes: comments, wide and see-through.
  5. Detail: thin strokes, number dabs and operator accents.

The Painting class draws a few strokes per animation frame, so on the site you can watch it happen. On the self-portrait, the line of code being painted lights up next to the canvas.

Step 6: write the code into the paint

This is the part I like most. Every stroke has its token's characters drawn along it, one letter at a time, rotated to follow the curve:

const c = Math.cos(th), sn = Math.sin(th);
ctx.setTransform(c, sn, -sn, c, x - sn * off, y + c * off);
ctx.fillText(cycle[k % cycle.length], 0, 0);
Enter fullscreen mode Exit fullscreen mode

At normal size the letters just look like bristle marks. The canvases are painted at a higher resolution than the screen, and a magnifier follows your cursor, so you can lean in and read the code inside the paint.

The performance surprise

My first version took 12 seconds to paint one picture. The planning was fast. The problem was the letters: around 40,000 tiny rotated glyphs per painting.

In Chrome, a 2D canvas is normally drawn on the GPU, and that turned out to be a bad fit for tens of thousands of separately transformed bits of text. Asking for a canvas that's optimised for reading pixels back moves it onto the CPU rasteriser:

this.ctx = canvas.getContext('2d', { willReadFrequently: true });
Enter fullscreen mode Exit fullscreen mode

I never read pixels back in the painter. I only wanted the CPU path. The same painting went from about 12 seconds to about half a second.

Try it

Paste something you wrote into the studio and see what it looks like. Recursive functions get a vortex. Short scripts get calm horizons. If you post what it paints, tag it #OilOnSilicon.

https://swastik21.github.io/oil-on-silicon/

Top comments (0)