Tutorial ColorPicking Shader: Unterschied zwischen den Versionen

Aus DGL Wiki
Wechseln zu: Navigation, Suche
K (Die Renderschleife)
K (Einleitung)
Zeile 4: Zeile 4:
  
 
Jaja, schon das dritte Selektionstutorial auf DelphiGL. Aber glaubt mir wenn
 
Jaja, schon das dritte Selektionstutorial auf DelphiGL. Aber glaubt mir wenn
ich euch sage, dass es dafür auch gute Gründe gibt. DGL bietet momentan leider
+
ich euch sage, dass es dafür auch gute Gründe gibt. DGL bot bisher leider
 
nur Tutorials, die das Picking auf Basis von OpenGLs Namestack behandeln. Diese
 
nur Tutorials, die das Picking auf Basis von OpenGLs Namestack behandeln. Diese
 
Methode ist aber gerade für Einsteiger ungeeignet, nicht etwa, weil sie
 
Methode ist aber gerade für Einsteiger ungeeignet, nicht etwa, weil sie

Version vom 3. August 2009, 20:34 Uhr

Objektselektion mit Color Picking

Einleitung

Jaja, schon das dritte Selektionstutorial auf DelphiGL. Aber glaubt mir wenn ich euch sage, dass es dafür auch gute Gründe gibt. DGL bot bisher leider nur Tutorials, die das Picking auf Basis von OpenGLs Namestack behandeln. Diese Methode ist aber gerade für Einsteiger ungeeignet, nicht etwa, weil sie unnötig kompliziert wäre, aber weil sie hoffnungslos veraltet, tendenziell inkompatibel zu nVidias Treibern (Softwaremodus!) und enorm unflexibel ist. Jedenfalls war es kein Anflug aus purer Bosheit, der die Khronos-Group dazu bewogen hat, diese Methode in OpenGL3 zu entfernen.

Ein Wort zur angestrebten Zielgruppe: Bedenkt bitte, dass das Tutorial eher darauf ausgelegt ist, euch eine performante, für große Projekte taugliche und vor allem zukunftssichere Methode zu präsentieren.

Für Einsteiger mit weniger großen Ambitionen empfiehlt es sich aber eher das Tutorial von Tilman (Vorabversion als PDF) welches mit einfacheren Mitteln zurecht kommt.

In diesem Tutorial soll es um Color Picking, also Selektion mithilfe von Farben, gehen. Damit ihr euch ein Bild davon machen könnt wo ihr steht, wenn ihr die Technik beherrscht, hier ein kurzer Überblick über die Vor- und Nachteile:


Vorteile Nachteile
  • Geschwindigkeit
Ein Selektionsvorgang mit dieser Methode dauert niemals länger als ein ganz normales Rendering der Szene - i.d.R. ist es sogar schneller, da das Verfahren darauf aufbaut, viel "unter den Tisch fallen zu lassen".
  • Speichereffizienz
Im Gegensatz zu strahlbasierten Verfahren, die im Härtefall eine eigene Kollisionsgeometrie benötigen, funktioniert Color Picking mit Shadern "in place" und benötigt keinen zusätzlichen Speicher (oder nur vernachlässigbar wenig). Wird es ohne Shader implementiert, fallen mit jedem selektierbarem Objekt 3-4 Byte pro Vertex an (d.h. etwa 12 Bytes für ein Quad).
  • Flexibilität
Color Picking funktioniert mit jeder Grafik-Schnittstelle und jeder Art von Geometrie - das schließt 2D, 3D, animiert, dynamisch und alphamaskiert mit ein. Objekte müssen nicht, wie in diesem Beispiel, durch einen festen Index identifiziert werden, sondern können auch problemlos mit eigenen Werten oder sogar Funktionen versehen werden. Und das Beste:
  • Simplizität
Das Alles gibt es zum Preis eines kleinen Fragmentshaders. Mehr Aufwand ist kaum nötig, um so gut wie alle Sonderfälle abzudecken. Man kann als Fausregel festhalten: Alles was der Benutzer sieht, kann selektiert werden. Ohne Kompromisse! Pixelgenau. Ohne aufwändige Mathematik dahinter. Und alles _voll_ Hardwarebeschleunigt. Bei der shaderlosen Variante müssen sich allerdings ein paar Gedanken über die zugrundeliegende Geometrie gemacht werden (auch vom Performanceaspekt her betrachtet).
  • Reproduzierbarkeit
