충돌 감지와 2차원에서의 객체 표현 (3)

등록일: 2014. 10. 23

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

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

적용 예제

앞의 대포 예제를 기반으로 해시 그리드 광범위 충돌 검사를 구현해 보자. 이번에는 이 절에서 지금까지 다룬 내용을 모두 포함하도록 예제를 전체적으로 재작성할 것이다. 아울러 대포와 포탄뿐 아니라 맞출 목표물도 추가할 것이다. 여기서는 예제를 간단히 하기 위해 0.5×0.5 미터 크기의 사각형을 목표물로 사용한다. 이 사각형은 움직이지 않는다. 아울러 대포도 움직이지 않는다. 이 예제에서 움직이는 대상은 포탄뿐이다. 보통 게임 세계의 객체는 정적인 객체와 동적인 객체로 나눌 수 있다. 이번에는 이런 객체를 나타내는 클래스를 작성해 보자.

GameObject, DynamicGameObject, Cannon

먼저 정적인 객체부터 구현을 시작하자. 코드는 예제 8-7에 나와 있다.

예제 8-7 ㅣ 위치와 경계면을 갖는 정적인 게임 객체인 GameObject.java

package com.badlogic.androidgames.gamedev2d;

import com.badlogic.androidgames.framework.math.Rectangle;
import com.badlogic.androidgames.framework.math.Vector2;

public class GameObject {
    public final Vector2 position;
    public final Rectangle bounds;

    public GameObject(float x, float y, float width, float height) {
        this.position = new Vector2(x,y);
        this.bounds = new Rectangle(x-width/2, y-height/2, width, height);
    }
}

이 게임의 모든 객체는 중심점과 일치하는 위치를 갖고 있다. 추가로 각각의 객체는 하나의 경계 도형(여기서는 사각형)을 갖고 있다. 생성자에서는 매개변수에 따라 위치와 경계 사각형(이 사각형은 객체의 중심을 중심으로 배치한다)을 설정한다.

동적 객체, 다시 말해 움직이는 객체와 관련해서는 객체의 속도와 가속도를 추적해야 한다(객체가 엔진이나 추진력 등에 의해 실제로 자체 가속되는 경우). 예제 8-8에서는 DynamicGameObject 클래스의 코드가 나와 있다.

그림 8-8 ㅣ 속도와 가속도 벡터를 사용해 GameObject를 확장한 DynamicGameObject.java

package com.badlogic.androidgames.gamedev2d;

import com.badlogic.androidgames.framework.math.Vector2;

public class DynamicGameObject extends GameObject {
    public final Vector2 velocity;
    public final Vector2 accel;

    public DynamicGameObject(float x, float y, float width, float height) {
        super(x, y, width, height);
        velocity = new Vector2();
        accel = new Vector2();
    }
}

여기서는 클래스가 GameObject 클래스를 상속하게 함으로써 position과 bounds 멤버를 물려받게 했다. 추가로 속도와 가속도에 대한 벡터도 생성했다. 새로운 동적 게임 객체는 초기화 이후 속도와 가속도가 모두 0이다.

이 예제에는 대포, 포탄, 그리고 목표물이 있다. 포탄은 간단한 물리 모델에 따라 움직이므로 DynamicGameObject다. 목표물은 정적이므로 표준 GameObject를 사용해 구현할 수 있다. 대포는 그 자체로는 GameObject 클래스다. 하지만 여기서는 GameObject를 상속한 Cannon 클래스를 생성하고 대포의 현재 각도를 저장하는 필드를 추가해 보겠다. 예제 8-9에 코드가 나와 있다.

예제 8-9 ㅣ 각도를 갖도록 GameObject를 상속한 Cannon.java

package com.badlogic.androidgames.gamedev2d;

public class Cannon extends GameObject {
    public float angle;

    public Cannon(float x, float y, float width, float height) {
        super(x, y, width, height);
        angle = 0;
    }
}

이 클래스는 대포를 객체로 표현하는 데 필요한 모든 데이터를 캡슐화한다. 이와 같이 특수한 객체가 필요할 때면 정적인 객체의 경우 GameObject를, 속도와 가속도를 갖는 객체일 경우 DynamicGameObject를 상속하면 된다.

알아두기

상속을 지나치게 많이 사용하면 골치 아픈 결과를 초래할 수 있고 코드 아키텍처 또한 지저분해질 수 있다. 상속을 위한 상속은 사용하지 말자. 방금 사용한 것처럼 간단한 상속 계층 구조라면 문제가 없지만 이보다 더 상속 구조가 깊어지게 해서는 안 된다(예를 들어 Cannon 클래스를 상속하는 등). 합성을 사용하면 상속을 통해 만들 수 있는 게임 객체를 얼마든지 대체할 수 있다. 하지만 책의 예제와 같은 상속은 얼마든지 사용해도 된다. 상속의 대체 방법이 궁금하다면 웹에서 ‘합성’ 또는 ‘mixin’을 검색해보자.

