Projekt (PixelRAM / C)Im Handbuch: Workspace: Grundlagen C zum Nachschlagen PixelRAM
image: interference.webp image_alt: "Bunte, ineinander verschlungene Farbringe mit weichen Kanten" kind: projekt duration: 3 Doppelstunden curriculum: "Sek I: Digitale Bilder und Visualisierung (Rastergrafik, Farbmodelle, Figuren und Animationen); Oberstufe: Vertiefung Computergrafik (Bilder aus Formeln, Bildschirmspeicher)" topics: [digitale-bilder, animation, computergrafik] language: PixelRAM / C reference: [c, pixelram] template: https://github.com/specht/pixelram-starter.git template_name: des PixelRAM-Starters

Echtzeitgrafik mit C

Mit PixelRAM programmierst du schnelle Computergrafik direkt in C. Es gibt keine Befehle für Linien, Kreise oder Figuren: Dein Programm bestimmt die Farbe jedes einzelnen Bildpunkts selbst. Daraus entstehen in diesem Tutorial bewegte Wellenmuster und ein Spiel für zwei an einer Tastatur.

Bei Pixelflow Canvas zeichnest du mit einfachen Befehlen in Ruby. PixelRAM geht eine Ebene tiefer: Der Bildschirm ist ein Stück Speicher, in dem für jeden Bildpunkt eine Zahl steht, und dein Programm schreibt diese Zahlen. Das ist der Bildschirm, wie ihn die ersten PCs hatten, und alles andere baust du dir selbst: Muster, Animationen, Spiele, Fraktale, eigene 3D-Grafik.

Dafür verwenden wir C. Die Sprache ist nah an der Arbeitsweise des Computers und schnell genug, um für jedes Bild zehntausende Pixel einzeln auszurechnen, im Browser, 60-mal pro Sekunde.

1. Einrichten

Repository klonen

Für diese Anleitung brauchst du die PixelRAM-Vorlage:

Stelle zuerst sicher, dass du keinen Ordner geöffnet hast. Um sicherzugehen, drücke einfach den Shortcut für »Ordner schließen«: StrgK und dann F. Dein Workspace sollte jetzt ungefähr so aussehen:

Ein leerer Workspace ohne geöffneten Ordner.

Klicke auf »Clone Repository« und füge die Adresse der Vorlage ein:

https://github.com/specht/pixelram-starter.git

Das Eingabefeld für die Adresse des Repositorys.

Bestätige mit Enter. Danach wählst du das Verzeichnis, in dem das Repository gespeichert werden soll: den Ordner /workspace.

Die Auswahl des Zielordners /workspace.

Das Repository wird jetzt in einen Unterordner von /workspace geklont, der so heißt wie das Repository. Beantworte die Frage, ob es jetzt geöffnet werden soll, mit »Open«:

Die Frage, ob das geklonte Repository geöffnet werden soll.

Übersetzen und ansehen

Öffne die Datei main.c und gib im Terminal ein:

make

Beim ersten Mal lädt make die neueste Version der Bibliothek PixelRAM herunter und legt sie im versteckten Ordner .pixelram ab; danach nimmt es immer diese Kopie. Mit make pixelram-update holst du später die neueste Version. Dann übersetzt es main.c in die Datei main.html: Dein C-Programm läuft als WebAssembly im Browser.

Der Workspace mit main.c im Editor; im Terminal darunter hat make die Datei main.html erzeugt.

Unten rechts in der Statusleiste der Knopf »Go Live« mit dem Mauszeiger darauf und dem Hinweis »Click to run live server«.

Klicke unten rechts auf »Go Live«. Im neuen Tab siehst du die Dateien des Projekts; klicke dort auf main.html. Du solltest einen kleinen grünen Punkt in der Mitte sehen:

Ein kleiner grüner Punkt leuchtet in der Mitte einer schwarzen Fläche.

Röhrenbildschirm: Fährst du mit der Maus über das Bild, erscheinen unten rechts vier Knöpfe. Der dritte schaltet den CRT-Filter ein (oder F6): Scanlines und die gewölbte Scheibe eines alten Röhrenbildschirms, passend zu den großen Pixeln. Die Bilder auf dieser Seite haben dafür oben rechts einen eigenen Knopf mit demselben Zeichen . Der erste Knopf mit der Kamera (oder F9) lädt das Bild als PNG herunter, in der Größe, die dein Programm hat, ohne CRT-Filter; fehlt er, holt make pixelram-update die neueste Fassung von PixelRAM.
So arbeitest du ab jetzt: Datei ändern und speichern, im Terminal make, dann die Vorschau neu laden, falls sie es nicht von selbst tut. Findet make einen Fehler, nennt es die Zeile in deiner Datei, und main.html bleibt, wie sie war. Lies die erste Fehlermeldung zuerst, die übrigen sind oft nur Folgefehler.

2. Ein Bild aus einer Formel

Das erste Pixel

So sieht main.c aus:

#include "pixelram.h"

int main(void)
{
    if (!screen_open(320, 180, pixel_indexed8, "PixelRAM"))
        return 1;

    set_pixel(160, 90, 10);

    return 0;
}

screen_open(320, 180, pixel_indexed8, "PixelRAM") öffnet einen Bildschirm mit 320 × 180 Pixeln. set_pixel(160, 90, 10) setzt genau einen davon: an der Stelle x = 160, y = 90, also in der Mitte, auf die Farbe 10. Der Punkt (0, 0) liegt links oben, y wächst nach unten.

Im Modus pixel_indexed8 ist jedes Pixel ein Byte, eine Zahl von 0 bis 255. Welche Farbe zu einer Zahl gehört, legt eine Palette fest: In der Standardpalette ist 10 ein helles Grün und 0 Schwarz.

Alle Pixel

Der Bildschirm hat 320 × 180 = 57.600 Pixel. Für jedes einen eigenen set_pixel-Befehl zu schreiben, wäre unsinnig. Stattdessen laufen zwei verschachtelte Schleifen über alle Zeilen und darin über alle Spalten, und eine Funktion color sagt für jedes Pixel, welche Farbe es bekommt. Ersetze den Inhalt von main.c durch:

#include "pixelram.h"

#define W 320
#define H 180

int color(int x, int y)
{
    return (x / 20 + y / 20) % 16;
}

int main(void)
{
    screen_open(W, H, pixel_indexed8, "PixelRAM");
    use_palette("sweetie_16");

    for (int y = 0; y < H; y++)
        for (int x = 0; x < W; x++)
            set_pixel(x, y, color(x, y));

    return 0;
}

#define W 320 gibt der Breite einen Namen, damit die Zahl nur an einer Stelle steht. use_palette("sweetie_16") wählt eine Palette mit 16 Farben, die Nummern 0 bis 15. Deshalb rechnet color am Ende % 16: Der Rest beim Teilen durch 16 liegt immer zwischen 0 und 15.

Diagonale Bänder aus bunten quadratischen Farbfeldern füllen die Fläche.

x / 20 ist eine Ganzzahldivision: Für x = 0 bis 19 kommt 0 heraus, für 20 bis 39 kommt 1 heraus, und so weiter. So entstehen Felder von 20 × 20 Pixeln. Dein Programm zeichnet keine Quadrate. Es beantwortet für jedes Pixel einzeln die Frage: Welche Farbe hast du? Darum geht es auf dieser ganzen Seite.

Aufgabe 1Eine Farbe ausrechnen

Welche Farbnummer gibt color für das Pixel x = 250, y = 175 zurück?

Tipp

Rechne x / 20 und y / 20 als ganze Zahlen, ohne Rest, zähle sie zusammen und nimm davon den Rest beim Teilen durch 16.

Formeln ausprobieren

Ändere nur die Zeile mit return in color. Jede dieser Formeln ergibt ein ganz anderes Bild:

Die dritte Formel zeichnet das Sierpiński-Dreieck, ein Fraktal. x & y verknüpft die beiden Zahlen Bit für Bit: Ein Bit des Ergebnisses ist 1, wenn es in x und in y 1 ist. Das Pixel wird weiß (Farbe 12), wenn x und y keine einzige 1 an derselben Stelle haben. ? und : sind ein Wenn-dann-sonst in einer Zeile. x ^ y in der vierten Formel ist das bitweise exklusive Oder.

Erfinde eigene Formeln. Was passiert, wenn du in der zweiten Formel x durch x - 160 und y durch y - 90 ersetzt? Wie bekommst du senkrechte Streifen, wie ein Schachbrett? Achte darauf, dass das Ergebnis zwischen 0 und 15 liegt.

Farbpaletten

Dieselben Zahlen sehen mit einer anderen Palette ganz anders aus. Ersetze "sweetie_16" zum Beispiel durch "pico_8" (16 Farben), "endesga_32" (32 Farben) oder "aap_64" (64 Farben). Hat eine Palette mehr Farben, kannst du statt % 16 auch % 32 oder % 64 rechnen.

Alle eingebauten Paletten siehst du unter specht.github.io/pixelram/palettes.html. Mit set_palette(nummer, rot, grün, blau) stellst du dir eine einzelne Farbe selbst ein, jeweils mit Werten von 0 bis 255.

3. Wellen

Jetzt wird die Formel für color eine Rechnung mit Abständen und Winkelfunktionen, und am Ende bewegt sich alles.

Abstand vom Mittelpunkt

Wie weit ist ein Pixel von der Mitte des Bildschirms entfernt? Der Satz des Pythagoras sagt es: Mit dx und dy, dem Abstand nach rechts und nach unten, ist der Abstand sqrt(dx * dx + dy * dy). Für sqrt brauchst du die Mathematik-Bibliothek: Schreib #include <math.h> unter die erste Zeile.

Dafür rechnet color mit Kommazahlen, double: int color(double x, double y). Die Schleife in main gibt weiter ganze Zahlen hinein, aber color kann so auch die Farbe zwischen zwei Pixeln ausrechnen; das braucht das Programm gleich. Die Mitte ist (W / 2.0, H / 2.0), denn mit 2.0 statt 2 rechnet C mit Kommazahlen. Am Ende schneidet (int) die Nachkommastellen ab, weil % nur mit ganzen Zahlen geht.

Jetzt bist du dran: Schreib color neu: dx und dy sind der Abstand des Punkts (x, y) zur Mitte, distance ist der Abstand nach Pythagoras, und alle 6 Pixel Abstand kommt die nächste Farbe: (int)(distance / 6.0) % 16.
Tipp

Rechne für den Punkt (190, 130) von Hand: Wie weit liegt er rechts und unterhalb der Mitte (160, 90), wie weit ist er insgesamt von ihr entfernt, und welche Farbe bekommt er?

Nächster Tipp

Ein Gerüst:

int color(double x, double y)
{
    double dx = …;
    double dy = …;
    double distance = …;

    return (int)(distance / 6.0) % 16;
}
Lösung anzeigen
int color(double x, double y)
{
    double dx = x - W / 2.0;
    double dy = y - H / 2.0;
    double distance = sqrt(dx * dx + dy * dy);

    return (int)(distance / 6.0) % 16;
}

Konzentrische Kreise in kräftigen Regenbogenfarben.

Pixel mit ungefähr demselben Abstand bekommen dieselbe Farbe. So entstehen Kreise, obwohl das Programm keinen einzigen Kreis zeichnet. Probiere aus, was passiert, wenn du 6.0 größer oder kleiner machst.

Aufgabe 2Abstand und Farbe

Welche Farbnummer bekommt das Pixel x = 220, y = 10 mit der neuen Funktion color?

Tipp

Die Mitte ist (160, 90). Also dx = 220 - 160 und dy = 10 - 90; dann Pythagoras, durch 6, abschneiden und % 16.

Aufgabe 3Dein color mit dem Abstand

Schreib color mit dem Abstand zur Mitte in main.c und speichere. Der Workspace fragt die Farbe an ein paar Stellen ab.

Kreiswellen

Die Ringe haben harte Kanten. Weicher wird es mit der Sinusfunktion: sin liefert Werte, die gleichmäßig zwischen −1 und 1 hin- und herpendeln. sin(distance * 0.12) wird damit zu einer Welle um die Mitte, mit einem Berg alle gut 52 Pixel.

Jetzt bist du dran: Ersetze die letzte Zeile von color durch zwei: double wave = sin(distance * 0.12); und ein return, das aus wave eine Farbe von 0 bis 15 macht, 0 im Wellental, 15 auf dem Berg.
Tipp

Welche Farbe soll ein Pixel bekommen, wenn wave −1 ist, welche bei 1 und welche bei 0? Wie machst du aus den Zahlen von −1 bis 1 erst Zahlen von 0 bis 2 und daraus welche von 0 bis 15?

Nächster Tipp

Ein Gerüst:

    double wave = sin(distance * 0.12);
    return (int)(…);   // aus −1 bis 1 wird 0 bis 15
Lösung anzeigen
    double distance = sqrt(dx * dx + dy * dy);    double wave = sin(distance * 0.12);    return (int)((wave + 1.0) * 7.5);

wave + 1.0 liegt zwischen 0 und 2, mal 7,5 ergibt das 0 bis 15: genau die 16 Farben der Palette.

distance * 0.20 statt 0.12 rückt die Wellen enger zusammen.

Viele konzentrische Ringe mit weichen Farbverläufen.

Aufgabe 4Deine Welle in 16 Farben

Mach in color aus wave eine Farbe von 0 bis 15 und speichere. Der Workspace sieht sich die Farben aller Pixel an.

Ausprobieren: Eine eigene Palette. Schreib in main unter use_palette die Zeile for (int i = 0; i < 16; i++) set_palette(i, i * 17, 0, 255 - i * 17);. Jetzt geht Farbe 0 (Blau) in 16 gleichen Schritten zu Farbe 15 (Rot), und aus den bunten Ringen werden weiche Wellenberge. Wie sieht ein Verlauf von Schwarz über Rot und Gelb bis Weiß aus, wie im Feuer? Dort steht, wie man ihn aus mehreren Abschnitten baut.

Glatte Kanten

Sieh dir die Ränder der Ringe genau an: Treppenstufen. Jedes Pixel bekommt genau eine der 16 Farben, also verläuft jede Grenze zwischen zwei Farben in Stufen. Gleich bewegen sich die Ringe, dann springen die Stufen bei jedem Bild von Pixel zu Pixel, und entlang jeder Kante krabbelt und flimmert es.

Auf den Bildschirmen der 80er- und 90er-Jahre fiel das kaum auf. Ein Röhrenbildschirm (CRT, Cathode Ray Tube) hat keine Pixel mit scharfen Rändern: Ein Elektronenstrahl fährt Zeile für Zeile über die Leuchtschicht hinter dem Glas, und wo er sie trifft, glüht sie auf, in einem runden Fleck mit weichem Rand. Der Strahl springt auch nicht von einer Farbe zur nächsten, er wird stärker oder schwächer, und dafür braucht er ein Stück Weg. So fließt jedes Pixel ein wenig in seine Nachbarn über, vor allem nach links und rechts, und zwischen den Zeilen bleibt ein dunkler Streifen, die Scanline. Treppenstufen wurden auf der Röhre von selbst weich, und wer damals Grafik zeichnete, rechnete damit: Zwei Farben im Schachbrettmuster sahen aus wie eine Mischfarbe. Ein wenig Glättung gab es umsonst. Der CRT-Filter von PixelRAM (das Röhrensymbol, F6) ahmt diesen Bildschirm nach; schalte ihn ein und sieh dir die Kanten noch einmal an.

Ohne Röhre muss das Programm selbst glätten. Ein Pixel ist ein kleines Quadrat, und bisher fragt das Programm nur an einem einzigen Punkt nach der Farbe: an seiner linken oberen Ecke (x, y). Läuft eine Grenze zwischen zwei Ringen mitten durch das Quadrat, bekommt es trotzdem ganz die Farbe dieser einen Ecke. Besser wäre eine Farbe dazwischen, so wie das Pixel zum Teil im einen und zum Teil im anderen Ring liegt. Dafür fragt das Programm an mehreren Punkten im Pixel und mischt die Farben. Mehrere Messpunkte für ein Pixel heißen Supersampling.

Viele Messpunkte für jedes Pixel wären viel Arbeit, und das schafft der Browser nicht 60-mal pro Sekunde, sobald sich etwas bewegt. Nötig sind sie aber nur an den Kanten, und die findet das Programm billig:

  1. Es rechnet wie bisher die Farbe an jedem Punkt (x, y) aus, nur eine Spalte und eine Zeile weiter, und merkt sie sich im Array corner. Das sind die vier Ecken jedes Pixels; jede Ecke gehört zu vier Pixeln und wird nur einmal gerechnet.
  2. Haben alle vier Ecken eines Quadrats dieselbe Farbe, liegt es ganz in einem Ring und bekommt diese Farbe.
  3. Sonst läuft eine Kante hindurch. Dann teilt das Programm das Quadrat in vier kleinere, rechnet die Farbe an den fünf neuen Ecken aus, den Mitten der vier Seiten und der Mitte, und macht mit jedem der vier kleinen Quadrate dasselbe wie in 2. und 3. Die Farbe des großen Quadrats ist die Mischung der vier kleinen.

Die Funktion square ruft sich dafür selbst auf: Sie ist rekursiv. Damit das ein Ende hat, zählt levels mit, wie oft sie noch teilen darf. Bei 0 nimmt square einfach die Farbe der linken oberen Ecke, so wie bisher. LEVELS 0 ist also genau das Bild von eben, LEVELS 1 teilt ein Pixel an einer Kante einmal (bis zu 4 Messpunkte), LEVELS 2 zweimal (bis zu 16), LEVELS 3 dreimal (bis zu 64). Genauer hinsehen nur dort, wo es etwas bringt: Das heißt adaptives Supersampling.

