메뉴
HN
Hacker News 36일 전

엘든 링의 단순한 AI 구조

IMP
6/10
핵심 요약

엘든 링의 개발사인 프롬소프트는 복잡하고 강력한 NPC AI를 구현하기 위해 상태 기계(FSM)를 넘어 푸시다운 오토마타(PDA) 기반의 목표 스택 시스템을 사용합니다. 개별 행동은 스택에 쌓아 처리하며 실패 시 상위 목표로 되돌아가는 방식으로, 고도화된 기술보다는 단순하고 효율적인 설계를 통해 뛰어난 게임 플레이를 구현합니다. 이는 게임 AI 설계에 있어 실용적인 아키텍처의 중요성을 보여줍니다.

번역된 본문

엘든 링의 단순한 AI 기술

프롬소프트(FROMSOFT)는 소울스본(Soulsborne) 시리즈 전반에 걸쳐 다양하고 가혹한 NPC 조우를 선보이는 것으로 유명하지만, 이러한 AI 의사결정 시스템의 실제 구현 방식은 생각보다 매우 단순한 기술에 기반하고 있습니다. 게임 코드의 대부분이 하복 스크립트(Havok Script, 하복사의 게임용 루아 구현체)로 작성되었기 때문에, 안개 너머의 시스템이 어떻게 구현되었는지 들여다보기가 꽤 쉽습니다. 이 글의 내용은 제가 직접 연구한 것이 아니라, 다른 사람들이 힘들게 추출하고 디컴파일하며 역설계한 코드를 그저 읽어본 것임을 참고하시기 바랍니다.

목표(Goals)

프롬소프트 AI 접근 방식의 핵심 도구는 '목표(Goal)'입니다. 이는 AI가 취할 수 있는 고유한 상태를 지칭하는 그들만의 용어입니다. 목표는 인스턴스화될 때 매개변수를 가질 수 있고 액터(Actor, 게임 내 객체) 자체에 저장된 데이터에 접근할 수 있지만, 본질적으로는 변경할 수 없는(immutable) 함수 테이블과 같습니다.

가장 단순한 방식은 여러 상태를 유한 상태 기계(Finite State Machine, FSM)나 계층적 유한 상태 기계(Hierarchical FSM)로 구성하는 것이겠지만, 프롬소프트는 여기서 한 발 더 나아가 시스템에 상태 스택(Stack)을 도입했습니다. 이는 일반적인 FSM을 푸시다운 오토마타(Pushdown Automaton, PDA)로 바꾸는 작업입니다.

이는 다소 추상적인 정의이므로, 위키피디아에서 돌아온 후 이를 하향식으로 구체적으로 살펴보겠습니다. 매 프레임마다 액터는 자신의 목표 스택에서 최상단에 있는 목표를 업데이트합니다. 목표가 업데이트될 때, 하위 목표(Sub-Goal)를 스택에 추가할 수 있으며, 이렇게 추가된 가장 최상단의 목표는 다음 프레임에 실행됩니다.

목표의 업데이트 함수는 계속(Continue), 성공(Success), 또는 실패(Failure)를 나타내는 값을 반환합니다. '계속'은 스택을 변경하지 않고 그대로 유지하며, 나머지 두 값은 해당 목표를 스택에서 제거(pop)하도록 합니다. 특히 '실패'의 경우, 상위 목표(이 하위 목표를 호출한 목표)에 도달할 때까지 스택에 있는 다른 모든 미실행 목표들도 함께 제거되도록 합니다.

예를 들어, '멋진 보스전(CoolBossBattle)'이라는 목표를 정의했다고 가정해 보겠습니다. 이 목표가 실행되는 동안 일련의 '공격 하위 목표'들이 스택에 추가될 수 있습니다. 이러한 공격 목표들은 다양한 방식으로 매개변수를 받을 수 있지만, 가장 주요한 것은 애니메이션 ID입니다.

[ 목표 스택 ] 3: Attack (R2, 콤보) <<<<-- 현재 업데이트 중 2: Attack (R2, 반복) 1: Attack (R2, 피니시) 0: CoolBossBattle

몇 초 후 첫 번째 공격이 적중하고, 해당 목표는 성공적으로 완료되어 스택에서 제거됩니다. 하지만 바로 다음 공격이 실패하면, 스택은 상위 목표로 되감기(unwind) 됩니다.

[ 목표 스택 ] 2: Attack (R2, 반복) <<<<-- 실패, 스택에서 제거됨. 1: Attack (R2, 피니시) <<<<-- 마찬가지로 제거됨. 0: CoolBossBattle

이제 시도했던 공격 콤보가 종료되었으므로, 보스는 다음 행동을 선택할 준비가 됩니다.

[ 목표 스택 ] 2: Attack(L1) 1: Attack(L1) 0: CoolBossBattle <<<<-- 업데이트 중, 다음 프레임을 위해 1과 2를 스택에 추가함.