공간 해시 그리드

대포는 경계가 1×1 미터의 사각형이며 포탄은 경계가 0.2×0.2 미터인 사각형이고, 목표물은 경계가 0.5×0.5 미터인 사각형이다. 여기서는 연산을 쉽게 하기 위해 경계 사각형이 모두 객체의 중심을 기준으로 배치되게 했다.

이 예제 애플리케이션을 실행하면 다양한 목표물을 임의의 위치에 배치한다. 다음은 이 세계에서 객체를 설정하는 방법을 보여준다.

Cannon cannon = new Cannon(0, 0, 1, 1);
DynamicGameObject ball = new DynamicGameObject(0, 0, 0.2f, 0.2f);
GameObject[] targets = new GameObject[NUM_TARGETS];

for(int i = 0; i < NUM_TARGETS; i++) {
    targets[i] = new GameObject((float)Math.random() * WORLD_WIDTH,
                                (float)Math.random() * WORLD_HEIGHT,
                                0.5f, 0.5f);
}

WORLD_WIDTH와 WORLD_HEIGHT 상수는 게임 세계의 크기를 정의한다. 모든 동작은 (0,0)과 (WORLD_WIDTH,WORLD_HEIGHT) 사이의 사각형 내에서 일어난다. 그림 8-17은 지금까지 구현한 게임 세계의 모형을 보여준다.

그림 8-17 | 게임 세계의 모형

나중에 실제 게임 세계도 이렇게 보이게 될 것이다. 하지만 그 전에 공간 해시 그리드를 위에 올려야 한다. 그럼 해시 그리드의 셀 크기는 얼마로 해야 할까? 딱 정해진 원칙은 없지만 필자는 보통 화면에서 가장 큰 객체의 다섯 배 정도 크기로 이 셀을 정한다. 이 예제에서 가장 큰 객체는 대포지만 대포는 무엇과도 충돌하지 않는다. 따라서 여기서는 두 번째로 큰 객체인 목표물의 크기를 기반으로 그리드 셀의 크기를 정하겠다. 목표물은 크기가 0.5×0.5 미터다. 따라서 그리드 셀의 크기는 2.5×2.5 미터가 적절하다. 그림 8-18은 세계 위에 그리드 셀을 적용한 모습을 보여준다.

이런 셀의 개수는 고정적이다. 이 예제의 경우 정확히 12개의 셀을 갖고 있다. 각 셀에는 고유 번호를 부여한다. 이 번호는 좌측 하단에 있는 셀부터 부여하며 ID 0부터 시작한다. 이 그림에서 상단 셀은 실제로는 세계 바깥으로까지 확장되는 것을 볼 수 있다. 하지만 이는 문제가 되지 않는다. 모든 객체가 세계의 경계 영역 안쪽에 위치하게끔 하는 것만 신경 쓰면 된다.

우리가 알아야 할 내용은 어떤 객체가 어떤 셀에 포함되는지다. 이때 객체가 포함된 셀의 ID를 계산할 수 있다면 더욱 좋다. 이렇게 된다면 다음과 같이 셀을 저장하는 간단한 데이터 구조를 사용할 수 있기 때문이다.

List<GameObject>[] cells;
그림 8-18 | 게임 세계 위에 12개의 셀로 구성된 해시 그리드가 적용됐다.

이 방식을 사용하면 각 셀을 GameObject의 리스트로 표현할 수 있다. 이렇게 되면 공간 해시 그리드 자체가 GameObject로 이루어진 리스트의 배열로 구성된다.

객체가 들어 있는 셀의 ID를 어떻게 알 수 있는지 생각해 보자. 그림 8-18을 보면 여러 목표물이 두 셀에 걸쳐 있음을 볼 수 있다. 사실 작은 객체라 하더라도 네 셀에 걸칠 수 있으며 그리드 셀보다 더 큰 객체는 네 개보다 많은 셀에 걸칠 수 있다. 이런 일을 막으려면 게임에서 가장 큰 객체의 크기에 몇 배를 곱해서 그리드 셀의 크기를 지정해야 한다. 이렇게 되면 객체가 포함될 수 있는 셀의 최대 개수가 4개로 제한된다.

객체의 셀 ID를 계산하려면 경계 사각형의 네 구석점을 가져와 경계점이 어느 셀에 들어 있는지 검사하면 된다. 점이 들어 있는 셀은 쉽게 판단할 수 있다. 이때는 먼저 셀 너비로 점의 좌표를 나눈다. 예를 들어 점이 (3,4)이고 셀의 크기가 2.5×2.5 미터라고 가정하자. 이 경우 점은 그림 8-18의 ID 5를 갖는 셀에 들어 있게 된다.

