간단한 2차원 물리

등록일: 2014. 10. 20

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

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

이 절에서는 아주 간단하고 제한적인 물리 이론을 적용해볼 것이다. 사실 게임에서는 모든 걸 가짜로 구현한다. 게임에서는 가능하다면 무거운 연산을 제거하는 속임수를 사용한다. 게임에서 객체의 행동은 물리적으로 100퍼센트 정확하지는 않다. 다만 실제처럼 믿을 수 있는 수준을 유지할 뿐이다. 또 때로는 물리적으로 정확한 동작이 꼭 필요하지 않은 경우도 있다(예를 들어 어떤 객체들은 아래로 떨어지지만 어떤 객체들은 위로 올라가는 등).

수퍼마리오 브라더스 같은 오래된 게임도 뉴턴 물리학의 기본적인 개념을 조금씩은 사용하고 있다. 이런 원리는 매우 간단하며 이해하기도 쉽다. 여기서는 게임 객체에 아주 간단한 물리 모델을 구현하는 데 필요한 최소한의 수준에서 물리학을 살펴보려고 한다.

뉴턴과 오일러, 영원한 좋은 친구들

여기서 주로 다룰 내용은 질점(point mass)의 운동 물릭학이다. 운동 물리학에서는 시간에 따른 객체의 위치, 속도, 가속도 변화를 다룬다. 질점은 관계 질량을 갖고 있는 무한히 작은 점을 사용해 객체의 근사 값을 측량하는 것을 뜻한다. 여기서는 질량 중심 주변의 객체 회전 속도를 나타내는 토크 같은 것을 다루지 않는다. 이 내용은 좀 더 복잡한 문제 영역이며 이와 관련해서는 별도로 책이 한 권 이상 나와 있기 때문이다. 그럼 객체의 세 가지 속성부터 먼저 살펴보자.

  • 객체의 위치는 특정 공간에서의 벡터다. 우리의 경우 이 공간은 2D 공간이다. 이 게임에서는 이를 벡터로 나타낸다. 보통 위치는 미터로 측정한다.
  • 객체의 속도는 초당 위치의 변화다. 속도는 2D 속도 벡터로 측정하는데, 이 벡터는 객체가 향하는 단위 길이 방향 벡터와 객체가 초당 움직이는 미터로 측정한 속도의 조합이다. 이때 속력은 속도 벡터의 길이만을 관장한다는 사실에 주의하자. 만일 속도 벡터를 속력만큼 정규화하면 단위 길이 방향 벡터를 얻을 수 있다.
  • 객체의 가속도는 초당 속도의 변화다. 가속도는 속도의 속력(가속도 벡터의 길이)에만 영향을 미치는 스칼라로 표현할 수도 있고 x, y축으로 서로 다른 가속도를 갖도록 2D 벡터로 표현할 수도 있다. 여기서는 탄성 움직임을 좀 더 쉽게 표현하기 위해 후자를 선택했다. 가속도는 보통 제곱초당 미터(m/s2)로 표현한다. 가속도는 매초마다 초당 미터 수만큼 속도에 변화를 준다.

특정 시점에 객체의 이런 속성값을 알고 있다면 이를 적분해 시간 흐름에 따른 객체의 이동 경로를 시뮬레이션할 수 있다. 이렇게 설명하면 어렵게 들릴 수 있지만 사실 이 작업은 Mr. Nom이나 Bob 테스트에서 이미 한 바 있다. 그때는 가속도를 사용하지 않고 속도를 고정 벡터로 지정했던 점이 다를 뿐이다. 객체의 가속도와 속도, 위치를 적분하는 일반적인 방식은 다음과 같다.

Vector2 position = new Vector2();
Vector2 velocity = new Vector2();
Vector2 acceleration = new Vector2(0, -10);
while(simulationRuns) {
    float deltaTime = getDeltaTime();
    velocity.add(acceleration.x * deltaTime, acceleration.y * deltaTime);
    position.add(velocity.x * deltaTime, velocity.y * deltaTime);
}

