Skip to content

Instantly share code, notes, and snippets.

@eruvanos
Created July 25, 2024 21:10
Show Gist options
  • Save eruvanos/dcef0132c0f2b535fdd4382d9137f74a to your computer and use it in GitHub Desktop.
Save eruvanos/dcef0132c0f2b535fdd4382d9137f74a to your computer and use it in GitHub Desktop.
Arcade & IMGUI
"""Bridge between arcade and imgui using arcade.gl for rendering.
Works with arcade 3.0.0.dev32 and imgui 2.0.0.
Requires "imgui[pyglet]".
Documentation for imgui usage can be found here
https://pyimgui.readthedocs.io/en/latest/index.html
LICENSE MIT
"""
import ctypes
import imgui
from imgui.integrations import compute_fb_scale
from imgui.integrations.base import BaseOpenGLRenderer
from pyglet import gl, clock
from pyglet.window import key, mouse
import arcade
from arcade import Window
from arcade.gl import BufferDescription, Context
class ArcadeGLRenderer(BaseOpenGLRenderer):
"""
A renderer using the arcade.gl module instead of PyOpenGL.
This is using pyglet's OpenGL bindings instead.
"""
VERTEX_SHADER_SRC = """
#version 330
uniform mat4 ProjMtx;
in vec2 Position;
in vec2 UV;
in vec4 Color;
out vec2 Frag_UV;
out vec4 Frag_Color;
void main() {
Frag_UV = UV;
Frag_Color = Color;
gl_Position = ProjMtx * vec4(Position.xy, 0, 1);
}
"""
FRAGMENT_SHADER_SRC = """
#version 330
uniform sampler2D Texture;
in vec2 Frag_UV;
in vec4 Frag_Color;
out vec4 Out_Color;
void main() {
Out_Color = Frag_Color * texture(Texture, Frag_UV.st);
}
"""
def __init__(self, window: Window, *args, **kwargs):
self._window = window
self._ctx: Context = window.ctx
self._program = None
self._vao = None
self._vbo = None
self._ibo = None
self._font_texture = None
super().__init__()
def render(self, draw_data):
"""Draws the provided draw_data using arcade.gl.
This should be called after imgui.render().
Normally this method should not be used if you do not use ArcaceGLRenderer directly.
"""
io = self.io
display_width, display_height = io.display_size
display_fb_scale = io.display_fb_scale
fb_width = int(display_width * display_fb_scale[0])
fb_height = int(display_height * display_fb_scale[1])
if fb_width == 0 or fb_height == 0:
return
self._program["ProjMtx"] = (
2.0 / fb_width,
0.0,
0.0,
0.0,
0.0,
2.0 / -fb_height,
0.0,
0.0,
0.0,
0.0,
-1.0,
0.0,
-1.0,
1.0,
0.0,
1.0,
)
draw_data.scale_clip_rects(*display_fb_scale)
self._ctx.enable_only(self._ctx.BLEND)
self._ctx.blend_func = self._ctx.BLEND_DEFAULT
self._font_texture.use(0)
for commands in draw_data.commands_lists:
# Write the vertex and index buffer data without copying it
vtx_type = ctypes.c_byte * commands.vtx_buffer_size * imgui.VERTEX_SIZE
idx_type = ctypes.c_byte * commands.idx_buffer_size * imgui.INDEX_SIZE
vtx_arr = (vtx_type).from_address(commands.vtx_buffer_data)
idx_arr = (idx_type).from_address(commands.idx_buffer_data)
self._vbo.write(vtx_arr)
self._ibo.write(idx_arr)
idx_pos = 0
for command in commands.commands:
# Use low level pyglet call here instead because we only have the texture name
gl.glBindTexture(gl.GL_TEXTURE_2D, command.texture_id)
# Set scissor box
x, y, z, w = command.clip_rect
self._ctx.scissor = int(x), int(fb_height - w), int(z - x), int(w - y)
self._vao.render(
self._program,
mode=self._ctx.TRIANGLES,
vertices=command.elem_count,
first=idx_pos,
)
idx_pos += command.elem_count
# Just reset scissor back to default/viewport
self._ctx.scissor = None
def refresh_font_texture(self):
width, height, pixels = self.io.fonts.get_tex_data_as_rgba32()
# Old font texture will be GCed if exist
self._font_texture = self._ctx.texture((width, height), components=4, data=pixels)
self.io.fonts.texture_id = self._font_texture.glo
self.io.fonts.clear_tex_data()
def _create_device_objects(self):
self._program = self._ctx.program(
vertex_shader=self.VERTEX_SHADER_SRC,
fragment_shader=self.FRAGMENT_SHADER_SRC,
)
self._program["Texture"] = 0
self._vbo = self._ctx.buffer(reserve=imgui.VERTEX_SIZE * 65536)
self._ibo = self._ctx.buffer(reserve=imgui.INDEX_SIZE * 65536)
# NOTE: imgui.INDEX_SIZE is type size for the index buffer. We might need to support 8 and 16 bit
# but right now we are assuming 32 bit
self._vao = self._ctx.geometry(
[
BufferDescription(
self._vbo, "2f 2f 4f1", ("Position", "UV", "Color"), normalized=("Color",)
),
],
index_buffer=self._ibo,
)
def _invalidate_device_objects(self):
# NOTE: OpenGL resource will automatically be released
self._font_texture = None
self._vbo = None
self._ibo = None
self._vao = None
self._program = None
self.io.fonts.texture_id = 0
def shutdown(self):
self._invalidate_device_objects()
class PygletMixin:
"""A mixin class to bridge imgui and pyglet event handling."""
REVERSE_KEY_MAP = {
key.TAB: imgui.KEY_TAB,
key.LEFT: imgui.KEY_LEFT_ARROW,
key.RIGHT: imgui.KEY_RIGHT_ARROW,
key.UP: imgui.KEY_UP_ARROW,
key.DOWN: imgui.KEY_DOWN_ARROW,
key.PAGEUP: imgui.KEY_PAGE_UP,
key.PAGEDOWN: imgui.KEY_PAGE_DOWN,
key.HOME: imgui.KEY_HOME,
key.END: imgui.KEY_END,
key.DELETE: imgui.KEY_DELETE,
key.SPACE: imgui.KEY_SPACE,
key.BACKSPACE: imgui.KEY_BACKSPACE,
key.RETURN: imgui.KEY_ENTER,
key.ESCAPE: imgui.KEY_ESCAPE,
key.A: imgui.KEY_A,
key.C: imgui.KEY_C,
key.V: imgui.KEY_V,
key.X: imgui.KEY_X,
key.Y: imgui.KEY_Y,
key.Z: imgui.KEY_Z,
}
def _set_pixel_ratio(self, window):
window_size = window.get_size()
self.io.display_size = window_size
# It is conceivable that the pyglet version will not be solely
# determinant of whether we use the fixed or programmable, so do some
# minor introspection here to check.
if hasattr(window, "get_viewport"):
viewport = window.get_viewport()
viewport_size = viewport[1] - viewport[0], viewport[3] - viewport[2]
self.io.display_fb_scale = compute_fb_scale(window_size, viewport_size)
elif hasattr(window, "get_pixel_ratio"):
self.io.display_fb_scale = (window.get_pixel_ratio(), window.get_pixel_ratio())
else:
# Default to 1.0 in this unlikely circumstance
self.io.display_fb_scale = (1.0, 1.0)
def _attach_callbacks(self, window):
window.push_handlers(
self.on_mouse_motion,
self.on_key_press,
self.on_key_release,
self.on_text,
self.on_mouse_drag,
self.on_mouse_press,
self.on_mouse_release,
self.on_mouse_scroll,
self.on_resize,
)
def _detach_callbacks(self, window):
window.remove_handlers(
self.on_mouse_motion,
self.on_key_press,
self.on_key_release,
self.on_text,
self.on_mouse_drag,
self.on_mouse_press,
self.on_mouse_release,
self.on_mouse_scroll,
self.on_resize,
)
def _map_keys(self):
key_map = self.io.key_map
# note: we cannot use default mechanism of mapping keys
# because pyglet uses weird key translation scheme
for value in self.REVERSE_KEY_MAP.values():
key_map[value] = value
def _on_mods_change(self, mods):
self.io.key_ctrl = mods & key.MOD_CTRL
self.io.key_super = mods & key.MOD_COMMAND
self.io.key_alt = mods & key.MOD_ALT
self.io.key_shift = mods & key.MOD_SHIFT
def on_mouse_motion(self, x, y, dx, dy):
self.io.mouse_pos = x, self.io.display_size.y - y
def on_key_press(self, key_pressed, mods):
if key_pressed in self.REVERSE_KEY_MAP:
self.io.keys_down[self.REVERSE_KEY_MAP[key_pressed]] = True
self._on_mods_change(mods)
def on_key_release(self, key_released, mods):
if key_released in self.REVERSE_KEY_MAP:
self.io.keys_down[self.REVERSE_KEY_MAP[key_released]] = False
self._on_mods_change(mods)
def on_text(self, text):
io = imgui.get_io()
for char in text:
io.add_input_character(ord(char))
def on_mouse_drag(self, x, y, dx, dy, button, modifiers):
self.io.mouse_pos = x, self.io.display_size.y - y
if button == mouse.LEFT:
self.io.mouse_down[0] = 1
if button == mouse.RIGHT:
self.io.mouse_down[1] = 1
if button == mouse.MIDDLE:
self.io.mouse_down[2] = 1
def on_mouse_press(self, x, y, button, modifiers):
self.io.mouse_pos = x, self.io.display_size.y - y
if button == mouse.LEFT:
self.io.mouse_down[0] = 1
if button == mouse.RIGHT:
self.io.mouse_down[1] = 1
if button == mouse.MIDDLE:
self.io.mouse_down[2] = 1
def on_mouse_release(self, x, y, button, modifiers):
self.io.mouse_pos = x, self.io.display_size.y - y
code = 0
delay = 0.2
if button == mouse.LEFT:
delay = 0
elif button == mouse.RIGHT:
code = 1
elif button == mouse.MIDDLE:
code = 2
# Need a slight delay for touch events
def set_mouse(delta_time):
self.io.mouse_down[code] = 0
clock.schedule_once(set_mouse, delay)
def on_mouse_scroll(self, x, y, mods, scroll):
self.io.mouse_wheel = scroll
def on_resize(self, width, height):
self.io.display_size = width, height
class ArcadeRenderer(PygletMixin, ArcadeGLRenderer):
"""A imgui renderer for arcade using arcade.gl for rendering."""
_enabled = False
def __init__(self, window: Window):
if imgui.get_current_context() is None:
imgui.create_context()
imgui.get_io().ini_file_name = "imgui2.ini"
super().__init__(window)
self._set_pixel_ratio(window)
self._map_keys()
def enable(self):
if not self._enabled:
self._enabled = True
self._attach_callbacks(self._window)
def disable(self):
if self._enabled:
self._enabled = False
self._detach_callbacks(self._window)
def show_window(self):
"""Show the imgui demo window."""
imgui.show_demo_window()
def draw(self):
"""Render imgui frame and draw it using arcade.gl on screen."""
imgui.render()
self.render(imgui.get_draw_data())
if __name__ == "__main__":
window = arcade.open_window(
window_title="ImGui Example", resizable=True, width=1280, height=720
)
# setup imgui
renderer = ArcadeRenderer(window)
renderer.enable()
@window.event("on_draw")
def on_draw():
window.clear()
# create imgui frame
imgui.new_frame()
# demo
renderer.show_window()
# own window
imgui.begin("Arcade GUI Debugger")
imgui.text("Hello, world!")
imgui.end()
# main menu
if imgui.begin_main_menu_bar():
if imgui.begin_menu("File", True):
clicked_quit, selected_quit = imgui.menu_item("Quit", "Cmd+Q", False, True)
if clicked_quit:
window.close()
imgui.end_menu()
imgui.end_main_menu_bar()
# render frame and draw imgui
renderer.draw()
window.run()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment