Edit on GitHub

hexdoc.graphics.render

  1# pyright: reportUnknownMemberType=false
  2
  3from __future__ import annotations
  4
  5import logging
  6import math
  7from dataclasses import dataclass
  8from enum import Flag, auto
  9from functools import cached_property
 10from pathlib import Path
 11from typing import Any, Literal, cast
 12
 13import importlib_resources as resources
 14import moderngl as mgl
 15import moderngl_window as mglw
 16import numpy as np
 17from moderngl import Context, Program, Uniform
 18from moderngl_window import WindowConfig
 19from moderngl_window.context.headless import Window as HeadlessWindow
 20from moderngl_window.opengl.vao import VAO
 21from PIL import Image
 22from pydantic import ValidationError
 23from pyrr import Matrix44
 24
 25from hexdoc.core import ModResourceLoader, ResourceLocation
 26from hexdoc.graphics import glsl
 27from hexdoc.minecraft.assets import AnimationMeta
 28from hexdoc.minecraft.assets.animated import AnimationMetaTag
 29from hexdoc.minecraft.models import BlockModel
 30from hexdoc.minecraft.models.base_model import (
 31    DisplayPosition,
 32    ElementFace,
 33    ElementFaceUV,
 34    FaceName,
 35    ModelElement,
 36)
 37from hexdoc.utils.types import Vec3, Vec4
 38
 39logger = logging.getLogger(__name__)
 40
 41
 42# https://minecraft.wiki/w/Help:Isometric_renders#Preferences
 43LIGHT_TOP = 0.98
 44LIGHT_LEFT = 0.8
 45LIGHT_RIGHT = 0.608
 46
 47LIGHT_FLAT = 0.98
 48
 49
 50class DebugType(Flag):
 51    NONE = 0
 52    AXES = auto()
 53    NORMALS = auto()
 54
 55
 56@dataclass(kw_only=True)
 57class BlockRenderer:
 58    loader: ModResourceLoader
 59    output_dir: Path | None = None
 60    debug: DebugType = DebugType.NONE
 61
 62    def __post_init__(self):
 63        self.window = HeadlessWindow(
 64            size=(300, 300),
 65        )
 66        mglw.activate_context(self.window)
 67
 68        self.config = BlockRendererConfig(ctx=self.window.ctx, wnd=self.window)
 69
 70        self.window.config = self.config
 71        self.window.swap_buffers()
 72        self.window.set_default_viewport()
 73
 74    @property
 75    def ctx(self):
 76        return self.window.ctx
 77
 78    def render_block_model(
 79        self,
 80        model: BlockModel | ResourceLocation,
 81        output_path: str | Path,
 82    ):
 83        if isinstance(model, ResourceLocation):
 84            _, model = self.loader.load_resource(
 85                type="assets",
 86                folder="models",
 87                id=model,
 88                decode=BlockModel.model_validate_json,
 89            )
 90
 91        model.load_parents_and_apply(self.loader)
 92
 93        textures = {
 94            name: self.load_texture(texture_id)
 95            for name, texture_id in model.resolve_texture_variables().items()
 96        }
 97
 98        output_path = Path(output_path)
 99        if self.output_dir and not output_path.is_absolute():
