WebGL-Modell-View-Projektion
Dieser Artikel untersucht, wie man Daten innerhalb eines WebGL-Projekts projiziert, um sie korrekt auf dem Bildschirm darzustellen. Es wird vorausgesetzt, dass Sie Grundkenntnisse in der Matrizentheorie, insbesondere mit Translations-, Skalierungs- und Rotationsmatrizen, besitzen. Es erklärt die drei Kernmatrizen, die typischerweise beim Erstellen einer 3D-Szene verwendet werden: die Modell-, View- und Projektionsmatrix.
Die Modell-, View- und Projektionsmatrizen
Individuelle Transformationen von Punkten und Polygonen im Raum in WebGL werden durch die grundlegenden Transformationsmatrizen wie Translation, Skalierung und Rotation gehandhabt. Diese Matrizen können zusammengesetzt und auf besondere Weise gruppiert werden, um sie nützlich für das Rendern komplizierter 3D-Szenen zu machen. Diese zusammengesetzten Matrizen verschieben letztendlich die ursprünglichen Modelldaten in einen speziellen Koordinatenraum, der als Clippingraum bekannt ist. Dies ist ein 2 Einheiten breiter Würfel, zentriert bei (0,0,0), mit Ecken, die von (-1,-1,-1) bis (1,1,1) reichen. Dieser Clippingraum wird zu einem 2D-Raum komprimiert und in ein Bild rasterisiert.
Die erste unten diskutierte Matrix ist die Modellmatrix, die definiert, wie Sie Ihre ursprünglichen Modelldaten nehmen und im 3D-Weltraum verschieben. Die Projektionsmatrix wird verwendet, um Weltkoordinaten in Clipping-Koordinaten zu konvertieren. Eine häufig verwendete Projektionsmatrix, die perspektivische Projektionsmatrix, wird verwendet, um die Effekte einer typischen Kamera zu imitieren, die als Stellvertreter für den Betrachter in der 3D-virtuellen Welt dient. Die View-Matrix ist dafür verantwortlich, die Objekte in der Szene zu bewegen, um die Position der Kamera zu simulieren, was verändert, was der Betrachter aktuell sehen kann.
Die folgenden Abschnitte bieten einen tiefen Einblick in die Ideen hinter und die Implementierung der Modell-, View- und Projektionsmatrizen. Diese Matrizen sind zentral für das Bewegen von Daten auf dem Bildschirm und sind Konzepte, die über einzelne Frameworks und Engines hinausgehen.
Clippingraum
In einem WebGL-Programm werden Daten typischerweise mit ihrem eigenen Koordinatensystem zur GPU hochgeladen, und dann transformiert der Vertex-Shader diese Punkte in ein spezielles Koordinatensystem, das als Clippingraum bekannt ist. Alle Daten, die über den Clippingraum hinausragen, werden abgeschnitten und nicht gerendert. Wenn ein Dreieck jedoch die Grenze dieses Raumes überschreitet, wird es in neue Dreiecke aufgeteilt, und nur die Teile der neuen Dreiecke, die im Clippingraum liegen, werden beibehalten.
Die obige Grafik ist eine Visualisierung des Clippingraums, in den alle Punkte passen müssen. Es ist ein Würfel mit zwei Einheiten auf jeder Seite, mit einer Ecke bei (-1,-1,-1) und der gegenüberliegenden Ecke bei (1,1,1). Das Zentrum des Würfels ist der Punkt (0,0,0). Dieses 8 Kubikmeter Koordinatensystem, das vom Clippingraum verwendet wird, ist unter dem Begriff "Normierte Gerätekoordinaten" (engl. normalized device coordinates, NDC) bekannt. Möglicherweise stoßen Sie von Zeit zu Zeit auf diesen Begriff, während Sie WebGL-Code erforschen und bearbeiten.
In diesem Abschnitt werden wir unsere Daten direkt in das Koordinatensystem des Clippingraums setzen. Normalerweise werden Modelldaten verwendet, die in einem beliebigen Koordinatensystem vorliegen und dann mit einer Matrix transformiert werden, welche die Modellkoordinaten in das Clippingraum-Koordinatensystem konvertieren. Für dieses Beispiel ist es am einfachsten, zu veranschaulichen, wie Clippingraum funktioniert, indem man Modellkoordinatenwerte verwendet, die im Bereich von (-1,-1,-1) bis (1,1,1) liegen. Der folgende Code wird 2 Dreiecke erstellen, die ein Quadrat auf dem Bildschirm zeichnen werden. Die Z-Tiefe in den Quadraten bestimmt, was gezeichnet wird, wenn die Quadrate denselben Raum teilen. Kleinere Z-Werte werden über größeren Z-Werten gerendert.
WebGLBox-Beispiel
Dieses Beispiel erstellt ein benutzerdefiniertes WebGLBox-Objekt, das ein 2D-Quadrat auf dem Bildschirm zeichnen wird. Es ist als Klasse implementiert, die einen Konstruktor und eine draw()-Methode enthält, um ein Quadrat auf dem Bildschirm zu zeichnen:
class WebGLBox {
canvas = document.getElementById("my-canvas");
gl = this.canvas.getContext("webgl");
webglProgram = createWebGLProgramFromIds(
this.gl,
"vertex-shader",
"fragment-shader",
);
positionLocation;
colorLocation;
constructor() {
const gl = this.gl;
// Setup a WebGL program
gl.useProgram(this.webglProgram);
// Save the attribute and uniform locations
this.positionLocation = gl.getAttribLocation(this.webglProgram, "position");
this.colorLocation = gl.getUniformLocation(this.webglProgram, "vColor");
// Tell WebGL to test the depth when drawing, so if a square is behind
// another square it won't be drawn
gl.enable(gl.DEPTH_TEST);
}
draw(settings) {
// Create some attribute data; these are the triangles that will end being
// drawn to the screen. There are two that form a square.
// prettier-ignore
const data = new Float32Array([
// Triangle 1
settings.left, settings.bottom, settings.depth,
settings.right, settings.bottom, settings.depth,
settings.left, settings.top, settings.depth,
// Triangle 2
settings.left, settings.top, settings.depth,
settings.right, settings.bottom, settings.depth,
settings.right, settings.top, settings.depth,
]);
// Use WebGL to draw this onto the screen.
// Performance Note: Creating a new array buffer for every draw call is slow.
// This function is for illustration purposes only.
const gl = this.gl;
// Create a buffer and bind the data
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
// Setup the pointer to our attribute data (the triangles)
gl.enableVertexAttribArray(this.positionLocation);
gl.vertexAttribPointer(this.positionLocation, 3, gl.FLOAT, false, 0, 0);
// Setup the color uniform that will be shared across all triangles
gl.uniform4fv(this.colorLocation, settings.color);
// Draw the triangles to the screen
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
}
Die Shader sind die Teile des Codes, die in GLSL geschrieben sind und unsere Datenpunkte letztendlich auf dem Bildschirm rendern. Zur Vereinfachung sind diese Shader in einem <script>-Element gespeichert, das über die benutzerdefinierte Funktion createWebGLProgramFromIds() in das Programm eingebracht wird. Diese Funktion erledigt die Grundlagen des Nehmens von GLSL-Quellcode und kompiliert ihn zu einem WebGL-Programm. Es nimmt drei Parameter — den Kontext, in dem das Programm gerendert wird, die ID des <script>-Elements, das den Vertex-Shader enthält, und die ID des <script>-Elements, das den Fragment-Shader enthält. Diese Funktion wird hier nicht ausführlich erklärt; wenn Sie ihre Implementierung sehen möchten, klicken Sie auf "Play" im Codeblock. Der Vertex-Shader positioniert die Vertices, und der Fragment-Shader färbt jedes Pixel.
Schaun Sie sich zunächst den Vertex-Shader an, der die Vertices auf dem Bildschirm bewegen wird:
// The individual position vertex
attribute vec3 position;
void main() {
// the gl_Position is the final position in clip space after the vertex shader modifies it
gl_Position = vec4(position, 1.0);
}
Als nächstes, um die Daten tatsächlich in Pixel zu rasterisieren, bewertet der Fragment-Shader alles auf Pixelbasis und legt eine einzige Farbe fest. Die GPU ruft die Shader-Funktion für jedes Pixel auf, das gerendert werden muss; die Aufgabe des Shaders ist es, die Farbe zurückzugeben, die für dieses Pixel verwendet werden soll.
precision mediump float;
uniform vec4 vColor;
void main() {
gl_FragColor = vColor;
}
Mit diesen Einstellungen ist es an der Zeit, direkt mit Clippingraum-Koordinaten auf den Bildschirm zu zeichnen.
const box = new WebGLBox();
Zuerst zeichnen Sie ein rotes Quadrat in der Mitte.
box.draw({
top: 0.5, // x
bottom: -0.5, // x
left: -0.5, // y
right: 0.5, // y
depth: 0, // z
color: [1, 0.4, 0.4, 1], // red
});
Als nächstes zeichnen Sie ein grünes Quadrat oben und hinter dem roten Quadrat.
box.draw({
top: 0.9, // x
bottom: 0, // x
left: -0.9, // y
right: 0.9, // y
depth: 0.5, // z
color: [0.4, 1, 0.4, 1], // green
});
Schließlich wird zur Demonstration, dass tatsächlich eine Clipping stattfindet, dieses Quadrat nicht gezeichnet, da es vollständig außerhalb des Clippingraums liegt. Die Tiefe liegt außerhalb des Bereichs von -1,0 bis 1,0.
box.draw({
top: 1, // x
bottom: -1, // x
left: -1, // y
right: 1, // y
depth: -1.5, // z
color: [0.4, 0.4, 1, 1], // blue
});
Die Ergebnisse
Übung
Eine hilfreiche Übung an diesem Punkt ist es, die Kästchen im Clippingraum zu verschieben, indem Sie den Code variieren, um ein Gefühl dafür zu bekommen, wie Punkte im Clippingraum abgeschnitten und bewegt werden. Versuchen Sie, ein Bild wie ein kantiges Smiley-Gesicht mit einem Hintergrund zu zeichnen.
Homogene Koordinaten
Die Hauptzeile des vorhergehenden Clip-Space-Vertex-Shader enthält diesen Code:
gl_Position = vec4(position, 1.0);
Die position-Variable wurde in der draw()-Methode definiert und als Attribut an den Shader übergeben. Dies ist ein dreidimensionaler Punkt, aber die gl_Position-Variable, die letztendlich durch die Pipeline weitergegeben wird, ist tatsächlich 4-dimensional — anstelle von (x, y, z) ist es (x, y, z, w). Es gibt keinen Buchstaben nach z, daher wird diese vierte Dimension konventionell als w bezeichnet. Im obigen Beispiel wird die w-Koordinate auf 1.0 gesetzt.
Die offensichtliche Frage ist "Warum die zusätzliche Dimension?" Es stellt sich heraus, dass diese Hinzufügung viele nette Techniken zur Manipulation von 3D-Daten ermöglicht. Diese zusätzliche Dimension führt den Begriff der Perspektive in das Koordinatensystem ein; mit ihr kann man 3D-Koordinaten in den 2D-Raum abbilden — wodurch es möglich wird, dass zwei parallele Linien an einer Stelle zusammenlaufen, wenn sie in die Ferne rücken. Der Wert von w wird als Divisor für die anderen Komponenten der Koordinate verwendet, sodass die echten Werte von x, y und z als x/w, y/w und z/w berechnet werden (und w wird dann auch w/w, was 1 wird).
Ein dreidimensionaler Punkt wird in einem typischen kartesischen Koordinatensystem definiert. Die hinzugefügte vierte Dimension verändert diesen Punkt in eine homogene Koordinate. Es stellt immer noch einen Punkt im 3D-Raum dar, und es kann leicht demonstriert werden, wie dieser Koordinatentyp durch ein Paar einfacher Funktionen konstruiert wird.
function cartesianToHomogeneous(point) {
let x = point[0];
let y = point[1];
let z = point[2];
return [x, y, z, 1];
}
function homogeneousToCartesian(point) {
let x = point[0];
let y = point[1];
let z = point[2];
let w = point[3];
return [x / w, y / w, z / w];
}
Wie bereits erwähnt und in den obigen Funktionen gezeigt, dividiert die w-Komponente die x, y und z-Komponenten. Wenn die w-Komponente eine andere als null reale Zahl ist, dann wird die homogene Koordinate leicht in einen normalen Punkt im kartesischen Raum übersetzt. Was passiert nun, wenn die w-Komponente null ist? In JavaScript würde der zurückgegebene Wert wie folgt aussehen.
homogeneousToCartesian([10, 4, 5, 0]);
Dies ergibt: [Infinity, Infinity, Infinity].
Diese homogene Koordinate repräsentiert einen Punkt im Unendlichen. Dies ist eine praktische Möglichkeit, einen Strahl zu repräsentieren, der von der Ursprungsrichtung in eine bestimmte Richtung schießt. Zusätzlich zu einem Strahl könnte es auch als Repräsentation eines Richtungsvektors betrachtet werden. Wenn diese homogene Koordinate mit einer Matrix mit einer Translation multipliziert wird, wird die Translation effektiv entfernt.
Wenn Zahlen auf Computern extrem groß (oder extrem klein) werden, beginnen sie weniger genau zu werden, weil es nur so viele Einsen und Nullen gibt, um sie darzustellen. Je mehr Operationen mit größeren Zahlen durchgeführt werden, desto mehr Fehler häufen sich im Ergebnis an. Die Division durch w kann effektiv die Präzision sehr großer Zahlen erhöhen, indem mit zwei potenziell kleineren, weniger fehleranfälligen Zahlen gearbeitet wird.
Der letzte Vorteil der Verwendung homogener Koordinaten ist, dass sie sehr gut für die Multiplikation mit 4x4-Matrizen geeignet sind. Ein Vertex muss mindestens eine der Dimensionen einer Matrix übereinstimmen, um mit ihm multipliziert werden zu können. Die 4x4-Matrix kann verwendet werden, um eine Vielzahl nützlicher Transformationen zu kodieren. In der Tat verwendet die typische perspektivische Projektionsmatrix die Division durch die w-Komponente, um ihre Transformation zu erreichen.
Das Abtrennen von Punkten und Polygonen aus dem Clip-Raum erfolgt, bevor die homogenen Koordinaten wieder in kartesische Koordinaten transformiert wurden (durch Division durch w). Dieser endgültige Raum wird als normierte Gerätekoordinaten oder NDC bezeichnet.
Um mit dieser Idee zu spielen, kann das vorherige Beispiel so modifiziert werden, dass die w-Komponente verwendet werden kann. Zusätzlich zur Änderung der data-Variable denken Sie daran, vertexAttribPointer() so zu ändern, dass 4 Komponenten (der zweite size-Parameter) anstelle von 3 verwendet werden.
// Redefine the triangles to use the W component
// prettier-ignore
const data = new Float32Array([
// Triangle 1
settings.left, settings.bottom, settings.depth, settings.w,
settings.right, settings.bottom, settings.depth, settings.w,
settings.left, settings.top, settings.depth, settings.w,
// Triangle 2
settings.left, settings.top, settings.depth, settings.w,
settings.right, settings.bottom, settings.depth, settings.w,
settings.right, settings.top, settings.depth, settings.w,
]);
Dann verwendet der Vertex-Shader den übergebenen 4-dimensionalen Punkt.
attribute vec4 position;
void main() {
gl_Position = position;
}
Zuerst zeichnen wir ein rotes Rechteck in der Mitte, aber setzen W auf 0,7. Da die Koordinaten durch 0,7 dividiert werden, werden sie alle vergrößert.
box.draw({
top: 0.5, // y
bottom: -0.5, // y
left: -0.5, // x
right: 0.5, // x
w: 0.7, // w - enlarge this box
depth: 0, // z
color: [1, 0.4, 0.4, 1], // red
});
Nun zeichnen wir ein grünes Quadrat oben, aber verkleinern es durch Setzen der w-Komponente auf 1,1
box.draw({
top: 0.9, // y
bottom: 0, // y
left: -0.9, // x
right: 0.9, // x
w: 1.1, // w - shrink this box
depth: 0.5, // z
color: [0.4, 1, 0.4, 1], // green
});
Dieses letzte Quadrat wird nicht gezeichnet, da es außerhalb des Clippingraums liegt. Die Tiefe liegt außerhalb des Bereichs -1,0 bis 1,0.
box.draw({
top: 1, // y
bottom: -1, // y
left: -1, // x
right: 1, // x
w: 1.5, // w - Bring this box into range
depth: -1.5, // z
color: [0.4, 0.4, 1, 1], // blue
});
Die Ergebnisse
Übungen
- Spielen Sie mit diesen Werten herum, um zu sehen, wie sie sich auf das, was auf dem Bildschirm dargestellt wird, auswirken. Beachten Sie, wie das zuvor abgeschnittene blaue Kästchen wieder in den Bereich zurückgebracht wird, indem die w-Komponente gesetzt wird.
- Versuchen Sie, ein neues Kästchen zu erstellen, das außerhalb des Clippingraums liegt, und bringen Sie es durch die Division durch w zurück in den Raum.
Modelltransformation
Das Platzieren von Punkten direkt im Clippingraum ist von begrenztem Nutzen. In realen Anwendungen haben Sie nicht alle Quellkoordinaten bereits in Clippingraum-Koordinaten. Daher müssen Sie die Modelldaten und andere Koordinaten meistens in den Clippingraum transformieren. Der bescheidene Würfel ist ein einfaches Beispiel dafür, wie dies zu tun ist. Würfeldaten bestehen aus Vertex-Positionen, den Farben der Würfelflächen und der Reihenfolge der Vertex-Positionen, die die einzelnen Polygone bilden (in Gruppen von 3 Vertices, um die Dreiecke zu konstruieren, die die Würfelflächen bilden). Die Positionen und Farben werden in GL-Puffern gespeichert, als Attribute an den Shader gesendet und dann individuell bearbeitet.
Schließlich wird eine einzelne Modellmatrix berechnet und festgelegt. Diese Matrix repräsentiert die Transformationen, die an jedem Punkt des Modells durchgeführt werden müssen, um ihn in den richtigen Raum zu verschieben, und um alle anderen notwendigen Transformationen an jedem Punkt des Modells durchzuführen. Dies gilt nicht nur für jeden Vertex, sondern für jeden einzelnen Punkt auf jeder Oberfläche des Modells.
In diesem Fall wird für jeden Frame der Animation eine Reihe von Skalierungs-, Rotations- und Translationsmatrizen verwendet, um die Daten an die gewünschte Stelle im Clippingraum zu verschieben. Der Würfel ist so groß wie der Clippingraum (-1,-1,-1) bis (1,1,1), sodass er verkleinert werden muss, um nicht den gesamten Clippingraum zu füllen. Diese Matrix wird direkt an den Shader gesendet, nachdem sie zuvor in JavaScript multipliziert wurde.
Der folgende Codeausschnitt definiert eine Methode im CubeDemo-Objekt, die die Modellmatrix erstellt. Die neue Funktion sieht so aus (die Hilfsfunktionen werden im Matrix-Mathematik für das Web-Kapitel vorgestellt):
function computeModelMatrix(now) {
// Scale down by 20%
const scaleMatrix = scale(0.2, 0.2, 0.2);
// Rotate a slight tilt
const rotateXMatrix = rotateX(now * 0.0003);
// Rotate according to time
const rotateYMatrix = rotateY(now * 0.0005);
// Move slightly down
const translateMatrix = translate(0, -0.1, 0);
// Multiply together, make sure and read them in opposite order
this.transforms.model = multiplyArrayOfMatrices([
translateMatrix, // step 4
rotateYMatrix, // step 3
rotateXMatrix, // step 2
scaleMatrix, // step 1
]);
}
Um dies im Shader zu verwenden, muss es an eine einheitliche Position gesetzt werden. Die Orte für die Uniformen werden im locations-Objekt unten gespeichert:
this.locations.model = gl.getUniformLocation(webglProgram, "model");
Und schließlich wird die Uniform auf diesen Ort gesetzt. Dies übergibt die Matrix an die GPU.
gl.uniformMatrix4fv(
this.locations.model,
false,
new Float32Array(this.transforms.model),
);
Im Shader wird jeder Positions-Vertex zuerst in eine homogene Koordinate (ein vec4-Objekt) transformiert und dann mit der Modellmatrix multipliziert.
gl_Position = model * vec4(position, 1.0);
Hinweis:
In JavaScript erfordert die Matrixmultiplikation eine benutzerdefinierte Funktion, während sie im Shader mit dem einfachen *-Operator in die Sprache integriert ist.
Der vollständige Orchestrierungscode ist ausgeblendet. Wenn Sie interessiert sind, klicken Sie erneut auf "Play" in einem Codeblock in diesem Abschnitt, um ihn sich anzusehen.
Die Ergebnisse
Zu diesem Zeitpunkt ist der w-Wert des transformierten Punkts immer noch 1.0. Der Würfel hat immer noch keine Perspektive. Der nächste Abschnitt wird dieses Setup nehmen und die w-Werte modifizieren, um etwas Perspektive zu liefern.
Übungen
- Verkleinern Sie das Kästchen mit der Skalierungsmatrix und platzieren Sie es an verschiedenen Stellen im Clippingraum.
- Versuchen Sie, es außerhalb des Clippingraums zu bewegen.
- Ändern Sie die Größe des Fensters und beobachten Sie, wie das Kästchen aus der Form gerät.
- Fügen Sie eine
rotateZ-Matrix hinzu.
Division durch W
Eine einfache Möglichkeit, etwas Perspektive auf unser Würfelmodell zu bekommen, besteht darin, die Z-Koordinate zu nehmen und in die w-Koordinate zu kopieren. Normalerweise wird bei der Umwandlung eines kartesischen Punktes in einen homogenen Punkt (x,y,z,1), aber wir werden ihn in etwas wie (x,y,z,z) ändern. In Wirklichkeit möchten wir sicherstellen, dass z für Punkte, die im Sichtfeld liegen, größer als 0 ist, sodass wir es leicht modifizieren, indem wir den Wert auf ((1.0 + z) * scaleFactor) ändern. Dies wird einen Punkt, der normalerweise im Clippingraum liegt (-1 bis 1), in einen Raum wie (0 bis 1) verschieben, abhängig davon, was der Skalierungsfaktor ist. Der Skalierungsfaktor ändert den endgültigen w-Wert, sodass er insgesamt entweder größer oder kleiner wird.
Der Shader-Code sieht so aus.
// First transform the point
vec4 transformedPosition = model * vec4(position, 1.0);
// How much effect does the perspective have?
float scaleFactor = 0.5;
// Set w by taking the z value which is typically ranged -1 to 1, then scale
// it to be from 0 to some number, in this case 0-1.
float w = (1.0 + transformedPosition.z) * scaleFactor;
// Save the new gl_Position with the custom w component
gl_Position = vec4(transformedPosition.xyz, w);
Die Ergebnisse
Sehen Sie das kleine Dreieck an der Ecke, das zur Kamera zeigt? Hier ist ein Screenshot, wann es erscheint:

Das ist eine zusätzliche Fläche, die unserem Objekt hinzugefügt wurde, weil die Rotation unserer Form dazu geführt hat, dass diese Ecke außerhalb des Clippingraums herausragt, wodurch die Ecke abgeschnitten wird. Siehe Perspektivische Projektionsmatrix unten für eine Einführung, wie Sie komplexere Matrizen verwenden können, um das Clipping zu steuern und zu verhindern.
Übung
Wenn das etwas abstrakt klingt, öffnen Sie den Vertex-Shader und spielen Sie mit dem Skalierungsfaktor herum und beobachten Sie, wie er die Vertices mehr zur Oberfläche hin schrumpft. Ändern Sie die w-Komponentenwerte vollständig für wirklich abgefahrene Darstellungen des Raumes.
Im nächsten Abschnitt werden wir diesen Schritt des Kopierens von Z in den w-Bereich nehmen und ihn in eine Matrix verwandeln.
Einfache Projektion
Der letzte Schritt des Ausfüllens der w-Komponente kann tatsächlich mit einer einfachen Matrix erreicht werden. Beginnen Sie mit der Identitätsmatrix:
// prettier-ignore
const identity = [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
];
multiplyPoint(identity, [2, 3, 4, 1]);
// [2, 3, 4, 1]
Verschieben Sie dann die 1 der letzten Spalte um einen Platz nach oben.
// prettier-ignore
const copyZ = [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 1,
0, 0, 0, 0,
];
multiplyPoint(copyZ, [2, 3, 4, 1]);
// [2, 3, 4, 4]
Im letzten Beispiel haben wir jedoch (z + 1) * scaleFactor durchgeführt:
const scaleFactor = 0.5;
// prettier-ignore
const simpleProjection = [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, scaleFactor,
0, 0, 0, scaleFactor,
];
multiplyPoint(simpleProjection, [2, 3, 4, 1]);
// [2, 3, 4, 2.5]
Wenn wir es noch weiter aufbrechen, können wir sehen, wie dies funktioniert:
const x = 2 * 1 + 3 * 0 + 4 * 0 + 1 * 0;
const y = 2 * 0 + 3 * 1 + 4 * 0 + 1 * 0;
const z = 2 * 0 + 3 * 0 + 4 * 1 + 1 * 0;
const w = 2 * 0 + 3 * 0 + 4 * scaleFactor + 1 * scaleFactor;
Die letzte Zeile könnte vereinfacht werden zu:
const w = 4 * scaleFactor + 1 * scaleFactor;
Dann den Skalierungsfaktor herausfaktorieren, erhalten wir dies:
const w = (4 + 1) * scaleFactor;
Dies ist genau das gleiche wie das (z + 1) * scaleFactor, das wir im vorherigen Beispiel verwendet haben.
Im Box-Demo wird eine zusätzliche computeSimpleProjectionMatrix()-Methode hinzugefügt. Dies wird in der draw()-Methode aufgerufen und hat den Skalierungsfaktor übergeben. Das Ergebnis sollte identisch mit dem letzten Beispiel sein:
function computeSimpleProjectionMatrix(scaleFactor) {
// prettier-ignore
this.transforms.projection = [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, scaleFactor,
0, 0, 0, scaleFactor,
];
}
Obwohl das Ergebnis identisch ist, besteht der wichtige Schritt hier im Vertex-Shader. Anstatt den Vertex direkt zu modifizieren, wird er mit einer zusätzlichen Projektionsmatrix multipliziert, die (wie der Name schon sagt) 3D-Punkte auf eine 2D-Zeichenfläche projiziert:
// Make sure to read the transformations in reverse order
gl_Position = projection * model * vec4(position, 1.0);
Die Ergebnisse
Der Sichtfrustum
Bevor wir fortfahren, wie man eine perspektivische Projektionsmatrix berechnet, müssen wir das Konzept des Sichtfrustum (auch bekannt als **Sichtfrust") vorstellen. Dies ist der Bereich des Raumes, dessen Inhalt dem Benutzer aktuell sichtbar ist. Es ist der 3D-Raum, der durch das Sichtfeld und die Abstände definiert ist, die als die nächstgelegenen und am weitesten entfernten Inhalte angegeben werden, die gerendert werden sollten.
Beim Rendern müssen wir bestimmen, welche Polygone gerendert werden müssen, um die Szene darzustellen. Das ist das, was das Sichtfrustum definiert. Aber was ist ein Frustum überhaupt?
Ein Frustum ist der 3D-Körper, der entsteht, wenn man einen beliebigen Körper nimmt und ihn mit zwei parallelen Ebenen abteilt. Betrachten Sie unsere Kamera, die einen Bereich betrachtet, der direkt vor ihrer Linse beginnt und in die Ferne reicht. Der sichtbare Bereich ist eine vierseitige Pyramide mit ihrem Gipfel an der Linse, ihren vier Seiten, die dem Umfang des peripheren Sichtbereichs entsprechen, und ihrer Basis am entferntesten Punkt, den sie sehen kann, wie hier:
Wenn wir dies verwenden würden, um die Polygone zu bestimmen, die jedes Bild gerendert werden müssen, müsste unser Renderer jedes Polygon innerhalb dieser Pyramide rendern, bis ins Unendliche, einschließlich auch der sehr nahe an der Linse — wahrscheinlich zu nah, um nützlich zu sein (und sicherlich inklusive Dinge, die so nahe sind, dass ein echter Mensch in derselben Einstellung nicht darauf fokussieren könnte).
Der erste Schritt zur Reduzierung der Anzahl von Polygonen, die wir berechnen und rendern müssen, besteht darin, diese Pyramide in das Sichtfrustum zu verwandeln. Die beiden Ebenen, die wir verwenden, um Vertices abzuschneiden und die Polygonanzahl zu reduzieren, sind die nahen Abwendungsebenen und die fernen Abwendungsebenen.
In WebGL werden die nahen und fernen Abwendungsebenen definiert, indem der Abstand von der Linse zum nächsten Punkt auf einer Ebene angegeben wird, die senkrecht zur Blickrichtung steht. Alles, was näher an der Linse ist als die nahe Abwendungsebene oder weiter von ihr entfernt als die ferne Abwendungsebene, wird entfernt. Dies ergibt das Sichtfrustum, das so aussieht:
Das Set von Objekten, das für jeden Frame gerendert werden soll, wird im Wesentlichen erstellt, indem man mit dem Set aller Objekte in der Szene beginnt. Dann werden alle Objekte, die vollständig außerhalb des Sichtfrustums liegen, aus dem Set entfernt. Als Nächstes werden Objekte abgeschnitten, die teilweise über das Sichtfrustum hinausgehen, indem alle Polygone, die vollständig außerhalb des Frustums liegen, abgeschnitten werden, sowie die Polygone, die über das Frustum hinausgehen, so abgeflacht werden, dass sie es nicht mehr verlassen.
Nachdem dies geschehen ist, haben wir das größte Set von Polygonen, das vollständig innerhalb des Sichtfrustums liegt. Diese Liste wird normalerweise weiter reduziert, indem Prozesse wie Back-Face-Abschattung (Entfernen von Polygonen, deren Rückseite zur Kamera gerichtet ist) und Ausblendung von Okkultationen mit verdeckte Flächenermittlung (Entfernen von Polygonen, die nicht gesehen werden können, weil sie vollständig durch Polygone blockiert sind, die näher an der Linse sind) angewendet werden.
Perspektivische Projektionsmatrix
Bis zu diesem Punkt haben wir unsere eigene 3D-Rendering-Umgebung Schritt für Schritt aufgebaut. Allerdings hat der aktuelle Code, so wie wir ihn aufgebaut haben, einige Probleme. Zum einen wird das Bild verzerrt, wann immer wir unser Fenster neu dimensionieren. Ein weiteres ist, dass unsere einfache Projektion nicht mit einem breiten Bereich von Werten für die Szenendaten umgeht. Die meisten Szenen arbeiten nicht im Clippingraum. Es wäre hilfreich, den relevanten Abstand zur Szene zu definieren, damit keine Präzision bei der Umwandlung der Zahlen verloren geht. Schließlich ist es sehr hilfreich, eine genaue Kontrolle darüber zu haben, welche Punkte im Clip-Raum platziert werden und welche nicht. In den vorherigen Beispielen werden die Ecken des Würfels gelegentlich abgeschnitten.
Die perspektivische Projektionsmatrix ist eine Art von Projektionsmatrix, die all diese Anforderungen erfüllt. Die Mathematik fängt auch an, etwas eindringlicher zu werden und wird in diesen Beispielen nicht vollständig erklärt. Kurz gesagt, es kombiniert die Division durch w (wie in den vorherigen Beispielen) mit einigen genialen Manipulationen basierend auf ähnlichen Dreiecken. Wenn Sie die vollständige Erklärung über die Mathematik dahinter lesen möchten, schauen Sie sich einige der folgenden Links an:
- OpenGL-Projektionsmatrix
- Perspektivische Projektion
- Versuch, die Mathematik hinter der perspektivischen Projektionsmatrix in WebGL zu verstehen
Ein wichtiger Punkt, der bei der perspektivischen Projektionsmatrix, die unten verwendet wird, zu beachten ist, dass sie die z-Achse umdreht. Im Clippingraum geht z+ vom Betrachter weg, während mit dieser Matrix z auf den Betrachter zugeht.
Der Grund, die z-Achse umzukehren, ist, dass das Clippingraum-Koordinatensystem ein linkshändiges Koordinatensystem ist (wobei die z-Achse vom Betrachter weg und in den Bildschirm zeigt), während die Konvention in Mathematik, Physik und 3D-Modellierung sowie für das View/Eye-Koordinatensystem in OpenGL darin besteht, ein rechtshändiges Koordinatensystem zu verwenden (z-Achse zeigt aus dem Bildschirm auf den Betrachter zu). Mehr dazu in den entsprechenden Wikipedia-Artikeln: Kartesisches Koordinatensystem, Rechte-Hand-Regel.
Werfen wir einen Blick auf eine perspective()-Funktion, die die perspektivische Projektionsmatrix berechnet.
function perspective(fieldOfViewInRadians, aspectRatio, near, far) {
const f = 1.0 / Math.tan(fieldOfViewInRadians / 2);
const rangeInv = 1 / (near - far);
// prettier-ignore
return [
f / aspectRatio, 0, 0, 0,
0, f, 0, 0,
0, 0, (near + far) * rangeInv, -1,
0, 0, near * far * rangeInv * 2, 0,
];
}
Die vier Parameter dieser Funktion sind:
fieldOfViewInRadians-
Ein Winkel, angegeben in Radiant, der angibt, wie viel von der Szene gleichzeitig für den Betrachter sichtbar ist. Je größer die Zahl ist, desto mehr ist durch die Kamera sichtbar. Die Geometrie an den Rändern wird mehr und mehr verzerrt, was einem Weitwinkelobjektiv entspricht. Wenn das Sichtfeld größer ist, werden die Objekte normalerweise kleiner. Wenn das Sichtfeld kleiner ist, kann die Kamera weniger und weniger in der Szene sehen. Die Objekte werden viel weniger durch Perspektive verzerrt und Objekte scheinen viel näher an die Kamera gerückt zu sein.
aspectRatio-
Das Seitenverhältnis der Szene, das gleich der Breite geteilt durch die Höhe ist. In diesen Beispielen ist das die Fensterbreite geteilt durch die Fensterhöhe. Die Einführung dieses Parameters löst schließlich das Problem, dass das Modell verzerrt wird, wenn die Kanvasgröße geändert und umgeformt wird.
nearClippingPlaneDistance-
Eine positive Zahl, die den Abstand in den Bildschirm zu einer Ebene, die senkrecht zum Boden steht, angibt, näher als die alles abgeschnitten wird. Dies wird auf -1 im Clippingraum gemappt und sollte nicht auf 0 gesetzt werden.
farClippingPlaneDistance-
Eine positive Zahl, die den Abstand zur Ebene angibt, jenseits der die Geometrie abgeschnitten wird. Dies wird auf 1 im Clippingraum gemappt. Dieser Wert sollte in einem vernünftigen Abstand zur Geometrie gehalten werden, um Präzisionsfehler beim Rendern zu vermeiden.
In der neuesten Version des Box-Demos wurde die computeSimpleProjectionMatrix()-Methode durch die computePerspectiveMatrix()-Methode ersetzt.
function computePerspectiveMatrix() {
const fieldOfViewInRadians = Math.PI * 0.5;
const aspectRatio = window.innerWidth / window.innerHeight;
const nearClippingPlaneDistance = 1;
const farClippingPlaneDistance = 50;
this.transforms.projection = perspective(
fieldOfViewInRadians,
aspectRatio,
nearClippingPlaneDistance,
farClippingPlaneDistance,
);
}
Der Shader-Code ist identisch mit dem des vorherigen Beispiels:
gl_Position = projection * model * vec4(position, 1.0);
Zusätzlich (nicht gezeigt) wurden die Positions- und Skalierungsmatrizen des Modells so geändert, dass es aus dem Clippingraum heraus und in das größere Koordinatensystem gebracht wird.
Die Ergebnisse
Übungen
- Experimentieren Sie mit den Parametern der perspektivischen Projektionsmatrix und der Modellmatrix.
- Ersetzen Sie die perspektivische Projektionsmatrix durch orthografische Projektion. In dem geteilten MDN-WebGL-Code finden Sie die
MDN.orthographicMatrix(). Diese kann dieMDN.perspectiveMatrix()-Funktion inCubeDemo.prototype.computePerspectiveMatrix()ersetzen.
View-Matrix
Während einige Grafikbibliotheken eine virtuelle Kamera haben, die positioniert und auf die Szene gerichtet werden kann, hat OpenGL (und dadurch WebGL) keine. Hier kommt die View-Matrix ins Spiel. Ihre Aufgabe ist es, die Objekte in der Szene zu übersetzen, zu drehen und zu skalieren, sodass sie sich im richtigen Verhältnis zum Betrachter befinden, basierend auf der Position und Orientierung des Betrachters.
Eine Kamera simulieren
Dies macht sich einen der grundlegenden Aspekte der speziellen Relativitätstheorie von Einstein zunutze: das Prinzip der Bezugssysteme und der relativen Bewegung sagt, dass man aus der Perspektive eines Betrachters das Ändern der Position und Orientierung des Betrachters simulieren kann, indem man die entgegengesetzte Änderung auf die Objekte in der Szene anwendet. In beiden Fällen erscheint das Ergebnis dem Betrachter identisch.
Betrachten Sie ein Kästchen, das auf einem Tisch steht, und eine Kamera, die auf dem Tisch einen Meter entfernt positioniert und auf das Kästchen gerichtet ist. Dann stellen Sie sich vor, die Kamera vom Kästchen weg zu bewegen, bis sie zwei Meter entfernt ist (indem Sie einen Meter zur Z-Position der Kamera hinzufügen), und dann sie 10 Zentimeter nach links zu schieben. Auf diese Weise entfernt sich das Kästchen von der Kamera um diesen Betrag und rutscht leicht nach rechts, wodurch es für die Kamera kleiner erscheint und ein kleiner Teil seiner linken Seite exponiert wird.
Setzen wir jetzt die Szene zurück und platzieren das Kästchen wieder an seinem Ausgangspunkt, wobei die Kamera zwei Meter entfernt und direkt auf das Kästchen gerichtet ist. Diesmal kann die Kamera jedoch nicht bewegt oder gedreht werden, da sie auf dem Tisch festgemacht ist. Das ist bei WebGL so. Wie simulieren wir also das Bewegen der Kamera durch den Raum?
Anstatt die Kamera rückwärts und nach links zu bewegen, wenden wir die umgekehrte Transformation auf das Kästchen an: Wir bewegen das Kästchen einen Meter rückwärts und dann 10 Zentimeter nach rechts. Das Ergebnis ist aus der Perspektive beider Objekte identisch.
Der letzte Schritt in all dem ist die Erstellung der View-Matrix, die die Objekte in der Szene so transformiert, dass sie positioniert sind, um die aktuelle Position und Orientierung der Kamera zu simulieren. Unser bisheriger Code kann den Würfel im Weltraum bewegen und alles projizieren, um Perspektive zu haben, aber wir können die Kamera noch immer nicht bewegen.
Stellen Sie sich vor, Sie drehen einen Film mit einer physischen Kamera. Sie haben die Freiheit, die Kamera grundsätzlich überall zu platzieren, und die Kamera in jede Richtung zu richten, die Sie wählen. Um dies in 3D-Grafiken zu simulieren, verwenden wir eine View-Matrix, um die Position und Drehung dieser physischen Kamera zu simulieren.
Im Gegensatz zur Modellmatrix, die die Modell-Vertices direkt transformiert, bewegt die View-Matrix eine abstrakte Kamera umher. In Wirklichkeit bewegt der Vertex-Shader immer noch nur die Modelle, während die "Kamera" an Ort und Stelle bleibt. Damit dies korrekt funktioniert, muss die umgekehrte Transformationsmatrix verwendet werden. Die umgekehrte Matrix kehrt im Wesentlichen eine Transformation um, sodass wenn wir die Kameraansicht nach vorne bewegen, die umgekehrte Matrix die Objekte in der Szene nach hinten bewegt.
Die folgende computeViewMatrix()-Methode animiert die View-Matrix, indem sie sich ein- und auswärts sowie links und rechts bewegt.
function computeViewMatrix(now) {
const moveInAndOut = 20 * Math.sin(now * 0.002);
const moveLeftAndRight = 15 * Math.sin(now * 0.0017);
// Move the camera around
const position = translate(moveLeftAndRight, 0, 50 + moveInAndOut);
// Multiply together, make sure and read them in opposite order
this.transforms.view = multiplyArrayOfMatrices([
// Exercise: rotate the camera view
position,
]);
}
Der Shader verwendet nun drei Matrizen.
gl_Position = projection * view * model * vec4(position, 1.0);
Nach diesem Schritt wird die GPU-Pipeline die aus dem Bereich liegenden Vertices ausschneiden und das Modell zum Fragment-Shader zum Rasterisieren senden.
Die Ergebnisse
Beziehung der Koordinatensysteme
An diesem Punkt wäre es vorteilhaft, einen Schritt zurückzutreten und die verschiedenen Koordinatensysteme, die wir verwenden, zu betrachten und zu kennzeichnen. Zuerst einmal werden die Vertices des Würfels in Modellraum definiert. Um das Modell in der Szene zu bewegen, müssen diese Vertices in den Weltraum konvertiert werden, indem die Modellmatrix angewendet wird.
Modellraum → Modellmatrix → Weltraum
Die Kamera hat noch nichts getan, und die Punkte müssen erneut verschoben werden. Derzeit befinden sie sich im Weltraum, aber sie müssen in den Altersraum verschoben werden (mit der Altersmatrix), um die Platzierung der Kamera darzustellen.
Weltraum → Altersmatrix → Altersraum
Schließlich muss eine Projektion hinzugefügt werden (in unserem Fall die perspektivische Projektionsmatrix), um die Weltkoordinaten in Clipperraum-Koordinaten zu verwandeln.
Altersraum → Projektionsmatrix → Clipperraum
Übung
- Bewegen Sie die Kamera in der Szene herum.
- Fügen Sie der View-Matrix einige Drehungsmatrizen hinzu, um sich umzuschauen.
- Schließlich verfolgen Sie die Position der Maus. Verwenden Sie 2 Drehungsmatrizen, um die Ansicht der Kamera nach oben und unten auf der Basis des Bildschirms positiv zu wenden.