An color ändert sich nichts. Ersetze main durch:

    return (int)((wave + 1.0) * 7.5);}// wie oft ein Pixel an einer Kante höchstens geteilt wird#define LEVELS 1// die Farben an den Ecken der Pixel: eine Zeile und eine Spalte mehrint corner[H + 1][W + 1];// Die Farbe eines Quadrats mit der linken oberen Ecke (x, y) und der// Seitenlänge size; c00, c10, c01 und c11 sind die Farben seiner Ecken.void square(double x, double y, double size, int c00, int c10, int c01, int c11, int levels, int rgb[3]){    if (levels == 0 || (c00 == c10 && c00 == c01 && c00 == c11))    {        // alle Ecken gleich (oder nicht weiter teilen): eine Farbe        uint8_t r, g, b;        get_palette(c00, &r, &g, &b);        rgb[0] = r;        rgb[1] = g;        rgb[2] = b;        return;    }    // eine Kante läuft durch: in vier kleinere Quadrate teilen    double half = size / 2;    int top    = color(x + half, y);    int left   = color(x,        y + half);    int middle = color(x + half, y + half);    int right  = color(x + size, y + half);    int bottom = color(x + half, y + size);    int upper_left[3], upper_right[3], lower_left[3], lower_right[3];    square(x,        y,        half, c00,    top,    left,   middle, levels - 1, upper_left);    square(x + half, y,        half, top,    c10,    middle, right,  levels - 1, upper_right);    square(x,        y + half, half, left,   middle, c01,    bottom, levels - 1, lower_left);    square(x + half, y + half, half, middle, right,  bottom, c11,    levels - 1, lower_right);    // und ihre Farben mischen    for (int i = 0; i < 3; i++)        rgb[i] = (upper_left[i] + upper_right[i] + lower_left[i] + lower_right[i]) / 4;}// zeichnet das ganze Bild mit color, die Kanten geglättetvoid draw(void){    for (int y = 0; y <= H; y++)        for (int x = 0; x <= W; x++)            corner[y][x] = color(x, y);    for (int y = 0; y < H; y++)        for (int x = 0; x < W; x++)        {            int rgb[3];            square(x, y, 1.0, corner[y][x], corner[y][x + 1], corner[y + 1][x], corner[y + 1][x + 1], LEVELS, rgb);            set_pixel_rgb(x, y, rgb[0], rgb[1], rgb[2]);        }}int main(void){    screen_open(W, H, pixel_rgb24, "PixelRAM");    use_palette("sweetie_16");    draw();    return 0;}
  • Paletten-Nummern lassen sich nicht mischen: Der Mittelwert aus Farbe 5 und Farbe 6 ist 5,5, und das ist keine Farbe. Das Programm öffnet den Bildschirm deshalb mit pixel_rgb24, und jedes Pixel bekommt mit set_pixel_rgb seinen Rot-, Grün- und Blauanteil, jeweils von 0 bis 255.
  • Die Palette bleibt trotzdem: get_palette(nummer, &r, &g, &b) schreibt die drei Anteile einer Paletten-Farbe in r, g und b. Das & gibt der Funktion die Adressen der Variablen, damit sie hineinschreiben kann. Jeder Messpunkt ist also eine der 16 Farben; nur Pixel auf einer Kante bekommen eine Mischung.
  • square gibt drei Zahlen zurück, Rot, Grün und Blau. Eine Funktion in C kann nur einen Wert zurückgeben, deshalb bekommt square das Array rgb und schreibt hinein: Ein Array als Parameter ist dasselbe Array wie beim Aufruf, kein neues.
  • corner steht außerhalb von draw: Mit 181 × 321 Zahlen ist das Array zu groß für den Platz, den eine Funktion im Browser für ihre Variablen hat.

So sieht ein Ausschnitt aus der Nähe aus, mit LEVELS von 0 bis 3:

Die Pixel innen in den Ringen sind bei jeder Stufe genau wie vorher, das Bild wird nicht unscharf. Nur an den Grenzen sitzen Pixel in Zwischentönen: Je mehr von einem Pixel im einen Ring liegt, desto näher ist seine Farbe an dessen Farbe. Den größten Schritt macht LEVELS 1, danach werden die Unterschiede klein, die Arbeit aber nicht: Bei den beiden Wellen weiter unten braucht ein Bild mit LEVELS 1 etwa zweieinhalbmal so lange wie ohne Glätten, mit LEVELS 2 gut fünfmal und mit LEVELS 3 elfmal. Für alles, was sich bewegt, ist LEVELS 1 die richtige Wahl; für ein einzelnes Bild darf es mehr sein.

Ausprobieren: Stell LEVELS auf 2 und 3 und vergleiche. Wo sieht das Programm genauer hin? Schreib in square ganz unten, nach der Schleife, die das Mischen erledigt, rgb[0] = 255; rgb[1] = 0; rgb[2] = 0;: Jetzt ist jedes Pixel rot, das das Programm geteilt hat. Und schalte den CRT-Filter ein und aus: Wie viel macht er bei geglätteten Kanten noch aus?

Bewegung

Bisher rechnet das Programm ein einziges Bild und ist dann fertig. Für eine Animation braucht es eine Schleife, die immer wieder ein neues Bild berechnet, bis jemand die Seite schließt:

while (!should_close())
{
    // ein Bild berechnen
    present();   // und zeigen
}

present() zeigt das fertige Bild und wartet bis zum nächsten, höchstens 60-mal pro Sekunde. seconds() sagt, wie viele Sekunden seit dem Start vergangen sind. Steckt diese Zeit in der Sinusfunktion, wandern die Wellen nach außen.

Die Zeit braucht color, gesetzt wird sie in main: Sie steht deshalb als Variable t außerhalb von beiden, wo jede Funktion sie lesen kann, über color:

#define W 320#define H 180// Sekunden seit dem Start, für jedes Bild neudouble t;int color(double x, double y)

In color werden die drei Zeilen für den Abstand zu einer, und in sin kommt - t * 2.0 dazu:

int color(double x, double y){    double distance = hypot(x - W / 2.0, y - H / 2.0);    double wave = sin(distance * 0.12 - t * 2.0);    return (int)((wave + 1.0) * 7.5);}

hypot(dx, dy) rechnet dasselbe wie sqrt(dx * dx + dy * dy), nur kürzer. draw bleibt, wie es ist. In main kommt die Schleife um draw():

int main(void){    screen_open(W, H, pixel_rgb24, "PixelRAM");    use_palette("sweetie_16");    while (!should_close())    {        t = seconds();        draw();        present();    }    screen_close();    return 0;}

Kein Kreis wird verschoben: Das Programm rechnet jedes Bild komplett neu, und weil t wächst, liefert sin jedes Mal ein wenig andere Werte. Die Kanten gleiten dabei ruhig nach außen, weil draw sie glättet.

Wie schnell die Wellen wandern, hängt dabei nur an der Uhr, nicht daran, wie viele Bilder pro Sekunde der Rechner schafft: Nach einer Sekunde ist t um 1 größer, ob in dieser Sekunde 144 Bilder gezeigt wurden, 60 oder nur 20. Auf einem langsamen Rechner springen die Wellen von Bild zu Bild ein größeres Stück, kommen aber in einer Sekunde genauso weit. Würde das Programm dagegen in jedem Bild ein festes Stück weiterrechnen, liefen die Wellen auf jedem Bildschirm anders schnell.

