3D-Grafik: Ein WebGL-Tutorial

Veröffentlicht: 2022-03-11

Der Einstieg in die Welt der 3D-Grafik kann sehr einschüchternd sein. Egal, ob Sie nur ein interaktives 3D-Logo erstellen oder ein vollwertiges Spiel entwerfen möchten, wenn Sie die Prinzipien des 3D-Renderings nicht kennen, müssen Sie eine Bibliothek verwenden, die viele Dinge abstrahiert.

Die Verwendung einer Bibliothek kann genau das richtige Werkzeug sein, und JavaScript hat ein erstaunliches Open-Source-Tool in Form von three.js. Die Verwendung vorgefertigter Lösungen hat jedoch einige Nachteile:

  • Sie können viele Funktionen haben, die Sie nicht verwenden möchten. Die Größe der minimierten Basisfunktionen von three.js beträgt etwa 500 KB, und alle zusätzlichen Funktionen (das Laden tatsächlicher Modelldateien ist eine davon) machen die Nutzlast noch größer. Es wäre eine Verschwendung, so viele Daten zu übertragen, nur um ein sich drehendes Logo auf Ihrer Website anzuzeigen.
  • Eine zusätzliche Abstraktionsebene kann ansonsten einfache Änderungen erschweren. Ihre kreative Art, ein Objekt auf dem Bildschirm zu schattieren, kann entweder einfach zu implementieren sein oder zig Stunden Arbeit erfordern, um sie in die Abstraktionen der Bibliothek zu integrieren.
  • Während die Bibliothek in den meisten Szenarien sehr gut optimiert ist, können viele Schnickschnack für Ihren Anwendungsfall herausgeschnitten werden. Der Renderer kann dazu führen, dass bestimmte Prozeduren millionenfach auf der Grafikkarte ausgeführt werden. Jede aus einem solchen Verfahren entfernte Anweisung bedeutet, dass eine schwächere Grafikkarte Ihre Inhalte problemlos verarbeiten kann.

Selbst wenn Sie sich entscheiden, eine High-Level-Grafikbibliothek zu verwenden, können Sie sie effektiver nutzen, wenn Sie grundlegende Kenntnisse über die Dinge unter der Haube haben. Bibliotheken können auch erweiterte Funktionen haben, wie ShaderMaterial in three.js . Wenn Sie die Prinzipien des Grafik-Renderings kennen, können Sie solche Funktionen verwenden.

Abbildung eines 3D-Toptal-Logos auf einer WebGL-Leinwand

Unser Ziel ist es, eine kurze Einführung in alle Schlüsselkonzepte zu geben, die hinter dem Rendern von 3D-Grafiken und der Verwendung von WebGL zu ihrer Implementierung stehen. Sie werden sehen, was am häufigsten getan wird, nämlich das Anzeigen und Bewegen von 3D-Objekten in einem leeren Raum.

Der endgültige Code steht Ihnen zum Forken und Herumspielen zur Verfügung.

Darstellung von 3D-Modellen

Als Erstes müssen Sie verstehen, wie 3D-Modelle dargestellt werden. Ein Modell besteht aus einem Netz von Dreiecken. Jedes Dreieck wird durch drei Scheitelpunkte für jede der Ecken des Dreiecks dargestellt. Es gibt drei gebräuchlichste Eigenschaften, die Scheitelpunkten zugeordnet sind.

Scheitelpunktposition

Die Position ist die intuitivste Eigenschaft eines Scheitelpunkts. Es ist die Position im 3D-Raum, dargestellt durch einen 3D-Koordinatenvektor. Wenn Sie die genauen Koordinaten von drei Punkten im Raum kennen, hätten Sie alle Informationen, die Sie benötigen, um ein einfaches Dreieck zwischen ihnen zu zeichnen. Damit Modelle beim Rendern wirklich gut aussehen, müssen dem Renderer noch ein paar Dinge zur Verfügung gestellt werden.

Scheitel Normal

Kugeln mit demselben Drahtmodell, auf die eine flache und glatte Schattierung angewendet wurde

Betrachten Sie die beiden obigen Modelle. Sie bestehen aus denselben Scheitelpunktpositionen, sehen aber beim Rendern völlig anders aus. Wie ist das möglich?

Abgesehen davon, dass wir dem Renderer mitteilen, wo ein Scheitelpunkt platziert werden soll, können wir ihm auch einen Hinweis darauf geben, wie die Oberfläche genau an dieser Position geneigt ist. Der Hinweis erfolgt in Form der Normalen der Oberfläche an diesem bestimmten Punkt auf dem Modell, dargestellt durch einen 3D-Vektor. Das folgende Bild soll Ihnen einen anschaulicheren Einblick geben, wie das gehandhabt wird.

Vergleich zwischen Normalen für flache und glatte Schattierung

Die linke und rechte Oberfläche entsprechen jeweils dem linken und rechten Ball im vorherigen Bild. Die roten Pfeile stellen Normalen dar, die für einen Scheitelpunkt angegeben sind, während die blauen Pfeile die Berechnungen des Renderers darstellen, wie die Normale für alle Punkte zwischen den Scheitelpunkten aussehen sollte. Das Bild zeigt eine Demonstration für den 2D-Raum, aber das gleiche Prinzip gilt auch für 3D.

Die Normale ist ein Hinweis darauf, wie Lichter die Oberfläche beleuchten. Je näher die Richtung eines Lichtstrahls an der Normalen liegt, desto heller ist der Punkt. Allmähliche Änderungen in der Normalenrichtung verursachen Lichtgradienten, während abrupte Änderungen ohne Änderungen dazwischen Oberflächen mit konstanter Beleuchtung darüber und plötzliche Änderungen der Beleuchtung zwischen ihnen verursachen.

Texturkoordinaten

Die letzte wichtige Eigenschaft sind Texturkoordinaten, die allgemein als UV-Mapping bezeichnet werden. Sie haben ein Modell und eine Textur, die Sie darauf anwenden möchten. Die Textur hat verschiedene Bereiche, die Bilder darstellen, die wir auf verschiedene Teile des Modells anwenden möchten. Es muss eine Möglichkeit geben, zu markieren, welches Dreieck mit welchem ​​Teil der Textur dargestellt werden soll. Hier kommt Textur-Mapping ins Spiel.

Für jeden Scheitelpunkt markieren wir zwei Koordinaten, U und V. Diese Koordinaten stellen eine Position auf der Textur dar, wobei U die horizontale Achse und V die vertikale Achse darstellt. Die Werte werden nicht in Pixel angegeben, sondern als prozentuale Position innerhalb des Bildes. Die untere linke Ecke des Bildes wird durch zwei Nullen dargestellt, während die obere rechte Ecke durch zwei Einsen dargestellt wird.