다음과 같이 각 점의 좌표를 셀의 크기로 나누면 2차원 정수 좌표를 얻을 수 있다.

cellX = floor(point.x / cellSize) = floor(3 / 2.5) = 1
cellY = floor(point.y / cellSize) = floor(4 / 2.5) = 1

아울러 이 셀 좌표로부터 쉽게 셀 ID를 얻을 수 있다.

cellId = cellX + cellY * cellsPerRow = 1 + 1 * 4 = 5

상수 cellsPerRow는 x축으로 세계 공간을 덮는 데 필요한 셀의 개수를 나타낸다.

cellsPerRow = ceil(worldWidth / cellSize) = ceil(9.6 / 2.5) = 4

각 열에 필요한 셀의 계수는 다음과 같이 계산할 수 있다.

cellsPerColumn = ceil(worldHeight / cellSize) = ceil(6.4 / 2.5) = 3

이를 활용하면 공간 해시 그리드도 쉽게 구현할 수 있다. 세계의 크기와 원하는 셀 크기를 지정하면 해시 그리드를 설정할 수 있다. 이때 모든 동작은 양수 값을 갖는 세계의 사분면 내에서 일어난다고 가정한다. 이 말은 세계의 x, y 점 좌표가 모두 양수라는 뜻이다. 이런 제약은 충분히 받아들일 수 있는 내용이다.

공간 해시 그리드에 얼마나 많은 셀이 필요한지는 매개변수 값을 통해 판단할 수 있다(cellsPerRow × cellsPerColumn). 또 객체의 경계면을 사용해 객체가 포함된 셀을 판단해 객체를 그리드에 집어넣는 간단한 메서드도 추가할 수 있다. 이 객체는 객체를 포함하는 각 셀의 객체 목록에 추가된다. 객체의 경계 도형의 구석점이 그리드 바깥에 있는 경우에는 구석점을 그냥 무시한다.

모든 객체는 위치 업데이트 이후 매 프레임마다 해시 그리드에 다시 삽입된다. 하지만 이 게임에는 대포처럼 움직이지 않는 객체들도 있으므로 매 프레임마다 모든 객체를 삽입하면 낭비가 심하다. 따라서 여기서는 셀 단위로 두 개의 리스트를 저장함으로써 동적 객체와 정적 객체를 구분할 것이다. 두 리스트 중 한 리스트는 매 프레임마다 업데이트되며 움직이는 객체를 보관하고, 다른 리스트에서는 정적인 객체를 보관하며 새로운 정적인 객체가 삽입된 경우에만 업데이트한다.

끝으로 다른 객체와 충돌시키려는 객체의 셀에 들어 있는 객체 목록을 반환하는 메서드가 필요하다. 이 메서드가 하는 일은 해당 객체가 어느 셀에 들어 있는지 검사한 후 이 셀에 들어 있는 동적인 객체와 정적인 객체의 리스트를 가져와 이를 호출자에게 반환하는 게 전부다. 물론 이때는 객체가 여러 셀에 있을 경우 중복 객체가 반환되는지 여부도 검사해 중복 객체 반환을 막아야 한다.

예제 8-10에는 이 코드가 나와 있다. SpatialHashGrid.getCellIds() 메서드의 경우 조금 복잡하므로 잠시 후에 이어서 설명하겠다.

예제 8-10 ㅣ 공간 해시 그리드의 구현체인 SpatialHashGrid.java의 발췌 코드

package com.badlogic.androidgames.framework.gl;

import java.util.ArrayList;
import java.util.List;

import com.badlogic.androidgames.gamedev2d.GameObject;

import android.util.FloatMath;

public class SpatialHashGrid {
    List<GameObject>[] dynamicCells;
    List<GameObject>[] staticCells;
    int cellsPerRow;
    int cellsPerCol;
    float cellSize;
    int[] cellIds = new int[4];
    List<GameObject> foundObjects;

앞에서 설명한 것처럼 여기서는 두 개의 셀 리스트를 저장한다. 하나는 동적인 객체의 리스트고 다른 하나는 정적인 객체의 리스트다. 또 검사하는 점이 세계 내부에 있는지 바깥에 있는지 알 수 있도록 행, 열당 셀의 개수도 저장한다. 아울러 셀 크기도 저장한다. cellIds 배열은 GameObject가 포함된 네 개의 셀 ID를 임시 저장할 수 있는 배열이다. 이 객체가 하나의 셀에만 들어 있다면 이 객체를 완전히 포함하는 이 배열의 첫 번째 요소가 셀의 ID로 설정된다. 이 객체가 두 셀에 걸쳐 있다면 이 배열의 처음 두 요소가 셀의 ID를 보관한다. 셀의 ID 숫자를 나타내기 위해 배열에서 ‘비어 있는’ 모든 요소는 -1로 설정한다. foundObjects는 getPotentialColliders()의 호출 결과를 보관하는 작업용 리스트다. 그럼 왜 필요할 때마다 새로운 배열과 리스트를 생성하지 않을까? 바로 가비지 컬렉터 때문이다.

@SuppressWarnings("unchecked")
public SpatialHashGrid(float worldWidth, float worldHeight, float cellSize) {
    this.cellSize = cellSize;
    this.cellsPerRow = (int)FloatMath.ceil(worldWidth/cellSize);
    this.cellsPerCol = (int)FloatMath.ceil(worldHeight/cellSize);
    int numCells = cellsPerRow * cellsPerCol;
    dynamicCells = new List[numCells];
    staticCells = new List[numCells];
    for(int i = 0; i < numCells; i++) {
        dynamicCells[i] = new ArrayList<GameObject>(10);
        staticCells[i] = new ArrayList<GameObject>(10);
    }
    foundObjects = new ArrayList<GameObject>(10);
}

이 클래스의 생성자에서는 세계의 크기와 원하는 셀의 크기를 인자로 받는다. 이들 인자로부터 얼마나 많은 셀이 필요한지 계산해 셀 배열의 인스턴스를 생성하고 각 셀에 포함된 객체들을 보관하는 리스트를 생성한다. 또 foundObjects 리스트도 생성자에서 초기화한다. 인스턴스를 생성하는 모든 ArrayList는 크기가 10인 GameObject 리스트로 지정한다. 이렇게 한 이유는 메모리 할당을 피하기 위해서다. 여기서는 하나의 셀에 10개보다 많은 GameObject가 들어가지 않는다고 가정한다. 이 가정이 맞는 한 이 배열은 크기를 다시 조정하지 않아도 된다.

public void insertStaticObject(GameObject obj) {
    int[] cellIds = getCellIds(obj);
    int i = 0;
    int cellId = -1;
    while(i <= 3 && (cellId = cellIds[i++]) != -1) {
        staticCells[cellId].add(obj);
    }
}

public void insertDynamicObject(GameObject obj) {
    int[] cellIds = getCellIds(obj);
    int i = 0;
    int cellId = -1;
    while(i <= 3 && (cellId = cellIds[i++]) != -1) {
        dynamicCells[cellId].add(obj);
    }
}

다음으로 insertStaticObject()와 insertDynamicObject() 메서드를 볼 수 있다. 이들 메서드는 getCellIds()를 통해 객체가 들어 있는 셀의 ID를 계산하고 객체를 적절한 리스트에 삽입한다. getCellIds() 메서드는 실제로 cellIds 멤버 배열을 채워준다.

public void removeObject(GameObject obj) {
    int[] cellIds = getCellIds(obj);
    int i = 0;
    int cellId = -1;
    while(i <= 3 && (cellId = cellIds[i++]) != -1) {
        dynamicCells[cellId].remove(obj);
        staticCells[cellId].remove(obj);
    }
}

또 removeObject() 메서드도 있다. 이 메서드는 객체가 어떤 셀에 들어 있는지를 판단하고 이에 따라 객체를 동적 또는 정적 리스트에서 적절히 지워주는 일을 한다. 이 메서드는 이를테면 게임 객체가 죽을 때 필요하다.

public void clearDynamicCells(GameObject obj) {
    int len = dynamicCells.length;
    for(int i = 0; i < len; i++) {
        dynamicCells[i].clear();
    }
}

clearDynamicCells() 메서드는 동적 셀 리스트에 들어 있는 모든 내용을 제거할 때 사용한다. 앞에서 설명한 것처럼 동적 객체를 재삽입하기 전에는 매 프레임마다 이 메서드를 호출해야 한다.

public List<GameObject> getPotentialColliders(GameObject obj) {
    foundObjects.clear();
    int[] cellIds = getCellIds(obj);
    int i = 0;
    int cellId = -1;
    while(i <= 3 && (cellId = cellIds[i++]) != -1) {
        int len = dynamicCells[cellId].size();
        for(int j = 0; j < len; j++) {
            GameObject collider = dynamicCells[cellId].get(j);
            if(!foundObjects.contains(collider))
                foundObjects.add(collider);
        }

        len = staticCells[cellId].size();
        for(int j = 0; j < len; j++) {
            GameObject collider = staticCells[cellId].get(j);
            if(!foundObjects.contains(collider))
                foundObjects.add(collider);
        }
    }
    return foundObjects;
}

끝으로 getPotentialColliders() 메서드가 있다. 이 메서드는 객체를 인자로 받고 이 객체와 같은 셀에 들어 있는 인접한 객체들의 리스트를 반환한다. 이때 찾은 객체의 리스트를 저장하기 위해 foundObjects 리스트를 사용한다. 이때도 메서드를 호출할 때마다 새로운 리스트를 매번 사용하지 않기 위해 이런 방식을 사용한다. 여기서 알아야 할 내용은 메서드의 인자로 넘긴 객체가 어느 셀에 있는지다. 이 셀만 알면 해당 셀에서 찾은 모든 동적 및 정적 객체를 foundObjects 리스트에 추가하고 중복이 없게 만들어주면 끝이다. 물론 foundObjects.contains()를 사용한 중복 검사는 최적화되지 않았다. 하지만 찾는 객체의 수가 절대 크지 않으므로 여기서는 이 방식을 사용해도 괜찮다. 만일 이로 인해 성능 문제가 발생한다면 이 부분부터 최적화하면 된다. 하지만 아쉽게도 이 부분의 최적화는 그다지 간단하지만은 않다. 물론 이때 Set을 사용할 수도 있지만 이 경우 매번 새로운 객체를 추가할 때마다 내부적으로 새로운 객체를 할당하게 된다. 따라서 지금은 현재 상태대로 사용하고 성능 문제가 발생한다면 이 부분을 다시 살펴봐야 한다는 점만 기억하자.

지금까지 필자가 설명하지 않은 메서드는 SpatialHashGrid.getCellIds()뿐이다. 예제 8-11에는 이 메서드의 코드가 나와 있다. 이 메서드는 겉보기에만 어려워 보이므로 지레 겁먹지 말자.

예제 8-11 ㅣ getCellIds()를 구현한 SpatialHashGrid.java의 나머지 코드