이 방식을 오일러(Euler) 수치 적분이라고 부르는데, 이 적분은 게임에서 사용하는 가장 직관적인 적분 방식이다. 이 공식에서는 먼저 (0, 0) 위치부터 시작하며 속도는 (0, 0), 가속도는 (0, -10)으로 주어졌다. 이 말은 매초당 속도가 y축으로 1미터씩 증가함을 뜻한다. 여기서는 x축으로의 움직임은 없다. 적분 순환문에 들어가기 전 객체들은 멈춰 있다. 적분 순환문 내에서는 먼저 델타 시간에 가속도를 곱한 값을 기반으로 속도를 업데이트하고 델타 시간에 속도를 곱한 값을 기반으로 위치를 업데이트한다. 내용은 이게 전부다. 다만 적분이란 단어가 어렵게 느껴질 뿐이다.

알아두기

앞에서와 마찬가지로 여기 설명한 내용은 전체 이론의 절반도 되지 않는다. 오일러 적분은 '불안정한' 적분 방식으로서 가능하다면 사용하지 않는 게 좋다. 보통은 조금 더 복잡한 베를러(verlet) 적분을 사용한다. 하지만 책의 예제로는 오일러 적분만으로도 충분하다.

힘과 질량

독자들은 가속도가 어디에서 왔는지 궁금할 것이다. 이 질문에 대한 답은 여러 가지다. 차의 가속도는 엔진에서부터 나온다. 엔진은 차가 가속하도록 차에 힘을 적용한다. 하지만 가속도가 이것만 있는 것은 아니다. 차는 중력으로 인해 지구 중심 방향으로도 가속된다. 차가 지구 중심으로 떨어지지 못하도록 막는 것은 차가 통과할 수 없는 땅뿐이다. 땅은 이런 지구 중력의 힘을 상쇄시킨다. 힘과 가속도에 대한 기본 공식은 다음과 같다.

힘 = 질량 × 가속도

이 공식은 다음과 같이 바꿔 쓸 수도 있다.

가속도 = 힘 / 질량

힘은 SI 단위 뉴턴으로 표기한다(이 단위를 누가 만들었는지 맞춰 보자). 가속도를 벡터로 지정하려면 힘도 벡터로 지정해야 한다. 따라서 힘은 방향을 가질 수 있다. 예를 들어 지구 중력은 아래쪽 방향으로 끌어당긴다(0, -1). 또한 가속도는 객체의 질량에 의존한다. 질량이 작은 객체와 비교해 객체의 질량이 클수록 객체를 가속하는 데 더 많은 힘이 든다. 이는 앞의 공식을 보더라도 당연한 결과다.

하지만 간단한 게임에서는 질량과 가속도를 무시하고 직접 속도와 가속도를 사용해도 된다. 앞의 의사 코드에서는 가속도를 m/s2당 (0,–10)으로 설정했는데, 이 가속도는 질량과 상관없이 객체가 지구 쪽으로 떨어질 때 경험하는 가속도와 거의 같다(공기 저항 등은 무시한다). 이 말이 의심이 된다면 갈릴레오에게 직접 물어보자!

이론적인 적용

앞의 예제를 활용해 지구 쪽으로 떨어지는 객체를 만들어 보자. 여기서는 순환문이 10번 실행되고 getDeltaTime()이 항상 0.1초를 반환하다고 가정하겠다. 이 경우 매 반복마다 다음과 같은 위치와 속도를 얻게 된다.

시간=0.1, 위치=(0.0,-0.1), 속도=(0.0,-1.0)
시간=0.2, 위치=(0.0,-0.3), 속도=(0.0,-2.0)
시간=0.3, 위치=(0.0,-0.6), 속도=(0.0,-3.0)
시간=0.4, 위치=(0.0,-1.0), 속도=(0.0,-4.0)
시간=0.5, 위치=(0.0,-1.5), 속도=(0.0,-5.0)
시간=0.6, 위치=(0.0,-2.1), 속도=(0.0,-6.0)
시간=0.7, 위치=(0.0,-2.8), 속도=(0.0,-7.0)
시간=0.8, 위치=(0.0,-3.6), 속도=(0.0,-8.0)
시간=0.9, 위치=(0.0,-4.5), 속도=(0.0,-9.0)
시간=1.0, 위치=(0.0,-5.5), 속도=(0.0,-10.0)

1초가 지나면 객체는 5.5미터 떨어지게 되고 속도는 (0, -10)m/s로 지구 중심을 향해 수직으로 내려온다(물론 땅에 닿기 전까지).

여기서는 공기 저항을 고려하지 않았으므로 이 객체는 끝없이 아래쪽으로 향하는 속력이 증가하게 된다(앞에서 말한 것처럼 이런 가짜 시스템은 얼마든지 만들 수 있다). 이 경우 객체의 속력과 일치하는 현재 속도 길이를 확인해 최대 속도를 강제할 수도 있다.

위키피디어에서는 자유 낙하하는 인간은 최대 속도로 시속 125마일을 가질 수 있다고 말하고 있다. 이를 초당 미터로 변환하면(125 × 1.6 × 1000/3600) 55.5m/s가 된다. 시뮬레이션을 좀 더 현실적으로 만들기 위해 순환문을 아래와 같이 수정해 보자.

while(simulationRuns) {
    float deltaTime = getDeltaTime();
    if(velocity.len() < 55.5)
        velocity.add(acceleration.x * deltaTime, acceleration.y * deltaTime);
    position.add(velocity.x * deltaTime, velocity.y * deltaTime);
}

이제 객체의 속력(속도 벡터의 길이)이 55.5m/s보다 작을 때만 가속도로 인해 속도가 증가하게 될 것이다. 이 최종 속도에 도달하면 더 이상 가속도만큼 속력을 증가시키지 않는다. 이와 같은 최대 속도는 많은 게임에서 사용하고 있는 트릭이다.

이 식에 (–1,0) m/s2처럼 x 방향으로 또 다른 가속도를 추가하면 바람으로 인한 가속도를 추가할 수 있다. 이를 적용하려면 속도에 가속도를 추가하기 전에 중력 가속도에 바람 가속도를 먼저 추가해야 한다.

Vector2 gravity = new Vector2(0,-10);
Vector2 wind = new Vector2(-1,0);

while(simulationRuns) {
    float deltaTime = getDeltaTime();
    acceleration.set(gravity).add(wind);
    if(velocity.len() < 55.5)
        velocity.add(acceleration.x * deltaTime, acceleration.y * deltaTime);
    position.add(velocity.x * deltaTime, velocity.y * deltaTime);
}

물론 이런 가속도를 모두 무시하고 객체가 고정 속도를 갖게 할 수도 있다. 앞에서 BobTest는 이런 방식을 사용한 대표적인 예다. 앞의 예제에서는 모서리에 부딪칠 때만 Bob의 속도를 변경했으며, 그마저도 순식간에 바꿔 버렸다.

실제 적용

이처럼 간단한 모델을 사용하더라도 가능성은 무궁무진하다. 이번에는 앞의 CannonTest 예제를 확장해 실제로 포탄을 쏠 수 있게 해보자. 이 예제에서 필요한 기능은 다음과 같다.

  • 사용자가 화면상에서 손가락을 드래그하면 대포가 이를 쫓아간다. 이를 통해 포탄을 발사할 각도를 지정한다.
  • 터치 업 이벤트를 감지하면 대포가 향하는 방향으로 포탄을 발사한다. 포탄의 초기 속도는 대포의 방향과 포탄이 처음에 갖는 속력의 합이다. 이 속력은 대포와 터치 지점 사이의 거리와 같다. 터치 지점이 멀면 멀수록 포탄은 더 빠르게 날아간다.
  • 포탄은 새로운 터치 업 이벤트가 있기 전까지는 계속해서 날아간다.
  • 이번에는 더 넓은 세계를 볼 수 있게 뷰 절두체를 (0, 0)부터 (9.6, 6.4)로 크기를 두 배 넓힌다. 추가로 대포는 (0, 0) 위치에 배치한다. 세계의 모든 단위는 미터로 주어진다.
  • 포탄은 0.2 × 0.2 미터, 즉 20 × 20 센티미터의 빨간색 사각형으로 표현한다. 이 정도면 실제 포탄과도 비슷해 보일 것이다. 물론 독자들 중에는 좀 더 현실적인 크기를 선호하는 사람도 있을 테지만.

초기 포탄의 위치는 (0, 0)으로 대포의 위치와 같다. 아울러 속도 또한 (0, 0)이다. 하지만 업데이트마다 중력을 적용하므로 포탄은 수직 아래 방향으로 떨어진다.

터치 업 이벤트가 발생하면 포탄의 위치를 다시 (0, 0)으로 설정하고 초기 속도를 (Math.cos(cannonAngle), Math.sin(cannonAngle))로 설정한다. 이렇게 하면 포탄이 대포가 향하는 방향으로 날아가게 된다. 아울러 포탄의 속력은 터치 지점과 대포 사이의 거리만큼 속도를 곱해 설정한다. 터치 지점이 대포에 가까울수록 포탄은 더 느리게 날아간다.