Ein Dreieck wird einfach gemalt, indem die UV-Koordinaten jedes Scheitelpunkts im Dreieck genommen werden und das Bild, das zwischen diesen Koordinaten erfasst wird, auf die Textur angewendet wird.

Demonstration des UV-Mappings mit einem hervorgehobenen Patch und sichtbaren Nähten auf dem Modell

Auf dem Bild oben sehen Sie eine Demonstration des UV-Mappings. Das sphärische Modell wurde genommen und in Teile geschnitten, die klein genug sind, um auf einer 2D-Oberfläche abgeflacht zu werden. Die Nähte, an denen die Schnitte gemacht wurden, sind mit dickeren Linien markiert. Einer der Patches wurde hervorgehoben, sodass Sie gut sehen können, wie die Dinge zusammenpassen. Sie können auch sehen, wie eine Naht durch die Mitte des Lächelns Teile des Mundes in zwei verschiedene Flecken legt.

Die Drahtgitter sind nicht Teil der Textur, sondern nur über das Bild gelegt, sodass Sie sehen können, wie die Dinge zusammenpassen.

Laden eines OBJ-Modells

Ob Sie es glauben oder nicht, das ist alles, was Sie wissen müssen, um Ihren eigenen einfachen Modelllader zu erstellen. Das OBJ-Dateiformat ist einfach genug, um einen Parser in wenigen Codezeilen zu implementieren.

Die Datei listet Scheitelpunktpositionen im Format v <float> <float> <float> auf, mit einem optionalen vierten Float, das wir der Einfachheit halber ignorieren. Scheitelpunktnormalen werden ähnlich mit vn <float> <float> <float> dargestellt. Schließlich werden Texturkoordinaten mit vt <float> <float> dargestellt, mit einem optionalen dritten Float, das wir ignorieren werden. In allen drei Fällen repräsentieren die Floats die jeweiligen Koordinaten. Diese drei Eigenschaften werden in drei Arrays akkumuliert.

Flächen werden durch Gruppen von Scheitelpunkten dargestellt. Jeder Scheitelpunkt wird mit dem Index jeder der Eigenschaften dargestellt, wobei die Indizes bei 1 beginnen. Es gibt verschiedene Möglichkeiten, dies darzustellen, aber wir bleiben beim Format f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3 , wobei alle drei Eigenschaften bereitgestellt werden müssen und die Anzahl der Scheitelpunkte pro Fläche auf drei begrenzt wird. Alle diese Einschränkungen werden vorgenommen, um den Loader so einfach wie möglich zu halten, da alle anderen Optionen eine zusätzliche triviale Verarbeitung erfordern, bevor sie in einem Format vorliegen, das WebGL gefällt.

Wir haben viele Anforderungen an unseren Dateilader gestellt. Das mag einschränkend klingen, aber 3D-Modellierungsanwendungen bieten Ihnen in der Regel die Möglichkeit, diese Einschränkungen beim Exportieren eines Modells als OBJ-Datei festzulegen.

Der folgende Code analysiert eine Zeichenfolge, die eine OBJ-Datei darstellt, und erstellt ein Modell in Form eines Arrays von Gesichtern.

 function Geometry (faces) { this.faces = faces || [] } // Parses an OBJ file, passed as a string Geometry.parseOBJ = function (src) { var POSITION = /^v\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var NORMAL = /^vn\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var UV = /^vt\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var FACE = /^f\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)(?:\s+(-?\d+)\/(-?\d+)\/(-?\d+))?/ lines = src.split('\n') var positions = [] var uvs = [] var normals = [] var faces = [] lines.forEach(function (line) { // Match each line of the file against various RegEx-es var result if ((result = POSITION.exec(line)) != null) { // Add new vertex position positions.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = NORMAL.exec(line)) != null) { // Add new vertex normal normals.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = UV.exec(line)) != null) { // Add new texture mapping point uvs.push(new Vector2(parseFloat(result[1]), 1 - parseFloat(result[2]))) } else if ((result = FACE.exec(line)) != null) { // Add new face var vertices = [] // Create three vertices from the passed one-indexed indices for (var i = 1; i < 10; i += 3) { var part = result.slice(i, i + 3) var position = positions[parseInt(part[0]) - 1] var uv = uvs[parseInt(part[1]) - 1] var normal = normals[parseInt(part[2]) - 1] vertices.push(new Vertex(position, normal, uv)) } faces.push(new Face(vertices)) } }) return new Geometry(faces) } // Loads an OBJ file from the given URL, and returns it as a promise Geometry.loadOBJ = function (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(Geometry.parseOBJ(xhr.responseText)) } } xhr.open('GET', url, true) xhr.send(null) }) } function Face (vertices) { this.vertices = vertices || [] } function Vertex (position, normal, uv) { this.position = position || new Vector3() this.normal = normal || new Vector3() this.uv = uv || new Vector2() } function Vector3 (x, y, z) { this.x = Number(x) || 0 this.y = Number(y) || 0 this.z = Number(z) || 0 } function Vector2 (x, y) { this.x = Number(x) || 0 this.y = Number(y) || 0 }

Die Geometry -Struktur enthält die genauen Daten, die benötigt werden, um ein Modell zur Verarbeitung an die Grafikkarte zu senden. Bevor Sie das tun, möchten Sie wahrscheinlich die Möglichkeit haben, das Modell auf dem Bildschirm zu bewegen.

Räumliche Transformationen durchführen

Alle Punkte im Modell, das wir geladen haben, sind relativ zu seinem Koordinatensystem. Wenn wir das Modell verschieben, drehen und skalieren möchten, müssen wir diese Operation nur an seinem Koordinatensystem ausführen. Das Koordinatensystem A relativ zum Koordinatensystem B ist durch die Position seines Zentrums als Vektor p_ab und den Vektor für jede seiner Achsen x_ab , y_ab und z_ab , der die Richtung dieser Achse darstellt. Wenn sich also ein Punkt auf der x -Achse des Koordinatensystems A um 10 bewegt, dann bewegt er sich – im Koordinatensystem B – in Richtung von x_ab , multipliziert mit 10.

Alle diese Informationen werden in der folgenden Matrixform gespeichert:

 x_ab.x y_ab.x z_ab.x p_ab.x x_ab.y y_ab.y z_ab.y p_ab.y x_ab.z y_ab.z z_ab.z p_ab.z 0 0 0 1

Wenn wir den 3D-Vektor q transformieren wollen, müssen wir nur die Transformationsmatrix mit dem Vektor multiplizieren:

 qx qy qz 1

