Reverse-Engineering omp.sh's Dithered Background

The landing page for omp.sh has a pink planet horizon rising out of the corner, rendered as thousands of tiny coloured dots. It looks like a shader. I pulled the site’s JS bundle apart to find out how it works, and it is not a shader: the whole thing is drawn pixel by pixel into a plain 2D canvas. This post covers how the original works and how I rebuilt it.

The rebuild is here, with four more variations on the same technique. Source on GitHub. The dunes variant is also the seventh background in this site’s rotation (force it with ?bg=dither); the standalone page keeps the faithful planet, and I did not want a copy of another product’s landing design running on the site itself.

Finding the renderer

The page itself is 5.8 KB of shell for a React SPA, so the interesting code is in the 313 KB bundle. Searching it for renderer libraries came back empty: no three, no webgl, no createShader, no fragment shaders. What it does contain is exactly one getContext, one ImageData and one putImageData.

That combination means software rendering: build one pixel buffer in JavaScript and write it to the canvas once. The poster is static. There is also a lazy-loaded WASM module behind a 'gpu' in navigator check that renders an animated version with WebGPU (the company is Stencil, and the export is create_stencil), but the canvas poster is the first paint and the fallback for every browser without WebGPU.

Brightness as dot density

A normal glow is a smooth gradient: brightness falls off exponentially with distance from the rim. This effect computes the same falloff but uses it as a probability instead of a colour. Each pixel rolls against the local band strength and either becomes a fully coloured dot or stays black:

const a = Math.exp(-dist / 14);          // tight sky-blue band
const b = Math.exp(-dist / 120) * 0.85;  // violet
const c = Math.exp(-dist / 600) * 0.7;   // plum
const r = rand(blockIndex);              // hash noise in 0..1
if (r < a) col = sky;
else if (r < a + b) col = violet;
else if (r < a + b + c) col = plum;      // otherwise: black

Brightness becomes dot density. Near the rim almost every roll succeeds, so the band reads as solid. Six hundred pixels out, roughly one roll in four succeeds, and the band thins to plum speckle. This is stochastic dithering, and it is why the image looks like print grain rather than a gradient.

The noise is not Math.random(). It is an integer hash of the pixel index:

function hash(n) {
  let t = n >>> 0;
  t = Math.imul(t ^ (t >>> 16), 2146121005);
  t = Math.imul(t ^ (t >>> 15), 2221713035);
  return (t ^ (t >>> 16)) >>> 0;
}

Deterministic noise means the same pixel always rolls the same number. The speckle pattern is stable across renders, which matters once you animate: in my animated variant the density field moves but the noise stays put, so dots flicker in place like a phosphor screen instead of boiling.

One circle, mostly off-screen

The planet is a circle far larger than the viewport, positioned so that only an arc crosses the corner. The code finds where the arc should meet the top and right edges, then solves the circle that passes through both points with a fixed bulge (the sagitta, 6.2% of the chord). The component renders with transform: scaleY(-1), so an arc solved at the top-right lands at the bottom-right.

Two details stop the rim looking sterile. A sine wave along the arc, 1 + 0.16 * sin(theta * k), modulates brightness into faint stripes. And the solid rim is only about 5 px wide; everything outside it goes through the dice roll above.

The passes that sell it

The dither solve runs on 2x2 blocks at a capped budget of about 2.2 megapixels, then gets upscaled with image-rendering: pixelated. Solving one pixel in four is what makes the dots read as deliberate texture.

On top of that, four finishing passes:

  • Per-pixel grain: every channel gets a random offset of up to 12 either way, including in the black regions.
  • Stars: up to 30,000 soft 2x2 points scattered along arcs that hug the rim, with a cube-root distribution so density rises toward the edge.
  • Salt and pepper: on a 512 px repeating tile, 4.5% of pixels get nudged toward pure black or pure white.
  • A CSS overlay of 1 px scanlines every 3 px (repeating-linear-gradient, 35% opacity) over the whole page.

Each pass is cheap and none is load-bearing on its own. Together they make a mathematically clean image look like a photograph of a CRT.

Five fields, one ditherer

Everything above only touches the distance function and the palette. Swap the distance function and the same machinery renders something else. My rebuild splits the engine from the field: a variant supplies solve(x, y) returning band strengths, and the engine does the dice roll, the grain, the particles and the salt and pepper.

  • horizon is the faithful port.
  • eclipse wraps the same three bands all the way round a closed disc, with a thin rim leaking inside.
  • dunes replaces distance-to-circle with distance-to-sine-ridge, stacked four silhouettes deep.
  • type rasterises a word off-screen with a soft glow and uses the luminance as the density field. Any mask works.
  • signal animates the field: a radial wave sweeps outward while the per-pixel noise stays fixed.

The original ships a colour-rotation matrix (the standard hue-rotate transform) and passes a hue of 41 degrees, which is how a sky-blue, violet and plum palette ends up pink. The rebuild exposes it as a slider.

Porting it to this site

The site version renders the dunes variant once per page load, with no animation loop. Two things needed adapting. Dark theme is nearly the original, with the ground colour matched to the page background. Light theme flips the idea: the ground becomes paper, the dots are darkened to hold contrast on white, and the vignette blends toward the ground colour instead of multiplying, because a multiply that fades a dark image to black at the corners would fade a light one to black too.

The bands also drop omp.sh’s palette: they take their colours from the same per-section purples the site’s other backgrounds use, so the dunes read as part of this site rather than a copy of that one. The whole component is one file with no dependencies. The site’s other backgrounds all load three.js: the low-poly clouds, the wireframe waves, the boids flock and the rest. This one renders with nothing beyond the 2D canvas API.