[게임 프로그래밍 패턴] Chapter11 바이트 코드

Jangmanbo·2023년 11월 10일
0

데이터 > 코드

마법을 코드로 구현한다면, 마법을 고칠 때마다 코드를 고쳐야 한다.
기획자가 수치를 약간 바꾸고 싶을 때도 코드를 수정하여 게임 전체를 빌드해야 한다.

따라서 마법과 같은 데이터는 게임 코드와 격리할 필요가 있다.
행동을 데이터 파일에 따로 정의하고 게임 코드에서 읽어서 실행하는 것이다.

그렇다면 어떻게 파일에 있는 바이트로 행동을 표현할 수 있을까?

인터프리터 패턴

(1 + 2) * (3 - 4)

여기서 연산자와 피연산자 모두 객체로 바뀌며, 이 표현식은 객체 트리로 바뀐다.
인터프리터 패턴의 목적은 이런 추상 구문 트리를 만들고 이를 실행하는 원리에 있다.

모든 표현식 객체가 상속받을 상위 인터페이스

class Expression {
public:
    virtual ~Expression() {}
    virtual double evaluate() = 0;
};

언어에서 지원하는 모든 표현식마다 Expression 인터페이스를 상속받는 클래스 정의

// 숫자 표현식 클래스
class NumberExpression : public Expression {
public:
    NumberExpression(double value) : value_(value) {}
    virtual double evaluate() { return value_; }

private:
    double value_;
};
// 덧셈 표현식 클래스
// 같은 방식으로 곱셈 표현식 정의하기
class AdditionExpression : public Expression {
public:
    AdditionExpression(Expression* left, Expression* right)
    : left_(left), right_(right) {}

    virtaul double evaluate() {
        double left = left_->evaluate();
        double right = right_->evaluate();

        return left + right;
    }
private:
    Expression* left_;
    Expression* right_;
};

이렇게 클래스 몇 개만으로 어떤 복잡한 수식 표현도 나타낼 수 있다.

인터프리터 패턴의 문제점

  • 코드를 로딩하면서 작은 객체를 엄청 많이 만들고 연결
  • 객체와 객체를 잇는 포인터가 많은 메모리를 소모
  • 포인터를 따라서 하위표현식에 접근해야 하기 때문에 데이터 캐시에 치명적, 가상 메서드 호출은 명령어 캐시에 치명적
    데이터 지역성: https://erikanes.tistory.com/538
    가상 함수: https://blog.naver.com/eowns9753/220650256908

-> 매우 느리고 메모리가 많이 필요하여 대부분의 프로그래밍 언어는 인터프리터 패턴을 사용하지 않는다.



가상 기계어

게임이 실행될 때 플레이어의 컴퓨터는 C++ 트리구조를 순회하는 것이 아니라 미리 컴파일해놓은 기계어를 실행한다.

기계어 장점

  • 밀도가 높다: 바이너리 데이터가 꽉 차있다
  • 선형적이다: 명령어가 같이 모여 있고, 순서대로 실행된다 (메모리를 넘나들지 않는다)
  • 저수준이다: 각 명령어는 비교적 최소한의 작업만 한다
  • 속도가 빠르다

그러나 우리가 실제로 게임을 기계어로 구현하여 유저에게 제공하는 것은 보안에 취약하다.
기계어의 성능과 인터프리터 패턴의 안정성 사이에서 절충해야 한다.

실제 기계어를 실행하지 말고, 가상 기계어를 정의하고 이를 실행하는 에뮬레이터를 만들자.

  • 가상 기계어(바이트 코드): 실제 기계어처럼 밀도가 높고, 선형적이고, 저수준이지만, 게임에서 완전히 제어하여 안전하다.

에뮬레이터는 가상 머신(VM), VM이 실행하는 가상 바이너리 기계어는 바이트코드라 한다.



바이트 코드 패턴

언제 쓸 것인가?

  • 언어가 너무 저수준이라 만드는 데 손이 많이 가고 오류가 쉽게 발생
  • 컴파일 시간으로 인해 반복 개발이 오래 걸림
  • 보안에 취약. 정의하려는 행동이 나머지 코드로부터 격리 필요

한마디로 정의할 행동은 많은데 게임 구현에 사용한 언어로는 구현하기 어려울 때 사용한다.

주의사항

  1. 바이트코드는 네이티브 코드보다는 느리므로 성능이 민감한 곳에는 적합하지 않다.
  2. 또 다른 시스템을 만드는 것은 처음엔 단순해도 실제로 적용하다 보면 규모가 커지기 마련이다.
    따라서 바이트코드가 표현할 수 있는 범위를 꼼꼼히 관리해야 한다.

프론트엔드가 필요

애초에 행동 구현을 코드에서 따로 빼낸 이유는 고수준으로 표현하기 위해서이다.

사용자가 고수준 형식으로 원하는 행동을 작성하면,
어떤 툴(컴파일러)이 이를 가상머신이 이해할 수 있는 바이트코드로 변환한다.

이러한 컴파일러를 만들 여유가 없다면 파이트코드 패턴을 쓰기 어렵다. (그러나 만드는게 그리 어렵지 않다.)

디버거의 부재

버그를 찾기 위한 디버거, 정적 분석기, 디컴파일러 같은 툴은 기성언어(assembly, high level language)에서나 지원한다.
바이트코드를 읽는 VM에서는 이런 툴을 지원하지 않는다.


바이트 코드 패턴 예제

마법 API

마법에는 마법사의 스탯 중 하나를 바꾸는 마법이 있을 것이다.