Dadurch bewegt sich der Punkt um qx entlang der neuen x -Achse, um qy entlang der neuen y -Achse und um qz entlang der neuen z -Achse. Schließlich bewirkt es, dass sich der Punkt zusätzlich um den p -Vektor bewegt, weshalb wir als letztes Element der Multiplikation eine Eins verwenden.

Der große Vorteil der Verwendung dieser Matrizen ist die Tatsache, dass wir, wenn wir mehrere Transformationen am Scheitelpunkt durchführen müssen, diese zu einer Transformation zusammenführen können, indem wir ihre Matrizen multiplizieren, bevor wir den Scheitelpunkt selbst transformieren.

Es gibt verschiedene Transformationen, die durchgeführt werden können, und wir werden uns die wichtigsten ansehen.

Keine Verwandlung

Wenn keine Transformationen stattfinden, ist der Vektor p ein Nullvektor, der Vektor x ist [1, 0, 0] , y ist [0, 1, 0] und z ist [0, 0, 1] . Von nun an bezeichnen wir diese Werte als Standardwerte für diese Vektoren. Die Anwendung dieser Werte ergibt eine Identitätsmatrix:

 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1

Dies ist ein guter Ausgangspunkt für das Verketten von Transformationen.

Übersetzung

Rahmentransformation für die Übersetzung

Wenn wir eine Übersetzung durchführen, dann haben alle Vektoren außer dem p -Vektor ihre Standardwerte. Daraus ergibt sich folgende Matrix:

 1 0 0 px 0 1 0 py 0 0 1 pz 0 0 0 1

Skalierung

Rahmentransformation für die Skalierung

Das Skalieren eines Modells bedeutet, den Betrag zu reduzieren, den jede Koordinate zur Position eines Punkts beiträgt. Durch die Skalierung entsteht kein einheitlicher Versatz, sodass der p -Vektor seinen Standardwert behält. Die voreingestellten Achsenvektoren sollten mit ihren jeweiligen Skalierungsfaktoren multipliziert werden, was die folgende Matrix ergibt:

 s_x 0 0 0 0 s_y 0 0 0 0 s_z 0 0 0 0 1

Hier s_x , s_y und s_z die Skalierung dar, die auf jede Achse angewendet wird.

Drehung

Rahmentransformation für Rotation um die Z-Achse

Das obige Bild zeigt, was passiert, wenn wir den Koordinatenrahmen um die Z-Achse drehen.

Die Drehung führt zu keinem gleichmäßigen Versatz, sodass der p -Vektor seinen Standardwert behält. Jetzt wird die Sache etwas kniffliger. Drehungen bewirken, dass Bewegungen entlang einer bestimmten Achse im ursprünglichen Koordinatensystem in eine andere Richtung verschoben werden. Wenn wir also ein Koordinatensystem um 45 Grad um die Z-Achse drehen, bewirkt eine Bewegung entlang der x -Achse des ursprünglichen Koordinatensystems eine Bewegung in diagonaler Richtung zwischen der x und y -Achse im neuen Koordinatensystem.

Der Einfachheit halber zeigen wir Ihnen nur, wie die Transformationsmatrizen bei Drehungen um die Hauptachsen aussehen.

 Around X: 1 0 0 0 0 cos(phi) sin(phi) 0 0 -sin(phi) cos(phi) 0 0 0 0 1 Around Y: cos(phi) 0 sin(phi) 0 0 1 0 0 -sin(phi) 0 cos(phi) 0 0 0 0 1 Around Z: cos(phi) -sin(phi) 0 0 sin(phi) cos(phi) 0 0 0 0 1 0 0 0 0 1

Implementierung

