N Numbrush Get Numbrush

How color by number apps store the outlines

Not as vectors and not as a flood fill. A second image where the pixel colours are region numbers, plus a twelve-pixel palette strip and a GPU shader.

A colouring app has to answer two questions thousands of times per picture: which region did the user just tap, and what colour should this pixel be right now. Both have to be instant on a canvas that is over two million pixels, on a phone, without the battery noticing.

The obvious approaches both fail, and the one that works is more interesting than either.

Why the obvious answers do not work

Vector shapes

Storing each region as an outlined path is the intuitive answer, and it is how the art was probably drawn. But a detailed mandala has several hundred regions with intricate boundaries, so hit-testing a tap means point-in-polygon tests against hundreds of complex paths, and rendering means the GPU rasterising all of them every frame. It scales badly in exactly the direction the artwork goes.

Flood fill

Tap a pixel, spread outwards until you hit a boundary, fill what you covered. This is how a paint program works, and it is wrong here for two reasons. It is a per-tap traversal over a potentially large area, so it is slow at the worst moment. And it is fragile: a single-pixel gap in an outline lets the fill escape and flood the neighbouring region, which is the kind of bug that ships and then only appears on one picture.

The answer: put the region numbers in an image

Instead of describing the regions, precompute them, and store the result as a second image the same size as the artwork. Every pixel of that image holds the id of the region that pixel belongs to.

A pixel has three usable colour channels of eight bits each, and a region id needs more than 256 values, so the id is split across two of them. In Numbrush the red channel carries the high byte and the green channel the low byte, giving a 16-bit id, and a blue channel of 255 marks the pixel as part of an outline. Region 0 is the background.

The file has to be lossless — a PNG, never a JPEG. Lossy compression works by changing pixel values slightly in ways the eye forgives, and here a pixel value is a number, not a colour. A JPEG of an id map is a picture of noise with the ids quietly rounded into each other.

On load, one linear pass converts the RGBA bytes into a flat array of 16-bit ids. From that moment, "which region is this pixel" is a single array lookup at a computed index. Not a search, not a traversal — one read.

The palette is a texture too

The second half of the trick is how the app knows what to draw. Each picture carries twelve colours, and those become a texture twelve pixels wide and one pixel tall. Each pixel holds one palette colour.

The interesting part is the alpha channel of that strip, which is not used for transparency at all. It carries a state flag: one value means this colour has been filled here, another means unfilled, another means this is the colour currently selected and should be highlighted. Three states, smuggled into a byte that was going spare.

The shader that ties them together

Rendering is then a small program running on the GPU, once per pixel, every frame. For each screen pixel it does roughly this:

  1. Sample the id map at this position and rebuild the 16-bit id from the red and green bytes.
  2. If the blue byte says outline, draw the near-black line colour and stop.
  3. Otherwise look the id up in the twelve-pixel palette strip.
  4. Read the flag out of that entry’s alpha byte.
  5. Draw the palette colour if filled; the unfilled shade if not; or the highlight treatment if this is the selected colour.

Because the highlight is decided inside the shader, changing which colour is selected costs nothing — you change one byte in a twelve-pixel texture and the entire canvas restyles on the next frame. The same is true of the three highlight styles, solid, checkerboard and pulsing outline: they are branches in the shader, not different assets.

Why a fill is instant

Here is the payoff. Filling a region means: set one byte in a small state array, then rebuild the palette strip — a few kilobytes of texture. That is the entire write.

The two-million-pixel id map is never touched. It is not re-scanned, not modified, not re-uploaded. No bitmap is repainted and nothing large is allocated. This is why a fill is instant on old hardware and why colouring for an hour does not heat the phone: the expensive object is read-only and the mutable object is tiny.

Two details that will ruin it

This approach has two failure modes that produce corrupted regions rather than obvious crashes, so they are worth naming.

The first is texture filtering. GPUs default to smoothing between neighbouring pixels, which is right for photographs and catastrophic for ids: interpolating between id 40 and id 41 produces id 40.5, which rounds to a region that may exist somewhere else entirely in the picture. The id map has to be sampled with nearest-neighbour filtering and no mipmaps. Symptomatically, you get a thin fringe of the wrong colour along every boundary.

The second is colour management. If the id texture is uploaded tagged with a colour space, the system may convert it — and a colour conversion applied to numbers changes the numbers. The map has to be uploaded raw, untagged, unpremultiplied. Symptomatically, everything is subtly wrong everywhere, which is much harder to diagnose than everything being obviously wrong somewhere.

Where the maps come from

The id map is not drawn by hand. An illustration is resized, smoothed with an edge-preserving filter, quantised into flat colour areas, and then labelled: each connected area of one colour becomes a region with an id. Regions too small or too thin to hit with a finger are merged into their neighbours, the outlines are carved, and label anchor points are computed so the number can sit at the visual centre of its region rather than at its bounding-box centre.

The last step is the important one for quality: a verification pass reads the finished files back off disk and checks them against the format rules. A picture that fails is rejected rather than shipped, because a broken id map is invisible until a user taps the one region it broke.

Frequently asked questions

Why not just use SVG paths for each region?

Hit-testing a tap would mean point-in-polygon tests against hundreds of intricate paths, and drawing would mean rasterising all of them every frame. A pre-baked id map turns hit-testing into a single array read and drawing into a texture lookup, both of which are constant time regardless of how detailed the artwork gets.

Why can the id map not be a JPEG?

Because its pixel values are numbers rather than colours. Lossy compression alters values in ways that are imperceptible for a photograph and fatal for data — ids would blend into their neighbours along every boundary. It has to be a lossless format such as PNG.

How many regions can a 16-bit id address?

Up to 65,535 plus the background, which is far beyond what a hand-tappable picture needs. Numbrush pictures run to a few hundred regions, because anything much finer stops being tappable before it stops being encodable.

Does the app store a filled version of each picture?

No, and that is the point. Your progress is one byte per region — whether it is filled and with what — and the picture you see is generated from that plus the id map every frame. Saving a picture is saving a few hundred bytes, not an image.

Try it on a real canvas

Numbrush is colour by number for adults: mandalas, rose windows and geometric pattern work, twelve colours a picture, a magnifier for the tight spots, and a wrong tap that costs you nothing. Free, offline, no ads and no account.

Download on the App Store

Related guides

All guides