Leider ist man bei einem hardwarebasierten Verfahren (trifft auf Color Picking mit OpenGL sowie Direct3D zu) auf Gedeih und Verderb dem Treiber ausgeliefert. Und damit hängt der Erfolg der Selektion theoretisch auch davon ab, wie gut sich der Treiberhersteller an Standards halten kann. Insbesondere Dinge wie Antialiasing können die Selektion verfälschen - in diesem Tutorial wird aber auch ein sehr effektiver Workaround dagegen angeboten. In der Praxis ist dieser Nachteil auch eher unbedeutend, weil man ein gewisses Maß an Standards durchaus vorraussetzen kann.
  • Redundanz zum Physikcode
Wenn alle mathematischen Grundlagen für eine Selektion via Raycasting geschaffen sind, wäre eine zusätzliche Implementierung von Color Picking nur Ballast. "Grundlagen für Raycasting" hört sich vielleicht abgehoben an, diese sind aber z.B. schon in einem einfachen Ego Shooter geschaffen, wenn man herauskriegen will, wo das Einschussloch gezeichnet werden soll oder welche Hitbox des Gegners getroffen wurde.
  • Hardwareanforderungen
Je mehr Features man nutzt (wie z.B. Shader), desto neuer muss die Grafikhardware sein. Die Methode, die hier im Tut vorgestellt wird, basiert auf GLSL und ist damit ab OpenGL 2.0 Core zu haben.

Theorie

Die Theorie hinter dem Color Picking ist ganz schnell erklärt. Man zeichnet jedes selektierbare Objekt in einer einmaligen Farbe und merkt sich, welches Objekt in welcher Farbe gezeichnet wurde. Dann wird ein Pixel unter dem Mauszeiger ausgelesen - anhand der ausgelesenen Farbe lässt sich auf das Objekt unter dem Mauszeiger zurückschließen. Das ist vielleicht momentan noch ein bisschen sehr abstrakt, sollte aber schon klarer werden, wenn ihr den Beispielcode verfolgt.


Implementierung

Organisation

Vorweg muss gesagt sein: Wirklich alles was ich in dem Beispielcode mache, ist nur eine Lösung von vielen. Wie euch durch die kurze Theorie schon dämmern sollte gibt es etliche Möglichkeiten Color Picking zu implementieren. In diesem Beispiel sieht das Ganze so aus: Die Daten der selektierbaren Objekte (Displayliste, Position und ein String) sind in einem Record zusammengefasst. Alle diese Records sind in einem Array angeordnet und werden auch per Schleife gezeichnet... exakt dieser Array-Index ist es nun, was jedem individuellen Objekt seine Identifizierbarkeit spendiert.


Der Shader

Jeder der bei Shader an böhmische Dörfer denkt, sollte sich vielleicht vorher kurz das GLSL-Tutorial auf DGL zu Gemüte führen. Prinzipiell handelt es sich um kleine Programme, die Teile der festen OpenGL-Pipeline (dazu gehört z.B. die Positionierung von Vertices und das Zeichnen oder Verwerfen von Pixeln) ersetzen. Shader können entweder in Assembler oder einer Sprache namens GLSL in einer C-artigen Syntax geschrieben werden - in diesem Beispiel wird letztere Methode verwendet, da die Assemblervariante kaum noch weiterentwickelt wird und schlechter zu lesen ist.

Sehen wir uns nun die Shader an, der vor einem Selektionsvorgang gebunden und anschließend wieder ausgeschaltet werden:


// Vertexshader

void main(void)
{
  gl_Position = ftransform();
  gl_TexCoord[0] = gl_MultiTexCoord0;
}


Der Vertexshader macht offensichtlich nichts (oder besser: nur) weltbewegendes. Jedes Vertex an seine Stelle setzen und Texturkoordinaten durchschleifen... aber da fehlt doch was? Der Vertexshader übergibt gar keine Farbdaten - d.h. wir können schonmal festhalten, dass die aktuelle Farbe in der Statemachine komplett ignoriert wird.


