Vector 클래스의 구현

등록일: 2014. 10. 17

시작하세요! 안드로이드 게임 프로그래밍

  • 마리오 제흐너 지음
  • 유윤선 옮김
  • 832쪽
  • 36,000원
  • 2011년 09월 30일

여기서는 2D 벡터를 쉽게 사용할 수 있는 클래스를 생성하려고 한다. 이 클래스는 Vector2라고 부르겠다. 이 클래스에는 벡터의 x, y 요소를 포함하기 위한 두 개의 멤버가 있어야 한다. 추가로 다음과 같은 기능을 하는 메서드도 제공해야 한다.

  • 벡터의 덧셈과 뺄셈
  • 스칼라를 사용한 벡터 요소의 곱셈
  • 벡터 길이의 측정
  • 벡터의 정규화
  • 벡터와 x축 사이의 각도 계산
  • 벡터의 회전

자바에는 연산자 오버로딩 기능이 없으므로 Vector2 클래스를 사용하기 쉽게 하려면 자체 메커니즘을 만들어야 한다. 이 메커니즘은 다음과 같이 구현하는 게 좋다.

Vector2 v = new Vector2();
v.add(10,5).mul(10).rotate(54);

Vector2 메서드 각각이 벡터 자체에 대한 참조를 반환하게 하면 이를 쉽게 구현할 수 있다. 물론 float 외에 다른 Vector2 인스턴스도 추가할 수 있게끔 Vector2.add() 메서드는 오버로드해야 한다. 예제 8-1에서는 이렇게 만든 Vector2 클래스를 보여준다.

예제 8-1 ㅣ 멋진 2D 벡터 기능을 구현한 Vector2.java

package com.badlogic.androidgames.framework.math;

import android.util.FloatMath;

public class Vector2 {
    public static float TO_RADIANS = (1 / 180.0f) * (float) Math.PI;
    public static float TO_DEGREES = (1 / (float) Math.PI) * 180;
    public float x, y;

    public Vector2() {
    }

    public Vector2(float x, float y) {
        this.x = x;
        this.y = y;
    }