100            output_path = self.output_dir / output_path
101
102        self.config.render_block(model, textures, output_path, self.debug)
103
104    def load_texture(self, texture_id: ResourceLocation):
105        logger.debug(f"Loading texture: {texture_id}")
106        _, path = self.loader.find_resource("assets", "textures", texture_id + ".png")
107
108        meta_path = path.with_suffix(".png.mcmeta")
109        if meta_path.is_file():
110            logger.debug(f"Loading animation mcmeta: {meta_path}")
111            # FIXME: hack
112            try:
113                meta = AnimationMeta.model_validate_json(meta_path.read_bytes())
114            except ValidationError as e:
115                logger.warning(f"Failed to parse animation meta for {texture_id}:\n{e}")
116                meta = None
117        else:
118            meta = None
119
120        return BlockTextureInfo(path, meta)
121
122    def destroy(self):
123        self.window.destroy()
124
125    def __enter__(self):
126        return self
127
128    def __exit__(self, *_: Any):
129        self.destroy()
130        return False
131
132
133class BlockRendererConfig(WindowConfig):
134    def __init__(self, ctx: Context, wnd: HeadlessWindow):
135        super().__init__(ctx, wnd)
136
137        # depth test: ensure faces are displayed in the correct order
138        # blend: handle translucency
139        # cull face: remove back faces, eg. for trapdoors
140        self.ctx.enable(mgl.DEPTH_TEST | mgl.BLEND | mgl.CULL_FACE)
141
142        view_size = 16
143        self.projection = Matrix44.orthogonal_projection(
144            left=-view_size / 2,
145            right=view_size / 2,
146            top=view_size / 2,
147            bottom=-view_size / 2,
148            near=0.01,
149            far=20_000,
150            dtype="f4",
151        ) * Matrix44.from_scale((1, -1, 1), "f4")
152
153        self.camera, self.eye = direction_camera(pos="south")
154
155        self.lights = [
156            ((0, -1, 0), LIGHT_TOP),
157            ((1, 0, -1), LIGHT_LEFT),
158            ((-1, 0, -1), LIGHT_RIGHT),
159        ]
160
161        # block faces
162
163        self.face_prog = self.ctx.program(
164            vertex_shader=read_shader("block_face", "vertex"),
165            fragment_shader=read_shader("block_face", "fragment"),
166        )
167
168        self.uniform("m_proj").write(self.projection)
169        self.uniform("m_camera").write(self.camera)
170        self.uniform("layer").value = 0  # TODO: implement animations
171
172        for i, (direction, diffuse) in enumerate(self.lights):
173            self.uniform(f"lights[{i}].direction").value = direction
174            self.uniform(f"lights[{i}].diffuse").value = diffuse
175
176        # axis planes
177
178        self.debug_plane_prog = self.ctx.program(
179            vertex_shader=read_shader("debug/plane", "vertex"),
180            fragment_shader=read_shader("debug/plane", "fragment"),
181        )
182
183        self.uniform("m_proj", self.debug_plane_prog).write(self.projection)
184        self.uniform("m_camera", self.debug_plane_prog).write(self.camera)
185        self.uniform("m_model", self.debug_plane_prog).write(Matrix44.identity("f4"))
186
187        self.debug_axes = list[tuple[VAO, Vec4]]()
188
189        pos = 8
190        neg = 0
191        for from_, to, color, direction in [
192            ((0, neg, neg), (0, pos, pos), (1, 0, 0, 0.75), "east"),
193            ((neg, 0, neg), (pos, 0, pos), (0, 1, 0, 0.75), "up"),
194            ((neg, neg, 0), (pos, pos, 0), (0, 0, 1, 0.75), "south"),
195        ]:
196            vao = VAO()
197            verts = get_face_verts(from_, to, direction)
198            vao.buffer(np.array(verts, np.float32), "3f", ["in_position"])
199            self.debug_axes.append((vao, color))
200
201        # vertex normal vectors
202
203        self.debug_normal_prog = self.ctx.program(
204            vertex_shader=read_shader("debug/normal", "vertex"),
205            geometry_shader=read_shader("debug/normal", "geometry"),
206            fragment_shader=read_shader("debug/normal", "fragment"),
207        )
208
209        self.uniform("m_proj", self.debug_normal_prog).write(self.projection)
210        self.uniform("m_camera", self.debug_normal_prog).write(self.camera)
211        self.uniform("lineSize", self.debug_normal_prog).value = 4
212
213        self.ctx.line_width = 3
214
215    def render_block(
216        self,
217        model: BlockModel,
218        texture_vars: dict[str, BlockTextureInfo],
219        output_path: Path,
220        debug: DebugType = DebugType.NONE,
221    ):
222        if not model.elements:
223            raise ValueError("Unable to render model, no elements found")
224
225        self.wnd.clear()
226
227        # enable/disable flat item lighting
228        match model.gui_light:
229            case "front":
230                flatLighting = LIGHT_FLAT
231            case "side":
232                flatLighting = 0
233        self.uniform("flatLighting").value = flatLighting
234
235        # load textures
236        texture_locs = dict[str, int]()
237        transparent_textures = set[str]()
238
239        for i, (name, info) in enumerate(texture_vars.items()):
240            texture_locs[name] = i
241
242            logger.debug(f"Loading texture {name}: {info}")
243            image = Image.open(info.image_path).convert("RGBA")
244
245            extrema = image.getextrema()
246            assert len(extrema) >= 4, f"Expected 4 bands but got {len(extrema)}"
247            min_alpha, _ = extrema[3]
248            if min_alpha < 255:
249                logger.debug(f"Transparent texture: {name} ({min_alpha=})")
250                transparent_textures.add(name)
251
252            # TODO: implement non-square animations, write test cases
253            match info.meta:
254                case AnimationMeta(
255                    animation=AnimationMetaTag(height=frame_height),
256                ) if frame_height:
257                    # animated with specified size
258                    layers = image.height // frame_height
259                case AnimationMeta():
260                    # size is unspecified, assume it's square and verify later
261                    frame_height = image.width
262                    layers = image.height // frame_height
263                case None:
264                    # non-animated
265                    frame_height = image.height
266                    layers = 1
267
268            if frame_height * layers != image.height:
269                raise RuntimeError(
270                    f"Invalid texture size for variable #{name}:"
271                    + f" {frame_height}x{layers} != {image.height}"
272                    + f"\n  {info}"
273                )
274
275            logger.debug(f"Texture array: {image.width=}, {frame_height=}, {layers=}")
276            texture = self.ctx.texture_array(
277                size=(image.width, frame_height, layers),
278                components=4,
279                data=image.tobytes(),
280            )
281            texture.filter = (mgl.NEAREST, mgl.NEAREST)
282            texture.use(i)
283
284        # transform entire model
285
286        gui = model.display.get("gui") or DisplayPosition(
287            rotation=(30, 225, 0),
288            translation=(0, 0, 0),
289            scale=(0.625, 0.625, 0.625),
290        )
291
292        model_transform = cast(
293            Matrix44,
294            Matrix44.from_scale(gui.scale, "f4")
295            * get_rotation_matrix(gui.eulers)
296            * Matrix44.from_translation(gui.translation, "f4")
297            * Matrix44.from_translation((-8, -8, -8), "f4"),
298        )
299
300        normals_transform = Matrix44.from_y_rotation(-gui.eulers[1], "f4")
301        self.uniform("m_normals").write(normals_transform)
302
303        # render elements
304
305        baked_faces = list[BakedFace]()
306
307        for element in model.elements:
308            element_transform = model_transform.copy()
309
310            # TODO: rescale??
311            if rotation := element.rotation:
312                origin = np.array(rotation.origin)
313                element_transform *= cast(
314                    Matrix44,
315                    Matrix44.from_translation(origin, "f4")
316                    * get_rotation_matrix(rotation.eulers)
317                    * Matrix44.from_translation(-origin, "f4"),
318                )
319
320            # prepare each face of the element for rendering
321            for direction, face in element.faces.items():
322                baked_face = BakedFace(
323                    element=element,
324                    direction=direction,
325                    face=face,
326                    m_model=element_transform,
327                    texture0=texture_locs[face.texture_name],
328                    is_opaque=face.texture_name not in transparent_textures,
329                )
330                baked_faces.append(baked_face)
331
332        # TODO: use a map if this is actually slow
333        baked_faces.sort(key=lambda face: face.sortkey(self.eye))
334
335        for face in baked_faces:
336            self.uniform("m_model").write(face.m_model)
337            self.uniform("texture0").value = face.texture0
338
339            face.vao.render(self.face_prog)
340
341            if DebugType.NORMALS in debug:
342                self.uniform("m_model", self.debug_normal_prog).write(face.m_model)
343                face.vao.render(self.debug_normal_prog)
344
345        if DebugType.AXES in debug:
346            self.ctx.disable(mgl.CULL_FACE)
347            for axis, color in self.debug_axes:
348                self.uniform("color", self.debug_plane_prog).value = color
349                axis.render(self.debug_plane_prog)
350            self.ctx.enable(mgl.CULL_FACE)
351
352        self.ctx.finish()
353
354        # save to file
355
356        image = Image.frombytes(
357            mode="RGBA",
358            size=self.wnd.fbo.size,
359            data=self.wnd.fbo.read(components=4),
360        ).transpose(Image.Transpose.FLIP_TOP_BOTTOM)
361
362        output_path.parent.mkdir(parents=True, exist_ok=True)
363        image.save(output_path, format="png")
364
365    def uniform(self, name: str, program: Program | None = None):
366        program = program or self.face_prog
367        assert isinstance(uniform := program[name], Uniform)
368        return uniform
369
370
371@dataclass
372class BlockTextureInfo:
373    image_path: Path
374    meta: AnimationMeta | None
375
376
377@dataclass(kw_only=True)
378class BakedFace:
379    element: ModelElement
380    direction: FaceName
381    face: ElementFace
382    m_model: Matrix44
383    texture0: float
384    is_opaque: bool
385
386    def __post_init__(self):
387        self.verts = get_face_verts(self.element.from_, self.element.to, self.direction)
388
389        self.normals = get_face_normals(self.direction)
390
391        face_uv = self.face.uv or ElementFaceUV.default(self.element, self.direction)
392        self.uvs = [
393            value
394            for index in get_face_uv_indices(self.direction)
395            for value in face_uv.get_uv(index)
396        ]
397
398        self.vao = VAO()
399        self.vao.buffer(np.array(self.verts, np.float32), "3f", ["in_position"])
400        self.vao.buffer(np.array(self.normals, np.float32), "3f", ["in_normal"])
401        self.vao.buffer(np.array(self.uvs, np.float32) / 16, "2f", ["in_texcoord_0"])
402
403    @cached_property
404    def position(self):
405        x, y, z, n = 0, 0, 0, 0
406        for i in range(0, len(self.verts), 3):
407            x += self.verts[i]
408            y += self.verts[i + 1]
409            z += self.verts[i + 2]
410            n += 1
411        return (x / n, y / n, z / n)
412
413    def sortkey(self, eye: Vec3):
414        if self.is_opaque:
415            return 0
416        return sum((a - b) ** 2 for a, b in zip(eye, self.position))
417
418
419def get_face_verts(from_: Vec3, to: Vec3, direction: FaceName):
420    x1, y1, z1 = from_
421    x2, y2, z2 = to
422
423    # fmt: off
424    match direction:
425        case "south":
426            return [
427                x2, y1, z2,
428                x2, y2, z2,
429                x1, y1, z2,
430                x2, y2, z2,
431                x1, y2, z2,
432                x1, y1, z2,
433            ]
434        case "east":
435            return [
436                x2, y1, z1,
437                x2, y2, z1,
438                x2, y1, z2,
439                x2, y2, z1,
440                x2, y2, z2,
441                x2, y1, z2,
442            ]
443        case "down":
444            return [
445                x2, y1, z1,
446                x2, y1, z2,
447                x1, y1, z2,
448                x2, y1, z1,
449                x1, y1, z2,
450                x1, y1, z1,
451            ]
452        case "west":
453            return [
454                x1, y1, z2,
455                x1, y2, z2,
456                x1, y2, z1,
457                x1, y1, z2,
458                x1, y2, z1,
459                x1, y1, z1,
460            ]
461        case "north":
462            return [
463                x2, y2, z1,
464                x2, y1, z1,
465                x1, y1, z1,
466                x2, y2, z1,
467                x1, y1, z1,
468                x1, y2, z1,
469            ]
470        case "up":
471            return [
472                x2, y2, z1,
473                x1, y2, z1,
474                x2, y2, z2,
475                x1, y2, z1,
476                x1, y2, z2,
477                x2, y2, z2,
478            ]
479    # fmt: on
480
481
482def get_face_normals(direction: FaceName):
483    return 6 * get_direction_vec(direction)
484
485
486def get_face_uv_indices(direction: FaceName):
487    match direction:
488        case "south":
489            return (2, 3, 1, 3, 0, 1)
490        case "east":
491            return (2, 3, 1, 3, 0, 1)
492        case "down":
493            return (2, 3, 0, 2, 0, 1)
494        case "west":
495            return (2, 3, 0, 2, 0, 1)
496        case "north":
497            return (0, 1, 2, 0, 2, 3)
498        case "up":
499            return (3, 0, 2, 0, 1, 2)
500
501
502def orbit_camera(pitch: float, yaw: float):
503    """Both values are in degrees."""
504
505    eye = transform_vec(
506        (-64, 0, 0),
507        cast(
508            Matrix44,
509            Matrix44.identity(dtype="f4")
510            * Matrix44.from_y_rotation(math.radians(yaw))
511            * Matrix44.from_z_rotation(math.radians(pitch)),
512        ),
513    )
514
515    up = transform_vec(
516        (-1, 0, 0),
517        cast(
518            Matrix44,
519            Matrix44.identity(dtype="f4")
520            * Matrix44.from_y_rotation(math.radians(yaw))
521            * Matrix44.from_z_rotation(math.radians(90 - pitch)),
522        ),
523    )
524
525    return Matrix44.look_at(
526        eye=eye,
527        target=(0, 0, 0),
528        up=up,
529        dtype="f4",
530    ), eye
531
532
533def transform_vec(vec: Vec3, matrix: Matrix44) -> Vec3:
534    return np.matmul((*vec, 1), matrix, dtype="f4")[:3]
535
536
537def direction_camera(pos: FaceName, up: FaceName = "up"):
538    """eg. north -> camera is placed to the north of the model, looking south"""
539    eye = get_direction_vec(pos, 64)
540    return Matrix44.look_at(
541        eye=eye,
542        target=(0, 0, 0),
543        up=get_direction_vec(up),
544        dtype="f4",
545    ), eye
546
547
548def get_direction_vec(direction: FaceName, magnitude: float = 1):
549    match direction:
550        case "north":
551            return (0, 0, -magnitude)
552        case "south":
553            return (0, 0, magnitude)
554        case "west":
555            return (-magnitude, 0, 0)
556        case "east":
557            return (magnitude, 0, 0)
558        case "down":
559            return (0, -magnitude, 0)
560        case "up":
561            return (0, magnitude, 0)
562
563
564def read_shader(path: str, type: Literal["fragment", "vertex", "geometry"]):
565    file = resources.files(glsl) / path / f"{type}.glsl"
566    return file.read_text("utf-8")
567
568
569def get_rotation_matrix(eulers: Vec3) -> Matrix44:
570    return cast(
571        Matrix44,
572        Matrix44.from_x_rotation(-eulers[0], "f4")
573        * Matrix44.from_y_rotation(-eulers[1], "f4")
574        * Matrix44.from_z_rotation(-eulers[2], "f4"),
575    )
logger = <Logger hexdoc.graphics.render (WARNING)>
LIGHT_TOP = 0.98
LIGHT_LEFT = 0.8
LIGHT_RIGHT = 0.608
LIGHT_FLAT = 0.98
class DebugType(enum.Flag):
51class DebugType(Flag):
52    NONE = 0
53    AXES = auto()
54    NORMALS = auto()

