출처: http://soen.kr/lecture/library/opengl/opengl-8.htm

24. 변환 – 개요

3차원 공간에 배치된 물체의 전체적인 변환 과정은 다음과 같다.

OpenGL은 변환 단계에서 수행되는 복잡한 연산들을 행렬로 처리한다. 모든 변환 함수의 연산 결과는 현재 행렬에 반영된다. 좀 더 정확하게는 현재 선택된 행렬 스택의 최상단 행렬인데 일단은 현재 행렬이라고 생각하면 된다.

다음 함수는 행렬 연산의 목적지를 지정한다.

void glMatrixMode(GLenum mode);

인수로 대상 행렬을 지정하며 이를 행렬 모드라고 한다. 이 함수로 지정한 행렬은 이후의 모든 행렬 연산의 대상이 된다. 물론 다른 행렬을 조작하고 싶을 때는 언제든지 행렬 모드를 바꿀 수 있다. 다음과 같은 행렬 모드가 있는데 주로 모델뷰 행렬이나 투영 행렬이 변환 대상이다.

  • GL_MODELVIEW: 모델 뷰 변환 행렬. 이 값이 디폴트이다.
  • GL_PROJECTION: 투영 행렬
  • GL_TEXTURE: 텍스처 행렬
  • GL_COLOR: 색상 행렬. (단 이 기능은 ARB_imaging 확장 기능이 지원되어야 한다.)

현재 행렬이 무엇인지를 알아내려면 glGet 함수로 GL_MATRIX_MODE를 전달한다.

void glLoadIdentity(void);

이 함수는 현재 행렬을 단위 행렬로 만든다. 단위 행렬은 우하향 대각선 방향만 1이고 나머지 요소는 모두 0인 행렬로서 임의의 행렬을 곱해도 원래 행렬이 계산되는 특수한 행렬이다. 곰셉의 1, 덧셈의 0과 같은 항등원으로서 연산을 해도 처음값이 유지된다. 현재 행렬을 단위 행렬로 만든다는 것은 행렬을 리셋한다는 뜻이며 이는 곧 어떠한 변환도 하지 않는다는 뜻이다.

지금까지 입체 확인을 위해 회전 기능을 사용했던 모든 예제를 보면 다음 두 행의 코드가 있다.

glMatrixMode(GL_MODELVIEW);
glLoadIdentity();

이 코드는 모델뷰 행렬을 리셋한다. DoDisplay를 이전에 실행했을 때 적용했던 회전값을 무시하고 다시 설정하기 위해 리셋을 해야 한다. 이 리셋 코드가 없으면 회전이 계속 누적 적용되어 원하는 대로 회전되지 않는다. 그림을 그리기 전에 화면을 지우는 것과 마찬가지로 행렬을 사용하기 전에 리셋을 먼저 해야 한다.

 

25. 관측 변환

관측(Viewing)이란 3차원 공간의 장면을 바라본다는 뜻이다. 마치 장면을 촬영하는 카메라를 이리 저리 옮기는 기법과 유사해서 카메라 변환이라고도 한다. 관측 지점은 다음 함수로 지정한다.

void gluLookAt(
   GLdouble eyeX, GLdouble eyeY, GLdouble eyeZ,
   GLdouble centerX, GLdouble centerY, GLdouble centerZ,
   GLdouble upX, GLdouble upY, GLdouble upZ
);

eye 좌표는 시선의 좌표 즉, 관찰자의 위치를 나타내는 좌표이다. center는 관찰자가 바라보고 있는 좌표이다. up은 위쪽을 가리키는 업 벡터를 나타낸다. 카메라로 장면을 촬영하고 있다면 카메라가 있는 곳이 eye 좌표이고 카메라가 초점으로 정한 부분이 center 좌표이며 카메라의 각도가 up 벡터이다. 규칙상 up 벡터는 시선과 평행해서는 안된다.

#include <windows.h>
#include <gl/glut.h>
#include <stdio.h>

void DoDisplay();
void DoKeyboard(unsigned char key, int x, int y);
GLfloat xAngle, yAngle, zAngle;
GLfloat ex, ey, ez;

int APIENTRY WinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance
       ,LPSTR lpszCmdParam,int nCmdShow)
{
     glutCreateWindow("OpenGL");
     glutDisplayFunc(DoDisplay);
     glutKeyboardFunc(DoKeyboard);
     glutMainLoop();
     return 0;
}

void DoKeyboard(unsigned char key, int x, int y)
{
     switch(key) {
     case 'a':yAngle += 2;break;
     case 'd':yAngle -= 2;break;
     case 'w':xAngle += 2;break;
     case 's':xAngle -= 2;break;
     case 'q':zAngle += 2;break;
     case 'e':zAngle -= 2;break;
     case 'z':xAngle = yAngle = zAngle = 0.0;break;

     case 'j':ex += 0.1;break;
     case 'l':ex -= 0.1;break;
     case 'i':ey -= 0.1;break;
     case 'k':ey += 0.1;break;
     case 'u':ez += 0.1;break;
     case 'o':ez -= 0.1;break;

     case 'm':ex = ey = ez = 0.0;break;
     }

     char info[128];
     sprintf(info, "ex=%.1f, ey=%.1f, ez=%.1f", ex, ey, ez);
     glutSetWindowTitle(info);
     glutPostRedisplay();

}

void DrawPyramid()
{
     // 아랫면 흰 바닥
     glColor3f(1,1,1);
     glBegin(GL_QUADS);
     glVertex2f(-0.5, 0.5);
     glVertex2f(0.5, 0.5);
     glVertex2f(0.5, -0.5);
     glVertex2f(-0.5, -0.5);
     glEnd();

     // 위쪽 빨간 변
     glBegin(GL_TRIANGLE_FAN);
     glColor3f(1,1,1);
     glVertex3f(0.0, 0.0, -0.8);
     glColor3f(1,0,0);
     glVertex2f(0.5, 0.5);
     glVertex2f(-0.5, 0.5);

     // 왼쪽 노란 변
     glColor3f(1,1,0);
     glVertex2f(-0.5, -0.5);

     // 아래쪽 초록 변
     glColor3f(0,1,0);
     glVertex2f(0.5, -0.5);

     // 오른쪽 파란 변
     glColor3f(0,0,1);
     glVertex2f(0.5, 0.5);
     glEnd();
}

