The Wall of Balls is a picture made from photos sent in by the N3D Melbourne community, people who 3D-print character balls. From a distance, you see our familiar group shot. Zoom in and each little square becomes someone’s photo of a print they made, with their name on it.

A few days after launch, about 90 people had contributed 531 photos. The wall has 16,192 squares, so those photos appear more than once.

Someone in our Discord asked how it works, and how the page stays smooth with so many pictures on screen. There are two parts to that: choosing where each photo goes, and drawing the finished wall efficiently. The code examples below are optional if you want a closer look.

The whole Wall of Balls: member photos arranged to form the N3D group shot
The wall a few days after launch. 531 photos repeated across 16,192 squares.

Make a guide from the big picture

A photomosaic is a picture made out of smaller pictures. From a distance, the details blend together and you mostly see the color of each little square. Arrange light and dark photos in the right places and a bigger picture starts to appear.

I started by shrinking the original group shot to a grid of 176 by 92 squares. Each square, or cell, has a color that the finished wall needs to match. Think of it as a paint-by-numbers guide, with a photo going into each space instead of paint.

See the code: Make the color guide
wall-target.js
const COLS = 176const img = sharp(src).modulate({ saturation: 1.3 }).linear(1.18, -22)  // a little contrast so the mosaic readsconst ROWS = Math.round(COLS * height / width)  // 92const raw = await img.resize(COLS, ROWS, { fit: 'fill' }).raw().toBuffer()const cells = []for (let i = 0; i < COLS * ROWS; i++) cells.push(hex(raw[i * 3], raw[i * 3 + 1], raw[i * 3 + 2]))writeFileSync('src/data/wall-target.json', JSON.stringify({ cols: COLS, rows: ROWS, cells }))
The original N3D group shot
The original shot.
The same shot reduced to a 176 by 92 grid of average colors
The color guide: 176 squares across and 92 down.
See the code: Read the saved colors
wall-target.json
{  "cols": 176,  "rows": 92,  "cells": ["#dcdcdc", "#dcdcdc", "#dbdbdb", "#68d597", "#25d270", ... 16,187 more, one per cell, left to right then top to bottom]}

Every photo becomes one color too

When someone uploads a photo of their print, it is cropped square and saved in three sizes: a small thumbnail, a medium version and a larger version for detail. The files use WebP, an image format that keeps downloads small. We also measure the average color at the center of the photo. That gives us a color to compare with the squares in our guide.

