[Android] 3D API, OpenGL ES – 2 : 폴리곤 렌더링

이전 글을 통해 안드로이드에서 OpenGL ES에 대한 초기화에 대해 살펴보았습니다. 이 글은 간단한 폴리곤을 화면상에 렌더링해 보는 API에 대해 살펴보겠습니다. 간단한 폴리곤에 대한 렌더링을 위해 먼저 폴리곤을 구성하는 데이터에 대한 개념을 살펴보도록 하겠습니다. (OpenGL ES를 보시기 전에 먼저 OpenGL을 선행 학습하시면 훨씬 쉽게 이글을 이해할 수 있습니다.)

정점(Vertex) : 정점은 3D 모델을 구성하는 최소 단위입니다. 정점은 2개 이상의 모서리(Edge)가 만나는 점입니다. 3D 모델에서 정점은 모든 연결된 모서리나 면(Face) 그리고 폴리곤 사이에 공유됩니다. 또한 정점은 카메라나 광원의 위치를 나타내는데도 사용됩니다.

모서리(Edge) : 모서리는 두개의 정점을 잇는 선분입니다. 모서리는 면이나 폴리곤의 외곽선입니다. 3D 모델에서 모서리는 2개의 인접한 면이나 폴리곤 사이에 공유됩니다. OpenGL ES에서는 선분을 정의한다라는 개념보다는 정점을 이용해 면을 정의한다고 합니다. 면은 최소 3개의 모서리로 구성됩니다.

면(Face) : 면은 삼각형입니다. 면은 3개의 정점으로 구성되며 3개의 모서리로 둘러 싸여졌습니다. 면의 구성을 변환하면 모든 연결된 정점과 모서리 그리고 폴리곤에 영향을 받습니다. 면을 구성하는 정점의 지정 순서에 따라 면의 앞면과 뒷면에 대한 정의가 달라집니다. 앞면과 뒷면이 중요한 이유는 퍼포먼스에 있습니다. 뒷면은 눈에 들어나지 않으므로 렌더링에서 제외될 수 있기 때문입니다. 정점의 지정 순서에 대해 앞면이냐 뒷면이냐를 정의할 수 있는데 glFrontFace 매서드를 통해 가능합니다. 정점의 지정순서는 삼각형에 대해 반시계 방향 순서(CCW)냐 시계 방향 순서(CW)냐입니다. OpenGL ES는 기본적으로 반시계 방향이 앞면을 의미합니다.

폴리곤(Polygon) : 폴리곤은 하나의 3D 모델이라고 생각하면 됩니다. 폴리곤을 구성하기 위해서는 정점들의 데이터와 이 정점들 중 3개를 이용해 면을 구성하기 위한 정점 인덱스 정보를 통해 이뤄집니다. 폴리곤을 화면에 렌더링하는 매서드는 glDrawArrays와 glDrawElements입니다. 이 두 함수의 첫번째 인자는 정점과 인덱스를 통해 구성할 모델의 형태입니다. 다음과 같이 총 7가지 모드가 존재합니다.

1. GL_POINTS : 정점을 개별적인 포인트로 렌더링한다.

사용자 삽입 이미지
2. GL_LINE_STRIP : 정점을 지정된 순서대로 이어 라인을 구성해 렌더링한다.사용자 삽입 이미지
3. GL_LINE_LOOP : GL_LINE_STRIP와 동일하고 첫번째와 마지막 정점을 잇는다.사용자 삽입 이미지
4. GL_LINES : 정점 2개씩 연속적으로 묶어 개별적인 선분 하나씩 구성해 렌더링한다.사용자 삽입 이미지
5. GL_TRIANGLES : 정점 3개씩 연속적으로 묶어 개별적인 삼각형을 구성해 렌더링한다.사용자 삽입 이미지
6. GL_TRIANGLE_STRIP : 처음은 제공되는 3개의 정점으로 삼각형을 구성하고 다음 정점 하나를 통해 또 다른 삼각형을 구성한다. 구성되는 삼각형들은 모두 동일한 면 방향을 갖도록 구성하게 된다.사용자 삽입 이미지
7. GL_TRIANGLE_FAN : GL_TRIANGLES_STRIP와 비슷하지만 처음 제공되는 정점을 중심으로 팬(Fan) 형태로 삼각형을 구성해 렌더링한다.사용자 삽입 이미지
이제 이러한 기본 지식을 토대로 OpenGL ES를 통해 안드로이드에서 간단한 사각형 폴리곤 모델을 렌더링 해보는 코드를 작성해 보겠습니다. 기반이 되는 코드는 이전 글의 초기화에서 작성했던 프로젝트를 기반으로 합니다. 변경될 부분은 MyRenderer 클래스와 Square라는 새로운 클래스 추가입니다. 먼저 Square이라는 새로운 클래스를 추가합니다.

public class Square { }

이 클래스는 앞에서 언급했던 사각형 폴리곤 모델에 대한 정점과 정점 인덱스 정보를 담고 있으며 렌더링하는 코드가 존재합니다. 구성할 사각형 폴리곤의 정점 좌표와 인덱스 정보는 다음과 같습니다.

사용자 삽입 이미지
즉.. 총 4개의 정점으로 존재하며 총 2개의 삼각형 면으로 구성되어 있습니다. 2개의 삼각형면을 구성하기 위해 사용할 정점에 대한 인덱스가 필요할텐데.. 이에 대한 정보를 클래스에 코드로 추가합니다.

public class Square {
    private float vertices[] = {
        -1.0f,  1.0f, 0.0f,
        -1.0f, -1.0f, 0.0f,
        1.0f, -1.0f, 0.0f,
        1.0f,  1.0f, 0.0f,
    };
 
    private short[] indices = { 0, 1, 2, 0, 2, 3 };
}

vertices가 정점에 대한 정보를 담고 있으며 indices가 삼각형 면을 구성할 정점에 대한 인덱스 정보를 담고 있습니다. 이제 Square의 생성자에서 이 정보를 OpenGL ES에서 사용할 수 있도록 Buffer로 저장하는 코드를 작성합니다.

 private FloatBuffer vertexBuffer;
 private ShortBuffer indexBuffer;

public Square() {
    ByteBuffer vbb = ByteBuffer.allocateDirect(vertices.length * 4);
    vbb.order(ByteOrder.nativeOrder());
    vertexBuffer = vbb.asFloatBuffer();
    vertexBuffer.put(vertices);
    vertexBuffer.position(0);

    ByteBuffer ibb = ByteBuffer.allocateDirect(indices.length * 2);
    ibb.order(ByteOrder.nativeOrder());
    indexBuffer = ibb.asShortBuffer();
    indexBuffer.put(indices);
    indexBuffer.position(0);
}

자바의 NIO를 이용해 배열을 ByteBuffer에 담아 정점에 대한 버퍼는 FloatBuffer 타입의 vertexBuffer로 정점 인덱스에 대한 버퍼는 ShortBuffer 타입의 indexBuffer에 담아 놓습니다. 이제 3D 모델에 대한 정의는 끝났고.. 이 모델을 렌더링해주는 코드를 Square에 추가해 보면 다음과 같습니다.

public void draw(GL10 gl) {
    gl.glFrontFace(GL10.GL_CCW);
    gl.glEnable(GL10.GL_CULL_FACE);
    gl.glCullFace(GL10.GL_BACK);
  
    gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
    gl.glVertexPointer(3, GL10.GL_FLOAT, 0, vertexBuffer);
    gl.glDrawElements(GL10.GL_TRIANGLES, indices.length, 
        GL10.GL_UNSIGNED_SHORT, indexBuffer);
    gl.glDisableClientState(GL10.GL_VERTEX_ARRAY);
    gl.glDisable(GL10.GL_CULL_FACE);
 }

먼저 2번 코드는 앞면은 정점 지정 순서가 반시계 방향으로 지정한 것이고 3번 코드와  4번 코드를 통해 뒷면은 렌더링되지 않도록 합니다. 6번 코드는 정점 배열을 통해 모델을 렌더링하겠다고 지정한 코드이며 7번 코드는 모델의 정점 배열을 지정해 주는 코드입니다. 8번은 실제로 렌더링 시키라는 코드로 이 함수에 정점 인덱스 배열이 인자로 들어갑니다. 9번 코드는 정점 배열과 정점 인덱스 배열의 지정이 끝났음을 알립니다. 이제 이렇게 만들어진 Square 클래스를 사용하는 일이 남았습니다.

MyRenderer 클래스에 Square 클래스에 대한 필드 변수를 추가하고 onSurfaceCreated 매서드에서 생성합니다.

public class MyRenderer implements Renderer {
    private Square square;

    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
        square = new Square();

        gl.glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
        gl.glShadeModel(GL10.GL_SMOOTH);
        gl.glClearDepthf(1.0f);
        gl.glEnable(GL10.GL_DEPTH_TEST);
        gl.glDepthFunc(GL10.GL_LEQUAL);
        gl.glHint(GL10.GL_PERSPECTIVE_CORRECTION_HINT, GL10.GL_NICEST);
    }    
}