void DoDisplay()
{
     glClear(GL_COLOR_BUFFER_BIT  | GL_DEPTH_BUFFER_BIT);
     glEnable(GL_DEPTH_TEST);

     glMatrixMode(GL_MODELVIEW);
     glLoadIdentity();
     glRotatef(xAngle, 1.0f, 0.0f, 0.0f);
     glRotatef(yAngle, 0.0f, 1.0f, 0.0f);
     glRotatef(zAngle, 0.0f, 0.0f, 1.0f);

     gluLookAt(
          ex, ey, ez,
          0.0, 0.0, -1.0,
          0.0, 1.0, 0.0
          );

     DrawPyramid();
     glFlush();

}

관측 지점은 ex, ey, ez로 하되 이 값은 키보드의 uj ik ol 키로 조정할 수 있도록 해 두었다. m키는 관측 지점을 (0,0,0)으로 리셋한다. 바라보는 곳은 (0,0,-1)로 하여 피라미드의 꼭대기보다 약간 더 위쪽으로 설정했고 업 벡터는 y 방향을 위쪽으로 하였다.

j키를 눌러 x축을 따라 오른쪽으로 관측 지점을 옮기면 물체는 왼쪽으로 이동한다. 시점이 고정된 상태에서 카메라를 이동시키면 각도가 틀어지므로 평행하게 이동하지 않고 약간 비스듬하게 이동할 것이다.

ex=0.3, ey=0.0, ez=0.0

마찬가지로 k키를 눌러 y 축을 따라 위쪽으로 관측 지점을 옮기면 물체는 아래쪽으로 이동한다.

ex=0.3, ey=0.3, ez=0.0

u, o 키로 z 좌표를 앞뒤로 이동해 보면 피라미드 꼭대기가 사라진다. 피라미드 안쪽으로 시점이 이동해 버리기 때문이다. 뒤쪽으로 너무 멀리 가도 피라미드가 사라지는데 이는 직교 투영의 가시 영역이 1 ~ -1사이로 지정되어 있기 때문이다. 이 범위를 넘어서면 가시 영역에서 사라져 버린다.

ex=0.3, ey=0.0, ez=0.5

 

26. 모델링 변환

모델링(Modeling) 변환은 3차원 공간에 배치된 물체를 변형한다. 이동, 확대/축소, 회전 등 여러 가지 변환이 있으며 두 가지 이상의 변환을 동시에 적용하기도 한다.

먼저 위치는 다음 함수로 이동시킨다.

void glTranslate[f,d](GLfloat x, GLfloat y, GLfloat z);

각 축으로 이동할 거리를 지정하며 이 거리가 물체를 구성하는 모든 정점에 더해진다. 결국 물체가 이 거리만큼 이동하는 것이다.

#include <windows.h>
#include <gl/glut.h>

void DoDisplay();
void DoMenu(int value);
int Action;

int APIENTRY WinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance
       ,LPSTR lpszCmdParam,int nCmdShow)
{
     glutCreateWindow("OpenGL");
     glutDisplayFunc(DoDisplay);
     glutCreateMenu(DoMenu);
     glutAddMenuEntry("변환 없음",0);
     glutAddMenuEntry("이동",1);
     glutAddMenuEntry("엉뚱한 위치에 나타나는 이동",2);
     glutAddMenuEntry("단위 행렬로 리셋",3);
     glutAddMenuEntry("스택에 저장 및 복구",4);
     glutAddMenuEntry("확대",5);
     glutAddMenuEntry("회전",6);
     glutAddMenuEntry("확대 후 이동",7);
     glutAddMenuEntry("이동 후 확대",8);
     glutAddMenuEntry("원점 기준 회전",9);
     glutAddMenuEntry("제자리 회전",10);
     glutAttachMenu(GLUT_RIGHT_BUTTON);
     glutMainLoop();
     return 0;
}

void DoMenu(int value)
{
     if (value < 100) {
          Action = value;
          glMatrixMode(GL_MODELVIEW);
          glLoadIdentity();
          glColor3f(1,1,1);
          glutPostRedisplay();
          return;
     }
}

void DoDisplay()
{
     // 변환 없음
     glClear(GL_COLOR_BUFFER_BIT);

     // 주전자 1개 그리기
     glutWireTeapot(0.2);

     // 오른쪽으로 0.6 이동 후 주전자 1개 그리기
     glTranslatef(0.6, 0.0, 0.0);
     glutWireTeapot(0.2);

     // 위쪽으로 0.6 이동 후 주전자 1개 그리기(?)
     glTranslatef(0.0, 0.6, 0.0);
     glutWireTeapot(0.2);

     glFlush();
     return;
}

(1) 주전자 1개 그리기

오른쪽으로 0.6만큼 이동시킨 후 똑같은 주전자를 하나 더 그렸다. 조금 이동한 위치에 그려질 것이다. 

(2) 오른쪽으로 0.6 이동 후 주전자 1개 그리기

이번에는 오른쪽뿐만 아니라 가운데 주전자의 위쪽에도 하나를 더 그려 보자. y 축으로 0.5만큼 이동한 곳에 하나 더 그리면 될 것 같다

(3) 위쪽으로 0.6 이동 후 주전자 1개 그리기(?)

이렇게 하면 될 것 같지만 엉뚱한 결과가 나타난다. y축으로만 0.5 이동시켰는데 x축으로도 0.5만큼 이동하여 오른쪽 위에 그려졌다.

이렇게 되는 이유는 변환이 누적적으로 적용되기 때문이다. 변환 함수는 현재 행렬에 변환 공식을 써 넣으며 현재 행렬은 이후의 모든 출력에 영향을 미친다. 행렬을 리셋하거나 특별히 다른 값으로 바꾸지 않는 한 현재 값이 계속 유지되고 매 호출시마다 행렬에 변환이 가해진다. 먼저 오른쪽으로 0.5 이동했고 다시 위로 0.5 이동했으므로 세번째 주전자는 오른쪽 위로 이동하는 것이다.

문제는 그 뿐만이 아니다. 행렬에 한번 누적된 것은 계속 유효하므로 다음번 그릴 때는 추가로 이동하게 된다. 위 예제를 실행해 놓은 상태에서 창의 폭을 조금씩 키워 보면 그때마다 그리기 함수가 다시 호출되고 매번 0.5만큼 누적적으로 이동한다. 그래서 주전자가 점점 오른쪽으로 이동하다가 결국은 뷰포트 밖으로 사라져 버린다. 이 예제뿐만 아니라 앞 두 예제도 동일한 부작용이 있다.

그리기를 할 때마다 앞서 수행한 변환을 취소해야 한다. 예를 들어 앞에서 오른쪽으로 이동했으면 왼쪽으로 이동하여 원점으로 다시 옮기면 된다. 그러나 앞서 어떤 변환을 했는데 일일이 알아 내기 어렵다. 그래서 아예 그리기를 할 때마다 현재 행렬 자체를 초기화하는 것이 편리하다. 단위행렬로 만들면 이전의 변환은 모두 리셋된다. 다음과 같이 수정하면 문제가 해결된다.

void DoDisplay()
{
     glClear(GL_COLOR_BUFFER_BIT);
     glMatrixMode(GL_MODELVIEW);

     glLoadIdentity(); // 초기화

     glutWireTeapot(0.2);

     glTranslatef(0.6, 0.0, 0.0);
     glutWireTeapot(0.2);

     glLoadIdentity(); // 초기화

     glTranslatef(0.0, 0.6, 0.0);
     glutWireTeapot(0.2);

     glFlush();
}

단위 행렬로 리셋하는 대신에 이전의 행렬을 스택에 저장하는 방법도 사용할 수 있다. 행렬을 스택에 저장하거나 복구할 때는 다음 함수를 호출한다.

void glPushMatrix(void);
void glPopMatrix(void);

행렬은 단순한 하나의 값이 아니라 스택에 여러 개가 저장되며 그 중 스택의 제일 위에 있는 행렬이 현재 행렬이다. 스택의 깊이는 행렬의 종류에 따라 다른데 모델뷰 행렬은 32개까지 저장할 수 있으며 투영 행렬은 2개만 저장할 수 있다. 다음과 같이 해도 결과는 같다.

void DoDisplay()
{
     glClear(GL_COLOR_BUFFER_BIT);
     glMatrixMode(GL_MODELVIEW);

     glPushMatrix(); // (1)

      glutWireTeapot(0.2);

      glPushMatrix();  // (2)
       glTranslatef(0.6, 0.0, 0.0);
       glutWireTeapot(0.2);
      glPopMatrix();  // (3)

      glTranslatef(0.0, 0.6, 0.0);
      glutWireTeapot(0.2);

     glPopMatrix(); // (4)
     glFlush();
}

그리기 전의 행렬을 일단 (1) 저장한다. 원점에 주전자를 그린 후 오른쪽으로 이동하기 전에 (2)한번 더 저장한다.  이 상태에서 오른쪽으로 이동한 곳에 주전자를 그리고 다시 (3)복구한다. 원점으로 제깍 돌아올 것이다. 이 상태에서 위로 이동한 후 그리면 원점 바로 위가 된다. 모든 그리기가 끝난 후 다시 (4)복구하면 DoDisplay를 호출하기 전의 상태로 돌아간다.

여러 곳에서 공유되는 전역 변수나 상태는 원칙적으로 바꾼 놈이 원래대로 돌려 놓는 것이 옳다. 행렬도 상태 머신에 저장되는 전역 값이므로 행렬을 바꾸는 측에서 원래값을 복원하는 것이 좋다. 이후 변환을 수행하는 코드에서는 원칙대로 행렬을 저장한 후 리턴하기 전에 복구할 것이다.

다음 함수는 물체를 확대한다.

void glScale[f, d](GLfloat x, GLfloat y, GLfloat z);

스케일값이 1보다 더 크면 확대되고 더 작으면 축소된다. 일정한 크기로 확대, 축소하고 싶다면 세 방향 모두 같은 비율을 주어야 한다. 각 방향별로 다른 배율을 주면 찌그러진 모양을 만들 수도 있다.

void DoDisplay()
{
     glClear(GL_COLOR_BUFFER_BIT);

     glMatrixMode(GL_MODELVIEW);

     glPushMatrix();
     glScalef(2.0, 3.0, 1.0);
     glutWireTeapot(0.2);
     glPopMatrix();

     glFlush();
}

수평으로 2배, 수직으로 3배 확대하였다. 

1보다 더 작은 값을 주면 축소되며 배율이 0이면 화면에서 사라진다. 음수를 주면 반대로 뒤집힌다. 다음은 glScalef(-2.0, 3.0, 1.0);로 확대하여 수평 방향으로 주전자를 뒤집은 것이다.

다음 함수는 회전시킨다.

void glRotate[f, d](GLfloat angle, GLfloat x, GLfloat y, GLfloat z);

angle은 회전시킬 각도이며 반시계 방향의 360분법 각도이다. x, y, z는 회전의 기준이 되는 벡터이다. 다음은 X,(또는 Y, Z) 축을 기준으로 45도 회전시킨 것이다.

void DoDisplay()
{
     glClear(GL_COLOR_BUFFER_BIT);
     glMatrixMode(GL_MODELVIEW);

     glPushMatrix();
     // x = 1.0, y = 1.0, z = 1.0
     glRotatef(45.0, x, y, z);
     glutWireTeapot(0.4);
     glPopMatrix();

     glFlush();
}

