프로젝트 개요

  • 프로젝트 명: LastLight
  • 장르: 3D 로컬/멀티 협동 액션 플랫포머
  • 개발 기간: 2025.01.20 ~ 2025.02.10
  • 플레이 타임: 10~15분
  • 소개: 평화로웠던 물건 나라에 갑자기 나타난 어둠. 물건들은 영혼을 잃고 세계는 빛을 잃어 얼어붙기 시작한다.
     세상에 남은 유일한 빛인 양초형제는 세상에 빛을 전하기 위해 등대로 향하는데.... 빛이 꺼지지 않게 2명이서 협력하며 등대의 빛을 밝혀라!

개발 환경

  • 개발 인원: 10명 (프로그래머 4명, 아트 3명, 기획 3명)
  • 개발 언어: C++17
  • 개발 도구: Microsoft Visual Studio 2022
  • 사용 라이브러리: DX11, assimp, fmod, PhysX, Xinput, nlohmann, ImGui

개발 내용

  • 담당 업무: 게임 엔진 제작, 에디터 툴 제작, 리소스 시스

게임 엔진 제작

더보기

엔진 라이프 사이클 구성

- Tick: FixedUpdate 보다 먼저 업데이트가 필요한 경우가 생겨 만들게 되었다. 

- Render: Window에 SwapChain의 화면을 출력하기 위한 일련의 모든 과정이다. 이 과정에 Camera의 Draw()와 UIManager의 Draw()등이 포함된다.

- Camera::Draw(): Render()중에 호출되는 메소드로, 각 월드의 Camera 컴포넌트 마다 해당 장면을 SwapChain에 그려준다.

- UIManager::Draw(): Render()중에 호출되는 메소드로, UICanvas의 UI들을 마지막에 한번에 SwapChain에 그려준다.


※엔진 기본 씬 그래프 구성


World

- Object의 모음을 관리하는 객체이다.

- Object의 생성과 삭제 및 검색을 담당한다.

- World는 기본적으로 Active(활성화), Non-Active(비활성화), Persistance(유지)의 3가지 상태로 나뉜다.

  Active(활성화)상태는 하나의 World만 활성화될 수 있으며, 해당 World는 엔진의 라이프 사이클 업데이트를 돌게 된다.

  Persistance(유지)상태는 Active상태가 아님에도 해당 World의 라이프 사이클 업데이트를 돌게 해주고 싶을 때 활성화 해준다. 캐릭터나 HUD UI 등과 같은 요소는 스테이지가 넘어가도 파괴되거나 비활성화 되면 안되기에 만들게 되었다.

- World는 각자 자신의 World가 무슨 Resource가 필요한지 알고, 활성화되어 있을 시 해당 Resource를 로드한다.


Object

- Object는 컴포넌트의 모음을 관리하는 객체이다.

- Component의 추가 및 검색을 할 수 있다. 

- 중복 Component에 대한 상황이 생각보다 많아 중복 Component를 추가할 수 있게 만들었다.


Component

- Object에 소속되어 특정 역할을 수행하는 객체이다.

- 라이프 사이클마다 특정 역할을 수행하거나, 특정 정보를 지니는 역할을 한다.


Component의 종류는 여러 개지만, 대표적으로 다음이 있다.

Transform: Object의 위치 정보를 제공하고, 갱신하는 역할을 한다. 또한 Object간의 계층 구조를 이룰 수 있도록 해준다.

MeshRenderer / SkinnedMeshRenderer: Transform 컴포넌트를 통해 가져온 해당 위치에 Mesh를 렌더링하는 역할을 한다. 또한 Material을 적용시켜 질감을 표현할 수 있다.

Animator: Bone의 Transform 위치 정보를 애니메이션 프레임에 따라 바꿔주는 역할을 한다.

Camera: 윈도우의 특정 영역에 Transform의 위치 정보와 여러 속성을 통해 화면을 출력한다.



리소스 시스템

더보기

※리소스 시스템


Resource Handle

- 기존에는 Path를 Key값으로 동일하게 사용하려 했으나, 경로가 바뀔 경우에 각 Resource를 사용하는 모든 Object의 Key값을 바꿔줘야하는 경우가 발생하였다.

- 따라서 Key값과 Path를 같이 들고 있는 Handle을 만들어 관리하도록 변경하였다.

- Resource Manager는 Resource Handle과 실질적인 Resource를 매핑하여 관리한다.

- Resource는 Alloc과 Free상태로 나뉘며, Handle을 등록하면 기본적으로 Free상태이다.


에디터 툴 제작

더보기

3D는 3차원 공간이므로 에디터가 없다면 레벨배치가 힘들 것이라고 판단하여 에디터를 제작하였다.


※에디터 툴 구성

 

HierarchyView

- 월드의 속하는 오브젝트를 계층적으로 나열하여 보여주는 역할을 한다.

- 드래그&드랍 액션으로 오브젝트의 계층 구조를 변경하거나, 오른쪽 클릭을 통해 오브젝트를 삭제할 수 있다.


InspectorView

- Object, Resource, World 등의 모든 Entity들의 정보를 보여주거나 수정할 수 있게해주는 역할을 한다.


