WebGL orthografische 3D-Ansicht

Gregg Tavares
Gregg Tavares

Orthografisches 3D-WebGL

Dieser Beitrag ist die Fortsetzung einer Reihe von Beiträgen zu WebGL. Im ersten Teil ging es um die Grundlagen und im vorherigen um 2D-Matrizen. Falls Sie diese noch nicht gelesen haben, sollten Sie das zuerst tun. Im letzten Beitrag haben wir uns mit 2D-Matrizen beschäftigt. Wir haben darüber gesprochen, dass Translation, Rotation, Skalierung und sogar die Projektion von Pixeln in den Clip-Space mit einer einzigen Matrix und etwas magischer Matrixmathematik durchgeführt werden können. Von da aus ist es nur ein kleiner Schritt zu 3D. In unseren vorherigen 2D-Beispielen hatten wir 2D-Punkte (x, y), die wir mit einer 3x3-Matrix multipliziert haben. Für 3D benötigen wir 3D-Punkte (x, y, z) und eine 4x4-Matrix. Nehmen wir unser letztes Beispiel und ändern es in 3D. Wir verwenden wieder ein „F“, aber diesmal ein 3D-„F“. Als Erstes müssen wir den Vertex-Shader so ändern, dass er 3D verarbeiten kann. Hier ist der alte Shader.

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform mat3 u_matrix;

void main() {
// Multiply the position by the matrix.
gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);
}
</script>

Und hier ist die neue Version

<script id="3d-vertex-shader" type="x-shader/x-vertex">
attribute vec4 a_position;

uniform mat4 u_matrix;

void main() {
// Multiply the position by the matrix.
gl_Position = u_matrix * a_position;
}
</script>

Es ist jetzt noch einfacher. Dann müssen wir 3D-Daten bereitstellen.

...

gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0);

...

// Fill the buffer with the values that define a letter 'F'.
function setGeometry(gl) {
gl.bufferData(
    gl.ARRAY_BUFFER,
    new Float32Array([
        // left column
        0,   0,  0,
        30,   0,  0,
        0, 150,  0,
        0, 150,  0,
        30,   0,  0,
        30, 150,  0,

        // top rung
        30,   0,  0,
        100,   0,  0,
        30,  30,  0,
        30,  30,  0,
        100,   0,  0,
        100,  30,  0,

        // middle rung
        30,  60,  0,
        67,  60,  0,
        30,  90,  0,
        30,  90,  0,
        67,  60,  0,
        67,  90,  0]),
    gl.STATIC_DRAW);
}

Als Nächstes müssen wir alle Matrixfunktionen von 2D in 3D ändern. Hier sind die 2D-Versionen (vorher) von makeTranslation, makeRotation und makeScale:

function makeTranslation(tx, ty) {
return [
1, 0, 0,
0, 1, 0,
tx, ty, 1
];
}

function makeRotation(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);
return [
c,-s, 0,
s, c, 0,
0, 0, 1
];
}

function makeScale(sx, sy) {
return [
sx, 0, 0,
0, sy, 0,
0, 0, 1
];
}

Und hier sind die aktualisierten 3D-Versionen.

function makeTranslation(tx, ty, tz) {
return [
    1,  0,  0,  0,
    0,  1,  0,  0,
    0,  0,  1,  0,
    tx, ty, tz, 1
];
}

function makeXRotation(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);

return [
1, 0, 0, 0,
0, c, s, 0,
0, -s, c, 0,
0, 0, 0, 1
];
};

function makeYRotation(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);

return [
c, 0, -s, 0,
0, 1, 0, 0,
s, 0, c, 0,
0, 0, 0, 1
];
};

function makeZRotation(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);
return [
    c, s, 0, 0,
-s, c, 0, 0,
    0, 0, 1, 0,
    0, 0, 0, 1,
];
}

function makeScale(sx, sy, sz) {
return [
sx, 0,  0,  0,
0, sy,  0,  0,
0,  0, sz,  0,
0,  0,  0,  1,
];
}