// Fragmentshader

uniform vec4 color;
uniform float use_alpha_tex;
uniform sampler2D sampler;

void main(void)
{
  if (use_alpha_tex == 1.0)
    if (texture2D(sampler, vec2(gl_TexCoord[0])).a < 0.2)
      discard;
      
  gl_FragColor = color;
}


Hier sieht es schon gleich viel interessanter aus. Der Fragmentshader bekommt zwei im Programm gesetzte uniform Variablen: color und use_alpha_tex. Wie man in der letzten Zeile sieht, ist der Wert dieser ominösen color-Variable genau das, was auch im Framebuffer erscheinen wird.

"use_alpha_tex" wird im Programm auf 1.0 gesetzt, wenn Texturen mit Alphakanal verwendet werden. Sollen solche Texturen verwendet werden, liest der Shader den aktuellen Alphawert und verwirft das Fragment, wenn Alpha unter dem Schwellenwert 0.2 liegt. Es handelt sich also nur um einen handgemachten Alphatest.

Dabei sei angemerkt, dass dies kein sonderlich stilvolles Vorgehen ist: Man versucht möglichst, einen Shader nur auf eine ganz eng umrissene Aufgabe hin zu gestalten - da haben solche Abfragen nichts zu suchen. Es dient in diesem Tutorial aber der Übersichtlichkeit, also werden wir mal beide Augen zudrücken.

Zusammenfassend kann man also sagen, dass wenn der Shader aktiviert ist, jedes einzelne gezeichnete Pixel eine von uns im Hauptprogramm festgelegte Farbe bekommt. Zusätzlich hat der Shader die Fähigkeit, bei Texturen mit Alphakanal "unsichtbare" Pixel zu ignorieren und damit den Hintergrund durchscheinen zu lassen.

Tipp: Objekte mit Textur ohne Alphakanal zählen bei der Selektion wie Objekte ganz ohne Textur, da es sich letztendlich nur um ein Quad handelt, das in einer Einheitsfarbe eingefärbt werden soll.


Die Renderschleife

Sehen wir uns jetzt einmal den Code an, bei dem wirklich etwas gezeichnet wird.


procedure Render;
const
  fac: single = 1/255;
var
  i: longword;
  iv: array [0..3] of byte absolute i; // iv und i überschneiden sich


Im Kopf der Prozedur sieht man schon erste Vorbereitungen auf das, was gleich während der Schleife passieren wird. Durch das absolute-Schlüsselwort überlappen i und iv im Speicher. Rein praktisch bedeutet das, dass man über iv auf die einzelnen Bytes der for-Variable i zugreifen kann, als wäre sie ein Byte-Array.

Durch Multiplikation mit der Konstante fac wird ein Byte-Wert (0..255) in einen Float im Bereich (0..1) umgerechnet. Dies ist notwendig, da die Grafikkarte intern mit Floats arbeitet und man daher nur Floats übergeben kann (auf SM 4.0 Karten funktioniert auch Integer, ist aber sehr unperformant). Das nächste Häppchen:


begin
  glClear(GL_COLOR_BUFFER_BIT);

  // Das Erste Model soll texturiert werden
  glEnable(GL_TEXTURE_2D);
  glUniform1f(SelShader.use_alpha_tex, 1.0);

  for i := 1 to high(Objekte) do
  begin
    if i = 2 then begin // Ab dem zweiten nicht mehr
      glDisable(GL_TEXTURE_2D);
      glUniform1f(SelShader.use_alpha_tex, 0.0);
    end;