    public int[] getCellIds(GameObject obj) {
        int x1 = (int)FloatMath.floor(obj.bounds.lowerLeft.x / cellSize);
        int y1 = (int)FloatMath.floor(obj.bounds.lowerLeft.y / cellSize);
        int x2 = (int)FloatMath.floor((obj.bounds.lowerLeft.x + obj.bounds.width) / cellSize);
        int y2 = (int)FloatMath.floor((obj.bounds.lowerLeft.y + obj.bounds.height) /cellSize);

        if(x1 == x2 && y1 == y2) {
            if(x1 >= 0 && x1 < cellsPerRow && y1 >= 0 && y1 < cellsPerCol)
                cellIds[0] = x1 + y1 * cellsPerRow;
            else
                cellIds[0] = -1;
            cellIds[1] = -1;
            cellIds[2] = -1;
            cellIds[3] = -1;
        }
        else if(x1 == x2) {
            int i = 0;
            if(x1 >= 0 && x1 < cellsPerRow) {
                if(y1 >= 0 && y1 < cellsPerCol)
                    cellIds[i++] = x1 + y1 * cellsPerRow;
                if(y2 >= 0 && y2 < cellsPerCol)
                    cellIds[i++] = x1 + y2 * cellsPerRow;
            }
            while(i <= 3) cellIds[i++] = -1;
        }
        else if(y1 == y2) {
            int i = 0;
            if(y1 >= 0 && y1 < cellsPerCol) {
                if(x1 >= 0 && x1 < cellsPerRow)
                    cellIds[i++] = x1 + y1 * cellsPerRow;
                if(x2 >= 0 && x2 < cellsPerRow)
                    cellIds[i++] = x2 + y1 * cellsPerRow;
            }
            while(i <= 3) cellIds[i++] = -1;
        }
        else {
            int i = 0;
            int y1CellsPerRow = y1 * cellsPerRow;
            int y2CellsPerRow = y2 * cellsPerRow;
            if(x1 >= 0 && x1 < cellsPerRow && y1 >= 0 && y1 < cellsPerCol)
                cellIds[i++] = x1 + y1CellsPerRow;
            if(x2 >= 0 && x2 < cellsPerRow && y1 >= 0 && y1 < cellsPerCol)
                cellIds[i++] = x2 + y1CellsPerRow;
            if(x2 >= 0 && x2 < cellsPerRow && y2 >= 0 && y2 < cellsPerCol)
                cellIds[i++] = x2 + y2CellsPerRow;
            if(x1 >= 0 && x1 < cellsPerRow && y2 >= 0 && y2 < cellsPerCol)
                cellIds[i++] = x1 + y2CellsPerRow;
            while(i <= 3) cellIds[i++] = -1;
        }
        return cellIds;
    }
}

