이 글은 WebGL 시리즈에서 이어지는 글입니다. 첫 글은 WebGL2 기초이며 이 전 글은 3D 직교 투영입니다. 아직 위 글들을 읽지 않았다면 먼저 읽어 보시기 바랍니다.
지난 번 글에서 3D를 그리는 방법을 알아 보았으나, 원근이 없는 3D였습니다. 원근이 없는 3D는 "직교 투영"을 활용해서 그린 것이었는데 직교 투영이 활용되는 경우도 있으나 일반적으로 우리가 원하는 "3D"는 아닙니다.
이를 위해서는 원근감을 추가해야 합니다. 원근감이란 무엇일까요? 원근감은 간단하게 말하자면 멀리 있는 것이 작게 보인다는 특징입니다.
위 예제를 보면 멀리 있는 것이 작게 그려진 것을 볼 수 있습니다. 현재 예제를 가지고 멀리 있는 물체를 작게 보이게 하는 가장 간단한 방법은 클립 공간의 X와 Y좌표를 Z값으로 나누는 것이 될 것입니다.
이렇게 생각해 보십시오. (10, 15)과 (20, 15)를 잇는 직선이 있다고 하면 길이는 10입니다. 현재 예제에서 이 직선은 10픽셀 길이로 그려질 것입니다. 하지만 우리가 Z값으로 이것을 나눈다고 가정해 봅시다. Z가 1이라면,
10 / 1 = 10 20 / 1 = 20 abs(10-20) = 10
10픽셀 길이일 것입니다. 만일 Z값이 2라면,
10 / 2 = 5 20 / 2 = 10 abs(5 - 10) = 5
5픽셀 길이일 것입니다. Z=3 이라면,
10 / 3 = 3.333 20 / 3 = 6.666 abs(3.333 - 6.666) = 3.333
보시다시피 Z값이 커질수록, 즉 멀리 떨어져 있을수록 더 작게 그려지는 것입니다. Z값의 크기가 작기 때문에(-1에서 1 사이) 클립 공간에서 Z값을 나눈다면 더 좋은 결과가 나타날 것입니다. fudgeFactor를 두어 나누기 전에 Z값에 곱한다면 주어진 거리의 물체가 얼마나 작게 그려질지를 조절할 수 있을겁니다.
한 번 해 봅시다. 먼저 버텍스 쉐이더를 수정하는데, Z값에 "fudgeFactor"를 그 값으로 나누도록 수정합시다.
...
+uniform float u_fudgeFactor;
...
void main() {
// position과 행렬을 곱함.
* vec4 position = u_matrix * a_position;
// 나누어질 z 값을 조정
+ float zToDivideBy = 1.0 + position.z * u_fudgeFactor;
// x와 y를 z로 나눔
* gl_Position = vec4(position.xy / zToDivideBy, position.zw);
}
클립 공간의 Z값이 -1과 1사이 값이기 때문에 zToDivideBy
값이 0과 +2 * fudgeFactor의 값을 가질 수 있도록 1을 더한 것에 주의하십시오.
또한 fudgeFactor값을 설정할 수 있도록 코드를 수정해야 합니다.
...
+ var fudgeLocation = gl.getUniformLocation(program, "u_fudgeFactor");
...
+ var fudgeFactor = 1;
...
function drawScene() {
...
+ // fudgeFactor를 설정
+ gl.uniform1f(fudgeLocation, fudgeFactor);
// geometry 그리기
gl.drawArrays(gl.TRIANGLES, 0, 16 * 6);
아래는 그 결과입니다.
잘 모르겠다면 "fudgeFactor" 슬라이더를 1.0에서 0.0으로 드래그해서 우리가 Z값으로 나누도록 코드를 수정하기 전과 동일하게 보이도록 바꿔 보십시오.
사실 WebGL은 우리가 gl_Position
에 입력한 x,y,z,w값을 받아서 w로 나누어주는 작업을 자동으로 수행하고 있습니다.
우리가 직접 나누기를 수행하지 않고, zToDivideBy
값을 gl_Position.w
에 넣도록 수정하여 위 사실을 손쉽게 증명할 수 있습니다.
...
uniform float u_fudgeFactor;
...
void main() {
// position과 행렬을 곱함
vec4 position = u_matrix * a_position;
// 나누어질 z값을 조정
float zToDivideBy = 1.0 + position.z * u_fudgeFactor;
// x,y,z를 zToDivideBy로 나눔
gl_Position = vec4(position.xyz, zToDivideBy);
}
결과가 완전히 동일한 것을 볼 수 있습니다.
WebGL이 W로 자동으로 나누어 주는 것이 어째서 유용할까요? 왜냐하면 이제부터는 행렬 계산의 마법을 이용해서 z값을 w에 넣어주는 또 다른 행렬을 사용할 것이기 때문입니다.
아래와 같은 행렬은,
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0,
z값을 w 위치에 복사할 것입니다. 각 열(column)을 보면 아래와 같고,
x_out = x_in * 1 + y_in * 0 + z_in * 0 + w_in * 0 ; y_out = x_in * 0 + y_in * 1 + z_in * 0 + w_in * 0 ; z_out = x_in * 0 + y_in * 0 + z_in * 1 + w_in * 0 ; w_out = x_in * 0 + y_in * 0 + z_in * 1 + w_in * 0 ;
이를 간략화 하면,
x_out = x_in; y_out = y_in; z_out = z_in; w_out = z_in;
전과 같이 1을 추가적으로 더하도록 할 것인데, w_in
이 항상 1.0이라는 것을 알고 있기 때문입니다.
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1,
위 행렬에 의해 W 계산은 아래와 같이 바뀝니다.
w_out = x_in * 0 + y_in * 0 + z_in * 1 + w_in * 1 ;
w_in
= 1.0 이기 때문에 결과적으로,
w_out = z_in + 1;
마지막으로 다시 fudgeFactor를 추가해 줍니다.
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, fudgeFactor, 0, 0, 0, 1,
다시 말해서,
w_out = x_in * 0 + y_in * 0 + z_in * fudgeFactor + w_in * 1 ;
이를 간략화 하면,
w_out = z_in * fudgeFactor + 1;
이제, 다시 행렬만을 사용하도록 프로그램을 수정해 봅시다.
먼저 다시 버텍스 쉐이더를 되돌립니다. 아래와 같이 간단한 형태입니다.
uniform mat4 u_matrix;
void main() {
// position과 행렬을 곱함
gl_Position = u_matrix * a_position;
...
}
다음으로, 우리의 Z → W 행렬을 만들기 위한 함수를 추가합니다.
function makeZToWMatrix(fudgeFactor) {
return [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, fudgeFactor,
0, 0, 0, 1,
];
}
그리고 이를 사용하도록 코드를 수정합니다.
...
// 행렬 계산
+ var matrix = makeZToWMatrix(fudgeFactor);
* matrix = m4.multiply(matrix, m4.projection(gl.canvas.clientWidth, gl.canvas.clientHeight, 400));
matrix = m4.translate(matrix, translation[0], translation[1], translation[2]);
matrix = m4.xRotate(matrix, rotation[0]);
matrix = m4.yRotate(matrix, rotation[1]);
matrix = m4.zRotate(matrix, rotation[2]);
matrix = m4.scale(matrix, scale[0], scale[1], scale[2]);
...
또다시 결과는 정확히 동일하다는 것에 주목하십시오.
위의 과정은 모두 여러분들에게 Z로 나누는 것이 원근 효과를 가져다 준다는 것과 WebGL이 우리를 대신해 편리하게 Z값으로 나누어 준다는 것을 알려드리기 위한 것이었습니다.
하지만 여전히 몇 가지 문제가 있습니다. 예를 들어 Z를 -100 정도로 설정하면 아래 애니메이션과 같은 결과를 볼 수 있습니다.
어떻게 된 걸까요? 왜 F가 사라져버리는 걸까요? X와 Y를 -1에서 1 사이로 자르는(clip) 것처럼, Z값도 자르기 때문입니다. 위에서 보이는 것은 Z < -1인 경우입니다.
이를 해결하기 위한 수식을 자세히 설명할 수도 있지만, 2D 투영에서와 동일한 방법으로 유도할 수 있습니다. Z값을 가지고 얼마만큼 더하고, 얼마만큼 조정해서 우리가 원하는 어떤 범위를 -1과 +1 사이에 오도록 재조정 할 수 있습니다.
멋진 점은 이러한 모든 과정을 단 하나의 행렬로 수행할 수 있다는 것입니다.
더 좋은 점은 fudgeFactor
대신에 fieldOfView
를 기반으로 이러한 과정을 적절하게 수행할 수 있는 값을 결정할 수 있다는 것입니다.
아래는 행렬 생성을 위한 함수입니다.
var m4 = {
perspective: function(fieldOfViewInRadians, aspect, near, far) {
var f = Math.tan(Math.PI * 0.5 - 0.5 * fieldOfViewInRadians);
var rangeInv = 1.0 / (near - far);
return [
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (near + far) * rangeInv, -1,
0, 0, near * far * rangeInv * 2, 0
];
},
...
이 행렬이 우리가 원하는 모든 변환 과정을 수행해 줍니다. 단위를 조정해서 클립 공간 내에 값이 오도록 하고,
시야각이 각도 단위로 주어졌을 때 필요한 계산을 수행하며, Z-clipping 공간을 지정할 수 있도록 합니다.
이 행렬은 eye 또는 camera가 원점인 (0, 0, 0)에 있다고 가정합니다. zNear
와 fieldOfView
가 주어지면 zNear
에 있는것이 Z = -1
에 오도록 하고, fieldOfView
의 절반 만큼 위쪽이나 아래쪽에 있는 것이 Y = -1
과 Y = 1
에 오도록 변환합니다.
X에 대해서는 입력 인자인 aspect
(종횡비)를 활용하여 계산합니다. 일반적으로 그 값은 디스플레이 영역의 width / height
를 사용합니다.
마지막으로, zFar에 있는 것이 Z = 1
에 오도록 합니다.
아래는 행렬의 효과를 보여주는 그림입니다.
그 모양은 4개의 면을 가진 뿔처럼 보이며 육면체가 회전하고 있는 그 내부 공간을 "절두체(Frustum)"라 부릅니다.
행렬은 절두체 내부 공간을 클립 공간으로 변환합니다. zNear
은 앞쪽 절단 공간을, zFar
은 뒤쪽 절단 공간을 정의합니다.
zNear
를 23으로 설정하면 회전하고 있는 육면체의 앞쪽이 잘리는 것을 볼 수 있습니다.
zFar
을 24로 설정하면 육면체의 뒤쪽이 잘리는 것을 볼 수 있습니다.
이제 하나의 문제만 남았습니다. 이 행렬은 관찰자가 0,0,0 위치에 있고, 그 관찰자가 음의 Z 방향을 바라보고 있으며 양의 Y방향이 위쪽이라고 가정합니다. 지금까지 사용한 행렬은 이런 방식이 아니었습니다.
물체를 보기 위해서는 물체를 절두체 안으로 옮겨야 합니다. F를 옮겨봅시다. 지금까지는 (45, 150, 0) 위치에 그리고 있었습니다. (-150, 0, -360) 위치로 옮기고 올바른 방향으로 보이도록 회전을 시키겠습니다.
이제, 이전의 코드를 m4.projection에서 m4.perspective로 바꾸어 호출합니다.
var aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
var zNear = 1;
var zFar = 2000;
var matrix = m4.perspective(fieldOfViewRadians, aspect, zNear, zFar);
matrix = m4.translate(matrix, translation[0], translation[1], translation[2]);
matrix = m4.xRotate(matrix, rotation[0]);
matrix = m4.yRotate(matrix, rotation[1]);
matrix = m4.zRotate(matrix, rotation[2]);
matrix = m4.scale(matrix, scale[0], scale[1], scale[2]);
결과는 아래와 같습니다.
다시 행렬의 곱만을 사용한 형태로 돌아왔으며, 시야각과 Z 공간을 정의할 수 있게 되었습니다. 아직 끝난 것은 아니지만 글이 너무 길어지고 있습니다. 다음으로 카메라를 보도록 합시다.