Source code for arcade.gui.surface

from array import array
from collections.abc import Generator
from contextlib import contextmanager

from PIL import Image
from pyglet.math import Vec2, Vec4
from typing_extensions import Self

import arcade
from arcade import Texture
from arcade.camera import CameraData, OrthographicProjectionData, OrthographicProjector
from arcade.color import TRANSPARENT_BLACK
from arcade.gl import BufferDescription, Framebuffer
from arcade.gui.nine_patch import NinePatchTexture
from arcade.types import LBWH, RGBA255, Point, Rect


[docs] class Surface: """Internal abstraction for widget rendering. Holds a :class:`arcade.gl.Framebuffer` and provides helper methods and properties for drawing to it. Args: size: The size of the surface in window coordinates position: The position of the surface in window pixel_ratio: The pixel scale of the window """ def __init__( self, *, size: tuple[int, int], position: tuple[int, int] = (0, 0), pixel_ratio: float = 1.0, ): self.ctx = arcade.get_window().ctx self._size = size self._pos = position self._pixel_ratio = pixel_ratio self._pixelated = False self._area: Rect | None = None # Cached area for the last draw call self.texture = self.ctx.texture(self.size_scaled, components=4) self.fbo: Framebuffer = self.ctx.framebuffer(color_attachments=[self.texture]) self.fbo.clear() #: Blend modes for when we're drawing into the surface self.blend_func_render_into = ( *self.ctx.BLEND_DEFAULT, *self.ctx.BLEND_ADDITIVE, ) #: Blend mode for when we're drawing the surface self.blend_func_render = ( *self.ctx.BLEND_DEFAULT, *self.ctx.BLEND_DEFAULT, ) # 5 floats per vertex (pos 3f, tex 2f) with 4 vertices self._buffer = self.ctx.buffer(reserve=4 * 5 * 4) self._geometry = self.ctx.geometry( content=[BufferDescription(self._buffer, "3f 2f", ["in_pos", "in_uv"])], mode=self.ctx.TRIANGLE_STRIP, ) self._program = self.ctx.load_program( vertex_shader=":system:shaders/gui/surface_vs.glsl", fragment_shader=":system:shaders/gui/surface_fs.glsl", ) self._update_geometry() self._cam = OrthographicProjector( view=CameraData(), projection=OrthographicProjectionData(0.0, self.width, 0.0, self.height, -100, 100), viewport=LBWH(0, 0, self.width, self.height), ) @property def position(self) -> Point: """Get or set the surface position""" return self._pos @position.setter def position(self, value): self._pos = value @property def size(self): """Size of the surface in window coordinates""" return self._size @property def size_scaled(self): """The physical size of the buffer""" return ( int(self._size[0] * self._pixel_ratio), int(self._size[1] * self._pixel_ratio), ) @property def pixel_ratio(self) -> float: """The pixel ratio of the surface""" return self._pixel_ratio @property def width(self) -> int: """Width of the surface""" return self._size[0] @property def height(self) -> int: """Height of the surface""" return self._size[1]
[docs] def clear(self, color: RGBA255 = TRANSPARENT_BLACK): """Clear the surface""" self.fbo.clear(color=color)
[docs] def draw_texture( self, x: float, y: float, width: float, height: float, tex: Texture | NinePatchTexture, angle: float = 0.0, alpha: int = 255, ): """Draw a texture to the surface. Args: x: The x coordinate of the texture. y: The y coordinate of the texture. width: The width of the texture. height: The height of the texture. tex: The texture to draw, also supports NinePatchTexture. angle: The angle of the texture. alpha: The alpha value of the texture. """ if isinstance(tex, NinePatchTexture): if angle != 0.0: raise NotImplementedError( f"Ninepatch does not support an angle != 0 yet, but got {angle}" ) if alpha != 255: raise NotImplementedError( f"Ninepatch does not support an alpha != 255 yet, but got {alpha}" ) tex.draw_rect(rect=LBWH(0, 0, width, height)) else: arcade.draw_texture_rect( tex, LBWH(x, y, width, height), angle=angle, alpha=alpha, pixelated=self._pixelated )
[docs] def draw_sprite(self, x: float, y: float, width: float, height: float, sprite: arcade.Sprite): """Draw a sprite to the surface Args: x: The x coordinate of the sprite. y: The y coordinate of the sprite. width: The width of the sprite. height: The height of the sprite. sprite: The sprite to draw. """ sprite.position = x + width // 2, y + height // 2 sprite.width = width sprite.height = height arcade.draw_sprite(sprite, pixelated=self._pixelated)
[docs] @contextmanager def activate(self) -> Generator[Self, None, None]: """Context manager for rendering safely to this :py:class:`Surface`. It does the following: #. Apply this surface's viewport, projection, and blend settings #. Allow any rendering to take place #. Restore the old OpenGL context settings Use it in ``with`` blocks like other managers: .. code-block:: python with surface.activate(): # draw stuff here """ # Set viewport and projection self.limit(LBWH(0, 0, *self.size)) # Set blend function prev_blend_func = self.ctx.blend_func try: self.ctx.blend_func = self.blend_func_render_into with self.fbo.activate(): yield self finally: # Restore blend function. self.ctx.blend_func = prev_blend_func
[docs] def limit(self, rect: Rect | None = None): """Reduces the draw area to the given rect, or resets it to the full surface.""" if rect is None: rect = LBWH(0, 0, *self.size) l, b, w, h = rect.lbwh w = max(w, 1) h = max(h, 1) # round to nearest pixel, to avoid off by 1-pixel errors in ui viewport_rect = LBWH( round(l * self._pixel_ratio), round(b * self._pixel_ratio), round(w * self._pixel_ratio), round(h * self._pixel_ratio), ) self.fbo.viewport = viewport_rect.lbwh_int self._cam.projection.rect = LBWH(0, 0, w, h) self._cam.viewport = viewport_rect self._cam.use()
[docs] def draw( self, area: Rect | None = None, ) -> None: """Draws the contents of the surface. The surface will be rendered at the configured ``position`` and limited by the given ``area``. The area can be out of bounds. Args: area: Limit the area in the surface we're drawing (l, b, w, h) """ self._update_geometry(area=area) # Set blend function blend_func = self.ctx.blend_func self.ctx.blend_func = self.blend_func_render # Handle the pixelated shortcut if filter is not set if self._pixelated: self.texture.filter = self.ctx.NEAREST, self.ctx.NEAREST else: self.texture.filter = self.ctx.LINEAR, self.ctx.LINEAR self.texture.use(0) self._geometry.render(self._program) # Restore blend function self.ctx.blend_func = blend_func
[docs] def resize(self, *, size: tuple[int, int], pixel_ratio: float) -> None: """Resize the internal texture by re-allocating a new one Args: size: The new size in pixels (xy) pixel_ratio: The pixel scale of the window """ # Texture re-allocation is expensive so we should block unnecessary calls. if self._size == size and self._pixel_ratio == pixel_ratio: return self._size = size self._pixel_ratio = pixel_ratio # Create new texture and fbo self.texture = self.ctx.texture(self.size_scaled, components=4) self.fbo = self.ctx.framebuffer(color_attachments=[self.texture]) self.fbo.clear()
[docs] def to_image(self) -> Image.Image: """Convert the surface to an PIL image""" return self.ctx.get_framebuffer_image(self.fbo)
def _update_geometry(self, area: Rect | None = None) -> None: """ Update the internal geometry of the surface mesh. The geometry is a triangle strip with 4 vertices. """ if area is None: area = LBWH(0, 0, *self.size) if self._area == area: return self._area = area # Clamp the area inside the surface # This is the local area inside the surface _size = Vec2(*self.size) _pos = Vec2(*self.position) _area_pos = Vec2(area.left, area.bottom) _area_size = Vec2(area.width, area.height) b1 = _area_pos.clamp(Vec2(0.0), _size) end_point = _area_pos + _area_size b2 = end_point.clamp(Vec2(0.0), _size) b = b2 - b1 l_area = Vec4(b1.x, b1.y, b.x, b.y) # Create the 4 corners of the rectangle # These are the final/global coordinates rendered p_ll = _pos + l_area.xy # type: ignore p_lr = _pos + l_area.xy + Vec2(l_area.z, 0.0) # type: ignore p_ul = _pos + l_area.xy + Vec2(0.0, l_area.w) # type: ignore p_ur = _pos + l_area.xy + l_area.zw # type: ignore # Calculate the UV coordinates bottom = l_area.y / _size.y left = l_area.x / _size.x top = (l_area.y + l_area.w) / _size.y right = (l_area.x + l_area.z) / _size.x # fmt: off vertices = array("f", ( p_ll.x, p_ll.y, 0.0, left, bottom, p_lr.x, p_lr.y, 0.0, right, bottom, p_ul.x, p_ul.y, 0.0, left, top, p_ur.x, p_ur.y, 0.0, right, top, )) # fmt: on self._buffer.write(vertices)