플레이어가 곰인형을 쓰레기 압축기에 넣는 것 같은 하이퍼-특정 상호작용에 게임이 어떻게 반응해야 하는지를 다룬다. 일관성과 특수성 사이의 트레이드오프를 짚고, Bevy의 ECS가 초래하는 보일러플레이트를 비판하며, 정적 마커/리소스 대신 런타임 이름/프로퍼티 맵을 쓰는 더 유연한 접근을 제안한다.
이게 체호프의 총 규칙이지요: 총이 하나 있어라.
이제 관객이 봤으니, 막이 내리기 전에 누군가를 쏘아야겠군...
게임에 곰인형과 쓰레기 압축기가 있다면, 플레이어 중 누군가는 그 곰인형을 그 쓰레기 압축기에 넣어 보려 할 가능성이 크다. 왜일까? 그것은 작고 결과가 없는 순수한 허무주의적 행위이기 때문이다. 그냥 어떻게 되는지 보고 싶어서. 세상이 타버리는 걸 보고 싶어하는 사람들도 있으니까. 왜 인지는 사실 중요하지 않다.
나는 곰인형-쓰레기 압축기 시나리오를, 플레이어가 스스로 재미를 만들어 내는 사례로 들고 왔다. 게임 디자이너인 내게 이런 행동들이 흥미로운 이유는 다음과 같다.
게임은 이런 행동을 최대한 잘 알아차리고 반응해야 한다고 생각한다. 곰인형을 압축기에 집어넣었는데 근처 NPC가 _"(헉) 넌 괴물이야"_라고 하면, 웃기기도 하고 감정적 임팩트를 강조해 준다. 난 그런 게 정말 좋다.
이쯤에서 _Theories of Fun_이니 뭐니 하며 왜 이런 상호작용이 게임을 특별하게 만드는지로 넘어갈 수 있겠지만, 그건 다음 글로 미루겠다. 이번 글은 메커닉에 관한 것이다. 즉, 이런 것들에 반응할 수 있도록 게임의 상태를 어떻게 추적할 것인가?
당신이 게임을 만든다고 하자. 어떤 글로벌 상태가 있고, 맵의 조각이나 NPC, 아이템일 수 있는 수많은 "게임 오브젝트"가 있다. 이 데이터는 어떻게 구성해야 할까? 글쎄, 경우에 따라 다르다.
그 데이터가 어떻게 사용될지를 고려해야 한다. 어떤 동작을 구동할 것인가? 보게 되겠지만, 동작의 유형에 따라 맞는 표현 방식과 구성 방식이 달라진다.
행동 요구사항으로 아주 흔한 것이 _일관성_이다. 예를 들어 물리를 보자. 물리 법칙은, 내가 알기로, 어디서나 꽤 비슷하다. 모든 게임 오브젝트에 동일한 물리를 적용하고 싶다면, 일반적으로 단일한 물리 구현을 갖추고 싶을 것이다. 그리고 그 구현은 주기적으로 게임 오브젝트들을 순회하며 물리 데이터를 읽고 업데이트해야 한다.
이렇게 생각해 보면 데이터 표현에 대해 몇 가지 결론을 낼 수 있다. 모든 물리 데이터를 큰 균일 배열에 저장하는 것이 아마 좋은 선택일 것이다. 반대로 각 게임 오브젝트(혹은 오브젝트의 클래스)마다 맞춤 물리 속성을 두는 것은 아마 나쁜 생각일 수 있다. 어떤 오브젝트는 관성모멘트를 다른 단위로 표현하고, 어떤 오브젝트는 위치와 자세를 사영 행렬 대신 사원수로 표현한다면, 일관된 동작을 달성하기가 더 어려워질 것이다.
일반적으로, 일관된 동작을 얻으려면, 일관된 장소에 일관된 데이터를 두는 것이 최선이다. 교과서적인 이야기다.
그렇지만 때로는 _특수성_이 일관성보다 더 중요하다. 내 곰인형-쓰레기 압축기 예시는 하이퍼-특정하다. 압축기는 하나, 곰인형도 하나뿐이니 일관성은 전혀 문제가 아니다.
고립된 상황에서는, 불일관한 데이터로 무엇을 하든 상관없다. 하지만 수천 개의 구체적 상태 변수가 있다고 해 보자. 그것들이 모두 다른 타입이라면? 규모가 커지면, 아무리 불일치하더라도, 고도로 특정한 동작에는 또 다른 제약이 생긴다. 핵심 문제는 사용 편의성이다. 임의(ad-hoc) 상태를 저장하기 쉬울수록, 더 자주 그렇게 하게 된다. 그러니 고도로 특정한 동작에는 손쉬운 데이터 정의와 접근이 필수다.
실제로는, 일관성과 특수성 사이에 종종 트레이드오프가 있다. 일관된 동작은 더 많은 게임 오브젝트에 영향을 주므로, 설정과 유지가 더 복잡한 보일러플레이트 많은 패턴을 감수할 수 있다. 하이퍼-특정 동작은 그런 사치를 누릴 수 없다. 잠깐 한 오브젝트에만 적용될 수도 있으니, 셋업과 사용 비용이 거의 들지 않는 패턴에 묶일 수밖에 없다.
내가 왜 이런 말을 하느냐고? 지난 몇 달 동안, 이 일관성/특수성 트레이드오프가 ECS 패턴에 대해 내가 겪는 몇몇 문제의 바탕이라는 걸 서서히 깨달았기 때문이다.
여긴 게임 엔진 설계 블로그다. 아마 ECS를 들어봤을 것이다. 만약 아니라면, ECS는 게임에서 흔히 쓰이는 데이터 저장 패턴이다. 약어다.
ECS 패턴은 _일관성_에 훌륭하다. 모든 시스템은 동작을 정의하고, 엔티티는 특정 컴포넌트 조합을 추가함으로써 그 동작에 옵트인할 수 있다. 이론적으로 ECS는 구성 가능성(컴포지션)도 제공한다. 여러 고립된 시스템이 각자 다루는 엔티티들 위에서 함께 작동하며 복잡한 창발적 동작을 만든다.
이런 이점들은 대개 비용을 수반한다. 엔티티에 붙은 컴포넌트를 추적하는 데 오버헤드가 있고, 새 컴포넌트와 시스템을 설정하는 데도 보일러플레이트가 있다. 그리고 내 생각에, 전형적인 컴포넌트 접근법은 불일관하거나 하이퍼-특정한 동작을 구현할 때 방해가 되는 경향이 있다.
내가 선호하는 ECS(Bevy)로 곰인형-쓰레기 압축기를 구현하려면 어떤 작업이 필요한지 보자.
우선 곰인형과 쓰레기 압축기 게임 오브젝트를 식별할 방법이 필요하다. ECS에는 이를 위한 관용구가 있는데, 데이터가 없는 "마커 컴포넌트"다.
#[derive(Component)]
struct TeddyBear;
#[derive(Component)]
struct TrashCompactor;
실제 상태를 저장할 곳도 필요하다. Bevy에서는 보통 "리소스"(사실상 전역 데이터 컨테이너)에 두게 된다.
#[derive(Resource)]
struct IsReadyBearInTrashCompactor(bool);
이 리소스는 게임 시작 시 초기화되어야 한다(세이브 간 상태를 유지하려면 등등). 그러려면 플러그인에 등록해야 한다. 해당 게임에는 아마 이미 플러그인이 있을 테니 거기에 추가하자.
impl Plugin for MyGame {
fn build(&self, app: &mut App) {
...
app.init_resource::<IsReadyBearInTrashCompactor>();
...
}
}
이제 시스템에서 실제 거리 체크를 하고 상태 리소스를 업데이트할 수 있다.
impl IsReadyBearInTrashCompactor {
fn update(
mut self: ResMut<Self>
teddy_bear: Single<&Transform, With<TeddyBear>>,
trash_compactor: Single<&Transform, With<TrashCompactor>>,
) {
let dist = teddy_bear.translation.distance(trash_compactor.translation);
if dist < 1.0 {
self.0 = true;
}
}
}
이것도 플러그인 안에서 실행되도록 스케줄에 등록해야 한다.
...
app.init_resource::<IsReadyBearInTrashCompactor>();
app.add_systems(PostUpdate, IsReadyBearInTrashCompactor::update);
...
모두 합치면 이렇게 된다.
#[derive(Component)]
struct TeddyBear;
#[derive(Component)]
struct TrashCompactor;
#[derive(Resource)]
struct IsReadyBearInTrashCompactor(bool);
impl IsReadyBearInTrashCompactor {
fn update(
mut self: ResMut<Self>
teddy_bear: Single<&Transform, With<TeddyBear>>,
trash_compactor: Single<&Transform, With<TrashCompactor>>,
) {
let dist = teddy_bear.translation.distance(trash_compactor.translation);
if dist < 1.0 {
self.0 = true;
}
}
}
impl Plugin for MyGame {
fn build(&self, app: &mut App) {
...
app.init_resource::<IsReadyBearInTrashCompactor>();
app.add_systems(PostUpdate, IsReadyBearInTrashCompactor::update);
...
}
}
상대적으로 단순한 기능치고는 코드가 꽤 많다! 타입도 잔뜩 늘어난다. 비슷한 작은 상호작용이 수천 개 있으면, 컴파일 시간이 느려지고, 관련 것을 찾기가 어려워지는 등 문제가 생긴다. 아주 작은 이득을 위해 컴파일러에 많은 일을 떠넘기는 셈이다.
평행우주의 다른 방식으로 같은 걸 쓰면 이렇게 된다.
fn is_teddy_bear_in_trash_compactor(world: &mut World) -> Result<()> {
let teddy_bear = world.entity_named("teddy_bear")?.get::<Transform>()?;
let trash_compactor = world.entity_named("trash_compactor")?.get::<Transform>()?;
let dist = teddy_bear.translation.distance(trash_compactor.translation);
if dist < 1.0 {
world.set_property("is_teddy_bear_in_trash_compactor", true);
}
Ok(())
}
impl Plugin for MyGame {
fn build(&self, app: &mut App) {
...
app.add_systems(PostUpdate, is_teddy_bear_in_trash_compactor);
...
}
}
여기서 두 가지만 바꿨다.
이건 큰 개선이라고 본다. 이전 버전과 비교해 더 자체 포함적이다. 타입 시스템 사용도 줄였다(잠재적으로 컴파일이 빨라지고) 매크로도 덜 쓴다. 규모를 떠올려 보자. 마커 컴포넌트가 몇백 개만 돼도 누적된다. 몇백 개의 플래그는 천 개가 넘는, 대부분 쓸모없는 타입이 될 수 있고, 우리는 그것들을 관리하고 컴파일 끝나길 기다려야 한다.
여기엔 유연성도 더 있다. 원한다면 이름을 전적으로 런타임에 정의할 수도 있다.
첫 번째 구현에서 두 번째로 오며 우리가 희생한 것은 무엇일까? 정합성이다. 곰인형 대신 동반자 큐브로 바꾸거나, 쓰레기 압축기 대신 소각로로 바꾼다고 해 보자. 첫 번째 버전에서는 컴파일러가 변경을 일관되게 했는지 보장해 준다. 두 번째 버전에서는 이름에 대한 모든 참조를 우리가 직접 올바르게 업데이트해야 한다.
하지만 여기서 컴파일러의 체크가 줄어드는 것이 큰 손실이라고는 생각하지 않는다. 컴파일러가 체크할 수 없는 것들이 있고, 체크하길 바라지 않는 것들도 있다.
이런 동적 패턴으로 할 수 있는 일들이 너무 많다. 정합성에서 잃는 만큼, 유연성과 상호운용성에서 만회한다.
내가 앞서 암시한, 많은 ECS 구현에 대한 불만은 이렇다. 종종 동적 패턴을 아예 포함하지 않거나, 심지어 그것을 못마땅해한다. 아마 ECS 기반 엔진(특히 Bevy) 사용자들 사이엔 약간의 선택 편향이 있을 것이다. 타입 체크와 정합성, 크고 일관된 시스템을 좋아하는 사람들을 끌어들이는 식으로 말이다. 그런 사람들은 예외, 불일관성, 애드혹 동작, 특수성을 좋아하지 않는 경향이 있다. 당신 작업에 그런 API가 필요 없다면 싫어하는 건 괜찮다. 하지만 엔진이 이런 것들을 지원하는 건 여전히 중요하다고 생각한다.
이런 패턴을 못 쓰게(혹은 덜 쓰게) 만드는 엔진은, 디자이너가 일관성/특수성, 정합성/유연성 트레이드오프를 스스로 결정할 수 있는 능력을 빼앗는다. 그리고 정적이고 정확하며 일관된 패턴에만 집중하면 어떤 것들을 구현하기가 더 어려워질 수 있다. 내가 앞서 든 카테고리들(콘텐츠 편집, 스크립팅, 네트워킹)은 역사적으로 Bevy의 약점이었다. 어쩌면 우리가 단지 잘못된 패턴을 쓰고 있기 때문일지도 모른다.
아직도 회의적인 분들이 있을 수 있다. 괜찮다. 이 글은 "보여주기"보다 "말하기"가 더 많았다. 다음 글에서는, 이런 런타임-동적 접근으로 얼마나 그럴듯한 규칙 기반 반응 시스템(소스 엔진의 대화 시스템을 모델로 삼음)을 구현할 수 있는지 더 구체적 예시로 보여 주겠다.
그때까지, 다가올 것의 전조를 하나 남겨둔다...
(criterion TeddyBearInTrashCompactor (is_teddy_bear_in_trash_compactor == true))
(rule (ConceptInteract Ally NpcIdle TeddyBearInTrashCompactor) (YouMonster))
(rule (ConceptInteract Ally IsLisa NpcIdle TeddyBearInTrashCompactor) (NeverLikedBears))
(response YouMonster list
(line "(헉) 너 괴물이야!"))
(response NeverLikedBears list
(line "원래 곰은 안 좋아했어."))