Retrocoding: Amiga, C, graphics.library und timer.device


Vorwort

Eigentlich wollte ich ja nur mal testen, wie sich der vbcc als Crosscompiler unter Linux macht, musste allerdings dann sehr bald feststellen, dass ich fast keinen eigenen brauchbaren reinen C-Code zur Verfügung hatte.

Kurzerhand hab ich dann mal als C-Fingerübung eine simple kleine Grafik- nunja, "Engine" ist hier eigentlich übertrieben, aber der Einfachheit halber und aus dem Mangel heraus an einem besserem Wort nennen wir das Kind jetzt doch mal so, geschrieben. :-)

Mir kam dann die Idee, daraus einen kleinen Workshop zu dem Thema Amiga-Programmierung zu machen in dem ich die Funktion und Entwicklung dieser Engine beschreibe und nebenbei noch ein paar Details zu einigen Komponenten des AmigaOS erkläre.

Die Engine selbst benutzt Double-Buffering zur Darstellung: Zeichenoperationen finden auf einer nicht sichtbaren Bitmap statt und erst wenn ein komplettes Bild fertig gezeichnet ist, wird dieses am Stück in die sichtbare Bitmap des Fensters kopiert. Im späteren Verlauf des Workshops möchte ich diese Technik noch ergänzen zu einer Vollbilddarstellung, bei welcher nicht der Inhalt der Bitmaps kopiert wird, sondern die Bitmaps selbst zur Anzeige verwendet werden.

Die gewünschte Framerate ist frei einstellbar und wird per timer.device gesteuert. Zudem werden wir jede Animation anhand der tatsächlich vergangenen Zeit steuern, so dass Animationen auch dann in korrekter Geschwindigkeit abgespielt werden, wenn auf dem Rechner mangels Rechenleistung die Framerate einbricht.

Unsere Engine ist ausgelegt für einen systemfreundlichen Betrieb in einer Multitaskingumgebung und ist ab OS1.2 lauffähig. Ab OS3.0 werden Funktionen verwendet, welche die Performance unter Grafikkarten verbessern. Allerdings ist in der jetzigen Version keine weitergehende Unterstützung für derartige RTG-Systeme vorhanden: unsere Renderer sind damit beschränkt auf 8bit-Grafiken.
Ebenso wenig ist Unterstützung für spezielle Features des Amiga-Chipsets vorhanden: also kein Hardwarescrolling, keine Sprites und keine Copperlisten.

Dafür gibt es eine schöne Spielwiese auf der man sich nach Herzenslust mit den Funktionen der graphics.library und Bitplanes austoben und experimentieren kann – und prinzipiell lassen sich die damit erarbeiteten Ergebnisse auch ohne viel Änderungen in "echte" Demos, Spiele oder Programme übertragen.

Hierzu werde ich dann einige Basis-Funktionen der graphics.library vorstellen um daraus dann einen einfachen 2D-Vektor-Renderer zu entwickeln, der durchaus auch auf reinen 68000er Systemen eine brauchbare Performance erreicht.

Um dem Workshop folgen zu können brauchst du einen funktionierenden C-Compiler. Ich werde zu Beginn kurz auf die Installation und Benutzung von vbcc und dem 3.9er NDK eingehen. Getestet ist der Source allerdings auch mit SAS/C 6.5, StormC 3 und Maxon C++ 4 und da ich im Laufe des Workshops keinerlei Compiler-spezifischen Konstrukte verwenden werde, sollten auch andere Compiler kein ernsthaftes Problem darstellen.

Außerdem solltest du zumindest über rudimentäres Wissen über die Programmiersprache C verfügen. Ansonsten möchte ich nicht allzu viel Hintergrundwissen als gegeben voraussetzen. Allerdings ist es natürlich eine Gratwanderung, auf der einen Seite alles verständlich zu erklären und sich andererseits nicht in bekannten Details zu verlieren. Falls du also etwas in dem Workshop nicht verstehst scheue dich nicht zu fragen. Im besten Fall möchte ich entsprechende Stellen dann überarbeiten oder um einen kleinen Exkurs ergänzen.

Und nun viel Spaß!
Kai Scherrer (kai@kaiiv.de)


Inhalt


Die schnelle C-Compiler-Rundschau

Für den Amiga gibt es eine ganze Reihe C-Compiler. Leider werden fast alle nicht mehr aktuell gepflegt. Eine rühmliche Ausnahme bildet hier vbcc, der in Frank Willes Händen stetig wächst und gedeiht. Ich stelle hier kurz ein paar wichtige Compiler vor:

Aztec C

Ein Urgestein, welcher schon in den frühsten Amiga-Jahren einen hervorragenden Ruf hatte und praktisch der inoffizielle Standard-Compiler auf dem Amiga bis Anfang der Neunziger war. Die Entwicklung wurde dann allerdings eingestellt und die letzte Version 5.2 wurde schnell zum Software-Oldtimer. Nur noch zu empfehlen, wenn man möglichst authentisch auf einem 1.3er System entwickeln mag.


SAS/C 6.5

Der offizielle Standardcompiler auf dem Amiga. Auch dies ist ein Urgestein, welcher von Anbeginn des Amigas verfügbar war, damals noch unter dem Namen "Lattice C". Die letzte Version war 6.58. Neben dem Compiler, Assembler und Linker gibt es noch einen Debugger, einen rudimentären Profiler und ein brauchbares make mit dazu. Die Bedienung ist komplex: der Compiler kennt 574765744 verschiedene Optionen von denen viele den Eindruck machen, als wären sich die Entwickler selbst nicht sicher, ob diese im Einzelnen gut oder schlecht sind. Auch dieser Compiler läuft noch auf einem reinen 68000er-System unter Workbench 1.3, ansonsten würde ich ihn aber nur verwenden, wenn ich alten bestehenden Source damit zu warten hätte.


StormC 3

Ein C++-Compiler aus dem Hause Haage & Partner. Ebenfalls ein komplettes Entwicklungspaket mit einer echten IDE, die allerdings arg zusammengewürfelt wirkt und sich stellenweise nur sehr hemdsärmelig bedienen lässt. Obwohl nicht offiziell, so scheint der Compiler aus der letzten Version von Maxon C++ hervorgegangen zu sein: es handelt sich teilweise um die gleichen Entwickler, beide Compiler haben identische Bugs und große Passagen aus dem Handbuch sind eins zu eins übernommen.
Es gab noch eine Version 4 bei der der Compiler gegen einen gcc getauscht wurde; zu der kann ich aber leider nichts sagen.


Maxon C++ 4

Mein persönlicher Favorit, was vor allem an der gelungenen integrierten Entwicklungsumgebung liegt: editieren, compilieren und debuggen geht hier in einem Fluss. Leider ist er nicht sonderlich verbreitet und die letzte Version war auch nicht allzulange auf dem Markt, da sich Maxon kurz nach dessen Veröffentlichung 1996 vom Amiga-Markt zurückzog. Daher ist er nur bedingt geeignet, wenn man bestehende Softwareprojekte damit portieren möchte; aber selbst schreiben ist damit sehr angenehm.


vbcc

Und das Beste kommt zum Schluss: der einzige Compiler der noch aktuell gepflegt wird. Ein solider C-Compiler mit Linker und Assembler. Außerdem kann er als Crosscompiler verwendet werden, was es erlaubt z.B. unter Linux seine Amiga-Binaries zu bauen.
Dies wird der Compiler sein den ich hier für den Workshop benutze, daher werde ich noch kurz auf die Installation eingehen.


Inhalt


vbcc-Installation unter AmigaOS

Um den Compiler für Amiga-Projekte zu nutzen sind mindestens drei Pakete notwendig:

Damit vbcc auch Binaries erstellt, die mit der Workbench 1.2 kompatibel sind, ist außerdem noch eine Konfig-Datei notwendig, die bei der aktuellen vbcc-Version 0.9d leider fehlt. Diese könnt ihr direkt hier herunterladen: kick13.

Zuerst entpackt man sich das NDK39.lha an eine geeignete Stelle auf der Festplatte, z.B. Work:Coding. Dann entpackt man vbcc_bin_amigaos68k.lha nach RAM: und startet den darin enthaltenen Installer. Nach der erfolgreichen Installation am besten Rebooten. Nun entpackt man vbcc_target_m68k-amigaos.lha ebenfalls nach RAM: und startet dessen Installer. Auf der Frage nach den System-headern gibt man den Pfad zum entpackten NDK39, Include/include_h an, also z.B. Work:Coding/NDK_3.9/Include/include_h
Nach der Installation nochmal rebooten.

Jetzt kopiert ihr noch kick13 nach vbcc:config/ und testet euer Setup kurz wie folgt:

Erstellt die Datei test.c mit folgendem Inhalt:

#include <dos/dos.h>
#include <clib/dos_protos.h>
#include <stdlib.h>
#include <stdio.h>

int main(int argc, char* argv[])
{
	static char hello[] = "Hello Amiga!\n";
	Write(Output(), hello, sizeof(hello) - 1);
	printf("Hello C!\n");
	exit(RETURN_OK);
}

Diese compiliert ihr mit vbcc mit folgendem Aufruf:

vc +kick13 test.c -o test -lamiga

Hier noch der Aufruf für SAS/C:

sc test.c link ansi strict

Wenn das ohne Fehler durchcompiliert startet ihr test. Das sollte einfach zwei Zeilen ausgeben:

Hello Amiga!
Hello C!

Wenn das erfolgreich war, dann könnt ihr mit diesem Compiler den Workshop mitmachen.


Inhalt


C und wie es das AmigaOS sah

Weil es gerade so schön passt, möchte ich an dieser Stelle auch gleich auf die Art und Weise eingehen, wie hier C mit dem AmigaOS zusammenspielt. In unserem test.c oben haben wir es gleich mit drei verschiedenen Ebenen zu tun:

Jetzt sollte uns auch klar sein, wieso test.c so ist wie es ist: hier wird abgeklopft, ob unsere Compilerinstallation so vollständig ist, dass wir sowohl die Standardbibliothek als auch die Amiga-Systemaufrufe benutzen können.

Inhalt


Los geht's

Was wollen wir jetzt eigentlich genau schreiben? Das ist schnell erklärt: wir möchten ein einfaches, größenveränderbares Fenster auf der Workbench öffnen, in welches wir per graphics.library zeichnen können – und das noch mit sauberem Timing animiert. Selbstredend soll sich das ganze systemfreundlich verhalten, sprich, es soll auf Useraktionen unmittelbar reagieren und nur so viel Rechenzeit und Ressourcen verbrauchen, wie es eben notwendig ist. Das klingt einfacher als es ist, weswegen wir den Weg dorthin nun Schritt für Schritt durchgehen.

Wir beginnen mit unserer main()-Funktion. Hier wollen wir noch nicht allzu viel machen, main() ist allerdings ein guter Ort um die benötigten Libraries zu öffnen. Dies wären für uns intuition.library für unser Fenster und graphics.library für unsere Zeichenbefehle.

Wir definieren also zunächst global deren beiden Basisadressen und initialisieren diese mit 0:

/* our system libraries addresses */
struct GfxBase* GfxBase = 0;
struct IntuitionBase* IntuitionBase = 0;

In main() öffnen wir dann diese beiden Libraries per OpenLibrary(). Das sieht dann so aus:

int main(int argc, char* argv[])
{
	/* as long we did not open all libraries successfully, we
	 * report a failure */
	int result = RETURN_FAIL;

	/* we need at least 1.2 graphic.library's drawing functions */
	if((GfxBase = (struct GfxBase*)OpenLibrary("graphics.library", 33)))
	{
		/* we need at least 1.2 intuition.library for our window */
		if((IntuitionBase = (struct IntuitionBase*)
		                   OpenLibrary("intuition.library", 33)))
		{
			/* All libraries needed are available, everything is fine */
			result = RETURN_OK;

			CloseLibrary((struct Library*)IntuitionBase);
			IntuitionBase = 0;
		}
		CloseLibrary((struct Library*)GfxBase);
		GfxBase = 0;
	}

	/* some startup codes do ignore main's return value, that's
	 * why we use exit() here instead of a simple return */
	exit(result);
}

Sobald wir also nun unsere Libraries erfolgreich geöffnet haben, initialisieren wir unsere eigenen Daten, öffnen das Fenster und springen in die Hauptschleife. Das machen wir in einer eigenen Funktion, die wir RunEngine() nennen. Außerdem definieren wir uns noch eine struct, in welcher wir unsere Laufzeitdaten unterbringen, so dass wir einen Zeiger darauf bequem an alle involvierten Funktionen durchreichen können. Damit vermeiden wir unschöne globale Variablen:

struct RenderEngineData
{
	struct Window* window;
	BOOL run;
};

int RunEngine(void)
{
	struct RenderEngineData* rd;

	/* as long we did not enter our main loop we report an error */
	int result = RETURN_ERROR;

	/* allocate the memory for our runtime data and ititialize it with zeros */
	if((rd = (struct RenderEngineData*)
	        AllocMem(sizeof(struct RenderEngineData), MEMF_ANY | MEMF_CLEAR)))
	{
		/* now let's open our window */
		static struct NewWindow newWindow =
		{
			0, 14,
			320, 160,
			(UBYTE)~0, (UBYTE)~0,
			IDCMP_CLOSEWINDOW | IDCMP_NEWSIZE | IDCMP_REFRESHWINDOW,
			WFLG_CLOSEGADGET | WFLG_DRAGBAR | WFLG_DEPTHGADGET |
			WFLG_SIMPLE_REFRESH | WFLG_SIZEBBOTTOM | WFLG_SIZEGADGET,
			0, 0,
			"Gfx Workshop",
			0,
			0,
			96, 48,
			(UWORD)~0, (UWORD)~0,
			WBENCHSCREEN
		};
		if((rd->window = OpenWindow(&newWindow)))
		{
			/* the main loop will run as long this is TRUE */
			rd->run = TRUE;

			result = MainLoop(rd);

			/* cleanup: close the window */
			CloseWindow(rd->window);
			rd->window = 0;
		}

		/* free our runtime data */
		FreeMem(rd, sizeof(struct RenderEngineData));
		rd = 0;
	}

	return result;
}

Wie das geübte Auge sofort erkennt, allokieren wir zunächst den genullten Speicher für unsere RenderEngineData und öffnen dann das Fenster. Das ist ein Simple-Refresh-Fenster, weswegen wir auch gleich ansagen, dass wir IDCMP_REFRESHWINDOW-Nachrichten erhalten wollen, mit denen uns intuition.library auffordert, den Inhalt des Fensters neu zu zeichnen. Da wir sowieso mehrmals pro Sekunde das Fenster neu zeichnen werden, ist ein Smart-Refresh-Window überflüssig, bei denen sich Intuition selbst um das Neuzeichnen kümmern würde.

Wenn das alles geklappt hat, springen wir in unsere MainLoop():

int MainLoop(struct RenderEngineData* rd)
{
	struct MsgPort* winport;
	ULONG winSig;

	/* remember the window port in a local variable for more easy use */
	winport = rd->window->UserPort;

	/* create our waitmask for the window port */
	winSig = 1 << winport->mp_SigBit;

	/* our main loop */
	while(rd->run)
	{
		struct Message* msg;

		/* let's sleep until a message from our window arrives */
		Wait(winSig);

		/* our window signaled us, so let's harvest all its messages
		 * in a loop... */
		while((msg = GetMsg(winport)))
		{
			/* ...and dispatch and reply each of them */
			dispatchWindowMessage(rd, (struct IntuiMessage*)msg);
			ReplyMsg(msg);
		}
	}
	return RETURN_OK;
}

Wir bleiben hier so lange in unserer Hauptschleife, solange unser rd->run Flag auf TRUE bleibt. Dies wollen wir auf FALSE setzen, sobald der User auf das Close-Gadget unseres Fensters klickt. In der Hauptschleife warten wir darauf, dass wir durch eine Nachricht von unserem Fenster signalisiert werden und bearbeiten und beantworten dann diese Nachricht(en) in einer Schleife. Das Bearbeiten findet in der Funktion DispatchWindowMessage() statt:

void DispatchWindowMessage(struct RenderEngineData* rd,
                           struct IntuiMessage* msg)
{
	switch(msg->Class)
	{
		case IDCMP_CLOSEWINDOW:
		{
			/* User pressed the window's close gadget: exit the main loop as
			 * soon as possible */
			rd->run = FALSE;
			break;
		}
		case IDCMP_REFRESHWINDOW:
		{
			BeginRefresh(rd->window);
			EndRefresh(rd->window, TRUE);
			break;
		}
	}
}

Wir reagieren hier auf das IDCMP_CLOSEWINDOW indem wir unser run-Flag auf FALSE setzen, was dazu führen wird, dass wir unsere Hauptschleife verlassen werden, sobald alle anderen Nachrichten abgearbeitet sind.

Da wir noch nichts zum Zeichnen haben, rufen wir bei IDCMP_REFRESHWINDOW einfach nur BeginRefresh()/EndRefresh() auf, womit wir Intuition mitteilen, dass wir unser Fenster erfolgreich neu gezeichnet haben.

Wenn wir das alles compilieren (engine1.c) dann erhalten wir ein leeres, größenveränderbares Fenster, welches wir wieder schließen können. Toll, nicht wahr? ;-)

vc +kick13 engine1.c -o engine1 -lamiga

Inhalt


Bitplane, BitMap und RastPort

Wie zeichnen wir nun in unser Fenster? Hierzu möchte ich ein wenig weiter ausholen und zunächst einige Begriffe der graphics.library klären.

