🔆

OpenGL, Vulkanを使ったアプリケーションで様々なレンダリング手法を実装した例を複数用意したい時

2023/06/22に公開

OpenGLやVulkanなどで遊んでいると様々なレンダリング手法を実装してテストしてみたくなります。こういう時に各テストをどのように実装したら簡潔に書くことが出来るのか悩んだので、ここにその解決策の1つを書いておきます。

前提

  • C++
  • OpenGL, VulkanなどのグラフィクスAPIを使っている
  • GLFW + ImGuiを使ってGUIを提供している
  • 各exampleによってGUI表示を変えたい

ディレクトリ構造

exampleディレクトリ以下に各exampleを実装した.hpp, .cppファイルを配置するようにします。

example/
├── example1
│   ├── shaders
│   │   ├── example1.frag
│   │   └── example1.vert
│   └── src
│       └── example1.cpp
├── common
│   └── src
│       ├── example-base.cpp
│       └── example-base.hpp
├── example2
│   ├── shaders
│   │   ├── example2.frag
│   │   └── example2.vert
│   └── src
│       └── example2.cpp

各exampleで使う共通の処理などはcommon以下に配置することにします。

exampleの実装

各exampleでは以下のような共通する処理が現れます。

  • GLFW, glad, ImGuiのセットアップ
  • 実装した自作レンダラーのセットアップ
  • シーンの読み込み
  • ユーザーからの入力処理
  • レンダリングループ

これらの処理は各exampleで完全に一致している部分もあれば、各exampleによって処理内容を変えたい部分もあります。例えばGLFW, glad, ImGui, レンダラーのセットアップなどは共通化できますが、シーンやユーザーからの入力処理、レンダリングループ内での処理内容は各exampleによって変えたい場合があります。

このような時に各exampleをどのように実装したら簡潔かつ拡張性が高くなるように出来るでしょうか?

解決策

各exampleで共通する処理をExampleBaseクラスに実装します。

example-base.hpp

class ExampleBase
{
   public:
    ExampleBase(uint32_t width, uint32_t height);
    virtual ~ExampleBase();

    // アプリケーションの起動
    void run();

   protected:
    uint32_t width;     // レンダリング画面の横幅
    uint32_t height;    // レンダリング画面の縦幅
    GLFWwindow* window; // GLFWのwindowハンドラ
    ImGuiIO* io;        // ImGuiのユーザー入力のハンドラ

    Camera camera;      // カメラを表現するクラス
    Scene scene;        // シーンを表現するクラス

   private:
    // GLFWを初期化する
    void initGlfw();
    // gladを初期化する
    void initGlad();
    // ImGuiを初期化する
    void initImGui();

    // レンダリングループ前に行う処理を記述する(例: シーンの読み込み)
    virtual void beforeRender() = 0;

    // レンダリングループ内におけるImGui関連の処理を記述する(例: ImGui::InputFloat)
    virtual void runImGui() = 0;

    // ユーザー入力の処理を記述する(例: キー入力でカメラを動かす)
    virtual void handleInput() = 0;

    // レンダリング処理を記述する(例: Uniform変数のセット, ドローコールの呼び出し)
    virtual void render() = 0;

    // アプリを終了する前に行う必要があるリソース解放処理を記述する
    virtual void release();

    // GLFWのframebufferSizeCallbackの処理を記述する
    virtual void framebufferSizeCallback(GLFWwindow* window, int width,
                                         int height);
    // GLFWのCallback関数用のラッパー
    static void framebufferSizeCallbackStatic(GLFWwindow* window, int width,
                                              int height);
};

example-base.cpp

ExampleBase::ExampleBase(uint32_t width, uint32_t height)
    : width{width}, height{height}
{
    initGlfw();
    initGlad();
    initImGui();
}

ExampleBase::~ExampleBase() { release(); }

void ExampleBase::initGlfw()
{
    if (!glfwInit()) { throw std::runtime_error("failed to initialize GLFW"); }

    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 6);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
    glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);  // required for Mac

    window = glfwCreateWindow(width, height, "example", nullptr, nullptr);
    if (!window) { throw std::runtime_error("failed to create window"); }
    glfwMakeContextCurrent(window);
    glfwSetWindowUserPointer(window, this);

    // この関数の引数はstaticメソッドである必要があるため、staticなラッパー関数を代わりに使う
    glfwSetFramebufferSizeCallback(window, framebufferSizeCallbackStatic);
}

void ExampleBase::initGlad()
{
    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
        throw std::runtime_error("failed to initialize glad");
    }
}

