개발일지/소프트 렌더러

소프트웨어 렌더러 만들기 - 2 (Vector2 클래스 및 프로젝트 설정)

hwi.middle 2023. 10. 26. 19:49

여기까지의 작업내용: https://github.com/hwi-middle/HimchanSoftwareRenderer/tree/0ebcba6de8f053600a872c3044c2cca371b7b58d

 

GitHub - hwi-middle/HimchanSoftwareRenderer

Contribute to hwi-middle/HimchanSoftwareRenderer development by creating an account on GitHub.

github.com

 

Vector2 클래스 구현

이번에는 2차원 벡터인 Vector2 클래스를 구현했다. 이 간단한 클래스 하나 구현 하는데에도 많은 걸 배웠는데, 배운 것을 블로그에 기록하기로 다짐해서 참 다행이라고 느꼈다. 특히 언리얼 엔진의 코드를 들여다보는 재미가 있었다.

 

클래스 구현 자체는 간단하지만, 내가 고민했던 것과 새롭게 배운 내용들을 한 가득 쓸 수 있을 것 같다. 이 프로젝트를 시작하길 잘했다.

 

VisualStudio 프로젝트 세팅

현재 나의 VS 솔루션은 3개의 프로젝트로 구성되어있다.

  • HCCore: 타입 정의 같은 것들
  • HCMath: 수학 모듈
  • HimchanSoftwareRenderer: 실제 렌더러

HCCore와 HCMath는 라이브러리이기 때문에 Configuration Type을 Static library로 지정했다.

HimchanSoftwareRenderer에서는 Additional Dependencies에서 위 두 프로젝트의 결과물을 가져오도록 했다.

.lib 파일이 생성되지 않는 이슈

HCCore에서 .lib 파일이 생성되지 않는 이슈가 있었다. 내가 Visual Studio의 설정값들을 잘못 만진 줄 알고 git에서 discard도 해보고 재실행도 해보고 Clean도 해보고 온갖 방법을 동원해도 해결되지 않았다.

 

https://stackoverflow.com/questions/34796246/visual-studio-not-generating-any-files-to-output-path

 

Visual Studio not generating any files to output path

When building a solution in VS none of the projects generate dll files even though build is successful. Also build is very fast, kind of scanning project names and outputting them in Output window....

stackoverflow.com

스택오버플로에서 답을 찾았는데, .vcxproj 파일에서 ClInclude를 Clcompile로 바꿔보라는 것이었다.

텍스트 에디터로 열어서 이 부분을 수정했더니 정상적으로 .lib 파일이 나왔다. 살펴보니 다른 모듈의 .vcxproj에는 ClInclude로 적혀있어도 잘 작동하고 있어서 다시 ClInclude로 바꿔놓았다.

FORCEINLINE

#define FORCEINLINE __forceinline

MSVC에서는 비표준(Windows 전용) 키워드나 매크로 같은 것들을 __로 시작하게 해두는데 꽤 불편하다. 그래서 언리얼 엔진에서 처럼 FORCEINLINE이라는 키워드로 define 해두었다.

타입 재정의

typedef __int8 int8;
typedef __int16 int16;
typedef __int32 int32;
typedef __int64 int64;
typedef unsigned __int8 uint8;
typedef unsigned __int16 uint16;
typedef unsigned __int32 uint32;
typedef unsigned __int64 uint64;

이것도 언리얼 엔진에서 크기를 특정해서 integer를 사용했던 경험을 떠올려서 만들어보았다.

 

멤버 변수 구성

static constexpr uint8 Dimension = 2;

union
{
    struct
    {
        float X;
        float Y;
    };

    std::array<float, Dimension> Components = { 0.f, 0.f };
};

최종적으로 멤버 구성은 이렇게 했다. 공용체를 쓸 생각은 못해봤고 그냥 X랑 Y만 넣을 심산이었는데, (이득우 교수님의) CKSoftRenderer와 언리얼 엔진 코드를 참고해보니 이런 형태로 구현되어 있어서 가져와봤다.

 

공용체라고 하는게 뭔지도 알고 이런 경우에 사용된다는 점도 알고 있었는데 막상 직접 코딩하다보면 '공용체를 써야겠다'는 생각은 전혀 하지 못하는 것 같다. 공용체를 사용한 멤버 변수 선언을 보고 너무 흥미로웠다.

 

FORCEINLINE constexpr float Vector2::operator[](const uint8 InIndex) const
{
	assert(InIndex < Dimension);
	return Components[InIndex];
}

std::array로 선언된 Components는 subscript operator를 overload할 때 이렇게 쓰인다.

 

요즘은 왜인지 프로그래밍에 별다른 감흥없이 해야 할 일이니까 하는 느낌이었는데, 다시 프로그래밍과 사랑에 빠진 것 같았다. 사랑스럽다...

템플릿 사용에 관한 고민

// Copyright Epic Games, Inc. All Rights Reserved.

