Skip to main content

[Game Math] Chapter 9. 게임 엔진: 콘텐츠를 만드는 기술

이득우의 게임 수학 책을 읽고 공부한 노트입니다.




게임 엔진의 구성 요소 #

  • 게임 엔진의 인터페이스는 게임 콘텐츠가 담기는 게임 공간을 설계하는 작업 공간과, 게임 데이터를 관리하는 작업 공간으로 나뉜다.

    공간 이름 하는 일
    씬(Scene) 또는
    레벨(Level)
    게임 공간 설계
    리소스(Resource) 또는
    애셋(Asset)
    데이터 관리
  • 게임엔진은 씬 데이터와 리소스 데이터를 결합해서 최종 게임 화면이 렌더링되로록 설계됐다.

씬과 리소스로 만들어지는 최종 화면의 렌더링 alt ><


씬의 구조 #

  • 게임 오브젝트(GameObject) 또는 액터(Actor)
    • 씬 안에서 콘텐츠를 구성하는 기본단위이다.
    • 크기, 회전, 위치로 구성된 트랜스폼(Transform)의 정보를 사용해서 관리한다.
    • 이 트랜스폼 정보에 모델링 행렬을 사용해서 렌더링할 물체의 정점을 변환한다.

모델링 행렬의 설계 #

  • 트랜스폼의 크기, 회전, 이동 데이터는 다음과 같다.

    분류 데이터
    크기 ($S$) 2차원 벡터 $(s_x, s_y)$
    회전 ($R$) 각 $\theta$
    이동 ($T$) 2차원 벡터 $(t_x, t_y)$
  • 위 데이터는 크기, 회전, 이동에 대한 세 가지 아핀 변환행렬에 대응된다.

$$S = \begin{bmatrix} a & 0 & 0 \\ 0 & b & 0 \\ 0 & 0 & 1 \end{bmatrix}$$ $$R = \begin{bmatrix} \cos\theta & -\sin\theta & 0 \\ \sin\theta & \cos\theta & 0 \\ 0 & 0 & 1 \end{bmatrix}$$ $$T = \begin{bmatrix} 1 & 0 & a\\ 0 & 1 & b \\ 0 & 0 & 1 \end{bmatrix}$$


  • 그렇다면 아핀 변환을 어떤 순서로 적용해야 할까?
  • 회전과 이동변환을 할 경우 아래처럼 순서에 따라서 다른 결과가 나온다. 이것은 회전 대신 크기변환을 해도 마찬가지이다. 따라서 이동변환은 가장 마지막에 해야하겠다.

회전변환과 이동변환 alt ><

  • 이동변환을 제외하면 크기변환과 회전변환이 남는다. 크기와 회전변환을 할 경우에도 순서에 따라서 다른 결과가 나온다. Chapter 7에서 보았듯이 회전변환은 물체의 형태를 그대로 보존해주는 강체 변환(Rigid Transform)이기 때문에 회전변환을 나중에 해서 형태를 유지하는 것이 사용자 입장에서 직관적인 변환이겠다.

크기변환과 회전변환 alt ><

  • 따라서 아핀 변환의 순서는 크기 → 회전 → 위치순서로 이뤄지며 행렬곱으로 나타내면 다음과 같다. (열 기준 행렬으로 설명한다. 따라서 변환의 순서는 오른쪽에서 왼쪽으로 진행된다.)

$$T \cdot R \cdot S$$

  • 이 3가지 변환을 수행하는 합성행렬인 모델링 행렬(Modeling matrix) 은 다음과 같다.

$$M = T \cdot R \cdot S = \begin{bmatrix} \cos\theta \cdot s_x & -\sin\theta \cdot s_y & t_x \\ \sin\theta \cdot s_x & \cos\theta \cdot s_y & t_y \\ 0 & 0 & 1 \end{bmatrix}$$


