WebGL orthografische 3D-Ansicht

Gregg Tavares
Gregg Tavares

WebGL-orthographische 3D-Darstellung

Dieser Beitrag ist Teil einer Reihe von Beiträgen zu WebGL. Im ersten ging es um die Grundlagen und im vorherigen um 2D-Matrizen. Falls Sie diese noch nicht gelesen haben, sollten Sie dies zuerst nachholen. Im letzten Beitrag haben wir uns mit der Funktionsweise von 2D-Matrizen befasst. Wir haben darüber gesprochen, dass Verschiebung, Drehung, Skalierung und sogar Projektion von Pixeln in den Clipbereich mit einer Matrix und einigen magischen Matrix-Mathematiken möglich sind. Und von dort ist es nur ein kleiner Schritt zu 3D. In unseren vorherigen 2D-Beispielen hatten wir 2D-Punkte (x, y), die wir mit einer 3 × 3-Matrix multipliziert haben. Für 3D-Objekte benötigen wir 3D-Punkte (x, y, z) und eine 4 × 4-Matrix. Nehmen wir unser letztes Beispiel und ändern es in 3D um. Wir verwenden wieder ein F, dieses Mal aber ein dreidimensionales F. Als Erstes müssen wir den Vertex-Shader so ändern, dass er 3D unterstützt. 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 das neue

<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 angeben.

...

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 Drehungsfunktionen. In 2D war nur eine erforderlich, da wir uns nur um die Z‑Achse drehten. Für 3D-Objekte möchten wir aber auch die Möglichkeit haben, um die x- und y-Achse zu drehen. Sie sehen, dass sie alle sehr ähnlich sind. Wenn wir sie ausarbeiten würden, würden sie sich genauso vereinfachen wie zuvor.

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;

Außerdem müssen wir die Projektionsfunktion aktualisieren. Hier ist die alte

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 Clipbereich umgewandelt wurden. Für unseren ersten Versuch, das Bild in 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 für x und y von Pixeln in Clipspace konvertieren mussten, müssen wir das auch für z tun. In diesem Fall mache ich auch die Z‑Raum-Pixeleinheiten. Ich gebe für die Tiefe einen Wert ähnlich wie width ein. Unser Bereich ist dann 0 bis width Pixel breit, 0 bis height Pixel hoch, aber für die Tiefe -depth / 2 bis +depth / 2. 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 sehen. Um das zu beheben, erweitern wir die Geometrie in 3D. Unser aktuelles F besteht aus 3 Rechtecken mit jeweils 2 Dreiecken. Für die 3D-Version benötigen Sie insgesamt 16 Rechtecke. Das sind ziemlich viele, um sie hier aufzulisten. 16 Rechtecke × 2 Dreiecke pro Rechteck × 3 Ecken pro Dreieck ergibt 96 Ecken. Wenn Sie sich alle ansehen möchten, öffnen Sie die Quellansicht des Beispiels. Wir müssen mehr Eckpunkte zeichnen, damit

// 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 zu färben. Dazu fügen wir unserem Vertex-Shader ein weiteres Attribut und ein Varying hinzu, um es vom Vertex-Shader an den Fragment-Shader weiterzugeben. 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 den Standort ermitteln, um die Farben bereitzustellen, und dann einen weiteren Puffer und ein Attribut einrichten, um ihm 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 dieses 3D-„F“, also Vorder-, Rück- und Seitenansicht 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 Objekte im Hintergrund nach denen im Vordergrund gezeichnet werden. Dreiecke in WebGL haben die Konzepte „nach vorne gerichtet“ und „nach hinten gerichtet“. Bei einem nach vorne gerichteten Dreieck verlaufen die Eckpunkte im Uhrzeigersinn. Bei einem nach hinten gerichteten Dreieck verlaufen die Eckpunkte gegen den Uhrzeigersinn.

Dreieckwicklung.

Mit WebGL können nur nach vorne oder nach hinten gerichtete Dreiecke gezeichnet werden. Wir können diese Funktion mit

gl.enable(gl.CULL_FACE);

