Book Image

3D Graphics Rendering Cookbook

By : Sergey Kosarevsky, Viktor Latypov
4 (2)
Book Image

3D Graphics Rendering Cookbook

4 (2)
By: Sergey Kosarevsky, Viktor Latypov

Overview of this book

OpenGL is a popular cross-language, cross-platform application programming interface (API) used for rendering 2D and 3D graphics, while Vulkan is a low-overhead, cross-platform 3D graphics API that targets high-performance applications. 3D Graphics Rendering Cookbook helps you learn about modern graphics rendering algorithms and techniques using C++ programming along with OpenGL and Vulkan APIs. The book begins by setting up a development environment and takes you through the steps involved in building a 3D rendering engine with the help of basic, yet self-contained, recipes. Each recipe will enable you to incrementally add features to your codebase and show you how to integrate different 3D rendering techniques and algorithms into one large project. You'll also get to grips with core techniques such as physically based rendering, image-based rendering, and CPU/GPU geometry culling, to name a few. As you advance, you'll explore common techniques and solutions that will help you to work with large datasets for 2D and 3D rendering. Finally, you'll discover how to apply optimization techniques to build performant and feature-rich graphics applications. By the end of this 3D rendering book, you'll have gained an improved understanding of best practices used in modern graphics APIs and be able to create fast and versatile 3D rendering frameworks.
Table of Contents (12 chapters)

Rendering a basic UI with Dear ImGui