Aufgabe 5Die Wellen bewegen sich

Bau die Schleife mit should_close, seconds und present in main.c ein, nimm t in die Sinusfunktion, übersetze mit make und sieh dir die Wellen in der Vorschau an.

Vermutung 6Plus statt minus

Was passiert, wenn du in color - t * 2.0 durch + t * 2.0 ersetzt? Überleg erst, dann probier es aus.

Plus statt minus
Ausprobieren: Schreib set_target_fps(10); unter use_palette("sweetie_16");, übersetze mit make und lade die Vorschau neu. Jetzt zeigt PixelRAM höchstens 10 Bilder pro Sekunde statt 60. Achte darauf, wie weit ein Ring in einer Sekunde kommt, und wie gleichmäßig er dabei wandert. Nimm die Zeile danach wieder heraus.

Zwei Wellen treffen sich

Die Quelle der Wellen muss nicht in der Mitte bleiben. Mit cos und sin der Zeit wandert ein Punkt in Schleifen über den Bildschirm. Nimm zwei solche Quellen, rechne für jedes Pixel beide Wellen aus und zähle sie zusammen. Die beiden Quellen stehen wie t außerhalb der Funktionen, color misst den Abstand zu beiden:

// Sekunden seit dem Start, für jedes Bild neudouble t;// zwei Wellenquellen, die durchs Bild wanderndouble ax, ay, bx, by;int color(double x, double y){    double d1 = hypot(x - ax, y - ay);    double d2 = hypot(x - bx, y - by);    double wave = sin(d1 * 0.12 - t * 1.0) + sin(d2 * 0.12 + t * 1.15);    return (int)((wave + 2) * 3.75);}

main bewegt die Quellen bei jedem Bild ein Stück weiter:

    while (!should_close())    {        t = seconds();        ax = W / 2.0 + cos(t * 0.4) * 70;        ay = H / 2.0 + sin(t * 0.55) * 40;        bx = W / 2.0 + cos(t * 0.65) * 90;        by = H / 2.0 + sin(t * 0.35) * 50;        draw();        present();    }
Vermutung 7Oben und unten

An einem Pixel ist die eine Welle gerade ganz oben (sin ergibt 1) und die andere ganz unten (−1). Welche Farbe bekommt das Pixel? Überleg erst, dann übersetze und sieh dir das Bild an.

Oben und unten

Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.

Wo beide Wellen gleichzeitig oben sind, verstärken sie sich, wo die eine oben und die andere unten ist, löschen sie sich aus. Das nennt man Interferenz. Die Summe liegt zwischen −2 und 2, deshalb rechnet die letzte Zeile von color (wave + 2) * 3.75.

Bunte Wellenringe von zwei Quellen überlagern sich zu einem verwickelten Muster mit weichen Kanten.

Für jedes Bild rechnet color an 58.000 Ecken zwei Abstände und zwei Sinuswerte aus, und weil die Ringe hier dicht liegen, an vielen Kanten noch einmal: zusammen rund 200.000-mal pro Bild, bei 60 Bildern pro Sekunde rund 12 Millionen-mal pro Sekunde. Für solche Rechnungen ist C gemacht.

Experimente. Mehr Ringe mit d1 * 0.18, schnellere Wellen mit t * 2.0, weitere Wege mit cos(t * 0.4) * 110. Füge eine dritte Quelle hinzu: Wie musst du dann die letzte Zeile von color ändern, damit die Farben wieder zwischen 0 und 15 liegen? Und wie sieht es mit "aap_64" und 64 Farben aus?

4. Ein Spiel: Lichtspur

Zwei Spuren fahren über das Spielfeld und ziehen eine Linie hinter sich her. Wer in eine Linie oder an die Wand fährt, verliert die Runde. Gespielt wird zu zweit an einer Tastatur, mit WASD und mit den Pfeiltasten.

Das Spiel ist älter als die ersten Heimcomputer, die man fertig kaufen konnte, etwa den Apple II von 1977. 1976 stellte die Firma Gremlin den Spielautomaten Blockade in die Spielhallen: Zwei Spieler steuern je einen Pfeil, der eine Mauer aus Blöcken hinter sich herzieht, und wer gegen eine Mauer fährt, verliert. Berühmt wurde die Idee 1982 durch den Film Tron. Darin leben Programme in einem Computer und fahren auf leuchtenden Motorrädern, den Light Cycles, gegeneinander; wer die Lichtspur des anderen berührt, ist ausgelöscht. Und aus Blockade wurde Snake, die Schlange, die auf Handys von Nokia ab 1998 Millionen Menschen gespielt haben.

Das Spiel bekommt eine eigene Datei. Lege lichtspur.c neben main.c an und übersetze sie mit:

make PROGRAM=lichtspur

Das ergibt lichtspur.html, die du in der Vorschau öffnest. So kann jedes Programm im Ordner seine eigene Datei haben.

Eine Spur, die fährt

#include "pixelram.h"

#define W 160
#define H 90
#define STEP (1.0 / 30)   // ein Schritt alle 1/30 Sekunde

int main(void)
{
    screen_open(W, H, pixel_indexed8, "Lichtspur");
    use_palette("sweetie_16");

    int x = 20, y = H / 2;   // Position
    int dx = 1, dy = 0;      // Richtung: nach rechts

    double last = seconds();
    double lag = 0;          // Zeit, für die noch Schritte fehlen

    while (!should_close())
    {
        if (key_pressed(pixel_key_up))    { dx = 0;  dy = -1; }
        if (key_pressed(pixel_key_down))  { dx = 0;  dy = 1;  }
        if (key_pressed(pixel_key_left))  { dx = -1; dy = 0;  }
        if (key_pressed(pixel_key_right)) { dx = 1;  dy = 0;  }

        double now = seconds();
        lag += now - last;
        last = now;
        if (lag > 0.25)   // nach einer Pause nicht davonrasen
            lag = 0.25;

        while (lag >= STEP)
        {
            x += dx;
            y += dy;
            set_pixel(x, y, 11);
            lag -= STEP;
        }

        present();
    }

    screen_close();
    return 0;
}
Vermutung 8Kurz gedrückt

Die Spur fährt nach rechts. Du drückst einmal kurz ↑ und lässt die Taste wieder los. Was macht die Spur? Überleg erst, dann probier es aus.