x축을 기준으로 한다는 것은 주전자의 수평 방향으로 막대기를 꽂아 놓고 이 막대기를 회전시키는 것과 같다. x축 기준이라고 해서 좌우로 회전하는 것이 아님을 주의하자. 가운데는 y축을 기준으로 회전한 것이다.

오른쪽은 Z축을 중심으로 45도 회전시켜 본 것이다. 반시계 방향으로 회전되므로 주전자의 코가 위쪽으로 올라간다. 물론 여러 축에 대해 동시에 회전을 적용하는 것도 가능하며 요리 조리 돌려 보면 물체의 모든 면을 골고루 관찰할 수 있다.

한번에 두가지 이상의 변환을 동시에 적용할 수도 있는데 이런 변환을 복합 변환이라고 한다. 이때 변환 순서에 따라 결과가 달라진다. 다음은 이동 함수와 확대 함수를 연거푸 호출한다.

void DoDisplay()
{
     glClear(GL_COLOR_BUFFER_BIT);
     glMatrixMode(GL_MODELVIEW);

     glPushMatrix();

     // 이동 후 확대
     glTranslatef(0.5, 0.5, 0.0);
     glScalef(1.5, 1.5, 1.0);

     glutWireTeapot(0.2);

     glPopMatrix();

     glFlush();
}

 

여러 개의 변환을 연이어 가하면 나중에 호출한 변환이 먼저 적용된다. 이 경우는 이동, 확대 함수를 이어서 호출했으므로 확대 후 이동된다. 순서를 바꾸어 이동 후 확대하면 이동 거리가 더 멀어진다.

이 현상은 연산의 우선 순위로 간단하게 설명된다. 이동은 덧셈이고 확대는 곱셈인데 곱셈의 우선 순위가 더 높기 때문에 순서에 따라 최종 결과가 달라지는 것이다. 더한 후에 곱할 것인가 곱한 후에 더할 것인가의 차이이다.

다음 코드는 오른쪽 위에 삼각형을 하나 그려 놓고 이 삼각형을 45도 회전시킬 것이다.

void DoDisplay()
{
     glClear(GL_COLOR_BUFFER_BIT);

     glMatrixMode(GL_MODELVIEW);
     glPushMatrix();
     {
         glBegin(GL_TRIANGLES);
         glVertex2f(0.5, 0.8);
         glVertex2f(0.2, 0.2);
         glVertex2f(0.8, 0.2);
         glEnd();

         glRotatef(45.0, 0.0, 0.0, 1.0);

         glColor3f(1,1,0);
         glBegin(GL_TRIANGLES);
         glVertex2f(0.5, 0.8);
         glVertex2f(0.2, 0.2);
         glVertex2f(0.8, 0.2);
         glEnd();
     }
     glPopMatrix();
     glFlush();
}

그러나 결과는 기대한 것과는 다르게 나타난다.

다르게 나온 그림

회전 함수들은 항상 원점을 기준으로 물체를 회전시킨다. 물체의 중심이 어디인지는 알지도 못하며 각 물체마다 중심이 다르므로 그렇게 할 수도 없다. 삼각형의 중심을 기준으로 회전시키려면 다음 세 단계를 거쳐야 한다.

glTranslatef(0.5, 0.5, 0.0);
glRotatef(45.0, 0.0, 0.0, 1.0);
glTranslatef(-0.5, -0.5, 0.0);

삼각형의 중심이 (0.5, 0.5)이므로 이 중심점을 평행이동시켜 원점으로 가져온다. 이 상태에서 축을 기준으로 회전시키고 다시 원래 위치로 이동시켜야 한다. 변환은 반대 순서로 적용되므로 음수 방향으로의 이동이 제일 나중이고 회전 후 반대 방향으로 재이동한다. 이 코드를 실행해 보면 노란색 삼각형이 흰 삼각형 위에서 45도 회전되어 있을 것이다.

원래 기대했던 그림

각 물체의 회전 중심이 달라도 이 방법대로 변환하면 원하는 중심점을 기준으로 회전할 수 있다. 회전 뿐만 확대도 확대의 중심점을 지정할 수 있는데 이때도 동일한 절차대로 중심점을 지정한다. 이 경우 변환 과정은 다음과 같이 수행된다.

여러 단계를 거쳐 느릴 것 같지만 이 단계들이 모두 행렬식 하나로 합쳐서 실행되므로 실행 속도상의 불이익은 없다.

 

27. 투영

3차원 좌표를 2차원으로 바꾸는 것을 투영(Projection)이라고 한다. 투영은 3차원 좌표를 대응되는 2차원 좌표로 전환한다.

투영은 보이는 범위를 제한하기도 한다. 3차원 공간에 그려진 물체라고 해서 모두 다 보여야 하는 것은 아니며 그 중 필요한 범위를 설정하여 일부만 표시한다. 보이는 영역을 잘라내는 것을 클리핑이라고 하며 클리핑 영역 안쪽의 보이는 범위를 가시 영역(View volume)이라고 한다.

개념적으로 투영은 유리창에 비친 모습을 그대로 그려내는 것이다. 어떤 3차원 장면 앞에 유리창을 대고 그 유리창에 보이는 모습을 그대로 그리는 것이 바로 투영이다. 유리창은 평면이고 이 평면의 면적은 제한적이므로 장면의 일부만 담을 수 있다.

투영과 클리핑은 화면의 크기에 영향을 받는다. 그래서 투영은 보통 화면의 크기가 바뀔 때 현재 화면 크기에 맞게 지정한다. 화면 크기가 바뀌는 시점에 특정한 처리를 하려면 다음 함수로 콜백을 등록한다.

void glutReshapeFunc(void (*func)(int width, int height));

콜백 함수의 인수로 작업영역의 폭과 높이가 전달된다. 윈도우의 크기가 아니라 작업영역 즉, 그림이 그려지는 영역의 크기이다. 이 영역의 비율로 종횡비를 계산하고 화면에 들어올 만큼만 클리핑해야 한다.