Support for flags

NONE = <DebugType.NONE: 0>
AXES = <DebugType.AXES: 1>
NORMALS = <DebugType.NORMALS: 2>
@dataclass(kw_only=True)
class BlockRenderer:
 57@dataclass(kw_only=True)
 58class BlockRenderer:
 59    loader: ModResourceLoader
 60    output_dir: Path | None = None
 61    debug: DebugType = DebugType.NONE
 62
 63    def __post_init__(self):
 64        self.window = HeadlessWindow(
 65            size=(300, 300),
 66        )
 67        mglw.activate_context(self.window)
 68
 69        self.config = BlockRendererConfig(ctx=self.window.ctx, wnd=self.window)
 70
 71        self.window.config = self.config
 72        self.window.swap_buffers()
 73        self.window.set_default_viewport()
 74
 75    @property
 76    def ctx(self):
 77        return self.window.ctx
 78
 79    def render_block_model(
 80        self,
 81        model: BlockModel | ResourceLocation,
 82        output_path: str | Path,
 83    ):
 84        if isinstance(model, ResourceLocation):
 85            _, model = self.loader.load_resource(
 86                type="assets",
 87                folder="models",
 88                id=model,
 89                decode=BlockModel.model_validate_json,
 90            )
 91
 92        model.load_parents_and_apply(self.loader)
 93
 94        textures = {
 95            name: self.load_texture(texture_id)
 96            for name, texture_id in model.resolve_texture_variables().items()
 97        }
 98
 99        output_path = Path(output_path)
