티스토리 뷰

C++ OpenGL(GLEW/GLFW) 개발환경 구축

C++을 통해 OpenGL을 개발할 수 있는 환경 구축에 대한 글이다.
라이브러리 쓰고 싶은데 C++ 환경에서 라이브러리를 쓸 줄 몰라서 관련 지식도 함께 정리해놓았다.

실제 구축을 어떻게 했는 지 알고 싶으면 2번 항목만 봐도 된다.
아니면 다음 repo를 둘러봐도 된다.

https://github.com/9ru9ru/open-gl-course/tree/ch0-ready-fixed

0. 개요

이 글에서 커버하는 지식 범위는 다음과 같다.

  • C 환경의 대략적인 빌드 과정
  • C 환경에서 정말 기초적인 라이브러리 지식
  • 라이브러리를 링크하고 빌드하기 위한 기초 CMake 사용법

이 글을 통한 수행할 목표는 다음과 같다.

  • mingw/Cmake 이용한 빌드 및 개발 환경 셋업.(IDE는 CLion 이용)
  • Windows64환경에서 CMake를 이용한 GLEW / GLFW 라이브러리 이용 셋업: lib/dll 사용.
  • 학습을 위한 간단한 클래스 분리

1. 기초 지식

C++에서 라이브러리를 이용하기 위한 기초 지식.

1.1. 빌드 과정

빌드 과정을 대략 알아야 헤더가 어떻게 사용되고 라이브러리가 어떻게 사용되는지 알 수 있다.
놀랍게도 C++은 그걸 알아야 라이브러리를 사용하는데 문제가 없다.

멋대로 도용한 원본 사진 출처 - https://faculty.cs.niu.edu/~mcmahon/CS241/Notes/build.html

1.1.1. 전처리(*.cpp / *.h → *.i)

전처리기가 소스 파일을 전처리 하는 과정이다.
크게 하는 일은 세가지다.

  1. 주석 제거
  2. 헤더파일 삽입: include 선언한 곳에 헤더 파일 내용을 복사해서 붙여넣는 작업.
  3. 매크로 치환 및 적용

이 과정을 거치면 전처리된 소스파일이 만들어진다.(*.i)
그리고 뒤에서 설명할 내용이지만 헤더 파일에는 보통 함수 정의만 모여있다.
추후 컴파일과 어셈블을 거치며 만들어지는 object 파일 / 라이브러리 파일에 구현이 모여있다.

1.1.2. 컴파일(*.i → *.s)

코드를 좀 쌌다면 잘 알 것이다.
컴파일러가 컴파일하는 과정이다.
따지고 보면 깊지만 슬쩍 짚고 넘어간다.

  1. 작성한 코드의 문법 검사
  2. static 영역의 메모리 할당 등 수행
  3. 최적화 진행

전처리된 소스 파일을 어셈블리 파일로 변경하게 된다.

리버싱 같은 거 할 때 많이 보게되는 언어다.

1.1.3. 어셈블(*.s → *.o)

어셈블리 파일을 기계어로 된 object 파일로 변환하는 과정이다.
여기서는 깊게 설명하지 않겠다.
내가 모르기 때문이다.

1.1.4. 링킹(오브젝트 파일, 정적 라이브러리 → executable)

오브젝트 파일들과 라이브러리를 모아 연결하여 실행 파일로 만드는 과정이다.
잘 생각해보면 object 파일은 각 소스로부터 개별로 만들어지는 것이다.
그러면 당연히 각 파일에선 선언 정보만 알고 있는 함수들이 많을 것이다.
이 선언들의 구현을 찾기 위해 서로 다른 obj 파일과 라이브러리를 연결해주는 과정이다.

즉 구현을 끌고 오는 과정을 포함한다.

1.1.5. 라이브러리는 어디 끼는가.

그러면 라이브러리 관련해서 알아야 할 중요한 정보는 뭘까?
딱 집중하자.

1: 전처리/컴파일 과정에서 라이브러리의 선언이 있는 header 파일이 이용된다.

#include<머시기.h>를 통해 헤더를 소스 파일에 포함시켰다고 해보자.
그럼 이 include가 있는 소스 파일은 라이브러리 코드의 존재(선언)를 알게 된다.
그럼 컴파일 에러가 발생하지 않는다. 즉 빨간 줄이 안 뜬다.