Das tun wir nur einmal, gleich zu Beginn unseres Programms. Wenn diese Funktion aktiviert ist, werden in WebGL standardmäßig nach hinten gerichtete Dreiecke entfernt. „Culling“ ist in diesem Fall ein komplizierter Begriff für „nicht zeichnen“. Hinweis: In WebGL hängt es von den Eckpunkten eines Dreiecks im Clipspace ab, ob es im Uhrzeigersinn oder gegen den Uhrzeigersinn gezeichnet wird. Mit anderen Worten: WebGL ermittelt, ob sich ein Dreieck vor oder hinter dem Betrachter befindet, nachdem Sie die mathematischen Berechnungen auf die Vertexe im Vertex-Shader angewendet haben. Das bedeutet, dass beispielsweise ein Dreieck im Uhrzeigersinn, das in X um −1 skaliert ist, ein Dreieck gegen den Uhrzeigersinn wird. Ein Dreieck im Uhrzeigersinn, das um 180 Grad um die X- oder Y-Achse gedreht wird, wird zu einem Dreieck gegen den Uhrzeigersinn. Da wir CULL_FACE deaktiviert haben, sehen wir sowohl Dreiecke im Uhrzeigersinn(vorne) als auch gegen den Uhrzeigersinn(hinten). Nachdem wir diese Option aktiviert haben, wird ein nach vorne gerichtetes Dreieck nicht mehr von WebGL gerendert, wenn es sich aufgrund von Skalierung, Drehung oder aus einem anderen Grund umdreht. Das ist gut, da Sie beim Drehen eines Objekts in 3D in der Regel möchten, dass die Dreiecke, die Ihnen zugewandt sind, als Vorderseite betrachtet werden.

Hey! Wo sind alle Dreiecke hin? Es stellte sich heraus, dass viele von ihnen in die falsche Richtung zeigen. Drehen Sie das Gerät und Sie sehen sie, wenn Sie auf die andere Seite schauen. Zum Glück lässt sich das Problem ganz einfach beheben. Wir sehen uns einfach an, welche rückwärts sind, und tauschen zwei ihrer Eckpunkte aus. Wenn beispielsweise ein umgekehrtes Dreieck

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

Wir drehen einfach die letzten beiden Eckpunkte, um die Richtung zu ändern.

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

Das ist schon näher dran, aber es gibt noch ein Problem. Auch 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 im Hintergrund sein sollten, über Dreiecke gezeichnet werden, die im Vordergrund sein sollten. Geben Sie den DEPTH BUFFER ein. Ein Tiefen-Buffer, manchmal auch Z-Buffer genannt, ist ein Rechteck mit depth Pixeln, ein Tiefen-Pixel für jedes Farbpixel, das zum Erstellen des Bilds verwendet wird. Wenn WebGL jedes Farbpixel zeichnet, kann es auch ein Tiefenpixel zeichnen. Dies geschieht anhand der Werte, die wir aus dem Vertex-Shader für Z zurückgeben. Genauso wie wir für X und Y in den Clipbereich konvertieren mussten, muss auch Z in den Clipbereich konvertiert werden, also in den Bereich (–1 bis +1). Dieser Wert wird dann in einen Wert im Tiefenraum (0 bis +1) umgewandelt. Bevor WebGL ein Farbpixel zeichnet, wird das entsprechende Tiefenpixel geprüft. Wenn der Tiefenwert für das Pixel, das gerade gezeichnet werden soll, größer als der Wert des entsprechenden Tiefenpixels ist, wird das neue Farbpixel von WebGL nicht gezeichnet. Andernfalls werden sowohl das neue Farbpixel mit der Farbe aus Ihrem Fragment-Shader als auch das Tiefenpixel mit dem neuen Tiefenwert gezeichnet. Das bedeutet, dass Pixel, die sich hinter anderen Pixeln befinden, nicht dargestellt werden. Diese Funktion lässt sich fast genauso einfach aktivieren wie die Funktion zum Entfernen von Bildern 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 erkläre ich, wie Sie dem Bild Perspektive verleihen.

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

Detailverliebte haben vielleicht bemerkt, dass wir unsere beiden Attribute so definiert haben:

attribute vec4 a_position;
attribute vec4 a_color;

Beide sind „vec4“, aber wenn wir WebGL anweisen, wie Daten aus unseren Buffers geholt werden sollen, verwenden 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 Fälle gibt an, dass nur drei Werte pro Attribut abgerufen werden sollen. Das funktioniert, weil WebGL im Vertex-Shader Standardwerte für die nicht angegebenen Werte bereitstellt. Die Standardwerte sind 0, 0, 0, 1, wobei x = 0, y = 0, z = 0 und w = 1. Deshalb mussten wir in unserem alten 2D-Vertex-Shader die 1 explizit angeben. Wir haben x und y übergeben und für z eine 1 benötigt. Da der Standardwert für z 0 ist, mussten wir explizit eine 1 angeben. Für 3D wird jedoch standardmäßig „1“ für „w“ verwendet, auch wenn wir keinen Wert für „w“ angeben. Das ist für die Matrixmathematik erforderlich.