template<typename T>	
struct TVector2 
{
	static_assert(std::is_floating_point_v<T>, "T must be floating point");
// ...

Vector2 클래스를 만들 때 고민했던 포인트는 템플릿 사용 여부였다. 언리얼 엔진의 Vector 클래스는 템플릿을 사용한다.

// Forward declaration of concrete types				// Macro version - declares all three variants.
using FVector 		= UE::Math::TVector<double>;		// UE_DECLARE_LWC_TYPE(Vector, 3);
using FVector2D 	= UE::Math::TVector2<double>;		// UE_DECLARE_LWC_TYPE(Vector2,, FVector2D);
using FVector4 		= UE::Math::TVector4<double>;		// UE_DECLARE_LWC_TYPE(Vector4);

다만 이런식으로 type alias를 통해서 FVector라는 이름으로 제공되다보니 실제 엔진을 활용한 구현 중에는 템플릿을 사용할 일은 없을 뿐이다. (FVector3f 같이 float형도 있다)

 

여하간에 언리얼 엔진처럼 floating-point면 다 되게 double이고 float이고 다 받아줄 것인가, 아니면 float로만 선언할 것인가 고민하다가 그냥 float으로 선언했다. 내가 만드는 소프트렌더러에서 굳이 배정밀도를 쓸 일이 없을 것 같았기 때문이다.

 

생성자에서의 default 키워드

CKSoftRenderer와 언리얼 엔진의 코드를 살펴보니 내가 잘 모르는 문법들이 몇 가지 보였다. 일단 생성자에 default, explicit 키워드가 붙어있었고, constexpr 키워드가 함수에 붙어있었다.

 

FORCEINLINE constexpr Vector2() = default;

그런데 default 키워드는 별거 없었다. 그냥 디폴트로 생성되는 거 쓰겠다고 명시적으로 작성해주는 것이다.

비슷하게 delete 키워드도 있는데, 디폴트로 생성되지 않게 강제로 막아버리는 것이다.

생성자에서의 explicit 키워드

https://modoocode.com/253

 

씹어먹는 C ++ - <4 - 6. 클래스의 explicit 과 mutable 키워드>

모두의 코드 씹어먹는 C ++ - <4 - 6. 클래스의 explicit 과 mutable 키워드> 작성일 : 2018-12-26 이 글은 50237 번 읽혔습니다. 에 대해 다룹니다. 안녕하세요 여러분! 이번 강좌는 클래스에서 비교적 자주 쓰

modoocode.com

(또) 이 블로그에서 내용을 잘 설명하고 있는데, 암시적 형변환에 의해서 생성자가 호출되는 것을 막는 키워드이다.

 

explicit FORCEINLINE TVector2<T>(T InF);

언리얼 엔진 코드 중에는 요런게 있는데, T가 어차피 floating-point로 제한되니까 일례로 float라고 생각해보면 이해가 쉽다. float로의 암시적 형변환을 통해서 TVector2 클래스의 생성자가 호출되지 않도록 하는 것이다.

 

모르고 있던 키워드를 공부하니까 코드를 작성한 사람의 디테일한 의도가 읽혀서 재미있다.

constexpr 함수

https://youtu.be/o9FXctFYlnY?si=kmSV5fj5xH7r9v34

constexpr을 상수가 아니라 함수에 사용하는 문법은 처음봤는데, 더 이상 TMP를 안해도(or 덜해도?) 되게끔 해주는 문법이었다. constexpr 함수는 결과 값을 컴파일 타임에 정할 수 있으면 그렇게 하고 아니면 일반 함수처럼 런타임에 결정되게끔 해준다. 그 외 몇 가지 제약들이 있는데, 아래 (유명한) 블로그에 잘 정리되어 있다.

 

https://modoocode.com/293

 

씹어먹는 C++ - <16 - 2. constexpr 와 함께라면 컴파일 타임 상수는 문제없어>

constexpr 을 통해 컴파일 타임 상수인 객체를 선언할 수 있다. const 와 constexpr 은 다르다. const 는 컴파일 타임에 상수일 필요가 없다! (const 인 애들 중에서 constexpr 이 있다고 생각하면 된다) constexpr

modoocode.com

 

더불어, constexpr 생성자는 constexpr 함수의 제약이 모두 적용되고 인자가 리터럴 타입이어야한다. 해당 내용도 위 블로그에 잘 정리되어있다.

 

nodiscard

[[nodiscard]] FORCEINLINE float GetMagnitude() const;
[[nodiscard]] FORCEINLINE constexpr float GetSquaredMagnitude() const;
[[nodiscard]] FORCEINLINE Vector2 GetNormalized() const;
[[nodiscard]] std::string ToString() const;

[[nodiscard]] attribute는 함수의 결과값이 버려지지 않도록 컴파일러 경고를 출력하도록 해준다.

내가 생각하기에 결과값을 버릴 이유가 없는 함수들에 다 달아두었다.

 

Custom Assertion에 관한 고민

포프님의 C++ 코딩스탠다드( https://docs.popekim.com/en/coding-standards/cpp )를 보면 C 표준에서 제공하는 assert 함수를 쓰지 말고 직접 구현하라고 하고있다. 왜 그렇게 해야하는지에 대한 이유도 궁금하지만, 일단 구현에 대해서 생각해보기로 했다.

 

어떻게 구현해야할지 참고하기 위해서 MSVC의 assert 구현을 살펴봤더니 결국에는 dll에서 정의하고 있다(내부 구현 함수인 _wassert에 __declspec(dllimport)가 붙어있음).

 

언리얼은... 뭐만 하면 SIMD 명령어, 어셈블리 등등이 섞여있어서 참고를 못하겠다-_-;; (뒤의 고속 역제곱근 관련 코드에서도...)

그러다가 결국 포프님께 물어서 답변을 들어왔다. 질문은 C 표준의 assert를 왜 쓰지말라는 것인지에 대한 것이었는데 구현에 대한 얘기까지 해주셨다. 일단 cassert를 쓰지 말라는 이유는 stack trace상의 콜스택이 assertion을 걸어둔 위치가 걸리지 않는다는 것이었다. 그 해결책은 매크로를 통해 int3 명령어나 __debugbreak를 쓰라는 것.

 

이번 버전에는 아직 cassert를 쓰고 있지만, 추후에 직접 assertion 해주는 매크로를 정의해보도록 하겠다.

 

(벡터의 정규화를 위한) 고속 역제곱근 알고리즘

여기가 오늘의 하이라이트 인 것 같다. 고속 역제곱근 알고리즘은 벡터를 정규화하는데에 필요한 알고리즘이다. 왜냐하면 벡터를 정규화 하려면 각 성분을 벡터의 크기로 나눠줘야하는데, 벡터의 크기를 구하려면 제곱근을 구해야하기 때문이다.

 

나눗셈 연산과, 제곱근 연산은 기본적으로 느리다. 그런데 그래픽스에서 벡터를 정규화하는 것은 일상이기 때문에 이 연산을 반드시 빠르게 만들어야한다.

 

이 때 필요한 것이 고속 역제곱근 알고리즘인데, 말 그대로 역제곱근(제곱근의 역수)를 빠르게 구하는 알고리즘이다. 제곱근의 역수를 구하면 거기다가 벡터의 성분들을 곱해주면 되므로 나눗셈과 제곱근을 구하는 2개의 연산이 한 번에, 그것도 빠르게 해결된다.

 

https://youtu.be/p8u_k2LIZyo?si=-87UE_TyTxvdLV0h

원리는 이 영상에서 잘 설명하고 있다. 퀘이크 3 아레나 소스코드에서 사용된 코드인데, 간단한 포인터 핵부터 수학적인 접근이 들어간다. (아마도) 동료가 달아둔 'what the fuck?' 이라는 comment가 인상적이다.

 

영상에서는 코드의 내용을 세 단계로 나누고 있지만, 나는 첫 번째 단계는 구현단의 얘기이지 방법론적인 얘기는 아닌 것 같아서 두 단계로 나눌 수 있다고 생각한다.

 

1. 결과값 근사하기

2. 뉴턴-랩슨법

 

일단 코드만 봐서는 절대 이해할 수 없을테고(똑똑하다면 붙잡고 연구할 수 있겠지만 굳이 그래야할까) 영상을 참고하면 된다. 뜬금없지만 알고리즘을 공부한게 여기서 도움이 됐는데, 백준 14786에서 뉴턴-랩슨법이라는게 뭔지 이미 익혀봤기 때문이다.

 

struct MathUtil
{
    static constexpr uint8 NewtonRaphsonIteration = 3;

    FORCEINLINE static float GetInvSqrt(float InValue)
    {
        const float ThreeHalfs = 1.5f;

        float X2 = InValue * 0.5f;
        float Y = InValue;
        int32 I = *(int32*)&Y;
        I = 0x5f3759df - (I >> 1);
        Y = *(float*)&I;

        for (uint8 Iter = 0; Iter < NewtonRaphsonIteration; ++Iter)
        {
            Y = Y * (ThreeHalfs - (X2 * Y * Y));
        }

        return Y;
    }
};

퀘이크 3 아레나의 코드에서는 뉴턴-랩슨법을 1번만 수행해도 충분한 정확도로 역제곱근을 구할 수 있기 때문에 추가적인 iteration없이 1번만 수행하고 있다. 다만 직접 구현해보니 오차가 충분히 작기는 했지만 더 정확하게 하기 위해서 3번정도 반복하도록 했다.

 

3번으로 설정한 이유는 (0, n)짜리 벡터를 정규화 했을 때 (0,1)로 정확히 떨어지도록 하는 가장 작은 iteration 횟수가 3이어서 그렇게 했다.

마무리

간단한 2차원 벡터 클래스를 구현하는데에도 공부할 게 가득이었다. 내가 시도해보지 않았다면 놓칠 수 있었던 여러가지 개념들을 익힐 수 있어서 좋았다. 특히, 단순히 이론을 공부해서 머리에 넣는게 아니라 실제로 프로젝트에 적용까지 하게 된다는 점이 마음에 든다.

 

아직 갈 길이 멀지만, 설레고 두근거린다. 계속 만들어나가다보면 전혀 생각지도 못했던 '고속 역제곱근 알고리즘' 같이 흥미로운 주제들도 나오겠지. 그리고 내가 렌더러를 만들어낼 수 있겠지. 반드시 끝을 보겠다!