2: 링크 과정에서 라이브러리의 구현이 있는 파일이 연결된다.

오브젝트 파일까지 만들어진다.
이 때 오브젝트 파일에는 선언 정보만 알고 있는 함수가 많다.

→ 따라서 구현을 끌고 와야한다. 이 과정이 링크다.

단, 뒤에서 설명하겠지만 정적 라이브러리(lib)에 해당하는 내용이다.
동적 라이브러리(dll)는 실행 파일을 만드는 과정에서 구현 정보를 링크시키지 않는다.

1.2. C++에서 라이브러리의 구분

이 부분은 이론과 실용을 함께 보도록 하자.

1.2.1. 라이브러리의 구분

다소 일반적으로 라이브러리를 설명하자면 사용하기 위한 기능들의 모음이다.
다운로드 받아 까보면 헤더 파일(.h)과 구현 파일(lib/dll)로 구분되어 있다.
헤더 파일은 다 알다 싶이 라이브러리를 구성하는 함수들의 정보(시그니처)가 선언되어 있는 곳이다.
이 구현은 막상 전부 구현파일(lib/dll)에 있다.

그렇다면 lib/dll 둘 다 함수의 구현 정보를 들고 있는 것은 마찬가지인데, 무슨 차이가 있는 걸까?
결론부터 얘기하자면 라이브러리 안에 있는 구현 정보가 사용되는 시점에 차이가 있다.
lib는 정적 라이브러리로, executable을 만드는 과정에서 구현 정보를 실행 파일에 넣어버린다.
링크 과정에서 이게 진행된다.

따라서 정적 라이브러리를 사용하면 exe가 구워질 때 구현 정보가 함께 들어간다.
따라서 exe는 사이즈가 커지지만 혼자서 라이브러리 함수들을 사용할 수 있다.

dll은 동적 라이브러리로, executable을 만드는 과정에서 구현 정보를 포함시키지 않는다.
링크 과정에서는 최소한의 정보만을 포함시킨다.
그 대신 dll이 실행 시 필요하게 된다.

즉 exe에 구현 정보를 포함시키지 않고, 프로그램이 실행되는 시점에 dll을 메모리에 띄워 연결시켜버린다.
이렇게 하면 exe에 구현 정보가 들어가지 않아 사이즈는 커지지 않지만 runtime에서도 dll 파일에 대한 의존성이 생겨버린다.

정리해보면 정적 라이브러리는

  1. 실행 파일을 만들 때 구현 정보를 다 포함시킨다.
  2. 실행 파일은 커지지만 혼자서도 라이브러리 기능을 이용할 수 있다. runtime에서 추가 파일이 필요 없다.

동적 라이브러리는

  1. 실행 파일을 만들 때 구현 정보를 최소한으로 포함시킨다.
  2. 실행 파일은 작아지지만 runtime 수행 시 라이브러리가 필요하게 된다.

1.2.2. 왜 라이브러리의 종류는 구분돼있나.

그럼 간단히 생각해보자. 왜 굳이 동적 라이브러리가 필요한걸까? 귀찮잖아.
동적 라이브러리는 여러 프로그램들에서 같은 라이브러리를 공유할 때 빛을 본다.

(Shared Library은 동적 라이브러리를 부르는 다른 이름이다.)

App A와 App B가 둘 다 메모리에 올라가는 상황이라고 해보자.

공통으로 사용되는 라이브러리가 있는데 프로그램을 정적 라이브러리로 구워버렸다면 그 라이브러리는 App A와 App B에 각각 하나씩 포함된 채로 메모리에 2번 올라갈 거다.

하지만 dll을 이용하게 되는 경우 라이브러리는 메모리에 한 번만 올라가도 된다.
App A와 B가 공유하면 되니까, 그래서 Shader Library로 불리기도 한다.

단순히 2개로 설명했지만 응용 프로그램들에서 공용으로 사용하는 dll은 어마무시하게 많을 것이다.
그런 상용 환경에서는 정말 엄청나게 리소스를 절약할 수 있는 방법이 될 것이다.

하지만.. 마소는 이제 더 이상 쓰지 않는 걸 지향한다고 하니 관련 내용을 찾아보는 것도 좋을 것 같다.
잠깐만 생각해봐도 각 App 별로 사용하는 dll 버전이 다르거나 dll간 의존성이 있거나 이런 일들이 비일비재 할 것이다…

