Design Patterns
Contents
Introducing design pattern
The SOLID principles
- Single-responsibbility principle | Open-closed principle | Liskov substitution | Interface segregation principle | Dependency inversion principle | Interface vs abstract
Essential Design patterns for GameProgramming
- Observer Pattern | State Pattern | Object Pooling | MVP pattern | Factory pattern | Command Pattern | Singleton
Gameprogrammingpattern
# Prototype, Game Loop, Update Method, Component 는 확인해보고 진행 할 것
Introduction
Design Patterns Revisited
- Command | Flyweight | Observer | Prototype | Singleton | State
Sequencing Patterns
- Double Buffer | Game Loop | Update Method
Behavioral Patterns
- Bytecode | Subclass Sandbox |Type Object
Decoupling Patterns
- Component / Event Queue / Service Locator
Optimization Patterns
- Data Locality / Dirty Flag /
Obect Pool/ Spatial Partion
Gang of Four
# Level up your code with Game Programming Pattern 에서 언급된 패턴 먼저 체크
Shout-out at Level up your code with Game Programming Pattern
- Adapter Pattern | Decotator Pattern | Facade Pattern | Template method Pattern | Strategy Pattern | Composite
Creantional Patterns
Abstract Factory Pattern| Builder Pattern | Factory Method Pattern |Prototype Pattern|Singleton pattern
Structural Patterns
Adapter Pattern| Bridge Pattern |Composite Pattern|Decotator Pattern|Facade Pattern|Flyweight Pattern| Proxy Pattern
Behavioral Patterns
- Chain of responsibility |
Command Pattern| Interpreter Pattern | Iterator Pattern |Observer Pattern|Strategy Pattern| Template method Pattern | Visitor Pattern | Mediator Pattern |State Pattern| Memento Pattern
Daily
Goals - 코드 작성 개선을 위한 기초적인 디자인 패턴을 공부한다.
Design Patterns (Basic)
- part 1 : Unity Learn - Design Pattern
- part 2 : Level up your code with Game Programming Pattern & gameprogrammingpatterns
- part 3 : Gang of four
Part1
- Unity Learn에서 소개된 디자인 패턴
- Level up your code with Game Programming Pattern, Unity Learn에서 소개된 참고 자료
- Unity Learn - Design Pattern이 Level up your code with Game Programming Pattern을 기반으로 작성되었음으로 공통되는 구성은 간단히 보강만 하고 넘어감
Part2
- gameprogrammingpatterns
- Level up your code with Game Programming Pattern이 gameprogrammingpatterns에 많은 영향을 받아 작성된것으로 보임, 공통되는 구성에 대해 다른 관점도 다룬다는 생각 진행
Part3
- Gang of Four 중 여태 다뤄지지 않는 패턴을 간단히 소개하고 넘어감
- 몇몇 샤라웃된 패턴은 자세히 진행
part 1,2,3,4 파트 별로 진행할 내용의 구성 및 순서가 다름 특히나 Gang of Four 의 생산-구조-행위 구조를 따라 글을 작성하기에는 기존 글 구성이 바뀜, 또한 내용의 뉘앙스가 뒤죽박죽이 될 것
어디까지나 Unity 기준으로 작성된 글 구조를 유지하고 Gang of Four에서의 다뤄지지 않은 요소를 간단하게 아래에 배치
2024 / 05 | 할당 | |
Part 1 - unity Learn - Design Pattern total & Level up your code with Game Programming Pattern | ||
23 | Observer pattern 시작 - 이론 | T |
24 | Observer pattern 기초 사용법 - 완 | - |
26 | State pattern 시작 - 이론, 기초 사용법 - 완 | T |
Object Pooling 시작 - 이론, 기초 사용법 - 완 | T | |
27 | MVP pattern 시작 - 이론, 기초 사용법 - 완 | - |
Factory pattern 시작 - 이론, 기초 사용법 - 완 | - | |
28 | Command pattern 시작 - 이론, 기초 사용법 -완 |
- |
29 | The SOLID principles | T |
30 | Singleton pattern 및 Part 1 내용과 공통된 구성 재확인 |
- |
Part 1 End - total 7days 26 hours T4 -5 |
||
Part 2 Level up your code with Game Programming Pattern & gameprogrammingpatterns |
||
30 | gameprogrammingpatterns Command Patter - 중 |
- |
2024/06 | ||
01 | Command Pattern - (보강) 완, Flyweight Pattern-완 | T |
Observer Pattern - (보강) 완, Prototype Pattern | - | |
03 | Prototype Pattern - 1 (완), Singleton - 3 | T |
Singleton - 1 (완), State - 1 | - | |
04 | State - 1 (완), Double Buffer - 2△(완), Game Loop -1▽ | T |
Game Loop - 2 (완) | - | |
10 | Update Method - 3 (완), Bytecode - 1 | T |
19 | Bytecode - 2△(완), Subclass Sandbox - 2▽(완) | T |
20 | Type Object - 2(완) | - |
2025/04 | ||
01 | Component - 2 | - |
02 | Component - 2(완) | - |
Event Queue - 2 | - | |
03 | Event Queue - 2(완) | - |
04 | Service Locator - 4(완) | T |
05 | Data locality -4(완) | T |
07 | Dirty flag - 4(완) | T |
08 | Object pool - 2(완), Spatial Patition -2 | T |
Spatial Patition -2(완) | - | |
Part 2 End - total 14days 56 hours T9 -10 |
||
total 21days 82 hours T13 -15 |
※part 3는 다른 글에서 진행
영어 공부한다는 마음으로 느려도 상관은 없었으나 너무 그대로 적어내린거 같아 반성해야겠다.
작년에 미쳐 끝마치치지 않고 멈춘것도 반성해야겠다.
Reference
https://learn.unity.com/project/65de084fedbc2a0699d68bfb?uv=2022.3
Design Patterns - Unity Learn
By implementing common game programming design patterns in your Unity project, you can efficiently build and maintain a clean, organized, and readable codebase. Design patterns not only reduce refactoring and the time spent testing, but they also speed up
learn.unity.com
https://unity.com/kr/resources/level-up-your-code-with-game-programming-patterns?isGated=false
Level up your programming with game programming patterns
Our new e-book explains well-known design patterns and shares practical examples for using them in your Unity project.
unity.com
https://github.com/Unity-Technologies/game-programming-patterns-demo/tree/main
GitHub - Unity-Technologies/game-programming-patterns-demo: A repo of small demos that assemble some of the well-known design pa
A repo of small demos that assemble some of the well-known design patterns in Unity development to support the ebook "Level up your code with game programming patterns" - Unity-Technologi...
github.com
Game Programming Patterns
Hey, Game Developer! Do you struggle to make your code hang together into a cohesive whole? Find it harder to make changes as your codebase grows? Feel like your game is a giant hairball where everything is intertwined with everything else? Wonder if and h
gameprogrammingpatterns.com
Design Patterns
디자인 패턴을 사용하여, 리펙토링 및 테스트 필요한 시간을 줄여, 온보딩(팀워크) 및 개발 속도를 증대할 수 있다.
Introducing Design patterns
- 마주하게 되는 문제에 대해서, 많은 개발자가 이미 거쳐갔고 그 해결방안은 개발되었다. 디자인 패턴을 공부함으로써 바퀴를 재발명하는 일을 줄인다.
- 디자인패턴을 공부하고 적용함으로써 코드를 깔끔하게 유지하고 더욱 큰 어플리케이션을 빌드할 수 있게 된다.
- 디자인 패턴은 모든 상황에 적용할 수는 없다. 하지만 큰 어플리케이션을 구현할때, 올바르게 접근하는 관점을 알게 된다.
- "단순함을 유지하되, 필요한 만큼만 복잡함을 추가한다."
The SOLID principles
- SOLID principles은 코드를 간결하게 작성하게 만들고 효과적으로 유지 및 확장 할 수 있도록 한다.
- SOLID 를 적용하는 것은 거 많은 작업을 요구할 수 있다. 특히 리펙터 작업이 필요하다. 하지만 장기적으로 이점이 많다. 얼마나 엄격하게 따를지 결정한다. 그에 따라 구현방법은 다양하다.
- principle 자체보다는 principle를 만들어진 이유, 관점에 대해서 고려한다.
SOLID
- Single responsibility | Open-closed | Liskov substitution | Interface segregation| Dependency inversion
Single responsibility principle
- 클래스를 변경해야 하는 이유는 단 하나여야한다. 오로지 클래스의 단일 책임이다.
- SOLID principle 중에서 가장 중요하다.
- 각 module, class, function은 하나의 책임을 가지고 로직의 한 부분만 캡슐화한다.
- 작은 클래스와 메서드는 설명, 이해, 구현하기 쉽다.
- GameObject의 컴포넌트 역시 이러한 컨셉을 가지고 있다. 각 컴포넌트는 각자의 역할과 책임을 가진다.
/목적
- Readabliity - 쉽게 읽히도록 한다. 별도의 규칙은 존재하지 않지만, 많은 개발자들은 코드 한계를 200~300 라인으로 제한한다. 설정한 제한을 넘는다면, refactor를 고려한다.
- Extensiblity - 더 쉽게 상속 받도록 만든다. 의도하지 않은 부분이 수정되거나 교체되지 않도록한다.
- Reusability - 쉽게 재사용 할 수 있게 만든다.
구현하기 쉽다는 것은 단순하다는 것이 아니다. 단순함은 쉽다는 것이아니다. 단순함이 쉬움을 만들어 낸다.
반면 교사 예제
public class UnrefactoredPlayer : MonoBehaviour
{
[SerializeField] private string _inputAxisName;
[SerializeField] private float _positionMultiplier;
private float _yPosition;
private AudioSource _bounceSfx;
private void Start()
{
_bounceSfx = GetComponent<AudioSource>();
}
private void Update()
{
float delta = Input.GetAxis(_inputAxisName) * Time.deltaTime;
_yPosition = Mathf.Clamp(_yPosition + delta, -1, 1);
transform.position = new Vector3(transform.position.x, _yPosition * _positionMultiplier, transform.position.z);
}
private void OnTriggerEnter(Collider other)
{
_bounceSfx.Play();
}
}
UnrefactoredPlayer 클래스는 책임이 뒤죽박죽이다. 프로젝트가 커짐에 따라 유지하기가 어려워 진다.
RefactoredPlayer
[RequireComponent(typeof(PlayerAudio), typeof(PlayerInput), typeof(PlayerMovement))]
public class Player : MonoBehaviour
{
[SerializeField] private PlayerAudio playerAudio;
[SerializeField] private PlayerInput playerInput;
[SerializeField] private PlayerMovement playerMovement;
private void Start()
{
playerAudio = GetComponent<PlayerAudio>();
playerInput = GetComponent<PlayerInput>();
playerMovement = GetComponent<PlayerMovement>();
}
}
Play script는 다른 컴포넌트(각 하나만 할 수 있는 클래스)를 관리한다. 이를 통해 요구사항이 바뀔때, 해당하는 코드를 쉽게 접근 가능하다.
하지만 지나치게 단순화하여 한 메소드 당 하나의 클래스를 생성하지 않도록 한다. 단일 책임의 균형을 잘 유지하도록 한다.
Open - closed principle
- 클래스는 확장에 열려있고, 수정엔 닫혀있어야한다.
- 쉽게 디버그를 할수 있게 만든다.
- 인터페이스와 추상을 사용하는 것은 새로운 클래스를 만드는 것에 이점이 있다.
반면 교사 예제
public class AreaCalculator
{
// old implemenation: not using open-closed principle
public float GetRectangleArea(Rectangle rectangle)
{
return rectangle.Width * rectangle.Height;
}
public float GetCircleArea(Circle circle)
{
return circle.Radius * circle.Radius * Mathf.PI;
}
}
public class Rectangle
{
public float width;
public float height;
}
public class Circle
{
public float radis;
}
예제는 분명 잘 작동하지만, 새로운 모양을 추가하기 위해서는 새로운 메서드를 작성해야한다.
무엇보다, 상위 모듈에서 각 모양을 구별하고 맞는 메서드를 호출해야한다.
확장(새로운 모양 추가에 대해) 기존 코드(script)를 건들이지 않게 해야한다.
올바른 예제
public abstract class Shape
{
public abstract float CalculateArea();
}
public class AreaCalculator
{
public float GetArea(Shape shape)
{
return shape.CalculateArea();
}
}
public class Rectangle : Shape
{
public float Width { get; set; }
public float Height { get; set; }
public override float CalculateArea()
{
return Width * Height;
}
}
public class Circle : Shape
{
public float Radius { get; set; }
public override float CalculateArea()
{
return Radius * Radius * Mathf.PI;
}
}
이제 AreaCalculator 는 Shape이 뭔지 몰라도 Area를 계산하는 기능을 가지게 된다. 이로써 기존 코드를 수정하지 않고 새로운 Shape를 추가 할 수 있다.
Liskov substitution principle
- 자식 클래스는 부모 클래스로 대체 가능해야한다.
- #자식 클래스의 참조 변수을 캐스팅을 통해 부모 클래스의 참조 변수로 만들어, 부모 클래스의 변수를 대채 가능하고, 작동에 문제가 없어야한다.
- #과자가 필요한 것이라면, 그게 고래밥이든 새우깡이던 상관이 없어야한다는 것
- 주의하지 않는다면, 불필요한 복잡성을 가지게 될 수 있음으로 주의
- 이를 통해 파생 클래스를 탄탄하고 유연하게 만들 수 있다.
/Tip
- 자식 클래스에서 부모 클래스의 기능을 제거하려고 한다면, LSP를 따르지 않는 것이다.
- 기능 제거를 위해 NotImplementedException을 던질려는 경우
- 추상화를 단순하게 유지한다.
- 아래 예제를 해결하기 위해 부모 클래스에 더 많은 로직을 넣으려는 건 LSP를 따르지 않는 것이다
- 자식 클래스는 부모 클래스와 같은 맴버를 가져야한다
- 클래스 계층 구조를 구성할 때, 충분히 고려한다
- 승용차과 기차와 같이 다른 부모 클래스를 상속해야함을 생각해야한다.
- 상속 보다는 구성을 선호한다.
- 상속을 통하기 보다는, 특정 기능을 캡슐화라는 별개 클래스 혹은 인터페이스를 만든다.
반면 교사 예제
public class Vehicle
{
public float speed = 100;
public string name;
public Vector3 direction;
public void GoForward()
{
}
public void Reverse()
{
}
public void TurnRight()
{
}
public void TurnLeft()
{
}
}
public class Navigator
{
public void Move(Vehicle vehicle)
{
vehicle.GoForward();
vehicle.TurnLeft();
vehicle.TurnRight();
}
}
이 코드에는 다음과 같은 확장에 대해 다음과 같은 문제가 존재한다. Train 같은 정해진 레일 위를 달리는 요소를 추가 되었다고 한다면, navigator.Move 에서 Vehicle vehicle을 Train(Vehicle를 상속 받은)으로 대체할 수가 없다.
하지만 물론 그렇다 하더라도 큰 문제는 없어보이지만, 이 경우 Train이 예측할 수 없게(의도와 달리 레일을 벗어나) 작동 할 수 있다.
올바른 예제
public interface ITurnable
{
public void TurnRight();
public void TurnLeft();
}
public interface IMovable
{
public float MoveSpeed { get; set; }
public float Acceleration { get; set; }
public void GoForward();
public void Reverse();
}
public class RailVehicle : IMovable
{
public string Name;
private float moveSpeed = 100f;
private float acceleration = 5f;
public float TurnSpeed = 5f;
public float MoveSpeed { get => moveSpeed; set => moveSpeed = value; }
public float Acceleration { get => acceleration; set => acceleration = value; }
// implement these differently than RoadVehicles
public virtual void GoForward()
{
}
public virtual void Reverse()
{
}
}
public class RoadVehicle : IMovable, ITurnable
{
public string Name;
private float moveSpeed = 100f;
private float acceleration = 5f;
public float TurnSpeed = 5f;
public float MoveSpeed { get => moveSpeed; set => moveSpeed = value; }
public float Acceleration { get => acceleration; set => acceleration = value; }
public virtual void GoForward()
{
}
public virtual void Reverse()
{
}
public virtual void TurnLeft()
{
}
public virtual void TurnRight()
{
}
}
public class Train : RailVehicle
{
}
public class Car : RoadVehicle
{
}
public class Navigator
{
public void MoveRoadVehicle(RoadVehicle roadVehicle)
{
roadVehicle.GoForward();
roadVehicle.TurnLeft();
roadVehicle.TurnRight();
}
public void MoveRailVehicle(RailVehicle railVehicle)
{
railVehicle.GoForward();
railVehicle.GoForward();
railVehicle.Reverse();
}
}
기능을 상속보다는 인터페이스를 통해 구성한다. 이를 통해 LSP를 만족한다
이 방법은 실제 세계에 대한 직관에 반한다고 생각할 수 있다. 이것을 the circle-ellipse problem이라고 한다.
모든 "is a" 관계가 상속으로 대변되지 않는다.
계층 구조를 통해 디자인해라.
Interface segregation principle
- 어떤 클라이언트에게 사용하지 않는 메서드에 의존하도록 강제해서는 안된다.
- single-responsibility에 따라 큰 인터페이스를 피하고 작은 클래스와 메서드를 유지한다.
- #또한 리스코브 치환 법칙에 따라, Interface로 치환해서 사용하는 경우도 잦다.
반면 교사 예제
public interface IUnitStats
{
public float Health { get; set; }
public int Defense { get; set; }
public void Die();
public void TakeDamage();
public void RestoreHealth();
public float MoveSpeed { get; set; }
public float Acceleration { get; set; }
public void MoveForward();
public void Reverse();
public void TurnLeft();
public void TurnRight();
public int Strength { get; set; }
public int Dexterity { get; set; }
public int Endurance { get; set; }
//public float Mass { get; set; }
//public float ExplosiveForce { get; set; }
//public float FuseDelay { get; set; }
//public float Timeout { get; set; }
//public void Explode();
}
만약 새로운 파괴가능한 소품을 넣고 싶다고 한다면, 위 인터페이스의 파생을 만든다 하더라도 불필요한 기능을 가지게 된다.
올바른 예제
public interface IMovable
{
public float MoveSpeed { get; set; }
public float Acceleration { get; set; }
public void MoveForward();
public void Reverse();
public void TurnLeft();
public void TurnRight();
}
public interface IDamageable
{
public float Health { get; set; }
public int Defense { get; set; }
public void Die();
public void TakeDamage();
public void RestoreHealth();
}
public interface IExplodable
{
public float Mass { get; set; }
public float ExplosiveForce { get; set; }
public float FuseDelay { get; set; }
public float Timeout { get; set; }
public void Explode();
}
public class EnemyUnit : MonoBehaviour, IDamageable, IMovable, IUnitStats
{
public float Health { get; set; }
public int Defense { get; set; }
public int Strength { get; set; }
public int Dexterity { get; set; }
public int Endurance { get; set; }
public float MoveSpeed { get; set; }
public float Acceleration { get; set; }
// implement logic here
public void TakeDamage()
{
}
public void Die()
{
}
public void RestoreHealth()
{
}
public void MoveForward()
{
}
public void Reverse()
{
}
public void TurnLeft()
{
}
public void TurnRight()
{
}
}
public class ExplodingBarrel : MonoBehaviour, IExplodable, IDamageable
{
public float Mass { get; set; }
public float ExplosiveForce { get; set; }
public float FuseDelay { get; set; }
public float Timeout { get; set; }
public float Health { get; set; }
public int Defense { get; set; }
// implement logic here
public void Die()
{
}
public void Explode()
{
}
public void RestoreHealth()
{
}
public void TakeDamage()
{
}
}
각 기능에 대한 인터페이스를 만들고, 이를 통해 클래스를 구성한다.
이를 통해 상속보다는 구성을 선호해야한다는 것을 알 수 있다.
Dependency inversion principle
- 상위 모듈은 하위 모듈에게서 직접적으로 아무것도 가져와서는 안된다. 둘다 추상적 개념만 의존해야한다.
- 하나의 클래스가 다른 것과 관계를 가지면 의존성 결합을 가진다. 각 의존성은 소프트웨어 디자인에 약간의 위험을 가져온다
- 결합도가 높다. 한 클래스가 다른 클래스의 작동방식에 대해 너무 많은 것을 알고 있으면 다른 클래스를 손상시킬 수 있다. 이것은 올바르지 않은 코드이며, 에러를 불러오는 스노우 볼이 된다.
- 적은 의존성은 가능하다. 각 클래스는 조화를 위해, 외부 연결을 의지하기 보다는 내부 부분을 가져야한다.
- 상위 수준의 클래스는 하위 수준의 클래스를 의존하여 무언가를 끝내라 한다. SOLID에 의해서 이것을 뒤집어야 야 한다.
반면 교사 예제
public class Door : MonoBehaviour
{
public void Open()
{
Debug.Log("The door is open.");
}
public void Close()
{
Debug.Log("The door is closed.");
}
}
public class Switch : MonoBehaviour
{
public void Activate()
{
if (IsActivated)
{
IsActivated = false;
Door.Close();
}
else
{
IsActivated = true;
Door.Open();
}
}
}
상위 레벨에서 무언가가 일어나면, 작동하도록 책임을 가지고
하위 레벨에서 실제 동작을 책임진다.
스위치는 문을 여닫는 메서드를 호출한다. 문제는 문에서 스위치로 직접적으로 이어지는 종속성이다.
만약 전등이나 로봇의 스위치가 필요하다면 어떻게 하는가?
추가적인 메서드를 작성할 수 있지만 이는 open-closed principle를 위반한다.
이를 개선하기 위해 ISwitchable 인터페이스를 적용한다.
public interface ISwitchable
{
public bool IsActive { get; }
public void Activate();
public void Deactivate();
}
public class Door : MonoBehaviour, ISwitchable
{
private bool isActive;
public bool IsActive => isActive;
public void Activate()
{
isActive = true;
Debug.Log("The door is open.");
}
public void Deactivate()
{
isActive = false;
Debug.Log("The door is closed.");
}
}
public class Trap : MonoBehaviour, ISwitchable
{
private bool isActive;
public bool IsActive => isActive;
public void Activate()
{
isActive = true;
Debug.Log("The trap is active.");
}
public void Deactivate()
{
isActive = false;
Debug.Log("The trap is inactive.");
}
}
public class Switch : MonoBehaviour
{
public ISwitchable client;
public void Toggle()
{
if (client.IsActive)
{
client.Deactivate();
}
else
{
client.Activate();
}
}
}
# interface에서 property를 가져서 파생 클래스에 여건을 만든다.?
이렇게 종속성을 반전 시킬 수 있다.
인터페이스는 둘 사이의 고정된 연결을 만들기 보다는 추상적 개념을 만든다.
스위치는 문의 메서드의 직접적으로 종속하지 않는다. 그 대신 ISwitchable 인터페이스의 매서드를 사용한다.
이를 통해 재사용성을 얻는다. 스위치는 ISwitchable 가지는 클래스에 대해 적용할 수 있다.
스위치의 클라이언트는 스위치의 호환성을 위해 ISwitchable 를 가지면 된다.
Interfaces vs abstract classes
- "상속 보다 구성을 선호한다." 는 생각의 방향을 유지하면서, 많은 예제가 인터페이스를 사용했다. 하지만 디자인 원리를 따르기 위해 abstract를 사용할 수 있다.
- 두 방법 상황에 따라 모두 추상화를 달성하는 유효한 방법이다.
/Abstract classes
- 추상 클래스는 base class 를 정의할 수 있다. 그로써 상속을 통해, 공통 기능을 파생 클래스로 넘길 수 있다.
- 아래의 경우 처럼 ISwitchavle 를 추상 클래스로 만들 수 있다.
- Abstract class의 이점은 필드, 상수, 정적멤버를 가질 수 있다는 것이다.
- 인터페이서와 달리 추상 클래스는 로직을 구현하고 구현 클래스에 코어를 나누도록 할 수 있다.
- 두개 이상의 클래스 상속이 불가한 점에 제한받기 전까지는 잘 작동한다.
- 구현하고자하는 클래스에 대해 무엇을 상속 받아야하는지 결정하기 어렵다.(ex: ISwitchavle, Robot)
/Interfaces
- 인터페이스는 Interface segregation principle를 통해, 패러다임에 딱 들어맞지 않는 대상에 대해 유용한 유연성을 가진다.
- 하지만 인터페이스는 오로지 멤버의 선언만을 가질 수 있다.
따라서 기능을 공유하려는 경우 추상메서드를 유연성을 가지기 위해서는 인터페이스를 사용한다.
추상 클래스 | 인터페이스 |
메서드 구현 가능 | 선언만 가능 |
변수와 필드의 선언 및 사용 가능 | 메서드와 properies 선언 가능(필드는 그렇지 않다) |
정적 멤버 가능 | 정적 멤버 불가능 |
생성자 사용 가능 | 생성자 사용 불가능 |
모든 접근지정자 사용 가능 | public 만 사용 가능 |
Design Pattern
- 디자인 패턴은 정해진 라이브러리나 프레임워크, 알고리즘 같은 것이 아니다. 결과물을 성취하기 위한 단계이다.
- 같은 패턴을 따르더라도, 다른 코드가 만들어진다. 패턴은 어디서든 적용 응용될 수 있다.
- 디자인 패턴을 알아두면, 여러 이점을 얻을 수 있다.
- 디자인 패턴을 통해, 개발자들이 문제를 해결하기 위해 어떻게 접근했는지 알 수 있다.
- 구현하고자하는 코드를 짧게 표현할 수 있다.
- 새로운 에셋 및 프레임을 이해하는 데에 도움이 된다.
- 모든 어플리케이션에 디자인패턴을 적용하려 하지마라, Maslw's hammer (망치를 들면 모든 문제는 못이 되어 버린다)
Patterns within unity
유니티는 몇가지의 게임 개발 패턴을 구현하여 제공하고 있다.
- Game loop - 게임의 핵심은 컴퓨터 성능 독립된 무한 루프이다. 컴퓨터의 다른 성능에 대해, 타임 스탬프를 통해 이러한 루프를 제공하고 있다. MonoBehaviour 의 Updata(), LateUpdate(), FixedUpdate()로 게임플레이를 관리 할 수 있다.
- Update - 프레임 마다 각 객체를 업데이트한다. Updata(), LateUpdate(), FixedUpdate()로 적용되어 있다.
- Prototype - 원본에 영향을 주지 않는 복사된 객체가 필요할 때 사용되는 패턴이다. 유니티의 prefab system이 복사본을 만들 수 있도록 해준다. Prefab Variants, nest Prefabs 기능도 제공한다.
- Component - 책임을 컴포넌트 별로 분할한다. 유니티의 gameobject 컴포넌트로 적용되어 있다.
Essential Design Pattern for Gameprogramming
- Observer Pattern | State Pattern | Object Pooling | MVP pattern | Factory pattern | Command Pattern | Singleton
Observer pattern
몇몇 상황에서 오브젝트가 다른 오브젝트를 참조하지 않고 다른 오브젝트에게 알릴 수 있는 메커니즘이 필요하다. (이때문에) 코드베이스(코드량)가 증가하면, 불필요한 종속성이 증가하고 결국 유연성이 떨어지고 오버헤드가 발생하게 된다. 이를 해결 할 수 있는 일반적인 해결책이 observer pattern 이다.
게임 플레이 과정에서 발생하는 거의 모든 것에 Observer pattern를 적용할 수 있다.
- 오브젝트가 서로 상호작용하되, 일대다 종속성을 사용해 느슨한 결합 상태를 유지할 수 있다. 즉, 하나의 오브젝트가 변경되면 모든 종속 객체에 자동으로 알림이 전달된다.
- 브로딩캐스팅하는 객체를 subject라고 하고, 듣는 객체들을 observers 혹은 subscribers 라고 한다
- 이 디자인 패턴을 통해, 관찰자와 대상를 분리하고, 관찰자는 다른 관찰자의 동작과 독립된다. 이를 통해 시스템의 다른 부분에 영향을 주지않고 코드를 수정하거나 확장하는것이 쉬워진다.
- 쉽게 코드를 재사용할 수 있으며, 객체간의 종속성이 명확하여 가독성이 향상된다.
Event
C#에서는 이벤트를 사용해 observer pattern이 다양하게 구현되어있다. 보통은 직접구현할 필요가 없다.
이벤트는 사건이 발생했음을 나태나는 알림이고, 몇가지 절차를 가진다.
- publisher(subject) 는 delegate(대리자?)를 기반으로 생성된다. delegate는 특정 함수 시그니처를 정의한다. event는 subject가 런타임동안 수행할 몇몇 작업이다. publisher는 종속 observers의 리스트를 유지한다. 상태가 변하면 알림을 보내게 된다. 이것이 Event이다.
- 각 Observer는 event handler를 만든다. delegate의 시그니처와 반드시 일치해야한다. Obsercer는 알림을 수신하고 각자 알아서 자신을 업데이트한다.
- 각 Observer는 관찰자의 event를 구독한다. 필요한 만큼 구독할 수 있다. Observer 모두 구독한 event가 일어나기를 기다린다.
- publisher가 런타임에 이벤트 발생을 알리면, 자체 내부 논리를 수행하는 event handler를 호출한다.
# 함수 시그니처은 함수를 특정 지을수 있는 함수이름이다 아이디 따위를 일컫는 말인듯하다.
Delegate는 메서드 시그니처를 정의하는 형식이다. 메서드 자체를 다른 메서드의 인수로 사용할 수 있게 한다. 메서드에 대한 참조를 가진 변수라고 생각하면 된다.
Event는 느슨한 결합방식으로 서로 통신할 수 있게하는 특별한 타입의 delegate이다.
Pattern 개량법
- ObservableCollection 사용, c#에서 제공하며, 항목이 추가되거나 제거되면 목록이 새로고침되면 알려진다.
- 고유 인스턴스 ID를 인수로 전달한다. 이를 통해 로직에서 GameObject를 확인할 수 있다.
- 정적 EventManager를 활용한다.
- 이벤트 큐 생성, 명령 패턴 결합하여, 이벤트를 이벤트 큐에 캡슐화 할 수 있다. 그 뒤 명령 버퍼를 사용하여, 이벤트의 재생 수와 선택적 무시를 할 수 있다.
In Code
Subject/publisher
using UnityEngine;
using System;
public class Subject: MonoBehaviour
{
public event Action ThingHappened;
public void DoThing()
{
ThingHappened?.Invoke();
}
}
위 클래스에서 MonoBehavior은 GameObject에 쉽게 연결하기 위한 것이로, 필수사항은 아니다.
자신만의 delegate를 정의해도 되지만, 대부분의 경우 사용가능한 System. Action이 있다. 만약 파라미터를 함께 보내야 한다면, Action<T> delegate를 사용하면 된다.(최대 16 인수) List<T> 처럼 사용하면 된다.
? 연산자는 null 조건부 연산자이다. 즉, event가 null이 아닐 경우 에만 호출된다. null 아니면, Invoke() 통해 event가 발생되고, event를 구독하고 있는 event handlers가 실행된다.
Event는 보통 동사로 이벤트가 발생했음을 나타내는 이름을 사용하는 것이 일반적이다.
# 맞다면, delegate로 구현되였거나, 그 일종이므로, 메서드로 생각해도 될 것 같다.
Observer
public class Observer : MonoBehaviour
{
[SerializeField] private Subject subjectToObserve;
private void OnThingHappened()
{
// any logic that responds to event goes here
Debug.Log("Observer responds");
}
private void Awake()
{
if (subjectToObserve != null)
{
subjectToObserve.ThingHappened += OnThingHappened;
}
}
private void OnDestroy()
{
if (subjectToObserve != null)
{
subjectToObserve.ThingHappened -= OnThingHappened;
}
}
}
public event Action ThingHappened; , 이벤트에 대해서 += 연산자를 사용해 구독할 수 있고, -= 연산자를 사용해 구독을 해제 할 수 있다.
- 구독 : += 연산자를 사용하면, ThingHappened와 OnThingHappened 메서드가 결합한다.
- 해제 : -= 연산자 사용, 런타임 중, 이벤트가 발생했는데, 관찰자가 제거된 경우, 오류가 발생할 수 있다. 따라서 관찰자 수명 주기에 따라 적절히 구독을 해제해야한다.
이벤트 핸들러를 표시하기 위해, 접두사 On을 붙혀 명명한다.
# 사용 예시를 보아, 결국은 컴포넌트의 참조가 필요해 보이지만, 구독과 해제가 자유롭다. 구독과 해제가 하드코딩 되진 않는다. 구독 시 메서드가 같이 호출된다는 것으로 보면 될 듯하다.
In Unity
유니티에서는 UnityEngine.Events API 의 UnityAction delegate를 사용하는 별도의 UnityEvents 시스템을 가지고 있다.
UnityEvents는 간단하게 게임플레이 이벤트를 만들 수 있지만, System event, action 보다는 느릴 수 있다.
UnityActions는 인수를 사용하는 메서드를 호출하는 데 이점이 있지만, UnityEvent는 인수가 없는 메서드로 제한된다.
UnityEvents 는 더 간단하고 사용하기 싶지만, 호출할 수 있는 메서드 유형이 더 제한된다.
UnityEvent, UnityAction 비교
UnityEvent
public UnityEvent onClick;