    public Vector2(Vector2 other) {
        this.x = other.x;
        this.y = other.y;
    }

이 클래스는 다른 수학 관련 클래스들과 함께 보관하기 위해 com.badlogic.androidgames.framework.math 패키지에 집어넣었다.

이 클래스에서는 먼저 두 개의 스태틱 상수인 TO_RADIANS와 TO_DEGREES부터 정의한다. 라디언으로 주어진 값을 변환하려면 TO_DEGREES를 곱해주면 된다. 반대로 각도로 주어진 값을 라디언으로 변환하려면 값에 TO_RADIANS를 곱하면 된다. 이 로직은 앞에서 각도-라디언 변환을 담당했던 공식을 살펴봄으로써 다시 한 번 확인할 수 있다. 이런 트릭을 사용하면 나눗셈을 하지 않아도 되므로 속도를 조금 더 개선할 수 있다.

다음으로 벡터의 요소를 저장하는 x, y 두 멤버를 정의하고 생성자를 정의한다. 이 내용은 어려울 게 전혀 없다.

public Vector2 cpy() {
    return new Vector2(x, y);
}

cpy() 메서드는 현재 벡터의 중복 인스턴스를 생성하고 이를 반환한다. 이 메서드는 벡터의 복사본을 수정하거나 원본 벡터의 값을 보존하려고 할 때 유용하다.

public Vector2 set(float x, float y) {
    this.x = x;
    this.y = y;
    return this;
}

public Vector2 set(Vector2 other) {
    this.x = other.x;
    this.y = other.y;
    return this;
}

set() 메서드는 두 개의 float 인자 또는 다른 벡터를 기반으로 벡터의 x, y 요소를 설정한다. 이들 메서드는 앞에서 설명한 것처럼 연산이 연쇄적으로 일어날 수 있도록 벡터에 대한 참조를 반환한다.

public Vector2 add(float x, float y) {
    this.x += x;
    this.y += y;
    return this;
}

public Vector2 add(Vector2 other) {
    this.x += other.x;
    this.y += other.y;
    return this;
}

public Vector2 sub(float x, float y) {
    this.x -= x;
    this.y -= y;
    return this;
}

public Vector2 sub(Vector2 other) {
    this.x -= other.x;
    this.y -= other.y;
    return this;
}

add()와 sub() 메서드도 두 종류로 존재한다. 한 메서드에서는 두 개의 float 인자를 받고 다른 메서드에서는 또 다른 Vector2 인스턴스를 인자로 받는다. 네 메서드는 모두 연산을 연쇄적으로 실행할 수 있도록 벡터에 대한 참조를 반환한다.

public Vector2 mul(float scalar) {
    this.x *= scalar;
    this.y *= scalar;
    return this;
}

mul() 메서드는 주어진 스칼라 값만큼 벡터의 x, y 요소를 그냥 곱하고 벡터 자체에 대한 참조를 반환한다.

public float len() {
    return FloatMath.sqrt(x * x + y * y);
}

len() 메서드는 앞에서 정의한 방식과 똑같이 벡터의 길이를 계산한다. 여기서는 자바 SE에서 제공하는 Math 클래스 대신 FloatMath 클래스를 사용하는 것에 유의하자. 이 클래스는 double 대신 float을 사용하는 특수 안드로이드 API 클래스로서 Math보다 조금 더 빠르다.

public Vector2 nor() {
    float len = len();
    if (len != 0) {
        this.x /= len;
        this.y /= len;
    }
    return this;
}

nor() 메서드는 단위 길이 벡터로 벡터를 정규화한다. 이 메서드에서는 먼저 길이를 계산하기 위해 내부적으로 len() 메서드를 사용한다. 길이가 0이면 0으로 나누는 것을 피하기 위해 로직을 빠져나간다. 길이가 0이 아닌 경우에는 단위 길이 벡터를 구하기 위해 벡터의 각 요소를 길이로 나눈다. 이번에도 벡터 연쇄 조작을 위해 벡터에 대한 참조를 반환한다.

public float angle() {
    float angle = (float) Math.atan2(y, x) * TO_DEGREES;
    if (angle < 0)
        angle += 360;
    return angle;
}

angle() 메서드는 앞에서 설명한 대로 atan2() 메서드를 사용해 벡터와 x축 사이의 각도를 계산한다. FastMath 클래스는 이 메서드를 제공하지 않으므로 이때는 항상 Math.atan2() 메서드를 사용해야 한다. 반환된 각도 값은 라디언이므로 이 값에 TO_DEGREES를 곱해 각도로 변환해야 한다. 각도가 0보다 작으면 360도를 더해줌으로써 각도가 0부터 360 사이의 값이 되게 한다.

public Vector2 rotate(float angle) {
    float rad = angle * TO_RADIANS;
    float cos = FloatMath.cos(rad);
    float sin = FloatMath.sin(rad);
    float newX = this.x * cos - this.y * sin;
    float newY = this.x * sin + this.y * cos;
    this.x = newX;
    this.y = newY;
    return this;
}

rotate() 메서드는 주어진 각도만큼 원점을 중심으로 벡터를 회전하는 일을 한다. FastMath.cos()와 FastMath.sin() 메서드는 각도 값으로 라디언을 받으므로 이때는 각도를 라디언으로 먼저 변환해야 한다. 그런 다음 앞서 정의한 공식을 사용해 벡터의 새로운 x, y 요소를 계산하고 마지막으로 벡터의 연쇄 수정을 위해 참조를 반환한다.

    public float dist(Vector2 other) {
        float distX = this.x - other.x;
        float distY = this.y - other.y;
        return FloatMath.sqrt(distX * distX + distY * distY);
    }
    public float dist(float x, float y) {
        float distX = this.x - x;
        float distY = this.y - y;
        return FloatMath.sqrt(distX * distX + distY * distY);
    }
}

