Hilbert allrgb

All 16,777,216 RGB colours, each exactly once: a Hilbert curve through the 256³ colour cube unrolled along a Hilbert curve filling a 4096² square.

---
format: typebulb/v1
name: Hilbert allrgb
---

**code.tsx**

```tsx
import React, { useCallback, useEffect, useRef, useState } from "react"
import { createRoot } from "react-dom/client"

const B2 = 12                 // 2D Hilbert order -> 4096 x 4096
const SIDE = 1 << B2
const B3 = 8                  // 3D Hilbert order -> 256 x 256 x 256

/** Skilling's TransposeToAxes: Hilbert transpose form -> geometric axes, any dimension. */
function transposeToAxes(X: Int32Array, b: number, n: number) {
  const N = 2 << (b - 1)
  let t = X[n - 1] >> 1
  for (let i = n - 1; i > 0; i--) X[i] ^= X[i - 1]   // Gray decode by H ^ (H/2)
  X[0] ^= t
  for (let Q = 2; Q < N; Q <<= 1) {                  // undo excess work
    const P = Q - 1
    for (let i = n - 1; i >= 0; i--) {
      if (X[i] & Q) X[0] ^= P                                       // invert
      else { t = (X[0] ^ X[i]) & P; X[0] ^= t; X[i] ^= t }           // exchange
    }
  }
}

/** Distance along the curve -> transpose form: bit j (from the top) lands in X[j % n]. */
function indexToTranspose(d: number, b: number, n: number, X: Int32Array) {
  for (let i = 0; i < n; i++) X[i] = 0
  const bits = b * n
  for (let j = 0; j < bits; j++) {
    const bit = (d >>> (bits - 1 - j)) & 1
    X[j % n] |= bit << (b - 1 - ((j / n) | 0))
  }
}

const scratch = new Int32Array(3)
function coords(d: number, b: number, n: number) {
  indexToTranspose(d, b, n, scratch)
  transposeToAxes(scratch, b, n)
  return Array.from(scratch.subarray(0, n))
}

type Curve = { half: number; base: Int32Array; variant: Uint8Array; tables: Int32Array[] }

/**
 * Decompose a b-level, n-dimensional Hilbert curve into blocks: the high half of an
 * index picks the block, the low half picks a point inside it. Every block holds the
 * same half-order curve under one of the group's signed permutations, so a single
 * offset table per distinct permutation covers all 4096 of them — 24 tables for the
 * cube, 4 for the square. Both packings are linear and carry-free at these magnitudes,
 * which collapses the whole per-pixel lookup to base[hi] + table[lo].
 *
 * Checked exhaustively against the direct Skilling path over all 2^24 indices.
 */
function buildCurve(b: number, n: number, pack: (c: number[]) => number): Curve {
  const k = b >> 1
  const half = 1 << (k * n)
  const span = 1 << k

  const lowCoords: number[][] = []
  const inv = new Map<string, number>()
  for (let lo = 0; lo < half; lo++) {
    const c = coords(lo, k, n)
    lowCoords.push(c)
    inv.set(c.join(), lo)
  }
  // Where each unit step sits in the low curve, for probing a block's orientation.
  const unit = Array.from({ length: n }, (_, j) => {
    const e = new Array(n).fill(0)
    e[j] = 1
    return inv.get(e.join())!
  })

  const base = new Int32Array(half)
  const variant = new Uint8Array(half)
  const tables: Int32Array[] = []
  const anchors: number[][] = []
  const seen = new Map<string, number>()

  for (let hi = 0; hi < half; hi++) {
    const p0 = coords(hi * half, b, n)
    const img: number[][] = []                       // where this block sends each axis
    for (let j = 0; j < n; j++) {
      const p = coords(hi * half + unit[j], b, n)
      img.push(p.map((v, i) => v - p0[i]))
    }
    const key = img.join(";")
    let t = seen.get(key)
    if (t === undefined) {
      t = tables.length
      seen.set(key, t)
      // A negative axis makes p0 the block's far corner; shift so offsets stay >= 0,
      // otherwise the carry-free add below breaks.
      const anchor = new Array(n).fill(0)
      for (const v of img) v.forEach((s, i) => { if (s < 0) anchor[i] = span - 1 })
      const tab = new Int32Array(half)
      for (let lo = 0; lo < half; lo++) {
        const o = anchor.slice()
        lowCoords[lo].forEach((w, j) => img[j].forEach((s, i) => { o[i] += w * s }))
        tab[lo] = pack(o)
      }
      tables.push(tab)
      anchors.push(anchor)
    }
    variant[hi] = t
    base[hi] = pack(p0.map((v, i) => v - anchors[t][i]))
  }
  return { half, base, variant, tables }
}

const packRGB = (c: number[]) => (c[0] << 16) | (c[1] << 8) | c[2]
const packXY = (c: number[]) => (c[1] << 12) | c[0]

/**
 * One walk of both curves at once. Each index d names a colour in the cube and a pixel
 * in the square simultaneously, and both are bijections, so every colour lands exactly
 * once. The two curves share a block count (both are 24-bit), hence the single loop nest.
 */
function fill(ctx: CanvasRenderingContext2D) {
  const t0 = performance.now()
  const cube = buildCurve(B3, 3, packRGB)
  const square = buildCurve(B2, 2, packXY)
  const img = ctx.createImageData(SIDE, SIDE)
  const buf = img.data

  for (let hi = 0; hi < cube.half; hi++) {
    const ct = cube.tables[cube.variant[hi]], cb = cube.base[hi]
    const st = square.tables[square.variant[hi]], sb = square.base[hi]
    for (let lo = 0; lo < cube.half; lo++) {
      const c = cb + ct[lo]
      const p = (sb + st[lo]) << 2
      buf[p] = c >>> 16; buf[p + 1] = (c >>> 8) & 255; buf[p + 2] = c & 255; buf[p + 3] = 255
    }
  }
  ctx.putImageData(img, 0, 0)
  return Math.round(performance.now() - t0)
}

function App() {
  const viewRef = useRef<HTMLCanvasElement>(null)
  const fullRef = useRef<HTMLCanvasElement | null>(null)
  const [ms, setMs] = useState<number | null>(null)

  // The full-resolution image lives off-screen; the visible canvas is only ever a view of it.
  if (!fullRef.current) {
    const c = document.createElement("canvas")
    c.width = SIDE
    c.height = SIDE
    fullRef.current = c
  }

  const draw = useCallback(() => {
    const view = viewRef.current, full = fullRef.current
    if (!view || !full) return
    const ctx = view.getContext("2d")
    if (!ctx) return
    ctx.imageSmoothingEnabled = true
    ctx.drawImage(full, 0, 0, SIDE, SIDE, 0, 0, view.width, view.height)
  }, [])

  useEffect(() => {
    const t = fill(fullRef.current!.getContext("2d")!)
    setMs(t)
    tb.log(`filled ${SIDE}² in ${t}ms`)
    draw()
  }, [draw])

  // Keep the view canvas's backing store matched to its laid-out size.
  useEffect(() => {
    const view = viewRef.current
    if (!view) return
    const fit = () => {
      const px = Math.max(1, Math.round(view.clientWidth * (window.devicePixelRatio || 1)))
      const size = Math.min(px, SIDE)
      if (view.width !== size) { view.width = size; view.height = size }
      draw()
    }
    fit()
    const ro = new ResizeObserver(fit)
    ro.observe(view)
    return () => ro.disconnect()
  }, [draw])

  useEffect(() => tb.onMessage((m) => {
    if (m === "selftest") return { ms }
    if (m === "shot") {                       // downscaled PNG, for headless visual checks
      const c = document.createElement("canvas")
      c.width = c.height = 512
      c.getContext("2d")!.drawImage(fullRef.current!, 0, 0, SIDE, SIDE, 0, 0, 512, 512)
      return c.toDataURL("image/png").split(",")[1]
    }
  }), [ms])

  return (
    <div className="wrap">
      <div className="stage">
        <canvas ref={viewRef} />
        <header>
          <h1>Every RGB colour, exactly once</h1>
          <p className="sub">
            A Hilbert curve threads all 256³ colours; a Hilbert curve fills the 4096² square.
            Walk both at once and neighbours on one stay neighbours on the other.
            <em> Shown scaled down — each screen pixel averages many; download for the real 4096².</em>
            {" "}Idea by{" "}
            <a href="https://x.com/matthen2/status/2081832238082048264"
               target="_blank" rel="noreferrer">Matt Henderson</a>.
          </p>
        </header>
      </div>
    </div>
  )
}

createRoot(document.getElementById("root")!).render(<App />)
```
**styles.css**