1.2.3. 동적 라이브러리에 대한 조금 더 깊은 지식

하지만 동적 라이브러리라고 빌드할 때 반드시 dll 파일만 필요한 것은 아니다.
때때로 lib 파일을 필요로 하기도 하는데 이는 dll을 링크하는 방식에 따라 차이가 있다.
dll은 그 함수를 호출하는 방법에 따라 암시적 링킹과 명시적 링킹으로 나눌 수 있다.

암시적 링킹은 실행 시 파일 자체에 어떤 dll의 어떤 함수를 호출하겠다는 정보를 포함시키고 프로그램 시작 시 해당 함수들을 초기화 한 후 이용하는 방식이다.
즉, 함수를 사용할 때 그냥(암시적으로) dll 함수를 불러올 수 있도록 링크한다는 뜻이다.
또한, 실행 파일에 어떤 함수를 사용하겠다는 정보를 포함하기 위해서는 lib 파일이 링크 때 필요하다. 또한 이 때 사용되는 lib는 위에서 말한 정적 라이브러리가 아니라 암시적 링킹에 필요한 심볼들이 있는 lib 파일이다.

명시적 링킹은 프로그램이 실행 중일 때 dll 파일이 메모리에 있는 지 검사하고 동적으로 원하는 함수만 호출하는 방법이다.
단, 이 때는 코드 작성 시 명시적으로 DLL을 불러오고 해제해주는 과정이 필요하게 된다.
또한 이 때는 링크 시 dll의 함수 정보가 필요하지 않기 때문에 lib 파일이 필요하지 않다.
즉, 정리해보면 링크 과정에서

  • 암시적 링킹은 lib와 dll 둘 다 필요.
  • 명시적 링킹은 dll만 필요.

1.2.4. 실습: 라이브러리를 까보자.

배운 내용을 라이브러리를 다운로드 받고 까보며 확인해보자.
glew sourceforge에 들어가보면 다음과 같이 화면이 나온다.

다운로드 받아서 까보자

보통 아래와 같은 구조다.

bin 안에는 dll
include 안에는 .h 파일
lib 안에는 lib 파일들이 들어있다.

따라서 다음 조합으로 중 하나로 파일들을 골라 이용할 수 있을 것이다.

  • h + lib를 이용한 정적 라이브러리 링킹
  • h + lib + dll을 이용한 동적 라이브러리의 암시적 링킹
  • h + dll을 이용한 동적 라이브러리의 명시적 링킹

1.3. CMake 간단 개요

CMake는 makefile을 만들어 주는 도구다.
makefile은 어떻게 빌드를 해야할 지에 대한 정보를 담아 놓는 파일로써, 이 파일을 이용해 빌드를 진행하게 된다. 이렇게 하면 빌드의 방법을 정해놓을 수 있으므로 누구나 일관된 빌드를 할 수 있게 된다.
간단하게 CMake를 통해 빌드 방법과 옵션을 결정할 수 있다고 생각해도 무리 없다.

빌드 과정을 다시 생각해보면 컴파일도, 링크도 포함돼있다.
즉, 외부라이브러리를 사용하려면 CMake에서 지정해줘야 한다.

이 과정에 대해서 2.2에서 깊게 다루겠다.

2. 실제 준비

1을 하나도 읽지 않아도 아래 방법대로 하면 개발 환경을 셋업할 수 있다.
셋업된 환경을 보고 싶은 사람은 아래의 링크를 보면 된다.

https://github.com/9ru9ru/open-gl-course/tree/ch0-ready-fixed

2.1. 프로젝트 구조 정리

CLion을 사용하여 아래와 같이 프로젝트를 준비했다.

이 때 include에는 헤더 파일을, lib 폴더에는 dll과 lib 파일들을 모아놓았다.
runtime은 소스 파일들이 들어가는 곳이다.

2.2. 라이브러리 준비

GLEW/GLFW를 다운로드 페이지에서 각각 받으면 된다.
나는 그냥 64비트 버전으로 진행했다.
위에서 이야기 해 본 라이브러리의 다양한 유형을 이용하기 위해 다음과 같이 셋업해보겠다.

  • GLEW: 정적 라이브러리 링크
  • GLFW: 동적 라이브러리 링크, 그 중 암시적 링크로 진행.