로컬 공간과 로컬 축 #

  • 로컬 공간(Local space)
    • Chapter 8에서 보았듯이, 하나의 물체를 표현하기 위해서 만들어진 메시 데이터는 자신만의 공간에서 물체를 구성하는 각 정점의 위치 정보가 저장된다. 이렇게 물체의 정보를 담기 위해 부여한 공간을 로컬 공간이라고 한다.
  • 월드 공간(World space)
    • 게임 콘텐츠는 단일 물체가 아니라 여러가지 물체를 모아서 하나의 배경을 만든다. 이럴 때 사용하는 새로운 공간을 월드 공간이라고 한다.

  • 예를 들어, 비행기 게임을 만든다고 하자. 그러면 월드 공간을 중심으로 설정된 비행기의 위치좌표가 필요하다. 또한 앞으로 나아가는 비행기의 방향을 조종하려면 월드 공간과 무관하게 비행기가 바라보는 방향 정보도 필요할 것이다.

월드 공간과 로컬 공간의 축 alt ><

  • 로컬 축(Local Axis)
    • 이렇게 물체를 기준으로 설정된 방향 정보를 로컬 축이라고 한다.
    • 로컬 축 정보는 로컬 공간의 기저벡터와 동일한 값을 가진다.
    • 게임 엔진은 월드의 $X$, $Y$, $Z$축과 구분하기 위해 각 로컬 축의 이름을 다음과 같이 부른다.
      로컬 축 로컬 축의 이름
      $+X$ 라이트벡터 (Right Vector)
      $+Y$ 업벡터 (Up Vector)
      $+Z$ 포워드벡터 (Forward Vector)

  • 트랜스폼의 이동과 크기 정보가 변경되더라도 게임 오브젝트가 보는 방향은 변함이 없기 때문에, 로컬 축 정보는 오직 회전 정보에만 영향을 받는다.
    • 게임 오브젝트에 새로운 회전 값 $\theta$가 설정되면 로컬 축을 구성하는 표준기저벡터도 이에 따라 변한다.
    • $e_1$이 변화된 값을 $\vec{r}$로, $e_2$가 변화된 값을 $\vec{u}$로 표현하면 그 값은 다음과 같다.

$$\vec{r} = (\cos\theta, \sin\theta)$$ $$\vec{u} = (-\sin\theta, \cos\theta)$$

  • 여기서 로컬축 벡터의 원소를 각각 $\vec{r} = (r_x, r_y)$, $\vec{u} = (u_x, u_y)$로 지정하면?
    • 아핀 회전 변환행렬은 삼각함수 대신 로컬 축 벡터를 사용해 생성할 수 있다. 따라서 모델링 행렬 역시 삼각함수 대신 로컬 축 정보를 사용해 계산할 수 있다.

$$M = T \cdot R \cdot S = \begin{bmatrix} r_x \cdot s_x & u_x \cdot s_y & t_x \\ r_y \cdot s_x & u_y \cdot s_y & t_y \\ 0 & 0 & 1 \end{bmatrix}$$

class TransformComponent
{
    // 회전하면 로컬 축을 갱신한다. 
    void SetRotation(float InDegree) { _Rotation = InDegree; Update(); }
    void AddRotation(float InDegree) { _Rotation += InDegree; Update(); }
    // ...
    
private:
    Vector2 _Position = Vector2::Zero;
    Vector2 _Rotation = 0.f;
    Vector2 _Scale = Vector2::One;

    Vector2 _Right = Vector2::UnityX;  // e1
    Vector2 _Up    = Vector2::UnityY;  // e2
};

// 열벡터를 사용해서 모델링 행렬을 만든다. 
FORCEINLINE Matrix3x3 TransformComponent::GetModelingMatrix() const
{
    return Matrix3x3(
        Vector3(_Scale.X * _Right.X, _Scale.X * _Right.Y, 0.f),
        Vector3(_ScaleY. * _Up.X,    _ScaleY. * _Up.Y,    0.f),
        Vector3(_Position.X,         _Position.Y,         1.f)
    );
}