Kurz gedrückt

Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.

Klicke in die Vorschau, damit sie die Tasten bekommt, und steuere mit den Pfeiltasten. Dreierlei ist neu:

  • Das Spielfeld hat nur 160 × 90 Pixel, also große Pixel.
  • key_pressed ist in genau dem Bild wahr, in dem eine Taste heruntergedrückt wurde. dx und dy merken sich die Richtung, und bei jedem Schritt geht die Spur einen Pixel weiter.
  • Das Programm löscht den Bildschirm nie. Jedes gesetzte Pixel bleibt stehen, so entsteht die Spur.

Eine türkise Linie fährt nach rechts, macht eine Schleife und kreuzt dabei ihre eigene Spur.

Ein fester Takt

Die Spur fährt einen Pixel nach dem anderen, sie kann nicht wie die Wellen einfach ein krummes Stück weiterrücken. Würde sie in jedem Bild einen Schritt machen, wäre sie auf jedem Bildschirm anders schnell: Viele zeigen 60 Bilder pro Sekunde, manche 120 oder 144, ein langsamer Rechner schafft vielleicht nur 20, und ein Tab im Hintergrund zeigt gar keine. Deshalb fährt die Spur in einem festen Takt, nach der Uhr: alle STEP Sekunden, also 30-mal pro Sekunde, einen Schritt.

lag sammelt die Zeit, die seit dem letzten Bild vergangen ist. Die innere while-Schleife macht für jede volle Dreißigstelsekunde darin einen Schritt und zieht sie von lag ab; der Rest wartet auf das nächste Bild. Bei 60 Bildern pro Sekunde fährt die Spur so etwa in jedem zweiten Bild einen Schritt, bei 10 Bildern pro Sekunde drei Schritte pro Bild. Gelenkt wird dagegen in jedem Bild, denn key_pressed ist nur in einem einzigen Bild wahr, und kein Tastendruck soll verloren gehen. War das Programm eine Weile angehalten, zum Beispiel weil der Tab im Hintergrund lag, holt es höchstens eine Viertelsekunde nach, statt Hunderte Schritte auf einmal zu fahren.

Ausprobieren: Schreib set_target_fps(10); unter use_palette("sweetie_16");, übersetze mit make PROGRAM=lichtspur und lade die Vorschau neu. Jetzt zeigt PixelRAM höchstens 10 Bilder pro Sekunde. Achte darauf, wie schnell die Spur über das Feld kommt und in welchen Sprüngen sie wächst. Ändere danach STEP in (1.0 / 60). Schreib zuletzt clear(0); als erste Zeile in die innere while-Schleife: Was bleibt von der Spur übrig, und warum? Nimm alles danach wieder heraus.

Game Programming Patterns, Robert Nystrom, 2014. Das Kapitel »Game Loop« macht genau hier weiter: Es vergleicht Spielschleifen, die in jedem Bild einen Schritt machen, mit solchen, die mit der vergangenen Zeit rechnen, und landet beim festen Takt mit derselben Variable lag. Das ganze Buch ist frei online zu lesen.

Noch kannst du deine eigene Spur kreuzen und aus dem Bild fahren. Das ändert sich jetzt.

Wände und Zusammenstöße

Was auf dem Bildschirm steht, ist Speicher, und Speicher kann man lesen: get_pixel(x, y) gibt die Farbnummer eines Pixels zurück. Ist das Feld, auf das die Spur fahren will, nicht mehr 0 (der Hintergrund), dann steht dort eine Wand oder eine Spur, und es kracht. Du brauchst keine Liste der Linien und keine Rechnung mit Rechtecken: Der Bildschirm selbst ist das Spielfeld.

Zeichne vor der Schleife einen Rahmen in Farbe 14:

for (int x = 0; x < W; x++)
{
    set_pixel(x, 0, 14);
    set_pixel(x, H - 1, 14);
}
for (int y = 0; y < H; y++)
{
    set_pixel(0, y, 14);
    set_pixel(W - 1, y, 14);
}

Bei jedem Schritt sieht das Programm nach, bevor es das neue Pixel setzt. Den Zusammenstoß merkt sich eine Variable bool crashed = false; vor der äußeren Schleife, und die Unfallstelle wird rot, Farbe 2. Für die Wahl zwischen zwei Farben hat C eine kurze Form: crashed ? 2 : 11 ist 2, wenn crashed wahr ist, und sonst 11.

Jetzt bist du dran: Schreib bool crashed = false; vor die äußere Schleife und ändere die innere while-Schleife: Nach dem Schritt fragt sie mit get_pixel, ob auf dem neuen Feld schon etwas steht, und setzt dann crashed auf true. Danach setzt sie das Pixel, rot nach dem Zusammenstoß, sonst wie bisher in Farbe 11. Ist die Spur verunglückt, fährt sie nicht weiter, auch wenn in diesem Bild noch Schritte übrig wären: Das gehört in die Bedingung der Schleife.
Tipp

Die Spur fährt nach rechts und steht bei (157, 45); der Rahmen rechts liegt bei x = 159. Was gibt get_pixel beim nächsten Schritt zurück, was beim übernächsten? Wann wird crashed wahr, und was soll die Schleife danach tun?

Nächster Tipp

In der Schleife kommt zuerst der Schritt, dann ein if mit get_pixel(x, y), dann set_pixel mit crashed ? 2 : 11. Die Bedingung der Schleife bekommt einen zweiten Teil, der falsch wird, sobald es gekracht hat.

Nächster Tipp

Ein Gerüst:

while (… && lag >= STEP)   // hier: nicht weiter nach dem Zusammenstoß
{
    x += dx;
    y += dy;
    if (…)                 // steht auf dem neuen Feld schon etwas?
        crashed = true;

    set_pixel(x, y, …);
    lag -= STEP;
}
Lösung anzeigen
while (!crashed && lag >= STEP)
{
    x += dx;
    y += dy;
    if (get_pixel(x, y) != 0)
        crashed = true;

    set_pixel(x, y, crashed ? 2 : 11);
    lag -= STEP;
}

!crashed in der Bedingung hält die Spur nach dem Zusammenstoß an, auch mitten in einem Bild, in dem sie noch mehrere Schritte vor sich hätte.

Aufgabe 9Wände und Zusammenstöße