All dies kann als Klasse implementiert werden, die 16 Zahlen speichert und Matrizen in einer Spaltenhauptordnung speichert.

 function Transformation () { // Create an identity transformation this.fields = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } // Multiply matrices, to chain transformations Transformation.prototype.mult = function (t) { var output = new Transformation() for (var row = 0; row < 4; ++row) { for (var col = 0; col < 4; ++col) { var sum = 0 for (var k = 0; k < 4; ++k) { sum += this.fields[k * 4 + row] * t.fields[col * 4 + k] } output.fields[col * 4 + row] = sum } } return output } // Multiply by translation matrix Transformation.prototype.translate = function (x, y, z) { var mat = new Transformation() mat.fields[12] = Number(x) || 0 mat.fields[13] = Number(y) || 0 mat.fields[14] = Number(z) || 0 return this.mult(mat) } // Multiply by scaling matrix Transformation.prototype.scale = function (x, y, z) { var mat = new Transformation() mat.fields[0] = Number(x) || 0 mat.fields[5] = Number(y) || 0 mat.fields[10] = Number(z) || 0 return this.mult(mat) } // Multiply by rotation matrix around X axis Transformation.prototype.rotateX = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[5] = c mat.fields[10] = c mat.fields[9] = -s mat.fields[6] = s return this.mult(mat) } // Multiply by rotation matrix around Y axis Transformation.prototype.rotateY = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[10] = c mat.fields[2] = -s mat.fields[8] = s return this.mult(mat) } // Multiply by rotation matrix around Z axis Transformation.prototype.rotateZ = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[5] = c mat.fields[4] = -s mat.fields[1] = s return this.mult(mat) }

Blick durch eine Kamera

Hier kommt der wichtigste Teil der Präsentation von Objekten auf dem Bildschirm: die Kamera. Eine Kamera besteht aus zwei Schlüsselkomponenten; nämlich seine Position und wie es beobachtete Objekte auf den Bildschirm projiziert.

Die Kameraposition wird mit einem einfachen Trick gehandhabt. Es gibt keinen visuellen Unterschied, ob man die Kamera einen Meter nach vorne oder die ganze Welt einen Meter nach hinten bewegt. Also tun wir natürlich Letzteres, indem wir die Inverse der Matrix als Transformation anwenden.

Die zweite Schlüsselkomponente ist die Art und Weise, wie beobachtete Objekte auf das Objektiv projiziert werden. In WebGL befindet sich alles, was auf dem Bildschirm sichtbar ist, in einer Box. Die Box erstreckt sich auf jeder Achse zwischen -1 und 1. Alles, was sichtbar ist, befindet sich in diesem Feld. Wir können den gleichen Ansatz von Transformationsmatrizen verwenden, um eine Projektionsmatrix zu erstellen.

Orthographische Projektion

Rechteckiger Raum wird mithilfe der orthografischen Projektion in die richtigen Framebuffer-Dimensionen umgewandelt

Die einfachste Projektion ist die orthographische Projektion. Sie nehmen ein Kästchen im Raum, das die Breite, Höhe und Tiefe angibt, unter der Annahme, dass sich sein Mittelpunkt in der Nullposition befindet. Dann ändert die Projektion die Größe der Box, damit sie in die zuvor beschriebene Box passt, in der WebGL Objekte beobachtet. Da wir jede Dimension auf zwei skalieren wollen, skalieren wir jede Achse um 2/size , wobei size die Dimension der jeweiligen Achse ist. Eine kleine Einschränkung ist die Tatsache, dass wir die Z-Achse mit einem negativen multiplizieren. Dies geschieht, weil wir die Richtung dieser Dimension umkehren wollen. Die endgültige Matrix hat diese Form:

 2/width 0 0 0 0 2/height 0 0 0 0 -2/depth 0 0 0 0 1

Perspektivische Projektion

Frustum wird mithilfe der perspektivischen Projektion in die richtigen Framebuffer-Dimensionen umgewandelt

Wir werden nicht auf die Details eingehen, wie diese Projektion konstruiert ist, sondern verwenden einfach die endgültige Formel, die inzwischen ziemlich Standard ist. Wir können es vereinfachen, indem wir die Projektion in der Nullposition auf der x- und y-Achse platzieren und die rechten/linken und oberen/unteren Grenzen gleich width/2 bzw. height/2 machen. Die Parameter n und f stellen die near und far Schnittebenen dar, die die kleinste und größte Entfernung sind, die ein Punkt haben kann, um von der Kamera erfasst zu werden. Sie werden durch die parallelen Seiten des Kegelstumpfs im obigen Bild dargestellt.

Eine perspektivische Projektion wird normalerweise mit einem Sichtfeld (wir verwenden das vertikale), dem Seitenverhältnis und den Entfernungen der nahen und fernen Ebene dargestellt. Diese Informationen können verwendet werden, um width und height zu berechnen, und dann kann die Matrix aus der folgenden Vorlage erstellt werden:

 2*n/width 0 0 0 0 2*n/height 0 0 0 0 (f+n)/(nf) 2*f*n/(nf) 0 0 -1 0

Zur Berechnung der Breite und Höhe können folgende Formeln verwendet werden:

 height = 2 * near * Math.tan(fov * Math.PI / 360) width = aspectRatio * height

Das FOV (Field of View) stellt den vertikalen Winkel dar, den die Kamera mit ihrem Objektiv erfasst. Das Seitenverhältnis stellt das Verhältnis zwischen Bildbreite und -höhe dar und basiert auf den Abmessungen des Bildschirms, auf dem wir rendern.

Implementierung

Jetzt können wir eine Kamera als eine Klasse darstellen, die die Kameraposition und die Projektionsmatrix speichert. Wir müssen auch wissen, wie man inverse Transformationen berechnet. Das Lösen allgemeiner Matrixinversionen kann problematisch sein, aber für unseren Spezialfall gibt es einen vereinfachten Ansatz.

 function Camera () { this.position = new Transformation() this.projection = new Transformation() } Camera.prototype.setOrthographic = function (width, height, depth) { this.projection = new Transformation() this.projection.fields[0] = 2 / width this.projection.fields[5] = 2 / height this.projection.fields[10] = -2 / depth } Camera.prototype.setPerspective = function (verticalFov, aspectRatio, near, far) { var height_div_2n = Math.tan(verticalFov * Math.PI / 360) var width_div_2n = aspectRatio * height_div_2n this.projection = new Transformation() this.projection.fields[0] = 1 / height_div_2n this.projection.fields[5] = 1 / width_div_2n this.projection.fields[10] = (far + near) / (near - far) this.projection.fields[10] = -1 this.projection.fields[14] = 2 * far * near / (near - far) this.projection.fields[15] = 0 } Camera.prototype.getInversePosition = function () { var orig = this.position.fields var dest = new Transformation() var x = orig[12] var y = orig[13] var z = orig[14] // Transpose the rotation matrix for (var i = 0; i < 3; ++i) { for (var j = 0; j < 3; ++j) { dest.fields[i * 4 + j] = orig[i + j * 4] } } // Translation by -p will apply R^T, which is equal to R^-1 return dest.translate(-x, -y, -z) }

Dies ist das letzte Stück, das wir brauchen, bevor wir anfangen können, Dinge auf dem Bildschirm zu zeichnen.

Zeichnen eines Objekts mit der WebGL-Grafikpipeline

Die einfachste Fläche, die Sie zeichnen können, ist ein Dreieck. Tatsächlich bestehen die meisten Dinge, die Sie im 3D-Raum zeichnen, aus einer großen Anzahl von Dreiecken.

Ein grundlegender Blick auf die Schritte der Grafikpipeline

Das erste, was Sie verstehen müssen, ist, wie der Bildschirm in WebGL dargestellt wird. Es ist ein 3D-Raum, der sich zwischen -1 und 1 auf der x- , y- und z -Achse erstreckt. Standardmäßig wird diese z -Achse nicht verwendet, aber Sie interessieren sich für 3D-Grafiken, also sollten Sie sie sofort aktivieren.

Vor diesem Hintergrund sind drei Schritte erforderlich, um ein Dreieck auf diese Fläche zu zeichnen.

Sie können drei Scheitelpunkte definieren, die das Dreieck darstellen, das Sie zeichnen möchten. Sie serialisieren diese Daten und senden sie an die GPU (Graphics Processing Unit). Wenn ein vollständiges Modell verfügbar ist, können Sie dies für alle Dreiecke im Modell tun. Die von Ihnen angegebenen Scheitelpunktpositionen befinden sich im lokalen Koordinatenraum des geladenen Modells. Einfach gesagt, die Positionen, die Sie angeben, sind genau die aus der Datei und nicht die, die Sie nach der Durchführung von Matrixtransformationen erhalten.

Nachdem Sie die Scheitelpunkte an die GPU übergeben haben, teilen Sie der GPU mit, welche Logik beim Platzieren der Scheitelpunkte auf dem Bildschirm verwendet werden soll. Dieser Schritt wird verwendet, um unsere Matrixtransformationen anzuwenden. Die GPU ist sehr gut darin, viele 4x4-Matrizen zu multiplizieren, also werden wir diese Fähigkeit gut nutzen.

Im letzten Schritt wird die GPU dieses Dreieck rastern. Rasterung ist der Vorgang, bei dem Vektorgrafiken aufgenommen und bestimmt werden, welche Pixel des Bildschirms gezeichnet werden müssen, damit dieses Vektorgrafikobjekt angezeigt wird. In unserem Fall versucht die GPU festzustellen, welche Pixel sich in jedem Dreieck befinden. Die GPU fragt Sie für jedes Pixel, in welcher Farbe es gemalt werden soll.

Dies sind die vier Elemente, die benötigt werden, um alles zu zeichnen, was Sie wollen, und sie sind das einfachste Beispiel einer Grafikpipeline. Was folgt, ist ein Blick auf jeden von ihnen und eine einfache Implementierung.

Der Standard-Framebuffer

Das wichtigste Element für eine WebGL-Anwendung ist der WebGL-Kontext. Sie können darauf mit gl = canvas.getContext('webgl') oder 'experimental-webgl' als Fallback verwenden, falls der aktuell verwendete Browser noch nicht alle WebGL-Funktionen unterstützt. Die canvas , auf die wir uns bezogen haben, ist das DOM-Element der Leinwand, auf die wir zeichnen möchten. Der Kontext enthält viele Dinge, darunter den Standard-Framebuffer.

Sie könnten einen Framebuffer locker als jeden Puffer (Objekt) beschreiben, auf den Sie zurückgreifen können. Standardmäßig speichert der Standard-Framebuffer die Farbe für jedes Pixel der Leinwand, an das der WebGL-Kontext gebunden ist. Wie im vorherigen Abschnitt beschrieben, befindet sich jedes Pixel, wenn wir auf den Framebuffer zeichnen, zwischen -1 und 1 auf der x- und y -Achse. Etwas, das wir auch erwähnt haben, ist die Tatsache, dass WebGL standardmäßig nicht die z- Achse verwendet. Diese Funktionalität kann durch Ausführen von gl.enable(gl.DEPTH_TEST) werden. Toll, aber was ist ein Tiefentest?

Durch Aktivieren des Tiefentests kann ein Pixel sowohl Farbe als auch Tiefe speichern. Die Tiefe ist die z- Koordinate dieses Pixels. Nachdem Sie zu einem Pixel in einer bestimmten Tiefe z gezeichnet haben, müssen Sie zum Aktualisieren der Farbe dieses Pixels an einer z -Position zeichnen, die näher an der Kamera liegt. Andernfalls wird der Ziehungsversuch ignoriert. Dies ermöglicht die Illusion von 3D, da das Zeichnen von Objekten, die sich hinter anderen Objekten befinden, dazu führt, dass diese Objekte von Objekten vor ihnen verdeckt werden.

Alle Ziehungen, die Sie durchführen, bleiben auf dem Bildschirm, bis Sie ihnen sagen, dass sie gelöscht werden sollen. Dazu müssen Sie gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) . Dadurch werden sowohl der Farb- als auch der Tiefenpuffer gelöscht. Um die Farbe auszuwählen, auf die die gelöschten Pixel eingestellt sind, verwenden gl.clearColor(red, green, blue, alpha) .

Lassen Sie uns einen Renderer erstellen, der eine Leinwand verwendet und auf Anfrage löscht:

 function Renderer (canvas) { var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') gl.enable(gl.DEPTH_TEST) this.gl = gl } Renderer.prototype.setClearColor = function (red, green, blue) { gl.clearColor(red / 255, green / 255, blue / 255, 1) } Renderer.prototype.getContext = function () { return this.gl } Renderer.prototype.render = function () { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) } var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) loop() function loop () { renderer.render() requestAnimationFrame(loop) }

Wenn Sie dieses Skript an den folgenden HTML-Code anhängen, erhalten Sie ein hellblaues Rechteck auf dem Bildschirm

 <!DOCTYPE html> <html> <head> </head> <body> <canvas width="800" height="500"></canvas> <script src="script.js"></script> </body> </html>

Der Aufruf von requestAnimationFrame bewirkt, dass die Schleife erneut aufgerufen wird, sobald der vorherige Frame gerendert und die gesamte Ereignisbehandlung abgeschlossen ist.

Vertex-Buffer-Objekte

Als erstes müssen Sie die Scheitelpunkte definieren, die Sie zeichnen möchten. Sie können dies tun, indem Sie sie über Vektoren im 3D-Raum beschreiben. Danach möchten Sie diese Daten in den GPU-RAM verschieben, indem Sie ein neues Vertex Buffer Object (VBO) erstellen.

Ein Pufferobjekt ist im Allgemeinen ein Objekt, das ein Array von Speicherblöcken auf der GPU speichert. Dass es sich um ein VBO handelt, gibt nur an, wofür die GPU den Speicher verwenden kann. Meistens handelt es sich bei den von Ihnen erstellten Pufferobjekten um VBOs.

Sie können das VBO füllen, indem Sie alle N Scheitelpunkte nehmen, die wir haben, und ein Array von Gleitkommazahlen mit 3N Elementen für die Scheitelpunktposition und Scheitelnormal-VBOs und 2N für die Texturkoordinaten-VBO erstellen. Jede Gruppe von drei Floats oder zwei Floats für UV-Koordinaten repräsentiert einzelne Koordinaten eines Scheitelpunkts. Dann übergeben wir diese Arrays an die GPU, und unsere Scheitelpunkte sind bereit für den Rest der Pipeline.

Da sich die Daten jetzt im GPU-RAM befinden, können Sie sie aus dem Allzweck-RAM löschen. Das heißt, es sei denn, Sie möchten es später ändern und erneut hochladen. Auf jede Änderung muss ein Upload folgen, da Änderungen in unseren JS-Arrays nicht für VBOs im tatsächlichen GPU-RAM gelten.

Nachfolgend finden Sie ein Codebeispiel, das alle beschriebenen Funktionen bereitstellt. Ein wichtiger Hinweis ist die Tatsache, dass auf der GPU gespeicherte Variablen nicht von der Garbage Collection erfasst werden. Das bedeutet, dass wir sie manuell löschen müssen, wenn wir sie nicht mehr verwenden möchten. Wir geben Ihnen hier nur ein Beispiel dafür, wie das gemacht wird, und werden uns nicht weiter auf dieses Konzept konzentrieren. Das Löschen von Variablen aus der GPU ist nur erforderlich, wenn Sie beabsichtigen, bestimmte Geometrien im gesamten Programm nicht mehr zu verwenden.

Wir haben unserer Geometry -Klasse und den darin enthaltenen Elementen auch eine Serialisierung hinzugefügt.

 Geometry.prototype.vertexCount = function () { return this.faces.length * 3 } Geometry.prototype.positions = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.position answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.normals = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.normal answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.uvs = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.uv answer.push(vx, vy) }) }) return answer } //////////////////////////////// function VBO (gl, data, count) { // Creates buffer object in GPU RAM where we can store anything var bufferObject = gl.createBuffer() // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, bufferObject) // Write the data, and set the flag to optimize // for rare changes to the data we're writing gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW) this.gl = gl this.size = data.length / count this.count = count this.data = bufferObject } VBO.prototype.destroy = function () { // Free memory that is occupied by our buffer object this.gl.deleteBuffer(this.data) }