다음 예제는 투영 기능을 테스트한다. 여러 가지 투영 방식을 동일하게 비교하기 위해 직교 투영의 Near, Far를 디폴트와 달리 -1, 1로 지정했다. 직교 투영의 Near, Far 디폴트는 원래 1, -1이며 음수로 적용하므로 사용자측의 z좌표는 음수이다. 디폴트를 -1, 1로 조정하면 z축은 사용자쪽이 양수가 되므로 피라미드의 꼭대기를 0.8로 부호를 바꾸었다.

#include <windows.h>
#include <gl/glut.h>
#include <stdio.h>

void DoDisplay();
void DoReshape(GLsizei width, GLsizei height);
void DoKeyboard(unsigned char key, int x, int y);
void DoMenu(int value);

GLfloat xAngle, yAngle, zAngle;
GLfloat left=-1, right=1, bottom=-1, top=1, Near=-1, Far=1;
GLfloat fov = 45;

int Projection;
int Object;

GLsizei lastWidth, lastHeight;

int APIENTRY WinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance
       ,LPSTR lpszCmdParam,int nCmdShow)
{
     glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB | GLUT_DEPTH);
     glutCreateWindow("OpenGL");
     glutDisplayFunc(DoDisplay);
     glutReshapeFunc(DoReshape);
     glutKeyboardFunc(DoKeyboard);
     glutCreateMenu(DoMenu);
     glutAddMenuEntry("Orthographic",1);
     glutAddMenuEntry("Frustrum",2);
     glutAddMenuEntry("Perspective",3);
     glutAddMenuEntry("Pyramid",4);
     glutAddMenuEntry("Cylinder",5);
     glutAttachMenu(GLUT_RIGHT_BUTTON);
     glEnable(GL_DEPTH_TEST);
     glutMainLoop();
     return 0;
}

void DoKeyboard(unsigned char key, int x, int y)
{
     switch(key) {
     case 'a':yAngle += 2;break;
     case 'd':yAngle -= 2;break;
     case 'w':xAngle += 2;break;
     case 's':xAngle -= 2;break;
     case 'q':zAngle += 2;break;
     case 'e':zAngle -= 2;break;
     case 'z':xAngle = yAngle = zAngle = 0.0;break;

     case 'r':left += 0.1;break;
     case 'f':left -= 0.1;break;
     case 't':right += 0.1;break;
     case 'g':right -= 0.1;break;
     case 'y':bottom -= 0.1;break;
     case 'h':bottom += 0.1;break;
     case 'u':top -= 0.1;break;
     case 'j':top += 0.1;break;

     case 'i':Near -= 0.1;break;
     case 'k':Near += 0.1;break;
     case 'o':Far -= 0.1;break;
     case 'l':Far += 0.1;break;
     case 'p':fov -= 1;break;
     case ';':fov += 1;break;

     case 'v':left=-1, right=1, bottom=-1, top=1;
          if (Projection == 0) {
              Near=-1, Far=1;
          } else {
              Near=1, Far=10;
          }
          break;
     }

     char info[128];
     sprintf(info, "(%.0f,%.0f,%.0f)"
          "(%.1f,%.1f,%.1f,%.1f,%.1f,%.1f)",
          xAngle, yAngle, zAngle,
          left, right, bottom, top, Near, Far);
     glutSetWindowTitle(info);
     glutPostRedisplay();
}

void DoMenu(int value)
{
     switch(value) {
     case 1:
          Projection = 0;
          Near = -1;
          Far = 1;
          break;
     case 2:
          Projection = 1;
          Near = 1;
          Far = 10;
          break;
     case 3:
          Projection = 2;
          Near = 1;
          Far = 10;
          break;
     case 4:
          Object = 0;
          break;
     case 5:
          Object = 1;
          break;
     }
     glutPostRedisplay();
}

void DoReshape(GLsizei width, GLsizei height)
{
     glViewport(0,0,width,height);

     lastWidth = width;
     lastHeight = height;
}



void DrawPyramid()
{
     // 아랫면 흰 바닥
     glColor3f(1,1,1);
     glBegin(GL_QUADS);
     glVertex2f(-0.5, 0.5);
     glVertex2f(0.5, 0.5);
     glVertex2f(0.5, -0.5);
     glVertex2f(-0.5, -0.5);
     glEnd();

     // 위쪽 빨간 변
     glBegin(GL_TRIANGLE_FAN);
     glColor3f(1,1,1);
     glVertex3f(0.0, 0.0, 0.8);
     glColor3f(1,0,0);
     glVertex2f(0.5, 0.5);
     glVertex2f(-0.5, 0.5);

     // 왼쪽 노란 변
     glColor3f(1,1,0);
     glVertex2f(-0.5, -0.5);

     // 아래쪽 초록 변
     glColor3f(0,1,0);
     glVertex2f(0.5, -0.5);

     // 오른쪽 파란 변
     glColor3f(0,0,1);
     glVertex2f(0.5, 0.5);
     glEnd();
}

void DoDisplay()
{
     glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
     glShadeModel(GL_FLAT);

     glMatrixMode(GL_PROJECTION);
     glLoadIdentity();

     switch (Projection) {
     case 0:
          glOrtho(left, right, bottom, top, Near, Far);
          break;
     case 1:
          glFrustum(left, right, bottom, top,Near,Far);
          break;
     case 2:
         {
            GLfloat aspect = (GLfloat)lastWidth / (GLfloat)lastHeight;
            gluPerspective(fov, aspect, Near, Far);
            break;
         }

    }

     glMatrixMode(GL_MODELVIEW);
     glPushMatrix();

     // 원근투영일 때는 약간 더 뒤쪽에서 보아야 한다.
     if (Projection != 0) {
          glTranslatef(0,0,-2);
     }
     glRotatef(xAngle, 1.0f, 0.0f, 0.0f);
     glRotatef(yAngle, 0.0f, 1.0f, 0.0f);
     glRotatef(zAngle, 0.0f, 0.0f, 1.0f);

     if (Object == 0) {
          DrawPyramid();
     } else {
          GLUquadricObj *pQuad;
          pQuad = gluNewQuadric();
          gluQuadricDrawStyle(pQuad, GLU_LINE);
          glTranslatef(0.0, 0.0, -0.6);
          glColor3f(1,1,1);
          gluCylinder(pQuad, 0.3, 0.3, 1.2, 20, 20);
     }

     glPopMatrix();
     glFlush();
}