ResourceView

- 클라이언트가 쓴, 즉 등록된 모든 Resource를 보여주는 역할을 한다.

- Resource는 Handle을 통해 매핑하여 관리한다. Handle에는 Key와 Path 등이 포함된다.

- Alloc상태의 Resource는 초록색 탭, Free상태의 Resource는 빨간색 탭으로 보여준다.


 

애니메이션

기본적으로 유니티의 애니메이션은 다음으로 구성되어 있다.

Pose : 각 한 모션에 대한 애니메이션의 키 값(각 프레임별로 메쉬가 어느 위치에 있는지)들로 구성된 확장자.

pose구성도

Pose Controller : 각 Pose들을 FSM식으로 구성하여 하나의 모델 애니메이션 사이클을 나타내는 정보. 

Pose Controller 구성도

Animator Component : Pose Controller를 참조해 모션을 직접 컨트롤 하는 개체.

Animator Component 구성

 

'프로그래밍 > 게임 개발' 카테고리의 다른 글

ImGui NodeEditor 집중 탐구  (0) 2025.06.09
[게임제작] LastLight  (4) 2025.03.05
[WinAPI] 유피의 소원  (2) 2024.10.07
[연습작] 콘솔 오목게임  (0) 2024.04.03
[연습작] 던파 플래시 모작  (1) 2024.02.12

내가 inline키워드를 명시적으로 쓰는 타입의 프로그래머인데, 링커에러가 뜬다? 그러면 이 글을 보시길 바란다.

 

3D자체엔진을 제작하려고 3D리소스 래핑을 하는 도중에 inline에 대하여 엄청난 오용을 하고 있었다는 사실을 깨달았다.

// Material.h
// FBX를 로드할 때 가져오는 정보. 
class MaterialResource : public IGraphicsResource
{
	using MaterialMapArray = std::array<std::shared_ptr<Texture>, static_cast<UINT>(eMaterialMapType::SIZE)>;
public:
	explicit MaterialResource(std::wstring_view _name);
	virtual ~MaterialResource();
public:
	inline void SetMaterialMap(eMaterialMapType _mapType, std::shared_ptr<Texture> _pTexture);
	inline std::shared_ptr<Texture> GetMaterialMap(eMaterialMapType _mapType);
private:
	MaterialMapArray mMaterialMaps;
};

// Material.cpp
MaterialResource::MaterialResource(std::wstring_view _name)
	: IGraphicsResource(_name)
{
}
MaterialResource::~MaterialResource()
{
}
inline void MaterialResource::SetMaterialMap(eMaterialMapType _mapType, std::shared_ptr<Texture> _pTexture)
{
	mMaterialMaps[static_cast<UINT>(_mapType)] = _pTexture;
}
inline std::shared_ptr<Texture> MaterialResource::GetMaterialMap(eMaterialMapType _mapType)
{
	return mMaterialMaps[static_cast<UINT>(_mapType)];
}

이런 클래스가 있다.

이 클래스는 일단 SetMaterialMap() 함수를 사용하면 에러를 이렇게 낸다.

개같은...링커에러

그렇다. 그 악명높은 링커에러가 뜬다.

근데 정의와 구현부도 제대로 되어있고, 헤더가 꼬인거도 아니고....당연하게도 main문은 있다.

애초에 SetMaterialMap() 함수를 사용한 코드에서 나는 에러다. 해당 코드를 지우면 제대로 빌드가 된다.

 

뭐가 문제임? 하는 사람도 있을거고,

왜 이따구로 짬? 하는 사람도 있을거다...

일단 확실한건, 대부분은 "뭐가 문제임?" 혹은 "inline이 뭐임?" 하는 반응이 대부분일 것이다.

내가 아는 inline의 역할은 컴파일 단계에서 함수 호출을 없애고, 호출 위치에 함수의 코드를 그대로 붙여넣어 함수 호출비용을 줄이는 것으로 알고있었다.

물론 이것도 맞다. 하지만 보조적으로 한 가지 역할이 더 있다.

그건 바로 여러 파일에서 #include한 함수가 중복 정의되어도 괜찮다고 링커에게 알려주는 역할이라는 것이다......

약간 처음 들으면 애매할 수 있는데, 이해하고 보면 당연한 말이다.

이해를 돕기 위해 gpt쌤의 말을 가져오면,

더보기

inline을 쓰면 헤더 파일에서 구현해야 하는 이유는 컴파일러와 링커의 작동 방식 때문입니다. 이를 차근차근 설명드릴게요.


1. 컴파일러는 각 번역 단위에서 따로 작동

  • C++에서 코드를 컴파일할 때 **각 소스 파일(.cpp)**은 독립적인 "번역 단위"로 처리됩니다.
  • inline 함수는 컴파일러에게 "이 함수는 어디에서든 중복 정의될 수 있지만, 중복되어도 괜찮다"는 뜻을 전달합니다.

중요: inline 함수는 중복 정의를 허용하지만, 컴파일러는 이를 보장하기 위해 번역 단위마다 해당 함수의 정의를 알고 있어야 합니다.


2. 헤더 파일은 모든 번역 단위에 포함됨

  • 헤더 파일에 inline 함수를 정의하면 이를 포함하는 모든 번역 단위에서 동일한 함수 정의가 생깁니다.
  • 이 정의가 동일하다면, 링커는 이를 하나로 합쳐 문제가 없게 처리합니다.

예제:

cpp
코드 복사
// header.h inline int Add(int a, int b) { return a + b; }
  • 이 코드가 포함된 각 .cpp 파일마다 Add 함수의 정의가 복사됩니다.
  • 링커는 "이건 모두 동일한 함수다!"라고 인식하고 하나로 합칩니다.

3. CPP 파일에 정의하면 발생하는 문제

  • inline 함수는 헤더 파일에 정의되지 않으면 다른 번역 단위에서 해당 정의를 볼 수 없습니다.
  • 링커는 다른 번역 단위에서 함수의 정의를 찾을 수 없고, "이 기호를 찾을 수 없다"는 에러를 발생시킵니다.

문제 상황:

cpp
코드 복사
// header.h inline int Add(int a, int b); // 선언만 있고 구현 없음 // source.cpp #include "header.h" int Add(int a, int b) { // 구현이 cpp 파일에만 존재 return a + b; }
  • 링커는 Add 함수가 헤더에 정의되어 있어야 한다고 가정하지만, 실제 정의는 CPP에 있으므로 충돌합니다.

4. inline의 의미와 동작

  • inline의 의미: 함수가 호출될 때 **코드 삽입(inline expansion)**으로 대체될 수 있다.
  • 컴파일러가 함수의 코드를 호출자 위치에 직접 삽입하려면, 함수의 **정의(body)**를 컴파일 시점에 알아야 합니다.
  • 따라서 inline 함수는 반드시 모든 번역 단위에서 동일한 정의를 볼 수 있도록 헤더 파일에 구현해야 합니다.

5. 중복 정의를 피하기 위한 설계

  • 일반적인 함수는 여러 번 정의되면 링커가 에러를 발생시킵니다(One Definition Rule 위반).
  • 그러나 inline 함수는 특별히 "같은 정의라면 여러 번 있어도 괜찮다"는 규칙이 있습니다.
  • 이 규칙을 제대로 활용하려면, 모든 번역 단위에서 동일한 정의가 필요하므로 헤더 파일에 구현해야 합니다.

요약: 헤더에 구현해야 하는 이유

  1. 모든 번역 단위에서 동일한 함수 정의를 보장하기 위해.
  2. 함수 호출 위치에 직접 코드 삽입하려면, 컴파일러가 함수의 정의를 알고 있어야 하기 때문에.
  3. 중복 정의로 인한 링킹 에러를 방지하기 위해.

따라서, inline 함수는 헤더 파일에 정의를 작성하고, CPP 파일에서는 구현을 배제해야 안정적으로 동작합니다! 😊

따라서, 쉽게 말하면 템플릿이랑 컴파일과정이 비슷하다고 볼 수 있습니다.

템플릿도 헤더에 구현을 하지 않으면 에러가 뜨므로.....

 

그래서 이렇게 바꾸니까 되었습니다.

class Texture;

// FBX를 로드할 때 가져오는 정보. 
class MaterialResource : public IGraphicsResource
{
	using MaterialMapArray = std::array<std::shared_ptr<Texture>, static_cast<UINT>(eMaterialMapType::SIZE)>;
public:
	explicit MaterialResource(std::wstring_view _name);
	virtual ~MaterialResource();
public:
	inline void SetMaterialMap(eMaterialMapType _mapType, std::shared_ptr<Texture> _pTexture) {
		mMaterialMaps[static_cast<UINT>(_mapType)] = _pTexture; 
	}
	inline std::shared_ptr<Texture> GetMaterialMap(eMaterialMapType _mapType) { 
		return mMaterialMaps[static_cast<UINT>(_mapType)];
	}
private:
	MaterialMapArray mMaterialMaps;
};
MaterialResource::MaterialResource(std::wstring_view _name)
	: IGraphicsResource(_name)
{
}
MaterialResource::~MaterialResource()
{
}
MaterialState::MaterialState(MaterialResource* _pMaterialResource)
	: mMaterialResource(_pMaterialResource)
{
	for (int mapType = 0; mapType < static_cast<UINT>(eMaterialMapType::SIZE); ++mapType)
	{
		mCBuffer.UseMap[mapType] = TRUE;
	}
}

cpp에 구현을 없애고 h에 구현을 했습니다.

 

근데 걍 느낀게....

처음에 inlline 굳이 쓴건, 어차피 컴파일러가 자동으로 inline화를 판단하지만, inline키워드를 씀으로써 const키워드처럼 필요없지만 (물론 const는 항상 필요없는건 아니다) 명시적으로 넣어주는 습관을 가지려고 쓴건데... 이렇게 복잡한 키워드인지 몰랐다.

하...걍 inline써도 컴파일러가 inline화 무시할 때도 있고, inline안써도 컴파일러가 알아서 inline화 시킬 때도 있는데,
걍 쓰지 맙시다.....

 

+ Recent posts