void ExampleBase::initImGui()
{
    IMGUI_CHECKVERSION();
    ImGui::CreateContext();
    io = &ImGui::GetIO();

    ImGui::StyleColorsDark();

    ImGui_ImplGlfw_InitForOpenGL(window, true);
    ImGui_ImplOpenGL3_Init("#version 460 core");
}

void ExampleBase::framebufferSizeCallback(GLFWwindow *window, int width,
                                          int height)
{
    this->width = width;
    this->height = height;
    glViewport(0, 0, width, height);
}

void ExampleBase::framebufferSizeCallbackStatic(GLFWwindow *window, int width,
                                                int height)
{
    ExampleBase *instance =
        static_cast<ExampleBase *>(glfwGetWindowUserPointer(window));
    instance->framebufferSizeCallback(window, width, height);
}

void ExampleBase::release()
{
    ImGui_ImplOpenGL3_Shutdown();
    ImGui_ImplGlfw_Shutdown();
    ImGui::DestroyContext();
    glfwDestroyWindow(window);
    glfwTerminate();
}

void ExampleBase::run()
{
    beforeRender();

    // レンダリングループ
    while (!glfwWindowShouldClose(window)) {
        glfwPollEvents();

        ImGui_ImplOpenGL3_NewFrame();
        ImGui_ImplGlfw_NewFrame();
        ImGui::NewFrame();

        runImGui();

        handleInput();

        render();

        ImGui::Render();
        ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());

        glfwSwapBuffers(window);
    }
}

ExampleBase::run()を呼び出すことでアプリケーションが起動します。

各exampleではこのクラスを継承し、virtualになっているメソッドをoverrideしていきます。例えば三角形を表示する例は以下のように実装できます。

triangle.cpp

class TriangleExample : public ExampleBase
{
   public:
    TriangleExample(uint32_t width, uint32_t height) : ExampleBase(width, height) {}

   private:
    void beforeRender() override
    {
        // 頂点データ
        float vertices[] = {
            -0.5f, -0.5f, 0.0f,  // position1
            1.0f,  0.0f,  0.0f,  // color1
            0.5f,  -0.5f, 0.0f,  // position2
            0.0f,  1.0f,  0.0f,  // color2
            0.0f,  0.5f,  0.0f,  // position3
            0.0f,  0.0f,  1.0f   // color3
        };
	// VBOにデータをセット
        buffer.setData(vertices, 18, GL_STATIC_DRAW);
        
	// VAOのセットアップ
        vao.bindVertexBuffer(buffer, 0, 0, 6 * sizeof(float));
        vao.activateVertexAttribution(0, 0, 3, GL_FLOAT, 0);
        vao.activateVertexAttribution(0, 1, 3, GL_FLOAT, 3 * sizeof(float));

        // シェーダーのセットアップ
        pipeline.loadVertexShader(
            std::filesystem::path(CMAKE_CURRENT_SOURCE_DIR) /
            "shaders/shader.vert");
        pipeline.loadFragmentShader(
            std::filesystem::path(CMAKE_CURRENT_SOURCE_DIR) /
            "shaders/shader.frag");
    }

    void runImGui() override {}

    void handleInput() override
    {
        if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) {
            glfwSetWindowShouldClose(window, GLFW_TRUE);
        }
    }

    void render() override
    {
        glClear(GL_COLOR_BUFFER_BIT);
        pipeline.activate();
        vao.activate();
        glDrawArrays(GL_TRIANGLES, 0, 3);
        vao.deactivate();
        pipeline.deactivate();
    }

    Buffer buffer;         // Bufferのラッパークラス
    VertexArrayObject vao; // VAOのラッパークラス
    Pipeline pipeline;     // シェーダーのラッパークラス
};

int main()
{
    TriangleExample app(1280, 720);

    app.run();

    return 0;
}

これはいわゆるテンプレートメソッドパターンです。共通する処理をExampleBaseにまとめ、必要な部分だけを各サブクラスで実装することでexampleをミニマルにすることが出来ます。

この例ではImGuiを使っていませんが、runImGui()にImGui関連の処理を記述することで各exampleでGUIを変えることが出来ます。また、各exampleに固有のパラメーターなどをメンバ変数として持たせ、ImGui経由でそのパラメーターを変化させるといったことも出来ます。

完全な実装例は https://github.com/yumcyaWiz/opengl-sandbox で見ることが出来ます。

この記事ではOpenGLを例に説明しましたが、Vulkanでも同様のことが出来ると思います。

Discussion