3D trực quan WebGL

Gregg Tavares
Gregg Tavares

WebGL Orthographic 3D

Bài đăng này là phần tiếp theo của loạt bài đăng về WebGL. Bài đầu tiên bắt đầu bằng các kiến thức cơ bản và bài trước là về ma trận 2D về ma trận 2D. Nếu bạn chưa đọc các bài viết đó, vui lòng xem trước. Trong bài viết trước, chúng ta đã tìm hiểu cách hoạt động của ma trận 2D. Chúng ta đã nói về việc dịch, xoay, điều chỉnh theo tỷ lệ và thậm chí là chiếu từ pixel vào không gian cắt đều có thể được thực hiện bằng 1 ma trận và một số phép toán ma trận kỳ diệu. Từ đó, bạn chỉ cần thêm một bước nhỏ nữa là có thể làm được 3D. Trong các ví dụ 2D trước, chúng ta có các điểm 2D (x, y) được nhân với một ma trận 3x3. Để thực hiện 3D, chúng ta cần các điểm 3D (x, y, z) và một ma trận 4x4. Hãy lấy ví dụ cuối cùng và thay đổi thành 3D. Chúng ta sẽ sử dụng lại chữ F, nhưng lần này là chữ "F" 3D. Việc đầu tiên chúng ta cần làm là thay đổi chương trình đổ bóng đỉnh để xử lý 3D. Đây là chương trình đổ bóng cũ.

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

Và đây là phiên bản mới

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

Việc này còn đơn giản hơn nữa! Sau đó, chúng ta cần cung cấp dữ liệu 3D.

...

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);
}

Tiếp theo, chúng ta cần thay đổi tất cả các hàm ma trận từ 2D thành 3D. Dưới đây là các phiên bản 2D (trước) của makeTranslation, makeRotation và 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
];
}

Sau đây là các phiên bản 3D đã cập nhật.

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,
];
}

Lưu ý rằng chúng ta hiện có 3 hàm xoay. Chúng ta chỉ cần một trong 2D vì chúng ta chỉ xoay quanh trục Z một cách hiệu quả. Tuy nhiên, để thực hiện 3D, chúng ta cũng muốn có thể xoay quanh trục x và trục y. Bạn có thể thấy rằng khi nhìn vào các lớp này, chúng đều rất giống nhau. Nếu chúng ta giải quyết các vấn đề này, bạn sẽ thấy các vấn đề này được đơn giản hoá như trước

Xoay theo trục Z

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

Độ xoay Y


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

Độ xoay theo trục X

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

Chúng ta cũng cần cập nhật hàm chiếu. Đây là phiên bản cũ

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
];
}

được chuyển đổi từ pixel sang không gian cắt. Trong lần đầu tiên mở rộng ứng dụng này lên 3D, hãy thử

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,
];
}

Tương tự như việc chúng ta cần chuyển đổi từ pixel sang không gian cắt cho x và y, đối với z, chúng ta cần làm tương tự. Trong trường hợp này, tôi cũng tạo các đơn vị pixel không gian Z. Tôi sẽ truyền vào một số giá trị tương tự như width cho chiều sâu để không gian của chúng ta sẽ có chiều rộng từ 0 đến chiều rộng pixel, chiều cao từ 0 đến chiều cao pixel, nhưng đối với chiều sâu, chiều sâu sẽ là -depth / 2 đến +depth / 2. Cuối cùng, chúng ta cần cập nhật mã tính toán ma trận.

// 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);

Vấn đề đầu tiên chúng ta gặp phải là hình học của chúng ta là một F phẳng, khiến bạn khó nhìn thấy bất kỳ hình ảnh 3D nào. Để khắc phục vấn đề đó, hãy mở rộng hình học thành 3D. F hiện tại của chúng ta được tạo thành từ 3 hình chữ nhật, mỗi hình có 2 tam giác. Để tạo hình 3D, bạn sẽ cần tổng cộng 16 hình chữ nhật. Có khá nhiều tính năng để liệt kê ở đây. 16 hình chữ nhật x 2 tam giác trên mỗi hình chữ nhật x 3 đỉnh trên mỗi tam giác là 96 đỉnh. Nếu bạn muốn xem tất cả các lớp này, hãy xem nguồn trên mẫu. Chúng ta phải vẽ thêm các đỉnh để

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