Wir haben jetzt drei Rotationsfunktionen. In 2D brauchten wir nur einen, da wir uns im Grunde nur um die Z-Achse gedreht haben. Für 3D-Inhalte müssen wir aber auch um die x- und y-Achse rotieren können. Sie sehen alle sehr ähnlich aus. Wenn wir sie ausrechnen würden, würden sie sich wie zuvor vereinfachen.

Z-Rotation

newX = x * c + y * s;
newY = x * -s + y * c;

Y-Rotation


newX = x * c + z * s;
newZ = x * -s + z * c;

X-Rotation

newY = y * c + z * s;
newZ = y * -s + z * c;

Wir müssen auch die Projektionsfunktion aktualisieren. Hier ist die alte Version

function make2DProjection(width, height) {
// Note: This matrix flips the Y axis so 0 is at the top.
return [
2 / width, 0, 0,
0, -2 / height, 0,
-1, 1, 1
];
}

die von Pixeln in den Clip-Space umgerechnet hat. Für unseren ersten Versuch, es auf 3D zu erweitern, versuchen wir es mit

function make2DProjection(width, height, depth) {
// Note: This matrix flips the Y axis so 0 is at the top.
return [
    2 / width, 0, 0, 0,
    0, -2 / height, 0, 0,
    0, 0, 2 / depth, 0,
-1, 1, 0, 1,
];
}

Genau wie wir Pixel für x und y in den Clipspace konvertieren mussten, müssen wir das auch für z tun. In diesem Fall verwende ich auch für den Z-Raum Pixel. Ich übergebe einen Wert ähnlich wie width für die Tiefe, sodass unser Raum 0 bis „width“ Pixel breit, 0 bis „height“ Pixel hoch, aber für die Tiefe -depth / 2 bis +depth / 2 ist. Schließlich müssen wir den Code aktualisieren, der die Matrix berechnet.

// Compute the matrices
var projectionMatrix =
    make2DProjection(canvas.width, canvas.height, canvas.width);
var translationMatrix =
    makeTranslation(translation[0], translation[1], translation[2]);
var rotationXMatrix = makeXRotation(rotation[0]);
var rotationYMatrix = makeYRotation(rotation[1]);
var rotationZMatrix = makeZRotation(rotation[2]);
var scaleMatrix = makeScale(scale[0], scale[1], scale[2]);

// Multiply the matrices.
var matrix = matrixMultiply(scaleMatrix, rotationZMatrix);
matrix = matrixMultiply(matrix, rotationYMatrix);
matrix = matrixMultiply(matrix, rotationXMatrix);
matrix = matrixMultiply(matrix, translationMatrix);
matrix = matrixMultiply(matrix, projectionMatrix);

// Set the matrix.
gl.uniformMatrix4fv(matrixLocation, false, matrix);

Das erste Problem ist, dass unsere Geometrie ein flaches F ist, was es schwierig macht, 3D zu erkennen. Um das zu beheben, erweitern wir die Geometrie auf 3D. Unser aktuelles F besteht aus 3 Rechtecken mit jeweils 2 Dreiecken. Für die 3D-Darstellung sind insgesamt 16 Rechtecke erforderlich. Das sind ganz schön viele. 16 Rechtecke × 2 Dreiecke pro Rechteck × 3 Eckpunkte pro Dreieck ergibt 96 Eckpunkte. Wenn Sie alle sehen möchten, rufen Sie die Quelle im Beispiel auf. Wir müssen mehr Eckpunkte zeichnen,

// Draw the geometry.
gl.drawArrays(gl.TRIANGLES, 0, 16 * 6);

Wenn man die Schieberegler bewegt, ist es ziemlich schwer zu erkennen, dass es sich um 3D handelt. Versuchen wir, jedes Rechteck in einer anderen Farbe einzufärben. Dazu fügen wir dem Vertex-Shader ein weiteres Attribut und eine Variable hinzu, um es vom Vertex-Shader an den Fragment-Shader zu übergeben. Hier ist der neue Vertex-Shader:

<script id="3d-vertex-shader" type="x-shader/x-vertex">
attribute vec4 a_position;
attribute vec4 a_color;

uniform mat4 u_matrix;

varying vec4 v_color;

void main() {
// Multiply the position by the matrix.
gl_Position = u_matrix * a_position;

// Pass the color to the fragment shader.
v_color = a_color;
}
</script>