Zeichne in lichtspur.c den Rahmen und prüfe mit get_pixel, ob die Spur zusammenstößt. Übersetze mit make PROGRAM=lichtspur und fahr in die Wand.

Vermutung 10Erst nachsehen, dann zeichnen

Was passiert, wenn du die Zeile mit set_pixel vor das if mit get_pixel schiebst, die Spur also erst zeichnest und dann nachsiehst? Überleg erst, dann probier es aus.

Erst nachsehen, dann zeichnen
Zum Nachdenken: Was passiert, wenn du direkt in die Gegenrichtung lenkst?

Zu zweit

Für zwei Spuren bräuchtest du alle Variablen doppelt: x1, y1, dx1, x2, … Übersichtlicher ist ein struct, das alles zusammenfasst, was zu einer Spur gehört. Funktionen wie drive bekommen dann einen Zeiger auf eine Spur, Player *p, und erreichen ihre Teile mit p->x. So arbeitet derselbe Code für beide. Hier das ganze Spiel, bis auf zwei Funktionen, die du kennst:

#include "pixelram.h"

#define W 160
#define H 90
#define WALL 14
#define CRASH 12
#define STEP (1.0 / 30)   // ein Schritt alle 1/30 Sekunde

typedef struct
{
    int x, y;      // Position
    int dx, dy;    // Richtung
    int color;
    bool crashed;
    int wins;
} Player;

void draw_field(Player *a, Player *b)
{
    clear(0);
    for (int x = 0; x < W; x++)
    {
        set_pixel(x, 0, WALL);
        set_pixel(x, H - 1, WALL);
    }
    for (int y = 0; y < H; y++)
    {
        set_pixel(0, y, WALL);
        set_pixel(W - 1, y, WALL);
    }

    // Siege als Punkte in der oberen Wand: links a, rechts b
    for (int i = 0; i < a->wins; i++)
        set_pixel(2 + 2 * i, 0, a->color);
    for (int i = 0; i < b->wins; i++)
        set_pixel(W - 3 - 2 * i, 0, b->color);
}

void start(Player *p, int x, int dx)
{
    p->x = x;
    p->y = H / 2;
    p->dx = dx;
    p->dy = 0;
    p->crashed = false;
}

void steer(Player *p, pixel_key up, pixel_key down, pixel_key left, pixel_key right)
{
    // hier: lenken wie bisher, aber Umdrehen geht nicht
}

void drive(Player *p)
{
    // hier: einen Schritt fahren und merken, ob es kracht
}

int main(void)
{
    screen_open(W, H, pixel_indexed8, "Lichtspur");
    use_palette("sweetie_16");

    Player a = { .color = 3 };    // Orange, fährt mit WASD
    Player b = { .color = 11 };   // Türkis, fährt mit den Pfeiltasten

    bool running = false;   // Die Leertaste startet jede Runde.
    draw_field(&a, &b);

    double last = seconds();
    double lag = 0;         // Zeit, für die noch Schritte fehlen

    while (!should_close())
    {
        double now = seconds();
        lag += now - last;
        last = now;
        if (lag > 0.25)   // nach einer Pause nicht davonrasen
            lag = 0.25;

        if (running)
        {
            steer(&a, pixel_key_w, pixel_key_s, pixel_key_a, pixel_key_d);
            steer(&b, pixel_key_up, pixel_key_down, pixel_key_left, pixel_key_right);
        }
        else if (key_pressed(pixel_key_space))
        {
            draw_field(&a, &b);
            start(&a, 20, 1);
            start(&b, W - 21, -1);
            running = true;
            lag = 0;   // die Runde beginnt ohne Rückstand
        }

        // beide Spuren fahren im selben Takt: immer beide einen Schritt
        while (running && lag >= STEP)
        {
            drive(&a);
            drive(&b);
            if (a.x == b.x && a.y == b.y)   // frontal ineinander
                a.crashed = b.crashed = true;

            set_pixel(a.x, a.y, a.crashed ? CRASH : a.color);
            set_pixel(b.x, b.y, b.crashed ? CRASH : b.color);

            if (a.crashed || b.crashed)
            {
                running = false;
                if (!a.crashed) a.wins++;
                if (!b.crashed) b.wins++;
            }
            lag -= STEP;
        }

        present();
    }

    screen_close();
    return 0;
}
Jetzt bist du dran: Schreib drive: Die Spur p fährt einen Schritt in ihre Richtung, und steht auf dem neuen Feld schon etwas, ist sie verunglückt. Das hast du vorhin schon geschrieben, mit x statt p->x.
Tipp

Welche Zeilen der inneren Schleife von vorhin machen genau das? Was wird aus x, dx und crashed, wenn sie jetzt zur Spur p gehören?

Nächster Tipp

Ein Gerüst:

void drive(Player *p)
{
    p->x += …;
    p->y += …;
    if (…)   // steht auf dem neuen Feld schon etwas?
        p->crashed = true;
}
Lösung anzeigen
void drive(Player *p){    p->x += p->dx;    p->y += p->dy;    if (get_pixel(p->x, p->y) != 0)        p->crashed = true;}
Aufgabe 11Dein drive

Schreib drive in lichtspur.c und speichere. Der Workspace lässt eine Spur einen Schritt fahren, einmal auf ein freies Feld und einmal auf ein Feld, auf dem schon etwas steht.

Jetzt bist du dran: Schreib steer: Mit den vier Tasten, die es bekommt, lenkt es die Spur p wie vorhin mit den Pfeiltasten. Nur umdrehen darf sie nicht mehr, sonst führe sie sofort in ihre eigene Spur: Wer nach rechts fährt, kann nach oben oder unten abbiegen, aber nicht nach links.
Tipp

Die Spur fährt nach rechts, also dx = 1, dy = 0. Welche der vier Tasten darf jetzt die Richtung ändern, welche nicht? Und wenn sie nach oben fährt? Was haben die erlaubten Fälle gemeinsam, wenn du auf dx und dy schaust?

Nächster Tipp

Nach oben oder unten darf die Spur nur abbiegen, wenn sie gerade waagrecht fährt, also p->dy == 0 ist. Eine Zeile als Muster:

    if (key_pressed(up) && p->dy == 0) { p->dx = 0; p->dy = -1; }
