Source code for arcade.context

"""
Arcade's version of the OpenGL Context.
Contains pre-loaded programs
"""

from array import array
from collections.abc import Iterable, Sequence
from pathlib import Path
from typing import Any

import pyglet
from PIL import Image
from pyglet import gl
from pyglet.graphics.shader import UniformBufferObject
from pyglet.math import Mat4

import arcade
from arcade.camera import Projector
from arcade.camera.default import DefaultProjector
from arcade.gl import BufferDescription, Context
from arcade.gl.compute_shader import ComputeShader
from arcade.gl.framebuffer import Framebuffer
from arcade.gl.program import Program
from arcade.gl.texture import Texture2D
from arcade.gl.vertex_array import Geometry
from arcade.texture_atlas import DefaultTextureAtlas, TextureAtlasBase

__all__ = ["ArcadeContext"]


[docs] class ArcadeContext(Context): """ An OpenGL context implementation for Arcade with added custom features. This context is normally accessed through :py:attr:`arcade.Window.ctx`. Args: window: The pyglet window gc_mode: The garbage collection mode for OpenGL objects. ``auto`` is just what we would expect in python while ``context_gc`` (default) requires you to call ``Context.gc()``. The latter can be useful when using multiple threads when it's not clear what thread will gc the object. gl_api: The OpenGL API to use. By default it's set to ``gl`` which is the standard OpenGL API. If you want to use OpenGL ES you can set it to ``gles``. """ atlas_size: tuple[int, int] = 512, 512 def __init__( self, window: pyglet.window.Window, # type: ignore gc_mode: str = "context_gc", gl_api: str = "gl", ) -> None: super().__init__(window, gc_mode=gc_mode, gl_api=gl_api) # Set up a default orthogonal projection for sprites and shapes self._window_block: UniformBufferObject = window.ubo self.bind_window_block() self.blend_func = self.BLEND_DEFAULT self._default_camera: DefaultProjector = DefaultProjector(context=self) self.current_camera: Projector = self._default_camera self.viewport = (0, 0, window.width, window.height) # --- Pre-load system shaders here --- # FIXME: These pre-created resources needs to be packaged nicely # Just having them globally in the context is probably not a good idea self.line_vertex_shader: Program = self.load_program( vertex_shader=":system:shaders/shapes/line/line_vertex_shader_vs.glsl", fragment_shader=":system:shaders/shapes/line/line_vertex_shader_fs.glsl", ) self.line_generic_with_colors_program: Program = self.load_program( vertex_shader=":system:shaders/shapes/line/line_generic_with_colors_vs.glsl", fragment_shader=":system:shaders/shapes/line/line_generic_with_colors_fs.glsl", ) self.shape_element_list_program: Program = self.load_program( vertex_shader=":system:shaders/shape_element_list_vs.glsl", fragment_shader=":system:shaders/shape_element_list_fs.glsl", ) self.sprite_list_program_no_cull: Program = self.load_program( vertex_shader=":system:shaders/sprites/sprite_list_geometry_vs.glsl", geometry_shader=":system:shaders/sprites/sprite_list_geometry_no_cull_geo.glsl", fragment_shader=":system:shaders/sprites/sprite_list_geometry_fs.glsl", ) self.sprite_list_program_no_cull["sprite_texture"] = 0 self.sprite_list_program_no_cull["uv_texture"] = 1 self.sprite_list_program_cull: Program = self.load_program( vertex_shader=":system:shaders/sprites/sprite_list_geometry_vs.glsl", geometry_shader=":system:shaders/sprites/sprite_list_geometry_cull_geo.glsl", fragment_shader=":system:shaders/sprites/sprite_list_geometry_fs.glsl", ) self.sprite_list_program_cull["sprite_texture"] = 0 self.sprite_list_program_cull["uv_texture"] = 1 self.sprite_list_program_no_geo = self.load_program( vertex_shader=":system:shaders/sprites/sprite_list_simple_vs.glsl", fragment_shader=":system:shaders/sprites/sprite_list_simple_fs.glsl", ) self.sprite_list_program_no_geo["sprite_texture"] = 0 self.sprite_list_program_no_geo["uv_texture"] = 1 # Per-instance data self.sprite_list_program_no_geo["pos_data"] = 2 self.sprite_list_program_no_geo["size_data"] = 3 self.sprite_list_program_no_geo["color_data"] = 4 self.sprite_list_program_no_geo["texture_id_data"] = 5 self.sprite_list_program_no_geo["index_data"] = 6 # Geo shader single sprite program self.sprite_program_single = self.load_program( vertex_shader=":system:shaders/sprites/sprite_single_vs.glsl", geometry_shader=":system:shaders/sprites/sprite_list_geometry_no_cull_geo.glsl", fragment_shader=":system:shaders/sprites/sprite_list_geometry_fs.glsl", ) self.sprite_program_single["sprite_texture"] = 0 self.sprite_program_single["uv_texture"] = 1 self.sprite_program_single["spritelist_color"] = 1.0, 1.0, 1.0, 1.0 # Non-geometry shader single sprite program self.sprite_program_single_simple = self.load_program( vertex_shader=":system:shaders/sprites/sprite_single_simple_vs.glsl", fragment_shader=":system:shaders/sprites/sprite_list_simple_fs.glsl", ) self.sprite_program_single_simple["sprite_texture"] = 0 self.sprite_program_single_simple["uv_texture"] = 1 self.sprite_program_single_simple["spritelist_color"] = 1.0, 1.0, 1.0, 1.0 # fmt: off self.spritelist_geometry_simple = self.geometry( [ BufferDescription( self.buffer( data=array("f", [ -0.5, +0.5, # Upper left -0.5, -0.5, # lower left +0.5, +0.5, # upper right +0.5, -0.5, # lower right ]) ), "2f", ["in_pos"] ), ], mode=self.TRIANGLE_STRIP, ) # fmt: on # Shapes self.shape_line_program: Program = self.load_program( vertex_shader=":system:shaders/shapes/line/unbuffered_vs.glsl", fragment_shader=":system:shaders/shapes/line/unbuffered_fs.glsl", ) self.shape_ellipse_filled_unbuffered_program: Program = self.load_program( vertex_shader=":system:shaders/shapes/ellipse/filled_unbuffered_vs.glsl", fragment_shader=":system:shaders/shapes/ellipse/filled_unbuffered_fs.glsl", ) self.shape_ellipse_outline_unbuffered_program: Program = self.load_program( vertex_shader=":system:shaders/shapes/ellipse/outline_unbuffered_vs.glsl", fragment_shader=":system:shaders/shapes/ellipse/outline_unbuffered_fs.glsl", ) self.shape_rectangle_filled_unbuffered_program = self.load_program( vertex_shader=":system:shaders/shapes/rectangle/filled_unbuffered_vs.glsl", fragment_shader=":system:shaders/shapes/rectangle/filled_unbuffered_fs.glsl", ) # Atlas shaders self.atlas_resize_program: Program = self.load_program( # NOTE: This is the geo shader version of the atlas resize program. # vertex_shader=":system:shaders/atlas/resize_vs.glsl", # geometry_shader=":system:shaders/atlas/resize_gs.glsl", # fragment_shader=":system:shaders/atlas/resize_fs.glsl", # Vertex and fragment shader version vertex_shader=":system:shaders/atlas/resize_simple_vs.glsl", fragment_shader=":system:shaders/atlas/resize_simple_fs.glsl", ) self.atlas_resize_program["atlas_old"] = 0 # Configure texture channels self.atlas_resize_program["atlas_new"] = 1 self.atlas_resize_program["texcoords_old"] = 2 self.atlas_resize_program["texcoords_new"] = 3 # NOTE: These should not be created when WebGL is used # SpriteList collision resources # Buffer version of the collision detection program. self.collision_detection_program = self.load_program( vertex_shader=":system:shaders/collision/col_trans_vs.glsl", geometry_shader=":system:shaders/collision/col_trans_gs.glsl", ) # Texture version of the collision detection program. self.collision_detection_program_simple = self.load_program( vertex_shader=":system:shaders/collision/col_tex_trans_vs.glsl", geometry_shader=":system:shaders/collision/col_tex_trans_gs.glsl", ) self.collision_detection_program_simple["pos_angle_data"] = 0 self.collision_detection_program_simple["size_data"] = 1 self.collision_detection_program_simple["index_data"] = 2 self.collision_buffer = self.buffer(reserve=1024 * 4) self.collision_query = self.query(samples=False, time=False, primitives=True) # General Utility # renders a quad (without projection) with a single 4-component texture. self.utility_textured_quad_program: Program = self.load_program( vertex_shader=":system:shaders/util/textured_quad_vs.glsl", fragment_shader=":system:shaders/util/textured_quad_fs.glsl", ) # --- Pre-created geometry and buffers for unbuffered draw calls ---- # FIXME: These pre-created resources needs to be packaged nicely # Just having them globally in the context is probably not a good idea self.generic_draw_line_strip_color = self.buffer(reserve=4 * 1000) self.generic_draw_line_strip_vbo = self.buffer(reserve=8 * 1000) self.generic_draw_line_strip_geometry = self.geometry( [ BufferDescription(self.generic_draw_line_strip_vbo, "2f", ["in_vert"]), BufferDescription( self.generic_draw_line_strip_color, "4f1", ["in_color"], ), ] ) # Shape line(s) self.shape_line_buffer_pos = self.buffer(reserve=8 * 10) self.shape_line_geometry = self.geometry( [ # Instanced quad (triangle strip) BufferDescription( self.buffer( data=array( "f", [ 0.0, # 4 dummy vertices 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ], ) ), "2f", ["in_vert"], ), BufferDescription( self.shape_line_buffer_pos, "4f", ["in_instance_pos"], instanced=True ), ], mode=self.TRIANGLE_STRIP, ) # ellipse/circle filled. Empty geometry. We generate it on the fly in the vertex shader. self.shape_ellipse_unbuffered_geometry: Geometry = self.geometry() # ellipse/circle outline. Empty geometry. We generate it on the fly in the vertex shader. self.shape_ellipse_outline_unbuffered_geometry: Geometry = self.geometry() # rectangle filled self.shape_rectangle_filled_unbuffered_buffer = self.buffer(reserve=8) # fmt: off self.shape_rectangle_filled_unbuffered_geometry: Geometry = self.geometry( [ # Instanced quad (triangle strip) BufferDescription( self.buffer( data=array( "f", [ -0.5, +0.5, # Upper left -0.5, -0.5, # lower left +0.5, +0.5, # upper right +0.5, -0.5, # lower right ], ) ), "2f", ["in_vert"], ), # Per instance data BufferDescription( self.shape_rectangle_filled_unbuffered_buffer, "2f", ["in_instance_pos"], instanced=True ), ], mode=self.TRIANGLE_STRIP, ) # fmt: on self.geometry_empty: Geometry = self.geometry() self._atlas: TextureAtlasBase | None = None # Global labels we modify in `arcade.draw_text`. # These multiple labels with different configurations are stored self.label_cache: dict[str, arcade.Text] = {} # self.active_program = None self.point_size = 1.0
[docs] def reset(self) -> None: """ Reset context flags and other states. This is mostly used in unit testing. """ self.screen.use(force=True) self.bind_window_block() # self.active_program = None self.viewport = 0, 0, self.window.width, self.window.height self.view_matrix = Mat4() self.projection_matrix = Mat4.orthogonal_projection( 0, self.window.width, 0, self.window.height, -100, 100 ) self.enable_only(self.BLEND) self.blend_func = self.BLEND_DEFAULT self.point_size = 1.0
[docs] def bind_window_block(self) -> None: """ Binds the global projection and view uniform buffer object. This should always be bound to index 0 so all shaders have access to them. """ gl.glBindBufferRange( gl.GL_UNIFORM_BUFFER, 0, self._window_block.buffer.id, 0, 128, # 32 x 32bit floats (two mat4) )
@property def default_atlas(self) -> TextureAtlasBase: """ The default texture atlas. This is created when Arcade is initialized. All sprite lists will use use this atlas unless a different atlas is passed in the :py:class:`arcade.SpriteList` constructor. """ if not self._atlas: # Create the default texture atlas # 8192 is a safe maximum size for textures in OpenGL 3.3 # We might want to query the max limit, but this makes it consistent # across all OpenGL implementations. self._atlas = DefaultTextureAtlas( self.atlas_size, border=2, auto_resize=True, ctx=self, ) return self._atlas @property def viewport(self) -> tuple[int, int, int, int]: """ Get or set the viewport for the currently active framebuffer. The viewport simply describes what pixels of the screen OpenGL should render to. Format is ``(x, y, width, height)``. Normally it would be the size of the window's framebuffer:: # 4:3 screen ctx.viewport = 0, 0, 800, 600 # 1080p ctx.viewport = 0, 0, 1920, 1080 # Using the current framebuffer size ctx.viewport = 0, 0, *ctx.screen.size """ return self.active_framebuffer.viewport @viewport.setter def viewport(self, value: tuple[int, int, int, int]): self.active_framebuffer.viewport = value if self._default_camera == self.current_camera: self._default_camera.use() @property def projection_matrix(self) -> Mat4: """ Get or set the current projection matrix. This 4x4 float32 matrix is usually calculated by a cameras but can be modified directly if you know what you are doing. This property simply gets and sets pyglet's projection matrix. """ return self.window.projection @projection_matrix.setter def projection_matrix(self, value: Mat4): if not isinstance(value, Mat4): raise ValueError("projection_matrix must be a Mat4 object") self.window.projection = value @property def view_matrix(self) -> Mat4: """ Get or set the current view matrix. This 4x4 float32 matrix is usually calculated by a cameras but can be modified directly if you know what you are doing. This property simply gets and sets pyglet's view matrix. """ return self.window.view @view_matrix.setter def view_matrix(self, value: Mat4): if not isinstance(value, Mat4): raise ValueError("view_matrix must be a Mat4 object") self.window.view = value
[docs] def load_program( self, *, vertex_shader: str | Path, fragment_shader: str | Path | None = None, geometry_shader: str | Path | None = None, tess_control_shader: str | Path | None = None, tess_evaluation_shader: str | Path | None = None, common: Iterable[str | Path] = (), defines: dict[str, Any] | None = None, varyings: Sequence[str] | None = None, varyings_capture_mode: str = "interleaved", ) -> Program: """ Create a new program given file names that contain the vertex shader and fragment shader. Note that the fragment and geometry shaders are optional when transform shaders are loaded. This method also supports resource handles. Example:: # The most common use case is having a vertex and fragment shader program = window.ctx.load_program( vertex_shader="vert.glsl", fragment_shader="frag.glsl", ) Args: vertex_shader: Path to the vertex shader. fragment_shader: Path to the fragment shader. geometry_shader: Path to the geometry shader. tess_control_shader: Tessellation Control Shader. tess_evaluation_shader: Tessellation Evaluation Shader. common: Common files to be included in all shaders. defines: Substitute `#define` values in the source. varyings: The name of the out attributes in a transform shader. This is normally not necessary since we auto detect them, but some more complex out structures we can't detect. varyings_capture_mode: The capture mode for transforms. Based on these settings, the `transform()` method will accept a single buffer or a list of buffers. - ``"interleaved"`` means all out attributes will be written to a single buffer. - ``"separate"`` means each out attribute will be written to separate buffers. """ from arcade.resources import resolve vertex_shader_src = resolve(vertex_shader).read_text() vertex_shader_src = self.shader_inc(vertex_shader_src) fragment_shader_src = None geometry_shader_src = None tess_control_src = None tess_evaluation_src = None common_src = [resolve(c).read_text() for c in common] if fragment_shader: fragment_shader_src = resolve(fragment_shader).read_text() fragment_shader_src = self.shader_inc(fragment_shader_src) if geometry_shader: geometry_shader_src = resolve(geometry_shader).read_text() geometry_shader_src = self.shader_inc(geometry_shader_src) if tess_control_shader and tess_evaluation_shader: tess_control_src = resolve(tess_control_shader).read_text() tess_evaluation_src = resolve(tess_evaluation_shader).read_text() tess_control_src = self.shader_inc(tess_control_src) tess_evaluation_src = self.shader_inc(tess_evaluation_src) return self.program( vertex_shader=vertex_shader_src, fragment_shader=fragment_shader_src, geometry_shader=geometry_shader_src, tess_control_shader=tess_control_src, tess_evaluation_shader=tess_evaluation_src, common=common_src, defines=defines, varyings=varyings, varyings_capture_mode=varyings_capture_mode, )
[docs] def load_compute_shader( self, path: str | Path, common: Iterable[str | Path] = () ) -> ComputeShader: """ Loads a compute shader from file. This methods supports resource handles. Example:: ctx.load_compute_shader(":shader:compute/do_work.glsl") Args: path: Path to texture common: Common sources injected into compute shader """ from arcade.resources import resolve path = resolve(path) common_src = [resolve(c).read_text() for c in common] return self.compute_shader( source=self.shader_inc(path.read_text()), common=common_src, )
[docs] def load_texture( self, path: str | Path, *, flip: bool = True, wrap_x=None, wrap_y=None, filter=None, build_mipmaps: bool = False, internal_format: int | None = None, immutable: bool = False, compressed: bool = False, ) -> Texture2D: """ Loads and creates an OpenGL 2D texture. Currently, all textures are converted to RGBA for simplicity. Examples:: # Load a texture in current working directory texture = window.ctx.load_texture("background.png") # Load a texture using Arcade resource handle texture = window.ctx.load_texture(":textures:background.png") # Load and compress a texture texture = window.ctx.load_texture( ":textures:background.png", internal_format=gl.GL_COMPRESSED_RGBA_S3TC_DXT5_EXT, compressed=True, ) Args: path: Path to texture flip: Flips the image upside down. Default is ``True``. wrap_x: The wrap mode for the x-axis. Default is ``None``. wrap_y: The wrap mode for the y-axis. Default is ``None``. filter: The min and mag filter. Default is ``None``. build_mipmaps: Build mipmaps for the texture. Default is ``False``. internal_format: The internal format of the texture. This can be used to override the default internal format when using sRGBA or compressed textures. immutable: Make the storage (not the contents) immutable. This can sometimes be required when using textures with compute shaders. compressed: If the internal format is a compressed format meaning your texture will be compressed by the GPU. """ from arcade.resources import resolve path = resolve(path) image: Image.Image = Image.open(str(path)) # type: ignore if flip: image = image.transpose(Image.Transpose.FLIP_TOP_BOTTOM) texture = self.texture( image.size, components=4, data=image.convert("RGBA").tobytes(), wrap_x=wrap_x, wrap_y=wrap_y, filter=filter, internal_format=internal_format, immutable=immutable, compressed=compressed, ) image.close() if build_mipmaps: texture.build_mipmaps() return texture
[docs] def shader_inc(self, source: str) -> str: """ Parse a shader source looking for ``#include`` directives and replace them with the contents of the included file. The ``#include`` directive must be on its own line and the file and the path should use a resource handle. Example:: #include :my_resource_handle:lib/common.glsl Args: source: The shader source code """ from arcade.resources import resolve lines = source.splitlines() for i, line in enumerate(lines): line = line.strip() if line.startswith("#include"): path = resolve(line.split()[1].replace('"', "")) lines[i] = path.read_text() return "\n".join(lines)
[docs] def get_framebuffer_image( self, fbo: Framebuffer, components: int = 4, flip: bool = True, ) -> Image.Image: """ Shortcut method for reading data from a framebuffer and converting it to a PIL image. Args: fbo: Framebuffer to get image from components: Number of components to read. Default is 4 (RGBA). Valid values are 1, 2, 3, 4. flip: Flip the image upside down. This is useful because OpenGL has the origin at the bottom left corner while PIL has it at the top left. """ mode = "RGBA"[:components] image = Image.frombuffer( mode, (fbo.width, fbo.height), fbo.read(components=components), ) if flip: image = image.transpose(Image.Transpose.FLIP_TOP_BOTTOM) return image