Zum Inhalt springen
Michael Malura
570 Github Stars 5810 npm Downloads 53 Projekte

Bild laden und rendern

Eine PNG-Datei ist ein Haufen Bytes, und irgendwie wird daraus ein Sprite auf dem Bildschirm. Mit SDL_LoadPNG_IO ist das eine Zeile, deshalb schaut man selten hin, was dazwischen passiert. Bis ein Bild mit vertauschten Farben auftaucht oder eine Pixelschleife auf einer Surface Müll baut. Also hab ich mir den Weg von der Datei bis zum Pixel in vier Panels aufgemalt.

Das Sprite ist ein 64x64-Edelstein in RGBA mit einem weichen Schein drumherum, dessen Alpha von 90 auf 0 ausläuft. Es liegt als Byte-Array im Programm (asset.hpp, erzeugt von einem kleinen Python-Skript), die Demo lädt zur Laufzeit also keine Datei nach. Panel 1 zeigt die Bytes der Datei, Panel 2 die SDL_Surface, Panel 3 die Textur und Panel 4 eine CPU-Schleife über die Pixel. Ein Klick auf eines der Bilder wählt einen Pixel, Panel 2 zeigt dann seine vier Bytes und die Rechnung für den Offset.

Was in der Datei steht

Jede PNG fängt mit denselben acht Bytes an: 89 50 4E 47 0D 0A 1A 0A. Beim ersten Byte ist das oberste Bit gesetzt, das fängt Übertragungen ab, die es abschneiden. Dahinter stehen "PNG" und ein CR, LF, Ctrl-Z, LF, an dem man merkt, wenn jemand Zeilenenden umgeschrieben hat. Danach kommen nur noch Chunks: vier Byte Länge (big endian), vier Byte Typ, die Daten und vier Byte CRC über Typ und Daten.

Meine Datei hat drei davon: IHDR (13 Bytes mit Breite, Höhe, Bit-Tiefe 8 und Farbtyp 6 für RGBA), IDAT (1952 Bytes komprimierte Pixel) und IEND (leer, die CRC ist immer AE426082). 8 + 25 + 1964 + 12 sind die 2009 Bytes der Datei. Dekodieren muss mein Chunk-Leser nichts. Die Länge kommt aus der Datei und ist damit Fremdeingabe, also wird sie geprüft, bevor man ihr glaubt:

const std::size_t left = file.size() - offset;
if (left < 12 || readBe32(&file[offset]) > left - 12) {
    out.error = Error::Truncated;

Der Test setzt die Länge des IDAT auf 0xFF00000E. Auf 32 Bit gerechnet würde das überlaufen.

Mit den Chips "bad signature", "bad CRC" und "cut off" machst du die Datei kaputt. Bei der falschen Signatur sagt SDL "File is not a PNG file", bei der abgeschnittenen Datei "Corrupt PNG", beide Male gibt es keine Surface. Bei der falschen CRC lädt SDL das Bild ohne Murren, jedenfalls in 3.4.20, der Version der Demo. Mein Chunk-Leser färbt die Zeile orange und nennt die CRC, die dort stehen müsste. Darauf, dass SDL beschädigte Dateien erkennt, würde ich mich nicht verlassen.

Surface: Format und Byte-Reihenfolge

Ab hier nimmt dir SDL_LoadPNG_IO die Arbeit ab: Zlib entpacken, Zeilenfilter rückgängig machen, Pixel ablegen. Zurück kommt eine SDL_Surface mit den Pixeln im RAM:

SDL_IOStream* io = SDL_IOFromConstMem(app.file.data(), app.file.size());
app.loaded = io != nullptr ? SDL_LoadPNG_IO(io, true) : nullptr;  // true: SDL schließt den Stream

Panel 2 meldet SDL_PIXELFORMAT_ABGR8888 und darunter "memory order RGBA". Das sieht falsch herum aus. SDL benennt gepackte Formate nach dem 32-Bit-Wort, vom höchsten Byte zum niedrigsten. Auf Little Endian liegt das niedrigste Byte zuerst im Speicher, also stehen die Bytes als R, G, B, A hintereinander. Die Demo rechnet das aus den Masken des Formats aus:

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;
}

