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