TA logoThe Technical Artist
Home/Field Notes/Breakdown: Channel Packer
breakdown
postmortem
tool: t-07

Breakdown: The Channel Packer

Deep Endarticle~4 min readreviewed 2026-07-05

A postmortem of building this site's Texture Channel Packer - which turned into a case study in the most TA lesson there is: your tools lie at the boundaries, so verify the bytes. The dead end is the good part; it's included in full.

The Problem

Channel packing is bread-and-butter pipeline work: occlusion into R, roughness into G, metallic into B, maybe smoothness into A - one texture, one sampler, a quarter of the memory. Artists do it in Photoshop by hand, badly, at 6pm. A browser tool with drop-slots per channel seemed like an afternoon's work. It was, plus a day of forensics.

The Obvious Build (Works, Until It Doesn't)

The natural web build: draw each source image to a canvas, read the pixels with getImageData, shuffle channels into an output ImageData, put it on a canvas, canvas.toDataURL("image/png"), done. RGB-only packing shipped in an hour and worked perfectly. Then came the alpha channel.

The Trap: Premultiplied Alpha

Browsers store canvas pixels with premultiplied alpha - each color channel is kept multiplied by its alpha, because that's what compositing wants. The canvas API converts on the way in and out, and here's the killer: the round trip is lossy whenever alpha < 255. Store R=180 with A=25, and the browser keeps round(180 x 25/255) = 18; reading back computes round(18 x 255/25) = 184. Your 180 is now 184 - and the lower the alpha, the fewer values survive at all. At A=25, the entire 0-255 range of R collapses to 26 representable values.

Now remember what the alpha channel holds in a packed texture: data. A smoothness map full of dark values - perfectly normal for a rough material - quantizes the RGB channels underneath into staircases. AO develops banding. Metallic masks grow speckle. And nothing errors: the tool produces a plausible PNG that's subtly, silently wrong, which is the most dangerous kind of wrong a pipeline tool can be. Several web packers in the wild have exactly this bug; their users' materials are a little worse and nobody knows why.

Verifying The Suspicion (The TA Move)

The debugging followed the shader-debugging playbook, translated to files: make the pixel show its homework. Test image: a horizontal gradient in R, constant A=100. Pack, export, decode, read back. Expected R at x=60: 42. Got: 43-ish - sometimes 41, sometimes 46, staircasing with position. Not random: quantized. Quantization at exactly the granularity 255/alpha predicts is a fingerprint, and it pointed straight at the premultiplication docs. Ten-minute experiment, day-long mystery avoided - the ratio that makes "how would you find out?" the whole interview.

The Fix: If The Canvas Can't Hold Your Data, Don't Put It There

The packed pixels never needed to live in a canvas - the canvas was only being used as a PNG factory. So the fix: keep the packed data in a plain Uint8ClampedArray (canvas is still fine as a reader of the opaque source images, where alpha is 255 and the round trip is exact), and write the PNG ourselves. A minimal PNG encoder is smaller than its reputation:

The whole format, in outline
PNG = 8-byte signature
 + IHDR chunk (width, height, bit depth 8, color type 6 = RGBA)
 + IDAT chunk (scanlines: 1 filter byte + raw pixels, zlib-compressed)
 + IEND chunk (empty)

chunk = length(4) + type(4) + data + crc32(type + data)

The zlib compression that used to make this a "use a library" problem is now built into browsers as CompressionStream("deflate"); the CRC32 is a 15-line table function older than most readers of this page. Total encoder: ~80 lines (readable in packer.js - view source is a feature around here). What you pack is now bit-for-bit what the file contains, verified by decoding the export and reading exact values back: R=42 is R=42, alpha 100 is alpha 100.

What It Teaches (Beyond Pngs)

  • Boundaries corrupt. Data is safest inside your own arrays; every crossing - canvas, exporter, importer, colorspace - is a place to lose precision silently. This is the same lesson as sRGB-on-a-roughness-map, wearing a different costume.
  • Plausible output is the enemy. A crash is a gift; a slightly-wrong file is a time bomb. Pipeline tools deserve round-trip tests: write, read back, compare exact values. Four lines of test, permanent peace of mind.
  • Formats are shallower than they look. "Write a PNG encoder" sounds like systems programming; it's a weekend read of a spec and 80 lines. The general form: when a format stands between you and correctness, read the spec before accepting the workaround.
  • The dead end was the education. The working tool is nice; understanding why the obvious version betrays you is transferable forever. Write your breakdowns accordingly - this page practices what the portfolio guide preaches, dead end first.
From the trenches

The spiritual ancestor of this bug: a studio whose baked lightmaps kept coming out subtly blotchy - only on characters, only sometimes. Months of blaming the baker. The actual culprit: a texture-processing step that round-tripped through a premultiplied intermediate "for speed." The engineer who found it did exactly what this page did - wrote a known gradient in, diffed the bytes out. Since then I don't trust any image pathway I haven't fed a gradient.