Da wäre zunächst einmal der Grafikspeicher an sich. Für die klassische graphics.library liegt dieser immer im planaren Format vor, sprich, wir haben je nach Farbanzahl eine entsprechende Anzahl an Bitplanes in denen je ein Bit für einen Pixel steht. Damit ist man zwar maximal flexibel was die gewünschte Anzahl an Farben angeht, aber dafür sind Zeichenoperationen darauf recht aufwendig, da man für jedes Pixel das man setzen will auf jeder Bitplane ein Byte lesen, eine and/or-Verknüpfung durchführen und das Byte wieder zurückschreiben muss. Wenn der Grafikspeicher auch noch im Chip-RAM liegt, dann wird dieses an sich schon langsame Vorgehen noch durch den langsamen Speicherzugriff zusätzlich ausgebremst. Um solche Dinge müssen wir uns zwar zunächst zum Glück nicht kümmern, da dies die graphics.library für uns regelt; wer aber schnelle und halbwegs moderne Grafikroutinen braucht, der kommt hier schwerlich umhin, eine chunky2planar-Routine zu benutzen. Aber das wird vorerst kein Thema hier sein.

Damit graphics.library überhaupt weiß, welche Bitplanes nun zu einer Grafik gehören und wie groß diese überhaupt sind gibt es die Struktur BitMap in graphics/gfx.h:

struct BitMap
{
    UWORD   BytesPerRow;
    UWORD   Rows;
    UBYTE   Flags;
    UBYTE   Depth;
    UWORD   pad;
    PLANEPTR Planes[8];
};

BytesPerRow gibt hier an, wie viele Bytes hier pro Zeile verwendet werden; genauer gesagt, die Anzahl an Bytes die man auf einen Punkt addieren muss um auf das Pixel in gleiche Spalte der nächsten Reihe zu kommen. Da es keine halben Bytes gibt, ist somit die Breite in Pixeln immer ein Vielfaches von 8. Bedingt durch einige Eigenschaften des Amiga-Chipsets ist sie in der Praxis sogar ein Vielfaches von 16, das heißt der Speicherverbrauch einer BitMap mit einer Breite von 33 Pixeln ist identisch mit der einer BitMap mit einer Breite von 48 Pixeln.

Rows gibt die Anzahl der Zeilen an und entspricht somit direkt der Höhe einer BitMap in Pixeln.

Depth gibt die Anzahl der Bitplanes an und entspricht so der verfügbaren Farbtiefe: bei nur einer Bitplane hat man zwei Farben, bei acht Bitplanes 256.

Planes ist ein Array von Adressen, die auf unsere Bitplanes im Speicher zeigen. Obwohl dies hier mit der Größe 8 definiert ist, darf man sich – zumindest bei BitMaps die man nicht selbst angelegt hat – nicht darauf verlassen, dass da auch wirklich acht Adressen verfügbar sind. Bei einer BitMap mit einer Depth von 2 kann es durchaus sein, dass der Speicher für die letzten 6 Bitplane-Pointer nicht allokiert ist. Das heißt, dass man beim Zugriff auf dieses Array immer Depth beachten muss: wenn Depth zum Beispiel 2 ist, dann darf man nicht auf Planes[2] zugreifen – auch nicht um zu testen, ob dessen Zeiger 0 wäre! Planes die außerhalb des mit Depth festgelegten Bereiches liegen sind als nicht existent anzusehen!

Auch Zuweisungen in der Form:

struct BitMap bmp = *foreignBmp;

sind daher zweifelhaft und zu unterlassen, wenn man die foreignBmp hier nicht direkt selbst allokiert hat!

Des Weiteren heißt das auch, dass somit unterschiedliche BitMap-Objekte durchaus auf die gleichen Bitplanes im Speicher verweisen können.

Über die BitMap-Struktur kennt graphics.library also schon mal den grundsätzlichen Aufbau der Bitplanes und deren Anzahl und Position. Für Zeichenoperationen braucht sie aber noch ein paar Infos mehr: neben der BitMap muss sich graphics.library noch ihren Zustand irgendwo merken, also welcher Pen gesetzt ist, welcher Zeichenmodus aktiv ist, welcher Font verwendet wird und noch einiges mehr. Das passiert in der Struktur RastPort die in graphics/rastport.h definiert ist. Einen solchen RastPort brauchen wir für die meisten Zeichenroutinen der graphics.library. Und selbstverständlich können auch hier mehrere RastPorts mit unterschiedlichen Einstellungen auf die gleiche BitMap als Zeichenziel zeigen.

Netterweise stellt uns unser Fenster schon einen fertigen RastPort zur Verfügung, den wir benutzen können. Doch Vorsicht: dieser RastPort gilt für das gesamte Fenster, inklusive Rahmen und Systemgadgets. Wer also diesen RastPort per SetRast() komplett einfärbt erhält statt einem Fenster eine einfarbige Fläche in Fenstergröße auf der Workbench.

So sieht also etwas vereinfach dargestellt die Verbindung zwischen RastPort, BitMap und Bitplanes aus:

Wer ganz schlau ist und sich zum Zeichnen die BitMap aus dem RastPort des Fensters angelt, der dürfte eine weitere Überraschung erleben: diese BitMap ist tatsächlich der komplette Bildschirminhalt und solange man den RastPort des Fensters benutzt, sorgt die layers.library dafür, dass Zeichenoperationen auf Bereiche außerhalb des Fensters oder von anderen Fenstern verdeckte Bereiche nicht stattfinden. Umgeht man das in der Art, dass man die Fenster-RastPort-BitMap in einen eigenen RastPort packt und diese per SetRast() einfärbt, erhält man einen komplett einfarbigen Screen und dürfte sich damit der Missgunst seiner Anwender sicher sein ;-)

Inhalt


Wir zeichnen jetzt endlich auch mal was

Mit diesem Wissen im Hinterkopf wenden wir uns nun aber erstmal wieder unserem Fenster und dessen Nachrichten zu: wir müssen unsere Grafik zu mehreren Gelegenheiten zeichnen:

Damit liegt es nahe, dass wir unsere Zeichenfunktion in eine eigene Funktion auslagern, die wir bei Bedarf aufrufen können:

void RepaintWindow(struct RenderEngineData* rd)
{
	struct RastPort* rastPort;
	struct Rectangle outputRect;
	struct tPoint lineStep;
	struct tPoint pos;
	WORD i;

	const WORD stepCount = 32;

	/* we make a local copy of our RastPort pointer for ease of use */
	rastPort = rd->window->RPort;

	/* our output rectangle is our whole window area minus its borders */
	outputRect.MinY = rd->window->BorderTop;
	outputRect.MinX = rd->window->BorderLeft;
	outputRect.MaxX = rd->window->Width - rd->window->BorderRight - 1;
	outputRect.MaxY = rd->window->Height - rd ->window->BorderBottom - 1;

	/* clear our output rectangle */
	SetDrMd(rastPort, JAM1);
	SetAPen(rastPort, 0);
	RectFill(rastPort, outputRect.MinX, outputRect.MinY,
	         outputRect.MaxX, outputRect.MaxY);

	/* now draw our line pattern */
	lineStep.x = (outputRect.MaxX - outputRect.MinX) / stepCount;
	lineStep.y = (outputRect.MaxY - outputRect.MinY) / stepCount;

	SetAPen(rastPort, 1);
	pos.x = 0;
	pos.y = 0;
	for(i = 0; i < stepCount; i++)
	{
		Move(rastPort, outputRect.MinX, outputRect.MinY + pos.y);
		Draw(rastPort, outputRect.MaxX - pos.x, outputRect.MinY);
		Draw(rastPort, outputRect.MaxX, outputRect.MaxY - pos.y);
		Draw(rastPort, outputRect.MinX + pos.x, outputRect.MaxY);
		Draw(rastPort, outputRect.MinX, outputRect.MinY + pos.y);

		pos.x += lineStep.x;
		pos.y += lineStep.y;
	}
}

Wir ermitteln hier zunächst das Ausgabe-Rechteck, indem wir von der Fensterbreite und -Höhe die entsprechenden Borders abziehen. Dieses Rechteck löschen wird dann vollständig per RectFill(). Danach malen wir noch ein paar Linien mit der Draw()-Funktion. Beim Linienziehen ist zu beachten, dass wir nur den Endpunkt angeben. Der Startpunkt wird im RastPort gespeichert und kann per Move() gesetzt werden, oder er ist der Endpunkt einer vorherigen Zeichenoperation.

Nun müssen wir noch dafür sorgen, dass unsere Repaint-Funktion auch an geeigneter Stelle aufgerufen wird:
Das erste Mal unmittelbar bevor wir in die Hauptschleife gehen in MainLoop():

	/* create our waitmask for the window port */
	winSig = 1 << winport->mp_SigBit;

	/* paint our window for the first time */
	RepaintWindow(rd);

	/* our main loop */
	while(rd->run)

Dann noch an zwei Stellen in DispatchWindowMessage():

	case IDCMP_NEWSIZE:
	{
		RepaintWindow(rd);
		break;
	}
	case IDCMP_REFRESHWINDOW:
	{
		BeginRefresh();
		RepaintWindow(rd);
		EndRefresh();
		break;
	}

Hier gehe ich noch kurz auf die Besonderheit von BeginRefresh() und Endrefresh() ein: die Idee ist, dass hier wirklich nur die Teile des Fensters neu gezeichnet werden, die auch tatsächlich durch irgendwelche Fensterverschiebeaktionen des Benutzers verdeckt waren und jetzt sichtbar wurden. Dazu wird die layers.library benutzt, welche Zeichenoperationen auf andere Bereiche unserer BitMap ins Leere laufen lässt. Das erhöht einerseits die Neuzeichengeschwindigkeit unter Umständen signifikant, kann aber auch bei Animationen zu Problemen führen, wenn man ein "neueres" Bild malt da so Fragmente des alten Bildes bestehen bleiben und eben nicht erneuert werden.