Offensichtlich sind unsere Objekte im Array so angeordnet, dass zuerst texturierte Objekte (wir setzen use_alpha_tex auf 1.0 und aktivieren TEXTURE_2D) gezeichnet werden sollen. Diese Art von Sortierung macht Sinn, da Statechanges auf jeden Fall zu vermeiden sind, wenn sie vermeidbar sind. Gemeint ist mit Sortierung nicht, dass Alphatexturen zuerst kommen müssen, das ist manchmal auch gar nicht möglich, sondern das nicht mitten in der Schleife zwischen texturiert und nicht texturiert gewechselt werden muss. Bei dem zweiten Durchlauf wird die Texturierung deaktiviert und es geht ohne weiter (ergo: Es gibt nur ein texturiertes Objekt). Der interessante Teil der Renderfunktion folgt:


    // Den Index in eine Farbe umrechnen und im Shader setzen
    {$ifdef ENDIAN_BIG}
    glUniform4f(SelShader.color, iv[1]*fac, iv[2]*fac, iv[3]*fac, 1.0);
    {$else}
    glUniform4f(SelShader.color, iv[0]*fac, iv[1]*fac, iv[2]*fac, 1.0);
    {$endif}

    // Zeichnen
    glLoadIdentity;
    glTranslatef(Objekte[i].posx, Objekte[i].posy, 0);
    glCallList(Objekte[i].displayliste);
  end;
end;


Wie versprochen werden die 3 untersten (ich sage bewusst nicht "ersten") Bytes einzeln in Floats umgerechnet und landen in color.r, color.g und color.b des Shaders. color.a wird nicht verwendet und auf 1.0 gesetzt (1.0 ist der übliche Standardwert für Alpha). Mit dem so präparierten Shader würde nun gezeichnet werden, wäre er aktiviert.

Die berechtigte Frage ist jetzt: Warum gibt es bei der Index-Umrechnung zwei verschiedene Varianten? Der Grund ist die sogenannte Endianess oder Byte Order. Bei Little-Endian-Systemen (z.B. x86) werden die Bytes mit höherer Adresse signifikanter, bei Big Endian (z.B. PowerPC, Playstation 3) ist es genau umgekehrt.

Da wir nur 24 Bit (= 3 Byte) des Farbpuffers benutzen, interessiert uns das signifikanteste Byte des DWords nicht. Da dieses auf Big-Endian-Systemen gerade iv[0] ist, müssen die Offsets verschoben werden. Die folgende Grafik verdeutlicht dies:


Endian.png


Natürlich wird bei so einer einfachen Methode die Reihenfolge der Farbkomponenten verändert. Aus RGB auf Little Endian wird BGR auf Big Endian. Da wir die gefärbten Bilder allerdings sowiso nicht zu Gesicht bekommen, ist es egal. Ja, wir werden tatsächlich nie sehen können, was während der Selektion passiert. Oder hat jemand bis jetzt ein SwapBuffers ausmachen können? Nein? Gut, das muss so :3

Wenn ihr den restlichen Code File.jpg analysiert werdet ihr feststellen, dass SwapBuffers nur in der Eventschleife von SDL aufgerufen wird. Wenn Render in der Select-Prozedur aufgerufen wird, werden die Buffer nicht getauscht (und damit das Bild nicht angezeigt).

Erratum: Wie ihr seht wurde die Grafik von mir korrigiert. Ich habe mich entschlossen den Fehler nur durchzustreichen, damit ihr davon lernen könnt und ihn nicht wiederholt: Es handelt sich bei dieser Vorgehensweise NICHT um eine BITweise Verschiebung nach links (SHL 8), sondern um eine BYTEweise. Was macht das schon aus? Sehr viel! Wie "The Winner" mir freundlicherweise mitteilte, interpretiert die CPU bei bitweisen Operationen das LongWord "am Stück", also vom höchstwertigen zum untersten Bit von Links nach Rechts, damit bedeutet ein SHL unabhängig von der Plattform immer eine Multiplikation mit 2 (= Verschiebung um ein Bit in Richtung des am meisten signifikanten Bits).

Der Selektionscode

Ich hoffe ihr wurdet von dem Bit- und Bytegeschubse noch nicht gänzlich frustiert und überlegt, den Rest des Tutorials erstmal beiseite zu legen. Im Selektionscode (folgt) kommt noch ein bisschen mehr davon ^_^ Bevor ich euch allerdings den Code gebe, solltet ihr erstmal wissen, was bei der Selektion genau gemacht wird. Anders als im kurzen Theorieteil besprochen lesen wir nicht nur einen Pixel, sondern eine 3x3-Box (9 Pixel) um den Mauszeiger herum aus. Das tun wir, um die Umgebung des Pixels zu untersuchen und damit Fehler durch Antialiasing sehr effektiv zu erkennen. Das Problem mit AA ist, dass es Mischfarben erzeugt: Das bedeutet wann man auf den Randbereich eines Objektes klickt, kann es sein, dass man nicht die Farbe des Objektes erwischt, sondern _irgend eine_ Mischfarbe.