100        if self.output_dir and not output_path.is_absolute():
101            output_path = self.output_dir / output_path
102
103        self.config.render_block(model, textures, output_path, self.debug)
104
105    def load_texture(self, texture_id: ResourceLocation):
106        logger.debug(f"Loading texture: {texture_id}")
107        _, path = self.loader.find_resource("assets", "textures", texture_id + ".png")
108
109        meta_path = path.with_suffix(".png.mcmeta")
110        if meta_path.is_file():
111            logger.debug(f"Loading animation mcmeta: {meta_path}")
112            # FIXME: hack
113            try:
114                meta = AnimationMeta.model_validate_json(meta_path.read_bytes())
115            except ValidationError as e:
116                logger.warning(f"Failed to parse animation meta for {texture_id}:\n{e}")
117                meta = None
118        else:
119            meta = None
120
121        return BlockTextureInfo(path, meta)
122
123    def destroy(self):
124        self.window.destroy()
125
126    def __enter__(self):
127        return self
128
129    def __exit__(self, *_: Any):
130        self.destroy()
131        return False
BlockRenderer( *, loader: hexdoc.core.ModResourceLoader, output_dir: pathlib.Path | None = None, debug: DebugType = <DebugType.NONE: 0>)
output_dir: pathlib.Path | None = None
debug: DebugType = <DebugType.NONE: 0>
ctx
75    @property
76    def ctx(self):
77        return self.window.ctx
def render_block_model( self, model: hexdoc.minecraft.models.block.BlockModel | hexdoc.core.ResourceLocation, output_path: str | pathlib.Path):
 79    def render_block_model(
 80        self,
 81        model: BlockModel | ResourceLocation,
 82        output_path: str | Path,
 83    ):
 84        if isinstance(model, ResourceLocation):
 85            _, model = self.loader.load_resource(
 86                type="assets",
 87                folder="models",
 88                id=model,
 89                decode=BlockModel.model_validate_json,
 90            )
 91
 92        model.load_parents_and_apply(self.loader)
 93
 94        textures = {
 95            name: self.load_texture(texture_id)
 96            for name, texture_id in model.resolve_texture_variables().items()
 97        }
 98
 99        output_path = Path(output_path)
