cyphen156
게임 수학 9장 : 게임 엔진(콘텐츠를 만드는 기술) 본문
게임 엔진은 유니티와 엔진과 자체엔진 개발공부로 어느정도 학습해 나가고 있다고 생각한다.
이번 9장에서는 이득우 교수님의 CK소프트 렌더러라는 CPU BaseEngine에서 게임이라는 소프트웨어를 구성하기 위해 게임 엔진이 어떤 기능을 해야 하는지 배울 것이다.
우선 모든 게임 오브젝트가 기본적으로 가지고 있는 Transform에 대해서 생각해 보면
Position(T), Rotation(R), Scale(S)로 정의된다.
이 트랜스폼 정보들은 행렬 곱 연산을 통해 아핀 변환을 수행하기 때문에 교환 법칙이 성립하지 않아 순서가 가장 중요하다.
이 변환의 순서 조합은 6가지가 생기는데 다음과 같다.
- S * R * T
- S * T * R
- T * S * R
- T * R * S
- R * S * T
- R * T * S


이렇게 순서에 따라 결과가 달라지기 때문에 일반적인 경우 크기를 먼저 변환한뒤, 회전 변환을 진행한 뒤 마지막으로 이동 변환을 수행하는 것이 좋다. 이러한 변환과정을 합친 행렬을 모델링 행렬이라고 부른다.

월드 공간과 로컬 공간
월드 공간은 항상 불변하는 고정 좌표계이고, 로컬공간은 물체의 회전에 따라 좌표계의 축이 회전하는 것을 의미한다.
현실 세계에서 위도와 경도, 나를 기준으로 잰 학교 까지의 거리 등이 해당한다.
예를 들어 블로그 주인장의 모교인 협성대학교의 경우 월드 좌표계 기준으로는 위도(Latitude) : 37.21320350000001 / 경도(Longitude) : 126.9533768로 고정되어있다.
반대로 현재 블로그 주인장을 기준으로 협성대학교는 남서쪽으로 약 37KM정도 떨어져 있다. 또한 인천에 있는 내 친구를 기준으로는 협성대학교는 동남쪽으로 약 50KM정도 떨어져 있다.


이렇게 "객체"를 중심으로 세계를 바라보는 것을 로컬 공간이라 칭한다.
로컬 공간에서의 객체는 항상 정면만을 바라보고 있다고 가정하므로, 로컬 좌표계는 항상 객체의 회전 정보에만 영향을 받는다.
이렇게 월드 공간과 로컬 공간, 인스턴스의 트랜스폼 정보 등을 통해 게임 엔진은 개발자가 쉽게 게임을 구성할 수 있도록 도와준다.
그렇다면 게임에서 표현되는 수많은 애니메이션과 텍스쳐, 오디오와 같은 파일들은 어떻게 관리될까?
이에 대한 해답은 리소스 저장소를 통해 알 수 있다.
컴퓨터를 사용하는 사람이라면 누구든 폴더와 파일이라는 개념을 쉽게 알고 있을 것이다.
이렇게 필요한 데이터들을 체계적으로 관리하고 사용할 수 있도록 도와주는 것이 리소스 저장소와 리소스 매니저이다.


게임 엔진의 워크 플로우
모든 엔진이 정확하게 아래 흐름대로 실행의 흐름이 제어되는 것은 아니지만 전체적인 틀은 어느정도 유사하다.

여기서 더 자세히 설명하자면 전체적인 흐름을 구성하는 생명주기가 존재하고, 이 안에 각 구성마다의 개별 워크 플로우가 세분화 되어 존재하는데 이것을 파이프 라인이라고 부른다.
렌더링 파이프라인

요즘에는 일반적으로 GPU에게 물체의 정보를 전달하면 하드웨어 설정에 따라 렌더링을 수행해 주지만 CPU를 통해 직접 구현하는 방법도 있다.
CK 소프트 렌더러의 렌더링 파이프라인은 다음 과정을 거친다.