이 메서드의 처음 네 줄에서는 객체 경계 사각형의 좌측 하단과 우측 상단의 셀 좌표를 계산한다. 이를 계산하는 법은 앞에서 이미 설명했다. 나머지 메서드의 내용을 이해하려면 객체가 그리드 셀과 어떻게 중첩되는지 먼저 생각해야 한다. 다음은 네 가지 충돌 가능성을 정리한 것이다.

  • 객체가 한 셀에 들어 있는 경우 : 이때는 경계 사각형의 좌측 하단과 우측 상단 구석이 모두 셀의 좌표와 동일하게 된다.
  • 객체가 가로로 두 셀에 중첩된 경우 : 이때는 좌측 하단 구석은 한 셀에 있고 우측 상단 구석은 오른쪽 셀에 있게 된다.
  • 객체가 세로로 두 셀에 중첩된 경우 : 이때는 좌측 하단 구석은 한 셀에 있고 우측 상단 구석은 위에 있는 셀에 있게 된다.
  • 객체가 네 셀에 중첩된 경우 : 이때는 좌측 하단 구석은 한 셀에 있고, 우측 하단 구석은 오른쪽 셀, 우측 상단 구석은 위쪽 셀, 우측 하단 구석은 첫 번째 셀의 위쪽 셀에 있다.

이 메서드에서 하는 일은 이런 가능성 각각에 대해서 검사하는 게 전부다. 첫 번째 if문에서는 단일 셀 중첩을 검사하고, 두 번째 if문은 가로 2중 셀 중첩, 세 번째 if문은 세로 2중 셀 중첩, else문은 4셀 중첩을 검사한다. 각 조건 블록에서는 해당 셀 좌표가 세계 내에 포함되는 경우에만 셀의 ID를 설정하고 있다. 이 메서드의 내용은 이게 전부다.

이 메서드를 보면 많은 연산을 필요로 할 것처럼 보인다. 사실이다. 하지만 메서드의 길이를 감안하면 생각보다 연산 비용이 그리 크지 않다. 가장 흔한 경우는 첫 번째 경우이며 이 경우 연산 비용은 가장 적게 든다. 독자들 중에는 이 메서드를 더 최적화할 수 있는 방법을 알고 있는 사람도 있을 것이다.

내용 정리

이 절에서 배운 내용을 종합해 간단한 예제를 만들어 보자. 이번에는 몇 페이지 전에 설명한 대포 예제를 확장할 것이다. 이 게임에서는 대포로 Cannon 객체를, 포탄으로 DynamicGameObject를, 그리고 목표물로 몇 개의 GameObject를 사용한다. 각 목표물은 크기가 0.5×0.5 미터이며 세계상에 임의로 배치할 수 있다.

이 예제에서는 목표물에 발포할 수 있어야 한다. 이를 위해서는 충돌 감지가 필요하다. 충돌을 감지하려면 모든 목표물을 순회하며 포탄과의 충돌을 검사해도 되지만 이렇게 하면 연산이 느려진다. 따라서 여기서는 새로 만든 SpatialHashGrid 클래스를 활용해 빠르게 현재 포탄의 위치를 기준으로 충돌하는 목표물이 있는지 검사할 것이다. 하지만 대포나 포탄을 그리드에 삽입하는 일은 의미가 없으므로 이 작업은 하지 않는다.

이 예제는 매우 길므로 몇 개의 작은 예제로 나눠서 살펴보겠다. 이 예제의 이름은 CollisionTest로 정하고 예제에 포함된 화면은 CollisionScreen으로 정한다. 항상 그렇듯이 여기서는 화면에 대해서만 살펴본다. 먼저 예제 8-12에 나와 있는 멤버와 생성자부터 시작해 보자.

예제 8-12 ㅣ 멤버와 생성자를 보여주는 CollisionTest.java의 발췌 코드

class CollisionScreen extends Screen {
    final int NUM_TARGETS = 20;
    final float WORLD_WIDTH = 9.6f;
    final float WORLD_HEIGHT = 4.8f;
    GLGraphics glGraphics;
    Cannon cannon;
    DynamicGameObject ball;
    List<GameObject> targets;
    SpatialHashGrid grid;

    Vertices cannonVertices;
    Vertices ballVertices;
    Vertices targetVertices;

    Vector2 touchPos = new Vector2();
    Vector2 gravity = new Vector2(0,-10);

    public CollisionScreen(Game game) {
        super(game);
        glGraphics = ((GLGame)game).getGLGraphics();

        cannon = new Cannon(0, 0, 1, 1);
        ball = new DynamicGameObject(0, 0, 0.2f, 0.2f);
        targets = new ArrayList<GameObject>(NUM_TARGETS);
        grid = new SpatialHashGrid(WORLD_WIDTH, WORLD_HEIGHT, 2.5f);
        for(int i = 0; i < NUM_TARGETS; i++) {
            GameObject target = new GameObject((float)Math.random() * WORLD_WIDTH,
                                               (float)Math.random() * WORLD_HEIGHT,
                                                0.5f, 0.5f);
            grid.insertStaticObject(target);
            targets.add(target);
        }
        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);
        targetVertices = new Vertices(glGraphics, 4, 6, false, false);
        targetVertices.setVertices(new float[] { -0.25f, -0.25f,
                                                 0.25f, -0.25f,
                                                 0.25f, 0.25f,
                                                 -0.25f, 0.25f }, 0, 8);
        targetVertices.setIndices(new short[] {0, 1, 2, 2, 3, 0}, 0, 6);
    }