Und wir müssen diese Farbe im Fragment-Shader verwenden,

<script id="3d-vertex-shader" type="x-shader/x-fragment">
precision mediump float;

// Passed in from the vertex shader.
varying vec4 v_color;

void main() {
gl_FragColor = v_color;
}
</script>

Wir müssen die Position nachschlagen, um die Farben bereitzustellen, und dann einen weiteren Puffer und ein weiteres Attribut einrichten, um die Farben zuzuweisen.

...
var colorLocation = gl.getAttribLocation(program, "a_color");

...
// Create a buffer for colors.
var buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(colorLocation);

// We'll supply RGB as bytes.
gl.vertexAttribPointer(colorLocation, 3, gl.UNSIGNED_BYTE, true, 0, 0);

// Set Colors.
setColors(gl);

...
// Fill the buffer with colors for the 'F'.

function setColors(gl) {
gl.bufferData(
    gl.ARRAY_BUFFER,
    new Uint8Array([
        // left column front
    200,  70, 120,
    200,  70, 120,
    200,  70, 120,
    200,  70, 120,
    200,  70, 120,
    200,  70, 120,

        // top rung front
    200,  70, 120,
    200,  70, 120,
    ...
    ...
    gl.STATIC_DRAW);
}

Oh, was ist das für ein Durcheinander? Es stellt sich heraus, dass alle verschiedenen Teile des 3D-F, also Vorderseite, Rückseite, Seiten usw., in der Reihenfolge gezeichnet werden, in der sie in unserer Geometrie erscheinen. Das führt nicht ganz zu den gewünschten Ergebnissen, da manchmal die Elemente im Hintergrund nach denen im Vordergrund gezeichnet werden. Dreiecke in WebGL haben das Konzept von Vorder- und Rückseite. Bei einem nach vorne gerichteten Dreieck verlaufen die Eckpunkte im Uhrzeigersinn. Bei einem nach hinten gerichteten Dreieck verlaufen die Eckpunkte gegen den Uhrzeigersinn.

Dreieckswicklung.

WebGL kann nur nach vorn oder nach hinten gerichtete Dreiecke zeichnen. Wir können diese Funktion mit

gl.enable(gl.CULL_FACE);

Das machen wir nur einmal, ganz am Anfang unseres Programms. Wenn diese Funktion aktiviert ist, werden in WebGL standardmäßig nach hinten gerichtete Dreiecke entfernt. „Culling“ ist in diesem Fall ein schickes Wort für „nicht zeichnen“. Beachten Sie, dass es bei WebGL davon abhängt, ob ein Dreieck im Uhrzeigersinn oder gegen den Uhrzeigersinn verläuft, und zwar von den Eckpunkten dieses Dreiecks im Clipspace. Mit anderen Worten: WebGL ermittelt, ob ein Dreieck vorne oder hinten liegt, NACHDEM Sie die Mathematik auf die Eckpunkte im Vertex-Shader angewendet haben. Das bedeutet beispielsweise, dass ein im Uhrzeigersinn ausgerichtetes Dreieck, das auf der X-Achse um -1 skaliert wird, zu einem gegen den Uhrzeigersinn ausgerichteten Dreieck wird. Ein im Uhrzeigersinn ausgerichtetes Dreieck, das um 180 Grad um die X- oder Y-Achse gedreht wird, wird zu einem gegen den Uhrzeigersinn ausgerichteten Dreieck. Da CULL_FACE deaktiviert war, sehen wir sowohl Dreiecke im Uhrzeigersinn(Vorderseite) als auch gegen den Uhrzeigersinn(Rückseite). Nachdem wir es aktiviert haben, wird jedes Mal, wenn ein nach vorn gerichtetes Dreieck aufgrund von Skalierung, Drehung oder aus einem anderen Grund umgedreht wird, dieses Dreieck von WebGL nicht gezeichnet. Das ist gut, denn wenn Sie ein Objekt in 3D drehen, möchten Sie im Allgemeinen, dass die Dreiecke, die Ihnen zugewandt sind, als Vorderseite betrachtet werden.

Hey! Wo sind die ganzen Dreiecke hin? Es stellt sich heraus, dass viele von ihnen in die falsche Richtung zeigen. Wenn Sie es drehen, sehen Sie sie auf der anderen Seite. Zum Glück lässt sich das Problem leicht beheben. Wir schauen uns einfach an, welche rückwärts sind, und tauschen zwei ihrer Eckpunkte aus. Wenn beispielsweise ein rückwärtsgerichtetes Dreieck

1,   2,   3,
40,  50,  60,
700, 800, 900,

wir drehen einfach die letzten beiden Eckpunkte um, damit es vorwärts geht.

1,   2,   3,
700, 800, 900,
40,  50,  60,

Das ist schon besser, aber es gibt noch ein Problem. Selbst wenn alle Dreiecke in die richtige Richtung zeigen und die nach hinten gerichteten Dreiecke entfernt werden, gibt es immer noch Stellen, an denen Dreiecke, die sich im Hintergrund befinden sollten, über Dreiecke gezeichnet werden, die sich im Vordergrund befinden sollten. Geben Sie den DEPTH BUFFER ein. Ein Tiefenpuffer, manchmal auch Z-Puffer genannt, ist ein Rechteck mit depth Pixeln, wobei für jedes Farbpixel, das zum Erstellen des Bildes verwendet wird, ein Tiefenpixel vorhanden ist. Wenn WebGL ein Farbpixel zeichnet, kann es auch ein Tiefenpixel zeichnen. Dazu werden die Werte verwendet, die vom Vertex-Shader für Z zurückgegeben werden. Genau wie wir X und Y in den Clip-Space konvertieren mussten, ist auch Z im Clip-Space oder (-1 bis +1). Dieser Wert wird dann in einen Tiefenraumwert (0 bis +1) umgewandelt. Bevor WebGL ein Farbpixel zeichnet, wird das entsprechende Tiefenpixel geprüft. Wenn der Tiefenwert für das zu zeichnende Pixel größer ist als der Wert des entsprechenden Tiefenpixels, zeichnet WebGL das neue Farbpixel nicht. Andernfalls werden sowohl das neue Farbpixel mit der Farbe aus dem Fragment-Shader als auch das Tiefenpixel mit dem neuen Tiefenwert gezeichnet. Das bedeutet, dass Pixel, die sich hinter anderen Pixeln befinden, nicht gezeichnet werden. Wir können diese Funktion fast genauso einfach aktivieren wie das Culling mit

gl.enable(gl.DEPTH_TEST);

Außerdem müssen wir den Tiefenpuffer auf 1,0 zurücksetzen, bevor wir mit dem Zeichnen beginnen.

// Draw the scene.
function drawScene() {
// Clear the canvas AND the depth buffer.
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
...

Im nächsten Beitrag zeige ich, wie Sie dem Bild Perspektive verleihen.

Warum ist das Attribut vec4, aber die Größe von gl.vertexAttribPointer 3?

Wenn Sie auf Details achten, ist Ihnen vielleicht aufgefallen, dass wir die beiden Attribute als

attribute vec4 a_position;
attribute vec4 a_color;

Beide sind „vec4“, aber als wir WebGL mitgeteilt haben, wie Daten aus unseren Puffern entnommen werden sollen, haben wir

gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0);
gl.vertexAttribPointer(colorLocation, 3, gl.UNSIGNED_BYTE, true, 0, 0);

Die „3“ in jedem dieser Werte gibt an, dass nur 3 Werte pro Attribut abgerufen werden sollen. Das funktioniert, weil WebGL im Vertex-Shader Standardwerte für die Werte bereitstellt, die Sie nicht angeben. Die Standardwerte sind 0, 0, 0, 1, wobei x = 0, y = 0, z = 0 und w = 1 ist. Deshalb mussten wir in unserem alten 2D-Vertex-Shader die 1 explizit angeben. Wir haben x und y übergeben und brauchten eine 1 für z. Da der Standardwert für z jedoch 0 ist, mussten wir explizit eine 1 angeben. Für 3D wird, auch wenn wir kein „w“ angeben, standardmäßig „1“ verwendet. Das ist genau das, was wir für die Matrixberechnung benötigen.