끝으로 이 벡터와 다른 벡터 사이의 거리를 계산하는 두 개의 메서드가 있다.

이로써 Vector2 클래스를 모두 살펴봤다. 이 클래스는 이어지는 코드에서 위치, 속도, 거리, 방향을 나타내는 데 사용할 것이다. 새로운 클래스에 대한 감을 익히기 위해 간단한 예제를 작성해 보자.

간단한 사용 예제

간단한 테스트 예제의 개요는 다음과 같다.

  • 세계(world)에서 고정 위치를 갖는 삼각형 모양의 대포를 생성한다. 이 삼각형의 중심은 (2.4, 0.5)다.
  • 매번 화면을 터치하면 삼각형이 터치 지점을 가리키게 한다.
  • 뷰 절두체에서는 (0, 0)과 (4.8, 3.2) 사이의 세계 공간을 보여준다. 이 예제에서는 픽셀 좌표에서 연산하는 대신 1 단위가 1 미터를 나타내는 고유 좌표계를 정의한다. 더불어 가로 모드에서 작업한다.

이를 구현하려면 생각해야 할 내용이 몇 가지 있다. 우리는 모델 공간에서 삼각형을 정의하는 법을 이미 알고 있다. 이때는 Vertices 인스턴스를 사용하면 된다. 대포는 기본 방향에서는 0도에 해당하는 우측을 가리킨다. 그림 8-4에서는 모델 공간에서의 대포 삼각형을 보여준다.

그림 8-4 | 모델 공간에서의 대포 삼각형

이 삼각형을 렌더링할 때는 glTranslatef()를 사용해 세계 공간(world space)의 (2.4, 0.5) 위치로 위치를 옮겨야 한다.

이 예제에서는 화면을 터치한 지점 방향을 가리키도록 대포의 모서리를 회전해야 한다. 이렇게 하려면 세계 공간을 터치하는 마지막 터치 이벤트가 어디서 발생했는지 알아야 한다. GLGame.getInput().getTouchX()와 getTouchY() 메서드는 원점이 좌측 상단 구석인 화면 좌표에서의 터치 좌표를 반환한다. 앞에서는 Mr. Nom 게임에서 한 것과는 달리 Input 인스턴스가 이벤트를 고정 좌표계에 맞춰 스케일 조정하지 않는다고 언급한 바 있다. 대신 여기서는 이를테면 우측 하단을 터치할 경우 히어로의 경우 (479, 319) 좌표를, 넥서스 원의 경우 (799, 479) 좌표를 그냥 가져온다. 이 터치 좌표는 세계 좌표(world coordinate)로 변환해야 한다. 이 작업은 Mr. Nom의 터치 핸들러와 Canvas 기반의 게임 프레임워크에서 이미 한 바 있다. 이번에 달라진 점은 좌표계 영역이 조금 더 좁다는 것과 세계의 y축이 위를 향한다는 점뿐이다. 다음은 일반적인 경우에서 이런 변환을 수행하는 의사 코드다. 이 코드는 5장의 터치 핸들러와도 거의 유사하다.

worldX = (touchX / Graphics.getWidth()) * viewFrustmWidth
worldY = (1 - touchY / Graphics.getHeight()) * viewFrustumHeight

여기서는 터치 좌표를 화면 해상도로 나눔으로써 (0, 1) 범위로 정규화했다. y좌표의 경우 y축을 뒤집기 위해 정규화된 터치 이벤트의 y좌표만큼 1에서 값을 뺐다. 이제 남은 일은 뷰 절두체의 너비 및 높이만큼 x, y 좌표의 스케일을 조절하는 것뿐이다. 이 경우 뷰 절두체의 너비와 높이는 각각 4.8과 3.2다. 이렇게 worldX와 worldY를 구하고 나면 이를 토대로 세계의 좌표점 위치를 갖고 있는 Vector2 인스턴스를 생성할 수 있다.