```css
/* The CLI zeroes this for us; typebulb.com's sandbox does not. */
body { margin: 0; }

.wrap {
  margin: 0;
  font: 14px/1.5 system-ui, sans-serif;
  min-height: 100dvh;
  display: grid;
  place-items: center;
}

/* Fit the square to the shorter viewport axis, so it gutters rather than scrolls. */
.stage {
  position: relative;
  width: min(100%, 100dvh);
}
canvas {
  display: block;
  width: 100%;
  aspect-ratio: 1;
  background: color-mix(in srgb, currentColor 8%, transparent);
}

/* The text rides on the image rather than pushing it down. */
header {
  position: absolute;
  inset: 0 0 auto 0;
  padding: 18px 22px 40px;
  pointer-events: none;
  color: #fff;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
  background: linear-gradient(to bottom,
    rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.34) 58%, transparent);
}
h1 { font-size: 19px; margin: 0; letter-spacing: -0.01em; }
.sub { margin: 5px 0 0; max-width: 64ch; opacity: 0.88; font-size: 13px; }
.sub em { font-style: normal; opacity: 0.72; }
.sub a {
  color: inherit;
  pointer-events: auto;   /* header is pointer-events: none, so opt the link back in */
  text-underline-offset: 2px;
}
```
**index.html**

