Before compression, pixels become scanlines - raw sample bytes with a leading filter byte per row. Today you serialize an image to those bytes using the simplest filter, None.
Serialize an RGBA image to filtered scanline bytes using filter type 0 (None) on every row.
The decoder unfiltered scanlines into pixels; the encoder does the reverse, turning pixels back into filtered scanlines. Each row becomes a filter-type byte followed by the row’s raw sample bytes - and the simplest, always-valid choice is filter None (type 0), which writes the samples unchanged. For a 2-pixel RGBA row that is a 0 byte then eight sample bytes. This is exactly the layout Inflate produced on the way in and unfilter consumed, now built rather than parsed.
Filter None produces a valid PNG that any decoder reads; it just does not help compression. That is a deliberate, honest starting point - correctness first, then squeeze. The next lesson adds the Sub filter as a real alternative so the encoder can reduce redundancy before compressing, but everything downstream (deflate, zlib, IDAT) works identically whichever filter each row used, because the filter byte travels with the row. Get the per-row filter byte and sample order right and these bytes are ready to compress.
// for each row y: append filter byte 0, then for each pixel append R,G,B,A.func toScanlines(im *Image) []byte { }