- 정점 변환 : 게임 오브젝트에 연결된 메시가 가지고 있는 모든 정점 정보를 변환하는 과정. 로컬 공간으로 설정된 데이터를 월드 공간에 맞춰 변환하는 과정을 수행한다.
- 정점 처리 : 정점 변환을 통해 처리된 각 정점들의 최종 위치값을 구하는 과정, 모델링 행렬에 로컬 공간의 좌표를 구하면 월드 좌표계로 변환된다.
- 픽셀화 : 메시를 구성하는 삼각형마다 픽셀화를 진행하는 과정
- 픽셀 처리 : 버텍스를 통해 색상 정보를 가져와 최종적으로 렌더링 될 색상을 구하고 조명 등, 여러 후 보정처리하는 과정
카메라와 뷰 공간
게임에는 월드 공간과 로컬 공간이라는 두 가지의 기준점이 있다고 하였다.
월드 공간을 모두 렌더링하게 되면 플레이어가 실제로 보지 않을 부분도 모두 컴퓨터가 연산하게 되어 처리량이 매우 많아지고 게임에서 가장 중요한 프레임이 떨어지게 되어 플레이어는 "렉"이라는 프리징 현상을 경험하게 된다.
이러한 것을 방지하기 위해 플레이어 주변 일정 부분만을 잘라내어(클리핑) 렌더링 하도록 하는 것을 뷰 공간이라 부르고, 이런 뷰 공간의 기준이 되는 것을 카메라라고 부른다.


이렇게 카메라 기준으로 물체를 바라보게 되면 월드 좌표에 있는 물체들과 카메라 사이의 상대 거리를 다시 계산해야 한다.
그리고 이 카메라 또한 월드 공간에서의 좌표를 가지고 있으므로, 원점으로부터 카메라 까지의 벡터와 렌더링 될 물체와 월드 공간에서의 벡터를 더하여 두 벡터의 덧셈 연산으로 뷰 공간에서의 좌표를 표현할 수 있으며 위치에 대한 역은 이동 역행렬로 계산한다.
따라서 이동행렬의 곱셈식으로 표현할 수 있다.

그런데 카메라는 위치 변환과 회전 변환만을 사용할 뿐 스케일 변환은 이용하지 않는다.
그 이유는 카메라는 실제로 사물을 바라보고있는 '나'에 대한 위치 정보와, '어떤 방향을 바라보고 있는지'에만 관심이 있기 때문이다.
그렇기 때문에 뷰 행렬은 최종적으로 다음과 같이 계산된다.