끝으로 남은 작업은 대포를 회전시킬 각도를 계산하는 것이다. 세계 좌표상에서의 대포와 터치 지점을 보여주는 그림 8-5를 살펴보자.

그림 8-5 | 기본 상태에서 오른쪽(각도가 0°)을 향하는 대포와 터치 지점, 대포를 회전시켜야 하는 각도. 사각형은 뷰 절두체가 화면에서 보여주는 세계 공간((0, 0)부터 (4.8, 3.2))을 나타낸다.

이제 남은 일은 대포의 중심인 (2.4, 0.5)부터 터치 지점(이때 대포의 중심에서 터치 지점의 좌표를 빼야 한다는 것에 주의하자. 그 반대가 아니다)을 빼서 거리 벡터를 구해야 한다. 거리 벡터를 구하고 나면 Vector2.angle() 메서드를 사용해 각도를 계산할 수 있다. 이 각도는 glRotatef()를 통해 모델을 회전하는 데 사용할 수 있다.

이제 이를 코드로 옮겨보자. 예제 8-2에서는 CannonTest 클래스 가운데 CannonScreen과 관련한 영역을 보여준다.

예제 8-2 ㅣ 화면을 터치하면 대포를 회전하는 CannonTest.java의 발췌 코드

class CannonScreen extends Screen {
    float FRUSTUM_WIDTH = 4.8f;
    float FRUSTUM_HEIGHT = 3.2f;
    GLGraphics glGraphics;
    Vertices vertices;
    Vector2 cannonPos = new Vector2(2.4f, 0.5f);
    float cannonAngle = 0;
    Vector2 touchPos = new Vector2();

이 클래스에서는 앞서 설명한 대로 먼저 절두체의 너비와 높이를 나타내는 두 상수부터 정의한다. 그런 다음 GLGraphics 인스턴스와 더불어 Vertices 인스턴스를 선언한다. 또 대포의 위치를 Vector2에 저장하고 각도를 float에 저장한다. 끝으로 원점에서부터 터치 지점 및 x축과의 각도를 계산하는 데 사용할 또 다른 Vector2를 생성한다.

그럼 왜 Vector2 인스턴스를 클래스 멤버로 선언했을까? 물론 이 인스턴스는 매번 생성할 수도 있지만 이렇게 하면 가비지 컬렉터에게 부담이 된다. 보통 Vector2 인스턴스는 한 번만 생성하고 최대한 재활용하는 게 좋다.

public CannonScreen(Game game) {
    super(game);
    glGraphics = ((GLGame) game).getGLGraphics();
    vertices = new Vertices(glGraphics, 3, 0, false, false);
    vertices.setVertices(new float[] { -0.5f, -0.5f,
                                        0.5f, 0.0f,
                                        -0.5f, 0.5f }, 0, 6);
    }

생성자에서는 GLGraphics 인스턴스를 가져오고 그림 8-4에 따라 삼각형을 생성한다.

@Override
public void update(float deltaTime) {
    List<TouchEvent> touchEvents = game.getInput().getTouchEvents();
    game.getInput().getKeyEvents();
    int len = touchEvents.size();
    for (int i = 0; i < len; i++) {
        TouchEvent event = touchEvents.get(i);
        touchPos.x = (event.x / (float) glGraphics.getWidth()) * FRUSTUM_WIDTH;
        touchPos.y = (1 - event.y / (float) glGraphics.getHeight()) * FRUSTUM_HEIGHT;
        cannonAngle = touchPos.sub(cannonPos).angle();
    }
}

다음으로 update() 메서드를 볼 수 있다. 이 메서드에서는 모든 TouchEvent를 순회하고 대포의 각도를 계산한다. 이 과정은 몇 단계로 이뤄진다. 우선 터치 이벤트의 화면 좌표를 세계 좌표로 변환한다. 그런 다음 터치 이벤트의 세계 좌표를 touchPos 멤버에 저장한다. 이어서 그림 8-5에 설명한 것처럼 touchPos 벡터에서 대포의 위치를 뺀다. 그런 후 이 벡터와 x축 사이의 각도를 계산한다. 그럼 모든 연산이 종료된다.

@Override
public void present(float deltaTime) {
    GL10 gl = glGraphics.getGL();
    gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
    gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
    gl.glMatrixMode(GL10.GL_PROJECTION);
    gl.glLoadIdentity();
    gl.glOrthof(0, FRUSTUM_WIDTH, 0, FRUSTUM_HEIGHT, 1, -1);
    gl.glMatrixMode(GL10.GL_MODELVIEW);
    gl.glLoadIdentity();
    gl.glTranslatef(cannonPos.x, cannonPos.y, 0);
    gl.glRotatef(cannonAngle, 0, 0, 1);
    vertices.bind();
    vertices.draw(GL10.GL_TRIANGLES, 0, 3);
    vertices.unbind();
}

present() 메서드는 앞에서 하던 일을 그대로 한다. 이 메서드에서는 뷰포트를 설정하고 화면을 정리하고 절두체의 너비와 높이를 사용해 정사영 투영 매트릭스를 설정하며, 이어지는 매트릭스 연산이 모두 모델-뷰 매트릭스에 적용됨을 오픈GL ES에게 알려준다. 또 이 메서드에서는 아이덴티티 매트릭스를 모델-뷰 매트릭스로 로드해 모델-뷰 매트릭스를 정리한다. 다음으로 (아이덴티티) 모델-뷰 매트릭스에 변환 매트릭스를 곱함으로써 삼각형의 정점을 모델 공간에서 세계 공간으로 옮긴다. 또 update() 메서드에서 계산한 각도를 사용해 glRotatef()를 호출함으로써 삼각형이 변환되기 전에 모델 공간에서 회전하게 한다. 이와 같이 변형은 항상 역순으로 적용해야 한다는 사실을 기억하자. 마지막으로 지정한 변형은 항상 첫 번째로 적용된다. 끝으로 삼각형의 정점을 바인딩하고, 렌더링한 후 바인딩 해제한다.