// sin cos함수를 이용해서 현재 회전 정보에 맞는 로컬 축 벡터를 갱신한다. 
FORCEINLINE void TransformComponent::Update()
{
    float sin, cos;
    Math::GetSinCos(sin, cos, _Rotation); // 발생한 회전의 sin, cos 값을 얻는다. 
    
    _Right = Vector2(cos, sin);  // e1
    _Up    = Vector2(-sin, cos); // e2
}

리소스 관리 #

  • 월드 공간에 동일한 게임 오브젝트를 여러 개 배치한다고 하자.
    • 모델링 행렬을 사용해서 로컬 공간 정보를 월드 공간 정보로 변환하여 트랜스폼 정보를 설정해야할 것이다.

로컬 공간에서 월드 공간으로 alt ><

  • 여기서 각각의 게임 오브젝트들은 각자의 트랜스폼 정보를 포함하고 있다. 그렇다면 리소스 데이터는 어떨까?
    • 용량이 큰 메시 정보를 모두 각각 가지고 있다면 메모리 공간이 낭비될 것이다. 따라서 리소스 데이터는 여러 게임 오브젝트들이 함께 사용할 수 있는 공유 자원의 형태로 관리하는 것이 바람직하다.

  • 리소스 저장소(Resource repository)
    • 씬과 무관하게 별도로 리소스를 모아두는 저장소이다.
    • 게임 오브젝트는 리소스의 키 값만 저장하며, 이것을 통해서 리소스에 접근한다.

리소스 저장소의 구조 alt ><



게임 엔진의 워크플로우 #

  • 워크플로우(Workflow)
    • 실행의 흐름이다.
    • 게임 엔진의 워크플로우는 크게 씬을 완성하는 과정, 완성된 씬으로부터 화면을 그려내는 과정으로 나뉜다.

  • (저자가 제작한) 게임 엔진의 워크플로우
순서 단계 설명
1 리소스 로딩 단계 메시, 텍스처 같은 리소스는 게임을 진행하면서 불러들이기에는 데이터 양이 크기 때문에
게임 시작 전에 미리 불러들여서 메모리에 올려둬야 안정적으로 게임을 진행할 수 있다.
LoadResources()
2 씬 구축 단계 게임 오브젝트의 트랜스폼 정보가 설정되고, 리소스 정보가 게임 오브젝트에 연결된다.
LoadScene2D()
3 게임 로직 단계 프레임마다 게임 오브젝트의 트랜스폼 값을 변경한다.
Update2D()
4 렌더링 로직 단계 트랜스폼 정보와 리소스 데이터를 활용해서 최종화면을 그린다.
Render2D()

// 1. 리소스 로딩 단계

// 메시
const std::size_t GameEngine::QuadMesh = std::hash<std::string>()("SM_Quad");

// 텍스처
const std::size_t GameEngine::BaseTexture = std::hash<std::string>()("Base");
const std::string GameEngine::CharacterTexturePath("CKMan.png");


bool GameEngine::LoadResources()
{
    // 사각형 메시 데이터 객체를 생성하고 QuadMesh 키를 부여한다. 
    Mesh& quadMesh = CreateMesh(GameEngine::QuadMesh);

    constexpr float squareHalfSize = 0.5f;
    constexpr int vertexCount = 4;
    constexpr int triangleCount = 2;
    constexpr int indexCount = triangleCount * 3;

    auto& v = quadMesh.GetVertices(); // 정점 버퍼
    auto& i = quadMesh.GetIndices();  // 인덱스 버퍼
    auto& uv = quadMesh.GetUVs();     // UV 값

    v = {
        Vector2(-squareHalfSize, -squareHalfSize),
        Vector2(-squareHalfSize, squareHalfSize),
        Vector2(squareHalfSize, squareHalfSize),
        Vector2(squareHalfSize, -squareHalfSize)
    };

    uv = {
        Vector2(0.125f, 0.75f),
        Vector2(0.125f, 0.875f),
        Vector2(0.25f, 0.875f),
        Vector2(0.25f, 0.75f)
    };

    i = {
        0, 2, 1, 0, 3, 2
    };

    // 텍스처 객체를 생성하고 BaseTexture 키를 부여한다. 
    Texture& baseTexture = CreateTexture(GameEngine::BaseTexture, GameEngine::CharacterTexturePath);
    
    // ...
}
// 2. 씬 구축 단계