투영 방법은 직교 투영(Orthographic)원근 투영(Perspective) 두 가지가 있다. 어떤 방법을 사용하는가에 따라 3차원 장면이 2차원 평면에 사상되는 방법이 달라진다. 디폴트는 직교 투영이며 위 예제도 직교 투영을 디폴트로 선택한다. 직교 투영은 거리에 상관없이 물체의 크기를 계산한다. 멀리 있는 물체라도 크기가 같다면 투영된 결과도 동일한 크기를 가진다. 직교 투영은 다음 함수로 지정한다.

void glOrtho(GLdouble left, GLdouble right, GLdouble bottom, GLdouble top, GLdouble nearVal, GLdouble farVal);

좌우하상근원(LRBTNF) 순서대로 클리핑 영역을 설정한다. 이 영역이 평행하게 투영면에 비춰진다. 물체가 투영면에 평행하게 맺히므로 평행 투영이라고도 한다.

left, right는 수평으로 3차원 공간의 어느 부분을 자를 것인가를 지정한다. bottom, top은 수직으로 어느 부분을 자를 것인가를 지정한다. near, far는 가시 영역의 전방 끝과 후방 끝을 나타낸다. 시점 좌표계가 오른손 법칙을 따르기 때문에 절단면의 z 좌표는 이 값의 부호를 바꾸어야 한다.  near, far가 -1, 1로 되어 있으면 앞쪽이 1이고 뒤쪽이 -1이 된다.

이 예제는 디폴트와 동일한 클리핑 영역을 사용하되 near와 far의 부호만 바꾸었다. 대신 피라미드의 꼭대기를 양수로 줌으로써 사용자쪽을 바라보도록 했다. 그래서 지금까지의 예제와 마찬가지로 피라미드를 정면에서 바라본 모양으로 그려진다.

(0, 0, 0)(-1.0, 1.0, -1.0, 1.0, -1.0, 1.0)

이제 클리핑 영역을 조정해 보자. 키보드의 rf, tg, yh, uj, ik, ol 키로 [좌우하상근원]값을 실행중에 조정할 수 있도록 해 두었으며 v키는 클리핑 영역을 리셋한다. 클리핑 영역의 왼쪽이 -1이며 피라미드의 왼쪽 좌표가 -0.5이므로 중앙과 왼쪽변의 절반쯤에 피라미드의 왼쪽면이 놓인다. r키를 눌러 왼쪽면을 -0.5까지 점점 줄여 보자.

(0, 0, 0)(-0.5, 1.0, -1.0, 1.0, -1.0, 1.0)

클리핑 영역이 더 좁게 설정되므로 피라미드의 왼쪽면이 클리핑 영역의 왼쪽면으로 점점 이동한다. 화면 크기는 그대로인데 투영할 영역이 줄어들었으므로 물체가 커지는 것이다. 만약 left가 -0.5보다 더 큰 값이 되면, 예를 들어 -0.3이 된다면 피라미드의 왼쪽변은 클리핑 영역을 벗어나므로 화면에서 잘려 사라진다. 오른쪽 변과 위, 아래변의 클리핑 영역을 조정해도 피라미드의 위치가 같은 방식으로 조정될 것이다.

이번에는 near를 조정해 보자.(i, k 키) -near가 0.8이면 가시 영역의 전면이 피라미드의 꼭지점과 일치하므로 피라미드 전체가 보인다. 그러나 0.8보다 더 작아지면 꼭지점이 가시 영역을 벗어나므로 보이지 않는다. 피라미드의 꼭지점 보다 더 아래쪽에서 자르기 때문이다. 시점이 피라미드 안으로 들어가 버렸으므로 바닥의 흰면이 보인다.

다음은 각각 -near를 0.6과 0.3으로 조정한 것이다.

(0, 0, 0)(-1.0, 1.0, -1.0, 1.0, -0.6, 0.6)

 

(0, 0, 0)(-1.0, 1.0, -1.0, 1.0, -0.3, 1.0)

near와 far가 같아지면 가시영역의 부피가 0이 되므로 아무것도 보이지 않는다.

이번에는 far를 조정해 보자. (o, l키) -far를 0보다 더 큰 값으로 조정하면 피라미드의 뒤쪽이 잘리며 0.8보다 더 커지면 아무것도 보이지 않는다. 피라미드를 회전한 상태에서 near, far를 조정해 보면 비스듬하게 잘리기도 한다.

(0, 0, 0)(-1.0, 1.0, -1.0, 1.0, -0.8, -0.2)

 

(0, 0, 0)(-1.0, 1.0, -1.0, 1.0, -0.8, 0.6)

한편, 직교 투영은 평행하게 투영하므로 공간상의 크기가 같으면 거리에 상관없이 동일한 크기로 보인다. 피라미드를 (30, -120) 각도로 회전하여 밑면이 보이도록 해 보자.

밑면의 앞쪽변과 뒷쪽변, 윗변과 아랫변이 똑같은 길이로 보인다. 실세계에서는 앞쪽과 뒤쪽의 거리가 다르기 때문에 앞쪽변이 더 길게 보이고 뒷쪽변이 짧게 보여야 한다. 책을 비스듬하게 들고 옆에서 바라보면 멀리 있는 쪽과 가까이 있는 쪽의 길이가 다른 것이 정상이다. 하지만 직교 투영은 평행하게 투영하므로 공간상의 크기가 투영면에 그대로 반영되는 특징을 보인다.

반면 원근 투영은 거리에 따라 물체의 크기가 달라진다. 똑같은 크기라도 가까이 있는 물체는 조금 크게 그리고 멀리 있는 물체는 작게 그린다. 실제로 우리가 풍경을 바라보는대로 사실적으로 그려진다. 