이 멤버가 스크립트에 추가되면, UI / button 오브젝트와 같이 유니티 에디터 인스펙터에서 메서드를 참조할 수 있는 공간이 생긴다.
onClick.Invoke();
UnityEvent를 위와 같이 Invoke() 할 수 있다.
onClick.onClick.AddListener(UnityAction call);
onClick.RemoveListener(UnityAction call);
구독하기 위해서는 += 연산자를 사용할 수 없고 마찬가지로 -= 연산자를 사용할 수 없다. 그 대신, .AddListener(), RemoveListener()를 사용한다.
# UnityAction call 은 메서드의 시그니처(메서드명)을 의미하는 듯(delegate?)
UnityAction
public UnityAction onClick;
UnityAction의 경우, 위 멤버가 추가되어도, 유니티 에디터 인스펙터에서 표시되지 않는다.
onClick.Invoke();
UnityAction 를 위와 같이 Invoke() 할 수 있다.
onClick +=
onClick -=
구독 및 해제 사용시 += , -= 연산자를 사용하면 된다.
# 약간의 조사 및 추론에 의하면, UnityEvent는 에디터에서 지원가능하도록 직렬화를 적용하였고, 그로인해 사용에 편리하지만 제약을 얻은 것으로 보인다. UnityEvent는 UnityAction 사용해 구현 됬을 것이라 생각된다.
# System action 과 UnityAction은 내부 구현이 거의 비슷하다고 한다. 다만 호환은 안된다고.
장점과 단점
장점
- publisher와 subscriber를 분리하는데 도움이 된다. 직접적 종속성 대신 느슨하게 유지하면서 통신한다.
- C# 및 Unity는 System.Action, UnityEvents, UnityActions와 같이 이미 이벤트 시스템이 포함되어 별도로 구현하지 않아도 된다.
- 각 관찰자과 각자의 이벤트 처리 논리를 가진다. 이를 통해 디버그 및 단위 테스트가 용이하다.
- 사용자 인터페이스에 적합하다.
단점
- 복잡성이 추가된다. 처음에 더 많은 세팅이 필요하게되고, 구독 및 해제(객체의 생명주기와 함께)에 신경써야한다.
- 종속성이 아예 없는 것이 아니라 참조가 필요하다. 이를 위해 중앙집중식 정적 EventManager를 사용하면 도움이 된다.
- 많은 게임 오브젝트에 대해 성능이 저하될 수 있다.
Pattern 개량법
- ObservableCollection 사용, c#에서 제공하며, 항목이 추가되거나 제거되면 목록이 새로고침되면 알려진다.
- 고유 인스턴스 ID를 인수로 전달한다. 이를 통해 로직에서 GameObject를 확인할 수 있다.
- 정적 EventManager를 활용한다.
- 이벤트 큐 생성, 명령 패턴 결합하여, 이벤트를 이벤트 큐에 캡슐화 할 수 있다. 그 뒤 명령 버퍼를 사용하여, 이벤트의 재생 수와 선택적 무시를 할 수 있다.
2024.05.24 - [Endeavor/Unity Stuff] - Unity - FPS microgame code 분석 의 EventManager.cs 참고
Unity - FPS microgame code 분석
Goals Unity FPS - microgame의 code를 분석한다# 다수의 추측도 포함.FPS microgame / Assets / FPS / Scripts / AI / DetectionModule.cs EnemyController.cs DetectionModule.cs EnemyMobile.cs EnemyTurret.cs FollowPlayer.cs NavigationModu
roaring-stretching-thought-wood.tistory.com
State pattern
States and state machines
런타임에 변경되는 많은 시스템을 추적하고 관리해야 한다. 예를 들어 캐릭터 역시, Standing, Jumping, Walking 등의 state를 가진다.
FSM
- 여러 상태에 대해, 주어진 시간에 하나의 현재 상태만 활성화 된다.
- 각 상태는 런타임 조건에 따라 다른 상태로의 전환을 트리거 할 수 있다.
- 전환이 발생하면 출력 상태가 새로운 활성 상태가 된다.
게임개발에서 finite-state-machine FSM를 사용해, 게임 액터 및 소품의 내부 상태를 추적한다. (Animation State Machines 도 FSM를 사용한다.)
State design pattern
- FSM과 다른점은, 상태를 나타내는 인터페이스와 각 상태에 대해 이 인퍼페이스를 구현하는 클래스를 정의한다.
- 상태에 따라 행동을 바꾸는 데에 필요한 Context나 Class는 현재 상태 객체의 참조를 가지고 있다.
- 내부 상태가 변경되면 상태 객체에 대한 참조를 업데이트하여 다른 객체를 가르키도록하여, 컨텍스트의 동작을 변경한다.
/효과
- SOLID 원칙을 준수하는 데 도움이 된다. 각 상태는 비교적 작으며 다른 상태로 전환하기 위한 조건만 추적한다.
- 개방-폐쇄 윈칙을 유지하면서, 기존 상태에 영향을 주지않고 더 많은 상태를 추가할 수 있다(# 전환하기 위한 조건은 적절히 손 봐야 할듯? 다른 종류의 상태에 대한 건가?)
- 하나의 Monolithic script보다 번거로운 전환이나 if문을 피할 수 있다.
- 상태 변경 사항을 외부 개체에 전달하도록 기능을 확장할 수도 있다. 이벤트 추가도 가능하다.
- 추적할 상태가 몇 개뿐일 경우 추가 구조가 과도할 수 있다. 이 패턴은 상태가 특정 복잡성까지 증가할 것으로 예상되는 경우에만 의미가 있을 수 있다.
Gang of Four 따라, state design pattern은 두가지 문제를 해결한다.
- 객체는 내부 상태가 변경되면, 동작도 변경되어야 한다.
- 상태별 동작은 독립적으로 정의 된다. 새 상태가 추가되어도, 기존 상태의 동작에는 영향을 주지 않는다.
새 상태가 추가되어도, 기존 상태에 미치는 영향을 최소화하기 위해, 상태를 객체로 캡슐화한다.
이에 따라 특정 상태는 Entry, Update, Exit로 캡슐화된다.
이에 따른 interface 코드
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.State
{
public interface IState
{
public void Enter()
{
// code that runs when we first enter the state
}
public void Update()
{
// per-frame logic, include condition to transition to a new state
}
public void Exit()
{
// code that runs when we exit the state
}
}
}
구현 예시
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.State
{
public class IdleState : IState
{
private PlayerController player;
// pass in any parameters you need in the constructors
public IdleState(PlayerController player)
{
this.player = player;
}
public void Enter()
{
// code that runs when we first enter the state
//Debug.Log("Entering Idle State");
}
// per-frame logic, include condition to transition to a new state
public void Update()
{
// if we're no longer grounded, transition to jumping
if (!player.IsGrounded)
{
player.PlayerStateMachine.TransitionTo(player.PlayerStateMachine.jumpState);
}
// if we move above a minimum threshold, transition to walking
if (Mathf.Abs(player.CharController.velocity.x) > 0.1f || Mathf.Abs(player.CharController.velocity.z) > 0.1f)
{
player.PlayerStateMachine.TransitionTo(player.PlayerStateMachine.walkState);
}
}
public void Exit()
{
// code that runs when we exit the state
//Debug.Log("Exiting Idle State");
}
}
}
- 메서드 Update()에 if 문으로 상태 천이 조건 및 메서드 TransitionTo() 가 호출된다. (monobehavier를 상속하지 않음으로, Update() 문은 따로 호출되어야함.)
StateMachine.cs 코드
[Serializable]
public class StateMachine
{
public IState CurrentState { get; private set; }
// reference to the state objects
public WalkState walkState;
public JumpState jumpState;
public IdleState idleState;
// event to notify other objects of the state change
public event Action<IState> stateChanged;
// pass in necessary parameters into constructor
public StateMachine(PlayerController player)
{
// create an instance for each state and pass in PlayerController
this.walkState = new WalkState(player);
this.jumpState = new JumpState(player);
this.idleState = new IdleState(player);
}
// set the starting state
public void Initialize(IState state)
{
CurrentState = state;
state.Enter();
// notify other objects that state has changed
stateChanged?.Invoke(state);
}
// exit this state and enter another
public void TransitionTo(IState nextState)
{
CurrentState.Exit();
CurrentState = nextState;
nextState.Enter();
// notify other objects that state has changed
stateChanged?.Invoke(nextState);
}
// allow the StateMachine to update this state
public void Update()
{
if (CurrentState != null)
{
CurrentState.Update();
}
}
}
#코드 분석
- StateMachine에서 모든 State를 생성하고 참조를 가진다.
- Update()에서 State에 따른 Update()를 호출한다. monobehavier를 상속하지 않음으로, Update() 문은 따로 호출되어야한다. PlayerController의 Update() 메서드에서 호출된다.
- PlayerController의 특정 변수( speed변수 등)를 바꾼다기 보단, 말 그대로 PlayerController의 상태를 구별하기 위한 것으로 보여진다.
- # 생각해보면 PlayerController의 속도 및 위치에 따른 상태를 나타내는 것인데, 상태에서 속도 변수를 변경한다는게 말이 안되는 것이긴 하다.
StateMachine
- Serializalbe 특성을 사용해 inspector에 표시할수있다 (???). 다른 Monobehaviour 객체가 StateMachine을 field로 사용 할 수 있어야 한다.
- CurrentState는 읽기 전용, 외부에서는 초기화 메서드를 사용해 초기화한다.
- 각 State는 TransitionTo 메서드를 호출하기위한 자체조건을 결정
응용
예제에서는 IState에서 IColoralbe를 상속 받아 Color MeshColor 멤버를 추가하여, 외부에서 상태에 따라 텍스트 색상을 변경하도록 구현되었다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.State
{
// extra interface added just for the example project,
// used to change the player's color
public interface IColorable
{
public Color MeshColor { get; set; }
}
}
public interface IState: IColorable
{
//~~~
}
public class IdleState : IState
{
//~~~
// color to change player (alternately: pass in color value with constructor)
private Color meshColor = Color.gray;
public Color MeshColor { get => meshColor; set => meshColor = value; }
//~~~
}
public class PlayerStateView : MonoBehaviour
{
[SerializeField] private Text labelText;
private PlayerController player;
private StateMachine playerStateMachine;
// mesh to changecolor
private MeshRenderer meshRenderer;
private void Awake()
{
player = GetComponent<PlayerController>();
meshRenderer = GetComponent<MeshRenderer>();
// cache to save typing
playerStateMachine = player.PlayerStateMachine;
// listen for any state changes
playerStateMachine.stateChanged += OnStateChanged;
}
void OnDestroy()
{
// unregister the subscription if we destroy the object
playerStateMachine.stateChanged -= OnStateChanged;
}
// change the UI.Text when the state changes
private void OnStateChanged(IState state)
{
if (labelText != null)
{
labelText.text = state.GetType().Name;
labelText.color = state.MeshColor;
}
ChangeMeshColor(state);
}
// set mesh material to the current state's associated color
private void ChangeMeshColor(IState state)
{
if (meshRenderer == null)
{
return;
}
meshRenderer.sharedMaterial.color = state.MeshColor;
}
}
개량법
- 애니메이션의 state pattern과 연동한다.
- 이벤트를 추가한다
- 계층적으로 상태를 구현한다.
- 간단한 AI를 구현한다.
Object pooling
- 오브젝트 생성 및 소멸자 호출은 비교적 많은 리소스를 소모하므로, 반복적으로 생성 및 삭제하는 대신, 오브젝트를 미리 생성하고 active 및 disactive 하여 재사용하는 Design pattern 이다.
- pattern은 플레이어가 끊김 현상을 알아차리지 못하는 적절한 시간에 사전 인스턴스화 되야한다.
Unity 에서는 pool API를 제공한다. objectpool 자체의 구현은 생략
using UnityEngine.Pool;
스택 기반의 ObjectPool 클래스를 얻을 수 있다. 필요에 따라 CollectionPool 클래스를 사용할 수도 있다.
추가적으로, DictionaryPool<T0,T1> 및 HashSetPool<T0>도 제공한다.
LinkedPool 를 사용하여 경우에 따라, 더 나은 메모리 관리로 이어지지만, 데이터 구조를 관리하기 위한 항목당 더 많은 CPU주기를 소비한다.
ObjectPool
Object<T> 의 생성자에는 일부 논리를 설정하는 데에 유용한 기능이 있다.
- pool을 채우기 위한 풀링된 아이템 생성
- pool에서 아이템을 가져오기
- pool에서 아이템을 반환하기
- 풀링된 아이템을 파괴하기(최대 리미트를 건들였을 경우)
public class ObjectPool<T> : IDisposable, IObjectPool<T> where T : class
{
//~~~
public ObjectPool(
Func<T> createFunc,
Action<T> actionOnGet = null,
Action<T> actionOnRelease = null,
Action<T> actionOnDestroy = null,
bool collectionCheck = true, int defaultCapacity = 10,
int maxSize = 10000
)
//~~~
}
#구현 코드가 생각보다 간단하다.
Func<T> createFunc
- Func<T>, return을 통해 type T를 반환하는 메서드를 대상으로 한다. (#로직 상)
- Instantiate() 를 통해 객체를 생성해 반환해 줘야한다.(#아마 일반적으로 생성해서 반환할 듯)
- 아래 예제에서는 발사체 class RevisedProjectile 에서 objectPool에 접근에 용이하도록 property 를 선언했다. 그리고 이것에 해당하는 메서드에서 초기화했다.
Action<T> actionOnGet
- Action<T>, T를 인수로 받고 리턴타입이 void인 메서드를 대상으로 한다. ( #로직 상)
- Get() 할 때, 같이 실행되는 로직이다. 없어도 무방. objectPool .Get() 으로 받은 뒤 로직을 구성해도 된다.
- 아래 예제에서는 SetActive(true)를 여기서 조작한다.
Action<T> actionOnRelease
- Action<T>, T를 인수로 받고 리턴타입이 void인 메서드를 대상으로 한다. (#로직 상)
- Release() 할 때, 같이 실행되는 로직이다. 없어도 무방. objectPool.Release()를 호출하기 전에 받은 뒤 로직을 구성해도 된다.
- 아래 예제에서는 SetActive(False)를 여기서 조작한다.
bool collectionCheck
- objectPool.Release()로 pool 객체를 반환해주기 전에, 이미 pool 내에 존재하는 객체인지 확인하는 로직의 동작 여부를 나타낸다.
- False를 하면 내부 리스트를 검사하는 로직을 패스하지만, 재활성화된 개체가 반환될 위험이 존재하게 된다.
defaultCapacity
- 미리 커밋하려는 리스트 메모리 할당량이다.(#개체 자체를 미리 생성해 두지 않는건 왜지?)
- m_List = new List<T>(defaultCapacity);
maxPoolSize
- 스택의 최대 크기이며, 이 크기를 초과해서 반환되면 파괴된다.
주어진 프로젝트를 살펴보니, 초기에는 투사체가 없다가 필요한 만큼 생성된다.
#미리 생성해 두기 위해서는 별도의 로직이 필요해보인다.
ObjectPool 사용예제
public class RevisedGun : MonoBehaviour
{
//~~~
[Tooltip("Prefab to shoot")]
[SerializeField] private RevisedProjectile projectilePrefab;
// stack-based ObjectPool available with Unity 2021 and above
private IObjectPool<RevisedProjectile> objectPool;
// throw an exception if we try to return an existing item, already in the pool
[SerializeField] private bool collectionCheck = true;
// extra options to control the pool capacity and maximum size
[SerializeField] private int defaultCapacity = 20;
[SerializeField] private int maxSize = 100;
private void Awake()
{
objectPool = new ObjectPool<RevisedProjectile>(CreateProjectile,
OnGetFromPool, OnReleaseToPool, OnDestroyPooledObject,
collectionCheck, defaultCapacity, maxSize);
}
// invoked when creating an item to populate the object pool
private RevisedProjectile CreateProjectile()
{
RevisedProjectile projectileInstance = Instantiate(projectilePrefab);
projectileInstance.ObjectPool = objectPool;
return projectileInstance;
}
// invoked when returning an item to the object pool
private void OnReleaseToPool(RevisedProjectile pooledObject)
{
pooledObject.gameObject.SetActive(false);
}
// invoked when retrieving the next item from the object pool
private void OnGetFromPool(RevisedProjectile pooledObject)
{
pooledObject.gameObject.SetActive(true);
}
// invoked when we exceed the maximum number of pooled items (i.e. destroy the pooled object)
private void OnDestroyPooledObject(RevisedProjectile pooledObject)
{
Destroy(pooledObject.gameObject);
}
//~~~
}
발사체 컨트롤 예제
public class RevisedGun : MonoBehaviour
{
[Tooltip("Projectile force")]
[SerializeField] private float muzzleVelocity = 700f;
[Tooltip("End point of gun where shots appear")]
[SerializeField] private Transform muzzlePosition;
[Tooltip("Time between shots / smaller = higher rate of fire")]
[SerializeField] private float cooldownWindow = 0.1f;
//~~~
private void FixedUpdate()
{
// shoot if we have exceeded delay
if (Input.GetButton("Fire1") && Time.time > nextTimeToShoot && objectPool != null)
{
// get a pooled object instead of instantiating
RevisedProjectile bulletObject = objectPool.Get();
if (bulletObject == null)
return;
// align to gun barrel/muzzle position
bulletObject.transform.SetPositionAndRotation(muzzlePosition.position, muzzlePosition.rotation);
// move projectile forward
bulletObject.GetComponent<Rigidbody>().AddForce(bulletObject.transform.forward * muzzleVelocity, ForceMode.Acceleration);
// turn off after a few seconds
bulletObject.Deactivate();
// set cooldown delay
nextTimeToShoot = Time.time + cooldownWindow;
}
}
}
# 코드 분석
nextTimeToShoot = Time.time + cooldownWindow; 및 if 조건 Time.time > nextTimeToShoot를 이용한 공격 딜레이
objectPool.Get(); 통해 객체를 받고, 객체를 설정
bulletObject.Deactivate(); 는 class RevisedProjectile에 선언된, 일정 시간 후 Release(this)를 하는 메서드
#
// projectile revised to use UnityEngine.Pool in Unity 2021
public class RevisedProjectile : MonoBehaviour
{
// deactivate after delay
[SerializeField] private float timeoutDelay = 3f;
private IObjectPool<RevisedProjectile> objectPool;
// public property to give the projectile a reference to its ObjectPool
public IObjectPool<RevisedProjectile> ObjectPool { set => objectPool = value; }
public void Deactivate()
{
StartCoroutine(DeactivateRoutine(timeoutDelay));
}
IEnumerator DeactivateRoutine(float delay)
{
yield return new WaitForSeconds(delay);
// reset the moving Rigidbody
Rigidbody rBody = GetComponent<Rigidbody>();
rBody.velocity = new Vector3(0f, 0f, 0f);
rBody.angularVelocity = new Vector3(0f, 0f, 0f);
// release the projectile back to the pool
objectPool.Release(this);
}
}
# 코드 분석
IObjectPool<T>, class ObjectPool<T> 가 상속받은 인터페이스로 선언됨, property 멤버도 생성
IEnumerator, 코루틴으로 몇초 뒤에 Release하도록 구성됨
#
MVC, MVP Pattern
Model View Controller
- 어플리케이션의 데이터와 논리를 표시하는 방식을 분리한다.
- Unity에서는 데이터(Model), 시각적 표현 UI(View), 둘 사이의 상호작용을 제어하는 논리(Controller or Presenter) 로 분리한다.
/ 장점과 단점
- 원할한 작업 분할, 단순화된 단위 테스트, 가독성
- 미리 계획해야한다.
- 모든 프로젝트가 이 패턴에 의해 쉽게 분할되지 않는다.
- 간단한 스크립트에서는 큰 이점을 얻지 못한다.
MVC
- 컨셉은 소프트웨어의 논리적인 부분을 데이터 및 시청각화 분리하는 것이다.
- 불필요한 종속성을 줄이고, 잠재적으로 spaghetti code를 줄일 수 있다.
/ 구조
- Model : 데이터를 저장한다. 값을 보유하는 데이터 컨테이너, 별도의 게임플레이 로직을 수행하거나 계산을 수행하지 않는다.
- View : 인터페이스, 화면에 데이터의 시각표현을 형식화하고 렌더링한다.
- Controller : 로직을 처리한다. 게임 데이터를 처리하고 런타임 시 값이 어떻게 변경되는지 계산한다.
Controller와 View에는 데이터가 포함되지 않는다. MVC는 각 레이어의 기능을 제한한다. 표면적으로는 단일 책임 원칙의 확장으로 생각할 수 있다.
MVP
Model View Presenter
MVC를 unity에 개발할때, UI 프레임워크는 view로 사용하면 된다. 하지만 모델 데이터의 변경 사항을 수신하기 위한 view 관련 코드가 필요하다. 유효한 방법이지만, 많은 Unity 개발자는 컨트롤러가 중개자 역할을 하는 MVC의 변형을 사용한다.(#아마 인터페이스로 상호작용하기 위함 인듯?)
Pensenter (Controller at MVC) 가 다른 레이어를 중재하는 역할을 한다. 사용자 입력을 View가 처리한다.
예제
예제에서는 데이터와 UI가 하나의 script에 포함되어있지만, 실제로 이렇게 구현하지 않음을 유의한다.
namespace DesignPatterns.MVP
{
// The Model. This contains the data for our MVP pattern.
public class Health : MonoBehaviour
{
// This event notifies the Presenter that the health has changed.
// This is useful if setting the value (e.g. saving to disk or
// storing in a database) takes some time.
public event Action HealthChanged;
private const int minHealth = 0;
private const int maxHealth = 100;
private int currentHealth;
public int CurrentHealth { get => currentHealth; set => currentHealth = value; }
public int MinHealth => minHealth;
public int MaxHealth => maxHealth;
public void Increment(int amount)
{
currentHealth += amount;
currentHealth = Mathf.Clamp(currentHealth, minHealth, maxHealth);
UpdateHealth();
}
public void Decrement(int amount)
{
currentHealth -= amount;
currentHealth = Mathf.Clamp(currentHealth, minHealth, maxHealth);
UpdateHealth();
}
// max the health value
public void Restore()
{
currentHealth = maxHealth;
UpdateHealth();
}
// invokes the event
public void UpdateHealth()
{
HealthChanged.Invoke();
}
}
}
데이터를 가지는 model에서 실제 상태 값을 저장하고, 값이 변경되면 이벤트를 invoke한다. 여기에 게임플레이 로직(액션을 통한 체력 증감)이 포함되지 않으며, 데이터를 늘이고 줄이는 방법만 포함된다.
mathf.Clamp(currentHealth, minHealth, maxHealth) 에서는 값을 범위를 벗어나지 않는 값을 반환함.
namespace DesignPatterns.MVP
{
// The Presenter. This listens for View changes in the user interface and the manipulates the Model (Health)
// in response. The Presenter updates the View when the Model changes.
public class HealthPresenter : MonoBehaviour
{
[Header("Model")]
[SerializeField] Health health;
[Header("View")]
[SerializeField] Slider healthSlider;
[SerializeField] Text healthText;
private void Start()
{
if (health != null)
{
health.HealthChanged += OnHealthChanged;
}
Reset();
}
private void OnDestroy()
{
if (health != null)
{
health.HealthChanged -= OnHealthChanged;
}
}
// send damage to the model
public void Damage(int amount)
{
health?.Decrement(amount);
}
public void Heal(int amount)
{
health?.Increment(amount);
}
// send reset to the model
public void Reset()
{
health?.Restore();
}
public void UpdateView()
{
if (health == null)
return;
// format the data for view
if (healthSlider !=null && health.MaxHealth != 0)
{
healthSlider.value = (float) health.CurrentHealth / (float)health.MaxHealth;
}
if (healthText != null)
{
healthText.text = health.CurrentHealth.ToString();
}
}
// listen for model changes and update the view
public void OnHealthChanged()
{
UpdateView();
}
}
}
데부분의 객체는 Health 자체를 조작하지 않는다. 해당 작업을 위해 HealthPresenter을 사용해야한다. 일반적으로, Health가 HealthChanged 이벤트를 발생시킬 때까지 인터체이스 업데이트를 기다린다. 이런 방법은 값을 설정하는 데 시간이 짧은 경우 (값을 디스크에 저장하거나 데이터베이스에 저장하는 경우) 유용하다.
Factory pattern
- 다른 객체를 생성하는 특정 객체를 가짐으로써, 다양한 무언가에 대해 런타임에 필요할 때까지 모르는 경우에 대해 대처할 수 있다.
- 이를 위해 팩토리라는 객체를 지정하고, product 생성과 관련된 세부사항이 캡슐화 된다.
- 각 product가 공통 인터페이스 혹은 기본 클래스를 따르는 경우, 구성 로직을 더 포함하게 만들어 factory에서 감출 수 있다. 이렇게, 새로운 유형의 product를 생성하는 것이 더 용이해진다.
/ 장점 단점
- product 유형이 많을때 용이하다.
- 각 팩토리는 기부 세부 정보에 접근하지않고, 초기화를 호출하는 방법만 알면 된다.
- 패턴 구현을 위해 여러 클래스와 하위 클래스를 생성해야한다. 약간의 오버해드가 발생한다.
코드 예제
namespace DesignPatterns.Factory
{
// a common interface between products
public interface IProduct
{
// add common properties and methods here
public string ProductName { get; set; }
// customize this for each concrete product
public void Initialize();
}
}
- 제품에 대한 인터페이스, 특정 템플릿을 따르되 그 외의 기능은 공유하지 않는다.
namespace DesignPatterns.Factory
{
// base class for factories
public abstract class Factory : MonoBehaviour
{
public abstract IProduct GetProduct(Vector3 position);
// shared method with all factories
public string GetLog(IProduct product)
{
string logMessage = "Factory: created product " + product.ProductName;
return logMessage;
}
}
}
- 이 샘플에서는 추상 메서드를 사용한다. 서브클래스 사용시, 리스코프 치환에 대해 유의한다. 부모클래스의 객체를 서브클래스의 객체로 대체 할 수 있어야 한다. 이를 통해, 부모 클래스의 주소 변수가 하위 클래스 객체를 참조하는지 알지못하는 채로 사용할 수 있어야 한다.
파생 예제
ProductA
namespace DesignPatterns.Factory
{
public class ProductA : MonoBehaviour, IProduct
{
[SerializeField] private string productName = "ProductA";
public string ProductName { get => productName; set => productName = value ; }
private ParticleSystem particleSystem;
public void Initialize()
{
// any unique logic to this product
gameObject.name = productName;
particleSystem = GetComponentInChildren<ParticleSystem>();
particleSystem?.Stop();
particleSystem?.Play();
}
}
}
ProductB
namespace DesignPatterns.Factory
{
public class ProductB : MonoBehaviour, IProduct
{
[SerializeField] private string productName = "ProductB";
public string ProductName { get => productName; set => productName = value; }
private AudioSource audioSource;
public void Initialize()
{
// do some logic here
audioSource = GetComponent<AudioSource>();
audioSource?.Stop();
audioSource?.Play();
}
}
}
- interface는 공통적으로 따르고 각각 자신만의 추가 요소와 별도로 구현된 메서드를 가진다.
- 각 Product의 다른 동작을 알 필요 없이, Initialize() 만 알면 된다는 점을 유의한다.
ConcreteFactoryA
namespace DesignPatterns.Factory
{
public class ConcreteFactoryA : Factory
{
// used to create a Prefab
[SerializeField] private ProductA productPrefab;
public override IProduct GetProduct(Vector3 position)
{
// create a Prefab instance and get the product component
GameObject instance = Instantiate(productPrefab.gameObject, position, Quaternion.identity);
ProductA newProduct = instance.GetComponent<ProductA>();
// each product contains its own logic
newProduct.Initialize();
return newProduct;
}
}
}
ConcreteFactoryB
namespace DesignPatterns.Factory
{
public class ConcreteFactoryB : Factory
{
// used to create a Prefab
[SerializeField] private ProductB productPrefab;
public override IProduct GetProduct(Vector3 position)
{
// create a Prefab instance and get the product component
GameObject instance = Instantiate(productPrefab.gameObject, position, Quaternion.identity);
ProductB newProduct = instance.GetComponent<ProductB>();
// each product contains its own logic
newProduct.Initialize();
// add any unique behavior to this factory
instance.name = newProduct.ProductName;
Debug.Log(GetLog(newProduct));
return newProduct;
}
}
}
- interface를 공통적으로 따르고 각각 자신만의 추가 요소와 별도로 구현된 메서드를 가진다
예제에서는 A,B 랜덤 생산을 위해 상위 모듈 ClickToCreate Factory[] 변수를 통해, factory a,b를 받아 랜덤을 적용하였다.
추가 고려 사항
- 사전을 사용하여 제품 검색
- 팩토리를 정적으로 생성
- MonoBehaviours일 필요없음
- 객체 풀 패턴과 결합한다.
Command pattern
- 원래 Gang of Four 중 하나이며, 특정 일련의 작업을 추적하려고 할때 유용하다.
- 명령패턴을 사용하여 작업을 객체로 표현하고, 액션을 캡슐화하여 사용자 입력에 응답하여 게임오브젝트의 동작을 제어하는 유연하고 확장가능한 시스템을 만들 수 있다.
- 버퍼를 통해 명령을 저장하여, 나중에 재생할 수 있도록, 작업을 잠재적으로 지연시켜 실행 타이밍을 제어할 수 있다.
/활용 예시
- 유닛 생산 대기열
- 턴 기반 게임에서, 턴을 넘기기전에 실행을 자유롭게 하거나 되돌림
- 퍼즐게임에서 실행을 하거나 되돌림
- 격투게임에서 콤보를 통한 특정 공격 실행
/장단점
- 플레이어 입력을 처리하는 데에서 이점과 확장성이 생긴다.
- 더 많은 구조가 필요하다.
명령 인터페이스 예제
명령 개체에는 수행할 논리와 실행 취소 방법이 포함된다.
namespace DesignPatterns.Command
{
// interface to wrap your actions in a "command object"
public interface ICommand
{
public void Execute();
public void Undo();
}
}
- 명령 개체에는 수행할 논리와 실행 취소 방법이 포함된다.
- CommandInvoker에서 Command의 실행 주체와, 내부 로직을 알 필요없이, Execute()와 Undo() 만으로 시스템이 구성되어야 한다.
CommandInvoker 예제
public class CommandInvoker
{
// stack of command objects to undo
private static Stack<ICommand> _undoStack = new Stack<ICommand>();
// second stack of redoable commands
private static Stack<ICommand> _redoStack = new Stack<ICommand>();
// execute a command object directly and save to the undo stack
public static void ExecuteCommand(ICommand command)
{
command.Execute();
_undoStack.Push(command);
// clear out the redo stack if we make a new move
_redoStack.Clear();
}
public static void UndoCommand()
{
if (_undoStack.Count > 0)
{
ICommand activeCommand = _undoStack.Pop();
_redoStack.Push(activeCommand);
activeCommand.Undo();
}
}
public static void RedoCommand()
{
if (_redoStack.Count > 0)
{
ICommand activeCommand = _redoStack.Pop();
_undoStack.Push(activeCommand);
activeCommand.Execute();
}
}
}
- 명령 실행 및 실행 취소를 담당한다. 추가로 Redo 기능을 위해, _redoStack를 가진다.
명령 구현 예제
public class MoveCommand : ICommand
{
private PlayerMover _playerMover;
private Vector3 _movement;
// pass parameters into the constructor
public MoveCommand(PlayerMover player, Vector3 moveVector)
{
this._playerMover = player;
this._movement = moveVector;
}
// logic of thing to do goes here
public void Execute()
{
// add point to path visualization
_playerMover?.PlayerPath.AddToPath(_playerMover.transform.position + _movement);
// move by vector
_playerMover.Move(_movement);
}
// undo logic goes here
public void Undo()
{
// move opposite direction
_playerMover.Move(-_movement);
// remove point from path visualization
_playerMover?.PlayerPath.RemoveFromPath();
}
}
# 코드 분석
move 동작을 직접적으로 구현한다기 보단, 메서드 호출로 Move커맨드를 구성한다고 보여진다. 이를통해, Move커맨드에 대해, Path 를 표시하는 기능 메서드 호출을 통해 구성되었다.
#
각 커맨드에 필요한 변수 멤버를 가진다.
InputManger
구성한 Command를 이용하여 동작을 구성한다.
public class InputManager : MonoBehaviour
{
// UI Button controls
[Header("Button Controls")]
[SerializeField] Button forwardButton;
[SerializeField] Button backButton;
[SerializeField] Button leftButton;
[SerializeField] Button rightButton;
[SerializeField] Button undoButton;
[SerializeField] Button redoButton;
[SerializeField] private PlayerMover player;
private void Start()
{
// button setup
forwardButton.onClick.AddListener(OnForwardInput);
backButton.onClick.AddListener(OnBackInput);
rightButton.onClick.AddListener(OnRightInput);
leftButton.onClick.AddListener(OnLeftInput);
undoButton.onClick.AddListener(OnUndoInput);
redoButton.onClick.AddListener(OnRedoInput);
}
private void RunPlayerCommand(PlayerMover playerMover, Vector3 movement)
{
if (playerMover == null)
{
return;
}
// check if movement is unobstructed
if (playerMover.IsValidMove(movement))
{
// issue the command and save to undo stack
ICommand command = new MoveCommand(playerMover, movement);
// we run the command immediately here, but you can also delay this for extra control over the timing
CommandInvoker.ExecuteCommand(command);
}
}
private void OnLeftInput()
{
RunPlayerCommand(player, Vector3.left);
}
private void OnRightInput()
{
RunPlayerCommand(player, Vector3.right);
}
private void OnForwardInput()
{
RunPlayerCommand(player, Vector3.forward);
}
private void OnBackInput()
{
RunPlayerCommand(player, Vector3.back);
}
private void OnUndoInput()
{
CommandInvoker.UndoCommand();
}
private void OnRedoInput()
{
CommandInvoker.RedoCommand();
}
}
CmmandInvoker를 통해 커맨드 동작을 실행시킨다.
개량법
- 더 많은 커맨드를 생성
- redo 기능을 위한 추가 추가 스택(예제에서 구현됨)
- queue 및 index를 사용해, 한 자료구조 내에서 undo, redo 구현
- 스택 사이즈 제한
- 필요한 파라미터를 전달 (예제에서 구현됨)
# 콤보 시스템 스크립트 분석 예정.
Singleton Pattern
- 클래스가 하나만 생성되도록 보장한다.
- global 접근이 용이하도록한다.
- 씬 간 플로우에 있어서, 하나의 객체만 유지하고 할 때(데이터 지속성 관련), 유용하다.
- Game Programming Pattens에서는 Singleton 패턴은 득보다 실이 더 많은, anit- pattern이라고 한다. 이러한 평판은 남용으로 인해 생겼났다.
/장단점
- global 접근이 필요하다. 많은 종속성을 숨기기 때문에 디버그가 힘들어 진다.
- unit 테스트는 반드시 독립된 단위로 실시한다. 때문에 씬 간 상태를 변화시킬 수 있는 싱글톤은 테스트에 간섭한다.
- 대부분은 디자인 패턴은 종속성을 제거하는 데 비해, 싱글톤은 반대이다.
기업 레벨의 프로젝트에서는 싱글톤을 피한다.
- 싱글톤은 상대적으로 학습 속도가 빠르다.
- 유저친화적이다. 씬의 객체가 언제든지 싱글톤 객체에 접근할 수 있다.
- GetComponent와 같은 비교적 오래 걸리는 작업을 피할 수 있다.
SimpleSingleton
public class SimpleSingleton : MonoBehaviour
{
// global access
public static SimpleSingleton Instance;
private void Awake()
{
if (Instance != null)
{
// if Instance is already set, destroy this duplicate
Destroy(gameObject);
}
else
{
// if Instance is not set, make this instance the singleton
Instance = this;
}
}
}
static과 Awake() 를 사용해, 생성된 객체가 하나만 유지되도록 한다.
simpleSingleton의 이슈
- 새로운 씬에서 객체가 사라진다(씬간 데이터 지속성이 필요하다.)
- singleton 특성을 사용하기 위해서는 사용하기 전에 설정해야한다.
lazy instantiation 게으른 초기화를 통해 싱클톤이 자동으로 구현되도록 한다.
SingletonPersistent
public class Singleton : MonoBehaviour
{
private static Singleton _instance;
// global access
public static Singleton Instance
{
get
{
if (_instance == null)
{
SetupInstance();
}
return _instance;
}
}
private void Awake()
{
// if this is the first instance, make this the persistent singleton
if (_instance == null)
{
_instance = this;
DontDestroyOnLoad(this.gameObject);
}
// otherwise, remove any duplicates
else
{
Destroy(gameObject);
}
}
private static void SetupInstance()
{
// lazy instantiation
_instance = FindObjectOfType<Singleton>();
if (_instance == null)
{
GameObject gameObj = new GameObject();
gameObj.name = "Singleton";
_instance = gameObj.AddComponent<Singleton>();
DontDestroyOnLoad(gameObj);
}
}
}
제너릭 적용
public class Singleton<T> : MonoBehaviour where T : Component
{
private static T _instance;
public static T Instance
{
get
{
if (_instance == null)
{
_instance = (T)FindObjectOfType(typeof(T));
if (_instance == null)
{
SetupInstance();
}
else
{
string typeName = typeof(T).Name;
Debug.Log("[Singleton] " + typeName + " instance already created: " +
_instance.gameObject.name);
}
}
return _instance;
}
}
public virtual void Awake()
{
RemoveDuplicates();
}
private static void SetupInstance()
{
// lazy instantiation
_instance = (T)FindObjectOfType(typeof(T));
if (_instance == null)
{
GameObject gameObj = new GameObject();
gameObj.name = typeof(T).Name;
_instance = gameObj.AddComponent<T>();
DontDestroyOnLoad(gameObj);
}
}
private void RemoveDuplicates()
{
if (_instance == null)
{
_instance = this as T;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
}
사용 예제
public class GameManager : Singleton<GameManager>
{
}
Game Programming Patterns
- writter : Bob Nystrom
Introduction
- 게임에서 일반적으로 직면하는 엔지니어링 문제에 적합한 패턴이다.
- 타임과 순서, 올바른 순서로 올바른 타이밍에
- 서로 발끝을 밟거나 발자국을 남기지 않고 다양한 동작을 신속하게 구축하는 방법
- 이 동작이 정의되면, 이제야 상호작용이 시작된다. 이러한 상호작용은 코드베이스가 서로 얽히지 않는 상태에서 이루어져야한다.
- 성능, 프레임 드랍 및 화난 리뷰어와 A급 게임 사이 차이를 만들어 낼 수도 있다.
SoftArchitecture
- 코드를 구성하는 것, 모든 것을 main() 넣는 것 일지라도, 어느정도의 조직이 있다.
- "좋은 디자인이란, 내가 변화를 가했을 때, 마치 전체 프로그램이 그것을 예상하고 만들어진 것 처럼 보인다는 것"
- “Just write your code so that changes don’t disturb its placid surface.”
- 디자인의 척도는 변경사항을 얼마나 잘 수용하느냐에 달려있다.
- 핵심은 수정할 때의 알아야할 코드가 적어야 한다는 것이다. (원숭이 뇌 뉴런을 학습시킬 정보를 줄인다.)
- 무언가를 추가하거나 디버드를 하는 등 어떤 이유 때문에 코드를 바꾸기전에, 관련된 부분들과 존재하는 코드가 무엇을 하는지 알아야한다. 이 부분이 가장 시간이 오래걸린다.
- 올바른 구문으로 구현하는건, 건 앞 뒤로 해야할 것이 많지만, 비교적 간단한 경우가 많고, 실제 코딩은 사소한 일이 된다.
/Cost
- decoupling pattern를 통해, 나누어진 부분을 독립적으로 이해하도록 만든다.
- 좋은 아키텍처는 진정한 노력과 규율이 필요하다.
- 나중에 유연성이 추가될 것이라고 추측하게 되면서, 오히려 유지 관리가 걸리는 코드와 복잡성을 게임에 추가한다. 예측이 맞으면 보상을 받지만, 결국 도움이 되지 않으면 바로 해가 된다.
- 코드에 얽매여 게임을 출시하려고 한다는 사실을 간과하기 쉽다.
- "확장성에 대한 사이렌 노래는 무엇을 위한 엔진인지도 파악하지 못하고 엔진작업을 하는 개발자를 짜증나게 한다.
/Performance and Speed
- 디자인이 안정될 때까지 코드를 유연하게 유지하고 마지막에 추상화 일부를 때어내 성능을 향상시킨다.
- 좋은 아키텍처와 빠른 런타임 성능과 오늘 마감 은 서로 부분적으로 반대된다. 여기에는 간단한 대답이 없으며 단지 절충안만 있을 뿐이다.
/Simplicity
- simplicity를 통해 이러한 제약을 완화할 수 있다. 하지만 간단히 적용할 수 있지 않다
Tip
- 추상화와 디커플링을 통해 프로그램을 빠르고 쉽게 발전시킬수 있지만, 유연성이 필요하다고 확신하지 않는 한 이를 수행하는 데 시간을 낭비하지 마십시요
- 개발 주기에 걸쳐 성능에 대한 생각을 가지고 설계하되, 가정을 코드에 고정시키는 낮은 수준의 핵심 최적화는 가능한 한 늧게까지 미루십시오
- 게임 디자인 공간을 탐색하기 위해 빠르게 움직이되, 뒤죽박죽이 되지 않도록 하십시오
- 재미있는 것을 만들고 싶다면 재미있게 만들어보십시오
- 마감 2개월 전에 게임이 1FPS에서 돌아가는 문제"에 대한 걱정은 잔소리일뿐이다.
# 각 코드는 자료를 참고 하면서 임의로 C# 으로 구현한거라 미숙한 부분이 많음
Design Patterns Revisited
- Command | Flyweight | Observer | Prototype | Singleton | State
Command
#Part 1에서 한번 다룸
"Encapsulate a request as an object, thereby letting users parameterize clients with different requests, queue or log requests, and support undoable operations"
정말 끔직한 문장이다 - Bob Nystrom
"A command is a reified method call"
커맨드 패턴은 구체화된 메서드 호출이다.
- "Reify" 는 "make real", 다른 말로 "first-class"로 만드는것
- 어떤 개념을 취하여, 변수를 추가하거나 함수에 넘길 수 있는 데이터의 조각(객체)로 만드는 것.
- 객체에 랩핑된 메서드 호출
"Commands are an object-oriented replacement for callbacks"
명령 패턴은 콜백을 객체지향적으로 대체한다.
Configuring Input
사용자 입력과 게임 액션을 일대일로 매칭하면 작동은 되지만, 많은 게임의 경우 유저가 키를 직접 맵핑하는 기능을 지원한다. 이를 위해, 키에 매칭한 메서드를 "교체" 할 수 있게 만들어야 한다. 교체는 변수 할당과 비슷하게, 게임 액션을 개체로 만들어야 한다는 것이다.
메서드, 키 일대일 매칭
public class InputManager : MonoBehaviour
{
void Update()
{
if (Input.GetKey(KeyCode.UpArrow)) { Jump(); }
else if (Input.GetKey(KeyCode.DownArrow)) { FireGun(); }
else if (Input.GetKey(KeyCode.LeftArrow)) { SwapWeapon(); }
else if (Input.GetKey(KeyCode.RightArrow)) { LunchIneffectively(); }
}
}
Command pattern
public interface Command
{
public abstract void Execute();
}
class JumpCommand : Command
{
public void Execute()
{
Jump();
}
void Jump() { }
}
public class InputManager : MonoBehaviour
{
Command buttonX;
Command buttonY;
Command buttonA;
Command buttonB;
void Update()
{
if (Input.GetKey(KeyCode.UpArrow)) { buttonX.Execute(); }
else if (Input.GetKey(KeyCode.DownArrow)) { buttonY.Execute(); }
else if (Input.GetKey(KeyCode.LeftArrow)) { buttonA.Execute(); }
else if (Input.GetKey(KeyCode.RightArrow)) { buttonB.Execute(); }
}
}
Directions for Actor
위의 Command Pattern 역시 잘 작동하지만, 제한적이다. 문제는 게임 액션 메소드( jump() 등)가 행위를 실행할 플레이어 아바타와 행위를 실행시킬 방법을 가진 최상위 객체가 되어버린 것이다. 이 Couoling(결합)으로 인해 유용성이 상당히 제한된다.
이를 개선하기 위해 이러한 사항을 넘겨 줄 수 있도록한다.
public interface GameActor
{
public abstract void Do();
}
public interface Command2
{
public abstract void Execute(GameActor actor);
}
class JumpCommand2 : Command2
{
public void Execute(GameActor actor)
{
actor.Do();
}
}
#Command 시 객체를 생성하는 경우
public interface GameActor
{
public abstract void Do();
}
class JumpCommand : Command
{
private GameActor actor;
public GameActor Actor { get { return actor; } set { actor = value; } }
JumpCommand(GameActor actor)
{
this.actor = actor;
}
public void Execute()
{
Jump();
}
void Jump()
{
Actor.Do();
}
}
이제 이를 해당하는 액터에서 실행 할 수 있도록 Command를 리턴하는 메서드(가져올 수 있도록)를 작성한다.
//~~~
Command2 currentCommand;
public Command2 CurrentCommand { get { return currentCommand; } }
void Update()
{
if (Input.GetKey(KeyCode.UpArrow)) { currentCommand = buttonX; }
else if (Input.GetKey(KeyCode.DownArrow)) { currentCommand = buttonY; }
else if (Input.GetKey(KeyCode.LeftArrow)) { currentCommand = buttonA; }
else if (Input.GetKey(KeyCode.RightArrow)) { currentCommand = buttonB; }
}
//~~~
public class Player : MonoBehaviour,GameActor
{
//~~~
void Command()
{
inputManager.CurrentCommand.Execute(this);
}
public void Do(){}
//~~~
}
명령과 명령을 수행하는 액터 사이에 레이어를 추가하여 작은 기능이 제공되었다. 플레이어가 게임의 어느 액터도 컨트롤 할 수 있게 만들 수 있다.
이런식으로, AI엔진과 액터 사이에도 이러한 패턴을 적용할 수 있다. AI엔진에서 Command 객체만 리턴하도록 만들면 된다.
이렇게 액션과 액터의 decoupling을 통해 유연성을 얻을 수 있다.
이제 이것을 Queue나 커맨드의 흐름으로 생각해본다. 커맨드생산자(AI 엔진, 입력)와 커맨드소비자(액터) 이런식의 직렬화(serializable)를 통해 네트워크를 통한 게임플레이도 가능해진다.
Undo and Redo
이 패턴에서 가장 알려진 용례로, 전략게임에서의 행동을 취소하는 Undo 기능을 구현할 수 있다. 커맨드 패턴이 없다면 Undo 기능은 정말로 구현하기 어렵다. (Mememto와 Persistent data structures 는 그리 도움이 되지 않는다.)
플레이어가 입력을 할때마다 Command 객체를 생성하고, Command 객체에서는 각 행위를 복구시키는 기능을 탑재한다.
##
Part1 에서 다룬 Command pattern과 다른점은 전에는 위치을 복구시키는 메세드를 작성했다면, 여기서는 이전 위치를 Move에 기억하게 만들어 놓았다.
##
Classy and Dyhunctional?
# 대강 이 당시에는 c++에서는 델리게이트 같은 기능이 제한되었기 때문에 이러한 방식을 썻다는 얘기 같음. 사실상 이와 비슷하게 작동하기 때문에, Part1 에서도 Command 패턴은 undo redo 용례만 다루었다고 생각됨
See Also
- 다양한 명령 클래스가 생길 수 있다. 이를 쉽게 구현하기 위해, 파생 Command를 만들 수 있는 편리한 상위 레벨의 메서드를 가진 부모 클래스를 정의하는 것이 도움이 된다. 이렇게, execute() 메서드가 Subclass Sandbox 패턴이 된다.
- 여기서의 예제에서는 액터를 정확히 선택했지만, 몇몇 경우 특히 명령개체가 계층적인 경우, 개체가 응답하거나 하위 객체에 명령을 넘길수 있다. 이경우 Chain of Responsibility 패턴을 얻게 된다.
- JumpCommand와 같은 일부 명령은 상태가 없는 순순한 행동(?)이다. 객체가 동일하기 때문에, 하나보다 많은 객체를 갖는 것은 메모리 낭비이다. Flyweight 패턴이 이를 해결한다.
Flyweight
안개가 일어나고, 장엄함 장송이 드러나고, 셀수없이 많은 나무가 우뚝쏟아 녹지의 대성당을 이루고 있다. 나뭇잎으로 이루어진 스테인드 글라스는 햇빛을 황금빛 안개 줄기로 조각낸다. 거대한 줄기 사이로 울창한 숲이 눈에 띈다.
이는 게임 게발자가 꿈꾸는 일종의 초자연적 현상이고, 이러한 장면은 이름마저 이보다 겸손할 수 없는 FlyWeight 이다.
Forest for the Trees
이러한 장면을 구현하는 것은, GPU 가 처리해야할 수 많은 폴리곤을 동반한다. 또한 이를 CPU에서 GPU로 전달해 줘야한다.
각 나무는 다음과 같은 요소로 이루어진다.
- 줄기, 가지, 녹지을 정의하는 폴리곤의 메세
- 나무껍집과 나뭇잎의 텍스쳐
- 숲에서의 위치와 방향
- 각 나무가 다르게보이도록 크기와 틴트롤 조절하는 변수
각 개체를 만들어 처리하는것은 너무나도 많은 GPU처리를 소모한다. 하지만 방법이 있다.
주요한 관점은 수많은 나무는 비슷해보인다는 것이다. 동일한 매시와 텍스처를 사용한다. 이러한 모든 객체의 모든 필드는 같다는 것을 의미한다.
이러한 공통요소와 차이요소를 나누어 클래스를 선언한다.
class TreeModel
{
Mesh mesh;
Texture bark;
Texture leaves;
}
class Tree
{
TreeModel model;
Vector3 position;
double height;
double thickness;
Color barkTint;
Color leafTint;
}
이는 메모리 저장하는데에는 도움이 되지만 GPU에는 도움이 되지 않는다.
A Thousand Instances
GPU에 밀어넣을 데이터를 줄이긴 위해서는 공유 데이터는 한번만 보내고 개별데이터를 밀어내는 것이다. 이는 오늘날의 그래픽 API가 지원한다. 이는 instanced randering이다
The Flyweight Pattern
객체가 너무 많아서 가벼워야하는 경우 적용된다.
instanced randering와 같이 데이터의 종류를 공통된 것과 아닌 것으로 나눈다. 공통부분에 대해 "The Gang of Four" 애서는 공통부분을 본질적인 것으로 보지만, 책의 저자는 “context-free" 으로 본다.
공통 부분이 아닌 부분은 extrinsic state 외부상태, 인스턴스의 고유한 항목이다. 이 패턴은 개체의 모든 위치에서 intrinsic state 를 공유해서 메모리를 절약한다.
이것은 마치 기본적인 리소스 공유처럼 보인다. 패턴으로 부르기 힘들다. 이는 부분적으로 공유상태에 대해 명확하고 별도의 ID를 생각해 낼 수 있기 때문이다.
공유객체에 대해 잘 정의된 ID가 없는 경우에 이 패턴을 사용하면 이 패턴이 덜 명확해지고 더 교묘해진다. 그러면 물체가 동시에 여러 장소에 마술처럼 있는 것 처럼 느껴진다.
A Place To Put Down Roots
나무가 자라는 땅도 게임에 표현해야한다. 바닥 타일 기반을 만들것이다. 각 타일은 특정 영역으로 덮혀진다
각 영역은 게임플레이에 영향을 주는 속성을 갖는다.
- 속도를 정하는 이동 코스트
- 배로 건너는 등을 나타내는 flag
- 사용될 텍스쳐
프로그래머는 효율성에 대한 편집증이 있기 때문에, 각 타일에 모든 상태를 저장하는 방법은 없다.. 그 대신 영역 타입에 열거형을 사용한다. 그리고 2차원 배열을 사용한다.
enum Terrain
{
TERRAIN_GRASS,
TERRAIN_HILL,
TERRAIN_RIVER,
}
class World
{
Terrain[,] tiles = new Terrain[WIDTH,HEIGHT];
public int GetMovementCost(int x,int y)
{
switch (tiles[x,y])
{
case Terrain.TERRAIN_GRASS:
return 1;
case Terrain.TERRAIN_HILL:
return 2;
case Terrain.TERRAIN_RIVER:
return 3;
default:
return 0;
}
}
public bool IsWater(int x, int y)
{
switch (tiles[x, y])
{
case Terrain.TERRAIN_GRASS:
return false ;
case Terrain.TERRAIN_HILL:
return false;
case Terrain.TERRAIN_RIVER:
return true;
default:
return false;
}
}
}
이 코드는 효과가 있지만, 코스트와 수륙여부가 영역의 대한 데이터이기 때문에 단일 지형 유형의 데이터가 번져있다. 이것을 캡슐화 한다.
public class Terrain
{
public Terrain(int movementCost, bool isWater, Texture texture)
{
this.movementCost = movementCost;
this.isWater = isWater;
this.texture = texture;
}
int movementCost;
public int MovementCost => movementCost;
bool isWater;
public bool IsWater => isWater;
Texture texture;
Texture ThisTexture => texture;
}
각 지형 객체를 만든 뒤, 2차원 배열 Terrain[,] ground을 만들고 객체를 참조시킨다.
What About Performance?
#대충 컴퓨터가 바뀜에 따라서 메모리의 배치 방법에 따라 열거형 혹은 객체를 사용한 패턴의 성능이 달라질 수 있다.
Flyweight pattern을 사용하여 수많은 객체를 사용하지 않아도 객체 지향 원칙을 적용할 수 있다.
See Also
- 위 예제에서는 지형 타입이 이미 저장되어 찾기 쉽고, 재사용하기 싶지만, 많은 경우 이러한 지형을 미리 생성하기 원치 않을 것이다. 무엇이 필요한지 모르면, 요청 시 생성하는 것이 좋다 이점을 얻을 려면, 요구했을때 이미 만들었는지 확인하고 리턴하면 된다. 이에따라 존재하는 객체를 찾을 수 있는 인터페이스, 구성을 캡슐화해야한다 이와 같이 생성자를 숨기는 것을 Factory Method의 예이다.
- 이미 생성된 flyweight를 반환하기 위해서는 pool을 추적해야 한다. object pool은 객체를 저장하는데 좋은 공간이 된다.
- State pattern을 사용할 때, "state" 객체는 상태를 사용하는 특정 머신과 관련된 필드가 없는 경우가 많다. state의 identity와 메서드는 충분히 유용하다. 이 경우 패턴을 적용하고 문제없이 동시에 여러 상태 시스템에서 동일한 state 객체를 재사용할 수 있다.
Observer
Observer 패턴이 사용된 MVC 아키텍쳐을 사용한 어플리케이션 없이 컴퓨터에 돌을 던질 수 없다.
Achievement Unlocked
게임의 한 측면과 관련된 코드를 잘 모아둔 상태에서 다양한 업적을 구현할 때, 어떻게 여러 업적이 모인 업적 시스템을 여러 코드와 연결하지 않고 구현 할 수 있나?
Observer 패턴의 목적이다. 받는게 누구인지 신경 쓰지 않고 알리는 것이다.
# 코드 설명은 생략 Part1 참고
It's to fast?
Observer패턴은 동기식이므로 주의해야한다. 너무 느린 구독자는 객체를 가릴 수도 있다. 이벤트에 동기적으로 응답하는 경우 가능한한 빨리 반환해야한다. 처리하는 작업이 느린 경우, 해당 작업을 다른 쓰레드나 큐에 넣도록 한다.
옵저버와 명시적 잠금을 혼합한다면 주의해야한다. 관찰자가 대상이 가지고 있는 잠금장치를 잡으려하면 게임이 Deadlock에 빠진다. 고도로 쓰레드 된 엔진이라면 비동기 EventQueue를 사용하는게 나을 수 있다.
Destroying subjects and observers
일반적으로 소멸자에 호출을 추가하면 된다. 두뇌로 기억해 두기만 된다
What's going on?
Observer 패턴을 통해 연결을 느슨하게 만들었지만, 여기서 문제가 생기면 찾아내기가 비교적 어렵다.
커뮤니케이션을 양면으로 자주 생각하는 경우, Observer 패턴보다는 명확한 것을 사용한다.
Observer 패턴의 경우, 서로 관련없는 덩어리가 하나의 큰 덩어리로 합쳐지지 않고 서로 통신 할 때 유용하다. 하나의 큰 덩어리내에서는 덜 유용하다
하나의 덩어리를 수행할 때에 다른 덩어리에 대해 많은 지식이 필요하지 않도록 최소한의 의사소통을 원한다.
Prototype
오늘날 프로토타입에 대해서 이야기 할 때, 디자인패턴의 프로초타입에 대해서 이야기하지 않는다.
The Prototype Design Pattern
악령, 악마, 사령 과 같은 몬스터는 base class, Moster를 파생시켜 만들게 된다.
그와 비슷하게 각 몬스터 스포너 역시 base class, Spawner를 파생시켜 만들게 된다.
public class Monster{}
public class Ghost : Monster{}
public class Demon : Monster{}
public class Spawner
{
public Monster SpawnMoster() { return new Monster(); }
}
public class GhostSpawner : Spawner
{
public Monster SpawnMoster()
{
Monster ghost = new Ghost();
return ghost;
}
}
public class DemonSpawner : Spawner
{
public Monster SpawnMoster()
{
Monster demon = new Demon();
return demon;
}
}
이러한 스포너들은 비슷한 부분이 정말 많게 되는데 이러한 중복 구문을 줄이는 방법이 있다.
방법은 spawn 방법을 spawner가 아닌 Moster에 추가하는 방법이다.
public class Monster
{
public virtual Monster clone()
{
return new Monster();
}
}
public class Ghost : Monster
{
int health;
int speed;
Ghost(int health, int speed)
{
this.health = health;
this.speed = speed;
}
public override Monster clone()
{
return new Ghost(health,speed);
}
}
public class Spawner
{
Monster prototype;
public Monster clone()
{
return prototype.clone();
}
}
간단한 고스트 스포너를 만들려면, Spawner의 protype 변수에 Ghost 객체를 넣으면 된다. 적절한 속성 선택으로 다양한 Ghost 객체를 만들 수 있다.
How well does it work?
각 몬스터의 스포너 클래스를 만들 필요는 없지만, 인위적인 문제에서 많은 코드를 절약하는 것처럼 보이지 않을 뿐만 아니라 인위적인 문제라는 사실도 있습니다. 각 몬스터마다 별도의 클래스가 있가는 것을 당연하게 받아들여야 했습니다. 요즘 게임엔진은 이런식으로 돌아가지 않습니다.
큰 계층구조 클래스는 관리하기 어렵기 때문에, Component 및 Type Object 를 사용해서 자체 클래스가 아니라 여러 엔티티를 모델링한다.
Spawn fuctions
각 몬스터에 대해 별도의 생성자 클래스를 만드는 대신 다른 방법이 있다. 생성 메서드를 만드는 것이다.
특정 몬스터의 구성을 위해 전체 클래스를 만드는 것보다 상용구가 적다.
public class Monster
{
}
public class Ghost : Monster
{
int health;
int speed;
}
public Monster spawnGhost()
{
return new Ghost();
}
public delegate Monster SpawnCallback();
public class Spawner
{
private SpawnCallback spawn;
public Spawner(SpawnCallback spwan)
{
this.spawn = spwan;
}
public Monster SpawnMonster()
{
return spawn();
}
}
public Monster CreateGhostEX()
{
Spawner spawner = new Spawner(spawnGhost);
return spawner.SpawnMonster();
}
Templates
public class Monster
{
}
public class Ghost : Monster
{
int health;
int speed;
}
public Monster spawnGhost()
{
return new Ghost();
}
public delegate Monster SpawnCallback();
public class Spawner<T> where T : Monster, new()
{
private SpawnCallback spawn;
public Monster SpawnMonster()
{
return new T();
}
}
The Prototype Language Paradigm
보통 "객체 지향" 프로그래밍을 "class"의 동의어라고 생각한다. OPP와 반대 되는 신앙처럼 보일수 있지만, OPP하면 사용해 데이터와 코드를 묶어 사용해 객체를 정의할 수 있다. OPP 의 특성은 상태와 행동을 긴밀하게 묶는 것이다.
클래스는 이를 위한 하나의 방법이지만, 소수의 사람들은 그렇지 않다고 주장한다. 80년대 "self" 를 만들었던 사람들이다. OOP와 마찬가지로, class가 없을 수 있었다.
Self
"Without class, how do we make new things? the way yo do this in Self is by cloning"
Self 는 class 기반 언어보다 더 객체 지향적이다. OPP의 관점은 상태와 행동의 결합이지만, 클래스는 상태와 행동을 분리하는 라인을 가진다. 함수를 실행시키기 위해, 인스턴스를 조회하고, 그 다음에 메소드를 조회한다. 행동은 class에 포함되어 있다. 메서드에 도달히기 위한 단계가 있다, 그것은 필드와 메서드가 다르다는 것을 의미한다.
Self (객체?) 는 그러한 구별을 제거한다. 무엇을 조회하던, object를 보면 된다. instrance는 상태와 행동 다 가지고 있다. 한개의 객체에 대해, 메서드는 완전히 그것의 유니크이다. 부모 객체는 행동과 상태를 재사용하게 만들어 주게 되고, 이에 따라 우리는 클래스 유틸리티의 일부를 다루었다. 또다른 핵심은 class가 instances를 만드는 방식을 제공해준다. class는 자신의 인스턴스를 위한 팩토리이다.
class가 없으면, 어떻게 새로운 것을 창조할 것이냐? Self(객체)를 통해 복제하면 된다.Clone() 은 이미 System에 적용되어 있다.
What about JavaScript?
자바 스크립트에서는 새로운 객체를 기존 객체를 통해 만들며, new 를 통해 빈 객체를 생성하면 프로토타입 객체에 위임한다. 동작을 정의하려면 프로토타입 객체에서 메서드를 추가한다. 메서드 역시 대리자를 통해 전달한다.
# 새로운 객체의 메서드를 대리자를 통해 전달한다는 것이 인상깊다.
Prototypes for Data Modeling
#데이터 지속성을 위한 파일 저장에 대한 사항
일회성 객체를 저장하기 위해, 반복되는 부분을 prototype으로 지정한다.
Singleton
이 장은 예외적으로 사용하지 않는 방법을 보여준다. 의도에도 불구하고 싱글톤 패턴은 득보다 실이 더 많다. Gang of Four에서 사용을 자제하라고 하지만, 그 메시지는 게임 산업에서 종종 상실된다..
싱글톤 패턴은 총상에 부목을 하는 것처럼 유용하다. 남용해서는 않된다.
The Singleton Pattern
"클래스의 인스턴스가 하나임을 보장한다. 그리고 글로벌 액세스를 제공한다."
여기서 "그리고"를 유의하고, 나눠서 고려한다.
Restricting a class to one instance
클래스의 인스턴스가 둘 이상이면 제대로 작동하지 않는 경우가 있다. 일반적으로 클래스가 자체 전역 상태를 유지하는 외부 시스템와 상호작용하는 경우이다.
파일 시스템을 랩핑하는 클래스를 생각해본다. 클래스의 작업이 비동기적으로 수행한다. 이는 여러 작업이 동시에 수행될 수 있음으로, 서로 조정되여야 한다는 것을 의미한다. 서로 상충되는 작업이 서로 간섭하지 않게 해야한다. 이렇게 하려면, 랩퍼에 대한 호출이 모든 과거 작업에 접근할 수 있어야 한다. 각 객체끼리 무슨 작업을 하는지 알수 없으므로, 하나의 인스턴스를 보장하기 위해 싱글턴을 사용한다.
Providing a global point of access
static을 사용한다.
public class FileSystem
{
public static FileSystem fileSystem;
public void instance()
{
if (FileSystem.fileSystem == null)
FileSystem.fileSystem = new FileSystem();
}
}
Gpt, Lazy Initialization
public class FileSystem
{
private static readonly Lazy<FileSystem> LazyInstance = new Lazy<FileSystem>(() => new FileSystem());
public static FileSystem Instance { get { return LazyInstance.Value; } }
private FileSystem() { }
}
Why We Use It
- 사용하지 않으면 만들지 않는다.
- 런타임에 초기화 된다. 싱글톤 없이 정적 멤버를 사용한다면, 자동 초기화 제한 사항이 있다. 프로그램이 시작된 뒤의 정보를 사용할 수 없다. 이는 안정적으로 의존할 수 없음을 의미한다.컴파일러는 정적변수가 서로에 대해 초기화 되는 순서를 보장하지 않는다. 지연 초기화는 이러한 문제를 해결한다. 싱글톤은 가능한 늦게 초기화되려 한다. 그 때까지 필요한 정보가 사용가능해야 한다. 순환 종속성이 없다면, 싱글톤을 초기화할 때, 다른 싱글톤을 참조할 수 있다.
- Subclass를 싱클톤 할 수 있다. 이는 강력하지만 종종 간과된다. 크로스 플랫폼을 위한 파일시스템 wrapper에 대해서, FileSystem 인터페이스를 만들고, 파생시켜 각 플랫폼의 파일시스템 클래스를 만들었다고 하자. 컴파일러 스위피를 사용하려 파일 시스템 래퍼를 생성한다.
Why We Regret Using It
단기적으로 싱글톤 패턴은 무해하다. 하지만 장기적으로 대가를 지불해야한다. 불필요한 싱글톤을 cold hard code로 캐스팅하면 다음과 같은 문제를 겪는다.
It's a global variable
게임이 점점 커지고 복잡해지면서, 아키텍터와 유지관리성이 병목현상이 발생하고, 생산성의 한계 때문에 게임 개발이 어려워지기 시작한다. 여러 개발자의 지혜에 따른다. 전역변수는 여러므로 해롭다.
- 코드를 이해하기 더 어려워 진다. 글로벌 변수를 사용하지 않는 함수의 경우, 함수의 내용과 인수만 알면 되기 때문 손쉽게 이해할 수 있다. 하지만, (데이터와 관련된) 전역 함수를 사용한다면, 데이터에 무엇이 영향을 끼치는지 전체 코드를 확인해야한다. 전체 코드가 많으면 많을 수록, 사소한 설정문제도 찾아내는 것이 어려워 진다.
- Coupling를 장려한다.
- 동시성에 친화적이지 않다. 오늘날 코더는 최대한 활용하지 못지라도 최소한 mutil-thread 방식으로 동작해야한다. 무언가 전역으로 만들 때, 메모리의 chuck를 만들게 된다. 이것을 다른 쓰레드가 무엇을 하던 상관없이 쓰레드가 보고 포크 할 수 있다.이것은 deadlock, race condition, 쓰레드 동기 버그가 일어난다.
전역변수를 통틀어, 싱글톤 패턴을 무서워 해야할 이유이다. 하지만 어떻게 전역 상태 없이 게임을 구성할 것인가?
방법은 많이 있다. 명확하지도 쉽게 찾을 수도 없다.
It Solves two problems even when you just have one
"그리고"에 유의했던 이유이다. 싱글톤 패턴은 어딘가 이상하다. 이 패턴은 몇개의 문제를 해결하는가? 하나? 둘?
보통 편리한 액세스 때문에, 싱글톤 패턴을 고려하게 된다. 글로벌 액세스는 편리하지만, 여러 인스터스를 생성할 수 있는 것도 마찬가지로 편하다.
디버그를 위한 Log class를 생각해본다. Log 기능에 싱글톤을 적용하면, 쉽게 접근가능하지만, 하나의 로거만 만들 수 있다는 이상한 제약을 가지게 된다. 만약 Log가 다양한 상황을 지원하도록 수정하려면, Log를 사용한 code를 모두 찾아 수정해야한다.
Lazy initialization takes control away from you
지연 초기화로 인해 특정 순간에 프레임 드랍이 발생할 수도 있다. 마찬가지로, 메모리 조각화를 방지하기 위해서는 메모리가 힙에 배치되는 방법을 면밀히 조절해야한다.
이 문제 때문에 대부분의 게임은 지연초기화를 의존하지 않는다. 대신 다음과 같이 사용한다.(#아마 게임 시작 시 미리 할당해 놓는다는 의미일 듯)
class FileSystem
{
static FileSystem instance;
static FileSystem Instance()
{
return instance;
}
}
정적 멤버를 사용하면 다형성을 사용할 수 없고, 클래스는 정적 초기화 시 생성가능해야한다(?). 필요하지 않을 때 메모리를 해제할 수도 없다.
싱글톤을 만드는 대신 여기 있는건, 간단한 정적 클래스이다.
What We Can Do Instead
싱글톤은 더 이상 toolbox에 존재하지 않는다. 하지만 어떻게 싱글톤을 사용하지 않고 문제를 해결해야 할까?
See if you need the class at all
게임에서 보이는 많은 싱글톤은 "manager","System","Engine"이다. 모호한 클래스이고 그저 다른 객체를 돌보기 위해 존재한다. 관리자 클래스가 유용하지만 종종 객체지향에 대한 익숙하지 않음을 반영하는 경우가 많다.
To limit a class to a single instance
하나의 클래스 인스턴스를 보장한다. 싱글톤이 제공하는 절반의 핵심이다. 하지만 공개적이고 전역적인 액세스는 필요하지 않을 수 있다. 액세스를 특정 영역에 제한하거나 단일 클래스에만 비공개 설정할 수도 있다.
전역 액세스 없이 하나의 인스턴스를 보장하는 방법 중 하나.
class FileSystem
{
static bool instantiated = false;
FileSystem()
{
Assert.IsFalse(instantiated);
instantiated = true;
}
}
# 둘 이상의 인스턴스 생성을 버그로 간주하고 Exception을 해버린다, 유니티에서 씬간 데이터 지속성을 위해 (적어도 내가 아는 선에서는) 사용할텐데.
단점은 단일 인스턴스를 런타임 단계에서야 보장한다는 것이다. 싱글톤은 컴파일단계에서 이를 보장한다.
To provide convenient access to a instance
편리한 엑세스는 싱글톤을 사용하는 주요한 이유이다. 여러 영역에서 필요한 오브젝트를 가져다준다. 용이함에는 대가가 따른다. 원하지 않는 오브젝트를 가져오기도 쉬워진다.
싱글톤을 샷건마냥 문제의 해결 수단으로 사용하지 말고, 액세스할 다른 방법을 고려해본다.
- 전달
- 부모 클래스로부터 가져오기, GameObject class에서 파생된 클래스, 부모 클래스에 static 멤버 및 메서드 추가하여 파생 클래스가 메서드를 가지게 한다.
class GameObject
{
static Logger logger;
protected Logger GetLogger()
{
return logger;
}
}
class Enemy : GameObject
{
void DoSomething()
{
GetLogger().Log("");
}
}
파생된 객체가 제공된 메서드를 구현하도록 하는 것은 Subclass Sandbox 챕터를 확인한다.
- 이미 글로벌인 것으로부터 가져온다. 전역변수가 없는 것이 이상적이지만, 대부분은 코드베이스는 전역적으로 사용 가능한 객체가 존재한다(Game, World). 이러한 편승하여 전역클래스를 줄인다.
%누군가는 이것이 Law of Demeter를 위반한다고 말하지만, 저자는 다수의 싱클톤보다 낫다고 생각한다.
Get it from Service Locator
Service Locator 패턴을 사용하여, 개체에 대해 전역 액세스를 갖도록하는 단 하나의 class를 만든다.
What's Left for Singleton
남은 질문은 "어디에 싱글톤을 사용해야만 한다는 것인가?"
State
이 챕터에 과하게 담았다.
We've All Been There -> Finite State Machine to the Rescue -> Enums and Swithches -> State Pattern
# FSM 및 Part 1 과 겹치는 부분 제외
Delegate to the state #자료의 표현을 살린 것
public class HeroineState
{
public virtual void HandleInput(Heroine heroine) { }
public virtual void Update(Heroine heroine) { }
}
public class Heroine
{
HeroineState state;
public virtual void HandleInput()
{
state.HandleInput(this);
}
public virtual void update()
{
state.Update(this);
}
}
%이것은 마치 Strategy 혹은 Type Object 패턴처럼 보인다. 주 오브젝트가 하위개체에게 위임한다. 차이점은 의도가 다르다.
- Strategy - 주 오브젝트에서 행동 일부를 분리하는 것
- Type Object - 같은 타입 오브젝트에 대해 비슷하게 동작하는 오브젝트 생성
- State - 주 개체가 위임한 개체를 변경하여 동작을 변경하는 것
Where Are the State Objects?
state를 변경하기 위해서는, state를 할당해야 한다.
Static state
state가 바뀔 때, state에서는 state를 호출해야한다. 이때 static instance를 만들어서 호출을 간단히 할 수있다.
Insantiate state
static state가 멤버 변수를 갖는 경우, static state 인스턴스 이므로, 단일 플레이어에 대해서는 잘 작동할지는 몰라도, 둘 이상의 플레이어(혹은 actor)에 대해서 동작하지 않을 수 있다. 이럴 경우 새로운 state 인스턴스를 생성하는 방향으로 가야하고, 따라서 현재 state 인스턴스는 반환해야한다.
public class HeroineState
{
public virtual HeroineState HandleInput(Heroine heroine) { return null; }
public virtual void Update(Heroine heroine) { }
}
public class StandingState : HeroineState
{
//~~~
}
public class DuckingState : HeroineState
{
public override HeroineState HandleInput(Heroine heroine)
{
if (Input.GetKeyDown(KeyCode.DownArrow))
{
return new StandingState();
}
return null;
}
}
public class Heroine
{
HeroineState state;
public virtual void HandleInput()
{
HeroineState state = this.state.HandleInput(this);
if (state != null)
{
Object.Destroy((Object)this.state);
this.state = state;
}
}
public virtual void update()
{
state.Update(this);
}
}
Hierarchical State Machines
비슷한 상태 머신은 상속을 통하여 구현한다. 상태는 상위 상태를 가진다. 이벤트가 발생하하고 하위 상태가 다룰 수 없을 때, 상위 상태로 롤업 된다. (#이때 부모가 상위 상태)
public class OnGroundState : HeroineState
{
public override void HandleInput(Heroine heroine)
{
if (Input.GetKey(KeyCode.B))
{
//Jump
}
else if (Input.GetKeyDown(KeyCode.DownArrow))
{
//Duck
}
}
}
public class DuckingState : OnGroundState
{
public override void HandleInput(Heroine heroine)
{
if (Input.GetKeyUp(KeyCode.DownArrow))
{
//Stand Up
}
else {
base.HandleInput(heroine);
}
}
}
계층을 구현하는 유일한 방법은 아니다. 단일 상태 대신, 상태 스택을 명시하여, 동작처리 시 스택의 맨 위에서 시작하여 하나의 동작을 처리할 때까지 내려간다. (매칭되지 않으면 무시)
Pushdown Automata
FSM 및 상태 스택을 사용하는 확장방법이다. FSM의 문제는 역사의 관점이 없다는 것이다(# 사실 이마저도 상태로 해결할 수 있다고 생각하지만) . 어디에 있는지 알수 있지만, 어디에 있었는지 알 수가 없다.
만약 액터가 총을 쐈다고 한다면, 총을 쏜 이후의 상태는 어떻게 할것인가? 총쏘기전에 했던 상태로 돌아와야한다.
FSM 에서 전의 상태를 잊어버리기 때문에, 달리며 발사, 걸으며 발사, 서서 발사와 같은 상태를 만들어야한다.(# 만들기 싫으시다?)
원하는 건, 이전 상태를 저장하는 방법이다. 여기서 Automata가 도움이 된다.
FSM은 단일 상태 포인터를 가진다면, Pushdown Automata는 상태의 스택을 가진다. FSM에서 새로운 상태로 천이한다만. Pushdown Automata는 두 가지가 기능이 추가된다.
- 새로운 상태를 스택에 push 할 수 있다. 새로운 상태가 스택의 최상위에 추가되어서 현재상태는 언제나 스택의 최상위이다. 이전 상태는 스택아래에 내버려둔다.
- 최상위 스택을 pop 할 수 있다. 상태가 나가면 그 아래있던 스택이 현재 스택이 된다.
이것을 통해, 이전 상태로 돌아가야하는 상태의 경우를 구현할 수 있다.
So How Useful Are They?
상태 머신의 일반적인 확장에도 불구하고, 제한적이다. 요즘 game AI의 추세는 behavior tree, planning systems 이다. 복잡한 AI에 관심이 있다면, 다른책을 원하게 될 것이다.
이것은 상태 머신이나 Pushdown Automata, 다른 시스템이 유용하지 않는다는 것이 아니다. 상태 머신은 다음과 같은 상황에서 유용하다.
- state에 따라, 변경되는 몇가지 행동 엔터티가 있는 경우
- 상대적으로 적은 별개의 옵션을 엄격히 나눌 수 있따.
- 엔터티가 시간에 따라 일련의 입력이나 이벤트에 응답해야하는 경우
게임에서 AI, 사용자 입력처리, 메뉴화면 탐색,텍스트 구문 분석, 네트워크 프로토콜, 비동기 구현에도 사용된다.
Sequencing Patterns
- Double Buffer | Game Loop | Update Method
Double Buffer
Intent
일련의 작업이 즉각적 혹은 동시에 일어나게 만든다.
Motivation
컴퓨터는 순차적 짐승이다. 컴퓨터의 힘은 아무리 큰 작업이라도, 작은 단위로 나누어서 수행한다는 것에 있다. 사용자는 종종 단일 순간 단계 혹은 동시에 수행되는 여러 작업을 확인해야한다.
일반적으로 게임 엔진은 랜더링을 수행해야한다. 렌더링 작업은 수많은 요소를 가지고 있고 이를 각 프레임마다 동시적으로 업데이트해야한다. Double buffering 은 이를 해결한다. 이해하기 위해서, 어떻게 화면을 디스플레이하는지 알아야 한다.
How computer graphics work(briefly) -> Act , Scene 1
#중략
매 프레임마다 빈 화면에서 요소 하나하나(나무 따로 땅 따로) 처리되는 것을 즉각적으로 표시하게하여 나타나는 걸 상상해본다. 문제가 있다. 매 프레임에 동시적으로 모든 요소를 표시할 필요가 있다.
Back to the graphics
이것이 Double buffer가 하는 일이다. 하나의 버퍼가 아닌 두개의 버퍼를 가진다. 하나는 완성된 프레임을 읽고, 하나는 프레임 화면을 구성하고 있는 중이다. 두번째 버퍼가 완성되면 첫번째 버퍼와 역할을 바꾼다.
The Pattern
buffered class는 buffer에 캡슐화 되어있다. 버퍼는 점진적으로 변경되지만,외부 코드를 위한, 원자화된 변경을 위해 두개의 버퍼를 가진다. next buffer, current buffer이다.
버퍼에 정보가 읽히면, 거건 current buffer다 버퍼에서 정보가 쓰이면, 그건 next buffer이다. 작성이 끝나면 두 버퍼를 swap 한다.
When To Use It
다음 사항을 모두 충족할 때에
- 점진적으로 편집되는 상태가 존재
- 수정 중에 접근 할 수 있는 상태
- 작업 중인 상태에서는 접근을 막고 싶을 때
- 상태를 읽을 수 있게하고, 작성 중에는 기다릴 필요가 없게하고 싶을 때
Keep in Mind
buffer는 낮은 구현 수준을 갖기 때문에, 다른 코드 영역에 영향이 적다. 다른 코드 영역은 별반 차이를 알지도 못한다. 몇가지 주의사항은 존재한다.
The swap itself takes time
Double-buffer 은 swap 단계가 필요하다.대게, 이것은 포인터 할당만큼 빠르지만, 처음부터 수정하는게 더 빠른 경우 도움이 되지 않는다.
We have to have two buffers
두개의 버퍼를 위해서 메모리를 두배로 사용하게 된다. 메모리가 제한된 장치에서 두개의 버퍼를 감당할 수 없다면, 작성중에 접근하지 못하도록 하는 다른 방법을 찾아야 한다.
Sample Code
생략 두개의 버퍼, 두개의 커서, swap 메서드
Not just for graphics
쓰레드나 인터럽트에서 접근하는 상태인 경우
Atificial unintelligence -> Buffered slaps
업데이트 될 상태와 현재 상태 시퀸스를 동기적으로 가져야하는 다수의 객체 관해서, 각 객체는 또 다른 객체의 현재 상태를 참조하여 업데이트 될 상태가 정해지기 때문에, 각 객체는 곧바로 상태를 업데이트 하는 것이 아니라 다른 객체들이 업데이트할 상태를 정할 때까지 업데이트를 유보해야한다. 이를 Double-buffer로 구현한다.
Design Decisions
Double Buffer 는 매우 간단하며, 이 패턴을 구현할 때 두가지 주요 결정이 있다.
How are the buffers swapped?
Swep은 작업동안 두 버퍼의 읽기 및 쓰기를 반드시 차단해야 하기 때문에 중요한 단계이다. 최고 성능을 위해서는 가능한 빨리 이루어져야 한다.
- 버퍼 포인터를 swap한다.
- 그래픽 디스플레이 예시에서 다룬것으로, 일반적인 double-buffering graphics 이다.
- 버퍼가 얼마나 크던, 빠르다. 단순히 포인터 할당만 바꾸기 때문이다. 속도, 단순함에 있어서 이걸 이기기 힘들다.
- 코드 외부에서는 영구적인 버퍼를 저장하지 못한다. 이것이 주요 제한사항이다. 데이터를 직접적으로 움직이지 않기 때문에, 나머지 코드에서 버퍼를 주기적으로 찾도록해야한다. 비디오 드라이버가 framebuffer가 고정된 메모리에 있기를 기대하는 시스템에서는 사용할 수 없다.
- 현재 버퍼의 데이터는 두 프레임 전에 해당한다(#6swap 직후 시점인듯). 최신 프레임이 아니다. 세번째 프레임이 그려지는 중에 첫번째 프레임이 출력된다. 대부분의 경우 이는 문제는 되지 않는다. 버퍼에 데이터를 제거하기 때문이다. 하지만 버퍼에 존재하는 데이터 일부를 재사용 사용한다면, 그건 생각보다 이전의 데이터 임을 명시한다.
- 버퍼 간 데이터를 복사한다.
- 버퍼를 사용하는 사용자가 버퍼 포인터를 재지정 할 수 없다면, 버퍼 데이터를 복사하는 방법 밖에 없다.
- 다음 버퍼는 한 프레임만 차이난다.
- 교체하는 데에 더 시간이 든다. 최대의 단점이다. 버퍼가 크면 클수독 시간이 소모된다. 더군나나 복사하는 동안 두 버퍼 모두 읽기 및 쓰기를 할 수 없다.
What is the granularity of the buffer?
버퍼는 몇개의 구성을 가지고 있냐는 것이다. 쓰임에 따라 다르다. 한 덩어리 일 수도 있고, 객체 컬렉션으로 구성되어 있을 수 도 있다. 대부분의 경우 항목에 특성에 따라 답이나오지만, 어느정도의 유연성이 있다. Actor의 경우처럼(객체 참조를 구성하는 경우처럼) 인덱스 참조를 통해 데이터를 저장할 수 있다.
- 버퍼가 모놀리식 인 경우
- swap이 간단하다. 버퍼는 하나의 쌍만 가지고 있으므로, 단일 스왑이 수행된다. 포인터를 swap 할 수 있으면, 버퍼 크기는 상관없다.
- 많은 데이터 조각이 있는 경우
- swap이 느려진다. 전체 컬렉션을 반복하고, 각 객체에 교환하라고 지시한다. 물론, 기존 버퍼를 건드릴 필요가 없다면, 인덱스를 활용해 모놀리식 버퍼와 동일한 성능을 얻기 위해 최적화 방법이 있다
See Also
거의 모든 그래픽 API에는 Double Buffer 가 사용된다. OpenGL 의 swapBuffers(), Direct3D의 swapchains, XNA framework의 endDraw()
Game Loop
# Game Loop는 엔진에 이미 구현되어 있기 때문에 간단히 짚고 넘어감
Intent
사용자의 입력과 프로세서 속도에서 게임 진행 시간을 분리한다.
Motivation
거의 모든 게임은 하나는 갖고 똑같은 둘 이상은 갖지 않는다
Interview with a CPU -> Event loops -> A world out of time -> Seconds per second
초창기 게임, 배시를 통한 대화형 게임, 사용자의 입력 이벤트를 기다리는 게임
- 사용자가 대기하면 아무것도 일어나지 않음
-> 사용자 입력을 처리하지만 기다리지는 않는 루프, 물리와 같은 요소, render()
- 프레임당 작업량, 플랫폼의 속도에 따른 시간, 초기에는 어떤 CPU에서 실행되는지 알기 때문에 이를 위해 특별히 코딩, 컴퓨터 성능에 따라 게임 속도가 차이남.
-> 하드웨어 차이에도 일관된 게임속도로 실행
The Pattren
게임 루프는 게임플레이 동안 계속해서 돌아간다. 각 루프에서 blocking없이 사용자 입력을 처리하고, 게임을 update하고, 게임을 렌더한다. 게임 속도를 제어하기 위해 시간의 흐름을 추적한다.
When to use it
이것은 사용하게 되어 있다.
Keep in Mind
프로그램은 90% 의 시간을 10%의 코드을 위해 소비한다.
You may need to coordinate with the platform's event loop
그래픽 UI 와 내장된 이벤트 루프가 있는 OS 또는 플랫폼 위에 게임을 구축하는 경우, 두 개의 애플리케이션 루프가 실행된다.
때로는, 제어권을 가지고 루프를 유일루프로 만들 수도 있다. 예을 들어, Window API의 경우 main()만으로도 된다..
플랫폼에 따라 이벤트 루프를 손쉽게 로드 아웃 할 수 없다.
Sample Code
몇가지 변형과 장점과 단점
Run, run as fast as you can
가장 단순한 게임 루프
while (true)
{
processInput();
update();
render();
}
문제는 게임 실행 속도를 제어할 수 없다는 것이다. 실행량, 성능에 따라 속도가 다르다
Take a little nap
게임의 frame per second, FPS를 조절하기 위해 코드를 추가한다.
// 가져온 코드, C++
while (true)
{
double start = getCurrentTime();
processInput();
update();
render();
sleep(start + MS_PER_FRAME - getCurrentTime());
}
sleep()를 통해 프레임을 작업하는 속도를 너무 빠르지 않게 보장한다. 만약 게임이 너무 느리다면 도움이 되지 않는다.
One small step, one giant step
더 정교한 것을 시도해본다. 문제는 다음과 같다
- 각 업데이트에 의해 시간이 일정량 흐른다.
- 처리하는데 실제 시간이 일정량 걸린다.
두번째 사항이 첫번째보다 오래걸리면, 게임이 느려진다. 게임시간으로 16ms이 흐르는데, 이것을 처리하는데 16ms보다 오래걸리면, 게임시간이 실제 시간을 따라잡을 수가 없다. 따라서 게임시간(각 Update 사이의 게임 시간)을 16ms 이상으로 늘리면 된다. update 주기는 길어지더라도 따라잡을 수 있다.
update의 게임시간을 실제 걸린 시간으로 선택한다. variable or fluid timestep
//가져온 코드 c++
double lastTime = getCurrentTime();
while (true)
{
double current = getCurrentTime();
double elapsed = current - lastTime;
processInput();
update(elapsed);
render();
lastTime = current;
}
update(elapsed)에 의해서, 실제 시간이 얼마나 걸린지 확인한다. 엔진은 그만큼의 게임 시간이 가도록한다.
- 게임은 다양한 하드웨어에서 일관된 속도로 플레이된다.
- 좋은 성능의 머신을 가진 플레이어는 원할한 게임플레이로 보상 받는다.
여기에는 큰 문제가 있다. 게임을 비 결정적이고 불안하게 만들었다. 발사된 총알에 대해서 생각해본다. 화면을 가로지르는데, 어떤 컴퓨터에서는 50frame, 어떤 컴퓨터는 5frame이 걸린다고 한다. 컴퓨터는 부동 수소점의 반올림에의해서 매 프레임마다 오류가 누적된다.
추가적으로 게임 물리 엔진은 실시간으로 run하기 위해, 실제 역학을 "근접한다". 이러한 근사가 터지지 않게, 덤핑이 적용된다. 이러한 덤핑은 특정 시간 단계에 신중히 조정된다. 따라서 프레임 시간이 변경되면, 물리가 불안정 해진다.
%"Deterministic" 결정적이란 프로그램을 돌리는 언제라도, 같은 입력에 대해서 같은 결과를 내는 것이다.
%"Blowing up" 터지다. 말 그대로 물리 엔진 오류로 물체가 이상한 속도록 하늘에 날라갈 수 있다.
Play catch up
일반적으로 가변 시간에 영향을 받지 않는건 렌더링이다. 렌더링은 순간을 포착하기 때문에, 얼마나 시간이 지났는지 상관하지 않는다. 물체가 어디 있던지 사물을 렌더링한다.
물리 및 AI 같은 엔진을 안정적으로 가동하기 위해서 고정된 시간을 사용하고, 프로세스 시간을 확보하기 위해 Render()에 유연성을 허용한다.
(어느정도의 성능이 뒷받침)
// 가져온 코드 c++
double previous = getCurrentTime();
double lag = 0.0;
while (true)
{
double current = getCurrentTime();
double elapsed = current - previous;
previous = current;
lag += elapsed;
processInput();
while (lag >= MS_PER_UPDATE)
{
update();
lag -= MS_PER_UPDATE;
}
render();
}
lag 는 실제 시간과 게임 시간과의 차이, 지연되면, update()로 게임 시간이 쫓아가게 만든다.
MS_PER_UPDATE를 너무 작게 잡으면 안된다.
Stuck in the middle
게임 시간(# 어쨋든 실제시간)을 기준으로, update는 정해진 시간에 주기적이지만
render 시간은 임의적이다. render는 update보다 빈도가 낮고 꾸준하지도 않는다. 이로 인해, render 된 오브젝트의 위치가 조금씩 엇나간다.
따라서 업데이트 타이밍과의 괴리를 전달한다.
render(lag / MS_PER_UPDATE);
이것을 통해, 오브젝트의 위치를 추정하여 나타낸다. 이 추정은 틀릴수도 있지만. 추정을 않했을 때보다 눈에 덜 띈다.
Design Decision
이 챕터의 길이에도 불구하고 더 많은 것을 생략했다. 디스플리이 새로 고침 빈도, 멀티쓰레딩, GPU을 동기화하는 것에 대해서 생각하면 Game loop는 더 복잡해진다.
Do you own the game loop, or does the platform?
플랫폼에 따라 game loop 가 불가능하거나 제공된 것을 사용해야한다.
- 플랫폼의 Event loop 사용
- 간단하다
- 플랫폼과 잘 작동된다.
- 타이밍에 대한 제어권이 없다.
- 게임 엔진의 loop 사용
- 작성할 필요없다.
- 엔진에 맞지않는 요구사항이 있는 경우, 제어권을 상실한다.
- 직접작성
- 모든 제어권을 가진다.
- 플랫폼과 인터페이스해야한다.
How do you manage power consumption?
휴대용 장치에서 작동하는 경우, 배터리소모에 대해서도 생각해야한다. CPU 를 적게 사용하는 것도 고려해야한다.
- 프레임 속도를 고정한다.
How do you control gameplay speed?
#위에서 다룬 코드들
- 동기화되지 않은 고정 시간
- 간단하다
- 게임 속도가 하드웨어 및 코드에 영향받는다.
- 동기화를 통한 고정시간
- 간단하다
- 전력친화적이다
- 게임이 너무 빠르게 진행되지 않는다.
- 너무 느리게 진행될 수 있다.
- 가변 시간
- 너무 느리거나 빠르다
- 비결정적이고 불안정하다, 물리 엔진과 네트워킹은 훨씬 더 어려워진다.
- 업데이트 시간, 가변 렌더링
- 너무 느리거나 빠르다
- 더 복잡하다. 시간조정을 하이엔드에서는 작게 하여 로우엔드가 너무 느리지 않도록한다.
See Also
- Fix Your Timestep 게임 루프에 대한 고전적인 기사 등
Update Method
Intent
독립 객체의 콜렉션을 시뮬레이션한다. 각 객체는 하나의 행동을 한 프레임에 처리한다.
Motivation
(게임 루프와 함께) 유닛이 프레임 단위로 한 단계씩 움직여야 한다. 이와 따라, 간단한 좌우 패트롤 기능도 프레임 단위로 구현해야 하므로, (좌우 움직임을 정하기 위한) 여러 변수가 추가되야한다.
점점 더 많은 변수와 코드가 늘어감에 따라, 각 엔티티의 자체 동작을 캡슐화하여 게임 루프를 깔끔하게 유지하고 엔터티를 쉽게 추가 및 제거 할 수 있게한다. 이를 위해 추상 class를 만들고 update() 추상메서드를 추가한다.
객체 컬렉션을 통해, 각 객체의 update() 메서드를 호출한다.
The Pattern
객채 컬렉션을 유지한다. 각 객체는 Update메서드를 구현하고 Update 메서드는 한 프레임을 시뮬레이션한다. 각 프레임마다 객체 컬렉션의 객체을 Update한다.
When to Use It
게임 루프와 함께 사용한다.
- 동시에 실행해야할 객체가 많이 존재하는 경우
- 각 객체의 행동이 대부분 다른 객체의 행동과 독립한 경우
- 객체가 시간에 따라 시뮬레이션해야하는 경우
Keep in Mind
패턴은 간단하고 놀라운 구석은 많지 않지만, 모든 코드는 파급력이 있다.
Splitting code into single frame slices makes it more complex
단일 프레임에 동작하도록 코드를 분할하는 것은 더 복잡하다.
You have to store state to resume where you left off each frame
행동이 프레임 단위로 실행되기 때문에, 일련의 상태에 따라 행동이 바뀌는 경우, 정보를 손실하지 않기 위해 상태를 추적하는 변수를 사용하거나 State 패턴을 사용한다.
% 필요한 것은 다중 쓰레드를 수행 할 수 있는 시스템이다 . 코드가 일시중지되거나 재개할 수 있으면, 완전히 return 할 필요없이 명시적으로 작성할 수 있다.
% 실제 쓰레드는 무거워서 제대로 동작하지 않지만, 가벼운 동시성 구성을 지원하는 경우, generator, coroutine, fiber 같은 것들을 사용 할 수 있다. Bytecode는 어플리케이션 레벨에서 쓰레드 수행을 만들 수 있는 또다른 옵션이다.
Object all simulate each frame but are not truly concurrent
객체가 프레임마다 업데이트 되는데에 순서가 존재한다. 이에 따라, 서로의 상태에 대해 바뀌는 객체들에 대해 업데이트 순서에 따라 참조하는 상태의 위상이 어긋날 수 있다.
이러한 순차성은 모호한 상황(ex 정말 동시에 일어나는 동작에서 동등한 우선권을 가지는 상황)을 조정할 필요없게 만들어 준다.
%만약 이러한 순차성을 원하지 않는 요소의 경우(ex 같은 위상을 참고하여 업데이트하고 싶은 경우), Double Buffer 패턴을 사용한다.
Be careful modifying the object list while updating
개체 목록을 신중하게 업데이트 해야한다. 모든 동작이 update에 포함되면서 객체를 생성하거나 지우는 동작도 여기에 포함된다. 객체가 생성되는 경우, 생성된 프레임에 생성된 객체가 동작할 수 있다면 플레이어가 인지도 못한 채 당하는 결과를 초래 할 수도 있다.
이를 방지하는 간단한 코드가 있다.
int numObjectsThisTurn = numObjects_;
for (int i = 0; i < numObjectsThisTurn; i++)
{
objects_[i].update();
}
현 프레임에 존재하는 객체 수를 추적하여, 새로 추가된 객체의 동작을 방지한다.
객체가 제거되는 경우 더 큰 문제 있다. 의도치 않게, 객체를 건너 뛸 수 있다. 이를 방지하기 위해, 죽은 개체는 바로 제거하지 않고 한 반복이 끝난 뒤에 제거시킨다.
%저렴한 해결책은 목록을 거꾸로 수행하는 것이다
%업데이트 루프의 항목을 처리하는 쓰레드가 여러개 있는 경우, 비용이 많이 드는 쓰레드 동기화를 피하기 위해 수정을 연기할 가능성이 크다.
Sample Code
# 아마도 한 entity 내에서 entity 모든 동작을 구현하는 경우에 대해 설명
Subclassing entities?!
Component 패턴을 사용해 Update 메서드를 entity 자체가 아닌 구성 요소의 entity에 있게 한다.
Defining entities
유닛의 존재 및 일정 동작 별로 entity를 정의하고 각 entity에 필요한 변수는 각 entity가 가지고 관리하게 함으로써 새로운 entity 추가를 용이하게 만든다. 이렇게 게임을 유닛으로 채우는 것과 구현하는 것을 분리하게 하여 결과적으로 별도의 데이터나 레벨 편집기를 사용하여 세계를 채울 수 있게하는 유연성을 제공한다.
Passing time
게임 시간(실제 시간)을 추적하고 사용하여라
Design Decisions
What class does the update method live on?
- the entity class
- 비추천
- the component class
- Update method 패턴을 사용해 게임 엔터티를 서로 분리하듯, 단일 엔터티의 일부를 서로 분리 시킨다. 렌더링, 물리, AI는 모두 스스로 관리될 수 있다.
- A delegate class
- State 패턴이나 , Type Object 패턴과 같이 동작 일부를 다른 객체에 위임하는 것과 관련된 패턴이다. 이러한 패턴에서도 Update 메서드를 적용한다.
How are dormant objects handled?
일시적으로 업데이트할 필요가 없는 개체가 많이 있는 경우가 많다. CPU 주기가 낭비 될 수도 있다. 한 가지 방법은 라이브 객체 별도의 컬렉션을 유지하는 것이다.
- 비활성 개체가 포함된 단일 컬렉션 사용
- 시간을 낭비하고 있다. 사용 가능 여부를 확인하거나 빈 메서드를 호출해야한다.
- 활성 개체만 모아놓은 별도 컬렉션
- 두번째 컬렉션을 유지하기 위해 추가 메모리 사용. 모든 엔터티에 대한 컬렉션이 이미 존재하므로 기술적으로 중복이긴하다. 속도가 메모리보다 중요한 경우, 가치있는 방법이다. 활성 엔터티, 비활성 엔터티 각각 다른 컬렉션 두개로 보유하는 방법도 있다.
- 두 컬렉션을 동기화 상태로 유지해야한다.
See Also
- Game Loop나 Component 패턴과 같이 게임 엔진의 핵심이다.
- 각 프레임마다 업데이트하는 캐싱 성능에 관심이 있다면, Data Locality를 참고한다
Behavioral Patterns
- Bytecode | Subclass Sandbox |Type Object
Bytecode
Intent
데이터를 가상 머신에 대한 지침으로 인코딩하여 동작에 데이터 유연성을 제공한다.
Motivation, Spell fight!
게임 요소를 조정하기 위해서는 코드 더미를 수정하고 다시 컴파일을 해야한다. 게임 컨텐츠 업데이트가 실행 파일을 패치를 동반한다. 디자이너의수치 조정도 엔지니어가 동반되어야한다. 여기서 더 나아가 게임에 모딩도 지원하고 싶은 경우, 이를 위해 모더에게 소스가 공개되어야 한다.
Data > Code
모드가 가능한 부분(샌드박스로 만들 부분)에 대하여, 수정되기 쉽고 로드되기 쉽고 실행 파일의 나머지 부분과 물리적으로 분리되기를 원한다. 즉 데이터 같았으면 좋겠다는 것이다. 데이터를 통해 게임 엔진이 무엇을 로드하고 실행할지 정의할 수 있게 하면 된다. 그리고 이를 Interpreter 패턴과 비교해본다.
#프로그래밍 언어계에서는 "virtual machine" 와 "interpreter" 는 동의어이다. 여기서는 상호교환적?으로 사용한다.
The Interpreter pattern
(중략) 이진 트리를 구성하여 간단한 수칙 연산(중위 표기법)을 표현하고 처리한다. 예에서, 상위 노드가 하위 노드를 호출하는 방식이기 때문에 메모리 문제, 명령 캐시에 대한 문제가 있다.
Machine code, virtually
원하는 건 사실상 기계어 코드와 다름이 없다. 기계어 코드는 데이터 밀도가 높고 선형적이며(순서적으로 실행되며) 낮은 수준으로 구현되며 빠르다. 하지만 실제 기계어 코드를 사용하고자 하는건 아니다. 원하는건 기계어 코드의 성능과 인터프리터 패턴의 안정성 사이의 절충안이다. 가상 머신 코드를 정의하고 작은 에뮬레이터를 작성한다. 이 에뮬레이터를 가상 머신(VM)이라고 부르며, 이에 머신 코드를 bytecode라고 한다.
The Pattern
명령어 세트(instruction set)는 하위 수준 작업을 정의한다. 일련의 명령어는 일련의 바이트로 인코딩된다. VM은 중간값용 스택을 사용하여 이러한 명령을 한번에 하나씩 실행한다. 명령어를 결합하여 복잡한 상위 수준 동작을 정의한다.
When to Use It
가볍게 적용할 만한 것이 아니다. 다음을 고려한다
- 너무 low-level(언어적으로?) 적인 부분이라, 구현하기 번거롭거나 에러가 발생하기 쉬운 경우
- 컴파일 시간이 느려서 반복작업이 느려지는 경우
- 너무 많은 신뢰를 가진 경우, 정의된 동작으로 게임을 중단시키지 않도록 하려면 해당 동작을 샌드박싱해야한다.
어디까지나 네이티브 코드보다는 느리기 때문에 엔진의 성능이 중요한 경우 적합하지 않는다.
Keep in Mind
시스템 내에서 자신의 언어나 시스템을 만드는 것은 매혹적인 일이다. 그래서 덩굴처럼 자라난다. 작은 언어나 스크립팅 시스템을 정의하는 작업은, 완전한 언어가 될 때까지 점점 더 많은 작은 기능을 추가하게된다. 이렇게, 다른 언어와는 달리 임시적이고 유기적으로 판자촌 같은 언어를 만들게 된다.
You'll need a front-end, You'll miss your debugger
규모가 큰 경우, 바이트 코드를 작성할 때에 작성 툴, 디버거 등의 기능에 대한 계획도 세운다.
Sample Code
#자료의 c++ 코드를 그대로 가져옴, 이해만 하고 있는다.
A magical API
마법적인 주문을 구현하기 위해서는 어떤 종류의 API(기능)가 필요한가?
void setHealth(int wizard, int amount);
void setWisdom(int wizard, int amount);
void setAgility(int wizard, int amount);
void playSound(int soundId);
void spawnParticles(int particleType);
위와 같은 기본적인 기능이 있어야 한다고 하자.
A magical instruction set
이를 제어할 수 있도록 바꾸어 본다.
일단 단순히 열거해 본다.
enum Instruction
{
INST_SET_HEALTH = 0x00,
INST_SET_WISDOM = 0x01,
INST_SET_AGILITY = 0x02,
INST_PLAY_SOUND = 0x03,
INST_SPAWN_PARTICLES = 0x04
};
몇가지의 프리미티브만 있기에 열거체의 범위가 바이트에 쉽게 들어맞았다. 바이트 위치에 의해 어떤 프리미티브인지 확인할 수 있다. 이를 올바른 API 메서드로 전달한다.
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_PARTICLES:
spawnParticles(PARTICLE_FLAME);
break;
}
이렇게 코드와 데이터 간의 다리를 형성한다.
class VM
{
public:
void interpret(char bytecode[], int size)
{
for (int i = 0; i < size; i++)
{
char instruction = bytecode[i];
switch (instruction)
{
// Cases for each instruction...
}
}
}
};
위치에 의해 유형이 정해짐으로 그다지 유연하지는 않지만 가상 머신이다. 여기에 유연성을 추가하기 위해서는 매개변수를 가져올 수 있어야 한다.
A stack machine
복잡한 중첩 표현식을 실행하기 위해 전체 표현식이 계산될 때까지, 하위 표현식이 진행되야 한다.(#후위 표현법을 사용한다) 하위 표현식의 결과가 올바른 주변 표현식으로 흘러가게 해야한다.
class VM
{
public:
VM()
: stackSize_(0)
{}
// Other stuff...
private:
static const int MAX_STACK = 128;
int stackSize_;
int stack_[MAX_STACK];
void push(int value)
{
// Check for stack overflow.
assert(stackSize_ < MAX_STACK);
stack_[stackSize_++] = value;
}
int pop()
{
// Make sure the stack isn't empty.
assert(stackSize_ > 0);
return stack_[--stackSize_];
}
// Other stuff...
};
스택을 사용해, 푸시 및 팝을 통해 하위 표현식에 팝 값이 전달된 뒤 연산(동작)이 진행되고 스택에 결과값을 push하도록 한다.
switch (instruction)
{
case INST_SET_HEALTH:
{
int amount = pop();
int wizard = pop();
setHealth(wizard, amount);
break;
}
case INST_SET_WISDOM:
case INST_SET_AGILITY:
// Same as above...
case INST_PLAY_SOUND:
playSound(pop());
break;
case INST_SPAWN_PARTICLES:
spawnParticles(pop());
break;
case INST_LITERAL:
// Read the next byte from the bytecode.
int value = bytecode[++i];
push(value);
break;
}
스택에 원시 정수 값을 전달하기 위해 LITERAL 명령을 추가한다. 또한 명령어 흐름이 바이트 시퀸스라는 사실을 활용한다. 이렇게 전과 달리 바이트의 위치에 의해 속성값이 고정되지 않는다
Behavior = composition
case INST_GET_HEALTH:
{
int wizard = pop();
push(getHealth(wizard));
break;
}
case INST_GET_WISDOM:
case INST_GET_AGILITY:
// You get the idea...
case INST_ADD:
{
int b = pop();
int a = pop();
push(a + b);
break;
}
추가적으로 스택에 여러 값을 가져오고 연산을 지원한다면,
setHealth(0, getHealth(0) + (getAgility(0) + getWisdom(0)) / 2);
위와 같은 동작도 바이트코드를 통해 구현이 가능하다.
LITERAL 0 [0] # Wizard index
LITERAL 0 [0, 0] # Wizard index
GET_HEALTH [0, 45] # getHealth()
LITERAL 0 [0, 45, 0] # Wizard index
GET_AGILITY [0, 45, 7] # getAgility()
LITERAL 0 [0, 45, 7, 0] # Wizard index
GET_WISDOM [0, 45, 7, 11] # getWisdom()
ADD [0, 45, 18] # Add agility and wisdom
LITERAL 2 [0, 45, 18, 2] # Divisor
DIVIDE [0, 45, 9] # Average agility and wisdom
ADD [0, 54] # Add average to current health
SET_HEALTH [] # Set health to result
A virtual machine
스택, 루프, 스위치문 만큼 단순하다는 것을 알 수 있다. 바이트코드는 게임의 나머지 부분에 영향을 미치는 몇가지 명령만 정의했기 때문에 악의적인 작업을 수행하거나 게임엔진의 이상한 부분에 접근할 수 없다.
Spellcasting tools
디자이너 친화적인 유용성은 없다. 이 격차를 메우기 위해서는 사용자가 상위 수준 동작을 정의한 다음 적절한 하위 수준 스택 머신 바이트코드를 생성할 수 있는 프로그램이 필요하다. 컴파일러를 만드는 것은 VM 만드는 것보다 휠씬 어렵다. 그 대신, 그래픽 인터페이스를 구축하는 것을 고려한다.
Design Decisions
How do instructions access the stack?
- 스택 기반 VM
- 적은 지침
- 간단한 코드 생성
- 추가지침
- 레지스터 기반 VM (값의 주소를 사용한 위치 기반)
- 지침이 더 크다(각 명령에 스택 오프셋도 있어야 한다)
- 지침이 더 적다. 각 명령어는 더 많은 작업을 수행 할 수 있으므로 많은 명령어가 필요하지 않다.
What instructions do you have?
- 외부 프리미티브
- 내부 프리미티브 : 리터럴, 산술, 비교 및 스택을 저글링하는 명령 같이 VM 내부의 값을 조작
- 제어 프름 : 조건부, 루프 등의 제어흐름, 점프를 사용
- 추출 : 바이트 코드를 복사하여 붙여넣는 대신, 재사용하기 위해, 호출가능한 프로시저 같은 것을 원할 수도 있다.
How are values represented?
- 단일 데이터 유형
- 간단하지만, 다른 데이터 유형으로 작업할 수 없다.
- 태그가 지정된 변형
- 동적으로 유형이 지정된 언어에 대한 일반적인 표현이다.
- 값은 자신의 유형을 알고 있다.
- 타입을 저장하기위한 더 많은 메모리가 필요하다.
- 태그가 지정되지 않은 공용체
- 작은 비트를 사용해 유형을 구분한다. 유형구분은 사용자의 몫이다.
- 필요한 비트만 저장하는데 사용하므로 효율적이다.
- 빠르지만 안전하지 않다
- 인터페이스
- 다양한 유형 테스트 및 변환을 위한 가상 메서드를 제공한다. 특정 게이터 유형에 대한 구체적인 클래스가 있다.
- 개방형이다.
- 객체지향적이다
- 장황하다
- 비효율적이다.
How is the bytecode generated?
- 텍스트 기반 언어를 정의하는 경우
- 구분을 정의해야 한다.
- 파서를 구현해야한다.
- 구문 오류를 처리해야한다.
- 기술자가 아니라면 다루기 어렵다.
- 그래픽 저작 도구를 정의하는 경우
- 사용자 인터페이스를 구현해야한다.
- 오류 사례가 적다.
- 휴대성은 더 어렵다
See Also
- Intetpreter 패턴
- Lua 프로그래밍 언어, 스크립트 언어, 컴팩트한 바이트코드 VM
- Kismet, Unreal 엔진용 편집기
Subclass Sandbox
Intent
베이스 클래스에서 제공하는 기본 기능으로 서브클래스의 행동을 정의한다.
Motivation
게임에서 다양성이 가득한 세계를 구현하기위해, 베이스 클래스를 파생한 수많은 파생 클래스를 만들게 된다. 이 경우 다음과 같은 문제가 발생한다.
- 수많은 중복된 코드, 비슷한 기능으로 인한 유사한 코드
- 게임 엔진이 이러한 클래스와 결합된다.
- 외부 시스템일 변경한가면, 몇몇 코드가 동작하지 못한다.
- 수많은 파생 클래스를 아우르는 베이스 클래스를 정의하는 것은 어렵다.
#수많은 파생 클래스를 만들고 있다는 것은, 데이터 기반 접근이 더 나은 경우이다. Type Object, Bytecode, interpreter를 참고해라
이를 방지하기 위해, 기본 요소 세트를 제공한다. 즉 몇몇 기본 클래스의 메서드를 통해 구현할 수 있도록한다. 베이스 클래스는 파생 클래스에서 호출될 목적을 가지는 메서드를 가진다. 그리고 이러한 메서드를 통해 동작을 구현할 수 있도록 샌드박스 메서드를 제공한다.
이렇게 수많은 파생 클래스에서 생기는 결합은 베이스 클래스로 집중시키고, 외부 시스템과의 결합은 베이스 클래스만 가지도록한다.
#최근에는 상속을 비판하는 경우가 있다. 기본 베이스와 하위 클래스의 결합은 강력하다.
#저자는 깊은 상속보다는 넓은 상속이 다루기 편하다고 한다
The Pattern
베이스 클래스는 추상 샌드박스 메서드와 하위 클래스에 제공할 여러 기능을 가진다. protected 접근지정자를 통해 이를 명확히 한다. 각 파생 클래스는 샌드박스 메서드에 제공된 기능을 가지고 동작을 구현한다.
When to Use It
- 수많은 파생클래스를 가진 베이스 클래스가 있는 경우
- 베이스 클래스에서 파생 클래스가 필요한 기능을 제공할 수 있는 경우
- 파생 클래스 간의 동작이 중복되는 경우
- 파생 클래스와 시스템의 결합도를 최소화하고 싶은 경우
Keep in Mind
최근에 상속은 나쁜 언어이다. base class는 점점 더 많은 코드를 가지게 되기 때문이다. 특히나 이 패턴은 그것에 취약하다. 베이스 클래스는 파생 클래스에게 필요한 모든 시스템과 결합을 가지고, 각 파생 클래스와 결합을 가지게 되므로, 베이스 클래스는 변경이 매우 힘들어지는 brittle base class problem이 발생한다. 물론, 파생 클래스의 유지 보수는 용이하다.
만약 베이스 클래스가 비대하지는 경우, 책임을 수행하는 별도의 클래스를 고려한다. Component 패턴이 도움이 된다.
# 코드는 c++ 그대로 가져옴
Sample Code
기본적인 구성
class Superpower
{
public:
virtual ~Superpower() {}
protected:
virtual void activate() = 0;
void move(double x, double y, double z)
{
// Code here...
}
void playSound(SoundId sound, double volume)
{
// Code here...
}
void spawnParticles(ParticleType type, int count)
{
// Code here...
}
double getHeroX()
{
// Code here...
}
double getHeroY()
{
// Code here...
}
double getHeroZ()
{
// Code here...
}
}
acrivate() 메서드는 샌드박스 메서드이다. 파생 클래스에서 이 메서드를 override하여 제공된 메서드를 사용해 동작을 구현한다.
class SkyLaunch : public Superpower
{
protected:
virtual void activate()
{
if (getHeroZ() == 0)
{
// On the ground, so spring into the air.
}
else if (getHeroZ() < 10.0f)
{
// Near the ground, so do a double jump.
}
else
{
// Way up in the air, so do a dive attack.
}
}
};
Design Decisions
What operations should be provided?
파생 클래스의 외부시스템과의 결합을 줄이기 위해 대신, 기본 클래스로 중앙 집중화 하면 할수록, 기본 클래스는 커지고 유지관리가 힘들어 진다.
- 베이스 클래스에서 제공하는 기능이 소수의 하위 클래스만 사용하는 경우, 비용 대비 큰 이익을 얻지 못한다. 기본 클래스에 복잡성이 추가하여 모든 사람이 영향을 받지만 몇가지 클래스만 혜택을 받는다. 특수한 경우 파생클래스에서 직접 호출하는게 더 깔끔할 수 있다.
- 상태를 수정하지 않는 메서드에 대해 안전한 결합이다. 반면 상태를 수정하는 호출은 기본클래스에 롤업될 수 있는 좋은 후보이다.
#안전함, 멀티스레드, (동기화 유지를 위해) 엄격하게 결정적인 경우
- 외부 시스템으로 호출만을 하는 경우 외부 메서드에 직접 호출하는 것이 간단할 수 있다. 그러나 기본 클래스가 캡슐화하고 싶은 필드에 대해서 유용할 수 있다.
Should methods be provided directly, or through objects that contain them?
기본 클래스가 매우 많은 메서드를 가지게 된 경우 , 다른 클래스로 이동시켜 관리한다.
ex) 베이스 클래스의 소리 관련 메서드를 SoundPlayer 클래스에 정의한 뒤, 멤버 객체로 가지게 한다. 그 뒤 SoundPlayer의 액세스를 제공한다.
- 기본 클래스의 메서드 수를 줄인다.
- 도우미 클래스의 코드는 일반적으로 유지 관리가 더 쉽다.
- 기본 클래스와 다른 시스템간의 결합도를 낮춘다.
How does the base class get the state that it needs?
기본 클래스에는 캡슐화하고 하위 클래스에 숨겨두려는 일부 데이터가 필요한 경우가 많다. 구현에 특정 외부 클래스 객체가 필요한 경우이다.
- 기본 클래스 생성자에 전달하는 경우
- 기본 클래스의 생성자를 통해 인수로 전달하면 파생 클래스를 생성할 때도 외부 클래스 객체를 알고 있어야하는 문제가 있다.(%파생 클래스가 여기에 관여하게 만들고 싶지 않다.)
- 2단계 초기화 수행, 별도의 초기화 메서드, 기본 생성자에서 이 메서드를 호출하도록 구현
- 해당 멤버를 정적으로 설정
- 만약 외부 클래스 객체가 싱글톤인 경우, 기본 클래스의 생성자 및 해당 객체 참조 멤버도 정적으로 설정, 기본 클래스를 전체 게임에 대해 한번만 초기화 하도록한다.
- 서비스 로케이터 사용
See Also
- Update Method 패턴도 샌드박스 method 인 경우가 많다.
- Template Method 패턴의 역할을 뒤집은 것이다.
- Facade 패턴의 변형으로 간주할 수도 있다.
Type Object
Intent
다른 유형을 나타내는 클래스, "유형 클래스"를 생성 함으로써, 새로운 유형 생성을 유연하게 한다.
새로운 유형을 데이터 형식으로 관리할 수 있다.
Motivation, The typical OOP answer, A class for a class
드래곤, 트롤 등은 몬스터이다(is-a). 이에 트롤, 드래곤을 몬스터 클래스를 상속시켜 구현한다. 그러면 각 드래곤, 트롤의 아종에 대한 파생클래스를 작성하게 된다. 각 품종, 아종이 많아질수록, 상속계층은 커지게 되고, 각 클래스가 추가 될때마다 고유한 품종으로 컴파일된다.
그 대신 다른 방법이 있다. "몬스터가 품종을 가진다(has-a)"라는 관점 하에, 상속이 아닌 참조되는 되도록 한다. 몬스터는 품종에 정의된 데이터를 참조한다. 각 품종 인스턴스는 유형을 나타내는 Type Object이다.
이렇게 코드베이스를 복잡하게 만들지 않고 새로운 유형을 정의할 수 있다. 유형 시스템이 하드코딩 되지 않고 일부를 런타임 시 정의할 수 있는 데이터로 만들었다. 품종 인스턴스의 값을 달리해서 다양한 아종을 만들 수 있다.
The Pattern
"type object" 와 "typed object"를 정의한다. 각 type object 인스턴스는 서로 다른 논리적 유형을 나타낸다. 각 typed object 는 유형을 묘사해주는 type object의 참조를 가진다.
When to Use It
다양한 사물의 종류를 정의하는 데에는 유용하지만, 언어 유형 시스템에 적용하는 것은 너무 엄격하다?
- 앞으로 어떤 유형이 필요하게 될지 모르는 경우
- 코드를 다시 컴파일하거나 수정할 필요없이 새로운 유형을 추가하고 싶은 경우?
keep in Mind
유형의 정의를 엄격한 코드에서 유연하지만 덜 동작적인 메모리 개체로 옮기는 것이다. 유형성은 좋지만, 유형을 데이터로 끌어올리면 몇가지 손실이 발생한다.
The type objects have to be tracked manually
클래스 선언 시에는 클래스를 알아서 처리해주는 컴파일러가 있었다면, 유형을 인스턴스로 관리하기 때문에 수동으로 관리해야한다.
It's harder to define behavior for each type
파생 클래스 선언과 달리, 재정의 메서드가 아닌 멤버 변수를 가진다. 유형 개체를 사용한 유형별 데이터 정의는 쉽지만 유형별 동작을 정의하기는 어렵다. 이에 따라 몬스터의 다양한 AI 알고리즘 사용을 사용해야 할 경우 더욱 어려워 진다.
이를 해결하기 위해, 미리 정의된 동작세트를 가지고 선택하게 만드는 방법이 있다. 대기, 추격, 도망과 같은 동작을 구현하는 함수를 정의 할 수 있다. 또 다른 강력한 솔루션은 데이터에서의 동작정의를 지원하는 것이다. interpreter 및 bytecode 패턴을 사용하는 것이다.
Sample code
#그대로 가져온 코드 c++
class Breed
{
public:
Breed(int health, const char* attack)
: health_(health),
attack_(attack)
{}
int getHealth() { return health_; }
const char* getAttack() { return attack_; }
private:
int health_; // Starting health.
const char* attack_;
};
class Monster
{
public:
Monster(Breed& breed)
: health_(breed.getHealth()),
breed_(breed)
{}
const char* getAttack()
{
return breed_.getAttack();
}
private:
int health_; // Current health.
Breed& breed_;
};
Monster class 에서 breed 인스턴스를 참조한다. breed 객체는 품종, 유형을 정의한다.
Making type objects more like types: constructors
Monster class 생성자에 breed 인스턴스를 인수로 전달해 monster 인스턴스를 생성하는 것이 아니라, breed 인스턴스가 moster 생성자를 호출하는 메서드를 사용한다.
class Breed
{
public:
Monster* newMonster() { return new Monster(*this); }
// Previous Breed code...
};
이렇게 함으로써, Moster 인스턴스 생성 전에 데이터를 저장할 수 있는 Breed 객체를 확보할 수 있다. 이를 통해 Moster 인스턴스 생성 전, 관련 데이터를 관리할 수 있다.
Sharing data through inheritance
클래스 상속을 통한 상속이 아니다. 단순한 데이터 공유이다. Breed 간의 상속(데이터 공유)을 통해 상위 폼종을 가질 수 있도록 한다. 이를 통해 다양한 아종이 공통된 스텟을 상위 폼종을 통해 가질수 있도록 한다. 이때, 품종의 속성이 바뀌지 않는다고 믿을 수 있다면, 데이터를 참조할 레퍼런스를 가져오는 것이 아니라 데이터를 복사해서 저장하는 것이 메모리, 속도면에서 좋다.
레퍼렌스 유지 시,
class Breed
{
public:
Breed(Breed* parent, int health, const char* attack)
: parent_(parent),
health_(health),
attack_(attack)
{}
int getHealth();
const char* getAttack();
private:
Breed* parent_;
int health_; // Starting health.
const char* attack_;
};
int Breed::getHealth()
{
// Override.
if (health_ != 0 || parent_ == NULL) return health_;
// Inherit.
return parent_->getHealth();
}
const char* Breed::getAttack()
{
// Override.
if (attack_ != NULL || parent_ == NULL) return attack_;
// Inherit.
return parent_->getAttack();
}
복사해 가져오기
Breed(Breed* parent, int health, const char* attack)
: health_(health),
attack_(attack)
{
// Inherit non-overridden attributes.
if (parent != NULL)
{
if (health == 0) health_ = parent->getHealth();
if (attack == NULL) attack_ = parent->getAttack();
}
}
int getHealth() { return health_; }
const char* getAttack() { return attack_; }
유형 데이터가 저장된 JSON파일을 로드하여 새로운 품종을 생성할 수 있을 것이다(게임 엔진이 지원한다면).
Design Decisions
Is the type object encapsulated or exposed?
Moster 객체가 breed 객체를 캡슐화 하느냐 안하느냐
- 유형이 캡슐화된 경우
- Type Object 패턴의 복잡성은 숨겨져있다. 유형이 지정된 객체, typed object 만 관리하면 된다.
- typed object는 type object 동작을 선택적으로 재정의할 수 있다. if문을 통해, 선택적으로 type object 동작을 따를것인지 아닌지 구현할 수 있다.
- type object 가 공개적으로 하려는 것에 대해서, typed object가 별도의 전달 메서드를 가져야한다.
- 유형 객체가 노출된 경우
- 외부 코드와 상호작용 할 수 있다.
- type object 는 공개 API의 일부이다.
How are typed objects created?
typed object와 type object 를 어떻게 만들고 결합시킬 것이냐
- typed object 생성자 인수로 type object 전달
- 외부 코드가 할당을 제어할 수 있다. 객체를 다양한 메모리 시나리오, 다른 할당자, 스택 등에서 사용할 수 있기를 원하는 경우 유연성이 제공된다.
- 사type object 에서 typed object 생성자 호출
- type object 는 메모리 할당을 제어한다. 사용자가 메모리 내 객체가 생성되는 위치를 선택하는 것을 원하지 않는 경우 유형 객체에 대한 팩토리 메서드를 거치도록 요구하면 이를 제어할 수 있다?. 모든 개체가 특정 object pool 이나 다른 메모리 할당자에서 나오는지 확인하려는 경우 유용하다.
Can the type change?
시간이 지남에 따라 유형이 변하도록 허용할 수 있다. ex) 다양한 종족 언데드화
- 유형이 변경되지 않는 경우
- 더 간단하다, 디버깅이 쉽다.
- 유형이 변경될 수 있는 경우
- 객체 생성이 줄어든다. 새로운 유형 생성이 아니라 변경시키면 되기 떼문에
- 가정이 충족되는지 주의한다.
What kind of inheritance is supported?
- 상속 없음
- 간단하고 단순하지만 중복된 노가다로 이어질 수 있다. 저자도 디자이너가 상속을 원하지 않는 경우를 본적이 없다
- 단일 상속
- 여전히 상대적으로 간단하다. 이해하기도 쉽다.
- 속성 조회 속도가 느려진다.
- 다중 상속
- 거의 모든 중복을 피할 수 있따.
- 복잡하다. 이해하고 추론하기가 어렵다.
See Also
- Type object가 해결하는 문제를 다른 방식으로 해결하는 Prototype이 있다.
- Flyweight 와 비슷하다. 인스턴스 간의 데이터 공유를 한다. 하나는 메모리 절약 관점에서 하나는 개념적인 구성과 유연성에 중점을 둔다.
- State 패턴과 많은 유사점이 있다. 자신을 정의하는 부분을 다른 객체에 위임한다.
Decoupling Patterns
- Component | Event Queue | Service Locator
Component
#여기서 Bjorn 은 한 엔티티의 예로 그냥 캐릭터 이름으로 생각하면 된다.
Intent
한 엔티티가 여러 영역에 걸쳐 존재하되, 각 영역에 대한 결합 없이 만들어 준다.
Motivation
한 엔티티에 온갖 기능이 필요하다고 해서, 요소가 구현된 코드가 여기저기 다양한 영역에 흔적이 있다면, 관리하기 힘들어진다.
The Gordian knot(고르디우스의 매듭)
반면교사 코드
if(colling() && (RenderState() != INVISIBLE))
{
Sound(HIT);
}
몇 줄 안되는 코드에도, 물리, 렌더, 소리를 망하라는 지식이 필요해진다.
%이런 결합은 동시성이 있는 현대에 이르러선 심하게 안좋다. 데드락과 동시성으로 인한 버그를 피하기 위해선,
각 영역은 분리되야 한다. 한 쓰레드는 사운드를 업데이트하는 동시에 다른 쓰레드가 그래픽을 업데이트 했다면 애상치 못한 동작으로 이어질 수 있다.
Cutting the knot
이를 해결하기 위해 Bjorn 클래스를 구성한다. 각 기능 별로 컴포넌트 클래스를 만들어 분리한 뒤, Bjorn 클래스각 각 컴포넌트의 인스턴스를 소유하도록한다.
여기까지 단순히, Bjorn 이라는 shell, 껍데기를 만들어 냈다.
Loose ends
이렇게 각 컴포넌트 클래스는 서로에 대해 분리된다. 비록 Bjorn 이라는 클래스가 각 컴포넌트를 가지고 있지만, 각 컴포넌트는 서로에 대해서 모른다.
만약 컴포넌트가 서로 상호작용해야 한다면, Bforn을 통해 통신이 가능케하며 제한한다.
Tying back together
다음을 생각해본다. 게임 오브젝트가 Prop, Decoratioin 클래스를 상속했다. 이때 각 클래스는 Rendering 클래스를 상속한 상태다. 이때 게임 오브젝트는 두개의 Rendering 클래스를 상속하게 된다.
% Deadly Diamond 다중 상속시 같은 기본 클래스로 상속하게 되는 두개 이상의 루트가 존재한 경우, Deadly란 말이 왜 붙었을지 생각하자
이를 상속이 아닌 Component 로 구현하면 된다. 각 컴포넌트를 통해 상속하는 것이 아니라 구성하는 것이다.
컴포넌트는 기본적으로 plug-and-play(부착하면 작동한다.). 복갑한 엔티티도 여러 컴포넌트를 연결하여 구성할 수 있다.
The Pattern
단일 엔티티는 여러 영역에 걸쳐 존재한다. 각 도메인을 서로 분리하기 위해, 각 component class 에 구현한다. 이렇게 단일 엔티티는 여러 구성요소의 컨테이너가 된다.
%본래 component 디자인 패턴은 서로 떨어진 서비스가 웹을 통해 통신하는 디자인 패턴이지만, 다루고자하는 내용을 잘 표현해서 이름을 가져다 썼다.
When to use it
컴포넌트는 주로 핵심 클래스에서 발견되지만, 여러 방면으로도 쓸수 있다.
- 클래스가 다양한 영역을 건드렸고, 이를 분리하고 싶을 때
- 클래스가 비대해지며 사용하기 어려워질 때
- 다양한 기능을 공유하지만 상속을 사용하지않고 필요한 부분만 재사용하는 다양한 오브젝트를 정의할 때
Keep in mind
컴포넌트 패턴은 만들고 사용하는 것보다 복잡하다. 각 개념적 오브젝트는 여러 오브젝트를 서로 올바르게 작동하도록 해야하는 클러스터가 된다.
각 컴포넌트가 상호작용하는 것은 더 힘들고 메모리 관리도 복잡해진다.
코드가 재사용 가능하게하고 분리시킨다는 점에서 수반되는 복잡함은 가치가 있지만, 너무 지나치게 적용하는 것은 좋지않다. 존재하지 않은 문제에 대해 과도하게 적용하지 말자.
컴포넌트를 가져다가 사용하는 경우, get 하는 과정(특히 루프문 내에서)에서 성능이 저하될 수 있다.
%컴포넌트 패턴은 캐시 일관성을 향상시키기도 한다. Data Locality 패턴을 참고한다.
Sample code
%샘플 코드가 컴포넌트 패턴을 온전히 담고 있지는 않으므로 실제 느낌을 받기 위해서는 각 영역에 해당하는 클래스에 대해 어느정도 고려한다.
A monolithic class
sample code(c++ 첨부된 코드를 그대로 가져옴)
다음과 같이 코드를 작성했다 하자.
class Bjorn
{
public:
Bjorn()
: velocity_(0),
x_(0), y_(0)
{}
void update(World& world, Graphics& graphics);
private:
static const int WALK_ACCELERATION = 1;
int velocity_;
int x_, y_;
Volume volume_;
Sprite spriteStand_;
Sprite spriteWalkLeft_;
Sprite spriteWalkRight_;
void update(World& world, Graphics& graphics)
{
// Apply user input to hero's velocity.
switch (Controller::getJoystickDirection())
{
case DIR_LEFT:
velocity_ -= WALK_ACCELERATION;
break;
case DIR_RIGHT:
velocity_ += WALK_ACCELERATION;
break;
}
// Modify position by velocity.
x_ += velocity_;
world.resolveCollision(volume_, x_, y_, velocity_);
// Draw the appropriate sprite.
Sprite* sprite = &spriteStand_;
if (velocity_ < 0)
{
sprite = &spriteWalkLeft_;
}
else if (velocity_ > 0)
{
sprite = &spriteWalkRight_;
}
graphics.draw(*sprite, x_, y_);
};
엔티티의 기능이 몇 없는데도 불구하고 상당히 길다. 여기에 다른 물리요소나 기능이 추가된다하면 더 길어질 것이다.
Splitting out a domain
위 클래스를 여러 컴포넌트로 나눈다.
sample code(c++ 첨부된 코드를 그대로 가져옴)
우선 InputComponent이다.
class InputComponent
{
public:
void update(Bjorn& bjorn)
{
switch (Controller::getJoystickDirection())
{
case DIR_LEFT:
bjorn.velocity -= WALK_ACCELERATION;
break;
case DIR_RIGHT:
bjorn.velocity += WALK_ACCELERATION;
break;
}
}
private:
static const int WALK_ACCELERATION = 1;
};
sample code(c++ 첨부된 코드를 그대로 가져옴)
class Bjorn
{
public:
int velocity;
int x, y;
void update(World& world, Graphics& graphics)
{
input_.update(*this);
// Modify position by velocity.
x += velocity;
world.resolveCollision(volume_, x, y, velocity);
// Draw the appropriate sprite.
Sprite* sprite = &spriteStand_;
if (velocity < 0)
{
sprite = &spriteWalkLeft_;
}
else if (velocity > 0)
{
sprite = &spriteWalkRight_;
}
graphics.draw(*sprite, x, y);
}
private:
InputComponent input_;
Volume volume_;
Sprite spriteStand_;
Sprite spriteWalkLeft_;
Sprite spriteWalkRight_;
};
input 처리를 컴포넌트에 양도하면서, 몇몇 결합도를 없앴다. 컨트롤러에 대해서는 나중에 다룬다.
sample code(c++ 첨부된 코드를 그대로 가져옴)
더 진행해보면.
class PhysicsComponent
{
public:
void update(Bjorn& bjorn, World& world)
{
bjorn.x += bjorn.velocity;
world.resolveCollision(volume_,
bjorn.x, bjorn.y, bjorn.velocity);
}
private:
Volume volume_;
};
class GraphicsComponent
{
public:
void update(Bjorn& bjorn, Graphics& graphics)
{
Sprite* sprite = &spriteStand_;
if (bjorn.velocity < 0)
{
sprite = &spriteWalkLeft_;
}
else if (bjorn.velocity > 0)
{
sprite = &spriteWalkRight_;
}
graphics.draw(*sprite, bjorn.x, bjorn.y);
}
private:
Sprite spriteStand_;
Sprite spriteWalkLeft_;
Sprite spriteWalkRight_;
};
엔티티는 다음과 같이 된다.
sample code(c++ 첨부된 코드를 그대로 가져옴)
class Bjorn
{
public:
int velocity;
int x, y;
void update(World& world, Graphics& graphics)
{
input_.update(*this);
physics_.update(*this, world);
graphics_.update(*this, graphics);
}
private:
InputComponent input_;
PhysicsComponent physics_;
GraphicsComponent graphics_;
};
Bjorn은 이제 컴포넌트를 준비하고 가지기만 하면 된다.
여기서 Bjorn 엔티티가 속도 값과 위치 값을 가지는 것을 보자. 이 값이 Bjoin에 존재해야 하는지 두가지 이유가 있다.
첫째로, 이 둘은 범 도메인이다. 여러 컴포넌트가 필요로 할 수 있는 값에 대해서, 이러한 값들은 어떤 컴포넌트에 속해야할지 정할 수 없다.
둘째로, 이를 통해 컴포넌트 간 통신이 결합될 필요없이 가능하다.
Robo-Bjorn
#명칭에 큰 의미는 없는 듯하다.
위 과정을 통해 컴포넌트 클래스로 몇 기능들을 분해했지만, 아직 행동이 추상화 되지 않았다. 아직 엔티티가 구체화된 컴포넌트 클래스를 알아야 한다.
sample code(c++ 첨부된 코드를 그대로 가져옴)
추상화한 컴포넌트 클래스를 만든다.
class InputComponent
{
public:
virtual ~InputComponent() {}
virtual void update(Bjorn& bjorn) = 0;
};
class PlayerInputComponent : public InputComponent
{
public:
virtual void update(Bjorn& bjorn)
{
switch (Controller::getJoystickDirection())
{
case DIR_LEFT:
bjorn.velocity -= WALK_ACCELERATION;
break;
case DIR_RIGHT:
bjorn.velocity += WALK_ACCELERATION;
break;
}
}
private:
static const int WALK_ACCELERATION = 1;
};
sample code(c++ 첨부된 코드를 그대로 가져옴)
Bjorn 역시 컴포넌트(input)의 인스턴스가 아닌 포인터를 갖게한다.
class Bjorn
{
public:
int velocity;
int x, y;
Bjorn(InputComponent* input)
: input_(input)
{}
void update(World& world, Graphics& graphics)
{
input_->update(*this);
physics_.update(*this, world);
graphics_.update(*this, graphics);
}
private:
InputComponent* input_;
PhysicsComponent physics_;
GraphicsComponent graphics_;
};
sample code(c++ 첨부된 코드를 그대로 가져옴)
Bjorn* bjorn = new Bjorn(new PlayerInputComponent());
그러면 인스턴스를 생성 할 때, 사용할 컴포넌트를 전달 할 수 있다.
인풋 컴포넌트가 가상 메서드 호출이 되었고 약간의 자원이 들어간다. 하지만 필요에 따라, 인풋컴포넌트를 만들어 넣을 수 있다. 예를 들어, 사용자 응답이 없을때, ai가 조종하게 만드는 것이다.
sample code(c++ 첨부된 코드를 그대로 가져옴)
Bjorn* bjorn = new Bjorn(new DemoInputComponent());
% 엔티티는 그져 ID 숫자에 불과하다. 그 ID를 통해, 분리된 구성요소 컬렉션을 유지한다. 각 구성요소 컬렉션은 어떤 ID의 엔티티에 컴포넌트가 적용되었는지 알고있다.
% 엔티티 구성요소 시스템은 엔티티가 알지도 못하게 새로운 컴포넌트를 추가할 수 있게한다. Data Locality 참고
No Bjorn at all?
Bjorn를 보면 실제로는 아무것도 없다. 그저 컴포넌트 가방일 뿐이다. 이건 기본 객체 클래스의 적합한 후보이다. 그저 어떤 컴포넌트, 부품을 껴넣을 지 선택하는 것이다.
위 과정을 물리, 렌더링 컴포넌트를 반복하고, Bjorn을 GameObject로 만든다.
sample code(c++ 첨부된 코드를 그대로 가져옴)
GameObject* createBjorn()
{
return new GameObject(new PlayerInputComponent(),
new BjornPhysicsComponent(),
new BjornGraphicsComponent());
}
그저 다른 컴포넌트를 사용하여 새로운 오브젝트를 만들 수 있다.
%이러한 방식은 Gang of Four의 Factory Method 패턴의 예가 된다.
Design Decisions
이 디자인 패턴과 관련해서 중요한 고민점은 어떤 컴포넌트 세트가 필요할 지이다.
엔진이 더 크고 복잡해질수록, 컴포넌트를 더 잘게 자르고 싶어질 것이다.
그 외에 고려해야할 구체적인 옵션이 있다.
How does the Object get its component?
한번 하나의 오브젝트를 여러 컴포넌트로 나누면, 다시 여러 컴포넌트를 모아 조립하는 방법을 결정해야한다.
- 오브젝트가 스스로의 컴포넌트를 만드는 경우
- 오브젝트가 필요한 컴포넌트만을 가지도록 보장할 수 있다. 컴포넌트 누락을 걱정을 안해도 된다.
- 오브젝트를 재설정하는 것이 더 힘들어 진다. 하드코딩하면 컴포넌트가 가진 유연성을 활용하지 못한다.
- 외부 코드가 컴포넌트를 제공하는 경우
- 유연하다. 다른 컴포넌트를 제공함으로써 다른 행위를 하게 만들 수 있다. 최대한 활용한다면, 객체는 컴포넌트 컨테이너가 된다.
- 오브젝트를 구체적인 컴포넌트와 분리하면서 컴포넌트의 인터페이스만 알고있으면, 컴포넌트의 파생형도 사용가능해진다.
How do component communicate with each other?
완전히 분리된 컴포넌트는 고립되어 작동하는 것이 이상적이지만, 어디까지나 엔티티의 부품이기 때문에 서로 소통해야할 상황이 존재한다.
어떻게 컴포넌트가 서로 상호작용하게 하냐? 몇가지 옵션이 있고 여타 다른 디자인 대안과는 다르게 상호배타적이지 않으니 동시에 적용할 수도 있다.
- 컨테이너 객체의 상태를 수정한다. (컨테이너 역할을 하는 객체, 엔티티에 속성을 추가한다.)
- 컴포넌트의 분리된 상태를 유지한다. 예를 들어, 객체가 속도 값을 가지고 있다면, 서로 다른 컴포넌트가 수정하고 사용할 수 있다.
- 이러한 공유정보는 모두 엔티티가 가져야한다. 이 경우 객체 클래스의 경계가 모호해 질 수 있다.
- 사용하고 있는 컨테이너 역할의 객체와 만약 다른 컴포넌트 구성을 사용하면 메모리가 낭비 될 수 있다. 공유 가능성 때문에 객체에 데이터를 올리다보면 결국 공유를 안해서 메모리 낭비가 발생한다.
- 여러 컴포넌트가 데이터를 처리하는 순서에 따라 결과값이 달라질 수 있으므로, 신중히 관리해야한다.
- %이런 공유된 변경가능한 상태, 값은 제대로 구현하기 어렵다.
- 서로 직접 참조한다.
- 간편하고 빠르다. 메서드를 통해 직접적으로 소통한다.
- 두 요소는 단단히 결합된다. 적어도 컴포넌트 쌍으로는 결합되었지만, 컨테이너 객체가 모놀리틱 한것 보다는 낫다.
Bjorn가 점프라는 상황에 대해 설명하면, 그래픽 코드는 점프 sprite를 사용할지 말지 알아야 한다. 그걸 결정하기 위해서, 물리 엔진에게서 땅에 닿아 있는지 물어보게 된다. 그래픽 컴포넌트가 어떤 물리엔진을 사용하는지 알게 하는 것이 쉬운 방법이다.
sample code(c++ 첨부된 코드를 그대로 가져옴)
class BjornGraphicsComponent
{
public:
BjornGraphicsComponent(BjornPhysicsComponent* physics)
: physics_(physics)
{}
void Update(GameObject& obj, Graphics& graphics)
{
Sprite* sprite;
if (!physics_->isOnGround())
{
sprite = &spriteJump_;
}
else
{
// Existing graphics code...
}
graphics.draw(*sprite, obj.x, obj.y);
}
private:
BjornPhysicsComponent* physics_;
Sprite spriteStand_;
Sprite spriteWalkLeft_;
Sprite spriteWalkRight_;
Sprite spriteJump_;
};
그래픽 컴포넌트가 물리 컴포넌트의 레퍼런스를 가지고 있다.
- By sending message
- 가장 복잡한 대안이다. 이를 위해 컨테이너 간 브로드케스트 메세지 시스템을 구현해야한다.
- 컨테이너는 메세지 값에 결합이 생긴며 서로에 대해 분리 될 수 있다.
- %이를 Mediator 패턴이라고 한다. 두개 이상의 객체가 중간 객체를 통해 메세지를 라우팅하여 간접적으로 서로 통신한다. 아래 예시 코드의 경우 메세지가 컨테이너 객체를 통해 통신하므로, 컨테이너 객체는 mediator가 된다.
- 컨테이너 오브젝트, 엔티티는 간단하다. 공유 요소가 컨테이너 오브젝트에 올라갈 필요가 없이 서로 메세지 시스템을 사용해 공유하면 된다.
sample code(c++ 첨부된 코드를 그대로 가져옴)
class Component
{
public:
virtual ~Component() {}
virtual void receive(int message) = 0;
};
메세지를 수신하기 위한 receive 메서드를 가진다. 여기서는 int 인수를 사용하지만, 완전한 메세지 시스템은 추가 첨부가 가능하다.
sample code(c++ 첨부된 코드를 그대로 가져옴)
class ContainerObject
{
public:
void send(int message)
{
for (int i = 0; i < MAX_COMPONENTS; i++)
{
if (components_[i] != NULL)
{
components_[i]->receive(message);
}
}
}
private:
static const int MAX_COMPONENTS = 10;
Component* components_[MAX_COMPONENTS];
};
구성 요서가 컨테이너에 액세스 할 수 있는 경우 컨테이너에 메시지를 보낼 수 있다. 메세지 전달 대상은 원래 보낸 구성 요소도 포함되므로, 피드백 루프에 빠지지 않도록 주의한다.
%더 나아가서 메시지 시스템에 큐를 적용할 수 있다 Event Queue를 참고한다.
위 모든 대안에 가장 좋은 방법이란 없다. 결국 전부 조금씩 적용하게 될 것이다.
Shared state(컨테이너 객체의 속성값)은 위치값과 크기값에 주로 적용된다.
직접참조의 경우, 애니메이션과 렌더링, 유저 입력과 AI, 물리와 충돌의 쌍으로 주로 적용된다.
메시지 시스템은 통신이 덜 중요한 컴포넌트에 유용하다. fire-and-forget nature 와 같이 발사하고 잊어버리는 특성을 지닌 경우, 예를 들어 물리 컴포넌트가 객체가 무언가에 충돌했다는 메세지를 보내면 소리를 재생하는데에 주로 사용된다.
See Also
- 유니티 프레임워크의 게임 오브젝트 클래스의 코어는 전적으로 컴포넌트을 중심으로 설계되었다.
- Gang of Four의 strategy 패턴과 유사하다. 두 객체 모두 객체의 동작의 일부를 가져와 하위 객체에 위임하는 것이다. 차이점은 Strategy 패턴의 strategy 객체는 상태를 갖지 않는다. 알고리즘은 캡슐화 하지만 데이터는 그렇지 않다. 행동을 정의하지만, 객체를 정의하진 않는다. 컴포넌트 역시 로컬 상태가 필요하지 않는 경우 strategy에 가깝다.
Event Queue
#정리하다보니 수신자, 발신자, listener, 청취자등의 용어를 사용했는데, 그 차이를 상관하지 않고 사용한 단어임. 문장의 의미에만 주목할 것.
Intent
메세지 혹은 이벤트를 처리되는 시점과 보내는 시점을 분리한다.
%이장에서 이벤트와 메세지를 대부분 혼용해 사용한다.구별이 필요할 때는 확실히 하겠다.
Motivation
아마 이미 익숙하게 사용해온 것들이다.
GUI event loops
인터페이스를 프로그래밍 해본적 있다면, 아마 이벤트에 대해 알고 있을 것이다. 버튼, 메뉴, 키 입력할 때 운영체제는 이벤트를 만들어 낸다. 이 이벤트 객체가 이벤트 앱에 던져지면 우리가 할 일은 그것을 잡아서 기능에 연결하는 것이다.
%이런 식의 스타일을 event-driven programming으로 매우 흔하다.
이러한 서신을 수신하기 위해서는 코드 어딘가에 event loop가 존재해야 한다.
sample code(c++ 첨부된 코드를 그대로 가져옴)
while (running)
{
Event event = getNextEvent();
// Handle event...
}
getNextEvent() 메서드에서 처리되지 않은 입력을 앱으로 가져온다. 이벤트 핸들러로 라우트하면 어플리케이션이 동작한다. 흥미롭게도, 어플리케이션은 이벤트를 원할때 받는다는 것이다. 운영체제는 유저가 입력한다고 어플리케이션의 코드로 바로 가지 않는다.
%대조적으로 운영체제로부터의 interrupt는 이런 식으로 작동한다. 만약 interrupt가 발생하면, 운영체제는 어플리케이션을 멈추고 interrupt handler로 간다.이런 갑작스러움이 interrupt를 다루기 어려운 이유이다.
이 의미는 유저의 입력이 들어오면, 운영체제가 어플로 입력을 전달한 사이, 그 사이에 들어로는 입력을 놓치지 않기 위해 어딘가로 저장해 놔야한다. 그 어딘가는 queue이다.
유저의 입력이 들어오면 운영체제는 처리되지 않은 이벤트를 queue에 추가한다. 그리고 먼저 들어왔던 순서대로 꺼내준다.
Central event bus
대부분의 게임은 이벤트 중심이 아니지만, 핵심요소로 이벤트 큐를 가지는 것이 일반적이다. central, global, main를 이를 설명하는데에 자주 듣게 될 것이다. 이는 서로 분리된 상태로 있으려는 높은 단계의 게임 시스템 사이에 사용되는 소통이다.
%왜 게임이 이벤트 기반이 아닌지는 Game Loop를 참고해라
#게임은 사용자 입력이 없이도 동작(배경이 움직이는 등)하므로 이벤트 기반이 아니다. 물론 정지한 채 사용자 입력을 기다린다면 이벤트 기반일 것이다.
예를 들어, 튜로리얼 시스템이 일종의 도달점을 알고 싶어 한다면, 대기열에 자신을 등록하고 적 사망 이벤트를 받기를 원한다고 나타낸다. 이 방법으로, 전투 시스템에서 적 사망 이벤트를 전달할 수 있고 두 시스템은 서로에 대해 알 필요가 없다.
이벤트 큐를 꼭 글로벌하게 사용할 필요는 없다. 단일 클래스와 영역에서도 유용하다.
Say what?
만약 소리를 게임에 추가시킨다고 하자 그러기 위해 간단한 소리 API를 작성하고 메뉴를 누를 때 마다 호출시킨다.
#예제 생략함
%싱글톤 패턴을 좋아하진 않지만, 스피커는 오로지 한 쌍만 존재한다고 여겨지기 때문에 패턴이 들어맞는 것중 하나이다. 더 간단히 정적 메서드를 작성하였다.
이러면 가끔식 메뉴를 전환 할 때, 몇 프레임 프리징이 결리는 것을 알 수 있다. 다음과 같은 문제가 있다.
- API가 오디오 엔진이 요청을 처리 완료할 때까지 호출자를 기다리게 한다.
작성한 오디오 api는 동기적이기 때문에 스피커에 소리가 나오기 전까지 호출자에게 돌아가지 않는다. 이렇게 게임이 잠시 멈춘다.
이 외에도 만약 한 프레임에 두 적을 죽였다면, 파형이 합산되 의도치 않은 시끄러운 소리가 난다.
더 많은 적이 있을 경우, 하드웨어의 재생되는 소리 개수가 제한되어 몇몇개는 짤려서 묵살된다.
이 문제의 해결을 위해, 호출된 사운드 일체를 살펴서 집계하고 우선순위를 만들어야 한다.
- 요청은 집계하여 처리 할 수 없다.
Api 요청 코드는 코드 베이스 여기저기 흩뿌려진 상태. 또한 멀티코어로 쓰레드를 활용한다는 점에서 요청 전에 집계 하는건 소용없다.
- 요청이 잘못된 쓰레드에서 처리된다.
이는 요청이 바로 처리해야할 사항처럼 보인다는 것이다.
위 사항을 해결하기 위해 수신과 처리를 분리한다.
The pattern
큐는 알림과 요청을 FIFO 순서로 저장한다. 알림을 보내면 큐에 저장되었다가 반환된다. 그 뒤, 요청 프로세서가 큐의 아이템을 처리한다. 요청은 바로 다뤄질 수도 있고 대기열에 있는 대상에게 라우트 될 수 있다. 이를 통해 발신자와 수신자를 정적 시간적으로 분리한다.
When to use it
그저 발신자와 수신자를 분리하고자 한다면 Observer와 Command 패턴이 덜 복잡하게 처리한다. 추가로 시간적으로 분리하고 싶다면, 큐를 사용해야한다.
%복잡성은 우리를 느리게 만들기 때문에, 단순함을 귀중한 자원으로 여겨라
요청 발신자가 응답을 반드시 필요로 할 경우는 큐는 적합하지 않다. 큐는 상황에 따라 요청을 지연하거나 묵살하기 때문이다.
Keep in mind
이벤트 큐는 복잡하고 영향력이 크기 때문에 어떻게 사용할지 신중해야한다.
A central event queue is a global variable
일반적인 사용방법으로, 게임의 모든 부분에 메세지가 라우팅 하게 만드는 것이다. 강력하지만, 강력하다고 좋은 것은 아니다.
또한 전역 변수는 전반적인 결합도에 영향을 끼치기 때문에 추가적인 위험이 따른다.
The state of the world can change under you
엔터티 사망을 큐로 처리한다 했을때, 큐에서 처리가 지연되는 동안 무슨 일이 발생 할 지 모른다.
이벤트를 수신 했을때, 현재 세상의 상태가 이벤트가 일어났을 때 세상이 어땠는지를 반영한다고 추정하지 않도록 주의한다. 큐에 저장된 데이터는 동기 시스템의 데이터보다 많은 데이터를 가지는 경향이 있다.
You can get stuck in feedback loops
피드백 루프에 빠질 수 있다. 이벤트 전송-> 이벤트 수신 및 응답 전송-> 응답 수신 및 이벤트 전송 -> 반복
동기적으로 동작한다면 빠르게 발견될 문제지만, 비동기 큐의 경우 큐를 오버플로우 시키고 충돌시키게 된다. 문제를 안고 게임이 계속 될 수도 있다
Sample code
요청을 구체화 해야한다. 세부정보를 저장할 수 있는 구조를 만든다.
sample code(c++ 첨부된 코드를 그대로 가져옴)
struct PlayMessage
{
SoundId id;
int volume;
};
다음으로, 메시지를 보류하기 위한 공간이 필요하다. 피보나치 힙, skiplist, 링크드 리스트 같은 데이터 구조도 사용할 수 있다. 하지만 간단한 배열을 사용한다.
%배열의 장점은 동적할당이 없고,메모리 오버헤드가 없고, Cache-friendly 메모리 연속사용이다.
%update method 패턴 사용
이 방법은 잘 작동하지만, 모든 요청 처리를 update() 메서드의 단일 호출로 할 수 있다고 전제한다. 리소스가 로드되고 처리되는 비동기적 방식이 아니다. 한번에 하나의 요청을 처리하기 위해 큐가 필요하다.
A ring buffer
#FIFO 를 위한 배열 사용으로 head tail 두 포인터를 사용한 방법, 원리는 아니까 생략
sample code(c++ 첨부된 코드를 그대로 가져옴)
class Audio
{
public:
static void init()
{
head_ = 0;
tail_ = 0;
}
// Methods...
private:
static int head_;
static int tail_;
static const int MAX_PENDING = 16;
static PlayMessage pending_[MAX_PENDING];
static int numPending_;
};
void Audio::playSound(SoundId id, int volume)
{
assert((tail_ + 1) % MAX_PENDING != head_);
// Add to the end of the list.
pending_[tail_].id = id;
pending_[tail_].volume = volume;
tail_ = (tail_ + 1) % MAX_PENDING;
}
void Audio::update()
{
// If there are no pending requests, do nothing.
if (head_ == tail_) return;
ResourceId resource = loadSound(pending_[head_].id);
int channel = findOpenChannel();
if (channel == -1) return;
startSound(resource, channel, pending_[head_].volume);
head_++;
}
Aggregating requests
이제 큐를 사용했으니 요청이 저장되어 있다. 같은 소리가 재생되는 경우, 즉 동일 요청이 들어오는 경우 병합 할 수 있다.
sample code(c++ 첨부된 코드를 그대로 가져옴)
void Audio::playSound(SoundId id, int volume)
{
// Walk the pending requests.
for (int i = head_; i != tail_;
i = (i + 1) % MAX_PENDING)
{
if (pending_[i].id == id)
{
// Use the larger of the two volumes.
pending_[i].volume = max(volume, pending_[i].volume);
// Don't need to enqueue.
return;
}
}
// Previous code...
}
같은 소리를 볼륨로 재생하기를 요청받을 경우, 그 요청을 병합하여 볼륨이 큰 값으로 재생하도록 한다.
이와 비슷하게 큐에 저장된 요청을 여러 방식으로 병합할 수 있다.
하지만, 시간 복잡도를 고려하면 부담이 된다. 위 방법만 해도 큐가 커질수록 느려질 수 밖에 없다.
%다른 데이터 구조를 활용하면, O(n) 시간복잡도를 피할 수 있다. 위 경우 Soundid를 사용해 해시테이블을 구성한다면 중복을 상수 시간복잡도로 만들 수 있다.
동시의 들어오는 요청의 량은 큐의 크기 만큼 크다. 요청을 빨리 처리하고 큐의 크기를 작게 유지한다면, 병합할 기회는 줄어든다. 비슷하게, 처리 병목으로 큐가 가득찬다면 병합할 방법을 더 찾아야한다.
이 패턴은 요청자에게 요청이 언제 처리되는지 알려주지 않지만, 요청이 지연되면서 동작에 영향을 끼칠 수도 있다. 이러한 영향이 있는지 확인하고 적용해야한다.
Spanning threads
가장 잔인한 문제이다. 동기적 audio API는 어떤 쓰레드가 playSound()를 호출하는데 그게 요청을 처리하는 쓰레드 일 수 있다. 그것은 종종 우리가 원하는 방식이 아니다.
간단한 전략은 각 API를 담당하는 쓰레드를 설정하는 것이다.
이제 방식을 정했으니 세가지 중요사항이 있다.
- API를 요청하는 코드와 그것을 수행하는 코드를 분리한다.
- 분리한 두 코드를 연결하는 큐를 가진다.
- 큐는 나머지 프로그램에 대해서 캡슐된다.
나머지 수행사항은 playSound()와 update()를 쓰레드 안전하게 만드는 것이다. 이 방법은 다루지 않겠다.
더 수준 높게, 큐가 동시에 수정되지 않도록 보장해야한다. # lock에 대해서 정말 간략히 설명한다. 생략
Design Decisions
많은 게임에서 이벤트 큐는 통신 구조의 핵심으로 사용한다. 우리는 수많은 시간을 복잡한 라우팅과 메시지 필터에 사용한다. 전화 교환소 같은 것을 만들기 전에 간단하게 시작하라고 당부하고 싶다.
What goes in the queue?
이벤트와 메시지 용어를 혼용해서 사용했지만, 그건 중요치 않다. 큐에 뭐가 들었던, 분리와 병합 능력을 가진다. 하지만 개념적으로 몇 차이가 있다.
- If you queue events
이벤트와 혹은 알림은 무언가 이미 일어난 상황이다. 큐는 객체가 이벤트에 응답하도록 한다. 일종의
비동기식 Observer 패턴인 셈이다,
- 다수의 수신자를 허용하고 싶을 때. 큐에 들어간 내용이 이미 일어난 일이므로 발신자는 누가 그것을 받는지는 상관하지 않는다. 발신자가 그것을 보낸 순간부터 이미 과거의 일이고 이미 잊어버리기 때문이다.
- 큐의 역할이 넓을 때. 이벤트 큐는 이벤트를 그것에 관심있는 집단(#아마 대기열, 혹은 구독자)에 브로드캐스트한다. 집단에게 최대한의 유연함을 허용하기 위해, 큐는 전역적인 경향이 있다.
- If you queue messages
메세지 혹은 요청은 액션이 일어나길 원하는 상황이다. sound API와 같은 비동기 API를 생각하면 된다.
%request의 또다른 말은 command 이다. Command 패턴을 참고하자. 거기서도 큐가 쓰인다.
- 단일 수신자만 가지게 될 때, sound API로 보낸 메시지가 큐에 저장되고 전혀 다른 게임 엔진이 메세지를 훔쳐간다고 생각하면 좋지 않은 일이다.
%예상대로 처리된다면 누가 코드를 처리하는지 신경쓰지 않고 메시지를 캡슐화하는 경우도 있다. service locator를 확인한다.
Who can read from the queue?
예제에서는 큐는 Audio class만 읽을 수 있도록 캡슐화 되어 있다. 유저 인터페이스 시스템에서는 listener를 마음껏 등록할 수 있다. single-cast 혹은 broadcast 두 스타일 모두 유용하다.
- A single-cast queue
class의 API에 queue가 구성되어 있는 경우 자연스럽게 들어맞는다. 예제에서 호출자 입장에서는 playSound() 메소드만 보면 된다.
# 큐에 스택을 쌓을수 있는 요소(예제의 playSound() 처럼)가 하나이고, 요소가 하나이므로 그 요소를 처리하는 listener 도 하나인 상태인것 같다.
- 큐에 class의 세부사항이 구현되어 있다. 발신자는 메시지를 보냈다는 것만 알면 된다.
- 큐가 더욱 캡슐화 되어 있다. 대부분 마찬가지로, 캡슐화되면 보통은 나아진다.
- 청취자 간의 충돌을 걱정할 필요가 없다. 만약 다수의 listener가 있었다면, 모두에게 아이템을 보낼것인지(broadcast) 아니면 단일 listener에게 배부할 것인지 정해야한다
어느 경우든, listener들은 중복된 일을 하거나 서로 간섭 할 수 있다. 원하는 동작에 대해 신중해야한다. listener가 하나면 복잡성이 사라진다.
- A broadcast queue
대부분의 이벤트 시스템이 동작하는 원리이다. 만약 10개의 listener가 있고 이벤트가 발생하면, 모두 이벤트를 보게된다.
- 이벤트는 아무런 동작으로 이어지지 않을 수도 있다. listener가 없으면 이벤트는 아무도 보지 않는다. 대부분의 broadcast 시스템에서 listener가 없으면 이벤트는 버려진다.
- 이벤트에 필터가 필요할 지도 모른다. Broadcast queue는 프로그램에서 주로 전역적으로 존재하게 되고 많은 listener를 가지게 된다. 이벤트 수와 listener를 곱한 만큼의 이벤트 핸들러를 호출하게 된다.
사이즈를 줄이기 위해서는 listener가 받게 될 이벤트를 걸러낸다. 예를 들어, 마우스 이벤트만 원하거나 UI의 특정 요소에 대한 것만 원할 수 있다.
#원하는 것만 구독 시킨다는거 아니야?
- A work queue
broadcast 큐와 같이, 다수의 listener가 있다. 차이점은 각 아이템은 그둘 중 하나에게만 전달된다. 이건 쓰레드 풀에 작업을 분배하는 일반적인 방법 중 하나이다.
- schedule를 가져야한다. 아이템이 하나의 listener에게만 가기 때문에, 큐는 무엇이 좋은 선택이지 찾아내는 로직이 필요하다. 간단한 round robin 방식이나 무작위 선택을 사용할 수도 있고, 복잡한 우선순위 시스템을 적용할 수도 있다.
Who can write to the queue?
이건 위 사항의 뒷면 같은 거다. 이 패턴은 모든 가능한 RW 설정에 동작한다. one-to-one, one-to-many, many-to-one, many-to-many
%fan-in에 대해서 들어봤을 것이다. 보통 many-to-one 통신 시스템을 가르키는 말이다. fan-out 은 one-to-many를 가르킨다.
- Pass ownership
이건 메모리를 관리하는 전통적인 방법이다. 메시지가 큐에 저장되면 큐는 그것을 소유하고 발신자는 그것을 소유하지 않는다. 처리가 진행되면, 수신자는 소유권을 가지고 할당 해제에 대한 소유권을 가진다.
- Share ownership
갈비지 컬렉터를 의존하는 방법으로 무언가 참조하지 않으면 자동으로 반환되게 만든다.
- The queue owns it
메세지가 항상 큐에 있도록 한다. 메세지를 할당하기 보단, 발신자는 갱신 요청을 통해 큐에 이미 존재하는 메세지의 참조를 반환하고 발신자는 이를 채운다. 메세지가 처리되면, 수신자는 그 메세지를 참조한다.
%즉, 데이터를 저장하는 큐는 일종에 object pool이다.
See Also
- 어떤 관점에서 이 패턴은 Observer 패턴의 비동기식 버전이라고도 볼 수 있다.
- 활용성이 크기 때문에, 다양한 레벨 단계에서 다양한 용도로 사용된다 그에 따라 명칭이 다르게 부르기도 한다.
- State 패턴과 유사한 유한 상태 머신은 입력의 연속이 요구 된다. 만약 비동기적으로 반응하고 싶다면 큐가 적당하다.
%다수의 state 머신이 서로 메시지를 보내며 상호작용하고 큐를 적용했다면 actor model이 된다.
Service Locator
Intent
이를 구현 할 때, 구체적인 클래스에 결합 없이 전역적인 액세스 포인트를 제공한다.
Motivation
코드의 몇몇 요소는 코드 베이스 전반에 걸쳐 나타난다.(#특히, 전역선언한 요소) 몇몇 요소에서 다른 나머지 요소, 별도의 id 혹은 메모리 할당이 필요없는 요소를 일일히 찾아 적용하는 것보다. 다른 나머지 요소가 몇몇 요소를 호출하는게 쉽다. 이런 몇몇 요소, 시스템은 서비스로도 여겨질 수도 있고, 게임 전반적으로 사용 가능해야한다.
사운드 API를 UI, 물리, 인 게임 어느 요소에서 호출해 사용하는 것과 비슷하다.
이 경우 우리는 API에 싱클톤 패턴를 적용하거나 정적 클래스를 생성하기도 한다. 두 방법 전부 하려던 것을 이루게 해 주지만, 끈적한 결합이 만들어 진다. 전역선언한 구체적인 클래스에 직접적으로 참조하기 때문이다.
전역 요소에 대한 수많은 호출지점을 일일히 관리하는 것도 힘들고, 전역 요소가 변경된다면 일일히 호출지점을 수정해야한다.
더 나은 방법으로는 전화번호 부를 드는 것이다. 이름 사용하여 찾고 주소를 얻을 수 있게 한다면, 주소가 바뀌어도 전화번호 부만 변경하면 된다. 더 나아가, 실제 주소를 알릴 필요도 없다. 사서함이나 대리할 수 있는 것을 나열할 수 있다. 호출자는 전화번호부에서 우리를 찾고, 우리는 우리를 찾는 방법만 관리하면 된다.
The pattern
Service 클래스는 몇가지 추상 인터페이스를 정의한다. 추상 인터페이스는 일련의 동작으로 이루어져 있다. 서비스 제공자는 이 인터페이스를 구체화, 구현하고, 서비스 할당자는 서비스 제공자의 구체적인 형태나 이를 찾아내는 방법을 숨긴채 적절한 서비스 제공자에 대한 액세스를 제공한다.
When it use it
프로그램의 전체 기능에서 접속할 수 있는 요소를 만들어 내면 문제를 초래한다. 싱글톤 패턴의 문제와 마찬가지이다. 하지만 이 패턴 역시 별반 다르지 않다.
그렇다면 왜 굳이 글로벌 요소에 직접 참조하는 대신 서비스 할당자를 경유해야 할까?
코드의 일부분에 대한 액세스(전역 매세드)와 인스턴스 참조를 통한 액세스를 비교해보면, 인스턴스 참조를 통한 액새스가 매우 쉽고 결합이 명확해진다. #결합이 명확해진다는 표현이 약하다 강하다는 느낌보단 질척이지 않는다는 느낌이다.
그래서 인스턴스 참조를 통해 코드 베이스를 짠다. 만들어진 결과를 보니, 만드려는 시스템 코드에 다른 API의 명칭이 군데 군데 섞여있다. 간단한 동작에도 로거 호출자와 메모리 관리 호출자가 껴있다. 또한 그 시스템의 매개변수는 그 시스템에 관련된 것이여야 한다. #아마도 매개변수명 그 자체에 대한 이야기인 것 같다.
단일 시스템은 단일한 특성을 나타내고 단일 시스템 입장에서 그 외 요소는 환경 요소이다. 그 환경요소를 코드 전반에 걸쳐 사용하는 것은 불필요한 복잡성을 추가하게 된다.
이 경우 이 패턴이 도움이 된다. 이건 싱글톤 패턴의 설정 가능하고 유연한 사촌이다. 잘 만 사용하면, 적은 런타임 비용으로 코드베이스가 유연해진다.
%잘못 사용하면 싱글톤 패턴의 모든 문제를 안고 런타임 비용을 지불한다.
Keep in mind
이 패턴을 적용하면 두 코드 사이의 결합은 작아지지만 런타임까지 연결을 미루기 때문에, 종속성을 파악하는 데에 어려움이 생긴다.
The service actually has to be located
싱글톤 패턴이나 정적 클래스를 사용하는 경우, 사용 불가능한 경우가 없기 때문에 당연히 존재할 것이라 여기고 호출할 수 있다. 하지만 이 패턴의 경우 서비스를 찾는 것이기 때문에 실패할 경우를 다뤄야 할 수 있다. 물론 항상 서비스를 제공하도록하는 전략도 보여줄 것이다.
The service doesn't know who is locating it
할당자는 전역적으로 존재한다. 모든 게임 내 코드는 서비스를 요청하고 찔러볼 수 있다. 이 의미는 서비스는 반드시 어떤 상황에서도 올바르게 동작해야한다는 것이다. 이와 다른, 특정 상황에서만 동작하는 클래스의 경우, 전체 세계에 노출시키는 것보다 이 패턴을 적용해 숨기는 것이 안전하다.
Sample code
우리의 오디오 예제로 돌아가본다. 서비스 할당자를 사용해 주소를 노출시켜 본다.
The service
예를 오디오 api를 간단히 묘사한 예제
sample code(c++ 첨부된 코드를 그대로 가져옴)
class Audio
{
public:
virtual ~Audio() {}
virtual void playSound(int soundID) = 0;
virtual void stopSound(int soundID) = 0;
virtual void stopAllSounds() = 0;
};
class ConsoleAudio : public Audio
{
public:
virtual void playSound(int soundID){// Play sound using console audio api...}
virtual void stopSound(int soundID){// Stop sound using console audio api...}
virtual void stopAllSounds(){// Stop all sounds using console audio api...}
};
추상 인터페이스 클래스를 사용해서 서비스 제공자를 구현하였다고 하자.
The service locator
정말 간단히 구현해 보면 다음과 같다.
sample code(c++ 첨부된 코드를 그대로 가져옴)
class Locator
{
public:
static Audio* getAudio() { return service_; }
static void provide(Audio* service)
{
service_ = service;
}
private:
static Audio* service_;
};
% 종속성 주입이라는 방법으로 용어에 비해 간단한 아이디어이다. 하나의 클래스가 다른 하나에 의존한다고 하자. 우리의 경우, 우리의 할당자 클래스가 Audio service의 인스턴스를 원한다. 일반적으로, 할당자는 그 인스턴스 자체를 구성하는데 책임이 있다. 그 대신 종속성 주입은 외부 코드가 종속성이 필요한 객체에게 종속성을 주입할 책임이 있다.
정적 getAudio() 메소드는 위치를 찾는다. 그건 코드베이스 어디에서나 찾을 수 있으며 오디오 서비스의 인스턴스를 반환해준다.
찾는 방법은 간단하다. 외부코드에서 서비스를 사용하기 전에 정적 provide() 메서드를 통해 서비스 제공자를 등록하면 되기 때문이다.
playSound() 메서드는 인터페이스를 통해 선언되었기 때문에, 사용하기에 있어 구체적인 클래스를 상관하지 않는다. 이와 동시에 중요한 점은, 로케이터 역시 구체적인 service provider에게 영향받지 않는다는 것이다.
구체적인 코드는 초기화 할 때와 제공할 때 뿐이다.
더 높은 수준의 디커플링이 있다. Audio 인터페이스는 서비스 로케이터를 통해 제공되는 것에 상관하지 않는다는 것이다. 이점은 쉽게 기존에 코드에 이 패턴을 적용할 수 있다는 것이다. 이점은 코드 베이스를 설계할 때부터 영향을 주는 싱클톤 패턴과는 대조적이다.
A null Service
패턴의 구현은 매우 간단하고 유연하지만 서비스가 등록되기 전의 요청에 대해서 NULL 값을 보내기면 충돌이 발생한다.
%이것에 대해 종종 시간적 결합이라고 말하곤 한다. 실행 순서가 중요하다는 것이다. 역시 이를 잘 줄이는 것이 코드가 관리하기 쉬워진다.
NULL Object 라고 부르는 패턴이 존재한다. NULL값을 반환해야 하는 상황에서 대신에 특수개체를 반환하는 것이다. 이 개체는 아무것도 수행하지 않지만, 일단 안전하게 진행시킨다.
sample code(c++ 첨부된 코드를 그대로 가져옴)
class NullAudio: public Audio
{
public:
virtual void playSound(int soundID) { /* Do nothing. */ }
virtual void stopSound(int soundID) { /* Do nothing. */ }
virtual void stopAllSounds() { /* Do nothing. */ }
};
class Locator
{
public:
static void initialize() { service_ = &nullService_; }
static Audio& getAudio() { return *service_; }
static void provide(Audio* service)
{
if (service == NULL)
{
// Revert to null service.
service_ = &nullService_;
}
else
{
service_ = service;
}
}
private:
static Audio* service_;
static NullAudio nullService_;
};
호출자는 서비스를 찾지 못했다는 것을 전혀 모른다. NULL를 다뤄야할 걱정도 없다. 항상 유효한 객체를 반환하는 것을 보장한다.
이 것은 의도적으로 서비스를 찾지 못하게 할 때도 유용하다. 시스템의 일시적으로 정지시킬때 단순히 등록을 하지 않으면 된다.
Logging decorator
이 패턴으로 할 수 있는 또다른 개선방안이다.
로깅 기능은 개발자에게 유용하지만 여기저기 적용하다보면, 현재 작업하는 부분과 상관없는 로그도 띄워질 우려가 있다.
이때 선택적으로 활성화 할 수 있도록 만들기 위해, 조건부로 로깅하려는 시스템이 서비스로 노출되면 Decorator 패턴을 사용하여 이를 해결할 수 있다.
sample code(c++ 첨부된 코드를 그대로 가져옴)
class LoggedAudio : public Audio
{
public:
LoggedAudio(Audio &wrapped)
: wrapped_(wrapped)
{}
virtual void playSound(int soundID)
{
log("play sound");
wrapped_.playSound(soundID);
}
virtual void stopSound(int soundID)
{
log("stop sound");
wrapped_.stopSound(soundID);
}
virtual void stopAllSounds()
{
log("stop all sounds");
wrapped_.stopAllSounds();
}
private:
void log(const char* message)
{
// Code to log message...
}
Audio &wrapped_;
};
오디오 서비스를 감싸면서, 동일한 인터페이스를 노출시키는 것을 볼 수 있다. 각 메서드는 감싸진 오디오 서비스를 실행 시키면서 로그 기능을 수행한다. 이를 통애 기존 오디오 서비스에 로그 기능을 데코레이트 했다.
void enableAudioLogging()
{
// Decorate the existing service.
Audio *service = new LoggedAudio(Locator::getAudio());
// Swap it in.
Locator::provide(service);
}
이제 원하는 로그를 보기 위해, 기존 서비스를 서비스 할당자로 배정받고, 감싼뒤 그 자신을 서비스 할당자에 등록하면 된다.
Design Decisions
일반적인 구현을 다루었지만, 몇가지 사항을 고려하면 매우 다양한 방식이 존재한다.
How is the service located?
- 외부 코드에서 등록할 때
위에서 다룬 예시가 이에 해당한다. 가장 (게임에서) 일반적인 방식이다.
- 빠르고 간단하다. get~~() 메소드에서 주소를 반환해주면 된다.
- 서비스 제공자가 어떻게 구성되는지 제어한다. 로컬 입력와 온라인 입력을 예로 들면, 코드의 나머지 부분은 이 둘을 구별하지 못한다.#어떤 서비스 제공자를 사용할 것인지 정할 수 있다는 뜻
이 방법을 위해서는 ip주소를 알아야 할 텐데, 서비스 할당자가 그것을 알 필요가 있을까? 아니다. 외부에서 등록된 제공자가 이를 다루면 된다. 온라인 입력을 다루는 서비스 제공자가 서비스 할당자에 등록되면 된다.
- 실행 중에도 서비스를 바꿀 수 있다. 최종 단계에서는 사용하지 않더라도, 개발도중에는 좋은 기술이 된다.
- 할당자가 외부 코드에 종속된다. 단점이다. 코드는 서비스가 이미 등록되었다고 가정하게 된다.
- 컴파일 시간에 바인딩하는 경우
이 아이디어는 할당 프로세스가 전처리기 매크로를 사용하여 컴파일 시간에 발생한다는 것이다.
class Locator
{
public:
static Audio& getAudio() { return service_; }
private:
#if DEBUG
static DebugAudio service_;
#else
static ReleaseAudio service_;
#endif
};
- 빠르다. 컴파일 시간에 동작된다. 런타임동안 아무것도 할 필요가 없다. getAudio() 호출이 인라인에 처리하여 바랄 수 있는 속도를 낸다.
- 서비스가 사용가능하다는 것을 보장한다. 로케이터가 서비스를 직접 소유하기 때문에 컴파일 이후 신경 쓸 필요가 없다.
- 서비스를 쉽게 바꿀 수 없다. 주요 단점이다. 빌드 시간에 바인딩되어 서비스를 바꾸고 싶다면 다시 컴파일 해야 한다.
- 런타임 동안 설정한다.
일반적으로, 런타임에 설정파일을 읽어드린 후 거기에 맞춰 리플랙션을 사용해 해당하는 클래스는 찾은 후 인스턴스를 생성해 구성한다는 것이다.
%리플랙션은 런타임 동안 유형 시스템과 상호작용하는 것이다. 예를 들어 이름으로 클래스를 찾고 생성자를 찾은 다음 호출하여 인스턴스를 만들어 낸다.
- 컴파일 하지 않아도 서비스를 바꿀 수 있다. 컴파일 시간에 바운딩 되는 서비스보다는 약간 유연하지만, 게임이 실행 중에 바꿀 수 있는 등록된 서비스 만큼 유연하지 않다. #아마도 코드 베이스에서 서비스를 상황에 따라 적절하게 등록을 바꿀 수 있게 만들어논 경우, 이 방식은 큰 이점이 없다는 의미인것 같다.
- 프로그래머가 아니여도 서비스를 바꿀수 있다. 디자이너가 게임 기능은 키고 끄는데에, 코드를 수정할 필요가 없게 할때 유용하다.
- 코드가 다양한 환경을 동시에 지원할 때. 할당 프로세스가 런타임 시간에 이루어지므로, 동일한 코드로 다양한 서비스를 동시에 지원할 수 있다. # 코드는 설정 파일을 읽고 서비스를 구성하고, 설정 파일에 올바른 설정이 적혀있어야한다. # 외부 데이터 파일의 수정을 통해 설정이 바뀌도록 지원한 것이다.
이 이유가 웹에서 이 방식이 많이 보여지는 이유이다. 단일 어플리케이션을 한 다양한 서버에 배포하기 위해 설정만 바꾸면 된다. 게임은 콘솔 장치가 표준화되어 있기에 비교적 유용성이 작다.
- 복잡하다. 이전의 방법과 다르게 이건 상당히 무겁다. 구성시스템, 파일 파셔 등 서비스를 할당하기 위해 갖춰야 할 것들이 있다.
- 서비스를 찾는 데 시간이 걸린다.
What happens if the service can't be located?
- 유저가 다루게 해라.
간단한 해결책은 책임을 전가하는 것이다. 할당자는 NULL를 반환하기만 하면 된다.
- 유저가 실패를 어떻게 다룰지 결정할 수 있다. 유저가 게임을 끌 수도 있다.
- 서비스 호출자가 실패를 처리해야 한다. 많은 중복코드를 야기하고 관리가 힘들어 진다.
- 게임을 중지시킨다.
우리는 컴파일 시간에 서비스가 항상 사용가능한지 증명할 수는 없지만, 런타임 시간에 할당자의 사용가능을 선언할 수 없다는 건 아니다. assert()를 사용한다.
class Locator
{
public:
static Audio& getAudio()
{
Audio* service = NULL;
// Code here to locate service...
assert(service != NULL);
return *service;
}
};
- 유저가 이 사항에 대해 다룰 필요가 없다.#왜냐면 문제는 게임 종료가 처리했기 때문이지.
- 게임이 중단된다. 버그 리포트를 상시적으로 추적해야 한다.
- null service를 반환한다.
- 유효한 서비스를 항상 반환하므로 문제를 다루지 않아도 된다.
- 서비스를 사용하지 못하더라도 게임은 진행된다. 장단점이 있다. 의도치 않게 누락된 버그를 찾기 어려워 진다. %nullservice 사용시 로그를 출력하게 하면 된다.
이러한 방법중에도 저자가 가장 자주 사용하는 방법은 서비스가 찾아질 거라고 주장하는 거다.(#asserting이라 적혀있는데 assert매소드 사용한다는 의미일 수도 있다.). 게임은 철저히 테스트 되어 출시되고 신뢰 할 수 있는 하드웨어에서 주로 실행된다.#위 방법중 서비스가 존재하지 않는 경우는 거의 고려하지 않는다는 이야기인 듯 하다.
What is the scope of the service?
할당자는 누구에게나 서비스를 제공해야한다고 전제했다. 이것이 일반적인 패턴 사용방식이지만, 다른 방식으로 단일 클래스와 그 하위 클래스에게 접근을 제한하는 것이다.
class Base
{
// Code to locate service and set service_...
protected:
// Derived classes can use service
static Audio& getAudio() { return *service_; }
private:
static Audio* service_;
};
이 방법으로, Base 클래스의 하위 클래스만 서비스 접근이 가능하다.
- 접근이 전역적일 경우.
- 이건 다른 전체 코드에서 동일한 서비스를 사용하도록 장려한다. 대부분의 서비스는 단일로 존재한다. 전체 코드베이스에 동일한 서비스에 접근하도록 허용함으로써, 무작위로 공급자를 인스턴스하지 않도록 할 수 있다.실제 공급자에 접근할 수 없기 때문이다.
- 언제, 어디서 호출될지에 대한 통제력을 잃습니다. 전역변수의 폐해이다.
- 접근이 클래스에 재한된 경우
- 결합도를 제어할 수 있습니다. 주요 장점이다. 상속을 통해 서비스를 제한 할 수 있다. 분리된 시스템은 분리된 상태로 유지할 수 있다.
- 중복된 코드 및 작업로 이어질 수 있다. 잠재적 단점으로 관련없는 클래스가 서비스를 필요로한 경우 서비스에 대한 자체 참조가 필요하다. 서비스가 찾거나 등록되는데 프로세스가 해당 클래스들 간에 복제되야 한다.% 계층구조를 변경해서 이러한 클래스들이 공통의 부모를 갖게 할 수 있지만, 그럴 가치가 있을까 싶을 정도로 번거롭다.
저자의 일반적인 지침은 게임 내 서비스의 도메인이 제한되는 경우, 범위를 클래스로 제한하는 것이다. 예를 들어 네트워크 액세스는 온라인 클래스에 제한한다. 로거 같은 경우 글로벌로 선언한다.
See Also
- 싱글톤 패턴과 유사하다.
- 유니티 프레임워크에서는 이 패턴을 컴포넌트 패턴과 사용한다. GetComponent()
Optimization Patterns
- Data Locality / Dirty Flag / Obect Pool / Spatial Partion
Data Locality
Intent
cpu 캐싱을 위한 데이터 배열로 메모리 접속 속도를 가속한다.
Motivation
cpu 기능이 향상되면서 처리속도는 빨라졌지만 데이터 접속속도는 비교적 빨라지지 않았다.
그럼에도 불구하고, cpu는 대부분의 시간을 휴무상태로 보내지 않는 이유가 뭘까? 많은 시간을 데이터를 기다리는 데에 갇혀 있긴 해도 그렇게 나쁘기만한 얘기는 아니다.
A data warehouse
작은 사무실의 회계사라고 생각해보아라. 난해한 논리에 따라 숫자를 정리하고 정해진 박스에 분류한다. 그 박스는 창고에 쌓인다. 창고에서 박스를 다시 꺼내기 위해서는 시간이 걸린다. 얼마나 유능한 회계사건, 박스 꺼내는 속도에 갇히게 된다.
작업 속도 개선을 위해 다음을 고려할 수 있다.
- 다음에 작업해야 할 박스를 미리 꺼내 대기시킨다.
- 창고에서 한번에 많은 박스, 팔레트 단위로 꺼낸다.
- 사무실에 여유 공간을 둔다.
%사용한 것의 주변에 있는 것을 사용하는 것은 locality of reference.
A pallet for you CPU
위 방법은 현대 컴퓨터와 유사하다. 다만 더 나아가서 CPU caching을 적용한다.
cpu 칩 안에 작은 메모리(SRAM)를 가진다.
%L1,L2,L3 같이 캐싱 매모리를 계층적으로 가진다.
캐시에 저장해 논 데이터가 필요하면 cache hit, 그렇지 않다면 cache miss이다.
#캐시는 필요한 데이터 뿐만 아니라, 그 주변의 데이터 통째로 가져온다는 점에 유의한다.
Wait, data is performance?
저자는 best case와 worst case를 비교해보면서 성능을 비교했다. 수십배 차이가 난다.
%장치마다 캐시 설정 및 사용이 다르므로 유의
코드의 측면 뿐만 아니라, 데이터를 어떻게 구성했는가도 성능에 영향을 주었다.
%이에 관련된 내용이 데이터 지향 설계이다.
어떻게 캐시 사용을 최적화 하는가는 거대한 이슈이고 이에 다루는건 책 한권이다.
여기서는 간단한 기술만 다르겠다.
간단히 표현하면, 데이터 구성을 통해 항목이 모여있게 하여, 한 캐시 라인에 모여있게 한다.
%단일 쓰레드를 전재하였다. 다중 쓰레드를 다루는 경우 각 각 별개의 캐시라인을 가지게 하는게 빠르다. 한 캐시 라인을 여러 쓰레드가 조정한가면 각 쓰레드는 캐시 비용이 많이 드는 동기화를 수행해야한다.
The pattern
데이터 지역성을 높인다. 데이터를 연속적으로 존재시켜, 자연스레 처리되는 순서대로 연속된 메모리에 저장되게 한다.
when to use it
성능 문제를 안고 있을 때 이다. 드물게 작동하는 부분에 이 것을 적용하느라 시간을 낭비하지 말아라. 최적화는 늘 더 복합해지고 뻣뻣해지기 때문에 필요하지 않는 경우 사용하지 않는다.
캐시 적중률을 확인하고 성능이 개선될 거라 판단되는 경우 적용한다.
이에 대한 프로파일러가 있다. %대부분 가격이 있지만 Cachegrind 라는 무료 툴이 있다.
너무 많은 시간을 캐시 사용을 최적화 하는데 사용하지 말고 데이터 구조가 얼마나 캐시 친화적인지 생각해 보아라
Keep in Mind
소프트 아키텍쳐의 특성은 추상화이다. 많은 설계 방식이 두 코드를 분리하여 쉽게 수정가능하도록 해준다. 객체 지향 언어에서는 주로 인터페이스이다.
(c++ 에서) 데이터를 참조하게 되면 데이터를 건너가기를 바라는 것이다. 이것은 캐시 미스를 유발하고 패턴은 이를 피한다.
% 인터페이스느 주로 virtual 메서드를 가진다는 것이다. 이것은 실제 method를 찾아가기 때문에 캐시 미스를 유발 한다.
이 패턴을 적용하기 위해선 몇몇 추상화를 희생해야한다. 데이터 지역성을 높이기 위해 상속, 인터페이스, 유용한 툴을 포기해야한다.
Sample code
데이터 지역성을 통한 최적화에 빠져들다 보면, cpu가 쉽게 소화할 수 있게 데이터를 나누는 수많은 방식을 발견하게 된다. 여기서는 그중 가장 일반적인 예를 다루도록 한다.
Contiguous arrays
sample code
class GameEntity
{
public:
GameEntity(AIComponent* ai,
PhysicsComponent* physics,
RenderComponent* render)
: ai_(ai), physics_(physics), render_(render)
{}
AIComponent* ai() { return ai_; }
PhysicsComponent* physics() { return physics_; }
RenderComponent* render() { return render_; }
private:
AIComponent* ai_;
PhysicsComponent* physics_;
RenderComponent* render_;
};
class AIComponent
{
public:
void update() { /* Work with and modify state... */ }
private:
// Goals, mood, etc. ...
};
class PhysicsComponent
{
public:
void update() { /* Work with and modify state... */ }
private:
// Rigid body, velocity, mass, etc. ...
};
class RenderComponent
{
public:
void render() { /* Work with and modify state... */ }
private:
// Mesh, textures, shaders, etc. ...
};
게임은 엔티티의 배열을 유지하고 있고 각 game loop 동안에 (한프레임 마다) 위 컴포넌트들을 호출해야 한다.
while (!gameOver)
{
// Process AI.
for (int i = 0; i < numEntities; i++)
{
entities[i]->ai()->update();
}
// Update physics.
for (int i = 0; i < numEntities; i++)
{
entities[i]->physics()->update();
}
// Draw to screen.
for (int i = 0; i < numEntities; i++)
{
entities[i]->render()->render();
}
// Other game loop machinery for timing...
}
위 코드가 캐시를 폭행하고 있다는 것을 볼 수 있다. 생각보다 더 폭행하고 있다.
게임 엔티티배열은 그것들의 포인터(주소)를 저장한다. 접근하면 cache miss를 날린다.
컴포넌트에 접근 할 때 cache miss를 날린다.
컴포넌트가 업데이트 할 때 cache miss를 날린다.
각 엔티티에서 위의 단계를 반복한다.
이 과정 중 메모리 관리자가 어떻게 메모리에 객체를 배치할지 알 수 없다. heap은 마구잡이로 구성될 가능성이 높다.
개선해 보자, 게임 엔티티 참조를 사용하는 이유는 엔티티의 다른 컴포넌트에 따라가 접근할 수 있다는 것이다. GameEntity 자체는 관심이 없다. 컴포넌트가 게임 루프가 신경쓰는 것이다.
저장 공간에 데이터를 별자리 마냥 퍼트리기 보단, 각 컴포넌트이 배열을 구성한다.
AIComponent* aiComponents =
new AIComponent[MAX_ENTITIES];
PhysicsComponent* physicsComponents =
new PhysicsComponent[MAX_ENTITIES];
RenderComponent* renderComponents =
new RenderComponent[MAX_ENTITIES];
while (!gameOver)
{
// Process AI.
for (int i = 0; i < numEntities; i++)
{
aiComponents[i].update();
}
// Update physics.
for (int i = 0; i < numEntities; i++)
{
physicsComponents[i].update();
}
// Draw to screen.
for (int i = 0; i < numEntities; i++)
{
renderComponents[i].render();
}
// Other game loop machinery for timing...
}
이 방법은 캡슐화를 많이 잃지 않는다. 그저 loop문 내에서 올바른 순서(캐시가 용이한 순서)로 처리되게 할 뿐이다.
그럼에도 컴포넌트는 적절하게 자신의 데이터와 메서드를 가지며 캡슐화 되어 있다.
GameEntity 역시 제거될 필요가 없다. 성능이 중요한 loop문에서 GameEntity를 피할 뿐이지, 다른 코드 베이스에서 GameEntity를 사용할 수 있다.
Packed data
파티클 시스템에 대해서 말해보자, 위에서와 같이 파티클을 크고 연속적인 배열로 만들었다. 이것을 manager 클래스로 래핑해본다.
class Particle
{
public:
void update() { /* Gravity, etc. ... */ }
// Position, velocity, etc. ...
};
class ParticleSystem
{
public:
ParticleSystem()
: numParticles_(0)
{}
void update();
private:
static const int MAX_PARTICLES = 100000;
int numParticles_;
Particle particles_[MAX_PARTICLES];
};
void ParticleSystem::update()
{
for (int i = 0; i < numParticles_; i++)
{
if (particles_[i].isActive())
{
particles_[i].update();
}
}
}
update()에서 플래그를 활용해 활성화 되지 않는 파티클은 건너뛴다. 활성화 되지 않은 파티클이 캐시에는 올라가게 된다.
%모든 파티클에 대한 if 확인은 branch misorediction 과 pipeline stall을 야기한다. 최신 cpu에서 명령을 처리하는 동안 몇 사이클이 소요된다. CPU를 부지런히 동작시키기 위해서 명령이 파이프라인이 되어 첫번째 명령이 끝나기 전에 subsequent을 시작한다. 그러기 위해 cpu는 어떤 명령이 다음에 수행될지 예측한다. 직선 코드는 쉽지만, control flow는 어렵다. if 명령어를 실행하는 동안, 다음에 어떤 명령어가 수행 될지 예측해야하기 때문이다. 이 방법을 위해 cpu는 branch predition을 한다. 이전에 실행된 코드를 보도 어떻게 다시 진행될 지 추측한다. 위 경우 플래그가 전환 될 때, 예측 실패가 된다. 그렇게 되면 cpu 예측했던 내용을 버리게 된다.
주어진 상황에서는 flag를 정렬하면 된다. 그러면 다음과 같이 적용할 수 있을 것이다.
for (int i = 0; i < numActive_; i++)
{
particles[i].update();
}
이게 퀵정렬을 모든 배열에 적용하라는 얘기는 아니다. 그러면 얻는 이득이 없어질 것이다. 우리가 원하는 것은 배열을 정렬된 상태로 유지하는 것이다.
void ParticleSystem::activateParticle(int index)
{
// Shouldn't already be active!
assert(index >= numActive_);
// Swap it with the first inactive particle
// right after the active ones.
Particle temp = particles_[numActive_];
particles_[numActive_] = particles_[index];
particles_[index] = temp;
// Now there's one more.
numActive_++;
}
void ParticleSystem::deactivateParticle(int index)
{
// Shouldn't already be inactive!
assert(index < numActive_);
// There's one fewer.
numActive_--;
// Swap it with the last active particle
// right before the inactive ones.
Particle temp = particles_[numActive_];
particles_[numActive_] = particles_[index];
particles_[index] = temp;
}
위와 같이 정렬된 상태를 유지하도록 코딩한다.
메모리에서 이리 저리 옮기는 알고리즘은, 메모리에서 포인터가 움직이는 것을 생각하면 더 값싼 비용을 치룬다.
%이런 경우 프로필을 만들어서 이런 결정을 작성해라.
파티클을 active state로 정렬하는 대신 배열 위치나 numActive로 관리 할 수 있다. 파티클 오브젝트는 작아져서 캐시에 더 많은 오브젝트를 밀어 넣을 수 있다.
여기선 이제 객체 지향이 사라졌다. Particle class는 더이상 자신의 상태를 다루지 못한다. 자신의 인덱스가 어디에 있는지 모르기 때문이다. 즉, 모든 코드는 파티클을 활성화하기 위해서는 파티클 시스템에 접근해야한다.
ParticleSystem과 Particle이 서로 단단히 결합된건 괜찮다. 두개의 물리적 클래스로 존재하는 하나의 단일 개념을 생각하기 때문이다. 파티클이 파티클 시스템 안에서만 의미있다는 관점을 받아드리라는 것이다.
Hot/cold splitting
이번엔 AI 컴포넌트를 예로 들어본다.
class AIComponent
{
public:
void update() { /* ... */ }
private:
Animation* animation_;
double energy_;
Vector goalPos_;
LootType drop_;
int minDrops_;
int maxDrops_;
double chanceOfDrop_;
};
개체가 죽었을 경우 전리품을 떨어뜨리기 위한 몇가지 멤버를 가진다고 하자. AI 컴포넌트를 update() 한다고 했을때, 이 내용들도 캐시에 올라가게 된다.
이럴때 사용하는 방법이다. 데이터 구조를 두 개로 나눈다. 하나는 hot 데이터, 매 프레임 필요한 데이터이고 다른 하나는 cold 데이터 그렇지 않은 데이터이다.
class AIComponent
{
public:
// Methods...
private:
Animation* animation_;
double energy_;
Vector goalPos_;
LootDrop* loot_;
};
class LootDrop
{
friend class AIComponent;
LootType drop_;
int minDrops_;
int maxDrops_;
double chanceOfDrop_;
};
그러면 cold 데이터를 가르키는 포인터를 제외하면 매프레임 필요한 데이터이다.
% hot, cold만 들어있는 두 배열을 만들어 동일 인덱스로 관리 할 수도 있다.
이 것이 모호해지는 순간은, 어떤것 hot, cold 데이터인지 모호해지는 경우이다. 특정 모드에서 매프레임 필요해지는 데이터 존재하는 순간이다. 또한 특정 경우 데이터 청크가 필요한 경우이다.
Design Decisions
이 패턴은 사고방식에 대한 것이다. 메모리 속에 데이터 배열에 대해 생각해야한다. 구체적인 디자인 공간은 열려있다. 전체 아키텍처가 데이터 지역성에 영향 받도록 할 수도 있다. 혹은 부분적으로 적용할 수도 있다.
How do you handle polymorphism?
※다형성
위 예제에서 subclassing과 virtual method를 피했다. 데이터 지역성을 위해 동질적인 객체로 이루어진 배열을 사용했기 때문이다. 그렇다면 다형성과 dynamic dispatch를 어떻게 조화시킬 수 있을까?
- 하지 않는다.
가장 간단한 방법은 subclassing을 하지 않는 것이다. 적어도 캐시 최적화를 한 부분에 대해서는 피한다. 어쩌피 subclassing 을 많이하는 문화에서 벗어나고 있다.
% subclassing 없이 다형성의 유연성을 최대한 유지하는 방법은 Type Object 패턴을 참조한다.
- 안전하고 쉽다. 어떤 클래스를 다루는지 정확히 알고 오브젝트는 동일한 사이즈이다.
- 빠르다. dynamic dispatch은 비용이 든다.
%절대적인 것은 없다. 언어에 따라 다르다.
- 유연성이 적다. 우리가 dynamic dispatch를 사용하는 이유는 그것이 오브젝트 간 동작을 다양하게 하는 강력한 도구이기 때문이다. 만약 고유의 렌더링, 움직임, 공격이 있는 다양한 엔티티를 만들고 싶을때, virtual method가 입증된 방법이다. 이 없이 switch 같은 것으로 구현하는 것은 빠르게 지저분해진다.
- 각 타입을 배열로 분리한다.
우리는 다형성을 사용하여 구체적인 타입을 알지 않아도 오브젝트의 동작을 호출할 수 있다. 다르게 말하자면, 혼합된 것들을 가지고 오브젝트가 동일한 메서드 호출에도 다양하게 동작하기를 바란다.
어째서 혼합된 상태에서 시작해야 할까? 대신 각 유형에 대한 컬렉션을 유지하면 어떨까?
- 오브젝트를 단단히 묶는다. 한 배열은 한 클래스이 오브젝트만 유지한다. 사이 빈 공간도, 다른 타입도 없다.
- staticlly dispatch를 할 수 있다. 다형성이 더 이상은 필요하지 않는다.
- 많은 컬렉션을 추적해야 한다. 만약 다양한 타입이 많다면 각각 배열의 오버해드와 복잡성이 귀찮을 수 있다.
- 모든 타입에 대해 알고 있어야 한다. 각 타입마다 배열을 유지하므로, 클래스 집합에서 분리될 수 없다. 다형성의 장점은 개방형이라는 것. #리스코프 치환의 장점을 뜻하는 것 같다. 그리고 이를 사용할 수 없는 방법이라는 것
- 포인터배열(참조배열)을 사용한다.
캐시에 대해 신경쓰지 않는 방법이다. 다형성 사용하면 된다.
- 유연하다
- 캐시에 덜 친화적이다.
How are game entities defined?
컴포넌트 패턴과 함께라면, 엔티티를 구성하는 컴포넌트에 대한 모든 연속된 배열을 얻게 된다. game loop는 이것들을 직접 순회하면서 게임 엔티티 오브젝틑 적게 중요해진다. 그러면서도 entity란 개념은 코드의 나머지 파트에서 유용하게 사용된다.
- 게임 엔티티 클래스가 컴포넌트에 대한 포인터를 가진 경우
첫번째 예제와 모습이 같다. 기본적인 OOP 솔루션이다. 구성요소가 포인터이기 때문에, 메모리에서 어떻게 조직되는지 상관쓰지 않는다.
- 연속된 배열에 컴포넌트을 저장할 수 있다. 게임 엔티티가 컴포넌트가 어디있는지 신경쓰지 않으므로 그것들을 조직해서 배열로 묶을 수 있다.
- 주어진 엔티티에서 컴포넌트을 얻기 쉽다.
- 메모리에서 컴포넌트 이동이 힘들다. 컴포넌트가 활성 유무가 있다면, 활성된 컴포넌트를 이동해 연속성을 유지하고 싶을 것이다. 엔티티의 포인터도 같이 갱신해야하며, 잘못하면 포인터가 깨진다
- 게임 엔티티가 컴포넌트의 id를 가지는 경우
컴포넌트를 조회 할 수 있는 id를 가지게 한다.
이 방식은 사용자에게 달려있다. 단순히 인덱스에 매치하거나 해시 맵을 만들 수도 있다.
- 더 복잡하다. id 시스템은 더 많은 작업을 필요로 한다.
- 더 느리다.
- 컴포넌트 매니져에 접근해야한다. 기본 아이디어는 컴포넌트를 조회할수 있는 추상화된 id를 가진다는 것이다. id로 컴포넌트를 조회할 클래스도 필요하다. 이 클래스는 배열을 랩핑한다.
컴포넌트 registry가 필요하다.
- 게임 엔티티 그 자체가 하나의 id이다.
이것은 게임 엔진이 사용하는 새로운 스타일이다. 엔티티 동작, 상태, 컴포넌트를 빼면 엔티티엔 별로 남은게 없다. 엔티티는 컴포넌트를 묶어둘 뿐이다. 컴포넌트가 엔티티를 정의한다고 말할 수 있다.
컴포넌트가 상호작용하기 때문에 중요하다.
알아챘겠지만, ID 뿐이다. 엔티티가 컴포넌트를 알게하는 것 보단, 컴포넌트가 엔티티를 알게 한다. 각 컴포넌트는 그들을 소유한 엔티티의 id를 가진다. 컴포넌트가 엔티티 id로 다른 컴포넌트를 찾을 수 있게하면, 상호통신 가능하다.
엔티티 클래스는 완전히 사라지고, 숫자를 둘러싼 랩퍼가 된다.
- 엔티티는 작다. 단일 값일 뿐이다.
- 엔티티는 비어있다. 물론 엔티티에서 모든 것을 뺐다는 것은 엔티티에 있는 모든 것을 빼내야 한다는 것이다. 더 이상 컴포넌트 특성이 아닌 상태나 동작을 가질 수 없다. 컴포넌트 패턴을 두배로 늘이게 된다.
- 더 이상 생명주기를 관리하지 않아도 된다. 엔티티는 밸류값이므로, 컴포넌트가 다 제거되어야 엔티티가 죽는다.
성능이 중요하다. 컴포넌트는 서로 종종 상호작용하는데 드래서 컴퍼넌트를 찾게 된다. id를 인덱스로 사용하는게 하나의 방법이다. 이 경우, 만약 모든 엔티티가 컴포넌트의 세트를 가지게 되면, 컴포넌트 배열들은 평행하다. (배열 내 동일한 인덱스를 가진다.)
이 배열들의 평행상태를 유지하도록 해야한다. 그러면 정렬하거나 다른 기준으로 감싸기가 어려워진다.
See Also
- 이 부분의 대부분은 컴포넌트 패턴과 관련된다. 컴포넌트 패턴은 캐시 사용에 가장 일반적인 데이터 구조이다. 그렇다고 컴포넌트 패턴에만 사용할 수 있다는 것은 아니다.
- "pitfalls of Object-Oriented Programming"은 게임 데이터 구조를 디자인하기 위해 가장 널리 읽힌 소개서이다.
- 항상 배열에 동일한 타입에 오브젝트을 사용한다. 오브젝트를 넣고 빼는데에 Object pool이 그에 관한 것이다
Dirty Flag
Intent
결과가 필요할 때까지 처리를 지연시켜 불필요한 작업을 피한다.
Motivation
대부분의 게임은 scene graph를 가진다. 세상 속에 있는 모든 오브젝트가 있는 큰 데이터 구조이다. 이 데이터를 사용해 어떻게 화면에 물체를 렌더링할지 결정한다.
간단하게 구성된다면, 그냥 오브젝트 리스트이다. 각 오브젝트는 모델, 프리미티브 그래픽, transform이 있다. transform은 오브젝트의 위치 밒 회전각도를 저장한다. 물체를 이동하거나 회전시킬때 이 값을 바꾼다. #유니티의 그거다.
%transform이 어떻게 저장되고 조작되는지는 여기서 다루지는 않지만, 간단히 요약하면 4x4 matrix이다. 두 행렬을 곱하여 두 행렬을 결합하는 하나의 행렬을 만들수 있다. #벡터와 선형대수학과 관련된 내용일 것이다.
scene graph가 아니라 scene bag를 가진다면 좀 더 쉬워진다. 하지만 대부분의 scene graph 는 계층적이다. 오브젝트는 부모 오브젝트의 위치와 방향을 기준으로 위치된다. 절대 좌표가 아니라 부모 오브젝트의 위치와 방향을 기준으로 좌표를 가지게 된다. # 유니티의 그것이고, 아마도 scene graph는 scene bag와 달리, 오브젝트 간 일련의 추가적인 계층이 존재하는 오브젝트 경우를 의미한는 것 같다.
만약 선박 위의 감시대 위에 해적 위에 앵무새가 앉아있다고 한다면, 앵무새는 해적을 기준으로, 해적은 감시대 기준으로, 감시대는 선박을 기준으로 위치하게 된다.
이렇게 선박~앵무새 간의 계층이 생기고, 선박 부모 오브젝트가 움직이면 자식 오브젝트도 움직인다. 만약 선박이 움직일때 앵무새의 위치를 일일히 수정한다면 골치아파진다.
하지만 각 로컬 좌표계로 위치시킨다면 화면을 렌더링할 때 필요한 절대좌표 값을 알아야한다.
Local and world transforms
위 과정은 간단하다 앵무새로부터 모든 계층적 부모 오브젝트의 좌표계 변환을 곱하면 된다. # 벡터, 선형대수학 관련
하지만 각 모델마다 행렬이 존재하며 매프레임마다, 즉 핫 라인마다 부모의 움직임에 재귀적으로 움직이는 자식의 이러한 연산을 하는 것은 성능 문제를 야기한다.
간단한 방법은 렌더링 과정에서 즉석에서 이러한 좌표변환을 수행하는 것이다. 좌표변환을 수행한 뒤 바로 화면에 그려넣는다. 하지만 이는 cpu 자원을 낭비하게 된다. 모든 오브젝트가 매 프레임 마다 움직이는 것은 아니기 때문이다.
Cached world transforms
쉬운 대안은 캐시하는 것이다. 각 객체에서 로컬 transform과 파생된 world transform를 저장한다. 렌더링 시에 미리 계산된 world transform를 사용한다. 오브젝트가 움직이지 않으면 캐시 값은 최신상태이다.
하지만 오브젝트가 움직인다면, 갱신해야한다. 이 부분에서 계층상태임을 잊지 말아야한다.
좌표 변환을 갱신하는 과정에서 중복된 계산을 실행할 수 있다. #한프레임에 서로다른 계층에 속한 여러 객체가 움직일 수도 있기 때문에
Deferred recalculation
local transform 변화과 world transform 갱신을 분리하여 해결할 수 있다. 위치 수정이 완료된 뒤에 한번만 갱신한다.
%소프트웨어 아키텍쳐가 얼마나 의도적인 미끄러짐을 설계하는지 흥미롭다.
이를 위해 플래그 값을 넣는다.
Dirty는 단순히 오래되었다는 의미지만, 굳이 Dirty란 단어를 썼다.
- 부모로 부터 재귀적인 로컬변환을 단일 계산으로 축소한다
- 움직이지 않으면 재계산하지 않는다.
- 오브젝트가 제거되어도 계산하지 않는다.
The pattern
시간에 따라 처리되는 기본 데이터가 있고 파생된 데이터비싼 처리 비용을 통해 결정된다. 더티 플래그는 파생된 데이터가 동기화에서 벗어났는지 추적한다. 기본 데이터가 변경되고, 갱신된 데이터가 필요해질 때, 플래그에 따라 재처리된다. 변경된 사항이 없으면 캐시된 데이터를 사용한다.
When to use it
이 책의 다른 패턴과는 다르게, 꽤 구체적인 문제만 해결한다. 대부분의 최적화처럼 성능 문제를 충분히 해결할 경우에만 패패턴을 적용해서 코드 복잡도를 해결한다.
더티 플래그는 계산과 동기화라는 두가지 작업에 적용된다. 각 경우 모두 파생데이터를 생성하는데 비용이 많이 들어간다.
위의 예에서는 계산에 비용이 많이 들고, 동기화는 떨어져 있는 네트워크 통신 및 머신과의 이동에 비용이 들어간다.
다음과 같은 요구 사항이 있다.
- 파생된 데이터의 사용보다 기본 데이터의 변경이 자주 일어난다.
기본 데이터가 변경 될 때마다 파생된 데이터가 사용된다면 이 패턴은 소용없다
- 점진적인 업데이트 방법에는 사용하기 어렵다.
해적선의 무게를 추적한다고 하자, 이 경우 더치 플래그를 사용하는 것보다 그때그때 누적 총합을 유지하는게 간단하다.
Keep in Mind
이 패턴이 어울린다고 생각하는 부분에도 몇 군데 불편한 점이 있을 수 있다.
There is a cost to deferring for too long
결과값이 필요해질 경우까지 무거운 작업을 지연시키는데, 종종 당장 필요하게 된다.
좌표계 계산의 경우 그래도 프레임 속도보다 빠르게 처리 할 수 있어 괜찮지만, 몇 몇 무거운 작업들의 경우 프레임 간격을 늘일 수 있다.
만약 무언가 잘못되면, 전체 작업이 무너진다. 몇몇 일부 상태를 지속되는 상태에 저장 사용하는 경우 발생한다. 메모장에 변경사항을 저장하기 전에 컴퓨터가 갑자기 꺼진 경우를 생각하면 된다. 몇몇 백그라운드 주기적 자동 저장은 너무 많은 작업을 잃게 하지 않으면서 파일 시스템과의 부담을 고려하는 방법이다.
%이러한 방식은 메모리 관리 시스템의 garbage 수집 전략을 반영한다. 참조 개수를 추적하는 방법은 더 이상 필요치 않을때 반환시키지만 참조 개수를 추적하는데에 비용이 든다. 간단한 수집기는 실제로 필요할 때까지 회수를 연기하지만, 힘 전체를 검색하기 때문에 프리징을 유발할 수 있다. 이 둘 사이에는 ref-counting 과 incremental GC 라는 복잡한 방법도 존재한다.
You have to maak sure to set the flag every time the state changes
파생된 데이터는 본질적으로 캐시이다. 캐시 데이터를 가진다는 것은 cache ivalidatio, 캐시 무효화라는 까다로운 측면을 다뤄야한다. 위 경우 기본 데이터가 변경 될때, 동기화에서 벗어났다는 의미의 플래그를 의미한다.
한 부분에서 동기화되지 않은 상태를 사용하면 사용자를 혼란케 하고 추적하기 힘든 버그를 유발한다. 따라서 캐시 무효화 플래그 변경이 빠지지는 않았는지 유의해야한다.
You have to keep the previous derived data in memory
갱신이 필요할 때 데이터를 계산한다는 것은 데이터를 저장해 놔야 된다는 것이다.
메모리를 소모해 시간적 이득을 보기 때문에, 실행 시간과 메모리 공간을 차지하는 것을 어떻게 조율할 것인지 생각해야한다.
%이와 반대로 압축을 사용하여 메모리 이득을 보는 방법도 있다.
Sample Code
긴 요구사항을 충족했다고 가정하고, 코드에서 어떻게 보여지는 지 확인하자. 앞선 좌표계 계산 예를 간단히 묘사해 최소한의 예제로 든다. 실제 행렬 계산은 어디에선가 구현되었다고 볼 수 있도록 캡슐화 하였다.
class Transform
{
public:
static Transform origin();
Transform combine(Transform& other);
};
필요한 것은 combine() 동작과 단위 행렬인 origin transform를 반환하는 origin()이 있다.# origin transform 은 절대좌표계를 구할 때 가져와지는 transform이다.
우리가 부모체인을 따라 로컬 transform를 결합하여 world transform를 얻을 수 있게 해준다.
이제 scene graph를 대충 스케치해본다.
class GraphNode
{
public:
GraphNode(Mesh* mesh)
: mesh_(mesh),
local_(Transform::origin())
{}
private:
Transform local_;
Mesh* mesh_;
GraphNode* children_[MAX_CHILDREN];
int numChildren_;
};
부모 기준의 좌표계에서 자신의 위치를 나타내는 locla과 자신의 자식들을 나타낸느 children를 가진다.
가장 최상위 노드는 세계 그 자체로 매쉬를 가지지 않는다.
GraphNode* graph_ = new GraphNode(NULL);
// Add children to root graph node...
# 유니티의 scene 그 자체라고 보면 된다.
scene graph 를 렌더링 하기 위해, 노드 트리를 탐색하고 접근 할 수 있어야 한다.
void renderMesh(Mesh* mesh, Transform transform);
구현은 생략하고 그러한 동작을 한다고 가정한다.
An unoptimized traversal
더렵힌다. 최적화 되지 않은 간단한 탐색을 한다. GrapgNode에 새로운 메서드를 추가한다.
void GraphNode::render(Transform parentWorld)
{
Transform world = local_.combine(parentWorld);
if (mesh_) renderMesh(mesh_, world);
for (int i = 0; i < numChildren_; i++)
{
children_[i]->render(world);
}
}
간단한 재귀적인 방법이다
Let's get dirty
더치 플래그를 추가한다.
class GraphNode
{
public:
GraphNode(Mesh* mesh)
: mesh_(mesh),
local_(Transform::origin()),
dirty_(true)
{}
// Other methods...
private:
Transform world_;
bool dirty_;
// Other fields...
};
동기화 전이라 true이다.
void GraphNode::setTransform(Transform local)
{
local_ = local;
dirty_ = true;
}
위 메서드를 통해 이동시킨다.
void GraphNode::render(Transform parentWorld, bool dirty)
{
dirty |= dirty_;
if (dirty)
{
world_ = local_.combine(parentWorld);
dirty_ = false;
}
if (mesh_) renderMesh(mesh_, world_);
for (int i = 0; i < numChildren_; i++)
{
children_[i]->render(world_, dirty);
}
}
부모가 바뀌면 부모 뿐만아니라 자식들도 갱신되야 한다. 하지만 자식 노드를 재귀적으로 설정을 바꾸는 것은 느리므로 대신 랜더 메서드에서 자식을 갱신하도록 만들 수있다
%여기서 if문이 행렬 연산보다 빠르다는 미묘한 가정이 있지만(대부분그렇게 생각할 테지만 ), CPU의 복잡한 동작으로 그것은 파이프에 크게 의존하고 있다고 알아야한다. Data locality를 참고하자.
이건 original naive implementation 과 유사하다. 계산하기 전에 플래그를 확인하고 계산한 뒤, 결과를
로컬 변수 대신 필드에 저장한다는 것이다. clean하면 건너뛴다.
dirty 변수는 영리하다. 부모가 더러우면 자식도 추적하게 한다.
자식의 dirty 플래그를 재귀적으로 표시할 필요가 없다. 그 대신 렌더링 할때 부모의 더티플래그를 전달한다.
Design Decisions
When is the dirty flag cleaned?
- 결과가 필요해질때
- 결과가 필요하지 않다면 계산을 피한다. 기본 데이터의 변경이 파생 데이터의 필요보다 자주 일어난다면, 유용하다
- 계산이 시간을 잡아먹는다면 멈춤(프리징)이 발생 할수 있다. 보통은 cpu가 빠르게 처리해서 문제되지 않지만 문제가 생기면 미리 작업해야한다.
- 잘 정의된 체크포인트에서
때때로, 지연된 처리를 실행하는게 자연스러운 시간이나 상황이 있을 수 있다. 게임이 저장되는 순간이나 기타 동기화 지점, 로딩 화면 및 컷씬에서 백그라운드로 .
- UX에 영향이 가지 않도록 한다. 이를 위해 게임에 몰입을 깨는 무언가를 자꾸 넣게될 수도 있다.
- 작업이 언제 일어나는지에 대한 통제력을 읽는다. 미세한 통제만 가지어 게임이 알아서 처리하도록 할 수 있다.
- 백그라운드에서
보통 첫번째 변경 시 고정 타이머를 시작해서 다음 타이머 실행 사이의 모든 변경사할을 처리한다.
%사용자의 입력과 프로그램의 동작사이 의도된 지연을 hysteresis라고 한다.
- 작업이 실행되는 주기를 조정할 수 있다. 타이머 간격을 조정하면 된다.
- 더 많은 중복 작업을 하게 된다. 타이머의 간격이 짧을 수록 변경되지 않은 작업을 해야한다.
- 비동기적 작업을 지원한다. 백그라운드 작업은 유저가 뭘 하든 동시에 동작된다는 것이다. 쓰레딩이나 동시성 자원이 필요하게 된다.
플레이어가 같은 상호작용을 하고 있는 경우 동시 수정이 가능한지도 고려해야 한다.
How fine-grained is your dirty tracking?
만약 해적선을 커스텀 한다고 하자, 배는 온라인으로 자동적으로 저장된다. 중단된 시점에서 다시 시작할 수 있다. 우리는 더티플래그를 사용해서 배의 부속물을 확인하고 서버로 전송한다. 서버는 배의 기본 사항과 약간 추가적인 메타데이터를 저장한다.
- 더 세분화
배에 작은 판자마다 더치 플래그를 만든다.
- 실제로 변경된 데이터만 처리된다.
- 더 거친 형태
어떤 부분을 바꾸면 전체 부분이 더티 플래그가 된다.
%이를 면봉으로 닦는다는 농담을 할 수도 있다.
- 바뀌지 않는 데이터를 처리하게 된다.
- 추가되는 메모리는 줄어든다. 10개의 배럴이 바뀌어도 하나의 플래그로 관리된다.
- 고정된 오버해드에 소모하는 시간이 줄어든다. 데이터가 바뀌면 몇가지 해야할 작업이 더 있다. 예를 들면 메타데이터 어디에서 변경되었는지 확인하는 작업이다. 처리해야할 청크가 클 수록 청크가 작아져거 오버헤드가 줄어든다 (?)
See also
- 물리 엔진도 물체가 움직였는지 정지했는지 추적한다
Obect Pool
#어느정도 축약
Intent
개별적으로 할당 및 해제하는 것보다 고정된 풀에서 재사용하여 성능과 메모리를 향상시킨다.
Motivation
파티클 시스템과 같이 개체가 빠르게 생성되야하고, 메모리 조각화를 유발하지 않게 해야한다.
The curse of fragmentation
휴대용 기기를 위한 프로그램은 여러 다양한 pc의 방법보다 임베디드 적이다. 메모리는 희귀하다, 유저는 게임이 안정적으로 구동되기를 바라지만, 효율적인 메모리 매니져가 사용가능하다고 할 수 없다. 이런 상황에서 메모리 조각화는 치명적이다.
조각화는 heap이 작은 조각으로 나뉜는 것을 의미한다. 크고 작은 데이터가 heap에 할당되고 해제되는 과정에서, heap의 빈공간이 하나의 큰 부분을 이루는 것이 아니라, 여러 작은 공간을 이루게 된다. 이건 heap의 빈공간이 충분하지만 그 빈공간보다 작은 데이터를 heap에 할당 시킬 수 없게 만든다.
비록 메모리 조각화는 드물게 발생해도 결국 게임을 망가뜨리게 된다.
% 데모모드에서 수일간 테스트하는 soak tests 라는게 있다,
The best of worlds
메모리 관리를 위해서 큰 메모리 청크를 미리 할당한다. 게임이 끝나기 전에 해제하지 않는다. 추가적으로 객페는 만들고 파괴할 수 있도록 한다.
object pool 이 이에 적합하다. 메모리의 큰 청크를 미리 할당하는 한편, 사용자에게 오브젝트의 생성과 해제를 자유롭게 할 수 있게 한다.
The pattern
pool 클래스는 재사용가능한 오브젝트의 배열이다.각 오브젝트는 살아있는 상태를 위해 is use 쿼리를 사용한다. 단순히 이 쿼리를 토글하는 것으로 오브젝트 생성과 해제를 대신한다.
When to use it
엔티티, 시각효과 및 소리 재생 같은 부분 등 게임에서 널리 사용된다.
- 오브젝트가 자주 생성되고 파괴된다.
- 오브젝트가 비슷한 크기이다.
- heap 할당은 느리고 메모리 조각화를 유발할 때
- 코드 베이스나 네트워크 연결 등 각 오브젝트는 캡슐화되어 획득하는게 비싸고 재사용가능 해야 할때
Keep in mind
garbage collector에 의존하며 그냥 할당 및 해제하는 것에 비해, 바이트를 어떻게 사용할 지에 대해 다뤄야한다.
The pool may waste memory on unnedded objects
오브젝트 풀의 크기는 게임의 필요에 따라 조정되어야 한다. 자원을 적절히 사용하여 다른 부분이 사용할 수 있게 한다.
Only a fixed number of objects can be active at any one time
풀에 만들어논 객체의 수는 한번에 생성하게 되는 객체의 수보다 커야한다. 하지만 사용가능한 객체수가 생성해야하는 객체보다 작을 때 어떻게 다룰지 생각해야한다.
- 완전히 배제한다. pool size를 조정하여 이러한 일이 없도록 만든다. 적 개체 혹은 게임 아이템의 경우 이 방법이 잘 먹히기도 한다. pool 슬롯 부족에 있어서 절대로 맞는 방법이란 존재하지 않는다. 이 방법은 특수한 상황을 고려하여 메모리를 크게 할당하게 되므로, 몇몇 특이 스테이지에서 풀 크기를 조정하는 것을 고려한다.
- 객체를 생성하지 않는다. 부족하면 안 만들면 된다. 몇몇 효과가 씹히는 것을 생각한다.
- 존재하는 객체를 죽인다. 이미 재생된 작은 소리를 끄고 새로운 음향효과를 재생한다. 효과의 사라짐이 효과의 부재보다 덜 눈에 띄는 경우 사용한다.
- 풀 크기를 늘인다. 메모리가 여유롭다면 크기를 키우면 된다. 혹여나 반환해야하는지 고려한다.
Memory size for each object is fixed
풀은 미리 오브젝트를 만들어 놓기 때문에, 주로 단일 타입의 크기를 맞춰놓는다. 만약 특정 클래스의 파생 클래스도 같이 지원시키고자 한다면, 사용하게 될 수도 있는 최대 크기로 맞춰야한다. 또한 예상치 못하게 큰 사이즈의 오브젝트는 메모리를 낭비하게 한다.
비슷하게 오브젝트 크기가 다양할 때, 맞춰논 크기보다 적게 사용하게 될 수도 있으므로 낭비가 발생한다. 이 정도가 심하면, 풀을 나눠서 객체 크기에 따라 적절히 사용하도록 한다. 큰 크기 풀, 작은 크기 풀
%이건 속도 효율적인 메모리 매니져를 만드는데에 일반적인 패턴이다.
Reused objects aren't automatically cleared
메모리 매니저가 관리해 주지 않기 때문에, 초기화를 까먹거나 반환이 누락되는 경우 예상치 못한 버그가 발생할 수 있다.
더불어서 몇몇 경우 모든 개체를 일일히 초기화하는 것은 시간을 잡아먹을 수 있다.
Unused objects will remain in memory
메모리 관리자도 메모리 조각화를 관리하기 때문에 오브젝트 풀은 덜 일반적이다. 하지만 할당과 재할당에 걸리는 비용을 피할 수 있다.
garbage collector과의 잠재적인 충돌을 주의한다.
Sample Code
실제 파티클 시스템은 중력, 바람, 마찰 등 물리효과를 반영한다. 여기서는 정말 간단하게 작성된 시스템이다.
class Particle
{
public:
Particle()
: framesLeft_(0) {}
void init(double x, double y,
double xVel, double yVel, int lifetime) { //init }
void animate() { //isUse() true 이면 생명주기동안 이동 }
bool inUse() const { return framesLeft_ > 0; }
private:
int framesLeft_; //생명주기
double x_, y_;
double xVel_, yVel_;
};
inUse를 통해 사용가능한 객체도 알 수 있다.
class ParticlePool
{
public:
void create(double x, double y,
double xVel, double yVel, int lifetime)
{
// Find an available particle.
for (int i = 0; i < POOL_SIZE; i++)
{
if (!particles_[i].inUse())
{
particles_[i].init(x, y, xVel, yVel, lifetime);
return;
}
}
}
void animate()
{
for (int i = 0; i < POOL_SIZE; i++)
{
particles_[i].animate();
}
}
private:
static const int POOL_SIZE = 100;
Particle particles_[POOL_SIZE];
};
creat()를 통해 외부코드가 객체를 받을 수 있게 한다. animate는 프레임 마다 호출되어 실행된다.
풀 크기는 하드코드 되어 있지만, 동적 배열을 사용하거나 외부에서 정의할 수 있다.
새 입자는 사용가능한 객체를 재사용하여 초기화한 뒤 반환된다.
여기서 배열을 탐색할 때 시간을 걸릴 수 있다는 것을 유의한다. 배열이 커지면 더 오랜 시간이 걸린다.
%O(n)시간 복잡도
A free list
탐색 시간을 줄이기 위한 명백한 방법은 찾지 않는 거다. 사용하지 않는 파티클 포인터리스트를 저장한다.
이 경우 전체 포인터리스트를 유지해야한다.
메모리를 희생하지 않고 사용하기 위해 사용하지 않는 파티클 데이터를 빌려쓴다. 파티클이 사용중이지 않다면 활성화 상태를 니티내는 값 외에는 필요없다.
class Particle
{
public:
// ...
Particle* getNext() const { return state_.next; }
void setNext(Particle* next) { state_.next = next; }
private:
int framesLeft_;
union
{
// State when it's in use.
struct
{
double x, y;
double xVel, yVel;
} live;
// State when it's available.
Particle* next;
} state_;
};
# c#에서 유니온이 되려나...?
%유니온은 메모리 최적화와 관련되어 이 담당이 아니라면 생소할 수 있다. 메모리 담당자는 보통 재밌는 트릭을 많이 알고 있다.
사용하지 않는 객체를 사용해 포인터로 연결된 체인을 만들 수 있다.
이건 free list 라고 불리는 영리한 방법이다. 이를 위해 포인터가 올바르게 초기화되고 유지되야한다.
class ParticlePool
{
public:
ParticlePool::ParticlePool()
{
// The first one is available.
firstAvailable_ = &particles_[0];
// Each particle points to the next.
for (int i = 0; i < POOL_SIZE - 1; i++)
{
particles_[i].setNext(&particles_[i + 1]);
}
// The last one terminates the list.
particles_[POOL_SIZE - 1].setNext(NULL);
}
// ...
private:
Particle* firstAvailable_;
};
% O(1)의 시간복잡도!
우리는 객체가 죽을때, 다시 free list 에 넣기 위해서 animate()가 true 를 반환하도록 해야한다.
bool Particle::animate()
{
if (!inUse()) return false;
framesLeft_--;
x_ += xVel_;
y_ += yVel_;
return framesLeft_ == 0;
}
void ParticlePool::animate()
{
for (int i = 0; i < POOL_SIZE; i++)
{
if (particles_[i].animate())
{
// Add this particle to the front of the list.
particles_[i].setNext(firstAvailable_);
firstAvailable_ = &particles_[i];
}
}
}
Design Decisions
간단한 객체풀 구현은 사소하다. 하지만 프로덕션코드는 그렇지 않다. 풀을 일반화하고 사용하기 안전하고 편하게 하기 위한 여러 방법이 있다.
Are objects coupled to the pool?
객체가 스스로 풀에 있는지 알고있는 경우이다. 대부분은 알고(결합되어)있지만 제네릭 풀 클래스의 경우 임의의 객체를 보관하기에 그럴 수 없다.
- 객체가 풀에 결합된다.
- 구현이 단순해진다. in use 플래스나 함수를 사용하여 사용하게 하면 된다.
- 객체가 풀에 의해서만 만들어지도록 보장한다. 단순한 방법은 친구 클래스로 만들어서 객체 생성자 접근를 제한한다.
class Particle
{
friend class ParticlePool;
private:
Particle()
: inUse_(false)
{}
bool inUse_;
};
class ParticlePool
{
Particle pool_[100];
};
이렇게 유저가 객체를 직접적으로 생성하지 못하게 한다.
- in use 플래그 사용을 피할 수 있다. 오브젝트의 활성화 여부를 알 수 있는 상태를 가지고 있다면 inUse()를 통해 true false를 반환하게 한다.
- 객체가 풀과 분리된 경우
- 어떤 타입도 풀 될 수 있다. 큰 이점이 있다. 풀에게서 분리하는 것으로 재사용가능한 재너릭 풀 클래스를 구현할 수 있다.
- in use 상태는 객체 밖에서 추적되야만 한다. 간단한 방법은 bool isUse 배열을 사용하는 것이다.
What is reponsible for initializiong the reused objects?
객체 재사용을 위해 새로운 상태로 재초기화 해야한다. 이 재초기화를 풀 혹은 밖에서 수행 할지 정해야한다.
- 풀이 재초기화를 수행한다.
- 풀은 그것의 객체를 완전히 캡슐화한다. 이를 통해 예기치 않은 객체 참조를 미연에 방지한다.
- 풀이 어떨게 객체를 초기화 될지 연관된다. 풀은 여러 초기화 방식을 제공한다.
- 코드의 밖에서 객체를 초기화 한다.
- 풀은 더 간단해진다. 여러 초기화 방식을 제공할 필요가 없다. 호출자는 초기화를 수행해야한다.
- 외부 코드는 새로운 객체를 반환받는것을 실패했을 경우를 다뤄야한다.
See Also
- Flyweight 과 많이 비슷하다. 둘다 재사용 가능한 객체를 유지한다. 재사용의 의미가 다르다. Flyweight는 객체를 여러 인스턴스가 공유하도록 한다. 단일 객체가 여러 곳에서 사용될 때 발생하는 메모리 낭비를 피한다.
- 이러한 방식은 Data Locality와 관련하여 CPU 캐시에 친화적이다.
spatial partition
Intent
객체의 위치를 사용하여 데이터 구조 내부에 객체를 효율적으로 위치시킨다.
Motivation
게임을 통해 다른 세상을 느끼도록하면서도, 여러 실제 세상의 물리나 실체를 공유하므로 픽셀로 이루어져도 실제라고 느끼게 된다.
이 가짜 세상의 한부분은 공간감각이다. 위치에 따라 객체를 두고 물리를 적용한다. 음향 효과의 경우 거리에 따라 크기를 다르게 한다.
그럼 어떤 물체가 주변에 있나? 이것에 대한 답이 필요하다. 각 프레임 마다 수행해야 하므로 성능이 목을 조른다.
Units on the field of battle
실시간 전략 게임에서 수백의 적들이 서로 싸우게 된다. 전사들은 검을 내려칠 주변의 어떤 적을 알아야한다.
어리석은 방법은 각 쌍마다 얼마나 가까운지 보는것이다.
void handleMelee(Unit* units[], int numUnits)
{
for (int a = 0; a < numUnits - 1; a++)
{
for (int b = a + 1; b < numUnits; b++)
{
if (units[a]->position() == units[b]->position())
{
handleAttack(units[a], units[b]);
}
}
}
}
이러한 이중 루프는 제어 불가이다.
& 비록 한 쌍에 대해서 서로 다시 재계산을 피하고 있지만, 이건 여전히 O(n^2) 시간복잡도를 가진다.
Drawing battle lines
2D 배틀 시뮬레이터를 생각해본다. 모든 유닛은 어떤 위치를 가진다. 하지만 같은 선상 위에 있어도 개체 배열에는 순서가 없다.
이 경우 쉬운 방법은 유닛의 위치에 따라 정렬하는 것이다. 그러면 binary search 를 적용하여 주변에 존재하는 적들을 선별 할 수 있다.
$binary search 는 O(log n) 복잡도를 가진다. 이를 모든 유닛에 대하여 실행하면 O(nlog n) 시간 복잡도를 가진다. pigeonhole sort 같은 방법을 사용하면 이마저도 O(n)으로 줄일 수 있다.
짚고 넘어갈 점은 명확하다. 데이터 구조를 위치와 연관시키면 더 빨라진다는 것이다.
이 패턴은 이것을 공간에 적용하는 방법을 다룬다.
The pattern
각 각 위치가 존재하는 객체 세트가 있다. 이것들을 spatial data structure 에 위치에 따라 조직한다. 이 데이터 구조는 객체가 주변에 있는지 효율적으로 조회할수 있게 한다. 오브젝트 위치가 바뀌면, spatial data structure를 업데이트 한다.
When to use it
라이브로 움직이는 게임 객체와 정적 아트와 게임 세계의 기하학를 모두 저장하는 일반적인 패턴이다. 정교한 게임은 여러 내용물에 따라 다양한 공간적 파티션을 갖는다.
이 패턴의 기본적인 요구사항은, 각 객체는 일종의 좌표를 가지며 위치를 충분히 조회해야 한다.
Keep in mind
공간 분할은 O(n), O(n^2) 작업을 좀더 관리할 수 있을 정도로 낮추기 위해 존재한다. n이 충분히 크면 가치가 있지만 n이 작다면 가치가 없다.
패턴은 객체를 위치를 통해 조직하는 것과 관련된다. 위치가 바뀌는 객체는 더 다루기 어렵다. 새로운 위치를 추적하기 위해 데이터 구조는 재조직해야한다. 코드 복잡도가 증가하고 cpu 자원이 소모되지만 그럴만한 가치가 있다.
%만약 해쉬 테이블의 key 객체가 자발적으로 변경된다고 해보자 왜 까다로운지 알 수 있다.
Sample code
패턴은 다양하다. 구현에서 부터 공간 분할 방법까지 말이다. 다른 패턴과는 달리,많은 바리에이션은 잘 문서화 되어 있다. 학회에서 잘 연구되었다. 대략적인 컨셉을 위해, 가장 간단한 예를 들겠다. a fixed grid
%이 챕터의 마지막에 게임에 가장 일반적인 공간 분할을 나열했다.
A sheet of graph paper
전장 필드에 정사각형 그리드를 새겨넣자. 유닛을 단일 배열에 저장하지 않고, 그리드의 셀 안에 넣는다. 각 셀은 셀 영역 안에 있는 유닛의 리스트를 저장한다.
전투를 다루기 위해, 같은 셀 안의 유닛을 고려한다. 전체 전장을 작은 전장으로 나누어서 상대하는 적의 개수를 줄인다.
A grid of linked units
우선, 간단한 unit class를 작성한다.
class Unit
{
friend class Grid;
public:
Unit(Grid* grid, double x, double y)
: grid_(grid),
x_(x),
y_(y)
{}
void move(double x, double y);
private:
double x_, y_;
Grid* grid_;
};
각 유닛을 grid 포인터를 가질 뿐만 아니라 friend class 선언도 했다. 유닛의 위치가 바뀌면, grid는 모든걸 업데이트하며 춤을 춰야 하기 때문이다.
class Grid
{
public:
Grid()
{
// Clear the grid.
for (int x = 0; x < NUM_CELLS; x++)
{
for (int y = 0; y < NUM_CELLS; y++)
{
cells_[x][y] = NULL;
}
}
}
static const int NUM_CELLS = 10;
static const int CELL_SIZE = 20;
private:
Unit* cells_[NUM_CELLS][NUM_CELLS];
};
셀은 유닛 리스트를 가지는데, 이를 위해 이중 링크드 리스트를 구성하겠다. Unit 포인터를 가지도록하고
class Unit
{
// Previous code...
private:
Unit* prev_;
Unit* next_;
};
유닛 클래스에 포인터를 추가한다.
%비록 이책에서는 built-in 된 라이브러리 사용을 피했지만, 설명하기 위함이므로 실제 사용에선 구현된 콜렉션을 사용하도혹 하자.
Entering the field of battle
유닛은 생성될 때, 그리드 안에 생성되야한다. 유닛 생성자에서 이것을 다룬다. 그리고 add() 메소드도 만든다.
Unit::Unit(Grid* grid, double x, double y)
: grid_(grid),
x_(x),
y_(y),
prev_(NULL),
next_(NULL)
{
grid_->add(this);
}
void Grid::add(Unit* unit)
{
// Determine which grid cell it's in.
int cellX = (int)(unit->x_ / Grid::CELL_SIZE);
int cellY = (int)(unit->y_ / Grid::CELL_SIZE);
// Add to the front of list for the cell it's in.
unit->prev_ = NULL;
unit->next_ = cells_[cellX][cellY];
cells_[cellX][cellY] = unit;
if (unit->next_ != NULL)
{
unit->next_->prev_ = unit;
}
}
A clash of sword
이제 모든 유닛은 셀에 있다. 공격을 시작한다.
void Grid::handleMelee()
{
for (int x = 0; x < NUM_CELLS; x++)
{
for (int y = 0; y < NUM_CELLS; y++)
{
handleCell(cells_[x][y]);
}
}
}
void Grid::handleCell(Unit* unit)
{
while (unit != NULL)
{
Unit* other = unit->next_;
while (other != NULL)
{
if (unit->x_ == other->x_ &&
unit->y_ == other->y_)
{
handleAttack(unit, other);
}
other = other->next_;
}
unit = unit->next_;
}
}
# 셀의 내의 유닛은 별도로 서로 향하여 움직이는 동작이 game loop를 통해 호출되어 있을 것이다.
셀마다의 링크드 리스트 포인터를 이용해 탐색한다는 점을 제외하곤 이건 전투를 다루는 어리석은 방법임을 명심해라.
다른점은 유닛은 전체 유닛에 대하여 비교하지 않고 가까울거라 판단되는 일부 유닛에 대하여 비교한다는 것이다.
%간단하게 분석하면 성능은 더 나빠보인다. 결국 삼중 중첩 루프문이기 때문이다. 다만 이중 중첩문에서 비교하는 대상이 확연이 줄었기 때문에, 외부루프 비용을 상쇄하기 충분하다. 하지만 sell이 작아지면, 외부루프는 문제가 된다.
Charging forward
성능적인 문제는 해결했다. 하지만 새로운 문제가 있다. 유닛이 셀에 갇혔다.
이를 해결하기 위해 유닛 이동에 대해 작업해야한다. 셀의 경계로 이동하면 기존 셀에서 제거하고 새로운 셀에 넣는다.
void Unit::move(double x, double y)
{
grid_->move(this, x, y);
}
void Grid::move(Unit* unit, double x, double y)
{
// See which cell it was in.
int oldCellX = (int)(unit->x_ / Grid::CELL_SIZE);
int oldCellY = (int)(unit->y_ / Grid::CELL_SIZE);
// See which cell it's moving to.
int cellX = (int)(x / Grid::CELL_SIZE);
int cellY = (int)(y / Grid::CELL_SIZE);
unit->x_ = x;
unit->y_ = y;
// If it didn't change cells, we're done.
if (oldCellX == cellX && oldCellY == cellY) return;
// Unlink it from the list of its old cell.
if (unit->prev_ != NULL)
{
unit->prev_->next_ = unit->next_;
}
if (unit->next_ != NULL)
{
unit->next_->prev_ = unit->prev_;
}
// If it's the head of a list, remove it.
if (cells_[oldCellX][oldCellY] == unit)
{
cells_[oldCellX][oldCellY] = unit->next_;
}
// Add it back to the grid at its new cell.
add(unit);
}
원리는 간단하다 경계에 접근하면, 셀을 이동시킨다. 그렇지 않다면 위치를 업데이트 한다.
At arm's length
간단하지만, 결점이 있다. 같은 셀 안으로 상호작용을 제한했기 때문에, 공격 범위를 적용하면 어색해진다.
사거리가 적용되면 다른 셀에 있는 유닛이 제일 가까울 경우도 있기에, 이웃 셀의 유닛도 같이 비교해야한다.
그러기 위해 handelcell() 를 분할한다.
void Grid::handleUnit(Unit* unit, Unit* other)
{
while (other != NULL)
{
if (distance(unit, other) < ATTACK_DISTANCE)
{
handleAttack(unit, other);
}
other = other->next_;
}
}
void Grid::handleCell(int x, int y)
{
Unit* unit = cells_[x][y];
while (unit != NULL)
{
// Handle other units in this cell.
handleUnit(unit, unit->next_);
// Also try the neighboring cells.
if (x > 0 && y > 0) handleUnit(unit, cells_[x - 1][y - 1]);
if (x > 0) handleUnit(unit, cells_[x - 1][y]);
if (y > 0) handleUnit(unit, cells_[x][y - 1]);
if (x > 0 && y < NUM_CELLS - 1) handleUnit(unit, cells_[x - 1][y + 1]);
unit = unit->next_;
}
}
유닛 뿐만아니라 약간 확장해서 셀의 좌표를 사용해 전달함을 확인한다.
%확인한 인접 셀이 십자 모양이 아닌 것도 확인한다.
오로지 절반의 이웃만 확인한 이유는(인접 셀은 총5개 임에도) 내부 루프는 현재 유닛 다음에 시작되기 때문이다. 동일 쌍이 두번 비교되는 것을 피한다.
절반만 봐도 충분하다.
공격 사정거리가 셀의 길이 보다 큰경우 더 많은 이웃 셀을 확인해야 한다.
Design Decisions
오로지 잘 알려진 공간 분할 데이터 구조는 비교적 적으며, 여기서 하나씩 살펴본다. 필수 특성 별로 구별했다. 저자의 바람은 쿼드 트리와 이진트리 ( BSP) 가 어떻게 작동하며, 어떻게, 왜 도움이 되는지 이해하길 바란다. 그래서 왜 다른 것보다 선택을 해야하는지 알기 바란다.
Is the partition hiearchical or flat?
위에서는 단일 평평한 셀로 공간을 분할 했다. 대조적으로 계층적 공간 분할 hierarchical spatial partitions 은 공간을 몇개의 공간으로 나누기도 한다. 만약 셀 내 최대 객체수가 넘어가면 분할된다. 이 과정은 재귀적으로 일어나 셀내 최대 객체수가 넘지 않도록 한다.
%이러한 숫자는 2, 4, 8 등 프로그래머에 친숙한 숫자로 이루어진다.
- 평평한 파티션의 경우 (계층적이지 않을 경우)
- 간단하다, 추론하기 쉽고 구현하기 간단하다.% 간단하다는 건 하나의 옵션이다.
- 메모리 사용이 일정하다. 새로운 오브젝트의 추가는 새로운 파티션이 필요로 하지 않다.(계층은 그렇지 않다)
- 객체가 위치를 바꿀때 업데이트가 빠르다.(계층은 여러 계층을 조정해야한다.)
- 계층적일 경우
- 빈 우주를 더 효과적으로 다룬다. 계층적이지 않을 경우 객체가 들어있지 않은 빈 셀을 가져야 한다. 계층적 공간 분할은 객체가 들어있지 않은 부분을 세분화 하지 않는다.
- 인구 밀도 있는 공간을 더 효율적으로 다룬다.
Does the partitioning depend on the set of objects?
예제에서는 그리드를 고정시키고 유닛을 셀에 끼웠다. 다른 방법은 객체의 실제 세트에 따라 경계를 설정하는 적응형이다.
요점은 각 영역이 비슷한 숫자의 객체를 가지게 하는 것이다. 위 예제에서 한 셀에 유닛이 몰려있는경우 이중 중첩문이 무거워진다.
- 분할이 개체와 독립적이다.
- 개채는 점진적으로 추가될 수 있다. 유닛을 추가하는건 맞는 셀에 넣을 뿐이다.
- 객체가 빠르게 움직이는 경우, 유닛이 움직이는건 어디선가 제거되고 추가된다는 것이다. 만약 분할 경계가 객체 세트에 대해 알아서 바뀌는 경우, 객체의 이동은 다른 여러 객체가 다른 셀로 변경될 수도 있다는 것이다.
%이는 정렬이진트리나 red-black 트리와 유사하다. 하나의 아이템이 추가되면 재정렬 되야한다.
- 비균형적이 될 수 있다. 객체가 고르게 분포되도록 할 수 없다.
- 객체 세트에 분할이 맞춰질 경우
BSP, k-d tree 같은 공간 분할은 세상을 재귀적으로 나눔으로써 대략적으로 같은 객체를 가지게 한다.
분할할 평면을 고를 때 각 면에 얼마나 객체가 들었는지 세어야한다.
- 분할이 균형적이다. 좋은 성능 뿐만아니라 안정적인 성능을 제공한다. 각 파티션이 같은 수의 객체를 가지므로 모든 조회가 동등한 시간이 걸린다고 보장할 수 있다. 안정적인 플레임 레이트가 필요할때, 안정성은 단순한 성능보다 중요하다.
- 한번에 객체 세트를 분할 하는 것이 더 효과적이다. 객체 세트가 바운더리에 영향을 주기 때문에, 모든 객체가 한번에 올라가는 것이 최고이다. 때문에 고정된 아트와 정적인 지오메트리에서 주로 사용되는 이유이다.
- 분할이 객체 독립적이지만 계층은 의존적일때
고정 분할과 적응형 분할의 뛰어난 특성을 가진 quadtrees가 있다.
% quadtree 는 2D 공간을, 3D 아날로그는 octree 이다. 그것은 크기를 가지며 8개의 큐브로 나눈다. 추가 차원을 제외하면 2D의 경우와 비슷하게 동작한다.
quadtree는 전체 공간이 하나의 파티션이라는 것 부터 시작한다. 객체가 한계점을 넘었을때, 작은 정사격형으로 나눈다. 사각형은 언제나 정사각형으로 경계는 언제나 공간을 절반으로 자른다.
각 네개의 사각형은 이러한 작업을 재귀적으로 실행한다.
개체수가 많은 사각형만 분할 하므로 개체 세트에 적응하지만 경계는 그렇지 않는다.
- 객체는 점진적으로 증가 될 수 있다. 새로운 객체의 추가는 올바른 사각형을 찾아 넣는것 뿐이다. 사각형이 나뉘는 것도 간단한 작업이다. 단일 객체의 추가는 한번의 분리보다 더많은 동작을 발생시키지 않는다.
- 객체는 빠르게 움직일 수 있다.
- 파티션은 균형적이다. 객체 수가 적을 수는 있어도 정해진 한계점보다 많을 수는 없다.
Are objects only stored in the partition?
공간 파티션은 공간으로 취급할 수도 있고, 보조 캐시로 간주할 수도 있다.
- 객체가 저장되는 공간일 뿐이라면
- 메모리 오베해드와 복잡도를 피한다. 이에 불구하고두개의 배열을 가지게 된다면 동기화를 유지해야한다.
- 객체를 위한 컬렉션이 있다면
- 모든 객체 탐색은 빨라진다. 문제의 객체가 살아있고 무엇을 처리해야 할때, 그것의 위치과 관련없이 모든 객체를 탐색해서 찾아야한다. 대부분의 셀은 비어있기에 낭비가 발생할 수 있다. 두번째 컬렉션이 객체들을 직접 가지고 있다면, 그렇지 않다.
See also
- 공간 분할 구조에 대해서 자세하게 설명하지 않았으므로 다음 단계로 이것들을 공부했으면 한다. 이름만 무섭지 직관적이다.
Gridm Quadtree, BSP, k-d tree, Bounding volume hieararchy - 각 공간 분할 구조는 기본적으로 1D 분할에서 부터 확장되었다.
그리드는 지속성있는 bucket sort이다.
BSPs, k-d tree, Bounding volume hieararchy 들은 이진 탐색 트리이다.
Quadtree 와 octree는 트리이다
'Study > Unity learn' 카테고리의 다른 글
Unity learn - Make a flag move with shadergraph (0) | 2024.05.03 |
---|---|
Unity learn - Creative Core (0) | 2024.04.29 |
Unity learn - Unity Junior Programmer (0) | 2024.04.09 |
Unity learn - Unity Essentials (0) | 2024.04.04 |
Unity learn - FPS Microgame Customize (0) | 2024.04.03 |