// 게임 오브젝트 목록
static const std::string PlayerGo("Player");

// 최초 씬 로딩을 담당하는 함수
void SoftRenderer::LoadScene2D()
{
    // 최초 씬 로딩에서 사용하는 모듈 내 주요 레퍼런스
    auto& g = Get2DGameEngine();

    // 플레이어의 생성과 설정
    constexpr float playerScale = 30.f;
    GameObject& goPlayer = g.CreateNewGameObject(PlayerGo);  // 게임 오브젝트를 생성한다. 
    goPlayer.SetMesh(GameEngine::QuadMesh);  // 메시 키를 설정한다. 
    goPlayer.GetTransform().SetScale(Vector2::One * playerScale);  // 최초 트랜스폼을 설정한다. 
    goPlayer.SetColor(LinearColor::Red);

    // 100개의 배경 게임 오브젝트 생성과 설정
    char name[64];
    constexpr float squareScale = 20.f;
    std::mt19937 generator(0);
    std::uniform_real_distribution<float> dist(-1000.f, 1000.f);
    for (int i = 0; i < 100; ++i)
    {
        std::snprintf(name, sizeof(name), "GameObject%d", i);
        GameObject& newGo = g.CreateNewGameObject(name);
        newGo.GetTransform().SetPosition(Vector2(dist(generator), dist(generator)));
        newGo.GetTransform().SetScale(Vector2::One * squareScale);
        newGo.SetMesh(GameEngine::QuadMesh);
        newGo.SetColor(LinearColor::Blue);
    }
}
// 3. 게임 로직 단계

// 게임 로직을 담당하는 함수
void SoftRenderer::Update2D(float InDeltaSeconds)
{
    // 게임 로직에서 사용하는 모듈 내 주요 레퍼런스
    auto& g = Get2DGameEngine();
    const InputManager& input = g.GetInputManager();

    // 게임 로직의 로컬 변수
    static float moveSpeed = 200.f;
    static float rotateSpeed = 180.f;
    static float scaleMin = 15.f;
    static float scaleMax = 30.f;
    static float scaleSpeed = 180.f;

    // 플레이어에 대한 주요 레퍼런스
    GameObject& goPlayer = g.GetGameObject(PlayerGo);
    TransformComponent& transform = goPlayer.GetTransform();

    // 입력에 따른 플레이어 위치와 크기의 변경
    float newScale = Math::Clamp(transform.GetScale().X + scaleSpeed * input.GetAxis(InputAxis::ZAxis) * InDeltaSeconds, scaleMin, scaleMax);
    transform.SetScale(Vector2::One * newScale);
    transform.AddRotation(input.GetAxis(InputAxis::XAxis) * rotateSpeed * InDeltaSeconds);
    transform.AddPosition(transform.GetLocalY() * input.GetAxis(InputAxis::YAxis) * moveSpeed * InDeltaSeconds);
}
// 4. 렌더링 로직 단계