Der VBO -Datentyp generiert das VBO im übergebenen WebGL-Kontext basierend auf dem als zweiten Parameter übergebenen Array.

Sie können drei Aufrufe an den gl Kontext sehen. Der createBuffer() Aufruf erstellt den Puffer. Der Aufruf von bindBuffer() weist die WebGL-Zustandsmaschine an, diesen spezifischen Speicher als aktuellen VBO ( ARRAY_BUFFER ) für alle zukünftigen Operationen zu verwenden, bis etwas anderes gesagt wird. Danach setzen wir den Wert des aktuellen VBO mit bufferData() auf die bereitgestellten Daten.

Wir stellen auch eine Destroy-Methode bereit, die unser Buffer-Objekt aus dem GPU-RAM löscht, indem deleteBuffer() verwenden.

Sie können drei VBOs und eine Transformation verwenden, um alle Eigenschaften eines Netzes zusammen mit seiner Position zu beschreiben.

 function Mesh (gl, geometry) { var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() }

Als Beispiel sehen Sie hier, wie wir ein Modell laden, seine Eigenschaften im Netz speichern und es dann zerstören können:

 Geometry.loadOBJ('/assets/model.obj').then(function (geometry) { var mesh = new Mesh(gl, geometry) console.log(mesh) mesh.destroy() })