Für die Funktionen SetAPen(), SetDrMd(), Move() und Draw() müssen wir außerdem noch ein include hinzufügen:

#include <clib/graphics_protos.h>

Wenn wir engine2.c nun per

vc +kick13 engine2.c -o engine2 -lamiga

compilieren und starten erhalten wir ein Fenster in dem ein Linienmuster gezeichnet wird. Wenn wir das Fenster vergrößern, wird die Grafik ebenfalls der neuen Fenstergröße angepasst. Auf langsamen Rechnern können wir aber schon hier den Effekt beobachten, dass man beim Zeichnen "zusehen" kann: zwar noch nicht in dramatischer Form, aber es gibt beim Neuzeichnen ein sichtbares Flackern, wenn zuerst der Hintergrund gelöscht und dann erst die Linien erscheinen. Das ist so noch nicht störend, aber wir möchten ja eine Animation abspielen und da ist ein solches Verhalten nicht hinnehmbar; die Grafik muss auf einen Schlag sichtbar sein.

Inhalt


Double-Buffering

Dieses Problem lösen wir, indem wir zunächst auf eine zweite, unsichtbare BitMap – einen sogenannten Backbuffer – zeichnen und erst sobald wir das Bild fertig gezeichnet haben tauschen wir das vorherige durch das neue aus. Diese Technik wird "double-buffering" genannt. Das Austauschen selbst kann hier auf verschiedene Arten erfolgen: wenn wir unsere Grafik auf einem eigenem Screen anzeigen, dann können wir einfach dem Grafikchip sagen, dass er jetzt die zweite Grafik anzeigen soll. Das geht sehr schnell und ohne viel Aufwand. Wir dagegen laufen allerdings auf der Workbench in einem Fenster und müssen daher einen anderen Weg gehen: wir kopieren die neue Grafik über die die alte. Das ist aufwendiger und langsamer, allerding für unsere Zwecke noch immer schnell genug, zumal die graphics.library hierfür den Blitter verwenden kann. "Blit" steht übrigens für "Block Image Transfer" und damit sollte jetzt auch klar sein, woher der Blitter seinen Namen hat. Auch im Rahmen dieses Workshops werde ich diesen Vorgang ab sofort als "blitten" bezeichnen.

Wir müssen uns also nun zunächst eine zweite BitMap und deren Bitplanes anlegen. Dafür gibt es in der graphics.library eine schöne Funktion AllocBitMap(). Leider ist diese erst ab OS3.0 verfügbar. Auf früheren OS-Versionen müssen wir das Anlegen der BitMap und das Allokieren der Bitplanes zu Fuß erledigen. Falls der geneigte Leser nun auf die Idee kommen sollte, dass man ja in dem Fall einfach nur die Zu-Fuß-Methode implementieren und AllocBitMap() dafür komplett links liegen lassen könne der täuscht sich: AllocBitMap() erzeugt speziell im Zusammenspiel mit Grafikkarten BitMaps auf die sich sowohl schneller zeichnen lässt und die sich schneller auf die Anzeige blitten lassen und sollte daher ab OS3.0 immer verwendet werden. Wir implementieren uns also ein eigenes AllocBitMap, welches je nach vorhandenem OS die eine oder die andere Methode benutzt:

struct BitMap* MyAllocBitMap(ULONG width, ULONG height,
                             ULONG depth, struct BitMap* likeBitMap)
{
	struct BitMap* bitmap;

	/* AllocBitMap() is available since OS3.0 */
	if(GfxBase->LibNode.lib_Version < 39)
	{
		if(depth <= 8)
		{
			/* lets allocate our BitMap */
			bitmap = (struct BitMap*)
			       AllocMem(sizeof(struct BitMap), MEMF_ANY | MEMF_CLEAR);
			if(bitmap)
			{
				WORD i;
				InitBitMap(bitmap, depth, width, height);

				/* now allocate all our bitplanes */
				for(i = 0; i < bitmap->Depth; i++)
				{
					bitmap->Planes[i] = AllocRaster(width, height);
					if(!(bitmap->Planes[i]))
					{
						MyFreeBitMap(bitmap);
						bitmap = 0;
						break;
					}
				}
			}
		}
		else
		{
			bitmap = 0;
		}
	}
	else
	{
		bitmap = AllocBitMap(width, height, depth, 0, likeBitMap);
	}

	return bitmap;
}

Analog dazu brauchen wir natürlich auch ein MyFreeBitMap():

void MyFreeBitMap(struct BitMap* bitmap)
{
	/* FreeBitMap() is available since OS3.0 */
	if(GfxBase->LibNode.lib_Version < 39)
	{
		ULONG width;
		WORD i;

		/* warning: this assumption is only safe for our own bitmaps */
		width = bitmap->BytesPerRow * 8;

		/* free all the bitplanes... */
		for(i = 0; i < bitmap->Depth; i++)
		{
			if(bitmap->Planes[i])
			{
				FreeRaster(bitmap->Planes[i], width, bitmap->Rows);
				bitmap->Planes[i] = 0;
			}
		}
		/* ... and finally free the bitmap itself */
		FreeMem(bitmap, sizeof(struct BitMap));
	}
	else
	{
		FreeBitMap(bitmap);
	}
}

Wir müssen nun unsere RenderEngineStruct erweitern. Zunächst merken wir uns die Ausgabegröße im Fenster:

	struct tPoint outputSize;

Zur Berechnung derselben schreiben wir uns eine kleine Funktion:

void ComputeOutputSize(struct RenderEngineData* rd)
{
	/* our output size is simply the window's size minus its borders */
	rd->outputSize.x =
	rd->window->Width - rd->window->BorderLeft - rd->window->BorderRight;
	rd->outputSize.y =
	rd->window->Height - rd->window->BorderTop - rd->window->BorderBottom;
}

Diese rufen wir einmal nach dem Öffnen des Fensters auf und ansonsten immer, wenn wir ein IDCMP_NEWSIZE erhalten.

Dann brauchen wir natürlich noch unsere BitMap, deren aktuelle Größe und einen dazugehörigen RastPort in unserer RenderEngineStruct:

	struct BitMap* backBuffer;
	struct tPoint backBufferSize;
	struct RastPort renderPort;

Um den Backbuffer zu erzeugen, bzw. um ihn einer neuen Größe anzupassen schreiben wir uns ebenfalls eine Funktion:

int PrepareBackBuffer(struct RenderEngineData* rd)
{
	int result;

	if(rd->outputSize.x != rd->backBufferSize.x ||
	   rd->outputSize.y != rd->backBufferSize.y)
	{
		/* if output size changed free our current bitmap... */
		if(rd->backBuffer)
		{
			MyFreeBitMap(rd->backBuffer);
			rd->backBuffer = 0;
		}

		/* ... allocate a new one... */
		rd->backBuffer = MyAllocBitMap(rd->outputSize.x, rd->outputSize.y,
		                               1, rd->window->RPort->BitMap);
		if(rd->backBuffer)
		{
			/* and on success remember its size */
			rd->backBufferSize = rd->outputSize;
		}

		/* link the bitmap into our render port */
		InitRastPort(&rd->renderPort);
		rd->renderPort.BitMap = rd->backBuffer;
	}

	result = rd->backBuffer ? RETURN_OK : RETURN_ERROR;

	return result;
}

Wie wir sehen ist das eine Funktion die zur Laufzeit fehlschlagen kann, wenn aus irgendeinem Grund die BitMap nicht angelegt werden kann. Wir werden nachher noch darauf eingehen, wie wir damit umgehen. Im Moment ist es nur wichtig, dass wir in diesem Fall einen Fehlercode zurückgeben.

Unsere RepaintWindow()-Funktion blittet jetzt nur noch den Backbuffer in den RastPort unseres Fensters:

void RepaintWindow(struct RenderEngineData* rd)
{
	/* on repaint we simply blit our backbuffer into our window's RastPort */
	BltBitMapRastPort(rd->backBuffer, 0, 0, rd->window->RPort,
	                  (LONG)rd->window->BorderLeft,
	                  (LONG)rd->window->BorderTop,
	                  (LONG)rd->outputSize.x, (LONG)rd->outputSize.y,
	                  (ABNC | ABC));
}

Die bisherigen Zeichenfunktionen wandern in eine eigene Funktion:

int RenderBackbuffer(struct RenderEngineData* rd)
{
	int result;

	result = PrepareBackBuffer(rd);

	if(result == RETURN_OK)
	{
		struct RastPort* rastPort;
		struct tPoint maxPos;
		struct tPoint lineStep;
		struct tPoint pos;
		WORD i;

		const WORD stepCount = 32;

		/* we make a local copy of our RastPort pointer for ease of use */
		rastPort = &rd->renderPort;

		/* clear our bitmap */
		SetRast(rastPort, 0);

		/* now draw our line pattern */
		maxPos.x = rd->backBufferSize.x - 1;
		maxPos.y = rd->backBufferSize.y - 1;

		lineStep.x = maxPos.x / stepCount;
		lineStep.y = maxPos.y / stepCount;

		SetAPen(rastPort, 1);
		pos.x = pos.y = 0;
		for(i = 0; i < stepCount; i++)
		{
			Move(rastPort, 0, pos.y);
			Draw(rastPort, maxPos.x - pos.x, 0);
			Draw(rastPort, maxPos.x, maxPos.y - pos.y);
			Draw(rastPort, pos.x, maxPos.y);
			Draw(rastPort, 0, pos.y);

			pos.x += lineStep.x;
			pos.y += lineStep.y;
		}
	}

	return result;
}

Auch diese Funktion kann fehlschlagen, da sie PrepareBackBuffer() aufruft. In diesem Fall liefern wir dessen Fehlercode zurück.
Ansonsten fällt noch auf, dass wir das RectFill() durch ein SetRast() ersetzen konnten, weil wir eben unsere eigene BitMap samt RastPort haben und nicht mehr auf die Fensterrahmen drumherum achten müssen.

Inhalt


Wasserdichte Fehlerbehandlung

Da wir nun eine Komponente haben, die zur Laufzeit unseres Programms fehlschlagen kann, müssen wir uns nun um die Fehlerbehandlung kümmern. In dem Fall wollen wir unser Programm sauber verlassen und den Fehlercode zurückgeben.

Sauber Verlassen heißt, dass wir alle ausstehenden Nachrichten unseres Fensters beantworten, dabei nicht crashen und dann alle allokierten Ressourcen wieder freigeben.

Um das zu erreichen erweitern wir abermals unsere RenderEngineData um einen returnCode, den wir im Fehlerfall innerhalb unserer MainLoop setzen können und an deren Ende zurückgeben:

	int returnCode;

Um uns die Fehlerbehandlung zu erleichtern wollen wir außerdem unsere Render-Funktion nur noch an einer Stelle aufrufen, nämlich unmittelbar vor unserem Wait(). Damit wir dort wissen ob wir sie aufrufen müssen fügen wir ein entsprechendes Flag in unsere RenderEngineData ein. Das gleiche machen wir auch noch für RepaintWindow():

	BOOL doRepaint;
	BOOL doRender;