// 렌더링 로직을 담당하는 함수
void SoftRenderer::Render2D()
{
    // 렌더링 로직에서 사용하는 모듈 내 주요 레퍼런스
    auto& r = GetRenderer();
    const auto& g = Get2DGameEngine();

    // 배경에 격자 그리기
    DrawGizmo2D();

    // 렌더링 로직의 로컬 변수
    size_t totalObjectCount = g.GetScene().size();

    // 씬을 구성하는 모든 게임 오브젝트의 순회
    for (auto it = g.SceneBegin(); it != g.SceneEnd(); ++it)
    {
        // 게임 오브젝트의 레퍼런스를 얻기
        const GameObject& gameObject = *(*it);
        if (!gameObject.HasMesh() || !gameObject.IsVisible())
        {
            continue;
        }

        // 렌더링에 필요한 게임 오브젝트의 주요 레퍼런스를 얻기
        const Mesh& mesh = g.GetMesh(gameObject.GetMeshKey());
        const TransformComponent& transform = gameObject.GetTransform();
        Matrix3x3 finalMatrix = transform.GetModelingMatrix();

        // 게임 오브젝트의 렌더링 수행 - 다음 절의 렌더링 파이프라인의 시작.
        DrawMesh2D(mesh, finalMatrix, gameObject.GetColor());

        // 플레이어의 정보를 화면에 출력
        if (gameObject == PlayerGo)
        {
            r.PushStatisticText("Player Position : " + transform.GetPosition().ToString());
            r.PushStatisticText("Player Rotation : " + std::to_string(transform.GetRotation()) + " (deg)");
            r.PushStatisticText("Player Scale : " + std::to_string(transform.GetScale().X));
        }
    }
}

렌더링 파이프라인 #

  • 렌더링 파이프라인(Rendering pipeline)
    • 게임 제작의 렌더링은 GPU가 처리한다. GPU 내부에 설정된 렌더링 워크플로우를 렌더링 파이프라인이라고 한다.

렌더링 파이프라인 alt &gt;&lt;
Image Source


  • 드로우 콜(Drawcall)
    • CPU가 GPU에게 렌더링 작업을 수행하도록 명령을 하는 것이다.
    • (저자가 제작한 게임 엔진에서는) DrawMesh2D()를 호출하는 것이 드로우 콜이라고 할 수 있겠다.

  • (저자가 제작한) 렌더링 파이프라인
순서 단계 설명
1 정점 변환과 처리 단계 메시에 설정된 정점의 데이터는 로컬 공간을 기준으로 설정되어 있다.
이것을 월드 공간 중심으로 변환하여 화면에 그려질 정점의 최종 위치 값을 구한다.
즉, 로컬 공간의 좌표에 모델링 행렬을 곱한다.
DrawMesh2D(), VertexShader2D()
2 픽셀화와 픽셀 처리 단계 Chapter 8에서 보았던 무게중심좌표를 활용해서 삼각형 영역의 픽셀을 추려내고, 색상을 결정한다.
해당 픽셀의 UV에 대응되는 색상을 가져온 후 필요하다면 추가로 조명 효과 등을 적용해서 최종 픽셀 색상을 설정한다.
DrawTriangle2D(), FragmentShader2D()

// 1. 정점 변환과 처리