Bei ARGB8888 kommt auf Little Endian B, G, R, A heraus. SDL_PIXELFORMAT_RGBA32 ist ein Alias, der je nach Plattform das passende Format wählt. Die Schleife in Panel 4 bekommt die vier Indizes mit und hartkodiert kein "Rot ist Byte 0".

Die Pitch-Falle

Der Pitch ist der Abstand zwischen zwei Zeilenanfängen in Bytes. Bei der geladenen Surface sind das 256, also genau 64 * 4, und deshalb fällt die Verwechslung mit der Zeilenbreite nie auf. Pitches mit Luft dahinter kommen bei ausgerichteten Zeilen vor, bei Ausschnitten aus größeren Bildern und bei SDL_LockTexture, das dir einen Pitch zurückgibt. Der Chip "pad rows" baut das nach: Er kopiert die Pixel in einen Puffer mit 64 Byte Luft hinter jeder Zeile und legt per SDL_CreateSurfaceFrom eine Surface darüber. Pitch 320, im Balken in Panel 2 wird die Luft orange, und der Pixel (32,36) liegt an einer anderen Stelle:

assert(pixelOffset(3, 2, 280, 4) == 2u * 280 + 12);
assert(pixelOffset(3, 2, 280, 4) != pixelOffset(3, 2, 256, 4));

Der Chip "width*4 bug" lässt die Schleife die Zeilen mit y * width * 4 statt y * pitch anspringen. Ich hatte ein schiefes Bild erwartet, es kam keins. Die Schleife liest und schreibt jeden Pixel an derselben Stelle und bearbeitet damit einfach die ersten 16384 Bytes des Puffers, Luft eingeschlossen. Die untersten 12 Zeilen bleiben unberührt (die Spitze des Steins bleibt teal, der Rest wird invertiert) und die Luft wird mitinvertiert. Eine Schleife, die von woanders liest als sie schreibt, würde das Bild schief ziehen. Ohne Padding ist der Fehler unsichtbar, dann schreibt die Demo "bug hidden" hin.

Surface oder Textur

SDL_CreateTextureFromSurface kopiert die Pixel in eine Textur. Die liegt dort, wo der Renderer sie braucht, bei einem GPU-Renderer im Grafikspeicher, und an ihre Bytes kommst du nicht mehr einfach heran. Die Surface bleibt im RAM, du kannst jedes Byte lesen und schreiben, zeichnen kann SDL_RenderTexture aber nur Texturen. Skalierung, Spiegeln (SDL_RenderTextureRotated) und Farb- und Alpha-Modulation sind Parameter beim Zeichnen. Die Bytes in Panel 2 bleiben dieselben, auch mit Orange und 25 % Alpha.

Panel 4 geht den anderen Weg: Die Schleife ändert die Bytes einer Kopie (Grau, Invertieren, Mischen mit Orange) und daraus entsteht eine neue Textur. Für Grau nehme ich die Rec.601-Gewichte als Ganzzahlen, (77 * r + 150 * g + 29 * b) >> 8, Reinrot ergibt 76. 4096 Pixel brauchten bei mir im Browser 3 bis 8 µs. Das liegt unter der Auflösung des Browser-Timers, also mittelt die Demo über 1000 Läufe. Ohne Compiler-Barriere in der Messschleife stand bei mir 0,0 da, vermutlich weil der Compiler die identischen Läufe zu einem zusammengefasst hat.

Nearest oder linear

Bei 4x Zoom sieht man den Unterschied sofort. Mit SDL_SCALEMODE_NEAREST bleibt jeder Pixel ein scharfes Quadrat, mit SDL_SCALEMODE_LINEAR verlaufen die Kanten, und das 1-Pixel-Schachbrett auf der hellen Fläche oben im Stein wird zu grauem Brei. Bei Pixelart nehme ich nearest.

Den Code findest du im Repo unter image-loading. Alle Demos der Serie sammelt das Projekt Game Dev Snippets.

04.09.2026 aktualisiert 06.11.2026
Teilen X Reddit Hacker News LinkedIn E-Mail
Neue Beiträge abonnieren RSS JSON