100        if self.output_dir and not output_path.is_absolute():
101            output_path = self.output_dir / output_path
102
103        self.config.render_block(model, textures, output_path, self.debug)
def load_texture(self, texture_id: hexdoc.core.ResourceLocation):
105    def load_texture(self, texture_id: ResourceLocation):
106        logger.debug(f"Loading texture: {texture_id}")
107        _, path = self.loader.find_resource("assets", "textures", texture_id + ".png")
108
109        meta_path = path.with_suffix(".png.mcmeta")
110        if meta_path.is_file():
111            logger.debug(f"Loading animation mcmeta: {meta_path}")
112            # FIXME: hack
113            try:
114                meta = AnimationMeta.model_validate_json(meta_path.read_bytes())
115            except ValidationError as e:
116                logger.warning(f"Failed to parse animation meta for {texture_id}:\n{e}")
117                meta = None
118        else:
119            meta = None
120
121        return BlockTextureInfo(path, meta)
def destroy(self):
123    def destroy(self):
124        self.window.destroy()
class BlockRendererConfig(moderngl_window.context.base.window.WindowConfig):
134class BlockRendererConfig(WindowConfig):
135    def __init__(self, ctx: Context, wnd: HeadlessWindow):
136        super().__init__(ctx, wnd)
137
138        # depth test: ensure faces are displayed in the correct order
139        # blend: handle translucency
140        # cull face: remove back faces, eg. for trapdoors
141        self.ctx.enable(mgl.DEPTH_TEST | mgl.BLEND | mgl.CULL_FACE)
142
143        view_size = 16
144        self.projection = Matrix44.orthogonal_projection(
145            left=-view_size / 2,
146            right=view_size / 2,
147            top=view_size / 2,
148            bottom=-view_size / 2,
149            near=0.01,
150            far=20_000,
151            dtype="f4",
152        ) * Matrix44.from_scale((1, -1, 1), "f4")
153
154        self.camera, self.eye = direction_camera(pos="south")
155
156        self.lights = [
157            ((0, -1, 0), LIGHT_TOP),
158            ((1, 0, -1), LIGHT_LEFT),
159            ((-1, 0, -1), LIGHT_RIGHT),
160        ]
161
162        # block faces
163
164        self.face_prog = self.ctx.program(
165            vertex_shader=read_shader("block_face", "vertex"),
166            fragment_shader=read_shader("block_face", "fragment"),
167        )
168
169        self.uniform("m_proj").write(self.projection)
170        self.uniform("m_camera").write(self.camera)
171        self.uniform("layer").value = 0  # TODO: implement animations
172
173        for i, (direction, diffuse) in enumerate(self.lights):
174            self.uniform(f"lights[{i}].direction").value = direction
175            self.uniform(f"lights[{i}].diffuse").value = diffuse
176
177        # axis planes
178
179        self.debug_plane_prog = self.ctx.program(
180            vertex_shader=read_shader("debug/plane", "vertex"),
181            fragment_shader=read_shader("debug/plane", "fragment"),
182        )
183
184        self.uniform("m_proj", self.debug_plane_prog).write(self.projection)
185        self.uniform("m_camera", self.debug_plane_prog).write(self.camera)
186        self.uniform("m_model", self.debug_plane_prog).write(Matrix44.identity("f4"))
187
188        self.debug_axes = list[tuple[VAO, Vec4]]()
189
190        pos = 8
191        neg = 0
192        for from_, to, color, direction in [
193            ((0, neg, neg), (0, pos, pos), (1, 0, 0, 0.75), "east"),
194            ((neg, 0, neg), (pos, 0, pos), (0, 1, 0, 0.75), "up"),
195            ((neg, neg, 0), (pos, pos, 0), (0, 0, 1, 0.75), "south"),
196        ]:
197            vao = VAO()
198            verts = get_face_verts(from_, to, direction)
199            vao.buffer(np.array(verts, np.float32), "3f", ["in_position"])
200            self.debug_axes.append((vao, color))
201
202        # vertex normal vectors
203
204        self.debug_normal_prog = self.ctx.program(
205            vertex_shader=read_shader("debug/normal", "vertex"),
206            geometry_shader=read_shader("debug/normal", "geometry"),
207            fragment_shader=read_shader("debug/normal", "fragment"),
208        )
209
210        self.uniform("m_proj", self.debug_normal_prog).write(self.projection)
211        self.uniform("m_camera", self.debug_normal_prog).write(self.camera)
212        self.uniform("lineSize", self.debug_normal_prog).value = 4
213
214        self.ctx.line_width = 3
215
216    def render_block(
217        self,
218        model: BlockModel,
219        texture_vars: dict[str, BlockTextureInfo],
220        output_path: Path,
221        debug: DebugType = DebugType.NONE,
222    ):
223        if not model.elements:
224            raise ValueError("Unable to render model, no elements found")
225
226        self.wnd.clear()
227
228        # enable/disable flat item lighting
229        match model.gui_light:
230            case "front":
231                flatLighting = LIGHT_FLAT
232            case "side":
233                flatLighting = 0
234        self.uniform("flatLighting").value = flatLighting
235
236        # load textures
237        texture_locs = dict[str, int]()
238        transparent_textures = set[str]()
239
240        for i, (name, info) in enumerate(texture_vars.items()):
241            texture_locs[name] = i
242
243            logger.debug(f"Loading texture {name}: {info}")
244            image = Image.open(info.image_path).convert("RGBA")
245
246            extrema = image.getextrema()
247            assert len(extrema) >= 4, f"Expected 4 bands but got {len(extrema)}"
248            min_alpha, _ = extrema[3]
249            if min_alpha < 255:
250                logger.debug(f"Transparent texture: {name} ({min_alpha=})")
251                transparent_textures.add(name)
252
253            # TODO: implement non-square animations, write test cases
254            match info.meta:
255                case AnimationMeta(
256                    animation=AnimationMetaTag(height=frame_height),
257                ) if frame_height:
258                    # animated with specified size
259                    layers = image.height // frame_height
260                case AnimationMeta():
261                    # size is unspecified, assume it's square and verify later
262                    frame_height = image.width
263                    layers = image.height // frame_height
264                case None:
265                    # non-animated
266                    frame_height = image.height
267                    layers = 1
268
269            if frame_height * layers != image.height:
270                raise RuntimeError(
271                    f"Invalid texture size for variable #{name}:"
272                    + f" {frame_height}x{layers} != {image.height}"
273                    + f"\n  {info}"
274                )
275
276            logger.debug(f"Texture array: {image.width=}, {frame_height=}, {layers=}")
277            texture = self.ctx.texture_array(
278                size=(image.width, frame_height, layers),
279                components=4,
280                data=image.tobytes(),
281            )
282            texture.filter = (mgl.NEAREST, mgl.NEAREST)
283            texture.use(i)
284
285        # transform entire model
286
287        gui = model.display.get("gui") or DisplayPosition(
288            rotation=(30, 225, 0),
289            translation=(0, 0, 0),
290            scale=(0.625, 0.625, 0.625),
291        )
292
293        model_transform = cast(
294            Matrix44,
295            Matrix44.from_scale(gui.scale, "f4")
296            * get_rotation_matrix(gui.eulers)
297            * Matrix44.from_translation(gui.translation, "f4")
298            * Matrix44.from_translation((-8, -8, -8), "f4"),
299        )
300
301        normals_transform = Matrix44.from_y_rotation(-gui.eulers[1], "f4")
302        self.uniform("m_normals").write(normals_transform)
303
304        # render elements
305
306        baked_faces = list[BakedFace]()
307
308        for element in model.elements:
309            element_transform = model_transform.copy()
310
311            # TODO: rescale??
312            if rotation := element.rotation:
313                origin = np.array(rotation.origin)
314                element_transform *= cast(
315                    Matrix44,
316                    Matrix44.from_translation(origin, "f4")
317                    * get_rotation_matrix(rotation.eulers)
318                    * Matrix44.from_translation(-origin, "f4"),
319                )
320
321            # prepare each face of the element for rendering
322            for direction, face in element.faces.items():
323                baked_face = BakedFace(
324                    element=element,
325                    direction=direction,
326                    face=face,
327                    m_model=element_transform,
328                    texture0=texture_locs[face.texture_name],
329                    is_opaque=face.texture_name not in transparent_textures,
330                )
331                baked_faces.append(baked_face)
332
333        # TODO: use a map if this is actually slow
334        baked_faces.sort(key=lambda face: face.sortkey(self.eye))
335
336        for face in baked_faces:
337            self.uniform("m_model").write(face.m_model)
338            self.uniform("texture0").value = face.texture0
339
340            face.vao.render(self.face_prog)
341
342            if DebugType.NORMALS in debug:
343                self.uniform("m_model", self.debug_normal_prog).write(face.m_model)
344                face.vao.render(self.debug_normal_prog)
345
346        if DebugType.AXES in debug:
347            self.ctx.disable(mgl.CULL_FACE)
348            for axis, color in self.debug_axes:
349                self.uniform("color", self.debug_plane_prog).value = color
350                axis.render(self.debug_plane_prog)
351            self.ctx.enable(mgl.CULL_FACE)
352
353        self.ctx.finish()
354
355        # save to file
356
357        image = Image.frombytes(
358            mode="RGBA",
359            size=self.wnd.fbo.size,
360            data=self.wnd.fbo.read(components=4),
361        ).transpose(Image.Transpose.FLIP_TOP_BOTTOM)
362
363        output_path.parent.mkdir(parents=True, exist_ok=True)
364        image.save(output_path, format="png")
365
366    def uniform(self, name: str, program: Program | None = None):
367        program = program or self.face_prog
368        assert isinstance(uniform := program[name], Uniform)
369        return uniform

