Spinning Noise

A disc of black and white noise whose spin varies smoothly with radius, slow at the rim and fast at the centre. No controls, no text, just motion.

---
format: typebulb/v1
name: Spinning Noise
---

**code.tsx**

```tsx
const GRAIN = 2           // noise cell size in device px, both radially and around
const OMEGA_MIN = 0.075   // rad/sec at the rim
const OMEGA_MAX = 3.9     // rad/sec at the centre
const PAD = 24            // CSS px of clearance between the rim and the nearest edge
const TAU = Math.PI * 2

const view = document.getElementById('field') as HTMLCanvasElement
const gl = view.getContext('webgl2', { antialias: false, depth: false, preserveDrawingBuffer: true })!

const VERT = `#version 300 es
void main() {
  vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
  gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`

// Speed varies smoothly with radius, but every pixel within a one-cell band shares
// that band's speed. Quantising there is what keeps the field crisp: sampled at the
// exact radius instead, neighbouring pixels drift apart without limit and the noise
// winds itself into aliased streaks within seconds. Bands are GRAIN device px, well
// under the grain itself, so the variation still reads as continuous.
const FRAG = `#version 300 es
precision highp float;
uniform sampler2D uNoise;
uniform vec2 uCentre;
uniform float uRadius, uRows, uGrain, uTime, uOmegaMin, uK;
out vec4 outColor;
const float TAU = 6.28318530718;
void main() {
  vec2 p = gl_FragCoord.xy - uCentre;
  float r = length(p);
  float edge = 1.0 - smoothstep(uRadius - 1.0, uRadius, r);
  float row = min(floor(r / uGrain), uRows - 1.0);
  float omega = uOmegaMin * exp(uK * (1.0 - (row + 0.5) * uGrain / uRadius));
  float cells = max(4.0, floor(TAU * (row + 0.5) + 0.5));
  float a = atan(p.y, p.x) - uTime * omega;
  float v = texelFetch(uNoise, ivec2(int(floor(fract(a / TAU) * cells)), int(row)), 0).r;
  outColor = vec4(vec3(v * edge), 1.0);
}`

function compile(type: number, src: string) {
  const s = gl.createShader(type)!
  gl.shaderSource(s, src)
  gl.compileShader(s)
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)!)
  return s
}

const prog = gl.createProgram()!
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT))
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG))
gl.linkProgram(prog)
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog)!)
gl.useProgram(prog)

const at = (n: string) => gl.getUniformLocation(prog, n)
const uCentre = at('uCentre'), uRadius = at('uRadius'), uRows = at('uRows'), uTime = at('uTime')
gl.uniform1f(at('uGrain'), GRAIN)
gl.uniform1f(at('uOmegaMin'), OMEGA_MIN)
gl.uniform1f(at('uK'), Math.log(OMEGA_MAX / OMEGA_MIN))

gl.bindTexture(gl.TEXTURE_2D, gl.createTexture())
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)

let rows = 0, radius = 0, t = 0, last = performance.now()

// Black and white noise in polar layout: one row per band, holding as many cells as
// fit around it, so a cell is about GRAIN px square wherever it sits. A band only
// ever slides along its own row, so no cell is stretched or resampled, ever.
function buildNoise() {
  const cols = Math.max(4, Math.round(TAU * (rows - 0.5)))
  const px = new Uint8Array(cols * rows)
  for (let i = 0; i < rows; i++) {
    const n = Math.max(4, Math.round(TAU * (i + 0.5)))
    for (let j = 0; j < n; j++) px[i * cols + j] = Math.random() < 0.5 ? 0 : 255
  }
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, cols, rows, 0, gl.RED, gl.UNSIGNED_BYTE, px)
}

function fit() {
  const dpr = devicePixelRatio || 1
  view.width = Math.round(view.clientWidth * dpr)
  view.height = Math.round(view.clientHeight * dpr)
  gl.viewport(0, 0, view.width, view.height)
  gl.uniform2f(uCentre, view.width / 2, view.height / 2)
  radius = Math.max(GRAIN, Math.min(view.width, view.height) / 2 - PAD * dpr)
  gl.uniform1f(uRadius, radius)
  const n = Math.floor(radius / GRAIN) + 1
  if (n !== rows) {
    rows = n
    gl.uniform1f(uRows, rows)
    buildNoise()
  }
}

function frame(now: number) {
  t += Math.min(0.05, (now - last) / 1000)
  last = now
  gl.uniform1f(uTime, t)
  gl.drawArrays(gl.TRIANGLES, 0, 3)
  requestAnimationFrame(frame)
}

fit()
new ResizeObserver(fit).observe(view)
requestAnimationFrame(frame)
```
**styles.css**

```css
:root { color-scheme: dark; }

html, body { margin: 0; background: #000; overflow: hidden; }

/* 100dvh fills its own window; the floor holds a band when rendered inline. */
#field { display: block; width: 100%; height: 100dvh; min-height: 480px; }
```
**index.html**

```html
<canvas id="field"></canvas>
```
**config.json**

```json
{
  "description": "A disc of black and white noise whose spin varies smoothly with radius, slow at the rim and fast at the centre. No controls, no text, just motion."
}
```

Markdown source · More bulbs by antypica · Typebulb home