그렇게 복잡하지 않습니다! 프롬소프트의 API에서는 이 스택의 루트를 '최상위 목표(Top Level Goal)'라고 부르는데, 저는 현재 실행 중인 목표를 스택의 '최상단(top)'이라고 부르면서 개념을 혼란스럽게 만들었습니다. 이 둘은 별개의 것이라는 점을 명심하시기 바랍니다.

활성화(Activate)

목표는 콜백으로 사용되는 몇 가지 함수로 정의되며, 그중 가장 많은 AI 로직을 포함하는 함수가 보통 '활성화(Activate)'입니다. 이 함수는 목표가 처음으로 업데이트될 때 호출되며, 이후 목표가 하위 목표들을 모두 소진하고 다시 실행을 시작할 때마다 호출됩니다.

보스 및 일반 NPC 목표의 경우, '활성화' 내부의 코드는 세계 및 액터의 컨텍스트와 (액터 자체에서 나오는) 무작위성을 혼합하여 액터가 취할 다음 행동을 선택하는 역할을 합니다. 가장 널리 사용되는 접근 방식은 공통 코드를 사용하여 여러 행동(단순한 함수들) 간에 가중치 무작위 선택을 수행하고, 선택된 행동을 호출하는 것입니다.

다시 우리의 '멋진 보스전'으로 돌아가서, 이번에는 대략적인 러스트(Rust) 의사코드로 살펴보겠습니다...