Creating a WindowConfig instance is the simplest interface this library provides to open and window, handle inputs and provide simple shortcut method for loading basic resources. It's appropriate for projects with basic needs.

Example:

.. code:: python

import moderngl_window

class MyConfig(moderngl_window.WindowConfig):
    gl_version = (3, 3)
    window_size = (1920, 1080)
    aspect_ratio = 16 / 9
    title = "My Config"
    resizable = False
    samples = 8

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        # Do other initialization here

    def render(self, time: float, frametime: float):
        # Render stuff here with ModernGL

    def resize(self, width: int, height: int):
        print("Window was resized. buffer size is {} x {}".format(width, height))

    def mouse_position_event(self, x, y, dx, dy):
        print("Mouse position:", x, y)

    def mouse_press_event(self, x, y, button):
        print("Mouse button {} pressed at {}, {}".format(button, x, y))

    def mouse_release_event(self, x: int, y: int, button: int):
        print("Mouse button {} released at {}, {}".format(button, x, y))

    def key_event(self, key, action, modifiers):
        print(key, action, modifiers)
BlockRendererConfig( ctx: moderngl.Context, wnd: moderngl_window.context.headless.window.Window)
135    def __init__(self, ctx: Context, wnd: HeadlessWindow):
136        super().__init__(ctx, wnd)
137
138        # depth test: ensure faces are displayed in the correct order
139        # blend: handle translucency
140        # cull face: remove back faces, eg. for trapdoors
141        self.ctx.enable(mgl.DEPTH_TEST | mgl.BLEND | mgl.CULL_FACE)
142
143        view_size = 16
144        self.projection = Matrix44.orthogonal_projection(
145            left=-view_size / 2,
146            right=view_size / 2,
147            top=view_size / 2,
148            bottom=-view_size / 2,
149            near=0.01,
150            far=20_000,
151            dtype="f4",
152        ) * Matrix44.from_scale((1, -1, 1), "f4")
153
154        self.camera, self.eye = direction_camera(pos="south")
155
156        self.lights = [
157            ((0, -1, 0), LIGHT_TOP),
158            ((1, 0, -1), LIGHT_LEFT),
159            ((-1, 0, -1), LIGHT_RIGHT),
160        ]
161
162        # block faces
163
164        self.face_prog = self.ctx.program(
165            vertex_shader=read_shader("block_face", "vertex"),
166            fragment_shader=read_shader("block_face", "fragment"),
167        )
168
169        self.uniform("m_proj").write(self.projection)
170        self.uniform("m_camera").write(self.camera)
171        self.uniform("layer").value = 0  # TODO: implement animations
172
173        for i, (direction, diffuse) in enumerate(self.lights):
174            self.uniform(f"lights[{i}].direction").value = direction
175            self.uniform(f"lights[{i}].diffuse").value = diffuse
176
177        # axis planes
178
179        self.debug_plane_prog = self.ctx.program(
180            vertex_shader=read_shader("debug/plane", "vertex"),
181            fragment_shader=read_shader("debug/plane", "fragment"),
182        )
183
184        self.uniform("m_proj", self.debug_plane_prog).write(self.projection)
185        self.uniform("m_camera", self.debug_plane_prog).write(self.camera)
186        self.uniform("m_model", self.debug_plane_prog).write(Matrix44.identity("f4"))
187
188        self.debug_axes = list[tuple[VAO, Vec4]]()
189
190        pos = 8
191        neg = 0
192        for from_, to, color, direction in [
193            ((0, neg, neg), (0, pos, pos), (1, 0, 0, 0.75), "east"),
194            ((neg, 0, neg), (pos, 0, pos), (0, 1, 0, 0.75), "up"),
195            ((neg, neg, 0), (pos, pos, 0), (0, 0, 1, 0.75), "south"),
196        ]:
197            vao = VAO()
198            verts = get_face_verts(from_, to, direction)
199            vao.buffer(np.array(verts, np.float32), "3f", ["in_position"])
200            self.debug_axes.append((vao, color))
201
202        # vertex normal vectors
203
204        self.debug_normal_prog = self.ctx.program(
205            vertex_shader=read_shader("debug/normal", "vertex"),
206            geometry_shader=read_shader("debug/normal", "geometry"),
207            fragment_shader=read_shader("debug/normal", "fragment"),
208        )
209
210        self.uniform("m_proj", self.debug_normal_prog).write(self.projection)
211        self.uniform("m_camera", self.debug_normal_prog).write(self.camera)
212        self.uniform("lineSize", self.debug_normal_prog).value = 4
213
214        self.ctx.line_width = 3

Initialize the window config

Keyword Args: ctx (moderngl.Context): The moderngl context wnd: The window instance timer: The timer instance

