Minimale WebGL in 75 Codezeilen

Modernes OpenGL und allgemeiner WebGL unterscheiden sich stark von dem älteren OpenGL, das ich in der Vergangenheit studiert habe. Ich verstehe, wie Rasterung funktioniert, daher bin ich mit den Konzepten ziemlich vertraut. Jedes Tutorial, das ich las, bot jedoch Abstraktionen und Hilfsfunktionen, die es mir schwerer machten zu verstehen, welche Teile zu den OpenGL-APIs selbst gehören.



Zur Verdeutlichung sind Abstraktionen wie das Aufteilen von Positionsdaten und das Rendern von Funktionen in separate Klassen in realen Anwendungen wichtig. Diese Abstraktionen streuen den Code jedoch in verschiedene Bereiche und erhöhen die Redundanz aufgrund des Boilerplates und der Datenübertragung zwischen logischen Einheiten. Ich finde es am bequemsten, ein Thema in einem linearen Codefluss zu studieren , in dem jede Zeile in direktem Zusammenhang mit diesem Thema steht.



Zunächst muss ich mich beim Ersteller des von mir verwendeten Tutorials bedanken . Auf dieser Grundlage habe ich alle Abstraktionen entfernt, bis ich das "minimal lebensfähige Programm" erhalten habe. Ich hoffe, es hilft Ihnen beim Einstieg in das moderne OpenGL. Folgendes werden wir tun:





Gleichseitiges Dreieck, oben grün, unten links schwarz und unten rechts rot, wobei die Farben zwischen den Punkten interpoliert werden. Eine etwas hellere Version des schwarzen Dreiecks [ Übersetzung in Habré].



Initialisierung



In WebGL müssen wir canvaszeichnen. Natürlich müssen Sie auf jeden Fall alle üblichen HTML-Boilerplates, Stile usw. hinzufügen, aber Canvas ist das Wichtigste. Nachdem das DOM geladen wurde, können wir mit Javascript auf die Zeichenfläche zugreifen.



<canvas id="container" width="500" height="500"></canvas>

<script>
  document.addEventListener('DOMContentLoaded', () => {
    // All the Javascript code below goes here
  });
</script>


Durch den Zugriff auf die Zeichenfläche können wir den WebGL-Rendering-Kontext abrufen und seine klare Farbe initialisieren. Farben in der OpenGL-Welt werden als RGBA gespeichert und jede Komponente hat einen Wert von 0bis 1. Die klare Farbe ist die Farbe, mit der die Leinwand zu Beginn jedes Rahmens gezeichnet und die Szene neu gezeichnet wird.



const canvas = document.getElementById('container');
const gl = canvas.getContext('webgl');

gl.clearColor(1, 1, 1, 1);


In realen Programmen kann und sollte die Initialisierung detaillierter sein. Insbesondere sollte die Aufnahme eines Tiefenpuffers erwähnt werden , mit dem Sie die Geometrie anhand der Z-Koordinaten sortieren können. Bei einem einfachen Programm, das nur aus einem Dreieck besteht, wird dies nicht durchgeführt.



Shader kompilieren



OpenGL ist im Kern ein Rasterisierungsframework, in dem wir entscheiden müssen, wie alles andere als die Rasterisierung implementiert werden soll. Daher müssen in der GPU mindestens zwei Codestufen ausgeführt werden:



  1. Ein Vertex-Shader, der alle Eingabedaten verarbeitet und für jede Eingabe eine 3D-Position (tatsächlich eine 4D-Position in einheitlichen Koordinaten ) ausgibt .
  2. Ein Fragment-Shader, der jedes Pixel auf dem Bildschirm verarbeitet und die Farbe wiedergibt, mit der das Pixel gezeichnet werden soll.


Zwischen diesen beiden Phasen ruft OpenGL die Geometrie vom Vertex-Shader ab und bestimmt, welche Bildschirmpixel von dieser Geometrie abgedeckt werden. Dies ist die Rasterungsphase.