Shader

Was folgt, ist der zuvor beschriebene zweistufige Prozess des Bewegens von Punkten in gewünschte Positionen und des Malens aller einzelnen Pixel. Dazu schreiben wir ein Programm, das viele Male auf der Grafikkarte ausgeführt wird. Dieses Programm besteht typischerweise aus mindestens zwei Teilen. Der erste Teil ist ein Vertex Shader , der für jeden Vertex ausgeführt wird und unter anderem ausgibt, wo wir den Vertex auf dem Bildschirm platzieren sollen. Der zweite Teil ist der Fragment Shader , der für jedes Pixel ausgeführt wird, das ein Dreieck auf dem Bildschirm abdeckt, und die Farbe ausgibt, in der dieses Pixel gezeichnet werden soll.

Vertex-Shader

Angenommen, Sie möchten ein Modell haben, das sich auf dem Bildschirm nach links und rechts bewegt. In einem naiven Ansatz könnten Sie die Position jedes Scheitelpunkts aktualisieren und erneut an die GPU senden. Dieser Prozess ist teuer und langsam. Alternativ würden Sie der GPU ein Programm geben, das für jeden Scheitelpunkt ausgeführt wird, und all diese Operationen parallel mit einem Prozessor ausführen, der genau für diese Aufgabe gebaut ist. Das ist die Rolle eines Vertex-Shaders .

Ein Vertex-Shader ist der Teil der Rendering-Pipeline, der einzelne Vertices verarbeitet. Ein Aufruf des Vertex-Shaders empfängt einen einzelnen Vertex und gibt einen einzelnen Vertex aus, nachdem alle möglichen Transformationen auf den Vertex angewendet wurden.

Shader sind in GLSL geschrieben. Diese Sprache enthält viele einzigartige Elemente, aber der größte Teil der Syntax ist sehr C-ähnlich, sodass sie für die meisten Menschen verständlich sein sollte.

Es gibt drei Arten von Variablen, die in einen Vertex-Shader ein- und ausgehen, und alle dienen einem bestimmten Zweck:

  • attribute — Dies sind Eingaben, die bestimmte Eigenschaften eines Scheitelpunkts enthalten. Zuvor haben wir die Position eines Scheitelpunkts als Attribut in Form eines dreielementigen Vektors beschrieben. Sie können Attribute als Werte betrachten, die einen Scheitelpunkt beschreiben.
  • uniform – Dies sind Eingaben, die für jeden Scheitelpunkt innerhalb desselben Rendering-Aufrufs gleich sind. Nehmen wir an, wir möchten unser Modell verschieben können, indem wir eine Transformationsmatrix definieren. Sie können eine uniform Variable verwenden, um dies zu beschreiben. Sie können auch auf Ressourcen auf der GPU verweisen, wie z. B. Texturen. Sie können Uniformen als Werte betrachten, die ein Modell oder einen Teil eines Modells beschreiben.
  • varying — Dies sind Ausgaben, die wir an den Fragment-Shader übergeben. Da es für ein Eckpunktdreieck potenziell Tausende von Pixeln gibt, erhält jedes Pixel abhängig von der Position einen interpolierten Wert für diese Variable. Wenn also ein Scheitelpunkt 500 als Ausgabe sendet und ein anderer 100, erhält ein Pixel, das sich in der Mitte zwischen ihnen befindet, 300 als Eingabe für diese Variable. Sie können Variationen als Werte betrachten, die Oberflächen zwischen Scheitelpunkten beschreiben.

So, let's say you want to create a vertex shader that receives a position, normal, and uv coordinates for each vertex, and a position, view (inverse camera position), and projection matrix for each rendered object. Let's say you also want to paint individual pixels based on their uv coordinates and their normals. “How would that code look?” Sie könnten fragen.

 attribute vec3 position; attribute vec3 normal; attribute vec2 uv; uniform mat4 model; uniform mat4 view; uniform mat4 projection; varying vec3 vNormal; varying vec2 vUv; void main() { vUv = uv; vNormal = (model * vec4(normal, 0.)).xyz; gl_Position = projection * view * model * vec4(position, 1.); }

Most of the elements here should be self-explanatory. The key thing to notice is the fact that there are no return values in the main function. All values that we would want to return are assigned, either to varying variables, or to special variables. Here we assign to gl_Position , which is a four-dimensional vector, whereby the last dimension should always be set to one. Another strange thing you might notice is the way we construct a vec4 out of the position vector. You can construct a vec4 by using four float s, two vec2 s, or any other combination that results in four elements. There are a lot of seemingly strange type castings which make perfect sense once you're familiar with transformation matrices.

You can also see that here we can perform matrix transformations extremely easily. GLSL is specifically made for this kind of work. The output position is calculated by multiplying the projection, view, and model matrix and applying it onto the position. The output normal is just transformed to the world space. We'll explain later why we've stopped there with the normal transformations.

For now, we will keep it simple, and move on to painting individual pixels.

Fragment Shaders

A fragment shader is the step after rasterization in the graphics pipeline. It generates color, depth, and other data for every pixel of the object that is being painted.

The principles behind implementing fragment shaders are very similar to vertex shaders. There are three major differences, though:

  • There are no more varying outputs, and attribute inputs have been replaced with varying inputs. We have just moved on in our pipeline, and things that are the output in the vertex shader are now inputs in the fragment shader.
  • Our only output now is gl_FragColor , which is a vec4 . The elements represent red, green, blue, and alpha (RGBA), respectively, with variables in the 0 to 1 range. You should keep alpha at 1, unless you're doing transparency. Transparency is a fairly advanced concept though, so we'll stick to opaque objects.
  • At the beginning of the fragment shader, you need to set the float precision, which is important for interpolations. In almost all cases, just stick to the lines from the following shader.