Khi di chuyển thanh trượt, bạn sẽ khó nhận ra rằng đó là hình ảnh 3D. Hãy thử tô màu cho mỗi hình chữ nhật một màu khác nhau. Để làm việc này, chúng ta sẽ thêm một thuộc tính khác vào chương trình đổ bóng đỉnh và một biến để truyền thuộc tính đó từ chương trình đổ bóng đỉnh đến chương trình đổ bóng mảnh. Dưới đây là chương trình đổ bóng đỉnh mới

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

Và chúng ta cần sử dụng màu đó trong chương trình đổ bóng mảnh

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

Chúng ta cần tra cứu vị trí để cung cấp màu sắc, sau đó thiết lập một vùng đệm và thuộc tính khác để cung cấp màu sắc cho vị trí đó.

...
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);
}

Ồ, đó là gì vậy? Hóa ra, tất cả các phần của chữ cái "F" 3D đó, mặt trước, mặt sau, mặt bên, v.v. đều được vẽ theo thứ tự xuất hiện trong hình học của chúng ta. Điều đó không mang lại cho chúng ta kết quả như mong muốn vì đôi khi các đối tượng ở phía sau được vẽ sau các đối tượng ở phía trước. Tam giác trong WebGL có khái niệm về mặt trước và mặt sau. Một tam giác hướng mặt trước có các đỉnh theo chiều kim đồng hồ. Một tam giác quay mặt về phía sau có các đỉnh đi theo hướng ngược chiều kim đồng hồ.

Cuộn tam giác.

WebGL chỉ có thể vẽ các tam giác hướng về phía trước hoặc hướng về phía sau. Chúng ta có thể bật tính năng đó bằng

gl.enable(gl.CULL_FACE);

chúng ta chỉ thực hiện một lần, ngay khi bắt đầu chương trình. Khi bật tính năng đó, WebGL sẽ mặc định "loại bỏ" các tam giác mặt sau. "Loại bỏ" trong trường hợp này là một từ hoa mỹ để chỉ "không vẽ". Xin lưu ý rằng đối với WebGL, việc một tam giác có được coi là theo chiều kim đồng hồ hay ngược chiều kim đồng hồ hay không phụ thuộc vào các đỉnh của tam giác đó trong không gian cắt. Nói cách khác, WebGL sẽ xác định xem một tam giác là mặt trước hay mặt sau SAU KHI bạn áp dụng toán học cho các đỉnh trong chương trình đổ bóng đỉnh. Ví dụ: một tam giác theo chiều kim đồng hồ được điều chỉnh theo tỷ lệ X bằng -1 sẽ trở thành một tam giác ngược chiều kim đồng hồ hoặc một tam giác theo chiều kim đồng hồ được xoay 180 độ xung quanh trục X hoặc Y sẽ trở thành một tam giác ngược chiều kim đồng hồ. Vì đã tắt CULL_FACE nên chúng ta có thể thấy cả tam giác theo chiều kim đồng hồ(mặt trước) và ngược chiều kim đồng hồ(mặt sau). Bây giờ, chúng ta đã bật tính năng này, bất cứ khi nào một tam giác mặt trước lật xung quanh do việc điều chỉnh theo tỷ lệ hoặc xoay hoặc vì bất kỳ lý do gì, WebGL sẽ không vẽ tam giác đó. Đó là một điều tốt vì khi xoay một vật thể trong không gian 3D, bạn thường muốn mọi tam giác hướng về phía bạn được coi là hướng mặt trước.