Beide Shader sind normalerweise in GLSL (OpenGL Shading Language) geschrieben, das dann in Maschinencode für die GPU kompiliert wird. Der Maschinencode wird dann an die GPU übergeben, damit er während des Rendervorgangs ausgeführt werden kann. Ich werde nicht im Detail auf GLSL eingehen, da ich nur die Grundlagen zeigen möchte, aber die Sprache ist nah genug an C, um den meisten Programmierern vertraut zu sein.



Zuerst kompilieren wir den Vertex-Shader und übergeben ihn an die GPU. In dem unten gezeigten Fragment wird der Shader-Quellcode als Zeichenfolge gespeichert, kann jedoch von anderen Stellen geladen werden. Schließlich wird die Zeichenfolge an die WebGL-API übergeben.



const sourceV = `
  attribute vec3 position;
  varying vec4 color;

  void main() {
    gl_Position = vec4(position, 1);
    color = gl_Position * 0.5 + 0.5;
  }
`;

const shaderV = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(shaderV, sourceV);
gl.compileShader(shaderV);

if (!gl.getShaderParameter(shaderV, gl.COMPILE_STATUS)) {
  console.error(gl.getShaderInfoLog(shaderV));
  throw new Error('Failed to compile vertex shader');
}


Es lohnt sich, hier einige Variablen im GLSL-Code zu erläutern:



  1. (attribute) position. , , .
  2. Varying color. ( ) . .
  3. gl_Position. , , varying-. , ,


Es gibt auch einen einheitlichen Variablentyp , der für alle Vertex-Shader-Aufrufe eine Konstante ist. Solche Uniformen werden für Eigenschaften wie eine Transformationsmatrix verwendet, die für alle Eckpunkte eines geometrischen Elements konstant ist.



Als nächstes machen wir dasselbe mit dem Fragment-Shader - wir kompilieren ihn und übertragen ihn auf die GPU. Beachten Sie, dass die Variable coloraus dem Vertex-Shader jetzt vom Fragment-Shader gelesen wird.



const sourceF = `
  precision mediump float;
  varying vec4 color;

  void main() {
    gl_FragColor = color;
  }
`;

const shaderF = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(shaderF, sourceF);
gl.compileShader(shaderF);

if (!gl.getShaderParameter(shaderF, gl.COMPILE_STATUS)) {
  console.error(gl.getShaderInfoLog(shaderF));
  throw new Error('Failed to compile fragment shader');
}


Ferner sind sowohl der Vertex- als auch der Fragment-Shader in einem OpenGL-Programm verknüpft.



const program = gl.createProgram();
gl.attachShader(program, shaderV);
gl.attachShader(program, shaderF);
gl.linkProgram(program);

if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
  console.error(gl.getProgramInfoLog(program));
  throw new Error('Failed to link program');
}

gl.useProgram(program);


Wir teilen der GPU mit, dass wir die oben genannten Shader ausführen möchten. Jetzt müssen wir nur noch die eingehenden Daten erstellen und die GPU diese Daten verarbeiten lassen.



Eingehende Daten an die GPU senden



Eingehende Daten werden im GPU-Speicher gespeichert und von dort verarbeitet. Anstatt für jedes eingehende Datenelement separate Zeichnungsaufrufe zu tätigen, die die entsprechenden Daten Stück für Stück übertragen, werden alle eingehenden Daten vollständig an die GPU gesendet und von dieser gelesen. (Altes OpenGL hat Daten zu einzelnen Elementen übergeben, was die Leistung verlangsamte.)



OpenGL bietet eine Abstraktion namens Vertex Buffer Object (VBO). Ich finde immer noch heraus, wie es funktioniert, aber wir werden am Ende Folgendes tun, um es zu verwenden:



  1. Speichern Sie die Datenfolge im Speicher der Zentraleinheit (CPU).
  2. Übertragen Sie Bytes in den GPU-Speicher über einen eindeutigen Puffer, der mit gl.createBuffer()und Ankerpunkten erstellt wurde gl.ARRAY_BUFFER .