Lösung anzeigen
void steer(Player *p, pixel_key up, pixel_key down, pixel_key left, pixel_key right){    // Umdrehen geht nicht: nur abbiegen    if (key_pressed(up)    && p->dy == 0) { p->dx = 0;  p->dy = -1; }    if (key_pressed(down)  && p->dy == 0) { p->dx = 0;  p->dy = 1;  }    if (key_pressed(left)  && p->dx == 0) { p->dx = -1; p->dy = 0;  }    if (key_pressed(right) && p->dx == 0) { p->dx = 1;  p->dy = 0;  }}
Aufgabe 12Dein steer

Schreib steer in lichtspur.c und speichere. Der Workspace drückt eine Taste, während die Spur in eine Richtung fährt, und sieht nach, wohin sie danach fährt.

Zweite Runde: Eine orange und eine türkise Spur haben sich durchs Spielfeld gewunden, bis eine verunglückt ist; die Unfallstelle leuchtet weiß. Links in der oberen Wand steht ein orangefarbener Siegpunkt.

Mit der Leertaste beginnt eine Runde. Die Punkte stehen als Pixel in der oberen Wand.

Aufgabe 13Gleichzeitig

Beide Spuren fahren im selben Schritt gegen eine Wand. Wer bekommt den Punkt? Lies dazu die Schleife in main.

Gleichzeitig

Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.

Gelenkt wird in jedem Bild, gefahren im festen Takt: Bei jedem Schritt fahren beide Spuren, und erst danach wird gezeichnet. Deshalb ist es gerecht, wenn beide gleichzeitig verunglücken: Dann gewinnt niemand.

Bau das Spiel aus. Ein paar Ideen, von leicht bis knifflig:
  • Schneller werden: Mach aus STEP eine Variable, die mit jedem Schritt ein wenig kleiner wird: Je länger eine Runde dauert, desto schneller fahren beide Spuren.
  • Hindernisse: Setze in draw_field ein paar Blöcke in Wandfarbe ins Feld, mit rand() aus <stdlib.h> jedes Mal woanders.
  • Lücken: Lass jede Spur ab und zu ein paar Pixel nicht zeichnen. Durch die Lücken kann man hindurchfahren.
  • Computergegner: Spur b lenkt selbst. Sie schaut mit get_pixel(b.x + b.dx, b.y + b.dy) ein Feld voraus und biegt ab, wenn dort etwas ist. Wie kann sie auch weiter vorausschauen?

Die Maus fragst du mit mouse_position(&x, &y) und mouse_button_down(pixel_mouse_left) ab, alle weiteren Tasten heißen wie pixel_key_a oder pixel_key_enter. Die ganze Liste steht in der Dokumentation.

5. Mehr PixelRAM Extra

Ein Extra für Schnelle. Wer es auslässt, springt zu Zeigen und veröffentlichen.

Beispiele im Starter

Im Ordner beispiele der Vorlage liegen fertige Programme. Jedes übersetzt du mit make PROGRAM=… und öffnest die passende HTML-Datei:

make PROGRAM=beispiele/tunnel

Dazu kommen beispiele/feuer, ein Feuer, in dem jedes Pixel die Hitze seiner Nachbarn darunter aufnimmt, und beispiele/sterne, ein Flug durch 400 Sterne in 3D. Kopiere ein Beispiel neben main.c und verändere es: Das ist oft der schnellste Weg zu einer eigenen Idee.

Der Palettentrick aus beispiele/magie heißt Color Cycling. Der Künstler Mark Ferrari hat damit ganze Landschaften mit Regen, Schnee und Wasser zum Leben erweckt, ohne ein einziges Pixel zu verändern. Hier kannst du seine Bilder ansehen.

Ganze Spiele

PixelRAM ist klein, aber kein Spielzeug. Dieselben wenigen Befehle, die du kennst, tragen auch Spiele aus den Neunzigerjahren, die damals ganz ähnlich programmiert wurden: Ein Programm schreibt Pixel in den Speicher. Sogar DOOM läuft auf PixelRAM. Crystal Caves (1991) ist ein Jump-and-Run, dessen erste Episode frei verteilt werden darf. Probier es aus:

make crystal

Das lädt PixelRAM mit seinen Spiele-Anpassungen und den Quelltext von OpenCrystalCaves, einer freien Neuprogrammierung des Spiels, und übersetzt alles. Das dauert ein, zwei Minuten. Dann öffnest du spiele/crystal.html in der Vorschau. Du läufst mit den Pfeiltasten, springst mit Z und schießt mit X.

Das Intro von Crystal Caves: »Apogee presents« in roter Schrift vor einem Sternenhimmel, unten ein gelber Strahlenkranz.

Die Grafik des Spiels kommt am Ende als Pixel in genau dem Speicher an, den du mit set_pixel beschreibst, und die Tasten kommen über key_down und poll_key_event herein. Wie das Spiel an PixelRAM angeschlossen ist, steht in ports/crystal im Repository von PixelRAM. Wer die Originaldateien besitzt, kann auf dieselbe Weise auch Prince of Persia und Descent übersetzen.

6. Zeigen und veröffentlichen

Solange dein Workspace läuft, kannst du anderen zeigen, was du gebaut hast. Starte die Vorschau, öffne dein Profil und teile unter »Shared Live Apps« die laufende Web-App; die Vorschau mit »Go Live« läuft auf Port 5500. Alle, die im Workspace angemeldet sind, finden sie dann in ihrem Profil unter »Von anderen geteilt«, deine Gruppe ganz oben. Erreichbar ist sie, solange dein Workspace und die Vorschau laufen.

Dauerhaft und für alle, auch ohne Anmeldung, wird dein Projekt mit einer eigenen Hackschule-Subdomain, zum Beispiel meinprojekt.hackschule.de. Dorthin kopierst du die fertigen Dateien deines Projekts. Das geht mit allem, was ganz im Browser läuft, ohne ein Programm auf dem Server. Wie du die Subdomain bekommst und die Dateien hochlädst, steht in der Anleitung zum eigenen Webspace.

7. Wie geht es weiter?

Auf dieser Seite hast du keine einzige fertige Form gezeichnet. Aus einer Formel wurde ein Bild, aus einer Welle eine Animation und aus einem gelesenen Pixel ein Spiel. Dieselbe Frage trägt noch viel weiter, bis zu eigener 3D-Grafik: Welche Farbe soll dieser Pixel haben?

Die ganze Bibliothek mit allen Befehlen ist unter specht.github.io/pixelram beschrieben.