// 메시를 그리는 함수
void SoftRenderer::DrawMesh2D(const class DD::Mesh& InMesh, const Matrix3x3& InMatrix, const LinearColor& InColor)
{
    // 메시의 구조를 파악하기 위한 로컬 변수
    size_t vertexCount = InMesh.GetVertices().size();
    size_t indexCount = InMesh.GetIndices().size();
    size_t triangleCount = indexCount / 3;

    // 메시 정보를 렌더러가 사용할 정점 버퍼와 인덱스 버퍼로 변환
    std::vector<Vertex2D> vertices(vertexCount);
    std::vector<size_t> indice(InMesh.GetIndices());
    for (size_t vi = 0; vi < vertexCount; ++vi)
    {
        vertices[vi].Position = InMesh.GetVertices()[vi];
        if (InMesh.HasColor())
        {
            vertices[vi].Color = InMesh.GetColors()[vi];
        }

        if (InMesh.HasUV())
        {
            vertices[vi].UV = InMesh.GetUVs()[vi];
        }
    }

    // 정점 변환 진행!!
    VertexShader2D(vertices, InMatrix);

    // 그리기모드 설정
    FillMode fm = FillMode::None;
    if (InMesh.HasColor())
    {
        fm |= FillMode::Color;
    }
    if (InMesh.HasUV())
    {
        fm |= FillMode::Texture;
    }

    // 메시를 삼각형으로 쪼개서 각각 그리기
    for (int ti = 0; ti < triangleCount; ++ti)
    {
        int bi0 = ti * 3, bi1 = ti * 3 + 1, bi2 = ti * 3 + 2;
        std::vector<Vertex2D> tvs = { vertices[indice[bi0]] , vertices[indice[bi1]] , vertices[indice[bi2]] };
        
        // 2. 픽셀화
        DrawTriangle2D(tvs, InColor, fm);
    }
}

  • 셰이더(Shader)
    • GPU의 렌더링 파이프라인의 몇몇 부분은 프로그래밍이 가능하다. 이런 부분을 개발자가 프로그래밍해서 만들어낸, 렌더링 효과를 계산하는 함수를 셰이더라고 한다.
  • 정점 셰이더(Vertex Shader)
    • 개발자들이 변환을 직접 설계해서 만든 정점 처리 함수이다.
// 정점 셰이더

FORCEINLINE void VertexShader2D(std::vector<Vertex2D>& InVertices, const Matrix3x3& InMatrix)
{
    // 위치 값에 최종 행렬을 적용해 변환
    for (Vertex2D& v : InVertices)
    {
        v.Position = InMatrix * v.Position;
    }
}



// 2. 픽셀화와 픽셀 처리

// 삼각형을 그리는 함수
void SoftRenderer::DrawTriangle2D(std::vector<DD::Vertex2D>& InVertices, const LinearColor& InColor, FillMode InFillMode)
{
    // 렌더링 로직에서 사용하는 모듈 내 주요 레퍼런스
    auto& r = GetRenderer();
    const GameEngine& g = Get2DGameEngine();
    const Texture& texture = g.GetTexture(GameEngine::BaseTexture);

    if (IsWireframeDrawing())
    {
        // 와이어 프레임 모드
    }
    else
    {
        // ... 삼각형 칠하기
        
        // 삼각형 영역 내 모든 점을 점검하고 색칠
        for (int x = lowerLeftPoint.X; x <= upperRightPoint.X; ++x)
        {
            for (int y = upperRightPoint.Y; y <= lowerLeftPoint.Y; ++y)
            {
                ScreenPoint fragment = ScreenPoint(x, y);
                Vector2 pointToTest = fragment.ToCartesianCoordinate(_ScreenSize);
                Vector2 w = pointToTest - InVertices[0].Position;
                float wdotu = w.Dot(u);
                float wdotv = w.Dot(v);

                float s = (wdotv * udotv - wdotu * vdotv) * invDenominator;
                float t = (wdotu * udotv - wdotv * udotu) * invDenominator;
                float oneMinusST = 1.f - s - t;
                if (((s >= 0.f) && (s <= 1.f)) && ((t >= 0.f) && (t <= 1.f)) && ((oneMinusST >= 0.f) && (oneMinusST <= 1.f)))
                {
                    Vector2 targetUV = InVertices[0].UV * oneMinusST + InVertices[1].UV * s + InVertices[2].UV * t;
                    
                    // 색상 결정 진행!!
                    r.DrawPoint(fragment, FragmentShader2D(texture.GetSample(targetUV), LinearColor::White));
                }
            }
        }
    }
}

  • 파편(Fragment)
    • GPU에서는 삼각형을 구성하는 픽셀을 파편이라고 한다.
  • 파편 셰이더(Fragment Shader) 혹은 픽셀 셰이더(Pixel Shader)
    • 개발자들이 직접 설계한, 최종 픽셀을 계산하는 함수이다.