내용이 간단하므로 바로 구현해 보자. 필자는 CannonTest의 코드를 복사해 CannonGravityTest.java라는 새 파일을 만들었다. 또 이 파일에 들어 있는 클래스명도 CannonGravityTest와 CannonGravityScreen로 바꿨다. 예제 8-3은 CannonGravityScreen을 보여준다.

예제 8-3 ㅣ CannonGravityTest의 발췌 코드

class CannonGravityScreen extends Screen {
    float FRUSTUM_WIDTH = 9.6f;
    float FRUSTUM_HEIGHT = 6.4f;
    GLGraphics glGraphics;
    Vertices cannonVertices;
    Vertices ballVertices;
    Vector2 cannonPos = new Vector2();
    float cannonAngle = 0;
    Vector2 touchPos = new Vector2();
    Vector2 ballPos = new Vector2(0,0);
    Vector2 ballVelocity = new Vector2(0,0);
    Vector2 gravity = new Vector2(0,-10);

바뀐 내용은 그다지 많지 않다. 여기서는 FRUSTUM_WIDTH와 FRUSTUM_HEIGHT를 각각 9.6과 6.4로 설정해 뷰 절두체의 크기를 두 배로 적용했다. 이 말은 세계에서 9.2 × 6.4 미터의 사각형을 볼 수 있다는 뜻이다. 이번에는 포탄도 그려야 하므로 ballVertices라는 또 다른 Vertices 인스턴스를 추가했다. 이 인스턴스는 포탄 사각형의 네 정점과 여섯 개의 인덱스를 보관한다. 새로운 멤버인 ballPos와 ballVelocity는 포탄의 위치와 속도를 저장하고 gravity 멤버는 프로그램이 실행되는 내내 상수 (0, -10) m/s2로 고정된 중력 가속도를 보관한다.

public CannonGravityScreen(Game game) {
    super(game);
    glGraphics = ((GLGame) game).getGLGraphics();
    cannonVertices = new Vertices(glGraphics, 3, 0, false, false);
    cannonVertices.setVertices(new float[] { -0.5f, -0.5f,
                                             0.5f, 0.0f,
                                             -0.5f, 0.5f }, 0, 6);
    ballVertices = new Vertices(glGraphics, 4, 6, false, false);
    ballVertices.setVertices(new float[] { -0.1f, -0.1f,
                                           0.1f, -0.1f,
                                           0.1f, 0.1f,
                                           -0.1f, 0.1f }, 0, 8);
    ballVertices.setIndices(new short[] {0, 1, 2, 2, 3, 0}, 0, 6);
}

생성자에서는 포탄 사각형을 표현하기 위해 추가 Vertices 인스턴스를 생성한다. 그런 다음 (–0.1,–0.1), (0.1,–0.1), (0.1,0.1), (–0.1,0.1) 정점을 사용해 모델 공간에 포탄을 정의한다. 이때 여섯 개의 정점만을 지정하기 위해 인덱스 드로잉을 사용한다.

@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();

        if(event.type == TouchEvent.TOUCH_UP) {
            float radians = cannonAngle * Vector2.TO_RADIANS;
            float ballSpeed = touchPos.len();
            ballPos.set(cannonPos);
            ballVelocity.x = FloatMath.cos(radians) * ballSpeed;
            ballVelocity.y = FloatMath.sin(radians) * ballSpeed;
        }
    }

    ballVelocity.add(gravity.x * deltaTime, gravity.y * deltaTime);
    ballPos.add(ballVelocity.x * deltaTime, ballVelocity.y * deltaTime);
}