(명시적 링크는 함수를 쓸 때 코드 작성 과정이 귀찮아지므로 암시적 방식을 이용해보자..!)

GLEW의 경우 까보면 다음과 같이 돼 있을 것이다.

include에서 헤더 파일을, lib에서 glew32s.lib를 준비했다.(뒤에 s붙은게 정적 라이브러리 용임)

GLFW의 경우 까보면 좀 헷갈릴 수 있다.

include에서 헤더 파일, lib-static-ucrt에서 dll과 그것에 매치되는 lib를 갖고 오면 된다.
그럼 최종적으로 디렉토리의 라이브러리 구조는 다음과 같이 될 것이다.

tmi를 잠깐 추가하자면 이건 이미 빌드된 라이브러리를 이용하는 방식이다.
CMake는 재귀적으로 빌드를 진행할 수 있으므로 CMake가 포함된 소스 파일을 라이브러리 대신 이용할 수 있다.

2.3. CMake 작성

이제 CMake를 통해 빌드 방법을 설정하고 라이브러리를 링크해주어야 한다.
이는 CMakeLists.txt 파일의 작성을 통해 이루어진다.
우선 CMakeLists.txt의 완성본을 보자.

# 프로젝트 기본 설정
cmake_minimum_required(VERSION 3.30)
project(OpenGLCourse)
set(CMAKE_CXX_STANDARD 20)

# 헤더 파일 찾을 곳 경로 설정
include_directories(include)

# 라이브러리 파일 경로 설정
link_directories(lib)

# build - 소스 파일을 포함시켜 빌드
file(GLOB_RECURSE SOURCES "runtime/*.cpp")
add_executable(OpenGLCourse ${SOURCES})

# link libraries - 라이브러리들을 링크시킨다.
set(OpenGLLibs glew32s glfw3dll opengl32)
target_link_libraries(OpenGLCourse ${OpenGLLibs})

# GLEW 정적 라이브러리 사용 시 필요한 정의 추가
add_definitions(-DGLEW_STATIC)

# glfw3.dll을 lib 폴더에서 실행 파일 폴더로 복사
add_custom_command(TARGET OpenGLCourse POST_BUILD
        COMMAND ${CMAKE_COMMAND} -E copy_if_different
        "${CMAKE_SOURCE_DIR}/lib/glfw3.dll"
        "$<TARGET_FILE_DIR:OpenGLCourse>/glfw3.dll"
)

CMake에는 정말 다양한 커맨드가 있지만 가장 간단하게 알아야 하는 커맨드는 다음과 같다.

  • cmake_minimum_required: CMake의 최소 버전을 설정한다.
  • project: 프로젝트 명을 설정한다.
  • set(A 머시기머시기): A를 머시기머시기로 설정한다. 머시기머시기는 리스트가 될 수도 있다.
  • include_directories: 주어진 디렉토리를 컴파일러가 include 파일들을 찾을 수 있는 곳으로 지정한다.
  • link_directories: 주어진 디렉토리를 linker가 라이브러리 파일들을 찾을 수 있는 곳으로 지정한다.
  • file(GLOB_RECURSE SOURCES "runtime/*.cpp"): GLOB_RECURSE(재귀적으로 하위 파일을 모두 찾는 모드) 모드로 SOURCES 변수에 runtime 폴더 및의 모든 소스 파일을 할당한다.
  • add_executable: 실행 파일을 만든다. 첫 번째에 이름, 두 번째에 포함되는 소스를 적으면 된다.
  • target_link_libraries: 실행 파일에 라이브러리들을 링크한다. 여기서는 glew32s glfw3dll opengl32를 링크했다. 라이브러리의 파일명을 적으면 된다.
    • opengl32는 기본적으로 포함된 라이브러리라 따로 추가하지 않았지만 링크가 된다.
  • add_definitions(-DGLEW_STATIC): 소스 파일의 컴파일에 -D뒤에 오는 플래그를 추가할 수 있다.
    • GLEW 라이브러리를 정적으로 이용하기 위해서는 GLEW_STATIC을 선언해야 하기 때문.
    • 혹은 #define GLEW_STATIC과 같은 전처리문을 소스 파일에 추가해도 된다.
  • add_custom_command(명령문): 별도의 명령문을 빌드 과정에서 수행하도록 하는 지시문이다.
    • 내부 명령문은 glfw의 dll 파일을 lib 폴더에서 실행 파일이 있는 경로로 복붙하는 명령문이다.
    • glfw는 동적 라이브러리를 통해 연결했기 때문에 실행 파일이 있는 곳에 dll이 필요하게 된다.