// 픽셀 셰이더 (단순하게 흰색을 합성해서 고유의 색을 표현하도록 함)

FORCEINLINE LinearColor FragmentShader2D(LinearColor& InColor, const LinearColor& InColorParam)
{
    return InColor * InColorParam;
}



카메라 시스템 #

  • 게임 엔진에는 개발자 관점에서 바라보는 게임 공간과 게이머가 바라보는 게임 공간, 이 두 가지 화면을 제공한다.
  • 그렇다면 게이머를 위한 카메라는 월드 공간에 어떻게 설정할 수 있을까?

가상 공간의 카메라 #

  • 뷰포트(Viewport)
    • 카메라는 자신이 출력할 화면의 해상도 정보를 가지고 월드의 일부를 그려낸다. 이 때 카메라가 출력할 화면의 크기 정보를 뷰포트라고 한다.
    • 즉, 게이머가 보는 게임 공간의 크기가 바로 뷰포트다.
  • 뷰 공간(View space)
    • 카메라에 설정된 뷰포트 정보를 바탕으로 월드 공간의 일부분을 렌더링해야 한다. 이를 위해서는 카메라를 중심으로 물체의 트랜스폼을 재조정하는 작업이 필요하다.
    • 여기서 카메라를 중심으로 변환한 공간을 뷰 공간이라고 한다.

  • 로컬 공간, 월드 공간, 뷰 공간의 개념을 정리하면 다음과 같다.

로컬, 월드, 뷰 공간 alt &gt;&lt;


  • 카메라를 중심으로 전개되는 뷰 공간을 어떻게 설계할 수 있을까?
  • 예를 들어, 월드 공간에 카메라가 $(-10, -10)$에 있고, 어떤 물체가 $(10, 10)$에 있다고 하자. 그렇다면 카메라로부터 해당 게임 오브젝트의 상대적 위치는 $(20, 20)$이 된다.

카메라를 중심으로 해석한 게임 오브젝트의 최종 위치 alt &gt;&lt;

  • 어떻게 하면 그 물체의 월드 공간 좌표인 $(10, 10)$을 뷰 공간 좌표인 $(20, 20)$로 바꿀 수 있을까?

  • 뷰 행렬(View matrix)
    • 모델링 행렬이 로컬 공간의 좌표를 월드 공간의 좌표로 변환해준다면, 뷰 행렬은 월드 공간의 좌표를 뷰 공간의 좌표로 변환해준다.
    • 위 그림을 보면, 뷰 공간 좌표인 $(20, 20)$은 물체의 월드 공간 좌표에다가 카메라의 위치를 기준으로 측정한 월드 원점의 상대 좌표인 $(10, 10)$을 더하면 얻을 수 있다.
    • 그리고 그 상대 좌표는 카메라의 월드 공간 좌표를 반전시켜서 얻을 수 있다. 즉, 이동 행렬의 역행렬을 사용해서 계산하면 된다.

$$V = \begin{bmatrix} 1 & 0 & -t_x \\ 0 & 1 & -t_y \\ 0 & 0 & 1 \end{bmatrix}$$

  • 따라서, 로컬 공간 $v_{local}$을 뷰 공간 $v_{view}$으로 변환하는 과정은 다음과 같다.
    • $v_{view} = V \cdot M \cdot v_{local}$
    • 즉, 행렬 $VM$은 로컬 공간을 뷰 공간으로 변환한다.

  • 다음은 플레이어를 따라가는 카메라의 로직을 구현한 예제이다.