Wird diese kritiklos wieder als Index interpretiert landen wir irgendwo im Array und im besten Fall stürzt das Programm ab (im schlimmsten Fall passiert nichts und der Fehler bleibt uns bis zur Veröffentlichung erhalten).


function Select(x, y: integer): longword;
var
  PixelData: array [0..9] of longword;
  count, i: longword;
begin
  // Koordinaten umrechnen (SDL-Fenster in OpenGL / 3x3-Box zentrieren)
  y := WINDOW_Y-y;
  if x > 0 then if x < WINDOW_X then dec(x) else dec(x,2);
  if y > 0 then if y < WINDOW_Y then dec(x) else dec(y,2);


Zuerst wird der "Mauszeiger" (eigentlich nur die Koordinaten, die wir bekommen haben) von Fensterpixeln (Ursprung oben) in OpenGL-Pixel (Ursprung unten) umgerechnet und wenn möglich nach unten links verschoben. Das verschieben sorgt dafür, dass die angeklickte Stelle genau in der Mitte der gleich ausgelesenen 3x3-Box ist.


  glUseProgram(SelShader.prog);
  glClearColor(0.0, 0.0, 0.0, 0.0);
  glEnable(GL_SCISSOR_TEST);
  glScissor(x, y, 3, 3); // Die wenigsten Pixel müssen in den Buffer

  Render;
  // Kein SwapBuffers. Das codierte Bild sieht eh nicht hübsch aus.
  
  glUseProgram(0);
  glClearColor(0.1, 0.1, 0.1, 1.0);
  glDisable(GL_SCISSOR_TEST);


Das Zeichnen sollte eigentlich selbsterklärend sein. Der Shader wird aktiviert, die Hintergrundfarbe auf (0 0 0 0) gesetzt (entspricht dem Index 0!) und der Scissor Test aktiviert. Der Test macht bei Selektion sehr viel Sinn, da wir uns ja eigentlich nur für 9 Pixel interessieren - warum dann alle im meinem Fall 786.432 Pixel in den Framebuffer schreiben? In Anbetracht dessen, dass Scissor so gut wie kostenlos ist, wäre das eine unverzeiliche Sünde. Was der Test allerdings nicht verhindert ist, dass für jedes Pixel der Fragmentshader abgearbeitet werden muss - seht es also nicht als Freibrief für den Uber-Shader aus if-Abfragen.

Aufgerufen wird die normale Render-Prozedur, die wir so angepasst haben, dass sie dem Shader, wenn er gebunden ist, die relevanten Daten zukommen lässt. Nehmt das als Anreiz euer Rendering bei großen Szenen gut zu optimieren (z.B. mit Octrees/BSP und Frustrum Culling) - eure Selektion profitiert genauso davon. Nach dem Rendering wird Alles zurückgesetzt und erhält damit wieder seine vorherige Ordnung.

Jetzt kommt endlich der wirklich interessante Teil.


  glReadPixels(x, y, 3, 3, GL_RGBA, GL_UNSIGNED_BYTE, @PixelData[0]);

  count := 0;

  for i := 0 to 9 do
  begin
    if PixelData[4] = PixelData[i] then inc(count);
    
    if count > 3 then
    begin
      {$ifdef ENDIAN_BIG}
      result := (PixelData[4] shr 8)
      {$else}
      result := PixelData[4] and $FFFFFF;
      {$endif}
      exit;
    end;
  end;

  // Wenn diese Stelle erreicht wird, hat die Schleife nicht mehr als 3
  // gleichfarbige Pixel zusammenbekommen. Selektion ungültig.
  result := 0;
end;


