Mugichoko's blog

Mugichoko’s blog

しがない研究者のプログラミングを中心としたメモ書き.

ModernGL奮闘記 (12) - ImGui -

ImGuiModernGLと組み合わせて使いたいと思い実装してみました.

これまでの取り組み

インストール

公式にある通り,ImGuiのPython Bindingには色々あるようです.今回は紹介されている中で一番上に紹介されていたという理由だけでpyimguiを使ってみました.

github.com

pip install imgui[full]

[full]にあるようなオプションについてはこのページを参照

コード(改訂前)

色々なドキュメントと実装をネット上で探りながら作ってみました.from glfw_quad import Quadで読み込んでいるQuadクラスに関しては,以下の過去記事に置いてあります.

mugichoko.hatenablog.com

所感としては,細かなところで色々詰まったなと... ModernGLは使うWindowによって,なんだかんだ書き方が結構変わってくるみたいですね.

今回もこれまでと同じようにGLFWを使っているのですが,ModernGL-Windowに用意されているmoderngl_window.context.glfw.window.Windowクラスを使いました.これは,これまでのmoderngl_window.create_window_from_settings()を使ってWindowを作成する方法だと,マウススクロールのCallback関数の実装ができなさそうだったためです.

結果,resourceへのアクセスの仕方やFBOの用意の仕方を少し変更しなければなりませんでした.

追記 (20 Jan., 2022):盛大な勘違いしていました.普通に過去記事でもマウススクロールのCallback関数を実装できていました.

import glfw
import imgui
import moderngl as mgl
import numpy as np
from pathlib import Path
from moderngl_window.context.glfw.window import Window
from moderngl_window.scene.camera import Camera
from imgui.integrations.glfw import GlfwRenderer

from glfw_quad import Quad