onSurfaceCreated 매서드를 보면 Square 변수를 생성하는 코드 이외에도 OpenGL ES의 다양한 값들을 지정해 주는 코드가 존재합니다. 다음으로 MyRenderer 클래스의 onDrawFrame 매서드에서 square를 그리는 코드를 작성합니다.

@Override
public void onDrawFrame(GL10 gl) {
    gl.glClear(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT);

    gl.glLoadIdentity();
    gl.glTranslatef(0, 0, -10);
    square.draw(gl);
}

화면상에 사각형 모델이 보이도록 5번 코드에서 Z축으로 -10만큼 이동하였습니다. 이에 앞서 투영 행렬과 모델뷰 행렬을 지정해 줘야 하는데.. 이 코드는 MyRenderer의 onSurfaceChanged 매서드에서 실행됩니다.

@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
    gl.glViewport(0, 0, width, height);
  
    gl.glMatrixMode(GL10.GL_PROJECTION);
    gl.glLoadIdentity();
  
    GLU.gluPerspective(gl, 45.0f, (float)width/(float)height, 0.1f, 100.0f);
    gl.glMatrixMode(GL10.GL_MODELVIEW);
}

이제 실행해 보면 다음과 같은 결과를 얻을 수 있습니다.

사용자 삽입 이미지

[Android] 3D API, OpenGL ES – 1 : 초기화

안드로이드에서 3차원 그래픽을 위한 API는 OpenGL ES입니다. 꽤 오래전부터 3차원 그래픽 API인 OpenGL에 대해 관심이 많은 저로써는 모바일에서 3D API인 OpenGL ES에 대해서도 관심이 많았습니다. 해서 모바일 OS 중의 하나이면서.. 모바일 OS 중에서 가장 관심이 많은 안드로이드에서 3D API인 OpenGL ES에 대한 글을 체계적으로 남겨 보려고 합니다. 그중 가장 먼저 초기화입니다.

이 글의 대상은 안드로이드에 대한 기본적인 내용(Activity, View의 개념)에 대해 알고 있는 개발자 분입니다. 이클립스에서 새로운 안드로이드 프로젝트를 생성할 수 있으며 애뮬레이터이든.. 가지고 계시는 디바이스에서든.. 실행해 그 결과를 살펴보실수 있는 분에 한합니다. 아울러 OpenGL ES가 토대로 하고 있는 OpenGL API를 알고 있다면 매우 쉽게 이 글을 이해하실 수 있을 것입니다. OpenGL ES를 보시기 전에 먼저 OpenGL을 선행 학습하시면 이글을 훨씬 쉽게 이해할 수 있습니다.

먼저 Android Project를 생성합니다. 나타나는 대화상자에서 입력해야할 곳에 아래 그림을 참조해 입력하시기 바랍니다.

사용자 삽입 이미지
위와 같이 입력한 뒤에 Finish 버튼을 클릭하면 OpenGLES_Tutorial1Activity 라는 클래스가 생성됩니다. 여기서 다음과 같이 필드 변수 하나와 onCreate 매서드의 코드를 수정합니다.

package ogl.tutorial1;

import android.app.Activity;
import android.os.Bundle;

public class OpenGLES_Tutorial1Activity extends Activity {
    private MyView myView;
 
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        
        myView = new MyView(this);
        
        setContentView(myView);
    }
}

MyView라는 View 파생 클래스를 하나 생성할 것인데.. 생성했다고 가정하고 일단 필드로써 정의해 onCreate에서 생성하고 setContentView로 지정했습니다. 여기까지는 안드로이드에서 일반적인 프로젝트를 생성하고 코딩하는 내용입니다. OpenGL ES의 내용은 아직 언급되지 않았구요. 이제 MyView 클래스 작성에서부터 OpenGL ES가 시작됩니다. MyView라는 새로운 클래스를 생성합니다.

사용자 삽입 이미지
상속받을 Superclass는 android.opengl 패키지의 GLSurfaceView라는 점이 중요합니다. 이 클래스는 View라는 클래스를 상속받는 클래스로 다음과 같은 책임을 가지는 클래스입니다.

  • OpenGL ES와 View 시스템을 연결
  • 서페이스에 OpenGL이 렌더링 될 수 있도록 EGLS Display를 관리함
  • Activity의 라이프 사이클과 함께 OpenGL ES가 작동하도록 함
  • 적당한 프레임버퍼의 픽셀 포맷 선택을 쉽게 해줌
  • 렌더링에 대한 별도의 스레드를 만들어 줌
  • OpenGL ES API 호출과 에러에 대한 검사를 위한 디버깅 도구 지원