Interessant, aber es bleibt weiterhin einfach (ich hoffe es war einfach, bis jetzt ^^). Aus Vogelperspektive: Es werden 9 RGBA-Werte aus dem Framebuffer geholt - Alpha wird immer 255, da wird nur 24 Bit benutzen (Standardwert von OpenGL) - und in 9 DWords geschrieben. Schließlich wird gezählt, wie viele Pixel mit PixelData[4] (das ist die Mitte, der Mauszeigerpixel) identisch sind. Wenn es mindestens 4 Pixel sind, wird die Selektion für gültig erklärt - d.h. der Mauszeigerpixel wird in result geschrieben und die Funktion kehrt zurück. Wenn die Funktion in der Schleife noch nicht verlassen wurde, wird result auf 0 gesetzt.

Hier ein Beispiel in dem gezeigt wird, wie uns diesen Vorgehen hilft, Fehler zu Erkennen:


AAc-Beispiel.png


Der von mir festgesetzte Schwellenwert "4" (bedenkt dabei, dass implementations bedingt der Pixel unter dem Mauszeiger mitgezählt wird) ist genaugenommen variabel. Theoretisch würde vermutlich auch 3 reichen, aber ich gehe da gerne auf Nummer sicher. Der Nachteil der Sicherheit ist, das man z.B. keine Linien mit 1px Breite selektieren kann - das kleinste selektierbare Objekt hat die gezeichnete Größe von 2x2 Pixeln.

Wie ihr seht, muss das LongWord vor der Rückgabe noch bearbeitet werden: Bei LE reicht es, den Alpha-Wert per Bitmaske abzuschneiden. Bei Big Endian landet der Alpha-Wert (glaube ich!) im untersten Byte. Daher muss in Richtung "unten" verschoben werden, damit der Alpha-Wert entfernt und unser Pixel-Index im unteren Bereich landet - schaut euch ruhig nochmal die Endian-Grafik an, wenn ihr jetzt nur Bahnhof versteht. Ich kann den Big Endian-Code leider mangels Maschine nicht testen. Sollte er also auf die Art nicht funktionieren, sofort in den IRC kommen oder mir eine PM schreiben (so kommt man auch an sein wohlverdientes Feedback).

Und damit sind wir mit dem Thema durch. Was jetzt noch kommt ist lediglich für den Spezialfall interessant, wenn man mit 32-Bit selektieren will... kann ja sein, dass man die Bitschubserei satt hat oder 16.777.215 codierbare Objekte neben dem Hintergrund nicht genug sind :-)


Ergänzung: 32/16 Bit Farbtiefe

32 Bit sind eigentlich leichter handzuhaben, als 24 Bit. Das kommt daher, dass ein echter 32-Bit-Wert aus dem Framebuffer genau unserem (und eurem, gemeint sind die BEler) nativen LongWord entspricht und damit die ganze Bitschubserei inklusive Unterscheidung zwischen BE und LE überflüssig macht:


    glUniform4f(SelShader.color, iv[0]*fac, iv[1]*fac, iv[2]*fac, iv[3]*fac);


    if count > 3 then
    begin
      result := PixelData[4];
      exit;
    end;


Dazu kommt noch, dass wir auf einmal Raum für 4.294.967.295 Objekte haben, was definitiv mehr ist, als man jemals ausschöpfen könnte - an unsere mitzeitreisdenden Leser: Das Tutorial stammt von 2009, das war damals so ;-)

Der Nachteil an 32 Bit zur Selektion (ja, jetzt wird es nochmal kurz ernst) ist, dass wir uns erstmal nicht darauf verlassen können, auch wirklich einen 32 Bit Buffer vom Treiber zu bekommen, wenn wir einen anfordern. Ohne Überprüfungen und Ausweichmöglichkeit auf 24 Bit läuft es also nicht. Daraus folgt? Genau - wir brauchen sowiso den 24 Bit-Code für solche Härtefälle. In Folge dessen ist der "Einfachheitsvorteil" von 32 Bit ad absurdum geführt und wir können getrost auf 24 Bit setzen.

Ein noch gravierenderer Nachteil ist, dass wir nun auch auf OpenGL-Funktionen aufpassen müssen, die den Alphawert verwenden. Wenn beispielsweise Alphatest während der Selektion aktiviert ist, werden selbst mit einer kulanten Einstellung wie (GL_GREATER, 0.0) alle Selektionen von Objekten mit Alpha=0 verworfen. Das sind auf Little Endian die ersten 16.777.215 und bei Big Endian jedes 256ste.