projection
lights
face_prog
debug_plane_prog
debug_axes
debug_normal_prog
def render_block( self, model: hexdoc.minecraft.models.block.BlockModel, texture_vars: dict[str, BlockTextureInfo], output_path: pathlib.Path, debug: DebugType = <DebugType.NONE: 0>):
216    def render_block(
217        self,
218        model: BlockModel,
219        texture_vars: dict[str, BlockTextureInfo],
220        output_path: Path,
221        debug: DebugType = DebugType.NONE,
222    ):
223        if not model.elements:
224            raise ValueError("Unable to render model, no elements found")
225
226        self.wnd.clear()
227
228        # enable/disable flat item lighting
229        match model.gui_light:
230            case "front":
231                flatLighting = LIGHT_FLAT
232            case "side":
233                flatLighting = 0
234        self.uniform("flatLighting").value = flatLighting
235
236        # load textures
237        texture_locs = dict[str, int]()
238        transparent_textures = set[str]()
239
240        for i, (name, info) in enumerate(texture_vars.items()):
241            texture_locs[name] = i
242
243            logger.debug(f"Loading texture {name}: {info}")
244            image = Image.open(info.image_path).convert("RGBA")
245
246            extrema = image.getextrema()
247            assert len(extrema) >= 4, f"Expected 4 bands but got {len(extrema)}"
248            min_alpha, _ = extrema[3]
249            if min_alpha < 255:
250                logger.debug(f"Transparent texture: {name} ({min_alpha=})")
251                transparent_textures.add(name)
252
253            # TODO: implement non-square animations, write test cases
254            match info.meta:
255                case AnimationMeta(
256                    animation=AnimationMetaTag(height=frame_height),
257                ) if frame_height:
258                    # animated with specified size
259                    layers = image.height // frame_height
260                case AnimationMeta():
261                    # size is unspecified, assume it's square and verify later
262                    frame_height = image.width
263                    layers = image.height // frame_height
264                case None:
265                    # non-animated
266                    frame_height = image.height
267                    layers = 1
268
269            if frame_height * layers != image.height:
270                raise RuntimeError(
271                    f"Invalid texture size for variable #{name}:"
272                    + f" {frame_height}x{layers} != {image.height}"
273                    + f"\n  {info}"
274                )
275
276            logger.debug(f"Texture array: {image.width=}, {frame_height=}, {layers=}")
277            texture = self.ctx.texture_array(
278                size=(image.width, frame_height, layers),
279                components=4,
280                data=image.tobytes(),
281            )
282            texture.filter = (mgl.NEAREST, mgl.NEAREST)
283            texture.use(i)
284
285        # transform entire model
286
287        gui = model.display.get("gui") or DisplayPosition(
288            rotation=(30, 225, 0),
289            translation=(0, 0, 0),
290            scale=(0.625, 0.625, 0.625),
291        )
292
293        model_transform = cast(
294            Matrix44,
295            Matrix44.from_scale(gui.scale, "f4")
296            * get_rotation_matrix(gui.eulers)
297            * Matrix44.from_translation(gui.translation, "f4")
298            * Matrix44.from_translation((-8, -8, -8), "f4"),
299        )
300
301        normals_transform = Matrix44.from_y_rotation(-gui.eulers[1], "f4")
302        self.uniform("m_normals").write(normals_transform)
303
304        # render elements
305
306        baked_faces = list[BakedFace]()
307
308        for element in model.elements:
309            element_transform = model_transform.copy()
310
311            # TODO: rescale??
312            if rotation := element.rotation:
313                origin = np.array(rotation.origin)
314                element_transform *= cast(
315                    Matrix44,
316                    Matrix44.from_translation(origin, "f4")
317                    * get_rotation_matrix(rotation.eulers)
318                    * Matrix44.from_translation(-origin, "f4"),
319                )
320
321            # prepare each face of the element for rendering
322            for direction, face in element.faces.items():
323                baked_face = BakedFace(
324                    element=element,
325                    direction=direction,
326                    face=face,
327                    m_model=element_transform,
328                    texture0=texture_locs[face.texture_name],
329                    is_opaque=face.texture_name not in transparent_textures,
330                )
331                baked_faces.append(baked_face)
332
333        # TODO: use a map if this is actually slow
334        baked_faces.sort(key=lambda face: face.sortkey(self.eye))
335
336        for face in baked_faces:
337            self.uniform("m_model").write(face.m_model)
338            self.uniform("texture0").value = face.texture0
339
340            face.vao.render(self.face_prog)
341
342            if DebugType.NORMALS in debug:
343                self.uniform("m_model", self.debug_normal_prog).write(face.m_model)
344                face.vao.render(self.debug_normal_prog)
345
346        if DebugType.AXES in debug:
347            self.ctx.disable(mgl.CULL_FACE)
348            for axis, color in self.debug_axes:
349                self.uniform("color", self.debug_plane_prog).value = color
350                axis.render(self.debug_plane_prog)
351            self.ctx.enable(mgl.CULL_FACE)
352
353        self.ctx.finish()
354
355        # save to file
356
357        image = Image.frombytes(
358            mode="RGBA",
359            size=self.wnd.fbo.size,
360            data=self.wnd.fbo.read(components=4),
361        ).transpose(Image.Transpose.FLIP_TOP_BOTTOM)
362
363        output_path.parent.mkdir(parents=True, exist_ok=True)
364        image.save(output_path, format="png")
def uniform(self, name: str, program: moderngl.Program | None = None):
366    def uniform(self, name: str, program: Program | None = None):
367        program = program or self.face_prog
368        assert isinstance(uniform := program[name], Uniform)
369        return uniform
@dataclass
class BlockTextureInfo:
372@dataclass
373class BlockTextureInfo:
374    image_path: Path
375    meta: AnimationMeta | None
BlockTextureInfo( image_path: pathlib.Path, meta: hexdoc.minecraft.assets.AnimationMeta | None)
image_path: pathlib.Path
@dataclass(kw_only=True)
class BakedFace:
378@dataclass(kw_only=True)
379class BakedFace:
380    element: ModelElement
381    direction: FaceName
382    face: ElementFace
383    m_model: Matrix44
384    texture0: float
385    is_opaque: bool
386
387    def __post_init__(self):
388        self.verts = get_face_verts(self.element.from_, self.element.to, self.direction)
389
390        self.normals = get_face_normals(self.direction)
391
392        face_uv = self.face.uv or ElementFaceUV.default(self.element, self.direction)
393        self.uvs = [
394            value
395            for index in get_face_uv_indices(self.direction)
396            for value in face_uv.get_uv(index)
397        ]
398
399        self.vao = VAO()
400        self.vao.buffer(np.array(self.verts, np.float32), "3f", ["in_position"])
401        self.vao.buffer(np.array(self.normals, np.float32), "3f", ["in_normal"])
402        self.vao.buffer(np.array(self.uvs, np.float32) / 16, "2f", ["in_texcoord_0"])
403
404    @cached_property
405    def position(self):
406        x, y, z, n = 0, 0, 0, 0
407        for i in range(0, len(self.verts), 3):
408            x += self.verts[i]
409            y += self.verts[i + 1]
410            z += self.verts[i + 2]
411            n += 1
412        return (x / n, y / n, z / n)
413
414    def sortkey(self, eye: Vec3):
415        if self.is_opaque:
416            return 0
417        return sum((a - b) ** 2 for a, b in zip(eye, self.position))
BakedFace( *, element: hexdoc.minecraft.models.base_model.ModelElement, direction: Literal['down', 'up', 'north', 'south', 'west', 'east'], face: hexdoc.minecraft.models.base_model.ElementFace, m_model: pyrr.objects.matrix44.Matrix44, texture0: float, is_opaque: bool)
element: hexdoc.minecraft.models.base_model.ModelElement
direction: Literal['down', 'up', 'north', 'south', 'west', 'east']
face: hexdoc.minecraft.models.base_model.ElementFace
m_model: pyrr.objects.matrix44.Matrix44
texture0: float
is_opaque: bool
position
404    @cached_property
405    def position(self):
406        x, y, z, n = 0, 0, 0, 0
407        for i in range(0, len(self.verts), 3):
408            x += self.verts[i]
409            y += self.verts[i + 1]
410            z += self.verts[i + 2]
411            n += 1
412        return (x / n, y / n, z / n)
def sortkey(self, eye: tuple[~_T_float, ~_T_float, ~_T_float]):
414    def sortkey(self, eye: Vec3):
415        if self.is_opaque:
416            return 0
417        return sum((a - b) ** 2 for a, b in zip(eye, self.position))
def get_face_verts( from_: tuple[~_T_float, ~_T_float, ~_T_float], to: tuple[~_T_float, ~_T_float, ~_T_float], direction: Literal['down', 'up', 'north', 'south', 'west', 'east']):
420def get_face_verts(from_: Vec3, to: Vec3, direction: FaceName):
421    x1, y1, z1 = from_
422    x2, y2, z2 = to
423
424    # fmt: off
425    match direction:
426        case "south":
427            return [
428                x2, y1, z2,
429                x2, y2, z2,
430                x1, y1, z2,
431                x2, y2, z2,
432                x1, y2, z2,
433                x1, y1, z2,
434            ]
435        case "east":
436            return [
437                x2, y1, z1,
438                x2, y2, z1,
439                x2, y1, z2,
440                x2, y2, z1,
441                x2, y2, z2,
442                x2, y1, z2,
443            ]
444        case "down":
445            return [
446                x2, y1, z1,
447                x2, y1, z2,
448                x1, y1, z2,
449                x2, y1, z1,
450                x1, y1, z2,
451                x1, y1, z1,
452            ]
453        case "west":
454            return [
455                x1, y1, z2,
456                x1, y2, z2,
457                x1, y2, z1,
458                x1, y1, z2,
459                x1, y2, z1,
460                x1, y1, z1,
461            ]
462        case "north":
463            return [
464                x2, y2, z1,
465                x2, y1, z1,
466                x1, y1, z1,
467                x2, y2, z1,
468                x1, y1, z1,
469                x1, y2, z1,
470            ]
471        case "up":
472            return [
473                x2, y2, z1,
474                x1, y2, z1,
475                x2, y2, z2,
476                x1, y2, z1,
477                x1, y2, z2,
478                x2, y2, z2,
479            ]
480    # fmt: on
def get_face_normals(direction: Literal['down', 'up', 'north', 'south', 'west', 'east']):
483def get_face_normals(direction: FaceName):
484    return 6 * get_direction_vec(direction)
def get_face_uv_indices(direction: Literal['down', 'up', 'north', 'south', 'west', 'east']):
487def get_face_uv_indices(direction: FaceName):
488    match direction:
489        case "south":
490            return (2, 3, 1, 3, 0, 1)
491        case "east":
492            return (2, 3, 1, 3, 0, 1)
493        case "down":
494            return (2, 3, 0, 2, 0, 1)
495        case "west":
496            return (2, 3, 0, 2, 0, 1)
497        case "north":
498            return (0, 1, 2, 0, 2, 3)
499        case "up":
500            return (3, 0, 2, 0, 1, 2)
def orbit_camera(pitch: float, yaw: float):
503def orbit_camera(pitch: float, yaw: float):
504    """Both values are in degrees."""
505
506    eye = transform_vec(
507        (-64, 0, 0),
508        cast(
509            Matrix44,
510            Matrix44.identity(dtype="f4")
511            * Matrix44.from_y_rotation(math.radians(yaw))
512            * Matrix44.from_z_rotation(math.radians(pitch)),
513        ),
514    )
515
516    up = transform_vec(
517        (-1, 0, 0),
518        cast(
519            Matrix44,
520            Matrix44.identity(dtype="f4")
521            * Matrix44.from_y_rotation(math.radians(yaw))
522            * Matrix44.from_z_rotation(math.radians(90 - pitch)),
523        ),
524    )
525
526    return Matrix44.look_at(
527        eye=eye,
528        target=(0, 0, 0),
529        up=up,
530        dtype="f4",
531    ), eye