MyView 클래스에 하나의 필드 변수를 추가하고 생성자를 다음과 같이 코딩합니다.

public class MyView extends GLSurfaceView {
    private MyRenderer renderer;

    public MyView(Context context) {
        super(context);
        renderer = new MyRenderer();
        setRenderer(renderer);
    }

    ....

MyRenderer라는 앞으로 추가할 또 다른 새로운 클래스에 대한 내부 필드를 선언했고 생성자에서 이 필드를 생성한 후 setRenderer로 렌더러로써 지정했습니다. 새롭게 추가할 MyRenderer라는 클래스를 생성합니다.

사용자 삽입 이미지
구현할 Interface를 지정해야 하는데.. android.opengl.GLServiceView 클래스의 Inner Interface인 Renderer 인터페이스를 추가합니다. Finish를 클릭합니다. 이 Renderer 인터페이스에서 구현해 줘야 하는 매서드는 총 3가지입니다.

  • onSufaceCreated – 초기화 코드 부분으로 렌더링 될때 변경되지 않는 것에 대한 설정 코드가 실행되면 적합하다. 화면을 지울 배경 색이나 z-buffer에 대한 활성화 여부 등등
  • onSurfaceChanged – 화면이 가로로 회전될때 등과 같이 화면의 크기가 변경될때 이와 관련된 코드가 실행되면 적합하다. 예를 들어서 Viewport의 크기 지정이라든지 카메라의 재설정 코드 등등
  • onDrawFrame – 프레임을 그리는 코드가 오면 적합하다.

구현해야할 매서드에 대해 내용을 파악했으니.. 이제 MyRenderer에 대한 코드를 작성해 보겠습니다. 색상에 대한 RGB값을 위해 3개의 float 변수를 추가하고 앞의 3개의 메서드를 구현해 보면 다음과 같습니다.

public class MyRenderer implements Renderer {
    private float red = 0.9f;
    private float green = 0.2f;
    private float blue = 0.2f;

    @Override
    public void onDrawFrame(GL10 gl) {
        gl.glClearColor(red, green, blue, 1.0f);
        gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
    }

    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {
        gl.glViewport(0, 0, width, height);
    }

    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
        //.
    }

일단.. 이해를 쉽게 하기 위해 단순히 화면을 red, green, blue 필드값으로 지정된 색상으로 지우는 것이 그리는 것의 전부입니다. 즉 onDrawFrame에서는 배경을 Clear할 색상을 지정하기 위해 glClearColor 매서드를 사용했고 실제 지우는 함수는 glClear 입니다. 그리고 onSurfaceChanged에서는 뷰포트의 크기를 지정하는 glViewport를 호출하고 있습니다. onSurfaceCreated에서는 특별하 무엇가를 그리는 것이 없기 때문에 별다른 코드는 존재하지 않습니다. 하지만 무언가 3D 오브젝트를 그릴때 이 부분에 코드가 필요할 것입니다. 실행해 보면 다음과 같은 결과가 나타나게 됩니다.

사용자 삽입 이미지
여기에 잠시 응용을 해보겠습니다.. 사용자가 화면에 손을 대고 스크롤하면 색상이 스크롤한 내용에 따라 변경되게 말입니다. 색상에 대한 값이 MyRenderer에 정의되어 있고.. 사용자의 스크롤에 대한 이벤트는 MyView에서 발생하니.. MyRenderer의 색상값을 변경할 수 있는 매서드를 MyRenderer에 추가해해 줘야 합니다.

public void setColor(float r, float g, float b) {
    red = r;
    green = g;
    blue = b;
}

이제 MyView에 터치 이벤트를 작성합니다.

public boolean onTouchEvent(final MotionEvent event) {
    queueEvent(new Runnable() {
        public void run() {
            renderer.setColor(
                event.getX()/getWidth(), event.getY()/getHeight(), 1.0f
            );
        }
    });
  
    return true;
}

네.. 화면에 손을 터치하면 터치된 좌표값을 통해 색상값을 재설정합니다. OpenGL에서 색상값은 0~1.0까지이므로 이 값의 범위에 맞춰주고 있습니다. 실행해서 손을 터치하거나 스크롤해보면 색상값이 그에 따라 변하는 것을 살펴볼 수 있습니다.