In diesem Kapitel geht es um eine Technik, die vor allem dann gerne eingesetzt wird, wenn der verfügbare Speicherplatz der Anwendung limitiert ist. Es gibt zum Beispiel Programmiercontests, bei denen eine 3D-Anwendung 64kb nicht überschreiten darf. Um dann noch detaillierte Texturen zu erstellen und als Datei auszulagern, reicht der Platz nicht mehr aus. Der Trick liegt darin, die Texturen zur Laufzeit zu erstellen, und über algorithmische Methoden die Grafik zu erstellen.
Aber auch für andere Zwecke kann es nützlich sein, Texturen zur Laufzeit zu bearbeiten. So benötigen Sie vielleicht die Farbinformationen einer Textur um damit eine Heightmap zu generieren. Oder vielleicht möchten Sie in Ihrem 3D-Egoshooter in der Schaltzentrale eines Raumschiffes ein paar Monitore einbauen, auf denen Aufnahmen der Überwachungskameras aus anderen Teilen des Levels laufen. Oder Sie brauchen mal eben eine neue Lightmap, weil sich der Ort der Lichtquelle verändert hat.
Nicht zuletzt bieten die Techniken, die Sie hier lernen neue Experimenitiermöglichkeiten um interessante grafische Effekte zu erzielen. Wie in den meisten anderen Kapiteln gilt auch hier die Erkenntnis: die Technik ist einfach, die Möglichkeiten unbegrenzt!
Bevor wir uns der Praxis zuwenden, müssen wir uns noch kurz mit der Art und Weise beschäftigen, wie Bildinformationen von Texturen im Speicher abgelegt werden und wie man auf diese zugreift.
Bisher sprachen wir nur von der Auflösung einer Textur, womit wir die Breite, Höhe und die Farbtiefe meinten. Wenn wir die Breite oder Höhe eines Bitmaps im Bildeditor ermittelten, spielte es keine Rolle, in welcher Farbauflösung dieses Bitmap vorliegt. Wen wir in DirectXGraphics aber auf Texturen zugreifen wollen, ist das von grosser Relevanz. Alle Bildinformationen einer Textur werden im Speicher hintereinander abgelegt - Pixel für Pixel. Das heisst, hat eine Textur 16 Bit, müssen wir immer 2 Bytes pro Pixel einlesen. Bei 32 Bit fassen wir immer 4 Bytes zu einem Pixel zusammen.
Und damit kommen wir auch schon zur neuen Vokabel, nämlich dem Pitch. Der Pitch gibt die Breite einer Textur an - jedoch in Bytes. Betrachten wir hierzu folgende Grafik:
Angenommen wir haben eine 16-Bit-Textur, die 3 Pixel breit ist. Dann würden wir als Pitch-Wert 6 erhalten. Denn wie wir wissen, besteht ein Byte aus 8 Bits. Bei 16 Bits pro Pixel haben wir also zwei Bytes pro Pixel. Da die Textur 3 Pixel hat, ergibt sich also 2*3=6.
Um Texturen bearbeiten zu können, stehen uns nur extrem wenige Informationen zur Verfügung. Genauer gesagt zwei: der Pitch-Wert und ein Zeiger auf den ersten Pixel der Textur. Wenn Sie sich jetzt fragen, wie man nur mit diesen beiden Werten eine Textur bearbeiten soll, dann lesen Sie ruhig weiter. Es geht!
Um eine Textur zur Laufzeit bearbeiten zu können, muss diese unter bestimmten Vorraussetzungen erstellt worden sein. Betrachten wir hierzu folgendes Beispiel:
D3DXCreateTexture( d3ddev8, 256, 256, 0, 0, // nicht D3DUSAGE_RENDERTARGET D3DFMT_A4R4G4B4, D3DPOOL_MANAGED, // oder D3DPOOL_SYSTEMMEM textur ); |
Zwei Dinge müssen Sie beachten, um später die Textur bearbeiten zu können:
Um auf die einzelnen Bildinformationen zugreifen zu können, müssen wir, wie bereits beim Vertex- und Indexbuffer bekannt, den Speicher für unsere Zugriffe erst einmal reservieren bzw. locken. Dies geschieht mit folgendem Befehl aus dem Textur-Interface:
function LockRect(const Level : Cardinal; out pLockedRect : TD3DLocked_Rect; const pRect : PRect; const Flags : LongWord) : HResult; |
Level: Das Mipmaplevel, dass Sie locken wollen.
pLockRect: Ein Record des Typs D3DLOCKED_RECT, das mit den nötigen Informationen über den gelockten Bereich gefüllt wird.
pRect: Ein Zeiger auf ein Record vom Typ TRect, das den Bereich der Textur angibt (in Pixel), der gelockt werden soll. Wahlweise kann auch nil übergeben werden, wenn Sie die gesamte Textur locken wollen.
Flags: Eine oder mehrere Konstanten aus den D3DLOCKFLAGS, ausser D3DLOCK_NOOVERWRITE.
Beachten Sie beim locken, das das Record pRect nicht grösser definiert ist, als die Textur da es sonst zu Fehlern kommen kann. Das folgende Beispiel zeigt den Befehl in der Praxis:
var d3dlr : TD3DLocked_Rect; hr : HRESULT; begin hr := textur.LockRect(0, d3dlr, nil, 0); if hr<>D3D_OK then begin exit; end; |
In diesem Beispiel werden alle Bildinformationen des ersten Miplevel der Textur gelockt. Wir haben sowohl Lese- als auch Schreibrechte auf den Speicherbereich, so dass wir nun damit beginnen können, die Texturfarben zu bearbeiten.
Falls Sie jetzt einen schwer zu verstehenden Abschnitt erwarten, dann muss ich Sie leider enttäuschen. Das eigentliche Bearbeiten einer Textur ist im Prinzip ganz einfach. Viel schwieriger, und das liegt im Können des Programmierers, ist es, etwas sinnvolles mit dieser Technik anzustellen. Doch schauen wir uns die Technik selbst erstmal an.
Das durch die LockRect-Funktion erhaltene Record TD3DLocked_Rect beinhaltet unter anderem das Attribut pBits. Es ist der Zeiger auf den Anfang des gelockten Bereiches (im oberen Beispiel sogar der Anfang der Textur). Damit haben wir im Prinzip auch schon Zugriff auf den ersten Pixel der Textur. Diesem Pixel können Sie bereits ohne weiteres einen neuen Farbwert zuweisen. Dabei ist allerdings entscheidend, in welchem Format Sie die Textur erstellt haben. Handelt es sich beispielsweise um eine 16-Bit Funktion, so können Sie an der Stelle, auf die der Zeiger zeigt, einen Integer-Wert vom Typ Word auslesen bzw. schreiben. Bei einer 32-Bit-Textur wäre die Integer-Zahl vom Typ LongWord. Um den nächsten Pixelwert auslesen bzw. beschreiben zu können inkrementieren Sie einfach den Zeiger.
Falls Sie noch nicht sicher sind, ob Sie die Beschreibung verstanden haben, gibt Ihnen vielleicht folgendes Beispiel mehr Sicherheit:
var d3dlr : TD3DLocked_Rect; hr : HRESULT; cursor : pWord; begin hr := textur.LockRect(0, d3dlr, NIL, 0); if hr<>D3D_OK then begin exit; end; // Damit die 16-Bitwerte bequemer zuweisbar sind cursor := d3dlr.pBits; cursor^:= $0fa3; // ersten Pixel beschreiben inc(cursor); // zu den nächsten 16 Bits cursor^:= $358d; // zweiten Pixel beschreiben |
Was passiert hier? Zunächst wandeln wir den untypisierten Zeiger in einen typisierten Word-Zeiger um. Dies ermöglicht uns die 16-Bit Farbwerte einfacher auszulesen bzw. zu schreiben. Danach wird auch schon der erste Wert beschrieben. Ich habe hier statt einer nichtssagenden Dezimalzahl eine Hexadezimalzahl benutzt. Hierbei beziehe ich mich auf die Textur, die im Abschnitt Vorraussetzungen erstellt wurde. In diesem Abschnitt haben wir eine 16-Bit Textur eingerichtet, bei der jedem Farbkanal 4-Bits zugewiesen wurden. Die Kanäle Alpha, Rot, Grün und Blau sind in den Stellen des Hexadezimalwertes wiedererkennbar. Die Stärke des jeweiligen Kanals reicht dabei von 0 bis f. Um auf den nächsten Pixel zugreifen zu können, wird mit der Funktion inc der Zeiger einfach inkrementiert.
Im Prinzip liegt darin das ganze Geheimnis um eine Textur zur Laufzeit verändern zu können. Es ist lediglich eine Frage des richtigen Algorithmus, was für Bilder oder Muster sich mit dieser Technik erstellen lassen.
Wichtig beim Zugriff auf die Texturpixel ist, dass sich der Zeiger nur innerhalb des gelockten Bereiches bewegt. Überschreiten Sie die Grenze kommt es in aller Regel zu einer Fehlermeldung. Daher ist es wichtig herauszubekommen, wie gross eine Textur ist. Dazu gibt es zwei Möglichkeiten. Entweder haben Sie beim Erstellen der Textur (zum Beispiel mit dem Befehl D3DXCreateTextureFromFileEx) ein Record vom Typ
D3DXIMAGEINFO erhalten, in dem die Höhe und Breite der Textur angegeben ist, oder aber Sie ermitteln die Grösse erst nach dem Locken. Dafür bietet sich der Pitch-Wert an.
Wir erinnern uns: Pitch gibt die Breite einer Textur in Bytes zurück. Bei 256 16-Bit Pixeln wären das also 512 Bytes. Damit erhalten wir also schonmal einen Rückschluss auf die Breite der Textur. Da man aus Performancegründen grundsätzlich quadratische Texturen verwenden sollte, können wir mit dem Pitch-Wert sowohl Breite als auch Höhe ermitteln. Um in der Praxis zum Beispiel eine Textur komplett in Weiss zu färben, könnte die Funktion dafür folgendermassen aussehen:
var d3dlr : TD3DLocked_Rect; cursor : pWord; x : integer; laenge : LongWord; hr : HRESULT; begin hr := textur.LockRect(0, d3dlr, NIL, 0); if hr=D3D_OK then begin laenge := (d3dlr.Pitch shr 1); cursor := d3dlr.pBits; for x := 1 to (laenge*laenge) do begin cursor^:= $ffff; inc(cursor); end; textur.UnlockRect(0); end; end; |
In diesem Fall haben wir also eine quadratische Textur, so dass Höhe gleich Breite ist. Mit shr verschieben wir alle Bits um eine Stelle nach rechts, was im Prinzip der Division durch 2 gleichkommt.
Das Beispiel enthält auch gleich schon die Funktion zum Unlocken. Diese sieht in der Definition so aus:
function UnlockRect(const Level : Cardinal) : HResult; |
Level: Das Mipmap-Level, dass Sie wieder unlocken wollen.
Ich kann mir gut vorstellen, dass Sie bisher noch skeptisch gegenüber der Texturbearbeitung sind. Daher möchte ich Ihnen gerne ein Beispiel zeigen, wo diese Technik eingesetzt werden kann.
In der Einleitung habe ich kurz die 64k Demos angesprochen. Dabei handelt es sich um selbstlaufende Programme, die möglichst viele audiovisuelle Effekte bieten. Die Programmgrösse ist hierbei auf 64kb begrenzt. Diese Aufgabe ist eine grosse Herausforderung für alle Programmierer, die nach den effizientesten und am vielseitigsten einsetzbaren Algorithmen suchen, um zum Beispiel Bilder (in Texturen) zu erstellen. Ein einfaches Beispiel sind Zaun-artige Muster.
Hierbei werden durch mehrmalige Zugriffe auf die Textur sowohl der Schatten des Zaunes als auch der Zaun selbst durch einen Algorithmus aufgetragen. Der Algorithmus dafür könnte zum Beispiel so aussehen:
procedure Paint_Texture( _textur : IDirect3DTexture8; _breite, _abstand, _sx, _sy : integer; _color : word; _overwrt : Boolean ); var d3dlr : TD3DLocked_Rect; cursor : pWord; x,y : integer; YInc : LongWord; summe : integer; hr : HRESULT; begin summe := _breite + _abstand; hr := _textur.LockRect(0, d3dlr, nil, 0); if hr=D3D_OK then begin YInc := (d3dlr.Pitch shr 1); cursor := d3dlr.pBits; for x := 1 to YInc do begin for y := 1 to YInc do begin if ((y+x-_sx) mod summe) <= _breite then begin cursor^:= _color; end else begin if _overwrt then begin cursor^:= $f000; end; end; if ((y+Yinc-x-_sy) mod summe) <= _breite then begin cursor^:= _color; end; inc(cursor); end; end; end else begin hHalt(PChar(errtostr(hr)), Application); end; _textur.UnlockRect(0); end; |
Das Bild auf dem Screenshot lässt sich durch den zweimaligen Aufruf der Prozedur erreichen. Für den Screenshot wurden dafür folgende Parameter übergeben:
Paint_Texture( tex1, 4, 16, 4, 2, $0333, true ); Paint_Texture( tex1, 4, 16, 0, 0, $0446, false ); |
Eine weitere Möglichkeit, Texturen zur Laufzeit zur verändern, ist, sie als Rendertarget zu benutzen. Das bedeutet, alles, was gerendert werden soll, wird nicht direkt auf dem Bildschirm ausgegeben, sondern erstmal nur innerhalb der Textur. Wenn Sie dann die Textur rendern, können Sie interessante bzw. nützliche Effekte erreichen. Auf diese Weise kann man zum Beispiel einen Spiegel simulieren oder eine Überwachungsmonitor. Aber auch andere visuelle Effekte, lassen sich damit erreichen. Wollen Sie zum Beispiel ein Objekt gleich mehrmals aus der gleichen Perspektive darstellen, können Sie dieses Objekt zunächst in eine Textur rendern um diese dann beliebig oft anzuzeigen.
Um eine Szene in eine Textur zu rendern müssen Sie zunächst eine Textur erstellen, und diese so einrichten, dass Sie als Rendertarget benutzt werden kann. Dazu ist es notwendig als vierten Parameter D3DUSAGE_RENDERTARGET zu verwenden. Die Create-Prozedur könnte also zum Beispiel so aussehen.
D3DXCreateTexture(d3ddev8, 256, 256, 0, D3DUSAGE_RENDERTARGET, // <-- wichtig D3DFMT_A1R5G5B5, D3DPOOL_DEFAULT, textur); |
Um das Renderziel zu ändern, bietet das Direct3DDevice-Interface folgende Funktion:
function SetRenderTarget(pRenderTarget, pNewZStencil : IDirect3DSurface8) : HResult; |
pRenderTarget: Ein IDirect3DSurface8-Interace, das als neues Renderziel benutzt werden soll. Übergeben Sie nil, wird das aktuelle Surface verwendet.
pNewZStencil: Ein IDirect3DSurface8-Interace, das als neuer Tiefenbuffer benutzt werden soll. Übergeben Sie nil, wird das aktuelle Surface verwendet.
Wie Sie feststellen, wird ein Surface, und keine Textur verlangt. Wir können also den Texturzeiger nicht direkt dieser Funktion übergeben. Ein Surface repräsentiert den eigentlichen Speicherbereich der Bildinformationen. Dabei wird für jedes Mipmaplevel der Textur ein eigenes Surface angelegt. Daher müssen wir erst mal auswählen, welches Surface wir überhaupt benutzen wollen. Dies geschieht mit folgender Funktion des Textur-Interfaces:
function GetSurfaceLevel(const Level : Cardinal; out ppSurfaceLevel : IDirect3DSurface8) : HResult; |
Level: Das Mipmaplevel, dessen Surface wir haben wollen.
ppSurfaceLevel: Ein Zeiger vom Typ IDirect3DSurface8.
Mit dieser Funktion erhalten wir also einen Zeiger auf das Surface, das wir als Rendertarget benutzen wollen. Im Prinzip ist das alles, was wir wissen müssen, um eine Textur als Rendertarget verwenden zu können. Ein Beispielcode könnte folgendermassen aussehen:
var rendertarget: IDirect3DSurface8; texsurf: IDirect3DSurface8; begin d3ddev8.GetRenderTarget( rendertarget ); tex1.GetSurfaceLevel( 0, texsurf ); d3ddev8.SetRenderTarget( texsurf, nil ); d3ddev8.Clear( 0, nil, D3DCLEAR_TARGET, D3DCOLOR_XRGB( 0, 0, 0), 1.0, 0 ); // Hier kommen alle Renderoperationen rein, // die auf der Textur abgebildet werden sollen d3ddev8.SetRenderTarget( rendertarget, nil ); d3ddev8.Clear( 0, nil, D3DCLEAR_TARGET, D3DCOLOR_XRGB( 0, 0, 0), 1.0, 0 ); d3ddev8.SetTexture(0, tex1); // Renderoperationen, bei denen tex1 // verwendet werden soll |
.GetRenderTarget gibt, wie Sie sich sicher denken können einen Zeiger auf das aktuelle Rendertarget zurück. Diesen Zeiger brauchen wir, um später die Szene auf dem Desktop wieder ausgeben zu können. Wie üblich, bietet es sich auch beim Rendern in eine Textur an, zunächst einmal mit der .Clear-Funktion alle Bildinformationen zu löschen, damit die 3D-Objekte in der Animation keine Spuren hinter sich her ziehen.