Nun können wir einfach vor Eintritt in die MainLoop doRender auf TRUE setzen und wir rendern so einmal unser Bild. So sieht dann der Beginn unserer MainLoop aus:

	/* paint our window for the first time */
	rd->doRender = TRUE;

	/* we need to compute our output size initially */
	ComputeOutputSize(rd);

	/* enter our main loop */
	while(rd->run)
	{
		struct Message* msg;

		if(rd->doRender)
		{
			rd->returnCode = RenderBackbuffer(rd);
			if(rd->returnCode == RETURN_OK)
			{
				/* Rendering succeeded, we need to repaint */
				rd->doRepaint = TRUE;
				rd->doRender = FALSE;
			}
			else
			{
				/* Rendering failed, do not repaint, leave our main
				 * loop instead */
				rd->doRepaint = FALSE;
				rd->run = FALSE;
			}
		}

		if(rd->doRepaint)
		{
			RepaintWindow(rd);
			rd->doRepaint = FALSE;
		}

		if(rd->run)
		{
			/* let's sleep until a message from our window arrives */
			Wait(winSig);
		}
		[...]

Und so unser IDCMP_NEWSIZE-Handler in DispatchWindowMessage():

		case IDCMP_NEWSIZE:
		{
			/* On resize we compute our new output size... */
			ComputeOutputSize(rd);

			/* ... and trigger a render call */
			rd->doRender = TRUE;
			break;
		}

Wenn wir nun engine3.c compilieren und starten erhalten wir wieder unser Fenster mit unserem Linienmuster. Allerdings diesmal ohne Geflacker beim Neuzeichnen – ein Umstand der sich bei dem nun folgenden ersten Animationsversuch als sehr hilfreich erweisen wird.

Inhalt


Unser Bild lernt laufen

Wir wollen nun unsere Grafik animieren, indem wir pro Frame immer nur einen Durchlauf unserer Schleife in RenderBackbuffer() zeichnen. Dazu lagern wir den momentanen Zähler i in unsere RenderEngineData struct aus:

	WORD currentStep;

Die RenderBackbuffer()-Funktion wird entsprechend überarbeitet, so dass sie pro Aufruf nur den aktuellen Wert für vier Linien verwendet und den Wert dann um eins erhöht:

int RenderBackbuffer(struct RenderEngineData* rd)
{
	int result;

	result = PrepareBackBuffer(rd);

	if(result == RETURN_OK)
	{
		struct RastPort* rastPort;
		struct tPoint maxPos;
		struct tPoint lineStep;
		struct tPoint pos;

		const WORD stepCount = 32;

		/* we make a local copy of our RastPort pointer for ease of use */
		rastPort = &rd->renderPort;

		/* clear our bitmap */
		SetRast(rastPort, 0);

		/* now draw our line pattern */
		maxPos.x = rd->backBufferSize.x - 1;
		maxPos.y = rd->backBufferSize.y - 1;

		lineStep.x = maxPos.x / stepCount;
		lineStep.y = maxPos.y / stepCount;

		SetAPen(rastPort, 1);

		pos.x = rd->currentStep * lineStep.x;
		pos.y = rd->currentStep * lineStep.y;

		rd->currentStep += 1;
		if(rd->currentStep >= stepCount)
		{
			rd->currentStep = 0;
		}

		Move(rastPort, 0, pos.y);
		Draw(rastPort, maxPos.x - pos.x, 0);
		Draw(rastPort, maxPos.x, maxPos.y - pos.y);
		Draw(rastPort, pos.x, maxPos.y);
		Draw(rastPort, 0, pos.y);
	}

	return result;
}

Sowie currentStep größer-gleich stepCount wird müssen wir diesen natürlich wieder auf 0 zurückstellen.

Nun müssen wir nur noch dafür sorgen, dass wir unser RenderBackbuffer() regelmäßig aufrufen. Dazu ignorieren wir in unserer Schleife das doRender-Flag und zeichnen einfach immer.

Das Wait() ersetzen wir durch ein SetSignal() mit dem wir einfach nur abfragen, ob wir eine Nachricht vom Fenster erhalten haben, ohne darauf zu warten:

	/* enter our main loop */
	while(rd->run)
	{
		ULONG sig;
		struct Message* msg;

		rd->returnCode = RenderBackbuffer(rd);
		if(rd->returnCode == RETURN_OK)
		{
			/* Rendering succeeded, we need to repaint */
			rd->doRepaint = TRUE;
		}
		else
		{
			/* Rendering failed, do not repaint, leave our main
			 * loop instead */
			rd->doRepaint = FALSE;
			rd->run = FALSE;
		}

		if(rd->doRepaint)
		{
			RepaintWindow(rd);
			rd->doRepaint = FALSE;
		}

		sig = SetSignal(0, winSig);

		if(sig & winSig)
		{
			/* our window signaled us, so let's harvest all its messages
			 * in a loop... */
			while((msg = GetMsg(winport)))
			{
				/* ...and dispatch and reply each of them */
				DispatchWindowMessage(rd, (struct IntuiMessage*)msg);
				ReplyMsg(msg);
			}
		}
	}

Hier der komplette Source: engine4.c, wieder zu compilieren mit

vc +kick13 engine4.c -o engine4 -lamiga

Inhalt


Das timer.device und was es uns nützt

Wie erwartet zeigt unser Fenster nun eine Animation an. Allerdings ist das in der Form noch alles andere als praktikabel: zum einen ist die Geschwindigkeit unserer Animation direkt abhängig von der Rechen- und Grafikleistung des Amigas auf dem sie läuft und zum anderen verbraucht sie sämtliche Rechenzeit die sie kriegen kann. Wir brauchen also hier eine andere Lösung. Man könnte nun einfach ein Delay() einbauen, das würde zumindest das Problem mit der Rechenleistung entschärfen. Wenn wir allerdings eine Animation mit nur einem Bild alle 2 Sekunden anzeigen wollen, dann würde das bewirken, dass unser Fenster auch bis zu zwei Sekunden lang blockiert ist und so nur sehr verzögert auf das Klicken des Close-Buttons und andere User-Aktionen reagiert. Außerdem müssten wir noch die Zeit fürs Rendern und Anzeigen mit berücksichtigen und ein einfaches Delay() ist mit seiner 1/50-Sekunden Auflösung dafür auch einfach nicht geeignet. Wir brauchen also einen anderen Taktgeber und mit dem timer.device haben wir auch einen, der für unsere Zwecke vollkommen ausreicht.

Wie jedes Device wird auch das timer.device über die exec-Funktionen OpenDevice()/CloseDevice() und SendIO()/WaitIO()/DoIO()/AbortIO() gesteuert. Zusätzlich hat das timer.device noch einen kleinen Satz an Funktionen, die wie jene einer Library aufgerufen werden. Diese sind in clib/timer_protos.h deklariert und benötigen wie jede Library einen initialisierten Basiszeiger. Diesen legen wir zunächst global an:

struct Library* TimeBase = 0;

Wie sich das für ein Device gehört wird auch das timer.device durch das Hin- und Herschicken von IORequests gesteuert. Diese IORequests werden von uns und nicht etwa vom Device erzeugt und für das Hin- und Herschicken benötigen wir außerdem einen MsgPort. Das timer.device hat des weiteren eine eigene IORequest-Struktur, nämlich struct timerequest aus devices/timer.h. Neben dem IORequest hängt dort noch eine struct timeval dran, welche Zeiten in einer Auflösung von Mikrosekunden (1/1000000 Sekunden) unterstützt.

Wir erweitern also wieder unsere RenderEngineData, diesmal um einen MsgPort und einen timerequest:

	struct MsgPort* timerPort;
	struct timerequest* timerIO;

Außerdem brauchen wir noch zwei Includes:

#include <devices/timer.h>
#include <clib/alib_protos.h>

Zum Öffnen des timer.device erstellen wir uns eine eigene Funktion:

int InitTimerDevice(struct RenderEngineData* rd)
{
	/* we do not return success until we've opened the timer.device */
	int result = RETURN_FAIL;

	/* create a message port through which we will communicate with the
	 * timer.device */
	if((rd->timerPort = CreatePort(0, 0)))
	{
		/* create a timerequest which we will we pass between the timer.device
		 * and ourself */
		rd->timerIO = (struct timerequest*)
		              CreateExtIO(rd->timerPort, sizeof(struct timerequest));
		if(rd->timerIO)
		{
			/* open the timer.device */
			if(OpenDevice(TIMERNAME, UNIT_MICROHZ,
			              (struct IORequest*)rd->timerIO, 0) == 0)
			{
				/* Success: let's set the TimerBase so we can call
				 * timer.device's functions */
				TimerBase = (struct Library*)rd->timerIO->tr_node.io_Device;
				result = RETURN_OK;
			}
		}
	}
	if(result != RETURN_OK)
	{
		/* in case of an error: cleanup immediatly */
		FreeTimerDevice(rd);
	}

	return result;
}

Und analog dazu FreeTimerDevice():

void FreeTimerDevice(struct RenderEngineData* rd)
{
	/* close the timer.device */
	if(TimerBase)
	{
		CloseDevice((struct IORequest*)rd->timerIO);
		TimerBase = 0;
	}

	/* free our timerequest */
	if(rd->timerIO)
	{
		DeleteExtIO((APTR)rd->timerIO);
		rd->timerIO = 0;
	}

	/* free our message port */
	if(rd->timerPort)
	{
		DeletePort(rd->timerPort);
		rd->timerPort = 0;
	}
}

Noch eine kurze Erklärung zu dem UNIT_MICROHZ oben: Es gibt mehrere Modi des timer.device, die sich vor allem in ihrer tatsächlichen Auflösung und ihrer Genauigkeit unterscheiden. Den Modus, den wir hier verwenden zeichnet sich vor allem durch seine hohe Auflösung aus. Allerdings ist er recht ungenau: wer ihn benutzt um Sekunden zu zählen, der wird feststellen, dass er nach wenigen Minuten von einer genau gehenden Uhr abweichen wird. Für unsere Zwecke ist dieses Manko allerdings nicht von Bedeutung.

Jedenfalls rufen wir diese Funktionen unmittelbar nach der Erzeugung unserer RenderEngineData bzw. direkt vor deren Freigabe auf.
Da wir unsere timerequests asynchron verschicken werden und sie uns daher nicht zur Verfügung stehen, während sie beim timer.device sind, verwenden wir nicht unseren frisch erzeugten timerequest sondern benutzen ihn nur als Vorlage um daraus die tatsächlich benutzten Requests zu erzeugen. Wir brauchen zunächst nur einen für unseren Taktgeber, den wir ebenfalls der RenderEngineData hinzufügen:

	struct timerequest tickRequest;

Diesen initialisieren wir, indem wir ihm einfach den Inhalt von timerIO zuweisen:

	rd->tickRequest = *rd->timerIO;

Insgesamt sieht unser RunEngine() nun so aus:

int RunEngine(void)
{
	struct RenderEngineData* rd;

	/* as long we did not enter our main loop we report an error */
	int result = RETURN_ERROR;

	/* allocate the memory for our runtime data and ititialize it with zeros */
	if((rd = (struct RenderEngineData*)
	        AllocMem(sizeof(struct RenderEngineData), MEMF_ANY | MEMF_CLEAR)))
	{
		result = InitTimerDevice(rd);

		if(result == RETURN_OK)
		{
			static struct NewWindow newWindow =
			{
				0, 14,
				320, 160,
				(UBYTE)~0, (UBYTE)~0,
				IDCMP_CLOSEWINDOW | IDCMP_NEWSIZE | IDCMP_REFRESHWINDOW,
				WFLG_CLOSEGADGET | WFLG_DRAGBAR | WFLG_DEPTHGADGET |
				WFLG_SIMPLE_REFRESH | WFLG_SIZEBBOTTOM | WFLG_SIZEGADGET,
				0, 0,
				"Gfx Workshop",
				0,
				0,
				96, 48,
				(UWORD)~0, (UWORD)~0,
				WBENCHSCREEN
			};

			/* setup our tick request */
			rd->tickRequest = *rd->timerIO;
			rd->tickRequest.tr_node.io_Command = TR_ADDREQUEST;

			/* now let's open our window */
			if((rd->window = OpenWindow(&newWindow)))
			{
				/* the main loop will run until this is TRUE */
				rd->run = TRUE;

				result = MainLoop(rd);

				/* cleanup: close the window */
				CloseWindow(rd->window);
				rd->window = 0;
			}
			FreeTimerDevice(rd);
		}

		/* free our runtime data */
		FreeMem(rd, sizeof(struct RenderEngineData));
		rd = 0;
	}

	return result;
}

In MainLoop() müssen wir uns merken, ob unser tickRequest gerade auf Beantwortung durch das timer.device wartet, da wir vorher unsere Schleife nicht verlassen dürfen.
Dazu verwenden wir ein einfaches BOOL:

	BOOL tickRequestPending;

Außerdem müssen wir unsere wait-Maske erweitern und den timerPort mit aufnehmen:

	/* create our waitmask for the timer port */
	tickSig = 1 << rd->timerPort->mp_SigBit;

	/* combine them to our final waitmask */
	signals = winSig | tickSig | SIGBREAKF_CTRL_C;

Dafür müssen wir doRender nicht mehr auf TRUE setzen, das machen wir später, wenn wir unsere Ticks empfangen.

Unmittelbar vor unserer Hauptschleife schicken wir dann den ersten tick-Request ab:

	/* we start with a no-time request so we receive a tick immediatly
	 * (we have to set 2 micros because of a bug in timer.device for 1.3) */
	rd->tickRequest.tr_time.tv_secs = 0;
	rd->tickRequest.tr_time.tv_micro = 2;
	SendIO((struct IORequest*)&rd->tickRequest);
	tickRequestPending = TRUE;

Statt dem SetSignal() warten wir nun wieder ordentlich auf das Eintreffen von Fensternachrichten oder timer.device-Ticks:

		sig = Wait(signals);

Wenn wir ein Tick-Signal erhalten schicken wir es sofort wieder raus, damit wir möglichst gleichmäßig eine 1/25 Sekunde später wieder signalisiert werden:

		if(sig & tickSig)
		{
			/* our tickRequest signalled us, let's remove it from the
			 * replyport */
			WaitIO((struct IORequest*)&rd->tickRequest);

			if(rd->run)
			{
				/* if we are running then we immediatly request another
				 * tick... */
				rd->tickRequest.tr_time.tv_secs = 0;
				rd->tickRequest.tr_time.tv_micro = 1000000 / 25;
				SendIO((struct IORequest*)&rd->tickRequest);
				rd->doRender = TRUE;
			}
			else
			{
				/* ... if not, we acknowlegde that our tickRequest returned */
				tickRequestPending = FALSE;
			}
		}

Nur wenn wir unsere Hauptschleife verlassen wollen setzen wir stattdessen tickRequestPending auf FALSE, da wir nun sicher das timer.device wieder schließen können.

Außerdem fragen wir noch ab, ob wir die Schleife verlassen wollen aber noch ein tick-Request aussteht um diesen in dem Fall abzubrechen:

		if(!rd->run && tickRequestPending)
		{
			/* We want to leave, but there is still a tick request pending?
			 * Let's abort it */
			AbortIO((struct IORequest*)&rd->tickRequest);
		}

Wenn wir nun engine5.c compilieren und starten sehen wir sofort, dass unsere Animation nun sehr viel ruhiger und gleichmäßiger läuft.

Inhalt


Noch mehr Timing

Unsere jetzige Implementierung ist nun also so weit, dass sie maximal unsere gewünschte Anzahl an Frames pro Sekunde ausgibt und eventuelle übrige Rechenzeit wieder an das System abgibt. Allerdings wird der umgekehrte Fall noch nicht hinreichend berücksichtigt: wenn wir die gewünschte Framerate nicht erreichen können dann wird auch unsere Animation langsamer laufen, da sie sich einfach einen festen Betrag pro Frame weiterbewegt. Das soll aber nicht so sein: mangels Framerate wird sie natürlich auch weiterhin ruckeln, aber wenn unser Rechteck z.B. eine Viertelumdrehung pro Sekunde macht, dann soll das auch so bleiben, egal ob wir mit 50fps oder nur mit 5fps laufen. Dazu müssen wir also noch beim Zeichnen die Komponente Zeit mit berücksichtigen. Auch diese holen wir uns vom timer.device indem wir einen entsprechenden Request hinschicken. Da unser erster Request ja schon beim timer.device ist und dort auf Beantwortung wartet müssen wir uns für den Zweck einen zweiten Request anlegen, den wir analog zum ersten initial mit rd->timerIO befüllen. Außerdem müssen wir uns noch den Zeitpunkt unseres letzten Frames merken. Wir erweitern also wieder unsere RenderEngineData um zwei Einträge:

	struct timerequest getTimeRequest;
	struct timeval lastRenderTime;

In RunEngine() initialisieren wir dann die beiden:

			/* setup our getTime request... */
			rd->getTimeRequest = *rd->timerIO;

			/* ... get the current time... */
			rd->getTimeRequest.tr_node.io_Command = TR_GETSYSTIME;
			rd->getTimeRequest.tr_node.io_Flags = IOF_QUICK;
			DoIO((struct IORequest*)&rd->getTimeRequest);

			/* ... and initialize our lastRenderTime */
			rd->lastRenderTime = rd->getTimeRequest.tr_time;

Nun müssen wir unsere RenderBackbuffer()-Funktion etwas umschreiben: bislang haben wir ja stur pro Frame unsere Rechteckkoordinaten um ein 1/32 der Ausgabegröße erhöht; nun wollen wir stattdessen, dass unsere Koordinaten alle vier Sekunden einmal um die Ausgabegröße erhöht werden. Um das möglichst präzise zu machen, merken wir uns die aktuelle Position nicht mehr als WORD sondern als Fließkommazahl FLOAT:

	FLOAT currentStep;

currentStep wird nun die aktuelle Position als einen Wert zwischen 0.0 und 1.0 speichern, wobei 0.0 für die minimale Koordinate und 1.0 für die maximale Koordinate stehen wird.

Um diesen Wert zu berechnen müssen wir pro RenderBackbuffer()-Aufruf die vergangene Zeit seit dem letzten Frame ermitteln. Das machen wir, indem wir uns die aktuelle Zeit holen und von dieser die Zeit unseres letzten Frames abziehen:

		struct timeval diff;

		/* get our current system time */
		rd->getTimeRequest.tr_node.io_Command = TR_GETSYSTIME;
		rd->getTimeRequest.tr_node.io_Flags = IOF_QUICK;
		DoIO((struct IORequest*)&rd->getTimeRequest);

		/* get the time passed since our last render call */
		diff = rd->getTimeRequest.tr_time;
		SubTime(&diff, &rd->lastRenderTime);

In diff haben wir nun diese Differenz im timeval-Format. Das ist für unsere Zwecke etwas unhandlich, wir hätten sie lieber als Fließkommazahl in Sekunden.
Dazu teilen wir die komplette Anzahl an Mikrosekunden in diff durch 1000000.0:

		FLOAT secondsPassed;
		ULONG micros;

		/* we usually don´t expect any seconds here, that´s why we do
		 * check that before doing an expensive multiplication */
		if(diff.tv_secs)
		{
			micros = diff.tv_secs * 1000000l;
		}
		else
		{
			micros = 0;
		}
		micros += diff.tv_micro;
		secondsPassed = ((FLOAT)micros) / 1000000.0f;

Nun müssen wir currentStep einfach nur um ein Viertel von secondsPassed erhöhen, damit wir alle vier Sekunden unseren Maximalwert von 1.0 erreichen.
Sobald wir diesen erreicht haben müssen wir den absoluten Wert davon abziehen.
Da wir in der Praxis hier nur einen absoluten Wert von 1.0 erwarten machen wir dies mit einer einfachen Subtraktion. Die while-Schleife dient hier nur als Sicherheitsnetz, falls wir doch mal einen Wert über 2.0 erhalten sollten; normalerweise werden wir sie nur einmal durchlaufen:

		/* we do a quarter rotate every four seconds */
		rd->currentStep += (secondsPassed / 4.0f);
		while(rd->currentStep >= 1.0f)
		{
			rd->currentStep -= 1.0f;
		}

Nun müssen wir nur noch als aktuelle Position das Produkt von unserer maximalen Position und currentStep einfügen:

		pos.x = (WORD)(rd->currentStep * (FLOAT)maxPos.x);
		pos.y = (WORD)(rd->currentStep * (FLOAT)maxPos.y);

Da wir nun mit Fließkommazahlen arbeiten, müssen wir nun beim Compilieren von engine6.c auch eine Mathe-Bibliothek mit angeben. Bei vbcc geht das so:

vc +kick13 engine6.c -o engine6 -lamiga -lmsoft

und bei SAS/C 6 so:

sc engine6.c ansi strict link math ffp

Wenn wir das Binary nun starten sehen wir ein weich rotierendes Rechteck. Wenn wir nun die Systemlast erhöhen (indem wir zum Beispiel engine6 mehrere Male starten) dann sehen wir, dass die Animation zwar beginnt ruckelig zu werden, das Rechteck sich aber dennoch in der gleichen Geschwindigkeit dreht.

Inhalt


Strukturelles

Eigentlich sind wir jetzt soweit fertig: wir haben eine Animation, die exakt timer.device gesteuert abläuft und sich auch unter Last nicht so schnell aus der Ruhe bringen lässt. Allerdings sind die Funktionen sehr ineinander verzahnt und wir wollen die nun so trennen, dass wir die RenderEngine unabhängig von dem was wir rendern wollen (wieder-)verwenden können. Außerdem möchten wir noch ein paar kleinere Optimierungen anbringen.

Wir möchten also aus unserem jetzigen Code den Renderer isolieren. Dieser besteht vor allem aus dem Zeichencode in RenderBackbuffer(), aber auch lastRenderTime und dessen Logik gehört zu ihm. Ebenso soll unser Renderer die gewünschte Framerate bestimmen können.
Damit liegt auf der Hand, dass unser Renderer erstmal aus drei Funktionen bestehen wird:

InitRenderer(), um ihn zu erzeugen und um die Framerate zu setzen,
RenderFrame(), um ein Frame zu zeichnen und
DestroyRenderer(), um den Renderer wieder zu zerstören.

Wir gehen außerdem davon aus, dass sowohl bei InitRenderer() als auch bei RenderFrame() so einiges schiefgehen kann, weswegen wir hier unseren bewährten int-Rückgabewert verwenden.
Bei DestroyRenderer() darf nichts schiefgehen (wir können an der Stelle eh keine sinnvolle Fehlerbehandlung mehr machen) daher bleibt dieser void:

int InitRenderer();
int RenderFrame();
void DestroyRenderer();

Der Renderer muss natürlich auch seine eigenen Daten über seine Lebenszeit hinweg sichern können. Dazu geben wir ihm Gelegenheit, diese in der Init-Funktion in Form eines void*-Pointers zu speichern und diesen bekommt er bei RenderFrame() und DestroyRenderer() als Parameter übergeben:

int InitRenderer(void** userData);
int RenderFrame(void* userData);
void DestroyRenderer(void* userData);

Sinnvollerweise definiert man sich für die Renderer-Daten eine Struktur, die man dann in InitRenderer() erzeugt:

struct RendererData
{
};

int InitRenderer(void** userData)
{
	int result;

	*userData = AllocMem(sizeof(struct RendererData), MEMF_ANY | MEMF_CLEAR);
	if(*userData)
	{
		result = RETURN_OK;
	}
	else
	{
		result = RETURN_ERROR;
	}

	return result;
}

und in DestroyRenderer() entsprechend wieder freigibt:

void DestroyRenderer(void* userData)
{
	FreeMem(userData, sizeof(struct RendererData));
}

In diese Renderer-Struktur ziehen wir nun unsere Daten aus RenderEngineData um:

struct RendererData
{
	struct timeval lastRenderTime;
	FLOAT currentStep;
};

Um diese Struktur zu initialisieren brauchen wir außerdem die aktuelle Systemzeit. Und da wir ja außerdem die Framerate setzen wollen sieht unser InitRenderer() letztendlich so aus:

int InitRenderer(void** userData, const struct timeval* sysTime,
                 struct timeval* refreshRate)
{
	int result;

	/* allocate our user data */
	*userData = AllocMem(sizeof(struct RendererData), MEMF_ANY | MEMF_CLEAR);
	if(*userData)
	{
		struct RendererData* rd = (struct RendererData*)(*userData);

		/* set our lastRenderTime to now */
		rd->lastRenderTime = *sysTime;

		/* we would like to get a refresh rate of 25 frames per second */
		refreshRate->tv_secs = 0;
		refreshRate->tv_micro = 1000000 / 25;

		result = RETURN_OK;
	}
	else
	{
		result = RETURN_ERROR;
	}

	return result;
}

In RenderFrame bringen wir dann unsere bisherigen Zeichenoperationen unter. Außerdem basteln wir uns noch eine kleine Hilfsfunktion um die Differenz zweier struct timeval in Sekunden als FLOAT umzurechnen:

FLOAT DiffInSeconds(const struct timeval* early, const struct timeval* late)
{
	struct timeval diff;
	ULONG micros;

	diff = *late;
	SubTime(&diff, (struct timeval*)early);

	micros = diff.tv_secs ? diff.tv_secs * 1000000l : 0;
	micros += diff.tv_micro;

	return ((FLOAT)micros) / 1000000.0f;
}

RenderFrame() sieht dann so etwas aufgeräumter aus:

int RenderFrame(void* userData, struct RastPort* renderTarget,
                const struct tPoint* renderTargetSize,
                const struct timeval* sysTime)
{
	FLOAT secondsPassed;
	struct tPoint pos;
	struct tPoint maxPos;
	struct RendererData* const rd = (struct RendererData*)userData;

	secondsPassed = DiffInSeconds(&rd->lastRenderTime, sysTime);

	maxPos.x = renderTargetSize->x - 1;
	maxPos.y = renderTargetSize->y - 1;

	/* we do a quarter rotate every four seconds */
	rd->currentStep += (secondsPassed / 4.0f);
	while(rd->currentStep >= 1.0f)
	{
		rd->currentStep -= 1.0f;
	}

	/* now compute our new position */
	pos.x = (WORD)(rd->currentStep * (FLOAT)maxPos.x);
	pos.y = (WORD)(rd->currentStep * (FLOAT)maxPos.y);

	/* clear our bitmap */
	SetRast(renderTarget, 0);

	/* draw our rectangle */
	SetAPen(renderTarget, 1);
	Move(renderTarget, 0l,             (LONG)pos.y             );
	Draw(renderTarget, (LONG)(maxPos.x - pos.x), 0l            );
	Draw(renderTarget, (LONG)maxPos.x, (LONG)(maxPos.y - pos.y));
	Draw(renderTarget, (LONG)pos.x,    (LONG)maxPos.y          );
	Draw(renderTarget, 0l,             (LONG)pos.y             );

	/* remember our render time */
	rd->lastRenderTime = *sysTime;

	return RETURN_OK;
}

Nun müssen wir nur noch dafür sorgen, dass diese Funktionen in der RenderEngine an den passenden Stellen aufgerufen werden. Da wir außerdem an mehreren Stellen uns die aktuelle Systemzeit holen müssen, lagern wir auch das in eine eigene Funktion aus:

void UpdateTime(struct RenderEngineData* rd)
{
	/* get our current system time */
	rd->getTimeRequest.tr_node.io_Command = TR_GETSYSTIME;
	rd->getTimeRequest.tr_node.io_Flags = IOF_QUICK;
	DoIO((struct IORequest*)&rd->getTimeRequest);
}

Die Systemzeit lesen wir dann einfach bei Bedarf aus unserem getTimeRequest aus.

In unseren RenderEngineData merken wir uns außerdem nun die gewünschte Refresh-Zeit und den userData-Pointer für den Renderer:

	struct timeval refreshRate;
	void* userData;

Und den InitRenderer()-Aufruf platzieren wir in RunEngine() unmittelbar bevor wir unser Fenster öffnen und in MainLoop() springen:

			/* get the current time... */
			UpdateTime(rd);

			/* ... and initialize our Renderer */
			result = InitRenderer(&rd->userData,
			                      &rd->getTimeRequest.tr_time,
			                      &rd->refreshRate);

			if(result == RETURN_OK)
			{
				[...] /* open window and do MainLoop */

				DestroyRenderer(rd->userData);
			}

RenderFrame() rufen wir dann einfach in RenderBackbuffer() auf:

int RenderBackbuffer(struct RenderEngineData* rd)
{
	int result;

	result = PrepareBackBuffer(rd, &backBufferDirty);

	if(result == RETURN_OK)
	{
		UpdateTime(rd);
		result = RenderFrame(rd->userData, &rd->renderPort,
		                     &rd->backBufferSize,
		                     &rd->getTimeRequest.tr_time);
	}

	return result;
}

Unmittelbar vor dem RenderFrame()-Aufruf holen wir hier die aktuelle Zeit und übergeben diese an RenderFrame().

Damit haben wir unsere Renderer-Logik vollständig auf drei Funktionen verteilt. In einem richtigen Programm könnte man jetzt zweckmäßigerweise die verbleibende RenderEngine in ein eigenes Modul auslagern. Im Rahmen dieses Workshops lassen wir das aber erstmal bleiben, um uns jetzt noch nicht mit Projekt-Setup oder Makefiles auseinander setzen zu müssen.

Stattdessen stört uns in der Form noch eine Sache: es kann durchaus sein, dass ein Renderer in RenderFrame feststellt, dass er eigentlich gar nicht rendern müsste, weil sich das Frame seit dem letzten Aufruf nicht geändert hat. Allerdings muss er dazu noch mitgeteilt bekommen, ob sein letztes Frame im Backbuffer noch vorhanden ist (z.B. weil der Backbuffer zwischenzeitlich neu angelegt werden musste), da er andernfalls immer neu zeichnen muss. Dazu erweitern wir RenderFrame um zwei Parameter:

int RenderFrame(void* userData, struct RastPort* renderTarget,
                const struct tPoint* renderTargetSize,
                BOOL renderTargetDirty, const struct timeval* sysTime,
                BOOL* updateDone);

Über renderTargetDirty bekommt der Renderer so mitgeteilt, ob sein letztes Frame noch in renderTarget vorhanden ist. Über updateDone informiert er den Aufrufer, ob er tatsächlich ein Frame in renderTarget gezeichnet hat.

Feststellen, ob der Backbuffer nun in jedem Fall neu gezeichnet werden muss, oder ob das vorige Frame noch erhalten ist kann unsere PrepareBackbuffer()-Funktion; also müssen wir auch diese erweitern:

int PrepareBackBuffer(struct RenderEngineData* rd, BOOL* backBufferDirty)
{
	int result;

	if(rd->outputSize.x != rd->backBufferSize.x ||
	   rd->outputSize.y != rd->backBufferSize.y)
	{
		[Allocate new bitmap code snipped...]

		*backBufferDirty = TRUE;
	}
	else
	{
		*backBufferDirty = FALSE;
	}

	result = rd->backBuffer ? RETURN_OK : RETURN_ERROR;

	return result;
}

Wir setzen hier also backBufferDirty immer dann auf TRUE, sobald wir unsere BitMap neu anlegen mussten.

In RenderBackbuffer() fügen wir nun diese beiden Funktionen zusammen und liefern das doRepaint von RenderBackbuffer() zurück:

int RenderBackbuffer(struct RenderEngineData* rd, BOOL* doRepaint)
{
	int result;
	BOOL backBufferDirty;

	result = PrepareBackBuffer(rd, &backBufferDirty);

	if(result == RETURN_OK)
	{
		UpdateTime(rd);
		result = RenderFrame(rd->userData, &rd->renderPort,
		                     &rd->backBufferSize, backBufferDirty,
		                     &rd->getTimeRequest.tr_time,
		                     doRepaint);
	}

	return result;
}

Nun müssen wir nur noch in MainLoop() den Renderaufruf überarbeiten:

		if(rd->doRender)
		{
			BOOL doRepaint;
			rd->returnCode = RenderBackbuffer(rd, &doRepaint);
			if(rd->returnCode == RETURN_OK)
			{
				/* Rendering succeeded, set repaint if required */
				if(!rd->doRepaint)
				{
					rd->doRepaint = doRepaint;
				}
				rd->doRender = FALSE;
			}
			else
			{
				/* Rendering failed, do not repaint.. */
				rd->doRepaint = FALSE;

				/* but signal ourself to leave instead */
				Signal(FindTask(0), SIGBREAKF_CTRL_C);
			}
		}

Zu beachten ist hier nur, dass wir ein bereits gesetztes rd->doRepaint nicht durch ein FALSE überschreiben.

Wir haben nun unser erstes Ziel erreicht: wir haben ein Fenster in welches wir per Double-Buffering zeichnen können und gleichzeitig auch bei wechselnden Frameraten präzise per timer.device gesteuert unsere einzelnen Frames erstellen können.

engine7.c wird ebenfalls mit diesem Aufruf compiliert:

vc +kick13 engine7.c -o engine7 -lamiga -lmsoft

Im nächsten Teil dieses Workshops möchte ich dann dieses Grundgerüst verwenden um ein paar einfache 2D-Vekor-Grafikroutinen zu erarbeiten.

Ich hoffe, dass Dir der Workshop bisher gefallen hat und würde mich freuen, wenn er Dich zu weiteren Experimenten animiert!

Inhalt


Dieser Workshop ist ein privater Artikel von:

Kai Scherrer, Elsässer Straße 1, 76870 Kandel, E-Mail: kai@kaiiv.de