fn action_giga_death_ray ( goals : & Goals , actor : & Actor ) { todo ! ( ) ; } fn action_leap_attack ( goals : & Goals , actor : & Actor ) { todo ! ( ) ; } fn action_ground_slam ( goals : & Goals , actor : & Actor ) { todo ! ( ) ; } fn action_light_attack_combo ( goals : & Goals , actor : & Actor ) { let ta...

원문 보기
원문 보기 (영어)
The Low-Tech AI Of Elden Ring FROMSOFT has a reputation for diverse and punishing npc encounters across the entire Soulsborne extended series, but the implementation of the AI decision making itself is perhaps unexpectedly low-tech. Since the majority of the code is implemented in Havok Script (A games-oriented Lua implementation from Havok) it’s pretty easy to take a peek behind the fog wall to see how they’re implemented. Note that none of what follows is original research, I’m just reading the code that others have done the hard work of extracting, decompiling, and reversing. Goals The primary tool of the FROMSOFT AI approach is the Goal 1 , which is their own terminology for a unique state that the AI can be in. Goals can be parametized when instanciated, and can access data stored on the Actor itself, but are otherwise really just an immutable table of functions. Now the simplest option would be to organize states into a Finite State Machine or maybe a Hierarchical Finite State Machine, but FROMSOFT go one step further and give the system a stack of states. This turns it from an FSM into Pushdown Automaton (PDA) . That’s an entirely abstract definition, so after you get back from wikipedia let’s talk about it concretely from the top down. Each frame Actors will update the Goal on top of their stack of Goals. When the Goal updates, it can then push more Goals as Sub-Goals onto the stack, the topmost of which will execute next frame. The Goal’s update function returns a value indicating either Continue, Success, or Failure. Continue will leave the stack unchanged, the other two will cause the Goal to be popped from the stack. Failure will additionally cause all other unexecuted Goals to be popped from the stack up to the parent Goal (The Goal which pushed this sub-goal). For example, we might define a Goal called CoolBossBattle , during the course of its execution it might then push a series of Attack Sub-Goals. Those attack Goals can be parametized by various means, but the main one is the animation id 2 . [ GOAL STACK ] 3: Attack (R2, Combo) <<<<-- Currently Updating 2: Attack (R2, Repeat) 1: Attack (R2, Finisher) 0: CoolBossBattle After a few seconds the first attack lands, and that Goal completes with success and is popped from the stack. However the next fails, causing the stack to unwind to its parent. [ GOAL STACK ] 2: Attack (R2, Repeat) <<<<-- Failed, will be popped from the stack. 1: Attack (R2, Finisher) <<<<-- Will be removed as well. 0: CoolBossBattle Readying it to chose its next action now that the attempted combo of attacks has ended. [ GOAL STACK ] 2: Attack(L1) 1: Attack(L1) 0: CoolBossBattle <<<<-- Updating, pushes 1, and 2 for the next frame. Not too complex 3 ! In their APIs they refer to the root of this stack as the “Top Level Goal”, which I’ve made confusing by referring to the currently executing goal as the “top” of the stack. So keep in mind those are separate things. Activate Goals are defined by a few functions used as callbacks, and the one which contains the most AI logic is usually activate. This is called the first time that a Goal is updated, and then every subsequent time that the Goal exhausts its Sub-Goals and starts executing again. For boss and regular npc Goals the code in Activate is responsible for choosing the next action that the Actor will take using a mix of context from the world and Actor, and randomness (which also comes from the Actor itself). The most widely used approach uses common code to perform a weighted random selection between a number of Actions (which are just functions), calling the winner. To return to our CoolBossBattle , this time in some Rusty pseudocode… fn action_giga_death_ray ( goals : & Goals , actor : & Actor ) { todo ! ( ) ; } fn action_leap_attack ( goals : & Goals , actor : & Actor ) { todo ! ( ) ; } fn action_ground_slam ( goals : & Goals , actor : & Actor ) { todo ! ( ) ; } fn action_light_attack_combo ( goals : & Goals , actor : & Actor ) { let target_distance = actor . target_distance ( Target :: Enemy ) ; let fate = actor . next_random ( ) ; // ApproachTarget itself being a goal defined in common code! if target_distance > 2.0 { goals . push_sub_goal ( Goal :: ApproachTarget , Target :: Enemy ) ; } goals . push_sub_goal ( Goal :: Attack , AnimId :: R1 , Combo :: Initial ) ; goals . push_sub_goal ( Goal :: Attack , AnimId :: R1 , Combo :: Repeat ) ; // Unlucky buster! It's the long combo. if fate < 0.2 { goals . push_sub_goal ( Goal :: Attack , AnimId :: R1 , Combo :: Repeat ) ; } goals . push_sub_goal ( Goal :: Attack , AnimId :: R1 , Combo :: Finisher ) ; } fn action_heavy_attack_combo ( goals : & Goals , actor : & Actor ) { todo ! ( ) ; } fn activate ( & self , goals : & Goals , actor : & Actor ) { let target_distance = actor . target_distance ( Target :: Enemy ) ; let mut weights = if target_distance > 6.0 { [ 15.0 , 65.0 , 0.0 , 10.0 , 10.0 , ] } else if target_distance > 1.5 { [ 0.0 , 0.0 , 5.0 , 60.0 , 35.0 , ] } else { [ 0.0 , 0.0 , 20.0 , 40.0 , 40.0 , ] } ; // This doesn't exactly work this way in the Lua code, and these cooldowns // don't make sense either, but hopefully it gives the rough idea. // // The helper function is checking last played data for the animation on the // Actor itself, and then modifying the weights before they go into the // common battle randomized selection. weights [ 3 ] = if common :: is_cooldown ( goals , actor , AnimId :: R1 , 8.0 ) { 0.0 } else { weights [ 3 ] ; } ; weights [ 4 ] = if common :: is_cooldown ( goals , actor , AnimId :: R2 , 10.0 ) { 0.0 } else { weights [ 4 ] ; } ; let actions = [ action_giga_death_ray , action_leap_attack , action_ground_slam , action_light_attack_combo , action_heavy_attack_combo , ] ; // Does some common setup for the number of actions and then rolls the dice // and chooses which function to call. common :: battle_activate ( goals , actor , weights , actions ) ; } Modifying the weights dynamically is handled in many different ways, but the most common are simple rng rolls from the actor and hp thresholding. Other, simpler, Goals than the top level battle Goal for an Actor may simply push a few sub-goals, perhaps reading some data from the Goal parameters. The nesting means that it’s possible to compose quite complex behavior from simple building blocks. Interrupts The other major callback defined for goals is the Interrupt. As the name suggests, this allows Goals to respond immediately to external events which are mostly configured on the Actor itself. My understanding is that interrupts bubble up, that is, it will run the interrupt on the currently executing Goal and then its parents recursively, until it runs out of Goals or one of the interrupt callbacks returns true to indicate it has consumed the interrupt. For example, if I wanted CoolBoss to move into a furious rage of attacks as soon as I set it on fire, then I might implement something like the following. fn interrupt ( & self , goals : & Goals , actor : & Actor , interrupt : Interrupt ) { match interrupt { // If I start burning, attack! SpecialEffectActivate { target , special_effect , } => { if target == Target :: Self && special_effect == SpecialEffect :: Fire { // Since there might still be other things running when // interrupt is called we need to unwind so we're on top again. goals . clear_sub_goals ( ) ; goals . push_sub_goal ( Goal :: Attack , AnimId :: R1 ) ; goals . push_sub_goal ( Goal :: Attack , AnimId :: R2 ) ; goals . push_sub_goal ( Goal :: Attack , AnimId :: R1 ) ; goals . push_sub_goal ( Goal :: Attack , AnimId :: R2 ) ; return true ; } } // If somebody uses an item they might be in for it. UseItem => { let fate = actor . next_random ( ) ; if fate < 0.5 { goals . clear_sub_goals ( ) ; action_light_attack_combo ( goals , actor ) ; } } // Perform a ground slam if I get attacked from underneath. Damage { target , } => { if target == Target :: Self { let distance = actor . target_distance ( Target :: Enemy ) ; let fate