원근 투영을 할 때는 다음 함수를 호출한다.

void glFrustum(GLdouble left, GLdouble right, GLdouble bottom, GLdouble top, GLdouble nearVal, GLdouble farVal);

인수의 순서는 직교 투영과 동일하되 near, far가 좌표가 아니라 시점에서의 거리를 지정하므로 둘 다 반드시 양수여야 한다. 이 함수는 사각뿔의 윗부분을 잘라낸 절두체(Frustum)로 가시 영역을 설정한다.

절두체는 직관성이 떨어진다. 그래서 다음 유틸리티 함수로 원근 투영을 설정하기도 한다. 시야각과 화면의 종횡비, near, far를 지정함으로써 절두체를 정의한다. 절두체와는 달리 시선이 정확하게 가시영역의 중심을 통과한다.

void gluPerspective(GLdouble fovy, GLdouble aspect, GLdouble zNear, GLdouble zFar);

팝업 메뉴에서 Frustum이나 Perspective를 선택하면 원근 투영으로 바뀌며 Near는 1, far는 10으로 정의된다. 원근 투영을 하면 원점에 있는 물체가 너무 가까와 가시 영역에 들어오지 못하므로 z축으로 -2만큼 더 뒤쪽으로 이동시켰다.

직교 투영에 비해 앞쪽에 보이는 면이 더 길어 보인다. 피라미드로 관찰하기 어렵다면 원통형으로 바꿔 보자. 팝업 메뉴에서 Cylinder를 선택하면 도형이 바뀔 것이다.

직교 투영을 하면 회전 각도에 상관없이 양 끝의 원 크기가 동일하다. 회전시켜 보면 어느 방향으로 돌고 있는지 잘 분간되지도 않는다. 원근 투영을 하면 가까이 있는 원이 더 크게 보여 훨씬 더 사실적이다. z키를 누르면 회전이 리셋되는데 직교 투영을 하면 양끝의 지름이 똑같아 그냥 원으로만 보이지만 원근 투영을 하면 앞쪽이 훨씬 더 크게 보여 원통형을 위에서 바라본 모양이 된다.

 

이 실험에서 보다시피 직교 투영보다는 원근 투영이 훨씬 더 사실적이고 우리가 실세계를 보는 것과 비슷하다. 그러나 직교 투영이 적합한 경우도 많은데 CAD 설계도면이나 지도, 아파트 평면도 등이 직교 투영으로 그려진 대표적인 예이다.

 

지도는 하늘에서 바라본 모양을 그린 것인데 실제로 하늘에서 땅쪽을 내려다 보면 저렇게 보이지 않는다. 시점 바로 아래의 도로는 폭이 넓어 보이고 멀리 있는 도로는 좁아 보이다가 결국은 소실점으로 사라지는 것이 정상이다. 하지만 지도는 직교 투영으로 그리는 것이 보통이므로 도로의 폭이 똑같아 보이는 것이다.

 

28. 뷰포트 변환

투영 변환 후에는 뷰포트 변환이 수행된다. 투영 변환은 클리핑 영역으로 장면의 어디를 출력할 것인가를 결정하는 것이고 뷰포트 변환은 클리핑 및 투영된 평면 이미지를 윈도우의 어디쯤에다 출력할 것인지를 지정한다. 뷰포트 변환은 다음 함수로 수행한다.

void glViewport (GLint x, GLint y, GLsizei width, GLsizei height);

(x, y)는 뷰포트의 왼쪽 아래 좌표이며 width, height는 폭과 높이이다. OpenGL에서는 윈도우의 좌표계도 좌상단이 아닌 좌하단이 원점이다. 디폴트 뷰포트 변환은 다음과 같다.

glViewport(0,0,width,height);

이 함수는 보통 윈도우의 크기가 변경되는 ReShape 콜백에서 호출한다. 좌하단은 원점이고 폭과 높이는 창의 크기와 일치하므로 디폴트대로 출력하면 윈도우 전체를 가득 채운다. 이 값을 조정하면 창의 일부만 채울 수도 있다.

#include <windows.h>
#include <gl/glut.h>

void DoDisplay();
void DoReshape(GLsizei width, GLsizei height);
void DoMenu(int value);

int Action = 1;
GLsizei lastWidth, lastHeight;

int APIENTRY WinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance
       ,LPSTR lpszCmdParam,int nCmdShow)
{
     glutCreateWindow("OpenGL");
     glutDisplayFunc(DoDisplay);
     glutReshapeFunc(DoReshape);
     glutCreateMenu(DoMenu);
     glutAddMenuEntry("전체 창 사용",1);
     glutAddMenuEntry("좌하단 사용",2);
     glutAddMenuEntry("우하단 사용",3);
     glutAddMenuEntry("절대 크기 사용",4);
     glutAttachMenu(GLUT_RIGHT_BUTTON);
     glutMainLoop();
     return 0;
}

void DoMenu(int value)
{
     Action = value;
     DoReshape(lastWidth, lastHeight);
     glutPostRedisplay();
}

void DoReshape(GLsizei width, GLsizei height)
{
     lastWidth = width;
     lastHeight = height;

     switch(Action) {
     case 1:
          // 전체 창 사용
          glViewport(0,0,width, height);
          break;
     case 2:
          // 좌하단 사용
          glViewport(0,0,width/2, height/2);
          break;
     case 3:
          // 우하단 사용
          glViewport(width/2,0,width/2, height/2);
          break;
     case 4:
          // 절대 크기 사용
          glViewport(30,30,200,200);
          break;
     }

}

void DoDisplay()
{
     glClear(GL_COLOR_BUFFER_BIT);

     glColor3f(0.0, 1.0, 0.0);
     glRectf(-1.0, 1.0, 1.0, -1.0);

     glBegin(GL_TRIANGLES);
     glColor3f(1.0, 0.0, 0.0);
     glVertex2f(0.0, 0.5);
     glVertex2f(-0.5, -0.5);
     glVertex2f(0.5, -0.5);

     glEnd();
     glFlush();
}