// wizard: 0이면 우리 마법사, 1이면 상대 마법사
void setHealth(int wizard, int amount);
void setWisdom(int wizard, int amount);
void setAgility(int wizard, int amount);

이런 API만으로도 다양한 마법효과를 만들 수 있다.

void playSound(int soundId);
void spawnParticles(int particleType);

연출 API도 넣어주자..

마법 명령어 집합

앞서 만든 API가 데이터에서 제어 가능하도록 바꿔보자.

우선 매개변수를 전부 제거하면, 마법은 단순한 명령어 집합이 된다.
이때 각 명령어는 열거형으로 표현될 수 있다.

enum Instruction {
	INST_SET_HEALTH = 0x00,
	INST_SET_WISDOM = 0x01,
	INST_SET_AGILITY = 0x02,
	INST_PLAY_SOUND = 0x03,
	INST_SPAWN_PARTICLE = 0x04
};

마법을 데이터로 인코딩하려면 이러한 열거형 값을 배열에 저장하면 된다.
이렇게 마법을 만들기 위한 코드가 실제로는 바이트들의 목록이라서 바이트 코드라고 불린다.

switch (instruction) {
	case INST_SET_HEALTH:
    	setHealth(0, 100);
        break;
    case INST_SET_WISDOM:
    	setWisdom(0, 100);
        break;
    case INST_SET_AGILITY:
    	setAgility(0, 100);
        break;        
    case INST_PLAY_SOUND:
    	playSound(SOUND_BANG);
        break;
    case INST_SPAWN_PARTICLE:
    	spawnParticles(PARTICLE_FLAME);
        break;
};

명령 하나를 실행하려면 어떤 명령인지 보고 이에 맞는 API 메서드를 호출한다.
이렇게 코드와 데이터를 연결한다.

class VM {
public:
	void interpret(char bytecode[], int size) {
    	for (int i = 0; i < size; i++) {
        	char instruction = bytecode[i];
            switch (instruction) {
            	// 각 명령별 case문
            }
        }
    }
};

또 마법 전체를 실행하는 VM에서는 이렇게 명령어를 읽는다.
하지만 이 가상머신에는 매개변수가 없다. 실제 언어와 같이 매개변수도 받을 수 있어야 한다.

스택 머신

복잡한 중첩식을 빠른 속도로 실행하기 위해, 스택을 이용하여 명령어 실행 순서를 제어한다.
(앞서 인터프리터 패턴은 중첩 객체 트리로 중첩식을 표현했고, 매우 느렸다.)

class VM {
public:
	VM:stackSize_(0) {}
    
private:
	void push(int value);
    int pop();
    
private:
	static const int MAX_STACK = 128;
    int stackSize_;
    int stack_[MAX_STACK];
};

명령어가 매개변수를 받게 하기 우해 다음과 같이 스택에서 꺼내온다.

switch (instruction) {
	case INST_SET_HEALTH:
    	int amount = pop();
        int wizard = pop();
    	setHealth(wizard, amount);
        break;
    case INST_SET_WISDOM:
    	// 같은 방식...
};

이렇게 스택에서 값을 얻어오기 위해서는 리터럴 명령어가 필요하다.

case INST_SET_HEALTH:
    int value = bytecode[++i];
    push(value);
    break;
};
리터럴 명령어
0x05123

리터럴 명령어를 만나면 옆에 있는 바이트를 숫자로 읽는다.

이렇게 마음대로 스탯을 바꿀 수 있게 되었다.
그러나 체력을 지혜 스탯의 반만큼 회복하기와 같은 마법은 아직 만들 수 없다.
숫자만이 아니라 규칙으로 마법을 표현할 수 있게 해보자.

행동 = 조합

지금까지 만든 VM은 아직 몇 가지 내장함수와 상수 매개변수만 지원한다.
바이트코드가 좀 더 행동을 표현할 수 있게 하려면 조합을 할 수 있어야 한다.

case INST_GET_HEALTH:
{
	int wizard = pop();
	push(getHealth(wizard));
	break;
}

case INST_ADD:
{
	int b = pop();
	int a = pop();
	push(a + b);
	break;
}

이러한 명령어들이 있으면 다른 마법사의 스탯을 기억하고, 연산하여 마법을 만들 수 있다.

가상 머신

지금까지 만든 간단한 VM을 통해 데이터 형태로 행동을 마음껏 정의할 수 있게 되었다.
(스택, 반복문, 스위치만으로 만든 결과이다.)

이렇게 행동을 코드와 안전하게 격리했다.
바이트코드에서는 정의해놓은 명령 몇 개를 통해서만 다른 코드에 접근할 수 있어, 잘못된 위치에 접근할 방법이 없기 때문이다.

마법 제작 툴

아직까지는 기획자가 수정할만한 VM은 아니다. (너무 저수준이다.)
툴을 이용해 고수준으로 정의하면 이를 저수준인 스택 머신 바이트코드로 변환해야 한다.

바이트코드 패턴의 궁극적 목표는 사용자가 행동을 고수준 형식으로 편하게 표현하는 것이다.
따라서 텍스트 파일 보다는, 사용성도 좋고 문법적 오류로 인한 에러 발생의 위험이 없는 그래픽 인터페이스를 제공하는 것이 좋다.

디자인 결정

이 항목은 생략..ㅎㅎ





어디서 바이트코드 패턴을 쓰는 건지 아직도 잘 감이 안온다..
언리얼 블루프린트가 그 예시인가 싶기는 한데, 맞나?
https://sungjjinkang.github.io/ue4_blueprint

예시를 안다면 댓글 달아주세요,,

0개의 댓글