예제 9_1 카메라 구현해보기
Update2D()
///예제 9_1 카메라 구현해보기
static float minDistance = 1.f;
static float lerpSpeed = 2.f;
// 메인 카메라의 트랜스폼
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);
}
Render2D()
// 렌더링 로직을 담당하는 함수
void SoftRenderer::Render2D()
{
// 렌더링 로직에서 사용하는 모듈 내 주요 레퍼런스
auto& r = GetRenderer();
const auto& g = Get2DGameEngine();
// 배경에 격자 그리기
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();
// 게임 오브젝트의 렌더링 수행
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));
}
}
}
전체 코드는 다음과 같습니다.
#include "Precompiled.h"
#include "SoftRenderer.h"
#include <random>
using namespace CK::DD;
// 격자를 그리는 함수
void SoftRenderer::DrawGizmo2D()
{
auto& r = GetRenderer();
const auto& g = Get2DGameEngine();
// 그리드 색상
LinearColor gridColor(LinearColor(0.8f, 0.8f, 0.8f, 0.3f));
// 뷰의 영역 계산
Vector2 viewPos = g.GetMainCamera().GetTransform().GetPosition();
Vector2 extent = Vector2(_ScreenSize.X * 0.5f, _ScreenSize.Y * 0.5f);
// 좌측 하단에서부터 격자 그리기
int xGridCount = _ScreenSize.X / _Grid2DUnit;
int yGridCount = _ScreenSize.Y / _Grid2DUnit;
// 그리드가 시작되는 좌하단 좌표 값 계산
Vector2 minPos = viewPos - extent;
Vector2 minGridPos = Vector2(ceilf(minPos.X / (float)_Grid2DUnit), ceilf(minPos.Y / (float)_Grid2DUnit)) * (float)_Grid2DUnit;
ScreenPoint gridBottomLeft = ScreenPoint::ToScreenCoordinate(_ScreenSize, minGridPos - viewPos);
for (int ix = 0; ix < xGridCount; ++ix)
{
r.DrawFullVerticalLine(gridBottomLeft.X + ix * _Grid2DUnit, gridColor);
}
for (int iy = 0; iy < yGridCount; ++iy)
{
r.DrawFullHorizontalLine(gridBottomLeft.Y - iy * _Grid2DUnit, gridColor);
}
ScreenPoint worldOrigin = ScreenPoint::ToScreenCoordinate(_ScreenSize, -viewPos);
r.DrawFullHorizontalLine(worldOrigin.Y, LinearColor::Red);
r.DrawFullVerticalLine(worldOrigin.X, LinearColor::Green);
}
// 게임 오브젝트 목록
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);
}
}
// 게임 로직을 담당하는 함수
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);
///예제 9_1 카메라 구현해보기
static float minDistance = 1.f;
static float lerpSpeed = 2.f;
// 메인 카메라의 트랜스폼
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();
// 배경에 격자 그리기
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();
// 게임 오브젝트의 렌더링 수행
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));
}
}
}
// 메시를 그리는 함수
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]] };
DrawTriangle2D(tvs, InColor, fm);
}
}
// 삼각형을 그리는 함수
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())
{
// 와이어프레임으로 메시를 그리기
LinearColor finalColor = _WireframeColor;
if (InColor != LinearColor::Error)
{
finalColor = InColor;
}
r.DrawLine(InVertices[0].Position, InVertices[1].Position, finalColor);
r.DrawLine(InVertices[0].Position, InVertices[2].Position, finalColor);
r.DrawLine(InVertices[1].Position, InVertices[2].Position, finalColor);
}
else
{
// 삼각형 칠하기
// 삼각형의 영역 설정
Vector2 minPos(Math::Min3(InVertices[0].Position.X, InVertices[1].Position.X, InVertices[2].Position.X), Math::Min3(InVertices[0].Position.Y, InVertices[1].Position.Y, InVertices[2].Position.Y));
Vector2 maxPos(Math::Max3(InVertices[0].Position.X, InVertices[1].Position.X, InVertices[2].Position.X), Math::Max3(InVertices[0].Position.Y, InVertices[1].Position.Y, InVertices[2].Position.Y));
// 무게중심좌표를 위해 점을 벡터로 변환
Vector2 u = InVertices[1].Position - InVertices[0].Position;
Vector2 v = InVertices[2].Position - InVertices[0].Position;
// 공통 분모 값 ( uu * vv - uv * uv )
float udotv = u.Dot(v);
float vdotv = v.Dot(v);
float udotu = u.Dot(u);
float denominator = udotv * udotv - vdotv * udotu;
// 퇴화 삼각형이면 그리기 생략
if (denominator == 0.f)
{
return;
}
float invDenominator = 1.f / denominator;
ScreenPoint lowerLeftPoint = ScreenPoint::ToScreenCoordinate(_ScreenSize, minPos);
ScreenPoint upperRightPoint = ScreenPoint::ToScreenCoordinate(_ScreenSize, maxPos);
// 두 점이 화면 밖을 벗어나는 경우 클리핑 처리
lowerLeftPoint.X = Math::Max(0, lowerLeftPoint.X);
lowerLeftPoint.Y = Math::Min(_ScreenSize.Y, lowerLeftPoint.Y);
upperRightPoint.X = Math::Min(_ScreenSize.X, upperRightPoint.X);
upperRightPoint.Y = Math::Max(0, upperRightPoint.Y);
// 삼각형 영역 내 모든 점을 점검하고 색칠
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));
}
}
}
}
}
모든 예제 코드의 소스파일은
GitHub - onlybooks/gamemath: <이득우의 게임 수학> 공식 깃허브 페이지
GitHub - onlybooks/gamemath: <이득우의 게임 수학> 공식 깃허브 페이지
<이득우의 게임 수학> 공식 깃허브 페이지. Contribute to onlybooks/gamemath development by creating an account on GitHub.
github.com
또한 제 개인 깃허브 레포지토리 에 있습니다.
Workspace/C++/GameMath at main · cyphen156/Workspace · GitHub
Workspace/C++/GameMath at main · cyphen156/Workspace
Studying . Contribute to cyphen156/Workspace development by creating an account on GitHub.
github.com
※ 이득우 교수님의 인프런 게임수학강의를 참고하여 작성되었습니다.
'수학 > 게임수학' 카테고리의 다른 글
게임 수학 11장 : 외적(3차원 공간의 분석과 응용) (1) | 2025.04.02 |
---|---|
게임 수학 10장 : 3차원 공간(입체 공간의 생성)-3차원 트랜스폼과 오일러각 (1) | 2025.03.24 |
게임 수학 8장 : 삼각형(물체를 구성하는 가장 작은 단위)-메시와 텍스쳐 (0) | 2025.03.19 |
게임 수학 7장-2 : 내적(벡터 공간의 분석과 응용)-조명과 투영 (0) | 2025.03.17 |
게임 수학 7장-1 : 내적(벡터 공간의 분석과 응용)-벡터 내적과 시야 (0) | 2025.03.13 |