```html
<div id="root"></div>
```
**config.json**

```json
{
  "description": "All 16,777,216 RGB colours, each exactly once: a Hilbert curve through the 256³ colour cube unrolled along a Hilbert curve filling a 4096² square.",
  "dependencies": {
    "react": "^19.2.7",
    "react-dom": "^19.2.7"
  }
}
```
**notes.md**

```md
The whole idea is one walk, two curves. For d = 0 … 2^24−1, Skilling's TransposeToAxes
turns d into a point in the 256³ cube (b=8, n=3) and, with the same d, into a point in
the 4096² square (b=12, n=2). Both maps are bijections onto their lattices, so every
colour is used exactly once and every pixel is written exactly once.

Locality carries over because both curves are Hilbert: a short hop in d is a short hop
in the cube *and* a short hop in the square.

Calling TransposeToAxes per pixel costs ~3.4s, which is why buildCurve exists instead.
Split each index into a high half (which block) and a low half (where inside it): every
block is the same half-order curve under one of the group's signed permutations, so 24
offset tables cover the cube and 4 cover the square. Both packings are linear and
carry-free, so a pixel is base[hi] + table[lo] — one lookup, one add. That is ~100ms
for the whole image, which is why there is no progress bar to speak of.

The subtlety is the anchor: a block whose symmetry flips an axis has p0 at its *far*
corner, so offsets would go negative and the carry-free add would corrupt neighbouring
fields. Shifting by span-1 on flipped axes keeps every offset >= 0. Verified by checking
all 2^24 indices against the direct path — worth redoing if buildCurve is ever touched.

The 4096² canvas is off-screen and the visible one is only ever a drawImage view of it,
so a resize is a free redraw.
```

Markdown source · More bulbs by antypica · Typebulb home