# https://github.com/moderngl/moderngl-window/blob/dc16c9c0ea9e95b244056dd6b2adf09cb36e5fbe/moderngl_window/context/glfw/window.py
class App(Window):
    def __init__(self, **kwargs):
        # Input parameters
        # Ref: https://moderngl-window.readthedocs.io/en/latest/reference/context/basewindow.html?highlight=BaseWindow
        super().__init__(**kwargs)

        # ImGui
        imgui.create_context()
        self.impl = GlfwRenderer(self._window, attach_callbacks=False)
        # UI elements
        self._slider_value = 0

        # additional callback
        glfw.set_scroll_callback(self._window, self.glfw_mouse_scroll_callback)
        
        # Resources
        self.resource_dir = Path(__file__).parent.resolve() / "resources"
        self.shader_dir = (self.resource_dir / "shaders").resolve()
        self.program = self.ctx.program(
            vertex_shader=open(self.shader_dir / "uv_vs.glsl").read(),
            fragment_shader=open(self.shader_dir / "uv_fs.glsl").read()
        )

        # Quad
        self.quad = Quad(self.ctx, self.program)
        # FBO for rendering Quad
        self.fbo_quad = self.ctx.framebuffer(
            color_attachments=self.ctx.texture((256, 256), 4),
            depth_attachment=self.ctx.depth_texture((256, 256)),
        )
        # Camera
        self.camera = Camera(aspect_ratio=self.fbo_quad.width / self.fbo_quad.height, near=0.01, far=100.0)
        self.camera.set_position(0, 0.0, 1.5)
        self.camera.look_at(pos=(0, 0, 0))

    def glfw_mouse_scroll_callback(self, window, x_offset: float, y_offset: float):
        self._slider_value = max(0, min(self._slider_value + y_offset, 100))
        self._mouse_scroll_event_func(x_offset, y_offset)

    def render_quad(self):
        self.fbo_quad.use()
        self.fbo_quad.clear(1, 0, 1, 1)
        self.ctx.enable(mgl.DEPTH_TEST | mgl.CULL_FACE)
        self.quad.render(self.camera)
        self.fbo.use()  # get back to the default target

    def render_ui(self):
        glfw.poll_events()
        self.impl.process_inputs()
        imgui.new_frame()

        if imgui.begin_main_menu_bar():
            if imgui.begin_menu("File", True):
                clicked_quite, selected_quit = imgui.menu_item(
                    "Quit", "Esc", False, True
                )
                if clicked_quite:
                    self.close()
                imgui.end_menu()
            imgui.end_main_menu_bar()

        imgui.begin("UI info.", True)
        changed, self._slider_value = imgui.slider_int(
            "slider", self._slider_value,
            min_value=0, max_value=100
        )
        screen_pos = imgui.get_cursor_screen_pos()  # this gets the location where it's called
        # https://github.com/moderngl/moderngl-window/blob/268c8d886e99e9aae05ea3bb5941994dc2a99569/examples/integration_imgui_image.py#L79
        imgui.image(self.fbo_quad.color_attachments[0].glo, *self.fbo_quad.size)
        if imgui.is_item_hovered():
            imgui.begin_tooltip()
            imgui.text("Runtime rendering!")
            imgui.end_tooltip()
        imgui.text(f"mouse pos: {self._mouse_pos}")
        imgui.text(f"screen pos: ({screen_pos[0]}, {screen_pos[1]})")
        relative_mouse_pos = (np.array(self._mouse_pos) - np.array(screen_pos)).astype(int)
        imgui.text(f"mouse pos in image: ({relative_mouse_pos[0]}, {relative_mouse_pos[1]})")
        imgui.end()

        imgui.begin("Desc.", True)
        imgui.push_text_wrap_pos(imgui.get_window_width())
        imgui.text_colored("slider:", 1, 1, 0)
        imgui.text("An example slider (int).")
        imgui.text_colored("image:", 1, 1, 0)
        imgui.text("An example image rendered at runtime.")
        imgui.text_colored("mouse pos:", 1, 1, 0)
        imgui.text("Mouse position in the window coordinate system.")
        imgui.text_colored("screen pos", 1, 1, 0)
        imgui.text("UI window location.")
        imgui.text_colored("mouse pos in image", 1, 1, 0)
        imgui.text("Mouse position in the image coordinate system.")
        imgui.pop_text_wrap_pos()
        imgui.end()

        imgui.render()
        self.impl.render(imgui.get_draw_data())

if __name__ == "__main__":
    app = App(size=(640, 480), title="ImGui test")

    while not app.is_closing:
        app.clear(1, 1, 0, 0)
        app.render_quad()
        app.render_ui()
        app.swap_buffers()

コード(改訂後:後日追記分)

追記 (20 Jan., 2022):先述の通り,これまでの書き方を踏襲して同じことができました.

#
# glfw_base_window.py
#
from pathlib import Path
import moderngl_window as mglw
from moderngl_window.conf import settings
from moderngl_window import resources

class BaseWindow:
    def __init__(self, wnd_size=(512, 512), title="GLFW") -> None:
        # create a gl window: https://moderngl-window.readthedocs.io/en/latest/reference/settings.conf.settings.html#moderngl_window.conf.Settings.WINDOW
        settings.WINDOW["class"] = "moderngl_window.context.glfw.Window"
        # OpenGL 4.3 or upper is required for compute shaders
        settings.WINDOW["gl_version"] = (4, 3)
        settings.WINDOW["title"] = title
        settings.WINDOW["size"] = wnd_size
        settings.WINDOW["aspect_ratio"] = wnd_size[0] / wnd_size[1]
        self.wnd = mglw.create_window_from_settings()

        # Resources
        self.resource_dir = Path(__file__).parent.resolve() / "resources"
        self.shaders_dir = (self.resource_dir / "shaders").resolve()
        self.textures_dir = (self.resource_dir / "textures").resolve()
        resources.register_scene_dir((self.resource_dir / "models").resolve())
        resources.register_program_dir(self.shaders_dir)

        # Set mouse callback
        ## Ref: https://github.com/moderngl/moderngl-window/blob/master/examples/custom_config_class.py
        self.wnd.resize_func = self.resize
        self.wnd.key_event_func = self.key_event
        self.wnd.mouse_position_event_func = self.mouse_position_event
        self.wnd.mouse_scroll_event_func = self.mouse_scroll_event
        self.wnd.mouse_drag_event_func = self.mouse_drag_event
        self.wnd.mouse_press_event_func = self.mouse_press_event
        self.wnd.mouse_release_event_func = self.mouse_release_event
        self.wnd.unicode_char_entered_func = self.unicode_char_entered
        ##self.wnd.mouse_exclusivity = True

        self._mouse_pos = [0, 0]
        self._mouse_pos_delta = [0, 0]
        self._scroll_offset = [0, 0]

    def resize(self, width: int, height: int):
        pass

    def key_event(self, key, action, modifiers):
        pass

    def mouse_position_event(self, x, y, dx, dy) -> None:
        pass

    def mouse_scroll_event(self, x_offset, y_offset) -> None:
        pass

    def mouse_drag_event(self, x, y, dx, dy):
        pass
    
    def mouse_press_event(self, x, y, button):
        pass

    def mouse_release_event(self, x: int, y: int, button: int):
        pass

    def unicode_char_entered(self, char):
        pass
#
# glfw_imgui.py
#
import numpy as np
import imgui
import moderngl as mgl
from moderngl_window.integrations.imgui import ModernglWindowRenderer
from moderngl_window.resources import programs
from moderngl_window.meta import ProgramDescription
from moderngl_window.scene.camera import Camera

from glfw_base_window import BaseWindow
from glfw_quad import Quad

# https://github.com/moderngl/moderngl-window/blob/2.1/examples/integration_imgui.py
class App(BaseWindow):
    def __init__(self, wnd_size: tuple[int, int], title: str = "App") -> None:
        super().__init__(wnd_size=wnd_size, title=title)
        imgui.create_context()
        self.wnd.ctx.error
        self.imgui = ModernglWindowRenderer(self.wnd)

        # Shaders
        self.program = programs.load(
            ProgramDescription(
                vertex_shader="uv_vs.glsl",
                fragment_shader="uv_fs.glsl",
            )
        )

        # Quad
        self.quad = Quad(self.wnd.ctx, self.program)

        # FBO for rendering Quad
        self.fbo_quad = self.wnd.ctx.framebuffer(
            color_attachments=self.wnd.ctx.texture((256, 256), 4),
            depth_attachment=self.wnd.ctx.depth_texture((256, 256)),
        )
        # Register the texture so that imgui can use it
        # https://github.com/moderngl/moderngl-window/blob/268c8d886e99e9aae05ea3bb5941994dc2a99569/examples/integration_imgui_image.py#L79
        self.imgui.register_texture(self.fbo_quad.color_attachments[0])
        # Camera
        self.camera = Camera(aspect_ratio=self.fbo_quad.width / self.fbo_quad.height, near=0.01, far=100.0)
        self.camera.set_position(0, 0.0, 1.5)
        self.camera.look_at(pos=(0, 0, 0))

    def resize(self, width: int, height: int):
        self.imgui.wnd.fixed_aspect_ratio = width / height
        self.imgui.wnd.set_default_viewport()
        self.imgui.resize(width, height)

    def key_event(self, key, action, modifiers):
        self.imgui.key_event(key, action, modifiers)

    def mouse_position_event(self, x, y, dx, dy) -> None:
        # print("Mouse position pos={} {} delta={} {}".format(x, y, dx, dy))
        self._mouse_pos = [x, y]
        self._mouse_pos_delta = [dx, dy]
        self.imgui.mouse_position_event(x, y, dx, dy)

    def mouse_scroll_event(self, x_offset, y_offset) -> None:
        # print("mouse_scroll_event", x_offset, y_offset)
        self._scroll_offset = [self._scroll_offset[0] + x_offset, self._scroll_offset[1] + y_offset]
        self._scroll_offset_delta = [x_offset, y_offset]
        self.imgui.mouse_scroll_event(x_offset, y_offset)
    
    def mouse_drag_event(self, x, y, dx, dy):
        self.imgui.mouse_drag_event(x, y, dx, dy)

    def mouse_press_event(self, x, y, button):
        self.imgui.mouse_press_event(x, y, button)

    def mouse_release_event(self, x: int, y: int, button: int):
        self.imgui.mouse_release_event(x, y, button)
        
    def unicode_char_entered(self, char):
        self.imgui.unicode_char_entered(char)

    def render_quad(self):
        self.fbo_quad.use()
        self.fbo_quad.clear(1, 0, 1, 1)
        self.wnd.ctx.enable(mgl.DEPTH_TEST | mgl.CULL_FACE)
        self.quad.render(self.camera)
        self.wnd.use()  # get back to the default target

    def render_ui(self):
        imgui.new_frame()
        if imgui.begin_main_menu_bar():
            if imgui.begin_menu("File", True):
                clicked_quite, selected_quit = imgui.menu_item(
                    "Quit", "Esc", False, True
                )
                if clicked_quite:
                    self.wnd.close()
                imgui.end_menu()
            imgui.end_main_menu_bar()

        imgui.begin("UI info.", True)
        self._scroll_offset[1] = int(max(0, min(self._scroll_offset[1], 100)))
        changed, self._scroll_offset[1] = imgui.slider_int(
            "slider", self._scroll_offset[1],
            min_value=0, max_value=100
        )
        screen_pos = imgui.get_cursor_screen_pos()  # this gets the location where it's called
        imgui.image(self.fbo_quad.color_attachments[0].glo, *self.fbo_quad.size)
        if imgui.is_item_hovered():
            imgui.begin_tooltip()
            imgui.text("Runtime rendering!")
            imgui.end_tooltip()
        imgui.text(f"mouse pos: {self._mouse_pos}")
        imgui.text(f"screen pos: ({screen_pos[0]}, {screen_pos[1]})")
        relative_mouse_pos = (np.array(self._mouse_pos) - np.array(screen_pos)).astype(int)
        imgui.text(f"mouse pos in image: ({relative_mouse_pos[0]}, {relative_mouse_pos[1]})")
        imgui.end()

        imgui.begin("Desc.", True)
        imgui.push_text_wrap_pos(imgui.get_window_width())
        imgui.text_colored("slider:", 1, 1, 0)
        imgui.text("An example slider (int).")
        imgui.text_colored("image:", 1, 1, 0)
        imgui.text("An example image rendered at runtime.")
        imgui.text_colored("mouse pos:", 1, 1, 0)
        imgui.text("Mouse position in the window coordinate system.")
        imgui.text_colored("screen pos", 1, 1, 0)
        imgui.text("UI window location.")
        imgui.text_colored("mouse pos in image", 1, 1, 0)
        imgui.text("Mouse position in the image coordinate system.")
        imgui.pop_text_wrap_pos()
        imgui.end()
        
        imgui.render()
        self.imgui.render(imgui.get_draw_data())

    def run(self):
        while not self.wnd.is_closing:
            self.wnd.clear(1, 1, 0, 0)
            self.render_quad()
            self.render_ui()
            self.wnd.swap_buffers()

if __name__ == "__main__":
    app = App(wnd_size=(640, 480), title="Test ImGui")
    app.run()

実装結果

以下の映像のようなものが実装できました.小さなこだわりとして,

  • スライダがマウススクロールでも操作でる
  • 右のDesc.内のテキストはそのウィンドウのサイズによって折り返される
  • 画像上にマウスカーソルを被せるとヒントの小窓が出てくる

というのがあります.

youtu.be