Graphical applications require some sort of UI. The interactive UI can be used to debug real-time applications and create powerful productivity and visualization tools. Dear ImGui is a fast, portable, API-agnostic immediate-mode GUI library for C++ developed by Omar Cornut (https://github.com/ocornut/imgui):

Dear ImGui is designed to enable fast iterations and to empower programmers to create content creation tools and visualization/debug tools (as opposed to UI for the average end user). It favors simplicity and productivity toward this goal and lacks certain features normally found in more high-level libraries. Dear ImGui is particularly suited to integration in game engines (for tooling), real-time 3D applications, full-screen applications, embedded applications, or any applications on gaming consoles where operating system features can be non-standard.

The ImGui library provides numerous comprehensive examples that explain how to make a GUI renderer for different APIs, including a 700-line code example using OpenGL 3 and GLFW (imgui/examples/imgui_impl_opengl3.cpp). In this recipe, we will demonstrate how to make a minimalistic ImGui renderer in 200 lines of code using OpenGL 4.6. This is not feature-complete, but it can serve as a good starting point for those who want to integrate ImGui into their own modern graphical applications.

Getting ready

Our example is based on ImGui version v1.83. Here is a JSON snippet for our Bootstrap script so that you can download the library:

{
  "name": "imgui",
  "source": {
    "type": "git",
    "url": "https://github.com/ocornut/imgui.git",
    "revision" : "v1.83"
  }
}

The full source code can be found in Chapter2/04_ImGui.

How to do it...

Let's start by setting up the vertex arrays, buffers, and shaders that are necessary to render our UI. Perform the following steps:

  1. To render geometry data coming from ImGui, we need a VAO with vertex and index buffers. We will use an upper limit of 256 kilobytes for the indices and vertices data:
    GLuint VAO;
    glCreateVertexArrays(1, &VAO);
    GLuint handleVBO;
    glCreateBuffers(1, &handleVBO);
    glNamedBufferStorage(handleVBO, 256 * 1024, nullptr,  GL_DYNAMIC_STORAGE_BIT);
    GLuint handleElements;
    glCreateBuffers(1, &handleElements);
    glNamedBufferStorage(handleElements, 256 * 1024,  nullptr, GL_DYNAMIC_STORAGE_BIT);
  2. The geometry data consist of 2D vertex positions, texture coordinates, and RGBA colors, so we should configure the vertex attributes as follows:
    glVertexArrayElementBuffer(VAO, handleElements);
    glVertexArrayVertexBuffer(  VAO, 0, handleVBO, 0, sizeof(ImDrawVert));
    glEnableVertexArrayAttrib(VAO, 0);
    glEnableVertexArrayAttrib(VAO, 1);
    glEnableVertexArrayAttrib(VAO, 2);

    The ImDrawVert structure is a part of ImGui, which is declared as follows:

    struct ImDrawVert {
      ImVec2 pos;
      ImVec2 uv;
      ImU32  col;
    };
  3. Vertex attributes corresponding to the positions, texture coordinates, and colors are stored in an interleaved format and should be set up like this:
    glVertexArrayAttribFormat(  VAO, 0, 2, GL_FLOAT, GL_FALSE,   IM_OFFSETOF(ImDrawVert, pos));
    glVertexArrayAttribFormat(  VAO, 1, 2, GL_FLOAT, GL_FALSE,  IM_OFFSETOF(ImDrawVert, uv));
    glVertexArrayAttribFormat(  VAO, 2, 4, GL_UNSIGNED_BYTE, GL_TRUE,  IM_OFFSETOF(ImDrawVert, col));

    The IM_OFFSETOF() macro is a part of ImGui, too. It is used to calculate the offset of member fields inside the ImDrawVert structure. The macro definition itself is quite verbose and platform-dependent. Please refer to imgui/imgui.h for implementation details.

  4. The final touch to the VAO is to tell OpenGL that every vertex stream should be read from the same buffer bound to the binding point with an index of 0:
    glVertexArrayAttribBinding(VAO, 0, 0);
    glVertexArrayAttribBinding(VAO, 1, 0);
    glVertexArrayAttribBinding(VAO, 2, 0);
    glBindVertexArray(VAO);
  5. Now, let's take a quick look at the shaders that are used to render our UI. The vertex shader looks similar to the following code block. The PerFrameData structure in the shader corresponds to the similar structure of the C++ code:
    const GLchar* shaderCodeVertex = R"(
      #version 460 core
      layout (location = 0) in vec2 Position;
      layout (location = 1) in vec2 UV;
      layout (location = 2) in vec4 Color;
      layout (std140, binding = 0) uniform PerFrameData
      {
         uniform mat4 MVP;
      };
      out vec2 Frag_UV;
      out vec4 Frag_Color;
      void main()
      {
         Frag_UV = UV;
         Frag_Color = Color;
         gl_Position = MVP * vec4(Position.xy,0,1);
      }
    )";
  6. The fragment shader simply modulates the vertex color with a texture. It should appear as follows:
    const GLchar* shaderCodeFragment = R"(
      #version 460 core
      in vec2 Frag_UV;
      in vec4 Frag_Color;
      layout (binding = 0) uniform sampler2D Texture;
      layout (location = 0) out vec4 out_Color;
      void main() {
         out_Color = Frag_Color * texture(       Texture, Frag_UV.st);
      }
    )";
  7. The vertex and fragment shaders are compiled and linked in a similar way to the Using the GLFW library recipe, so some parts of the code here have been skipped for the sake of brevity. Please refer to the source code bundle for the complete example:
    const GLuint handleVertex =  glCreateShader(GL_VERTEX_SHADER);...
    const GLuint handleFragment =  glCreateShader(GL_FRAGMENT_SHADER);... 
    const GLuint program = glCreateProgram();...
    glUseProgram(program);

These were the necessary steps to set up vertex arrays, buffers, and shaders for UI rendering. There are still some initialization steps that need to be done for ImGui itself before we can render anything. Follow these steps:

  1. Let's set up the data structures that are needed to sustain an ImGui context:
    ImGui::CreateContext();
    ImGuiIO& io = ImGui::GetIO();
  2. Since we are using glDrawElementsBaseVertex() for rendering, which has a vertex offset parameter of baseVertex, we can tell ImGui to output meshes with more than 65535 vertices that can be indexed with 16-bit indices. This is generally good for performance, as it allows you to render the UI with fewer buffer updates:
    io.BackendFlags |=  ImGuiBackendFlags_RendererHasVtxOffset;
  3. Now, let's build a texture atlas that will be used for font rendering. ImGui will take care of the .ttf font loading and create a font atlas bitmap, which we can use as an OpenGL texture:
    ImFontConfig cfg = ImFontConfig();
  4. Tell ImGui that we are going to manage the memory ourselves:
    cfg.FontDataOwnedByAtlas = false;
  5. Brighten up the font a little bit (the default value is 1.0f). Brightening up small fonts is a good trick you can use to make them more readable:
    Cfg.RasterizerMultiply = 1.5f;
  6. Calculate the pixel height of the font. We take our default window height of 768 and divide it by the desired number of text lines to be fit in the window:
    cfg.SizePixels = 768.0f / 32.0f;
  7. Align every glyph to the pixel boundary and rasterize them at a higher quality for sub-pixel positioning. This will improve the appearance of the text on the screen:
    cfg.PixelSnapH = true;
    cfg.OversampleH = 4;
    cfg.OversampleV = 4;
  8. And, finally, load a .ttf font from a file:
    ImFont* Font = io.Fonts->AddFontFromFileTTF(  "data/OpenSans-Light.ttf", cfg.SizePixels, &cfg);

Now, when the ImGui context initialization is complete, we should take the font atlas bitmap created by ImGui and use it to create an OpenGL texture:

  1. First, let's take the font atlas bitmap data from ImGui in 32-bit RGBA format and upload it to OpenGL:
    unsigned char* pixels = nullptr;
    int width, height;
    io.Fonts->GetTexDataAsRGBA32(  &pixels, &width, &height);
  2. The texture creation code should appear as follows:
    GLuint texture;
    glCreateTextures(GL_TEXTURE_2D, 1, &texture);
    glTextureParameteri(texture, GL_TEXTURE_MAX_LEVEL, 0);
    glTextureParameteri(  texture, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTextureParameteri(  texture, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTextureStorage2D(  texture, 1, GL_RGBA8, width, height);
  3. Scanlines in the ImGui bitmap are not padded. Disable the pixel unpack alignment in OpenGL by setting its value to 1 byte to handle this correctly:
    glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
    glTextureSubImage2D(texture, 0, 0, 0,  width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
    glBindTextures(0, 1, &texture);
  4. We should pass the texture handle to ImGui so that we can use it in subsequent draw calls when required:
    io.Fonts->TexID = (ImTextureID)(intptr_t)texture;
    io.FontDefault = Font;
    io.DisplayFramebufferScale = ImVec2(1, 1);

Now we are ready to proceed with the OpenGL state setup for rendering. All ImGui graphics should be rendered with blending and the scissor test turned on and the depth test and backface culling disabled. Here is the code snippet to set this state:

glEnable(GL_BLEND);
glBlendEquation(GL_FUNC_ADD);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glDisable(GL_CULL_FACE);
glDisable(GL_DEPTH_TEST);
glEnable(GL_SCISSOR_TEST);

Let's go into the main loop and explore, step by step, how to organize the UI rendering workflow:

  1. The main loop starts in a typical GLFW manner, as follows:
    while ( !glfwWindowShouldClose(window) ) {
      int width, height;
      glfwGetFramebufferSize(window, &width, &height);
      glViewport(0, 0, width, height);
      glClear(GL_COLOR_BUFFER_BIT);
  2. Tell ImGui our current window dimensions, start a new frame, and render a demo UI window with ShowDemoWindow():
      ImGuiIO& io = ImGui::GetIO();
      io.DisplaySize =    ImVec2( (float)width, (float)height );
      ImGui::NewFrame();
      ImGui::ShowDemoWindow();
  3. The geometry data is generated in the ImGui::Render() function and can be retrieved via ImGui::GetDrawData():
      ImGui::Render();
      const ImDrawData* draw_data = ImGui::GetDrawData();
  4. Let's construct a proper orthographic projection matrix based on the left, right, top, and bottom clipping planes provided by ImGui:
      Const float L = draw_data->DisplayPos.x;
      const float R = draw_data->DisplayPos.x +                  draw_data->DisplaySize.x;
      const float T = draw_data->DisplayPos.y;
      const float B = draw_data->DisplayPos.y +                  draw_data->DisplaySize.y;
      const mat4 orthoProj = glm::ortho(L, R, B, T);
      glNamedBufferSubData( perFrameDataBuffer, 0,    sizeof(mat4), glm::value_ptr(orthoProj) );
  5. Now we should go through all of the ImGui command lists, update the content of the index and vertex buffers, and invoke the rendering commands:
      for (int n = 0; n < draw_data->CmdListsCount; n++) {
         const ImDrawList* cmd_list =       draw_data->CmdLists[n];
  6. Each ImGui command list has vertex and index data associated with it. Use this data to update the appropriate OpenGL buffers:
      glNamedBufferSubData(handleVBO, 0,    (GLsizeiptr)cmd_list->VtxBuffer.Size *    sizeof(ImDrawVert), cmd_list->VtxBuffer.Data);
      glNamedBufferSubData(handleElements, 0,    (GLsizeiptr)cmd_list->IdxBuffer.Size *    sizeof(ImDrawIdx), cmd_list->IdxBuffer.Data);
  7. Rendering commands are stored inside the command buffer. Iterate over them and render the actual geometry:
         for (int cmd_i = 0; cmd_i < cmd_list->          CmdBuffer.Size; cmd_i++ )      {
            const ImDrawCmd* pcmd =          &cmd_list->CmdBuffer[cmd_i];
            const ImVec4 cr = pcmd->ClipRect;
            glScissor(           (int)cr.x, (int)(height - cr.w),           (int)(cr.z - cr.x), (int)(cr.w - cr.y) );
            glBindTextureUnit(          0, (GLuint)(intptr_t)pcmd->TextureId);
            glDrawElementsBaseVertex(GL_TRIANGLES,          (GLsizei)pcmd->ElemCount, GL_UNSIGNED_SHORT,          (void*)(intptr_t)(pcmd->IdxOffset *          sizeof(ImDrawIdx)), (GLint)pcmd->VtxOffset);
         }
      }
  8. After the UI rendering is complete, reset the scissor rectangle and do the usual GLFW stuff to swap the buffers and poll user events:
      glScissor(0, 0, width, height);
      glfwSwapBuffers(window);
      glfwPollEvents();
    }

Once we exit the main loop, we should destroy the ImGui context with ImGui::DestroyContext(). OpenGL object deletion is similar to some of the previous recipes and will be omitted here for the sake of brevity.

The preceding code will render the UI. To enable user interaction, we need to pass user input events from GLWF to ImGui. Let's demonstrate how to deal with the mouse input to make our minimalistic UI interactive:

  1. First, let's install a cursor position callback for GLFW:
    glfwSetCursorPosCallback(window,  []( auto* window, double x, double y ) {
         ImGui::GetIO().MousePos = ImVec2(x, y );
      });
  2. The final thing we need to bring our UI to life is to set the mouse button callback and route the mouse button events into ImGui:
    glfwSetMouseButtonCallback(window,  [](auto* window, int button, int action, int mods) {
         auto& io = ImGui::GetIO();
         int idx = button == GLFW_MOUSE_BUTTON_LEFT ?        0 : button == GLFW_MOUSE_BUTTON_RIGHT ? 2 : 1;
         io.MouseDown[idx] = action == GLFW_PRESS;
      });

Now we can run our demo application. The application for this recipe renders a Dear ImGui demo window. If everything has been done correctly, the resulting output should look similar to the following screenshot. It is possible to interact with the UI using a mouse:

Figure 2.3 – The Dear ImGui demo window

Figure 2.3 – The Dear ImGui demo window

There's more…

Our minimalistic implementation skipped some features that were needed to handle all ImGui rendering possibilities. For example, we did not implement user-defined rendering callbacks or the handling of flipped clipping rectangles. Please refer to imgui/examples/imgui_impl_opengl3.cpp for more details.

Another important part is to pass all of the necessary GLFW events into ImGui, including numerous keyboard events, cursor shapes, scrolling, and more. The complete reference implementation can be found in imgui/examples/imgui_impl_glfw.cpp.