2.4. 초간단 과제 수행 구조

위에서 슬쩍 언급했듯 매 번 main에 배운 걸 때려박아 만드는 것은 매우 유지보수가 힘든 일이다.
그러나 과제로 배우는 내용인 만큼 매 번 실행되는 내용이 극적으로 변화하기 때문에 객체 지향적으로 많이 적어봐야 쓸모도 없다.
특히 코드를 역할에 따라 여기저기 너무 나눠 적으면 한 번에 보기가 어렵다..!

여기서부터는 다들 알아서 자기 스타일로 하길 바라며 내가 매우 간단하게 만든 것을 적는다.

내가 원하는 건 다음과 같다.

  • main()에서는 그냥 함수 하나의 호출만을 두고 싶다.
  • 과제 클래스들은 Run() 메서드 만을 가진 인터페이스를 구현하도록 하여 배운 내용을 실행해볼 수 있도록 한다.
  • 이렇게 하면 나중에 예전에 배운 걸 돌려보고 싶을 때 한 줄만 바꾸면 된다.

뒤에서 구현할 Section01Runner를 포함하여 다음과 같이 코드들을 작성했다.

main.cpp

#include "base/IBaseRunner.h"
#include "section01/Section01Runner.h"

int main()
{
    // 해당 부분만 계속 변경하며 스터디.
    IBaseRunner* runner = new Section01Runner();
    runner->Run();
}

IBaseRunner.h

#ifndef IBASERUNNER_H
#define IBASERUNNER_H

class IBaseRunner {
    public:
        virtual bool Run() = 0;
        virtual ~IBaseRunner() = default;
};

Section01Runner.h

#ifndef SECTION01RUNNER_H
#define SECTION01RUNNER_H

#include "../base/IBaseRunner.h"
#include <GL/glew.h>

class Section01Runner : public IBaseRunner
{
    public:
        bool Run() override;
        ~Section01Runner() override = default;
    private:
        const GLint windowWidth = 800, windowHeight = 600;
};

Section01Runner.cpp

#include "Section01Runner.h"
#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <stdio.h>

bool Section01Runner::Run()
{
    // init GLFW.
    if(!glfwInit())
    {
        printf("GLFW init fail");
        glfwTerminate();
        return false;
    }

    // Setup GLFW window properties.
    // OpenGL Version.
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);

    // Core profile = No Backwards Compatible.
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
    // Allow forward compatible.
    glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);

    GLFWwindow* mainWindow = glfwCreateWindow(windowWidth, windowHeight, "Hello Window", NULL, NULL);
    if (!mainWindow)
    {
        printf("glfwCreateWindow failed!");
        glfwTerminate();
        return false;
    }

    // Get Buffer Size Info.
    int bufferWidth, bufferHeight;
    glfwGetFramebufferSize(mainWindow, &bufferWidth, &bufferHeight);

    // Set Context for GLEW to use.
    glfwMakeContextCurrent(mainWindow);

    // Allow modern extension feats.
    glewExperimental = GL_TRUE;

    if (glewInit() != GLEW_OK)
    {
        printf("glew init failed");
        glfwDestroyWindow(mainWindow); // 이 시점에서 window가 생성되었으니 지워야 한다.
        glfwTerminate();
        return false;
    }

    // Setup Viewport size.
    glViewport(0, 0, bufferWidth, bufferHeight);

    // loop til window closed.
    while (!glfwWindowShouldClose(mainWindow))
    {
        // Get + handle user input events.
        glfwPollEvents();

        // Clear window.
        glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);

        glfwSwapBuffers(mainWindow);
    }

    return true;
}

이렇게 하면 GLEW/GLFW를 활용하여 C++에서 개발할 수 있는 환경이 대략적으로 만들어진다.
아주 최소한의 유지보수가 가능하도록 만들었다.

그냥 라이브러리 써서 대충 만들고 싶었는데 필요해서 동작 방식까지 공부하게 됐다.
참 익숙하지 않은 언어다...

댓글