Both values are in degrees.

def transform_vec( vec: tuple[~_T_float, ~_T_float, ~_T_float], matrix: pyrr.objects.matrix44.Matrix44) -> tuple[~_T_float, ~_T_float, ~_T_float]:
534def transform_vec(vec: Vec3, matrix: Matrix44) -> Vec3:
535    return np.matmul((*vec, 1), matrix, dtype="f4")[:3]
def direction_camera( pos: Literal['down', 'up', 'north', 'south', 'west', 'east'], up: Literal['down', 'up', 'north', 'south', 'west', 'east'] = 'up'):
538def direction_camera(pos: FaceName, up: FaceName = "up"):
539    """eg. north -> camera is placed to the north of the model, looking south"""
540    eye = get_direction_vec(pos, 64)
541    return Matrix44.look_at(
542        eye=eye,
543        target=(0, 0, 0),
544        up=get_direction_vec(up),
545        dtype="f4",
546    ), eye

eg. north -> camera is placed to the north of the model, looking south

def get_direction_vec( direction: Literal['down', 'up', 'north', 'south', 'west', 'east'], magnitude: float = 1):
549def get_direction_vec(direction: FaceName, magnitude: float = 1):
550    match direction:
551        case "north":
552            return (0, 0, -magnitude)
553        case "south":
554            return (0, 0, magnitude)
555        case "west":
556            return (-magnitude, 0, 0)
557        case "east":
558            return (magnitude, 0, 0)
559        case "down":
560            return (0, -magnitude, 0)
561        case "up":
562            return (0, magnitude, 0)
def read_shader(path: str, type: Literal['fragment', 'vertex', 'geometry']):
565def read_shader(path: str, type: Literal["fragment", "vertex", "geometry"]):
566    file = resources.files(glsl) / path / f"{type}.glsl"
567    return file.read_text("utf-8")
def get_rotation_matrix( eulers: tuple[~_T_float, ~_T_float, ~_T_float]) -> pyrr.objects.matrix44.Matrix44:
570def get_rotation_matrix(eulers: Vec3) -> Matrix44:
571    return cast(
572        Matrix44,
573        Matrix44.from_x_rotation(-eulers[0], "f4")
574        * Matrix44.from_y_rotation(-eulers[1], "f4")
575        * Matrix44.from_z_rotation(-eulers[2], "f4"),
576    )