// 게임 로직을 담당하는 함수
void SoftRenderer::Update2D(float InDeltaSeconds)
{
    // 게임 로직에서 사용하는 모듈 내 주요 레퍼런스
    auto& g = Get2DGameEngine();
    const InputManager& input = g.GetInputManager();

    // 게임 로직의 로컬 변수
    static float moveSpeed = 200.f;
    static float rotateSpeed = 180.f;
    static float scaleMin = 15.f;
    static float scaleMax = 30.f;
    static float scaleSpeed = 180.f;
    static float minDistance = 1.f; // 플레이어와 카메라의 위치가 일치할 최소거리
    static float lerpSpeed = 2.f; // 플레이어가 카메라를 쫒아가는 속도

    // 플레이어에 대한 주요 레퍼런스
    GameObject& goPlayer = g.GetGameObject(PlayerGo);
    TransformComponent& transform = goPlayer.GetTransform();

    // 입력에 따른 플레이어 위치와 크기의 변경
    transform.AddPosition(Vector2(input.GetAxis(InputAxis::XAxis), input.GetAxis(InputAxis::YAxis)).GetNormalize() * moveSpeed * InDeltaSeconds);
    float newScale = Math::Clamp(transform.GetScale().X + scaleSpeed * input.GetAxis(InputAxis::ZAxis) * InDeltaSeconds, scaleMin, scaleMax);
    transform.SetScale(Vector2::One * newScale);
    transform.AddRotation(input.GetAxis(InputAxis::WAxis) * rotateSpeed * InDeltaSeconds);

    // 플레이어를 따라다니는 카메라의 트랜스폼
    TransformComponent& cameraTransform = g.GetMainCamera().GetTransform();
    Vector2 playerPos = transform.GetPosition();
    Vector2 cameraPos = cameraTransform.GetPosition();
    if ((playerPos - cameraPos).SizeSquared() < minDistance * minDistance)
    {
        // 최소거리보다 작으면 플레이어 위치로 간다. 
        cameraTransform.SetPosition(playerPos);
    }
    else // 최소거리보다 크면 서서히 이동한다. 
    {
        float ratio = Math::Clamp(lerpSpeed * InDeltaSeconds, 0.f, 1.f);
        Vector2 newCameraPos = cameraPos + (playerPos - cameraPos) * ratio;
        cameraTransform.SetPosition(newCameraPos);
    }
}

// 렌더링 로직을 담당하는 함수
void SoftRenderer::Render2D()
{
    // 렌더링 로직에서 사용하는 모듈 내 주요 레퍼런스
    auto& r = GetRenderer();
    const auto& g = Get2DGameEngine();
    const auto& texture = g.GetTexture(GameEngine::BaseTexture);

    // 배경에 격자 그리기
    DrawGizmo2D();

    // 렌더링 로직의 로컬 변수
    size_t totalObjectCount = g.GetScene().size();
    Matrix3x3 viewMatrix = g.GetMainCamera().GetViewMatrix(); // 뷰 행렬

    // 씬을 구성하는 모든 게임 오브젝트의 순회
    for (auto it = g.SceneBegin(); it != g.SceneEnd(); ++it)
    {
        // 게임 오브젝트의 레퍼런스를 얻기
        const GameObject& gameObject = *(*it);
        if (!gameObject.HasMesh() || !gameObject.IsVisible())
        {
            continue;
        }

        // 렌더링에 필요한 게임 오브젝트의 주요 레퍼런스를 얻기
        const Mesh& mesh = g.GetMesh(gameObject.GetMeshKey());
        const TransformComponent& transform = gameObject.GetTransform();
        Matrix3x3 finalMatrix = viewMatrix * transform.GetModelingMatrix(); // 뷰행렬 * 모델링 행렬으로 VM을 구한다 

        // 게임 오브젝트의 렌더링 수행
        DrawMesh2D(mesh, finalMatrix, gameObject.GetColor());
        
        // 플레이어의 정보를 화면에 출력
        if (gameObject == PlayerGo)
        {
            r.PushStatisticText("Player Position : " + transform.GetPosition().ToString());
            r.PushStatisticText("Player Rotation : " + std::to_string(transform.GetRotation()) + " (deg)");
            r.PushStatisticText("Player Scale : " + std::to_string(transform.GetScale().X));
        }
    }
}