이 클래스는 CannonGravityScreen으로부터 많은 로직을 가져왔다. 이 클래스는 먼저 목표물의 개수와 세계의 크기를 지정하는 다양한 상수를 정의하는 것부터 시작한다. 이어서 GLGraphics 인스턴스와 더불어 대포, 포탄, 목표물을 나타내는 객체(목표물의 경우 리스트에 저장)를 선언한다. 물론 이 클래스에는 SpatialHashGrid도 들어 있다. 세계를 렌더링하려면 몇 개의 메시가 필요한데, 이 중 하나는 대포에, 하나는 포탄에, 하나는 각각의 목표물을 렌더링하는 데 사용한다. BobTest에서 100개의 Bob을 화면에 렌더링할 때 한 개의 사각형만 사용한 것을 기억하자. 여기서도 하나의 Vertices를 통해 목표물의 삼각형들(사각형들)을 보관하는 대신 이 원칙을 그대로 사용한다. 마지막에 있는 두 멤버는 CannonGravityTest와 동일하다. 이들 멤버는 사용자가 화면을 터치할 때 포탄을 발사하고 중력을 적용하기 위해 사용한다.

생성자에서는 앞에서 설명한 일들을 모두 처리한다. 즉 세계 객체와 메시의 인스턴스를 생성한다. 이때 주의해서 볼 점은 해시 그리드의 정적 객체로 목표물도 추가한다는 점이다.

이어서 예제 8-13에 나와 있는 CollisionTest 클래스의 다음 메서드를 살펴보자.

예제 8-13 ㅣ update() 메서드가 들어 있는 CollisionTest.java의 발췌 코드

@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())* WORLD_WIDTH;
        touchPos.y = (1 - event.y / (float) glGraphics.getHeight()) * WORLD_HEIGHT;

        cannon.angle = touchPos.sub(cannon.position).angle();

        if(event.type == TouchEvent.TOUCH_UP) {
            float radians = cannon.angle * Vector2.TO_RADIANS;
            float ballSpeed = touchPos.len() * 2;
            ball.position.set(cannon.position);
            ball.velocity.x = FloatMath.cos(radians) * ballSpeed;
            ball.velocity.y = FloatMath.sin(radians) * ballSpeed;
            ball.bounds.lowerLeft.set(ball.position.x - 0.1f, ball.position.y - 0.1f);
        }
    }

    ball.velocity.add(gravity.x * deltaTime, gravity.y * deltaTime);
    ball.position.add(ball.velocity.x * deltaTime, ball.velocity.y * deltaTime);
    ball.bounds.lowerLeft.add(ball.velocity.x * deltaTime, ball.velocity.y * deltaTime);

    List<GameObject> colliders = grid.getPotentialColliders(ball);
    len = colliders.size();
    for(int i = 0; i < len; i++) {
        GameObject collider = colliders.get(i);
        if(OverlapTester.overlapRectangles(ball.bounds, collider.bounds)) {
            grid.removeObject(collider);
            targets.remove(collider);
        }
    }
}

항상 그렇듯이 제일 먼저 터치와 키 이벤트를 가져오고 터치 이벤트만을 순회한다. 터치 이벤트의 처리는 CannonGravityTest에서와 같다. 차이가 있다면 이전 예제에서 사용한 벡터 대신 Cannon 객체를 사용한다는 것과 터치 업 이벤트 시점에 대포가 발사 준비를 할 때 포탄의 경계 사각형도 재설정한다는 점뿐이다.

다음으로 바뀐 내용은 포탄을 업데이트하는 부분이다. 이번에는 직접 벡터를 사용하는 대신 포탄으로 사용하기 위해 인스턴스를 생성한 DynamicGameObject의 멤버를 사용한다. 여기서는 DynamicGameObject.acceleration 멤버는 무시하고 대신 중력을 포탄의 속도에 추가한다. 또 포탄의 속력에 2를 곱해 포탄이 더 빠르게 날아가게 한다. 이때 주의할 점은 포탄의 위치뿐 아니라 경계 사각형의 좌측 하단 구석 위치도 업데이트해야 한다는 점이다. 이렇게 하지 않으면 포탄은 움직이지만 경계 사각형은 움직이지 않게 되므로 이 부분이 매우 중요하다. 그런데 포탄의 경계 사각형이 포탄의 위치를 갖게 해도 되지 않을까? 물론 그렇게 할 수도 있지만 객체에 첨부할 경계 도형이 여러 개인 경우도 배제할 수 없다. 이 경우 객체의 실제 위치를 보관할 경계 도형을 어떤 것으로 지정해야 할지에 대한 문제가 생긴다. 이처럼 두 값을 서로 분리하면 도움이 되는 측면이 있으며, 연산 부담도 크게 늘어나지 않는다. 속도에 델타 시간을 한 번만 곱한다면 이런 연산을 조금 줄일 수 있긴 하다. 결국 이렇게 하지 않을 때의 추가 연산 부담은 두 번의 추가 벡터 덧셈으로 요약할 수 있다. 하지만 이를 통해 얻을 수 있는 코드의 유연성을 감안한다면 이런 부담은 충분히 감내할 만하다.