    @Override
    public void pause() {
    }

    @Override
    public void resume() {
    }

    @Override
    public void dispose() {
    }
}

이제 매번 터치를 할 때마다 손가락을 따라 다니는 삼각형이 생겨났다. 그림 8-6에서는 화면의 우측 상단 구석을 터치한 후의 결과를 보여준다.

그림 8-6 | 우측 상단 구석의 터치 이벤트에 반응하는 삼각형 대포

이때 대포 위치에 삼각형을 렌더링하든 대포 이미지로 매핑된 사각형 텍스처를 렌더링하든 중요하지 않다는 사실을 기억하자. 오픈GL ES에서는 이런 부분을 전혀 상관하지 않는다. 이 예제에서는 매트릭스 연산이 모두 present() 메서드 안으로 다시 들어갔다. 사실 이렇게 해야 오픈GL ES의 상태를 추적하기가 더 쉽다. 아울러 한 번의 present() 호출에서 여러 뷰 절두체를 사용해야 하는 경우도 많이 있다(예를 들어 세계를 렌더링하기 위해 미터 단위로 세계를 설정하고 UI 요소를 렌더링하기 위해 픽셀로 세계를 설정하는 경우). 사실 앞 장에서 본 것처럼 이때 성능 영향은 그다지 크지 않으므로 대부분의 경우 이런 식으로 작업하더라도 문제될 게 없다. 다만 성능 문제가 혹 생긴다면 이를 최적화할 수 있다는 점만 알아두면 된다.

이제부터 벡터는 우리의 가장 친한 친구가 될 것이다. 벡터는 사실상 세계에 속하는 모든 대상을 지정하는 데 사용할 수 있다. 이 장에서는 벡터를 사용한 간단한 물리 이론도 적용해볼 것이다. 사실 대포가 실제로 포를 쏠 수 없다면 무용지물이기 때문이다.