투영에 대해서는 별다른 지정을 하지 않았으므로 클리핑 영역은 모든 축으로 -1 ~ 1사이이다. DoDisplay에서 -1 ~ 1 영역을 가득 채우는 초록색 사각형을 그렸다. 이 사각형은 클리핑 영역을 표시한다. 그리고 안쪽에 빨간색 삼각형을 그렸다.

DoReshape에서는 Action에 따라 뷰포트 영역을 지정하며 Action은 팝업 메뉴로 선택한다. 디폴트대로 그리면 파란색 배경에 빨간색 삼각형이 그려질 것이다. 클리핑 영역이 뷰포트를 가득 채운다.

폭이나 높이를 절반으로 줄이면 뷰포트의 일부만 사용할 수도 있다. 좌하단만 사용할 때는 시작점을 원점에 고정하고 폭과 높이를 절반으로 줄이면 된다. 윈도우의 크기를 조정해도 뷰포트는 항상 동일한 위치에 있을 것이다. 시작 지점을 폭의 절반으로 지정하면 오른쪽 아래에 출력된다.

 

윈도우의 폭과 높이를 참조하지 않고 상수로 절대 크기를 지정할 수도 있다. 물론 원하는 위치에 원하는 크기대로 배치되지만 윈도우 크기에 무관하게 항상 일정한 크기를 가진다는 점에서 바람직하지 않다. 윈도우가 작아지면 장면의 일부가 보이지 않는다.

 

뷰포트 변환을 윈도우 크기에 종속적으로 지정하면 윈도우 크기 변화에 따라 장면이 찌그러지는 부작용이 있다. 다음은 윈도우를 옆으로 길게 늘인 모양이다

윈도우의 크기에 따라 그림이 작아지거나 커질 수는 있지만 모양이 찌그러지는 것은 일반적으로 바람직하지 않다. 다음 예제는 두 가지 방법으로 종횡비를 유지한다.

#include <windows.h>
#include <gl/glut.h>

void DoDisplay();
void DoReshape(GLsizei width, GLsizei height);

int APIENTRY WinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance
       ,LPSTR lpszCmdParam,int nCmdShow)
{
     glutCreateWindow("OpenGL");
     glutDisplayFunc(DoDisplay);
     glutReshapeFunc(DoReshape);
     glutMainLoop();
     return 0;
}

void DoReshape(GLsizei width, GLsizei height)
{
     /* 뷰포트 변환으로 종횡비 유지
     if (width > height) {
          glViewport((width - height)/2, 0, height, height);
     } else {
          glViewport(0, (height - width)/2, width, width);
     }
     //*/


     // 직교 투영 영역을 조정하여 종횡비 유지
     glViewport(0,0,width,height);

     if (height == 0) return;

     glMatrixMode(GL_PROJECTION);
     glLoadIdentity();

     GLfloat aspect = (GLfloat)width / (GLfloat)height;

     if (width > height) {
          glOrtho(-1.0 * aspect, 1.0 * aspect, -1.0, 1.0, 1.0, -1.0);
     } else {
          glOrtho(-1.0, 1.0, -1.0/aspect, 1.0/aspect, 1.0, -1.0);
     }
     glMatrixMode(GL_MODELVIEW);
     glLoadIdentity();

}

void DoDisplay()
{
     glClear(GL_COLOR_BUFFER_BIT);

     glColor3f(0.0, 1.0, 0.0);
     glRectf(-1.0, 1.0, 1.0, -1.0);

     glBegin(GL_POLYGON);
     glColor3f(1.0, 0.0, 0.0);
     glVertex2f(0.0, 0.5);
     glVertex2f(-0.5, -0.5);
     glVertex2f(0.5, -0.5);
     glEnd();
     glFlush();
}

첫 번째 방법은 뷰포트의 위치와 크기를 조정하는 것이다. 윈도우가 가로로 길쭉한 경우와 세로로 길쭉한 경우의 처리가 달라진다. 폭이 높이보다 더 큰 경우 양쪽 크기를 모두 높이로 맞추고 폭과 높이차의 절반만큼을 왼쪽에 더해 중앙으로 오게 만든다. 반대인 경우는 양쪽을 폭에 맞추고 아래쪽에 두 값차의 절반을 더한다. 길이가 짧은 쪽에 맞추되 긴쪽의 남는 여백만큼 중앙으로 이동시키는 것이다.

예를 들어 폭이 600이고 높이가 300이면 뷰포트 크기를 더 작은쪽인 300으로 맞추고 두 값의 차인 300의 절반만을 x에 더해 수평 중앙에 뷰포트를 배치하는 것이다. 수평 양쪽으로 150만큼 여백이 생기므로 그림은 정확하게 중앙에 배치되며 윈도우 크기에 상관없이 항상 일정한 종횡비를 유지한다.

 

두 번째 방법은 직교 투영시 클리핑 영역의 크기를 조정하는 것이다. 윈도우의 폭이 더 길다면 그 비율만큼 클리핑 영역의 x축 범위를 넓혀주면 된다. 예를 들어 폭이 높이보다 2배 더 길다면 수평 클리핑 영역을 -2 ~ 2 사이로 넓혀준다. 이렇게 되면 -1라는 좌표가 왼쪽변 끝이었다가 중간 지점이 되므로 왼쪽에 절반만큼의 여백이 생긴다.

높이가 더 길다면 이때는 종횡비가 1보다 작은 값이 되므로 수직 클리핑 영역에 이 값을 나누어야 수직 영역이 더 커진다. 어쨌든 폭, 높이의 비율 중 더 큰쪽의 클리핑 영역을 비율만큼 늘려줌으로써 그림 자체를 윈도우 종횡비에 맞추는 것이다.

문의 | 코멘트 또는 yoonbumtae@gmail.com


카테고리: Media Artetc.


0개의 댓글

답글 남기기

Avatar placeholder

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다