Erlaubt der Treiber die Verwendung von lediglich 16 Bit, so wären noch 65535 Objekte selektierbar. Das ist zwar in der Regel ebenfalls mehr als genug, aber leider erfordert 16 Bit aufgrund unterschiedlicher Bitlängen der Farbkomponenten (R: 5, G: 6, B: 5) einen enormen Aufwand bei der Zerlegung/Zusammensetzung des Index in/aus Farben, der nicht ohne massive Verwendung von Bitmasken und -shifts auskommt. Aus diesem Grund habe ich mich dazu entschlossen, das Thema "16 Bit" im Tutorial nicht weiter zu behandeln.


Shaderlose Variante (Anregung)

Einleitung

In den letzten Kapiteln wurde die Implementierung mit Shadern besprochen. Diese bieten, wie schon am Anfang kurz in den Vorteilen angerissen, ein Maximum an Flexilibität und Speichereffizienz (da komplett unabhängig von der Geometrie) bei unbeeinträchtigter Performance. Im Prinzip eine Win-Win-Situation, könnte man meinen... leider gibt es aber auch noch Hardware, auf der keine Shader laufen. In diesem Kapitel soll es also darum gehen, euch eine Übersicht darüber zu geben, was in einer solchen Situation machbar ist - inklusive Bewertung im Kontext der angestrebten Zielgruppe (siehe "Einleitung"). Implementieren müsst ihr es dann aber selbst. Wenn euch die Shaderlose Variante nicht weiter interessiert, könnt ihr auch problemlos zum Schluss übergehen - ihr verpasst nichts mehr.

Wesentlich schwieriger sind ohne Shader allerdings Spezialeffekte wie transparente Texturen. Eine Lösung sieht so aus, dass ihr zunächst das texturierte Objekt mit aktiviertem Alphatest in eine Stencil-Maske zeichnet. Danach wird in einem zweiten Pass ein Quad mit der Indexfarbe gezeichnet - durch die Maske werden nur solche Pixel geschrieben, die vorher den Alphatest bestanden und damit die Stencilmaske geformt haben. Nun aber zum grundlegenden Thema:

Prinzipiell stehen euch ohne Shader zwei Möglichkeiten offen: 1) Einstreuen von Immediate Mode in eure Renderschleife oder 2) Anpassung der Geometrie.


Immediate Mode

Möglichkeit 1 sieht in Pseudocode folgendermaßen aus:


  procedure Render(mode)
  
    if mode = rendermode
      Texturen aktivieren
      Lighting aktivieren
      usw.
    if mode = selection
      Texturen deaktiveren
      Lighting deaktivieren
      usw.

    foreach i in models
      if mode = rendermode
        Normale Farbe[i] setzen oder Textur binden
      if mode = selection
        glColor3ubv(@i) // nur Little Endian...
      
      Farblose(!) Geometrie[i] zeichnen


Der Vorteil wird schnell ersichtlich: Die Methode bleibt flexibel und einfach handzuhaben - bei gleichzeitig guter Speichereffizienz. Was auf diese Art aber leider niemals erreicht wird ist maximale Performance. Durch die Verwendung von Immediate Mode wird dem Treiber jede Möglichkeit geraubt, den Rendervorgang zu optimieren. Gleichzeitig kann der Treiber auch keine Daten cachen (und ggf. wiederverwenden) sondern muss im Gegenteil noch Leistung dafür verbrauchen, jederzeit auf jeden Fehltritt von euch vorbereitet zu sein. Die Methode stößt also schnell an ihre Grenzen, wenn große Mengen von Daten verarbeitet werden sollen.


Vertex Array / VBO

Betrachtet man Möglichkeit 2 genauer, stellt man fest, dass es auch hier wieder mehrere Optionen gibt. Die Displaylisten, die im Shaderteil verwendet werden, sind state complete. Das bedeutet, sie enthalten alle Informationen, die der Treiber zum zeichnen braucht und sind nicht auf externe Aufrufe wie glColor angewiesen - der Treiber kann sie also in ein internes Format umwandeln, welches das volle Spektrum an Optimierungen und Cachetricks unterstüzt. Der Treiber erhält alle Daten "am Stück" und kann sich 100%ig auf das Rendern "konzentrieren", weil der Benutzer gar nicht die Möglichkeit erhält, irgendetwas absurdes zu tun.

