[§] /docs/how-to/images-at-scale
How to show hundreds of images, and their palettes
Put hundreds of screenshots in one 3D wall, print them as halftone, and pull a palette from each.
TL;DR
- Pack thumbnails into one 2048px atlas and give each instance its own UV rectangle.
- Use
/_next/imagewith a width and quality Next 16 allows, or the request fails. - Pixelate by distance as cheap level of detail. Sort palettes in OKLCH.
Goal: a wall of 300 sites that loads fast, plus colour you can learn from. Take the technique, never the asset.
You need
- three with
@react-three/fiber, andnext/image's optimiser at/_next/image. data/examples.json(293 screenshots under/examples/).- A 2D canvas for the atlas, halftone and palettes.
atlas 2048 × 2048: 16 × 16 cells of 128px ┌──┬──┬──┬──┐ │00│01│02│..│ instance i ├──┼──┼──┼──┤ uvOffset = (i % 16, floor(i / 16)) / 16 │16│17│..│ │ uvScale = 1 / 16 └──┴──┴──┴──┘
Steps
1. Fetch thumbnails from the optimiser
const url = (src: string) => `/_next/image?url=${encodeURIComponent(src)}&w=128&q=75`;In Next 16, w must be one of imageSizes (32, 48, 64, 96, 128, 256, 384) or deviceSizes (640, 750,
828, 1080, 1200, 1920, 2048, 3840). q must be in images.qualities, which is only [75] by default.
Any other value returns a 400, not a resized image. (Checked on this site: w=130 and q=80 both fail.)
2. Draw them into one atlas
const c = document.createElement('canvas'); c.width = c.height = 2048;
const ctx = c.getContext('2d')!;
await Promise.all(list.map(async (src, i) => {
const img = new Image(); img.src = url(src); await img.decode();
ctx.drawImage(img, (i % 16) * 128, Math.floor(i / 16) * 128, 128, 128);
}));
const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace;One texture means one upload and one draw call for the whole wall.
3. Give each instance its UV rectangle
Add an InstancedBufferAttribute called aUvOffset (two floats per instance). In the vertex shader:
vUv = uv / 16.0 + aUvOffset;. See instanced blocks for the mesh.
4. Pixelate by distance
Far tiles do not need detail. Snap their UVs to a coarser grid in the fragment shader:
float cells = mix(128.0, 8.0, smoothstep(4.0, 30.0, vDist)); // near: sharp, far: blocky
vec2 local = floor(fract(vUv * 16.0) * cells) / cells;
vec3 col = texture2D(uAtlas, (floor(vUv * 16.0) + local) / 16.0).rgb;It is level of detail for free, and it fits the pixel look.
5. Develop an image as halftone
Draw dots on a rotated grid. Each dot's radius comes from the brightness under it. Animate the dot size down to zero while fading the real image in, and it looks like a print developing.
6. Pull a palette with k-means
Sample about 2,000 pixels. Start with 5 random centres. Assign each pixel to its nearest centre, move each centre to the average of its pixels, and repeat about 8 times. Convert the centres to OKLCH and sort by lightness, then hue. Sorted palettes read as a system. Unsorted ones look random.
Pitfalls
- The screenshots are not ours. Show them for commentary with a link to the real site. Never reuse them as textures in a product.
- Tainted canvas. Same-origin
/_next/imageURLs are fine. Remote URLs need CORS orgetImageDatathrows.
Why it works
GPUs hate switching textures and love drawing one mesh many times. An atlas plus instancing turns 300 images into one texture and one call.
See it live
[ex] in the wild · 17
