With that in mind, you can easily write a shader that paints the red channel based on the U position, green channel based on the V position, and sets the blue channel to maximum.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec2 clampedUv = clamp(vUv, 0., 1.); gl_FragColor = vec4(clampedUv, 1., 1.); }

The function clamp just limits all floats in an object to be within the given limits. The rest of the code should be pretty straightforward.

With all of this in mind, all that is left is to implement this in WebGL.

Combining Shaders into a Program

The next step is to combine the shaders into a program:

 function ShaderProgram (gl, vertSrc, fragSrc) { var vert = gl.createShader(gl.VERTEX_SHADER) gl.shaderSource(vert, vertSrc) gl.compileShader(vert) if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(vert)) throw new Error('Failed to compile shader') } var frag = gl.createShader(gl.FRAGMENT_SHADER) gl.shaderSource(frag, fragSrc) gl.compileShader(frag) if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(frag)) throw new Error('Failed to compile shader') } var program = gl.createProgram() gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) throw new Error('Failed to link program') } this.gl = gl this.position = gl.getAttribLocation(program, 'position') this.normal = gl.getAttribLocation(program, 'normal') this.uv = gl.getAttribLocation(program, 'uv') this.model = gl.getUniformLocation(program, 'model') this.view = gl.getUniformLocation(program, 'view') this.projection = gl.getUniformLocation(program, 'projection') this.vert = vert this.frag = frag this.program = program } // Loads shader files from the given URLs, and returns a program as a promise ShaderProgram.load = function (gl, vertUrl, fragUrl) { return Promise.all([loadFile(vertUrl), loadFile(fragUrl)]).then(function (files) { return new ShaderProgram(gl, files[0], files[1]) }) function loadFile (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(xhr.responseText) } } xhr.open('GET', url, true) xhr.send(null) }) } }

There isn't much to say about what's happening here. Each shader gets assigned a string as a source and compiled, after which we check to see if there were compilation errors. Then, we create a program by linking these two shaders. Finally, we store pointers to all relevant attributes and uniforms for posterity.

Actually Drawing the Model

Last, but not least, you draw the model.

First you pick the shader program you want to use.

 ShaderProgram.prototype.use = function () { this.gl.useProgram(this.program) }

Then you send all the camera related uniforms to the GPU. These uniforms change only once per camera change or movement.

 Transformation.prototype.sendToGpu = function (gl, uniform, transpose) { gl.uniformMatrix4fv(uniform, transpose || false, new Float32Array(this.fields)) } Camera.prototype.use = function (shaderProgram) { this.projection.sendToGpu(shaderProgram.gl, shaderProgram.projection) this.getInversePosition().sendToGpu(shaderProgram.gl, shaderProgram.view) }

Finally, you take the transformations and VBOs and assign them to uniforms and attributes, respectively. Since this has to be done to each VBO, you can create its data binding as a method.

 VBO.prototype.bindToAttribute = function (attribute) { var gl = this.gl // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, this.data) // Enable this attribute in the shader gl.enableVertexAttribArray(attribute) // Define format of the attribute array. Must match parameters in shader gl.vertexAttribPointer(attribute, this.size, gl.FLOAT, false, 0, 0) }

Then you assign an array of three floats to the uniform. Each uniform type has a different signature, so documentation and more documentation are your friends here. Finally, you draw the triangle array on the screen. You tell the drawing call drawArrays() from which vertex to start, and how many vertices to draw. The first parameter passed tells WebGL how it shall interpret the array of vertices. Using TRIANGLES takes three by three vertices and draws a triangle for each triplet. Using POINTS would just draw a point for each passed vertex. There are many more options, but there is no need to discover everything at once. Below is the code for drawing an object:

 Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) }

The renderer needs to be extended a bit to accommodate all the extra elements that need to be handled. It should be possible to attach a shader program, and to render an array of objects based on the current camera position.

 Renderer.prototype.setShader = function (shader) { this.shader = shader } Renderer.prototype.render = function (camera, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }

We can combine all the elements that we have to finally draw something on the screen:

 var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Geometry.loadOBJ('/assets/sphere.obj').then(function (data) { objects.push(new Mesh(gl, data)) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) loop() function loop () { renderer.render(camera, objects) requestAnimationFrame(loop) } 

Object drawn on the canvas, with colors depending on UV coordinates

This looks a bit random, but you can see the different patches of the sphere, based on where they are on the UV map. You can change the shader to paint the object brown. Just set the color for each pixel to be the RGBA for brown:

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); gl_FragColor = vec4(brown, 1.); } 

Brown object drawn on the canvas

It doesn't look very convincing. It looks like the scene needs some shading effects.

Adding Light

Lights and shadows are the tools that allow us to perceive the shape of objects. Lights come in many shapes and sizes: spotlights that shine in one cone, light bulbs that spread light in all directions, and most interestingly, the sun, which is so far away that all the light it shines on us radiates, for all intents and purposes, in the same direction.

Sunlight sounds like it's the simplest to implement, since all you need to provide is the direction in which all rays spread. For each pixel that you draw on the screen, you check the angle under which the light hits the object. This is where the surface normals come in.

Demonstration of angles between light rays and surface normals, for both flat and smooth shading

You can see all the light rays flowing in the same direction, and hitting the surface under different angles, which are based on the angle between the light ray and the surface normal. The more they coincide, the stronger the light is.

If you perform a dot product between the normalized vectors for the light ray and the surface normal, you will get -1 if the ray hits the surface perfectly perpendicularly, 0 if the ray is parallel to the surface, and 1 if it illuminates it from the opposite side. So anything between 0 and 1 should add no light, while numbers between 0 and -1 should gradually increase the amount of light hitting the object. You can test this by adding a fixed light in the shader code.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); gl_FragColor = vec4(brown * lightness, 1.); } 

Brown object with sunlight

Wir stellen die Sonne so ein, dass sie in der Richtung vorwärts-links-unten scheint. Sie können sehen, wie glatt die Schattierung ist, obwohl das Modell sehr gezackt ist. Sie können auch feststellen, wie dunkel die untere linke Seite ist. Wir können Umgebungslicht hinzufügen, wodurch der Bereich im Schatten heller wird.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); float ambientLight = 0.3; lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); } 

Braunes Objekt mit Sonnenlicht und Umgebungslicht

Den gleichen Effekt erreichen Sie, indem Sie eine Lichtklasse einführen, die die Lichtrichtung und die Umgebungslichtintensität speichert. Dann können Sie den Fragment-Shader ändern, um diesen Zusatz aufzunehmen.

Jetzt wird der Shader zu:

 #ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }

Dann können Sie das Licht definieren:

 function Light () { this.lightDirection = new Vector3(-1, -1, -1) this.ambientLight = 0.3 } Light.prototype.use = function (shaderProgram) { var dir = this.lightDirection var gl = shaderProgram.gl gl.uniform3f(shaderProgram.lightDirection, dir.x, dir.y, dir.z) gl.uniform1f(shaderProgram.ambientLight, this.ambientLight) }

Fügen Sie in der Shader-Programmklasse die erforderlichen Uniformen hinzu:

 this.ambientLight = gl.getUniformLocation(program, 'ambientLight') this.lightDirection = gl.getUniformLocation(program, 'lightDirection')

Fügen Sie im Programm einen Aufruf für das neue Licht im Renderer hinzu:

 Renderer.prototype.render = function (camera, light, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() light.use(shader) camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }

Die Schleife ändert sich dann leicht:

 var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }

Wenn Sie alles richtig gemacht haben, sollte das gerenderte Bild dasselbe sein wie im letzten Bild.

Ein letzter zu berücksichtigender Schritt wäre das Hinzufügen einer tatsächlichen Textur zu unserem Modell. Lass uns das jetzt tun.

Hinzufügen von Texturen

HTML5 bietet eine hervorragende Unterstützung für das Laden von Bildern, sodass keine verrückte Bildanalyse erforderlich ist. Bilder werden als sampler2D an GLSL übergeben, indem dem Shader mitgeteilt wird, welche der gebundenen Texturen abgetastet werden sollen. Es gibt eine begrenzte Anzahl von Texturen, die man binden könnte, und die Grenze basiert auf der verwendeten Hardware. Ein sampler2D kann an bestimmten Positionen nach Farben abgefragt werden. Hier kommen die UV-Koordinaten ins Spiel. Hier ist ein Beispiel, bei dem wir Braun durch Musterfarben ersetzt haben.

 #ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; uniform sampler2D diffuse; varying vec3 vNormal; varying vec2 vUv; void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }

Die neue Uniform muss der Auflistung im Shader-Programm hinzugefügt werden:

 this.diffuse = gl.getUniformLocation(program, 'diffuse')

Schließlich implementieren wir das Laden von Texturen. Wie bereits erwähnt, bietet HTML5 Möglichkeiten zum Laden von Bildern. Alles, was wir tun müssen, ist das Bild an die GPU zu senden:

 function Texture (gl, image) { var texture = gl.createTexture() // Set the newly created texture context as active texture gl.bindTexture(gl.TEXTURE_2D, texture) // Set texture parameters, and pass the image that the texture is based on gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image) // Set filtering methods // Very often shaders will query the texture value between pixels, // and this is instructing how that value shall be calculated gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) this.data = texture this.gl = gl } Texture.prototype.use = function (uniform, binding) { binding = Number(binding) || 0 var gl = this.gl // We can bind multiple textures, and here we pick which of the bindings // we're setting right now gl.activeTexture(gl['TEXTURE' + binding]) // After picking the binding, we set the texture gl.bindTexture(gl.TEXTURE_2D, this.data) // Finally, we pass to the uniform the binding ID we've used gl.uniform1i(uniform, binding) // The previous 3 lines are equivalent to: // texture[i] = this.data // uniform = i } Texture.load = function (gl, url) { return new Promise(function (resolve) { var image = new Image() image.onload = function () { resolve(new Texture(gl, image)) } image.src = url }) }

Der Prozess unterscheidet sich nicht wesentlich von dem Prozess, der zum Laden und Binden von VBOs verwendet wird. Der Hauptunterschied besteht darin, dass wir nicht mehr an ein Attribut binden, sondern den Index der Textur an eine ganzzahlige Uniform binden. Der sampler2D Typ ist nichts anderes als ein Zeiger-Offset zu einer Textur.

Jetzt muss nur noch die Mesh -Klasse erweitert werden, um auch Texturen zu verarbeiten:

 function Mesh (gl, geometry, texture) { // added texture var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.texture = texture // new this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() } Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.texture.use(shaderProgram.diffuse, 0) // new this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) } Mesh.load = function (gl, modelUrl, textureUrl) { // new var geometry = Geometry.loadOBJ(modelUrl) var texture = Texture.load(gl, textureUrl) return Promise.all([geometry, texture]).then(function (params) { return new Mesh(gl, params[0], params[1]) }) }

Und das endgültige Hauptskript würde wie folgt aussehen:

 var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Mesh.load(gl, '/assets/sphere.obj', '/assets/diffuse.png') .then(function (mesh) { objects.push(mesh) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) } 

Strukturiertes Objekt mit Lichteffekten

Sogar das Animieren fällt an dieser Stelle leicht. Wenn Sie möchten, dass sich die Kamera um unser Objekt dreht, können Sie dies tun, indem Sie nur eine Codezeile hinzufügen:

 function loop () { renderer.render(camera, light, objects) camera.position = camera.position.rotateY(Math.PI / 120) requestAnimationFrame(loop) } 

Rotierter Kopf während der Kameraanimation

Fühlen Sie sich frei, mit Shadern herumzuspielen. Das Hinzufügen einer Codezeile verwandelt diese realistische Beleuchtung in etwas Cartoonisches.

 void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = lightness > 0.1 ? 1. : 0.; // new lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }

Es ist so einfach, der Beleuchtung zu sagen, dass sie in ihre Extreme gehen soll, je nachdem, ob sie einen festgelegten Schwellenwert überschreitet.

Kopf mit angewendeter Cartoon-Beleuchtung

Wohin als nächstes gehen

Es gibt viele Informationsquellen, um alle Tricks und Feinheiten von WebGL zu lernen. Und das Beste daran ist, dass Sie, wenn Sie keine Antwort finden, die sich auf WebGL bezieht, in OpenGL danach suchen können, da WebGL im Wesentlichen auf einer Teilmenge von OpenGL basiert, wobei einige Namen geändert wurden.

In keiner bestimmten Reihenfolge finden Sie hier einige großartige Quellen für detailliertere Informationen, sowohl für WebGL als auch für OpenGL.

  • WebGL-Grundlagen
  • WebGL lernen
  • Ein sehr detailliertes OpenGL-Tutorial, das Sie auf sehr langsame und detaillierte Weise durch alle hier beschriebenen grundlegenden Prinzipien führt.
  • Und es gibt viele, viele andere Websites, die Ihnen die Prinzipien der Computergrafik beibringen.
  • MDN-Dokumentation für WebGL
  • Khronos WebGL 1.0-Spezifikation, wenn Sie daran interessiert sind, die eher technischen Details zu verstehen, wie die WebGL-API in allen Grenzfällen funktionieren sollte.