Will man diese Art von Displaylisten ohne Shader für die Selektion fit machen, benötigt man auf der einen Seite Listen mit den "normalen Farben" (welche auch schlichtweg "keine Farben" sein können, wenn nur Texturen verwendet werden) und Listen mit eingebauten Selektionsfarben. Bei dieser Methode wird die benötigte Geometrie verdoppelt - mit anderen Worten: Sie kommt aus Speichersicht überhaupt nicht in Frage. Es bringt auch nichts, die Displaylisten "unverbindlicher" zu halten und die Spezifika wie Farbe in der Renderschleife festzulegen - damit würdet ihr nämlich wieder beim Immediate Mode landen und hättet trotz Listen, die durch die Mischung mit Immediate Mode nicht mehr optimierbar und damit nutzlos geworden sind, nichts gewonnen.

Es muss also ein Weg gefunden werden, der folgende Kriterien erfüllt:

  1. Der Treiber muss seine Daten "am Stück" erhalten können.
  2. Selektion und normales Rendering sollen die gleiche Geometrie nutzen.
  3. Trotzdem müssen die Farben austauschbar sein.

Die Lösung sieht so aus, dass wir selbst ein Vertex Array (Hauptspeicherbasiert) oder ein Vertex Buffer Object (VRAM-Basiert) erstellen. Der Einfachheit halber stelle ich hier nur die Variante mit den VAs vor - es sollte sich aber ziemlich gut auf VBOs übertragen lassen.

Es werden drei Arrays erstellt:

  • Vertices
  • Farbdaten oder Texturkoordinaten
  • Selektionsfarben

In OpenGL ist es nun möglich, mittels der gl*Pointer-Funktionen die Datenquellen für einen Aufruf von glDrawArrays zu bestimmten. glVertexPointer zeigt stets auf unsere Vertices.

glColorPointer wird je nach dem, ob es sich um eine Selektion oder ein normales Rendering handelt, auf die Farbdaten oder die Selektionsfarben gesetzt. Werden nur Texturen verwendet, bleibt glTexCoordPointer immer auf diese gesetzt, der Farbpointer immer auf die Selektionsfarben - und je nach Fall werden dann GL_COLOR_ARRAY oder GL_TEXTURE_2D deaktiviert oder aktiviert.

Bedenkt auch, dass ihr mehrmals verwendete Vertices nicht mehrfach speichern müsst. Benutzt stattdessen indizierte Arrays um den Speicherverbrauch zu senken und ganz nebenbei die caching performance des Treibers zu erhöhen.

Schlusswort

Das war jetzt Color Picking. Ich sage es aber gerne nochmal: Bevor ihr euch meine Implementation auf's Auge drücken lasst denkt daran, dass es nur eine Lösung von vielen ist. Wann immer in eurer Situation etwas anderes angebracht sein sollte, macht es anders! Es kann auch nicht schaden, wenn ihr euch den kompletten Beispielcode zum Tutorial (gibt es hier zum Herunterladen) mal anseht. Neben dglOpenGL und Jedi-SDL verwende ich noch drei Units, die von mir sind. Ich erhebe keinen Urheberrechtsanspruch auf diese Units, d.h. ihr könnt sie nach belieben ausschlachten, verändern und selbst verwenden (meinetwegen auch kommerziell).

Wenn ihr eine Frage oder Feedback zum Tutorial oder zum Verfahren habt, findet ihr mich öfters im IRC-Channel von DGL oder erreicht mich bei Fragen von öffentlichem Interesse auch im Forum.

Gruß Waran

Dateien



Vorhergehendes Tutorial:
Tutorial Objektselektion
Nächstes Tutorial:
Tutorial TexFilter

Schreibt was ihr zu diesem Tutorial denkt ins Feedbackforum von DelphiGL.com.
Lob, Verbesserungsvorschläge, Hinweise und Tutorialwünsche sind stets willkommen.