Für jede Variable von Eingabedaten (Attribut) im Vertex-Shader haben wir einen VBO, obwohl es möglich ist, einen VBO für mehrere Elemente der Eingabedaten zu verwenden.



const positionsData = new Float32Array([
  -0.75, -0.65, -1,
   0.75, -0.65, -1,
   0   ,  0.65, -1,
]);

const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, positionsData, gl.STATIC_DRAW);


Normalerweise definieren wir die Geometrie mit den Koordinaten, die unsere Anwendung versteht, und verwenden dann eine Reihe von Transformationen im Vertex-Shader, um sie dem OpenGL- Clip-Bereich zuzuordnen . Ich werde nicht näher auf den Kürzungsraum eingehen (er ist mit einheitlichen Koordinaten verbunden), während Sie nur wissen müssen, dass sich X und Y im Bereich von -1 bis +1 ändern. Da der Vertex-Shader die Eingabe einfach so übergibt, wie sie ist, können wir unsere Koordinaten direkt im Beschneidungsraum festlegen.



Dann binden wir den Puffer auch an eine der Variablen im Vertex-Shader. Im Code machen wir Folgendes:



  1. Wir erhalten den Variablendeskriptor positionaus dem oben erstellten Programm.
  2. Wir weisen OpenGL an, Daten vom Ankerpunkt gl.ARRAY_BUFFERin Dreiergruppen mit bestimmten Parametern zu lesen , z. B. mit einem Versatz und einem Schritt von 0.




const attribute = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(attribute);
gl.vertexAttribPointer(attribute, 3, gl.FLOAT, false, 0, 0);


Es ist erwähnenswert, dass wir auf diese Weise ein VBO erstellen und es an ein Vertex-Shader-Attribut binden können, da wir diese Funktionen einzeln ausführen. Wenn wir diese beiden Funktionen trennen würden (z. B. alle VBOs in einem Durchgang erstellen und sie dann an separate Attribute binden), müssten wir jedes Mal aufrufen, bevor wir jedes VBO dem entsprechenden Attribut zuordnen gl.bindBuffer(...).



Rendern!



Wenn alle Daten im GPU-Speicher ordnungsgemäß vorbereitet sind, können wir OpenGL anweisen, den Bildschirm zu löschen und das Programm auszuführen, um die von uns vorbereiteten Arrays zu verarbeiten. Im Rahmen des Rasterungsschritts (Bestimmen, welche Pixel von den Scheitelpunkten abgedeckt werden) weisen wir OpenGL an, Scheitelpunkte in Dreiergruppen als Dreiecke zu behandeln.



gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);


Mit einem solchen linearen Schema wird das Programm auf einmal ausgeführt. In jeder praktischen Anwendung würden wir Daten strukturiert speichern, sie bei Änderungen an die GPU senden und sie in jedem Frame rendern.






Zusammenfassend ist im Folgenden ein Diagramm mit einer minimalen Anzahl von Konzepten dargestellt, die erforderlich sind, um unser erstes Dreieck auf dem Bildschirm anzuzeigen. Aber auch dieses Schema wurde stark vereinfacht, daher ist es am besten, die in diesem Artikel vorgestellten 75 Codezeilen zu schreiben und sie zu studieren.





Die letzte stark vereinfachte Abfolge von Schritten, die zum Anzeigen eines Dreiecks erforderlich sind



Für mich war der schwierigste Teil beim Erlernen von OpenGL die Menge an Boilerplate, die erforderlich ist, um das einfachste Bild auf dem Bildschirm anzuzeigen. Da das Rasterisierungs-Framework die Bereitstellung von 3D-Rendering-Funktionen erfordert und die Kommunikation mit der GPU sehr umfangreich ist, müssen viele Konzepte direkt untersucht werden. Hoffentlich hat Ihnen dieser Artikel die Grundlagen auf einfachere Weise gezeigt als in anderen Tutorials.



Siehe auch:








Siehe auch:






All Articles