이 메서드의 마지막 부분에는 충돌 감지 코드가 나와 있다. 여기서 하는 일은 포탄과 동일한 셀에 들어 있는 해시 그리드상의 목표물들을 찾는 게 전부다. 이를 위해 여기서는 SpatialHashGrid.getPotentialColliders()를 사용한다. 포탄이 들어 있는 셀은 이 메서드에서 직접 판단하므로 여기서는 그리드에 포탄을 삽입하지 않아도 된다. 다음으로 충돌할 수 있는 객체를 모두 순회하고 실제로 포탄의 경계 사각형과 잠재적으로 충돌할 수 있는 경계 영역 사이에 중첩이 있는지 확인한다. 중첩이 있다면 목표물 리스트에서 목표물을 제거한다. 여기서 목표물은 그리드에 정적 객체로만 추가한다는 점을 다시 한 번 기억하자.

이로써 게임 로직을 모두 구현했다. 나머지 코드는 실제 렌더링을 담당하는 내용으로 익숙한 내용이다. 이 코드는 예제 8-14에 나와 있다.

예제 8-14 ㅣ present() 메서드가 들어 있는 CollisionTest.java

@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, WORLD_WIDTH, 0, WORLD_HEIGHT, 1, -1);
    gl.glMatrixMode(GL10.GL_MODELVIEW);

    gl.glColor4f(0, 1, 0, 1);
    targetVertices.bind();
    int len = targets.size();
    for(int i = 0; i < len; i++) {
        GameObject target = targets.get(i);
        gl.glLoadIdentity();
        gl.glTranslatef(target.position.x, target.position.y, 0);
        targetVertices.draw(GL10.GL_TRIANGLES, 0, 6);
    }
    targetVertices.unbind();
    gl.glLoadIdentity();
    gl.glTranslatef(ball.position.x, ball.position.y, 0);
    gl.glColor4f(1,0,0,1);
    ballVertices.bind();
    ballVertices.draw(GL10.GL_TRIANGLES, 0, 6);
    ballVertices.unbind();

    gl.glLoadIdentity();
    gl.glTranslatef(cannon.position.x, cannon.position.y, 0);
    gl.glRotatef(cannon.angle, 0, 0, 1);
    gl.glColor4f(1,1,1,1);
    cannonVertices.bind();
    cannonVertices.draw(GL10.GL_TRIANGLES, 0, 3);
    cannonVertices.unbind();
}

이 메서드에서 새로운 내용은 없다. 항상 그렇듯 먼저 투영 매트릭스와 뷰포트를 설정하고 화면을 정리한다. 다음으로 targetVertices에 저장된 사각형 모델을 재사용해 모든 목표물을 렌더링한다. 이 부분은 BobTest에서 한 것과 기본적으로 같으며 이번에는 목표물을 렌더링한다는 차이만 있다. 다음으로 CollisionGravityTest에서 한 것처럼 포탄과 대포를 렌더링한다.

이 코드에서 주의해서 볼 부분은 포탄이 항상 목표물 위에 그려지고 대포가 항상 포탄 위에 그려지게끔 드로잉 순서를 수정한 부분이다. 또 glColor4f()를 사용해 목표물은 녹색으로 칠했다.

이 코드의 테스트 결과는 그림 8-17과 같으므로 다시 수록하지 않겠다. 코드를 실행하고 포탄을 발사하면 포탄이 목표물을 향해 날아갈 것이다. 아울러 포탄과 충돌하는 목표물은 게임 세계에서 사라질 것이다.

이 예제를 조금만 가다듬고 게임의 흥미를 유발하는 동기를 제공하면 정말 멋진 게임이 될 수 있다. 독자들이 보기에는 어떤 부분을 추가하면 좋을지 생각해 보자. 필자는 앞에서 익힌 새로운 내용들이 익숙해질 때까지 이 예제를 여러 번 응용해 볼 것을 권장한다.

이 장을 마무리하기 전에 설명하고 싶은 내용이 몇 개 더 있다. 바로 카메라와 텍스처 아틀라스, 스프라이트 등이다. 이들 내용은 그래픽 관련한 트릭으로서 게임 세계의 모델과 상관없이 적용할 수 있다. 그럼 각 내용을 하나씩 살펴보자.