update() 메서드도 조금만 바뀌었다. 세계에서의 터치 점 계산과 대포 각도의 계산 방식은 그대로 남아 있다. 첫 번째로 추가된 내용은 이벤트 처리 순환문 내에 있는 if 문이다. 터치 업 이벤트가 발생할 경우에는 포탄을 쏠 준비를 해야 한다. 이때는 FastMath.cos()와 FastMath.sin()에서 쓸 수 있게 먼저 대포가 향하는 방향 각도를 라디언으로 변환한다. 다음으로 대포와 터치 지점 사이의 거리를 계산한다. 이 거리는 포탄의 속력이 된다. 그런 다음 포탄의 위치를 대포의 위치로 설정한다. 끝으로 포탄의 초기 속도를 계산한다. 이때는 앞 절에서 설명한 사인과 코사인을 사용해 대포의 각도로부터 방향 벡터를 생성한다. 이어서 이 방향 벡터에 포탄의 속력을 곱함으로써 포탄의 최종 속도를 구한다. 여기서 포탄이 처음부터 속도를 가지고 있는 부분이 조금 재미있다. 실제로는 당연히 포탄을 발사할 경우 0m/s2부터 가속도가 시작해 공기 저항, 중력, 대포에서 발사한 힘에 의해 최종 가속도가 정해진다. 하지만 여기서는 가속도가 아주 짧은 시간 내에(수백 밀리초) 적용되므로 이와 같은 속임수를 써도 무방하다. update() 메서드에서는 끝으로 포탄의 속도를 업데이트하고 이를 바탕으로 포탄의 위치를 조절한다.

@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);
    gl.glColor4f(1,1,1,1);
    cannonVertices.bind();
    cannonVertices.draw(GL10.GL_TRIANGLES, 0, 3);
    cannonVertices.unbind();

    gl.glLoadIdentity();
    gl.glTranslatef(ballPos.x, ballPos.y, 0);
    gl.glColor4f(1,0,0,1);
    ballVertices.bind();
    ballVertices.draw(GL10.GL_TRIANGLES, 0, 6);
    ballVertices.unbind();
}

present() 메서드에서는 포탄 사각형의 렌더링을 추가하는 간단한 일만 한다. 여기서는 대포 삼각형을 렌더링한 후 이 작업을 하는데, 이렇게 할 경우 사각형을 렌더링하기 전에 모델-뷰 매트릭스를 정리해야 한다. 이를 위해 여기서는 glLoadIdentity()를 호출하고 이어서 glTranslatef()를 사용해 포탄의 사각형을 모델 공간에서 세계 공간상의 포탄의 현재 위치로 변환한다.

    @Override
    public void pause() {
    }

    @Override
    public void resume() {
    }

    @Override
    public void dispose() {
    }
}

이 예제를 실행하고 화면을 몇 번 터치해 보면 포탄이 날아가는 느낌을 제대로 느낄 수 있을 것이다. 그림 8-7에서는 결과 화면을 보여준다(물론 정지 화면이라 그다지 생동감은 없다).

그림 8-7 | 삼각형 대포에서 빨간색 사각형 포탄을 쏘는 장면

이 정도면 게임 개발에 필요한 물리 이론은 충분히 살펴본 것이다. 이와 같이 간단한 모델을 사용하더라도 포탄보다 더 복잡한 내용도 시뮬레이션할 수 있다. 예를 들어 수퍼마리오도 같은 방식으로 시뮬레이션할 수 있다. 수퍼마리오 브라더스 게임을 해본 적이 있다면 마리오가 달릴 때 최고 속도에 도달하기까지 조금 시간이 걸리는 것을 알 수 있다. 이런 기능은 앞의 의사 코드에서처럼 빠른 가속도와 속도 최대 값을 사용해 구현할 수 있다. 점프는 포탄을 쏘는 방식과 유사하게 구현할 수 있다. 마리오의 현재 속도는 y축에 대한 초기 점프 속도에 의해 조절된다(속도도 다른 벡터처럼 더할 수 있다는 사실을 기억하자). 발이 땅에 닿아 있지 않다면 마리오가 땅으로 다시 내려오도록 중력 가속도를 적용할 수 있다. x축 방향 속도는 y축에서 일어나는 결과로 인해 영향을 받지 않는다. 이때도 왼쪽, 오른쪽 버튼을 눌러서 x축 속도를 변경할 수 있다. 이런 간단한 모델의 장점은 복잡한 동작을 최소한의 코드로 구현할 수 있다는 점이다. 나중에 게임을 작성할 때도 이와 유사한 물리 이론을 실제로 사용할 것이다.

포탄을 쏘기만 하는 것은 별 재미가 없다. 맞출 대상이 있을 때에야 비로소 포탄을 쏘는 재미가 있다. 이를 위해서는 충돌 감지가 필요하다. 다음 절에서 살펴볼 내용도 바로 이것이다.