A PNG file is a pile of bytes, and somehow a sprite appears on screen. With SDL_LoadPNG_IO it is one line, so you rarely look at what happens in between. Until an image shows up with swapped colors, or a pixel loop makes a mess of a surface. So I drew the whole way from the file to the pixel in four panels.
The sprite is a 64x64 gem in RGBA with a soft glow around it, its alpha fading from 90 to 0. It sits in the program as a byte array (asset.hpp, made by a small Python script), so nothing is loaded from disk at run time. Panel 1 shows the bytes of the file, panel 2 the SDL_Surface, panel 3 the texture and panel 4 a CPU loop over the pixels. A click on one of the images picks a pixel, and panel 2 then shows its four bytes and the calculation for the offset.
What is in the file
Every PNG starts with the same eight bytes: 89 50 4E 47 0D 0A 1A 0A. The first byte has its top bit set, which catches transfers that strip it. Then come "PNG" and a CR, LF, Ctrl-Z, LF that gives away anyone who rewrote the line endings. After that it is chunks all the way: four bytes of length (big endian), four bytes of type, the data and four bytes of CRC over type and data.
My file has three of them: IHDR (13 bytes with width, height, bit depth 8 and color type 6 for RGBA), IDAT (1952 bytes of compressed pixels) and IEND (empty, its CRC is always AE426082). 8 + 25 + 1964 + 12 are the 2009 bytes of the file. My chunk reader decodes nothing. The length comes from the file, so it is untrusted input and gets checked before it is believed:
const std::size_t left = file.size() - offset;
if (left < 12 || readBe32(&file[offset]) > left - 12) {
out.error = Error::Truncated;
The test sets the length of the IDAT to 0xFF00000E. Computed in 32 bits that would wrap around.
The chips "bad signature", "bad CRC" and "cut off" break the file. With the wrong signature SDL says "File is not a PNG file", with the cut-off file "Corrupt PNG", and neither gives you a surface. With the wrong CRC SDL loads the image without complaint, at least in 3.4.20, the version the demo uses. My chunk reader turns the row orange and names the CRC that should be there. I would not count on SDL to notice damage.
Surface: format and byte order
From here SDL_LoadPNG_IO does the work for you: inflate the zlib data, undo the row filters, store the pixels. What comes back is an SDL_Surface with the pixels in RAM:
SDL_IOStream* io = SDL_IOFromConstMem(app.file.data(), app.file.size()); app.loaded = io != nullptr ? SDL_LoadPNG_IO(io, true) : nullptr; // true: SDL closes the stream
Panel 2 reports SDL_PIXELFORMAT_ABGR8888 and below it "memory order RGBA". That looks backwards. SDL names packed formats after the 32-bit word, from the highest byte to the lowest. On little endian the lowest byte comes first in memory, so the bytes lie as R, G, B, A. The demo works this out from the masks of the format:
inline int byteIndex(std::uint32_t mask, bool littleEndian) {
if (mask == 0) return -1;
const int byte = std::countr_zero(mask) / 8;
return littleEndian ? byte : 3 - byte;
}
For ARGB8888 on little endian that gives B, G, R, A. SDL_PIXELFORMAT_RGBA32 is an alias that picks the matching format for the platform. The loop in panel 4 gets the four indices passed in and does not hardcode "red is byte 0".
The pitch trap
The pitch is the distance between two row starts in bytes. For the loaded surface that is 256, exactly 64 * 4, which is why mixing it up with the row width never shows. Pitches with room behind each row turn up with aligned rows, with crops of larger images and with SDL_LockTexture, which hands you a pitch. The chip "pad rows" imitates that: it copies the pixels into a buffer with 64 bytes of room behind every row and puts a surface over it with SDL_CreateSurfaceFrom. Pitch 320, the room turns orange in the bar in panel 2, and pixel (32,36) sits somewhere else now:
assert(pixelOffset(3, 2, 280, 4) == 2u * 280 + 12); assert(pixelOffset(3, 2, 280, 4) != pixelOffset(3, 2, 256, 4));
The chip "width*4 bug" makes the loop jump to rows with y * width * 4 instead of y * pitch. I expected a sheared image and got none. The loop reads and writes every pixel at the same place, so it simply processes the first 16384 bytes of the buffer, room included. The bottom 12 rows stay untouched (the tip of the gem stays teal while the rest is inverted) and the room gets inverted along with the pixels. A loop that reads from somewhere other than where it writes would shear the image. Without padding the bug stays hidden, and the demo says so.
Surface or texture
SDL_CreateTextureFromSurface copies the pixels into a texture. It lives where the renderer needs it, in video memory with a GPU renderer, and you can no longer simply get at its bytes. The surface stays in RAM and every byte is yours to read and write, but SDL_RenderTexture only draws textures. Scaling, flipping (SDL_RenderTextureRotated) and color and alpha modulation are parameters of the draw call. The bytes in panel 2 stay the same, even with orange and 25 % alpha.
Panel 4 goes the other way: the loop changes the bytes of a copy (gray, invert, mix with orange) and a new texture is made from it. For gray I use the Rec. 601 weights as integers, (77 * r + 150 * g + 29 * b) >> 8, and pure red gives 76. 4096 pixels took 3 to 8 µs in my browser. A single run is below the browser timer's resolution, so the demo averages 1000. Without a compiler barrier in the timing loop it showed 0.0 for me, probably because the compiler merged the identical runs into one.
Nearest or linear
At 4x zoom the difference is obvious. With SDL_SCALEMODE_NEAREST every pixel stays a sharp square, with SDL_SCALEMODE_LINEAR the edges blur, and the 1-pixel checkerboard on the bright face at the top of the gem turns into gray mush. For pixel art I take nearest.
The code is in the repo under image-loading. The project Game Dev Snippets collects all demos of the series.