Chào cậu! Tất cả các tam giác đã đi đâu? Hóa ra, nhiều trong số đó đang quay sai hướng. Xoay hình ảnh và bạn sẽ thấy các hình ảnh đó xuất hiện khi nhìn vào mặt bên kia. May mắn là bạn có thể dễ dàng khắc phục vấn đề này. Chúng ta chỉ cần xem xét những đỉnh nào bị đảo ngược và hoán đổi 2 đỉnh của các đỉnh đó. Ví dụ: nếu một tam giác ngược là

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

chúng ta chỉ cần lật 2 đỉnh cuối cùng để chuyển hướng.

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

Gần đúng hơn nhưng vẫn còn một vấn đề nữa. Ngay cả khi tất cả các tam giác đều hướng về đúng hướng và các tam giác hướng về phía sau bị loại bỏ, chúng ta vẫn có những vị trí mà tam giác ở phía sau được vẽ chồng lên tam giác ở phía trước. Nhập DEPTH BUFFER. Bộ đệm chiều sâu (Depth buffer), đôi khi được gọi là Z-Buffer, là một hình chữ nhật gồm các pixel depth, một pixel chiều sâu cho mỗi pixel màu dùng để tạo hình ảnh. Khi WebGL vẽ từng pixel màu, nó cũng có thể vẽ một pixel chiều sâu. Hàm này thực hiện việc này dựa trên các giá trị mà chúng ta trả về từ chương trình đổ bóng đỉnh cho Z. Giống như chúng ta phải chuyển đổi sang không gian cắt cho X và Y, nên Z nằm trong không gian cắt hoặc (-1 đến +1). Sau đó, giá trị đó được chuyển đổi thành giá trị không gian chiều sâu (từ 0 đến +1). Trước khi vẽ một pixel màu, WebGL sẽ kiểm tra pixel chiều sâu tương ứng. Nếu giá trị chiều sâu cho pixel sắp vẽ lớn hơn giá trị của pixel chiều sâu tương ứng, thì WebGL sẽ không vẽ pixel màu mới. Nếu không, chương trình sẽ vẽ cả điểm ảnh màu mới bằng màu từ chương trình đổ bóng mảnh VÀ vẽ điểm ảnh chiều sâu bằng giá trị chiều sâu mới. Điều này có nghĩa là các pixel nằm sau các pixel khác sẽ không được vẽ. Chúng ta có thể bật tính năng này gần như đơn giản như cách chúng ta bật tính năng loại bỏ bằng

gl.enable(gl.DEPTH_TEST);

Chúng ta cũng cần xoá vùng đệm độ sâu về 1.0 trước khi bắt đầu vẽ.

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

Trong bài đăng tiếp theo, tôi sẽ trình bày cách tạo phối cảnh cho hình ảnh.

Tại sao thuộc tính vec4 nhưng gl.vertexAttribPointer có kích thước 3

Những bạn chú trọng đến chi tiết có thể nhận thấy chúng ta đã xác định 2 thuộc tính là

attribute vec4 a_position;
attribute vec4 a_color;

cả hai đều là "vec4" nhưng khi chúng ta cho WebGL biết cách lấy dữ liệu ra khỏi vùng đệm, chúng ta đã sử dụng

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

"3" trong mỗi số đó cho biết chỉ cần lấy 3 giá trị cho mỗi thuộc tính. Cách này hoạt động vì trong chương trình đổ bóng đỉnh, WebGL cung cấp các giá trị mặc định cho những giá trị mà bạn không cung cấp. Giá trị mặc định là 0, 0, 0, 1 trong đó x = 0, y = 0, z = 0 và w = 1. Đó là lý do tại sao trong chương trình đổ bóng đỉnh 2D cũ, chúng ta phải cung cấp rõ ràng giá trị 1. Chúng ta đã truyền x và y và cần 1 cho z, nhưng vì giá trị mặc định cho z là 0 nên chúng ta phải cung cấp rõ ràng giá trị 1. Tuy nhiên, đối với 3D, mặc dù chúng ta không cung cấp "w", nhưng giá trị mặc định là 1, đây là giá trị chúng ta cần để toán học ma trận hoạt động.