See the code: Prepare an uploaded photo
wall-transcode.ts
const master = sharp(upload).rotate().resize({ width: 1200, height: 1200, fit: 'cover' })for (const width of [96, 320, 1200]) {  const out = await master.clone().resize(width).webp({ quality: 80 }).toBuffer()  if (width === 96) {    // one color per photo: the mean of a 56px square from the middle of the small tile    const { channels } = await sharp(out).extract({ left: 20, top: 20, width: 56, height: 56 }).stats()    avgHex = '#' + channels.slice(0, 3).map((c) => Math.round(c.mean).toString(16).padStart(2, '0')).join('')  }}

The photos stay in private storage while they are reviewed. Subydoo, our support bot, checks them first for anything unsuitable for a family page. A person then makes the final decision. Only approved photos appear on the wall.

See the code: Approve or reject a photo
api/mod/wall/snaps/[id]/route.ts
// POST { action: "approve" | "reject" | "remove" }if (action === 'approve') await publishWallTile(snap.storage_key)  // holding bucket -> public bucketif (action === 'reject')  await removeWallTile(snap.storage_key, WALL_SNAP_PENDING_BUCKET)if (action === 'remove')  await removeWallTile(snap.storage_key, WALL_SNAP_BUCKET)  // pull an approved oneawait admin.from('wall_snap').update({ status, reviewed_by: mod.id, reviewed_at: new Date().toISOString() }).eq('id', id)

Find a place for each photo

Now we have a color for every square in the grid and a color for every approved photo. Choosing which photo goes where happens in two passes.

Pass one: everybody gets on the wall

First, each approved photo gets a turn. It takes the empty square with the closest color match. With more squares than photos, this gives every photo a place before any are repeated, even if its colors are unusual.

Pass two: fill the gaps

Most of the grid is still empty after that. To fill it, we reuse photos with colors close to each remaining square. Photos that have already appeared often get a small penalty, so one good match does not take over the whole picture. We visit the squares in a shuffled order to help spread the repeats around.

See the code: Place the photos
wall-layout.ts
// pass 1: every snap lands once, at its closest free cellfor (const snap of shuffled(snaps)) {  const cell = closestFreeCell(snap.lab)  cells[cell] = snap}// pass 2: nearest snap for every empty cell, repeats gently penalizedfor (const cell of shuffled(emptyCells)) {  cells[cell] = argmin(snaps, (s) => okLabDistance(s.lab, cell.lab) + 0.004 * uses[s])}

For color matching, I use a method called OKLab. It compares colors in a way that is closer to how our eyes see differences. That helps the wall choose a photo that looks right, rather than one whose color numbers happen to be close.

See the code: Compare two colors
color-match.ts
export function okLabDistance(a: OkLab, b: OkLab): number {  const dL = a.L - b.L  const da = a.a - b.a  const db = a.b - b.b  return Math.sqrt(dL * dL + da * da + db * db)}

The shuffle is repeatable: the same collection of photos produces the same layout. Adding or removing photos can change their positions. The server does the matching in well under a second and saves the result for five minutes. Your browser receives the finished arrangement and draws it.

Help the colors match

A photo might be a good match for one square and a poor match for another. Without help, all those repeats make the big picture look muddy. I add a transparent wash of the intended color to each square, with a stronger wash in bright areas so the white floor still looks white. The original group shot also sits over the whole wall at 40 percent opacity. Both layers fade away as you zoom in, revealing the members’ photos in their real colors.

See the code: Choose the tint strength
wall-layout.ts
// how much of the target color to paint over a tile: 50% on black, up to 80% on whiteexport function tintFor(hex: string): number {  return Math.min(0.92, 0.5 + 0.3 * relativeLuminance(hex))}
A close crop of the wall showing individual member photos washed toward the target colors
A crop from the middle of the mark. Each square is a member's photo. The wash is what makes the black outline read as black.

A larger, more varied collection gives the wall more colors to choose from and can improve the matches. But the colors matter as much as the number of photos. Filling every square with a different photo would not, by itself, mean we could remove the tint.

Keeping it fast

When you are zoomed out, the browser does not need to redraw thousands of separate photos every time you move. It first combines the small tiles into one image held in memory, called an offscreen canvas. It can then move and scale that image as a whole. Once it is prepared, drawing the zoomed-out view takes roughly the same work whether the wall uses 500 photos or 5,000. Downloading and preparing more photos can still take longer.

See the code: Draw the zoomed-out wall
wall-canvas.tsx
// paint one cell of the offscreen base canvas: tile, then its washconst x = (i % cols) * BASE_CELLconst y = Math.floor(i / cols) * BASE_CELLif (img) {  ctx.drawImage(img, x, y, BASE_CELL, BASE_CELL)  ctx.fillStyle = hexToRgba(target[i], tint[i])} else {  ctx.fillStyle = hexToRgba(target[i], GHOST_ALPHA)  // empty cell: a faint ghost of the target}ctx.fillRect(x, y, BASE_CELL, BASE_CELL)// every frame after that, zoomed out: one blit of the whole base canvasif (cellSize < BASE_CELL) ctx.drawImage(base, 0, 0, baseW, baseH, v.ox, v.oy, baseW * v.scale, baseH * v.scale)

Zoom in far enough and the small tiles start to look blurry. The page then loads sharper versions of the photos you can actually see and draws them on top. A link to an individual photo remembers its ID, so opening that link brings you straight to a highlighted tile.

Zoom in and the wash fades and the photos show. Drag around, zoom out, and it goes back to being the family shot.

The browser can keep a local copy of each photo for repeat visits, instead of downloading it again. Each small tile is around 3 to 4 KB. A repeated photo only needs to be downloaded once, even if it fills many squares.

See the code: Save photos for repeat visits
wall-transcode.ts
await admin.storage.from(bucket).upload(`${storageKey}-${width}.webp`, out, {  contentType: 'image/webp',  cacheControl: '31536000, immutable',  // one year, never revalidate  upsert: false,})
See the code: Create a still image
wall-snapshot.ts
$ npx tsx archive/scripts/wall-snapshot.ts --cell=16531 approved snaps, grid 176x92531 unique tiles fetchedwrote public/email/refer/wall-now.jpg (2816x1472)

I also use the same layout and drawing code to make still images for emails, including the image near the top of this article. With 531 photos, that took about ten seconds, mostly spent downloading the tiles.

A picture we are making together

When all 151 of the original designs have a photo on the wall, we will print it at poster size. Every contributor will get a share card with their tiles marked. I like that the familiar group shot is becoming a record of what people have actually made, one photo at a time.