Einführung
Das Rendern von 3D-Grafiken ist keine leichte Aufgabe, aber äußerst interessant und aufregend. Dieser Artikel richtet sich an diejenigen, die gerade erst anfangen, sich mit OpenGL vertraut zu machen, oder an diejenigen, die daran interessiert sind, wie grafische Pipelines funktionieren und was sie sind. Dieser Artikel enthält keine genauen Anweisungen zum Erstellen eines OpenGL-Kontexts und -Fensters oder zum Schreiben Ihrer ersten OpenGL-Fensteranwendung. Dies liegt an den Funktionen jeder Programmiersprache und der Auswahl einer Bibliothek oder eines Frameworks für die Arbeit mit OpenGL (ich werde C ++ und
GLFW verwenden ), insbesondere da es im Netzwerk einfach ist, ein Tutorial für die Sprache zu finden, an der Sie interessiert sind. Alle im Artikel angegebenen Beispiele funktionieren in anderen Sprachen mit einer leicht geänderten Semantik von Befehlen. Warum dies so ist, werde ich später erläutern.
Was ist OpenGL?
OpenGL ist eine Spezifikation, die eine plattformunabhängige Softwareschnittstelle zum Schreiben von Anwendungen mit zweidimensionaler und dreidimensionaler Computergrafik definiert. OpenGL ist keine Implementierung, sondern beschreibt nur die Befehlssätze, die implementiert werden sollten, d. H. ist eine API.
Jede Version von OpenGL hat ihre eigene Spezifikation, wir werden von Version 3.3 bis Version 4.6 arbeiten, weil Alle Neuerungen ab Version 3.3 betreffen Aspekte, die für uns von geringer Bedeutung sind. Bevor Sie mit dem Schreiben Ihrer ersten OpenGL-Anwendung beginnen, sollten Sie herausfinden, welche Versionen Ihr Treiber unterstützt (dies können Sie auf der Website des Herstellers Ihrer Grafikkarte tun) und den Treiber auf die neueste Version aktualisieren.
OpenGL-Gerät
OpenGL kann mit einer großen Zustandsmaschine verglichen werden, die viele Zustände und Funktionen zum Ändern hat. Der OpenGL-Status bezieht sich im Wesentlichen auf den OpenGL-Kontext. Während der Arbeit mit OpenGL werden wir verschiedene Statusänderungsfunktionen durchlaufen, die den Kontext ändern, und Aktionen ausführen, die vom aktuellen Status von OpenGL abhängen.
Wenn wir OpenGL beispielsweise den Befehl geben, vor dem Rendern Linien anstelle von Dreiecken zu verwenden, verwendet OpenGL Linien für alle nachfolgenden Renderings, bis wir diese Option ändern oder den Kontext ändern.
Objekte in OpenGL
OpenGL-Bibliotheken sind in C geschrieben und verfügen über zahlreiche APIs für verschiedene Sprachen, sind jedoch C-Bibliotheken. Viele Konstruktionen aus C werden nicht in Hochsprachen übersetzt, daher wurde OpenGL unter Verwendung einer großen Anzahl von Abstraktionen entwickelt. Eine dieser Abstraktionen sind Objekte.
Ein Objekt in OpenGL besteht aus einer Reihe von Optionen, die seinen Status bestimmen. Jedes Objekt in OpenGL kann anhand seiner ID und der Optionen beschrieben werden, für die es verantwortlich ist. Natürlich hat jeder Objekttyp seine eigenen Optionen, und der Versuch, nicht vorhandene Optionen für das Objekt zu konfigurieren, führt zu einem Fehler. Darin liegt die Unannehmlichkeit der Verwendung von OpenGL: Eine Reihe von Optionen wird mit einer C-ähnlichen Struktur beschrieben, deren Kennung häufig eine Zahl ist, die es dem Programmierer nicht ermöglicht, einen Fehler in der Kompilierungsphase zu finden, weil fehlerhafter und korrekter Code sind semantisch nicht zu unterscheiden.
glGenObject(&objectId); glBindObject(GL_TAGRGET, objectId); glSetObjectOption(GL_TARGET, GL_CORRECT_OPTION, correct_option);
Sie werden sehr oft auf solchen Code stoßen. Wenn Sie sich also daran gewöhnen, wie es ist, eine Zustandsmaschine einzurichten, wird es für Sie viel einfacher. Dieser Code zeigt nur ein Beispiel für die Funktionsweise von OpenGL. Anschließend werden reale Beispiele vorgestellt.
Aber es gibt Pluspunkte. Das Hauptmerkmal dieser Objekte ist, dass wir viele Objekte in unserer Anwendung deklarieren, ihre Optionen festlegen und jedes Mal, wenn wir Operationen mit dem OpenGL-Status starten, das Objekt einfach mit unseren bevorzugten Einstellungen binden können. Dies können beispielsweise Objekte mit 3D-Modelldaten sein oder etwas, das wir auf dieses Modell zeichnen möchten. Der Besitz mehrerer Objekte erleichtert das Wechseln zwischen ihnen während des Rendervorgangs. Mit diesem Ansatz können wir viele Objekte konfigurieren, die zum Rendern benötigt werden, und ihre Zustände verwenden, ohne wertvolle Zeit zwischen Frames zu verlieren.
Um mit OpenGL arbeiten zu können, müssen Sie sich mit einigen grundlegenden Objekten vertraut machen, ohne die wir nichts anzeigen können. Am Beispiel dieser Objekte werden wir verstehen, wie Daten und ausführbare Anweisungen in OpenGL gebunden werden.
Basisobjekte: Shader und Shader-Programme. =
Shader ist ein kleines Programm, das an einem bestimmten Punkt in der Grafikpipeline auf einem Grafikbeschleuniger (GPU) ausgeführt wird. Wenn wir Shader abstrakt betrachten, können wir sagen, dass dies die Phasen der Grafikpipeline sind, die:
- Wissen, wo Sie Daten für die Verarbeitung erhalten.
- Wissen, wie Eingabedaten verarbeitet werden.
- Sie wissen, wo sie Daten für die weitere Verarbeitung schreiben müssen.
Aber wie sieht die Grafik-Pipeline aus? Sehr einfach, so:

Bisher interessiert uns in diesem Schema nur die Hauptvertikale, die mit der Vertex-Spezifikation beginnt und mit dem Frame Buffer endet. Wie bereits erwähnt, hat jeder Shader seine eigenen Eingabe- und Ausgabeparameter, die sich in Art und Anzahl der Parameter unterscheiden.
Wir beschreiben kurz jede Phase der Pipeline, um zu verstehen, was sie tut:
- Vertex Shader - wird benötigt, um 3D-Koordinatendaten und alle anderen Eingabeparameter zu verarbeiten. Am häufigsten berechnet der Vertex-Shader die Position des Vertex relativ zum Bildschirm, berechnet die Normalen (falls erforderlich) und generiert Eingabedaten für andere Shader.
- Tessellation Shader und Tessellation Control Shader - Diese beiden Shader sind dafür verantwortlich, die vom Vertex Shader kommenden Grundelemente zu detaillieren und die Daten für die Verarbeitung im geometrischen Shader vorzubereiten. Es ist schwierig zu beschreiben, wozu diese beiden Shader in zwei Sätzen fähig sind, aber damit die Leser eine kleine Vorstellung davon haben, gebe ich ein paar Bilder mit geringer und hoher Überlappung:
Ich rate Ihnen, diesen Artikel zu lesen, wenn Sie mehr über Tessellation erfahren möchten. In dieser Artikelserie werden wir uns mit Tessellation befassen, aber es wird nicht bald sein. - Geometrischer Shader - ist für die Bildung geometrischer Grundelemente aus der Ausgabe des Tessellations-Shaders verantwortlich. Mit dem geometrischen Shader können Sie neue Grundelemente aus den grundlegenden OpenGL-Grundelementen (GL_LINES, GL_POINT, GL_TRIANGLES usw.) erstellen. Mit dem geometrischen Shader können Sie beispielsweise einen Partikeleffekt erstellen, indem Sie das Partikel nur nach Farbe, Clusterzentrum, Radius und Dichte beschreiben.
- Der Rasterisierungs-Shader ist einer der nicht programmierbaren Shader. In einer verständlichen Sprache werden alle grafischen Grundelemente der Ausgabe in Fragmente (Pixel) übersetzt, d. H. bestimmt ihre Position auf dem Bildschirm.
- Der Fragment-Shader ist die letzte Stufe der Grafik-Pipeline. Der Fragment-Shader berechnet die Farbe des Fragments (Pixel), das im aktuellen Bildpuffer festgelegt wird. Am häufigsten werden im Fragment-Shader die Schattierung und Beleuchtung des Fragments, die Zuordnung von Texturen und normale Karten berechnet. Mit all diesen Techniken können Sie unglaublich schöne Ergebnisse erzielen.
OpenGL-Shader sind in einer speziellen C-ähnlichen GLSL-Sprache geschrieben, aus der sie kompiliert und zu einem Shader-Programm verknüpft werden. Bereits zu diesem Zeitpunkt scheint das Schreiben eines Shader-Programms eine äußerst zeitaufwändige Aufgabe zu sein, da Sie müssen die 5 Schritte der Grafikpipeline bestimmen und miteinander verknüpfen. Glücklicherweise ist dies nicht der Fall: Die Tessellations- und Geometrie-Shader werden standardmäßig in der Grafik-Pipeline definiert, sodass wir nur zwei Shader definieren können - den Scheitelpunkt- und den Fragment-Shader (manchmal auch als Pixel-Shader bezeichnet). Betrachten Sie diese beiden Shader am besten anhand eines klassischen Beispiels:
Vertex-Shader #version 450 layout (location = 0) in vec3 vertexCords; layout (location = 1) in vec3 color; out vec3 Color; void main(){ gl_Position = vec4(vertexCords,1.0f) ; Color = color; }
Fragment Shader #version 450 in vec3 Color; out vec4 out_fragColor; void main(){ out_fragColor = Color; }
Beispiel für eine Shader-Assembly unsigned int vShader = glCreateShader(GL_SHADER_VERTEX);
Diese beiden einfachen Shader berechnen nichts, sondern leiten die Daten nur über die Pipeline weiter. Achten wir darauf, wie die Vertex- und Fragment-Shader verbunden sind: Im Vertex-Shader wird die Color-Variable deklariert, in die die Farbe geschrieben wird, nachdem die Hauptfunktion ausgeführt wurde, während im Fragment-Shader genau dieselbe Variable mit dem In-Qualifier deklariert wird, d. H. Wie zuvor beschrieben, empfängt der Fragment-Shader Daten vom Scheitelpunkt durch einfaches Verschieben der Daten weiter durch die Pipeline (aber tatsächlich ist es nicht so einfach).
Hinweis: Wenn Sie im Fragment-Shader keine Variable vom Typ vec4 deklarieren und initialisieren, wird nichts auf dem Bildschirm angezeigt.
Aufmerksame Leser haben bereits die Deklaration von Eingabevariablen vom Typ vec3 mit seltsamen Layoutqualifizierern am Anfang des Vertex-Shaders bemerkt. Es ist logisch anzunehmen, dass dies eine Eingabe ist, aber woher beziehen wir sie?
Basisobjekte: Puffer und Vertex-Arrays
Ich denke, es lohnt sich nicht zu erklären, was Pufferobjekte sind. Wir sollten besser überlegen, wie ein Puffer in OpenGL erstellt und gefüllt wird.
float vertices[] = {
Dies ist nicht schwierig. Wir hängen den generierten Puffer an das gewünschte Ziel an (später werden wir herausfinden, welches) und laden die Daten, die ihre Größe und Art der Verwendung angeben.
GL_STATIC_DRAW - Daten im Puffer werden nicht geändert.
GL_DYNAMIC_DRAW - Die Daten im Puffer ändern sich, aber nicht oft.
GL_STREAM_DRAW - Die Daten im Puffer ändern sich bei jedem Draw-Aufruf.
Es ist großartig, jetzt befinden sich unsere Daten im GPU-Speicher, das Shader-Programm wird kompiliert und verknüpft, aber es gibt eine Einschränkung: Woher weiß das Programm, woher die Eingabedaten für den Vertex-Shader stammen? Wir haben die Daten heruntergeladen, aber nicht angegeben, woher das Shader-Programm sie beziehen würde. Dieses Problem wird durch einen separaten Typ von OpenGL-Objekten gelöst - Vertex-Arrays.

Das Bild stammt aus diesem Tutorial .
Wie bei Puffern lassen sich Vertex-Arrays am besten anhand ihres Konfigurationsbeispiels anzeigen.
unsigned int VBO, VAO; glGenBuffers(1, &VBO); glGenBuffers(1, &EBO); glGenVertexArrays(1, &VAO); glBindVertexArray(VAO);
Das Erstellen von Vertex-Arrays unterscheidet sich nicht vom Erstellen anderer OpenGL-Objekte. Das interessanteste beginnt nach der Zeile:
glBindVertexArray(VAO);
Ein Vertex-Array (VAO) speichert alle damit durchgeführten Bindungen und Konfigurationen, einschließlich der Bindung von Pufferobjekten zum Entladen von Daten. In diesem Beispiel gibt es nur ein solches Objekt, in der Praxis kann es jedoch mehrere geben. Danach wird das Vertex-Attribut mit einer bestimmten Nummer konfiguriert:
glBindBuffer(GL_ARRAY_BUFFER, VBO); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), nullptr);
Woher haben wir diese Nummer? Erinnern Sie sich an Layout-Qualifikationsmerkmale für Vertex-Shader-Eingabevariablen? Sie bestimmen, an welches Scheitelpunktattribut die Eingabevariable gebunden wird. Gehen Sie nun kurz die Funktionsargumente durch, damit keine unnötigen Fragen entstehen:
- Die Attributnummer, die wir konfigurieren möchten.
- Die Anzahl der Gegenstände, die wir nehmen möchten. (Da die Eingabevariable des Vertex-Shaders mit layout = 0 vom Typ vec3 ist, nehmen wir 3 Elemente vom Typ float)
- Art der Artikel.
- Ist es notwendig, die Elemente zu normalisieren, wenn es sich um einen Vektor handelt?
- Der Versatz für den nächsten Scheitelpunkt (da wir die Koordinaten und Farben nacheinander haben und jeder den Typ vec3 hat, verschieben wir uns um 6 * sizeof (float) = 24 Bytes).
- Das letzte Argument zeigt, welcher Offset für den ersten Scheitelpunkt verwendet werden soll. (Für Koordinaten beträgt dieses Argument 0 Byte, für Farben 12 Byte.)
Jetzt können wir unser erstes Bild rendern
Denken Sie daran, das VAO und das Shader-Programm zu binden, bevor Sie das Rendering aufrufen.
{
Wenn Sie alles richtig gemacht haben, sollten Sie folgendes Ergebnis erhalten:

Das Ergebnis ist beeindruckend, aber woher kam die Verlaufsfüllung im Dreieck, weil wir nur 3 Farben angegeben haben: Rot, Blau und Grün für jeden einzelnen Scheitelpunkt? Dies ist die Magie des Rasterisierungs-Shaders: Tatsache ist, dass der im Scheitelpunkt festgelegte Farbwert nicht in den Fragment-Shader gelangt. Wir übertragen nur 3 Eckpunkte, aber es werden viel mehr Fragmente erzeugt (es gibt genau so viele Fragmente wie gefüllte Pixel). Daher wird für jedes Fragment der Durchschnitt der drei Farbwerte ermittelt, je nachdem, wie nahe es an jedem der Scheitelpunkte liegt. Dies ist sehr gut an den Ecken des Dreiecks zu sehen, wo die Fragmente den Farbwert annehmen, den wir in den Scheitelpunktdaten angegeben haben.
Mit Blick auf die Zukunft werde ich sagen, dass die Texturkoordinaten auf die gleiche Weise übertragen werden, was es einfach macht, Texturen auf unsere Grundelemente zu legen.
Ich denke, das ist es wert, diesen Artikel zu beenden. Das Schwierigste liegt hinter uns, aber das Interessanteste fängt gerade erst an. Wenn Sie Fragen haben oder einen Fehler im Artikel gesehen haben, schreiben Sie darüber in den Kommentaren, ich werde sehr dankbar sein.
Im nächsten Artikel werden wir uns mit Transformationen befassen, etwas über einheitliche Variablen lernen und lernen, wie man Primitiven Texturen auferlegt.