Skip to content

Instantly share code, notes, and snippets.

@8Observer8
Created November 16, 2025 16:20
Show Gist options
  • Select an option

  • Save 8Observer8/a8f41f104bb33fe99b54b39c8687cf31 to your computer and use it in GitHub Desktop.

Select an option

Save 8Observer8/a8f41f104bb33fe99b54b39c8687cf31 to your computer and use it in GitHub Desktop.
Pygame and ModernGL. Move a camera with WASD and arrow keys
# pip install moderngl pygame numpy pyglm
from typing import Optional, Tuple
import glm
import moderngl
import numpy as np
import pygame as pg
# ---------------- TYPES ---------------- #
Color = Tuple[int, int, int, int] # (R, G, B, A)
class WindowSettings:
def __init__(self, size=(640, 480), clear_color=(0, 0, 0, 255)):
self.size = size
self.clear_color = clear_color
class IRenderer:
def __init__(self, settings: WindowSettings):
self.settings = settings
# ---------------- SHADERS ---------------- #
RECT_VERTEX_SHADER = """
#version 330 core
in vec2 in_vert;
in vec4 in_color;
uniform mat4 uMvpMatrix;
out vec4 v_color;
void main() {
gl_Position = uMvpMatrix * vec4(in_vert, 0.0, 1.0);
v_color = in_color;
}
"""
RECT_FRAGMENT_SHADER = """
#version 330 core
in vec4 v_color;
out vec4 f_color;
void main() {
f_color = v_color;
}
"""
TEXT_VERTEX_SHADER = """
#version 330 core
in vec2 in_vert;
in vec2 in_tex;
uniform mat4 uMvpMatrix;
out vec2 v_tex;
void main() {
gl_Position = uMvpMatrix * vec4(in_vert, 0.0, 1.0);
v_tex = in_tex;
}
"""
TEXT_FRAGMENT_SHADER = """
#version 330 core
in vec2 v_tex;
out vec4 f_color;
uniform sampler2D texture0;
uniform vec4 color;
void main() {
vec4 tex_color = texture(texture0, v_tex);
f_color = vec4(color.rgb, tex_color.a * color.a);
}
"""
# ---------------- RENDERER ---------------- #
class ModernGLRenderer(IRenderer):
"""2D renderer using ModernGL in pixel coordinates"""
def __init__(self, settings: WindowSettings):
super().__init__(settings)
# Create window
flags = pg.OPENGL | pg.DOUBLEBUF | pg.RESIZABLE
self._screen = pg.display.set_mode(settings.size, flags)
self.ctx = moderngl.create_context()
self.ctx.enable(moderngl.BLEND)
self.ctx.blend_func = (moderngl.SRC_ALPHA, moderngl.ONE_MINUS_SRC_ALPHA)
# Camera (NEW)
self.cam_pos = glm.vec3(0, 0, 5)
self.cam_target = glm.vec3(0, 0, 0)
self.cam_up = glm.vec3(0, 1, 0)
self.view_matrix = glm.lookAt(self.cam_pos, self.cam_target, self.cam_up)
# Programs
self.rect_program = self.ctx.program(
vertex_shader=RECT_VERTEX_SHADER,
fragment_shader=RECT_FRAGMENT_SHADER
)
self.text_program = self.ctx.program(
vertex_shader=TEXT_VERTEX_SHADER,
fragment_shader=TEXT_FRAGMENT_SHADER
)
# Projection matrix
self._update_projection()
# Font
pg.font.init()
self.font = pg.font.SysFont('Arial', 24)
# ---------------- PROJECTION ---------------- #
def _update_projection(self):
width, height = self.settings.size
self.proj_matrix = glm.ortho(0.0, width, height, 0.0, -10.0, 10.0)
def handle_resize(self, new_width: int, new_height: int):
self.settings.size = (new_width, new_height)
self.ctx.viewport = (0, 0, new_width, new_height)
self._update_projection()
# ---------------- CAMERA UPDATE (NEW) ---------------- #
def update_camera(self, dt):
speed = 300.0 * dt # pixels per second
keys = pg.key.get_pressed()
# WASD movement
if keys[pg.K_w]:
self.cam_pos.y -= speed
if keys[pg.K_s]:
self.cam_pos.y += speed
if keys[pg.K_a]:
self.cam_pos.x -= speed
if keys[pg.K_d]:
self.cam_pos.x += speed
# Arrow keys (same as WASD)
if keys[pg.K_UP]:
self.cam_pos.y -= speed
if keys[pg.K_DOWN]:
self.cam_pos.y += speed
if keys[pg.K_LEFT]:
self.cam_pos.x -= speed
if keys[pg.K_RIGHT]:
self.cam_pos.x += speed
# Recreate view matrix
self.cam_target = glm.vec3(self.cam_pos.x, self.cam_pos.y, 0)
self.view_matrix = glm.lookAt(self.cam_pos, self.cam_target, self.cam_up)
# ---------------- COLOR UTILS ---------------- #
@staticmethod
def _color_normalize(color: Color):
return tuple(c / 255.0 for c in color)
# ---------------- FRAME ---------------- #
def begin_frame(self):
self.clear()
def end_frame(self):
pg.display.flip()
def clear(self, color: Optional[Color] = None):
nor_color = self._color_normalize(color if color else self.settings.clear_color)
self.ctx.clear(*nor_color)
# ---------------- BUILD MVP ---------------- #
def _make_mvp(self, model):
mvp = self.proj_matrix * self.view_matrix * model
return np.array(mvp.to_list(), dtype="f4").tobytes()
# ---------------- DRAW RECTANGLE ---------------- #
def draw_rectangle(self, x, y, width, height, color=(255, 255, 255, 255)):
color_normalized = self._color_normalize(color)
vertices = np.array([
0.0, 0.0, *color_normalized,
width, 0.0, *color_normalized,
0.0, height, *color_normalized,
width, 0.0, *color_normalized,
width, height, *color_normalized,
0.0, height, *color_normalized,
], dtype='f4')
vbo = self.ctx.buffer(vertices.tobytes())
vao = self.ctx.vertex_array(self.rect_program, [(vbo, '2f 4f', 'in_vert', 'in_color')])
model = glm.translate(glm.mat4(1.0), glm.vec3(x, y, 0))
self.rect_program['uMvpMatrix'].write(self._make_mvp(model))
vao.render()
vbo.release()
vao.release()
# ---------------- DRAW TEXT ---------------- #
def draw_text(self, text: str, x: float, y: float, color: Color = (255, 255, 255, 255)):
color_normalized = self._color_normalize(color)
text_surface = self.font.render(text, True, (255, 255, 255))
w, h = text_surface.get_size()
texture_data = pg.image.tostring(text_surface, 'RGBA')
texture = self.ctx.texture((w, h), 4, texture_data)
texture.filter = (moderngl.LINEAR, moderngl.LINEAR)
vertices = np.array([
0.0, 0.0, 0.0, 0.0,
w, 0.0, 1.0, 0.0,
0.0, h, 0.0, 1.0,
w, 0.0, 1.0, 0.0,
w, h, 1.0, 1.0,
0.0, h, 0.0, 1.0,
], dtype='f4')
vbo = self.ctx.buffer(vertices.tobytes())
vao = self.ctx.vertex_array(self.text_program, [(vbo, '2f 2f', 'in_vert', 'in_tex')])
model = glm.translate(glm.mat4(1.0), glm.vec3(x, y, 0))
self.text_program['uMvpMatrix'].write(self._make_mvp(model))
self.text_program['color'].value = color_normalized
texture.use(0)
vao.render()
texture.release()
vbo.release()
vao.release()
# ---------------- MAIN ---------------- #
def main():
pg.init()
clock = pg.time.Clock()
settings = WindowSettings(size=(600, 400), clear_color=(50, 50, 50, 255))
renderer = ModernGLRenderer(settings)
running = True
while running:
dt = clock.tick(60) / 1000.0 # seconds
for event in pg.event.get():
if event.type == pg.QUIT:
running = False
elif event.type == pg.VIDEORESIZE:
renderer.handle_resize(*event.size)
# Update camera each frame
renderer.update_camera(dt)
renderer.begin_frame()
renderer.draw_rectangle(50, 50, 200, 100, (255, 0, 0, 255))
renderer.draw_text("Camera WASD + Arrows!", 300, 200, (0, 255, 0, 255))
renderer.end_frame()
pg.quit()
if __name__ == "__main__":
main()
@8Observer8
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment