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.
```