Skip to content
The Technical ArtistArt. Code. Pipelines. Games.
Home/Field Notes/Breakdown: Channel Packer
breakdown
postmortem
tool: t-07

Breakdown: The Channel Packer

Deep Endarticle~4 min read

This breakdown explains why the Texture Channel Packer writes PNG data directly. A canvas round trip can change RGB values when alpha is low, which matters when those channels hold material data. We’ll look at the failure, the byte-level check, and the fix.

On this page The Problem The Obvious Build The Trap Verifying The Suspicion The Fix: Own The Bytes What It Teaches

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. Doing this by hand is repetitive, so a browser tool with one input per channel is useful. The important part is checking that the exported channel values still match the inputs.

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 colour 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 - quantises 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. It’s worth testing any packer with a known gradient and low alpha values before relying on it for material data.

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: quantised. Quantisation at exactly the granularity 255/alpha predicts is a fingerprint, and it pointed straight at the premultiplication docs. A small, controlled test helps separate quantisation from a channel-order or colour-space error.

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"); CRC32 can be implemented as a short table-based function. 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.
  • A plausible preview isn’t enough. A slightly incorrect file can pass a visual check and cause problems further down the pipeline. Pipeline tools deserve round-trip tests: write, read back, compare exact values. Keep a round-trip test so future changes are checked against the same values.
  • 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 practises what the portfolio guide preaches, dead end first.
A practical example

The same test is useful for other texture-processing steps. Feed in a known gradient, read the output back, and compare the values. If a lightmap develops banding or blotches, test each conversion separately before assuming the baker is responsible.