From d45476a6704b81df6079df101404720c4d96f9b8 Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Wed, 15 Oct 2025 19:12:33 -0400 Subject: [PATCH 01/31] Add NoArcadeWindowError subclassing RuntimeError (#2784) * Add NoArcadeWindowError and have_window function * Add NoArcadeWindowError subclassing RuntimeError * Make arcade.window_commands.get_window() raise NoArcadeWindowError when no window exists * Add arcade.window_commands.have_window() function * Improve documentation of get_window() function * Attempt to make ruff happy * Remove have_window() since the name / location are controversial * Add window_exists function * Fix doc naming --------- Co-authored-by: Darren Eberly --- arcade/__init__.py | 2 ++ arcade/exceptions.py | 10 ++++++++++ arcade/window_commands.py | 31 +++++++++++++++++++++++++++---- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/arcade/__init__.py b/arcade/__init__.py index a5152680d6..968e311bf8 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -90,6 +90,7 @@ def configure_logging(level: int | None = None): from .window_commands import start_render from .window_commands import unschedule from .window_commands import schedule_once +from .window_commands import window_exists from .sections import Section, SectionManager @@ -359,6 +360,7 @@ def configure_logging(level: int | None = None): "create_text_sprite", "clear_timings", "get_window", + "window_exists", "get_fps", "has_line_of_sight", "load_animated_gif", diff --git a/arcade/exceptions.py b/arcade/exceptions.py index a3b651823f..7ba2c8ed17 100644 --- a/arcade/exceptions.py +++ b/arcade/exceptions.py @@ -7,6 +7,7 @@ from typing import TypeVar __all__ = [ + "NoArcadeWindowError", "OutsideRangeError", "IntOutsideRangeError", "FloatOutsideRangeError", @@ -23,6 +24,15 @@ _CT = TypeVar("_CT") # Comparable type, ie supports the <= operator +class NoArcadeWindowError(RuntimeError): + """No valid Arcade window exists. + + It may be handled as a :py:class:`RuntimeError`. + """ + + ... + + class OutsideRangeError(ValueError): """ Raised when a value is outside and expected range diff --git a/arcade/window_commands.py b/arcade/window_commands.py index 99d2f41c05..13dc5c5533 100644 --- a/arcade/window_commands.py +++ b/arcade/window_commands.py @@ -14,6 +14,7 @@ import pyglet +from arcade.exceptions import NoArcadeWindowError from arcade.types import RGBA255, Color if TYPE_CHECKING: @@ -26,6 +27,7 @@ "get_display_size", "get_window", "set_window", + "window_exists", "close_window", "run", "exit", @@ -55,13 +57,19 @@ def get_display_size(screen_id: int = 0) -> tuple[int, int]: def get_window() -> Window: - """ - Return a handle to the current window. + """Return a handle to the current window. + + If no window exists, it will raise an exception you can + handle as a :py:class:`RuntimeError`. Use :py:func:`window_exists` + to prevent raising an exception. - :return: Handle to the current window. + Raises: + :py:class:`~arcade.exceptions.NoArcadeWindowError` when no window exists. """ if _window is None: - raise RuntimeError("No window is active. It has not been created yet, or it was closed.") + raise NoArcadeWindowError( + "No window is active. It has not been created yet, or it was closed." + ) return _window @@ -77,6 +85,21 @@ def set_window(window: Window | None) -> None: _window = window +def window_exists() -> bool: + """ + Returns True or False based on wether there is currently a Window. + + Returns: + Boolean for if a window exists. + """ + try: + get_window() + except NoArcadeWindowError: + return False + + return True + + def close_window() -> None: """ Closes the current window, and then runs garbage collection. The garbage collection From fe9b113b535e9f47b73ce99c15f0e4f5b7a3036f Mon Sep 17 00:00:00 2001 From: Vincent Poulailleau Date: Wed, 5 Nov 2025 18:31:31 +0100 Subject: [PATCH 02/31] Update captions for framebuffer tutorial steps (#2788) --- doc/tutorials/framebuffer/index.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/tutorials/framebuffer/index.rst b/doc/tutorials/framebuffer/index.rst index 757c5d1a97..f54c108d9c 100644 --- a/doc/tutorials/framebuffer/index.rst +++ b/doc/tutorials/framebuffer/index.rst @@ -16,11 +16,11 @@ Then create a simple program with a frame buffer: Now, color everything that doesn't have an alpha of zero as green: .. literalinclude:: step_02.py - :caption: Pass-through frame buffer + :caption: Green where alpha is not zero in the FBO :linenos: Something about passing uniform data to the shader: .. literalinclude:: step_03.py - :caption: Pass-through frame buffer + :caption: Passing uniform data to the shader :linenos: From 6496a3e8e7645a358457a6dc2b375160d5327275 Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Fri, 7 Nov 2025 20:55:34 -0500 Subject: [PATCH 03/31] Bump to Pillow 12.0.0 for full Python 3.14 support (#2792) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9e74a561e8..9671ae48dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ ] dependencies = [ "pyglet~=2.1.5", - "pillow~=11.3.0", + "pillow~=12.0.0", "pymunk~=6.9.0", "pytiled-parser~=2.2.9", ] From 225a1919c971fe38848713fc1fb6baf04e2cf83f Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Fri, 7 Nov 2025 21:07:57 -0500 Subject: [PATCH 04/31] Changelog Updates (#2793) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9350a9ce3a..76220abe57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. +## Unreleased + +- Upgraded Pillow to 12.0.0 for Python 3.14 support. +- Adds a new `arcade.NoAracdeWindowError` exception type. This is raised when certain window operations are performed and there is no valid Arcade window found. Previously where this error would be raised, we raised a standard `RuntimeError`, this made it harder to properly catch and act accordingly. This new exception subclasses `RuntimeError`, so you can still catch this error the same way as before. The `arcade.get_window()` function will now raise this if there is no window. +- Along with the new exception type, is a new `arcade.windows_exists()` function which will return True or False based on if there is currently an active window. + ## 3.3.3 - Support for Python 3.14 From 9df5ffb7f14512cb7832292a757fa180db8c804f Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Wed, 26 Nov 2025 16:20:37 +0100 Subject: [PATCH 05/31] Gui/fixes (#2795) * Fix UIManager to correctly handle size hint of (0, 0) * Refactor UIScrollArea to allow multiple children and detect UIScrollArea in UIDropdown * Update CHANGELOG.md to reflect UIScrollArea enhancements and UIDropdown positioning fix --- CHANGELOG.md | 4 ++++ arcade/gui/experimental/scroll_area.py | 3 --- arcade/gui/ui_manager.py | 4 ++-- arcade/gui/widgets/dropdown.py | 21 +++++++++++++++------ tests/unit/gui/test_uimanager_layouting.py | 5 +++++ 5 files changed, 26 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76220abe57..8b5c84811a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Upgraded Pillow to 12.0.0 for Python 3.14 support. - Adds a new `arcade.NoAracdeWindowError` exception type. This is raised when certain window operations are performed and there is no valid Arcade window found. Previously where this error would be raised, we raised a standard `RuntimeError`, this made it harder to properly catch and act accordingly. This new exception subclasses `RuntimeError`, so you can still catch this error the same way as before. The `arcade.get_window()` function will now raise this if there is no window. - Along with the new exception type, is a new `arcade.windows_exists()` function which will return True or False based on if there is currently an active window. +- GUI + - `UIManager` did not apply size hint of (0,0). Mainly an issue with `UIBoxLayout`. + - Allow multiple children in `UIScrollArea`. + - Fix `UIDropdown Overlay` positioning within a `UIScrollArea`. ## 3.3.3 diff --git a/arcade/gui/experimental/scroll_area.py b/arcade/gui/experimental/scroll_area.py index bd7db75412..3bee0c13e9 100644 --- a/arcade/gui/experimental/scroll_area.py +++ b/arcade/gui/experimental/scroll_area.py @@ -239,9 +239,6 @@ def __init__( def add(self, child: W, **kwargs) -> W: """Add a child to the widget.""" - if self._children: - raise ValueError("UIScrollArea can only have one child") - super().add(child, **kwargs) self.trigger_full_render() diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index 3a5707d47d..f7b6500d5f 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -246,8 +246,8 @@ def _do_layout(self): # actual layout if child.size_hint: sh_x, sh_y = child.size_hint - nw = surface_width * sh_x if sh_x else None - nh = surface_height * sh_y if sh_y else None + nw = surface_width * sh_x if sh_x is not None else None + nh = surface_height * sh_y if sh_y is not None else None child.rect = child.rect.resize(nw, nh, anchor=AnchorPoint.BOTTOM_LEFT) if child.size_hint_min: diff --git a/arcade/gui/widgets/dropdown.py b/arcade/gui/widgets/dropdown.py index afa75c495a..43e88358a9 100644 --- a/arcade/gui/widgets/dropdown.py +++ b/arcade/gui/widgets/dropdown.py @@ -6,6 +6,7 @@ from arcade import uicolor from arcade.gui import UIEvent, UIMousePressEvent from arcade.gui.events import UIControllerButtonPressEvent, UIOnChangeEvent, UIOnClickEvent +from arcade.gui.experimental import UIScrollArea from arcade.gui.experimental.focus import UIFocusMixin from arcade.gui.ui_manager import UIManager from arcade.gui.widgets import UILayout, UIWidget @@ -21,7 +22,7 @@ class _UIDropdownOverlay(UIFocusMixin, UIBoxLayout): # TODO move also options logic to this class - def show(self, manager: UIManager): + def show(self, manager: UIManager | UIScrollArea): manager.add(self, layer=UIManager.OVERLAY_LAYER) def hide(self): @@ -197,11 +198,19 @@ def _update_options(self): self._overlay.detect_focusable_widgets() def _show_overlay(self): - manager = self.get_ui_manager() - if manager is None: - raise Exception("UIDropdown could not find UIManager in its parents.") - - self._overlay.show(manager) + # traverse parents until UIManager or UIScrollArea is found + parent = self.parent + while parent is not None: + if isinstance(parent, UIManager): + break + if isinstance(parent, UIScrollArea): + break + parent = parent.parent + + if parent is None: + raise Exception("UIDropdown could not find a valid parent for the overlay.") + + self._overlay.show(parent) def _on_button_click(self, _: UIOnClickEvent): self._show_overlay() diff --git a/tests/unit/gui/test_uimanager_layouting.py b/tests/unit/gui/test_uimanager_layouting.py index 1f6487867b..f9f29ee409 100644 --- a/tests/unit/gui/test_uimanager_layouting.py +++ b/tests/unit/gui/test_uimanager_layouting.py @@ -25,9 +25,13 @@ def test_supports_size_hint(window): widget3 = UIDummy() widget3.size_hint = (1, None) + widget4 = UIDummy() + widget4.size_hint = (0, 0) + manager.add(widget1) manager.add(widget2) manager.add(widget3) + manager.add(widget4) with sized(window, 200, 300): manager.draw() @@ -35,6 +39,7 @@ def test_supports_size_hint(window): assert widget1.size == Vec2(200, 300) assert widget2.size == Vec2(100, 75) assert widget3.size == Vec2(200, 100) + assert widget4.size == Vec2(0, 0) def test_supports_size_hint_min(window): From 6efb32cc6268597ef4b4e9e64c59817ecb6f6a64 Mon Sep 17 00:00:00 2001 From: Miles Curry <2590700+MiCurry@users.noreply.github.com> Date: Sat, 13 Dec 2025 18:16:01 -0700 Subject: [PATCH 06/31] Fix broken link in procedural caves BSP (#2798) It appears rougelikedevelopment.org is no more. Perhaps it is now rougebasin.com? Regardless, this replaces the dead link with a good one. --- arcade/examples/procedural_caves_bsp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/examples/procedural_caves_bsp.py b/arcade/examples/procedural_caves_bsp.py index fe6d05c196..17974acbd2 100644 --- a/arcade/examples/procedural_caves_bsp.py +++ b/arcade/examples/procedural_caves_bsp.py @@ -3,7 +3,7 @@ Binary Space Partitioning (BSP) For more information, see: -https://roguebasin.roguelikedevelopment.org/index.php?title=Basic_BSP_Dungeon_generation +https://www.roguebasin.com/index.php?title=Basic_BSP_Dungeon_generation https://github.com/DanaL/RLDungeonGenerator If Python and Arcade are installed, this example can be run from the command line with: From 4b9d5157faf3a91293bf48398c5a4e1d493f6a18 Mon Sep 17 00:00:00 2001 From: "A. J. Andrews" <86714785+DragonMoffon@users.noreply.github.com> Date: Sat, 20 Dec 2025 21:40:51 +1300 Subject: [PATCH 07/31] Camera2d requested additions (#2796) * make camera2D position setter use `pos` not `_pos` and add x, y properties * Add `move_to` method with optional duration as requested by Eruvanos * add `Camera2D.move_by` method to avoid position access costs * add `Camera2D.drag_by` method to allow for accurate dragging * linting and formatting passs * Sphyinxify docs * code block in correct format * better position doc string as part of #2558 * formatting pass for new docstring * arcade uses american spelling much to my dismay * also remove alias of British spelling * improve camera init position logic and add aspect argument to init * formatting pass * additionally exception when aspect == 0 in update values * aspect ratio unit tests * fix viewport not respecting aspect ratio and redundant rect reaction found this issue with unit tests hurra * unit test formating --- arcade/camera/camera_2d.py | 188 ++++++++++++++++++++++++++--- tests/unit/camera/test_camera2d.py | 20 +++ 2 files changed, 191 insertions(+), 17 deletions(-) diff --git a/arcade/camera/camera_2d.py b/arcade/camera/camera_2d.py index 52e8096aa2..d337c4e943 100644 --- a/arcade/camera/camera_2d.py +++ b/arcade/camera/camera_2d.py @@ -2,7 +2,7 @@ from collections.abc import Generator from contextlib import contextmanager -from math import atan2, cos, degrees, radians, sin +from math import atan2, cos, degrees, pow, radians, sin from typing import TYPE_CHECKING from pyglet.math import Vec2, Vec3 @@ -60,7 +60,11 @@ class Camera2D: If the viewport is not 1:1 with the projection then positions in world space won't match pixels on screen. position: - The 2D position of the camera in the XY plane. + The 2D position of the camera. + + This is in world space, so the same as :py:class:`Sprite` and draw commands. + The default projection is a :py:func:`XYWH` rect positioned at (0, 0) so the + position of the camera is the center of the viewport. up: A 2D vector which describes which direction is up (defines the +Y-axis of the camera space). @@ -75,6 +79,11 @@ class Camera2D: The near clipping plane of the camera. far: The far clipping plane of the camera. + aspect: The ratio between width and height that the viewport should + be constrained to. If unset then the viewport just matches the given + size. The aspect ratio describes how much larger the width should be + compared to the height. i.e. for an aspect ratio of ``4:3`` you should + input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. scissor: A ``Rect`` which will crop the camera's output to this area on screen. Unlike the viewport this has no influence on the visuals rendered with @@ -96,6 +105,7 @@ def __init__( near: float = DEFAULT_NEAR_ORTHO, far: float = DEFAULT_FAR, *, + aspect: float | None = None, scissor: Rect | None = None, render_target: Framebuffer | None = None, window: Window | None = None, @@ -111,7 +121,20 @@ def __init__( # but we need to have some form of default size. render_target = render_target or self._window.ctx.screen viewport = viewport or LBWH(*render_target.viewport) - width, height = viewport.size + + if aspect is None: + width, height = viewport.size + elif aspect == 0.0: + raise ZeroProjectionDimension( + "aspect ratio is 0 which will cause invalid viewport dimensions." + ) + elif viewport.height * aspect < viewport.width: + width = viewport.height * aspect + height = viewport.height + else: + width = viewport.width + height = viewport.width / aspect + viewport = XYWH(viewport.x, viewport.y, width, height) half_width = width / 2 half_height = height / 2 @@ -136,8 +159,10 @@ def __init__( f"projection depth is 0 due to equal {near=} and {far=} values" ) - pos_x = position[0] if position is not None else half_width - pos_y = position[1] if position is not None else half_height + # By using -left and -bottom this ensures that (0.0, 0.0) is always + # in the bottom left corner of the viewport + pos_x = position[0] if position is not None else -left + pos_y = position[1] if position is not None else -bottom self._camera_data = CameraData( position=(pos_x, pos_y, 0.0), up=(up[0], up[1], 0.0), @@ -148,7 +173,7 @@ def __init__( left=left, right=right, top=top, bottom=bottom, near=near, far=far ) - self.viewport: Rect = viewport or LRBT(0, 0, width, height) + self.viewport: Rect = viewport """ A rect which describes how the final projection should be mapped from unit-space. defaults to the size of the render_target or window @@ -322,7 +347,7 @@ def unproject(self, screen_coordinate: Point) -> Vec3: _view = generate_view_matrix(self.view_data) return unproject_orthographic(screen_coordinate, self.viewport.lbwh_int, _view, _projection) - def equalise(self) -> None: + def equalize(self) -> None: """ Forces the projection to match the size of the viewport. When matching the projection to the viewport the method keeps @@ -331,8 +356,6 @@ def equalise(self) -> None: x, y = self._projection_data.rect.x, self._projection_data.rect.y self._projection_data.rect = XYWH(x, y, self.viewport_width, self.viewport_height) - equalize = equalise - def match_window( self, viewport: bool = True, @@ -352,7 +375,7 @@ def match_window( scissor: Flag whether to also equalize the scissor box to the viewport. On by default position: Flag whether to position the camera so that (0.0, 0.0) is in - the bottom-left + the bottom-left of the viewport aspect: The ratio between width and height that the viewport should be constrained to. If unset then the viewport just matches the window size. The aspect ratio describes how much larger the width should be @@ -386,7 +409,7 @@ def match_target( The projection center stays fixed, and the new projection matches only in size. scissor: Flag whether to update the scissor value. position: Flag whether to position the camera so that (0.0, 0.0) is in - the bottom-left + the bottom-left of the viewport aspect: The ratio between width and height that the value should be constrained to. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. @@ -428,14 +451,18 @@ def update_values( The projection center stays fixed, and the new projection matches only in size. scissor: Flag whether to update the scissor value. position: Flag whether to position the camera so that (0.0, 0.0) is in - the bottom-left + the bottom-left of the viewport aspect: The ratio between width and height that the value should be constrained to. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. If unset then the value will not be updated. """ if aspect is not None: - if value.height * aspect < value.width: + if aspect == 0.0: + raise ZeroProjectionDimension( + "aspect ratio is 0 which will cause invalid viewport dimensions." + ) + elif value.height * aspect < value.width: w = value.height * aspect h = value.height else: @@ -454,7 +481,11 @@ def update_values( self.scissor = value if position: - self.position = Vec2(-self._projection_data.left, -self._projection_data.bottom) + self._camera_data.position = ( + -self._projection_data.left, + -self._projection_data.bottom, + self._camera_data.position[2], + ) def aabb(self) -> Rect: """ @@ -512,6 +543,103 @@ def point_in_view(self, point: Point2) -> bool: return abs(dot_x) <= h_width and abs(dot_y) <= h_height + def move_to(self, position: Point2, *, duration: float | None = None) -> Point2: + """ + Move the camera to the provided position. + If duration is None this is the same as setting camera.position. + duration makes it easy to move the camera smoothly over time. + + When duration is not None it uses :py:func:`arcade.math.smerp` method + to smoothly move to the target position. This means duration does NOT + equal the fraction to move. To make the motion frame rate independant + use ``duration = dt * T`` where ``T`` is the number of seconds to move + half the distance to the target position. + + Args: + position: x, y position in world space to move too + duration: The number of frames it takes to approximately move half-way + to the target position + + Returns: + The actual position the camera was set too. + """ + if duration is None: + x, y = position + self._camera_data.position = (x, y, self._camera_data.position[2]) + return position + + x1, y1, z1 = self._camera_data.position + x2, y2 = position + d = pow(2, -duration) + x = x1 + (x2 - x1) * d + y = y1 + (y2 - y1) * d + + self._camera_data.position = (x, y, z1) + return x, y + + def move_by(self, change: Point2) -> Point2: + """ + Move the camera in world space along the XY axes by the provided change. + If you want to drag the camera with a mouse :py:func:`camera2D.drag_by` + is the method to use. + + Args: + change: amount to move XY position in world space + + Returns: + final XY position of the camera + """ + pos = self._camera_data.position + new = pos[0] + change[0], pos[1] + change[1] + self._camera_data.position = new[0], new[1], pos[2] + return new + + def drag_by(self, change: Point2) -> Point2: + """ + Move the camera in world space by an amount in screen space. + This is a utility method to make it easy to drag the camera correctly. + normally zooming in/out, rotating the camera, and using a non 1:1 projection + causes the mouse dragging to desync with the camera motion. It automatically + negates the change so the change represents the amount the camera appears + to move. This is because moving the camera left makes everything appear to + move right. So a user moving the mouse right expects the camera to move + left. + + The simplest use case is with the Window/View's :py:func:`on_mouse_drag` + .. code-block:: python + + def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers): + self.camera.drag_by((dx, dy)) + + .. warning:: This method is more expensive than :py:func:`Camera2D.move_by` so + use only when needed. If your camera is 1:1 with the screen and you + only zoom in and out you can get away with + ``camera2D.move_by(-change / camera.zoom)``. + + .. warning:: This method must assume that viewport has the same pixel scale as the + window. If you are doing some form of upscaling you will have to scale + the mouse dx and dy by the difference in pixel scale. + + Args: + change: The number of pixels to move the camera by + + Returns: + The final position of the camera. + """ + + # Early exit to avoid expensive matrix generation + if change[0] == 0.0 and change[1] == 0.0: + return self._camera_data.position[0], self._camera_data.position[1] + + x0, y0, _ = self.unproject((0, 0)) + xc, yc, _ = self.unproject(change) + + dx, dy = xc - x0, yc - y0 + pos = self._camera_data.position + new = pos[0] - dx, pos[1] - dy + self._camera_data.position = new[0], new[1], pos[2] + return new + @property def view_data(self) -> CameraData: """The view data for the camera. @@ -547,17 +675,43 @@ def projection_data(self) -> OrthographicProjectionData: @property def position(self) -> Vec2: - """The 2D world position of the camera along the X and Y axes.""" + """ + The 2D position of the camera. + + This is in world space, so the same as :py:class:`Sprite` and draw commands. + The default projection is a :py:func:`XYWH` rect positioned at (0, 0) so the + position of the camera is the center of the viewport. + """ return Vec2(self._camera_data.position[0], self._camera_data.position[1]) # Setter with different signature will cause mypy issues # https://github.com/python/mypy/issues/3004 @position.setter - def position(self, _pos: Point) -> None: - x, y, *_z = _pos + def position(self, pos: Point) -> None: + x, y, *_z = pos z = self._camera_data.position[2] if not _z else _z[0] self._camera_data.position = (x, y, z) + @property + def x(self) -> float: + """The 2D world position of the camera along the X axis""" + return self._camera_data.position[0] + + @x.setter + def x(self, x: float) -> None: + pos = self._camera_data.position + self._camera_data.position = (x, pos[1], pos[2]) + + @property + def y(self) -> float: + """The 2D world position of the camera along the Y axis""" + return self._camera_data.position[1] + + @y.setter + def y(self, y: float) -> None: + pos = self._camera_data.position + self._camera_data.position = (pos[0], y, pos[2]) + @property def projection(self) -> Rect: """Get/set the left, right, bottom, and top projection values. diff --git a/tests/unit/camera/test_camera2d.py b/tests/unit/camera/test_camera2d.py index 0760405075..5ab9ad752e 100644 --- a/tests/unit/camera/test_camera2d.py +++ b/tests/unit/camera/test_camera2d.py @@ -82,6 +82,20 @@ def test_camera2d_init_inheritance_safety(window: Window, camera_class): assert isinstance(subclassed, Camera2DSub1) +ASPECT_RATIOS = (1.0, 4.0 / 3.0, 16.0 / 9.0, 16.0 / 10.0) + + +def test_camera2d_init_aspect_equal_0_raises_zeroprojectiondimension(window: Window): + with pytest.raises(ZeroProjectionDimension): + camera = Camera2D(aspect=0.0) + + +@pytest.mark.parametrize("aspect", ASPECT_RATIOS) +def test_camera2d_init_respects_aspect_ratio(window: Window, aspect): + ortho_camera = Camera2D(aspect=aspect) + assert ortho_camera.viewport_width / ortho_camera.viewport_height == pytest.approx(aspect) + + RENDER_TARGET_SIZES = [ (800, 600), # Normal window size (1280, 720), # Bigger @@ -105,6 +119,9 @@ def test_camera2d_init_uses_render_target_size(window: Window, width, height): assert ortho_camera.viewport_bottom == 0 assert ortho_camera.viewport_top == height + assert ortho_camera.position.x == width / 2.0 + assert ortho_camera.position.y == height / 2.0 + @pytest.mark.parametrize("width, height", RENDER_TARGET_SIZES) def test_camera2d_from_camera_data_uses_render_target_size(window: Window, width, height): @@ -122,6 +139,9 @@ def test_camera2d_from_camera_data_uses_render_target_size(window: Window, width assert ortho_camera.viewport_bottom == 0 assert ortho_camera.viewport_top == height + assert ortho_camera.position.x == width / 2.0 + assert ortho_camera.position.y == height / 2.0 + def test_move_camera_and_project(window: Window): camera = Camera2D() From f3de4d9cbdfc535f1b0a791da3445b019f86e56e Mon Sep 17 00:00:00 2001 From: Miles Curry <2590700+MiCurry@users.noreply.github.com> Date: Tue, 23 Dec 2025 06:24:53 -0700 Subject: [PATCH 08/31] Raise TypeError if Viewport is not a Rect and make Viewport (#2790) * Make viewport a property/setter and raise TypeError if not a Rect This commit makes viewport a property of Camera2D and adds a setter for it. In the setter, and in __init__, we check that Viewport is a Rect and raise a TypeError if it is not. Without this check, an error will be raised after either calling `Camera2D.equalise()` or in `Camera2D.use()`, which may confuse users as to why the error is occurring. Backtrace when calling `Camera2D.equalise()` with a non-rect viewport: ``` File "E:\Projects\SpaceGame\SpaceGame\gamemodes\basegame.py", line 128, in setup_two_player_cameras player_one_camera.equalise() File "E:\Programs\python-arcade\arcade\camera\camera_2d.py", line 336, in equalise self._projection_data.rect = XYWH(x, y, self.viewport_width, self.viewport_height) ^^^^^^^^^^^^^^^^^^^ File "E:\Programs\python-arcade\arcade\camera\camera_2d.py", line 751, in viewport_width return int(self._viewport.width) ``` Backtrace when calling `Camera2D.use()` with a non-rect viewport: ``` File "E:\Projects\SpaceGame\SpaceGame\gamemodes\pvp.py", line 139, in on_draw self.cameras[player].use() File "E:\Programs\python-arcade\arcade\camera\camera_2d.py", line 271, in use self._window.ctx.viewport = self._viewport.lbwh_int ``` * Refactor viewport assignment and type check in camera_2d.py --------- Co-authored-by: Maic Siemering --- arcade/camera/camera_2d.py | 55 +++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/arcade/camera/camera_2d.py b/arcade/camera/camera_2d.py index d337c4e943..e55a603985 100644 --- a/arcade/camera/camera_2d.py +++ b/arcade/camera/camera_2d.py @@ -122,6 +122,9 @@ def __init__( render_target = render_target or self._window.ctx.screen viewport = viewport or LBWH(*render_target.viewport) + if not isinstance(viewport, Rect): + raise TypeError("viewport must be a Rect type,use arcade.LBWH or arcade.types.Viewport") + if aspect is None: width, height = viewport.size elif aspect == 0.0: @@ -135,6 +138,7 @@ def __init__( width = viewport.width height = viewport.width / aspect viewport = XYWH(viewport.x, viewport.y, width, height) + half_width = width / 2 half_height = height / 2 @@ -173,7 +177,8 @@ def __init__( left=left, right=right, top=top, bottom=bottom, near=near, far=far ) - self.viewport: Rect = viewport + self.viewport = viewport + """ A rect which describes how the final projection should be mapped from unit-space. defaults to the size of the render_target or window @@ -289,7 +294,7 @@ def use(self) -> None: _projection = generate_orthographic_matrix(self.projection_data, self.zoom) _view = generate_view_matrix(self.view_data) - self._window.ctx.viewport = self.viewport.lbwh_int + self._window.ctx.viewport = self._viewport.lbwh_int self._window.ctx.scissor = None if not self.scissor else self.scissor.lbwh_int self._window.projection = _projection self._window.view = _view @@ -322,7 +327,7 @@ def project(self, world_coordinate: Point) -> Vec2: return project_orthographic( world_coordinate, - self.viewport.lbwh_int, + self._viewport.lbwh_int, _view, _projection, ) @@ -345,7 +350,9 @@ def unproject(self, screen_coordinate: Point) -> Vec3: _projection = generate_orthographic_matrix(self.projection_data, self.zoom) _view = generate_view_matrix(self.view_data) - return unproject_orthographic(screen_coordinate, self.viewport.lbwh_int, _view, _projection) + return unproject_orthographic( + screen_coordinate, self._viewport.lbwh_int, _view, _projection + ) def equalize(self) -> None: """ @@ -470,8 +477,7 @@ def update_values( h = value.width / aspect value = XYWH(value.x, value.y, w, h) - if viewport: - self.viewport = value + self.viewport = value if projection: x, y = self._projection_data.rect.x, self._projection_data.rect.y @@ -498,7 +504,7 @@ def aabb(self) -> Rect: ux, uy, *_ = up rx, ry = uy, -ux # up x Z' - l, r, b, t = self.viewport.lrbt + l, r, b, t = self._viewport.lrbt x, y = self.position x_points = ( @@ -881,17 +887,28 @@ def projection_far(self) -> float: def projection_far(self, new_far: float) -> None: self._projection_data.far = new_far + @property + def viewport(self) -> Rect: + return self._viewport + + @viewport.setter + def viewport(self, viewport: Rect) -> None: + if not isinstance(viewport, Rect): + raise TypeError("viewport must be a Rect type,use arcade.LBWH or arcade.types.Viewport") + + self._viewport = viewport + @property def viewport_width(self) -> int: """ The width of the viewport. Defines the number of pixels drawn too horizontally. """ - return int(self.viewport.width) + return int(self._viewport.width) @viewport_width.setter def viewport_width(self, new_width: int) -> None: - self.viewport = self.viewport.resize(new_width, anchor=Vec2(0.0, 0.0)) + self._viewport = self._viewport.resize(new_width, anchor=Vec2(0.0, 0.0)) @property def viewport_height(self) -> int: @@ -899,18 +916,18 @@ def viewport_height(self) -> int: The height of the viewport. Defines the number of pixels drawn too vertically. """ - return int(self.viewport.height) + return int(self._viewport.height) @viewport_height.setter def viewport_height(self, new_height: int) -> None: - self.viewport = self.viewport.resize(height=new_height, anchor=Vec2(0.0, 0.0)) + self._viewport = self._viewport.resize(height=new_height, anchor=Vec2(0.0, 0.0)) @property def viewport_left(self) -> int: """ The left most pixel drawn to on the X axis. """ - return int(self.viewport.left) + return int(self._viewport.left) @viewport_left.setter def viewport_left(self, new_left: int) -> None: @@ -918,14 +935,14 @@ def viewport_left(self, new_left: int) -> None: Set the left most pixel drawn to. This moves the position of the viewport, and does not change the size. """ - self.viewport = self.viewport.align_left(new_left) + self._viewport = self._viewport.align_left(new_left) @property def viewport_right(self) -> int: """ The right most pixel drawn to on the X axis. """ - return int(self.viewport.right) + return int(self._viewport.right) @viewport_right.setter def viewport_right(self, new_right: int) -> None: @@ -933,14 +950,14 @@ def viewport_right(self, new_right: int) -> None: Set the right most pixel drawn to. This moves the position of the viewport, and does not change the size. """ - self.viewport = self.viewport.align_right(new_right) + self._viewport = self._viewport.align_right(new_right) @property def viewport_bottom(self) -> int: """ The bottom most pixel drawn to on the Y axis. """ - return int(self.viewport.bottom) + return int(self._viewport.bottom) @viewport_bottom.setter def viewport_bottom(self, new_bottom: int) -> None: @@ -948,14 +965,14 @@ def viewport_bottom(self, new_bottom: int) -> None: Set the bottom most pixel drawn to. This moves the position of the viewport, and does not change the size. """ - self.viewport = self.viewport.align_bottom(new_bottom) + self._viewport = self._viewport.align_bottom(new_bottom) @property def viewport_top(self) -> int: """ The top most pixel drawn to on the Y axis. """ - return int(self.viewport.top) + return int(self._viewport.top) @viewport_top.setter def viewport_top(self, new_top: int) -> None: @@ -963,7 +980,7 @@ def viewport_top(self, new_top: int) -> None: Set the top most pixel drawn to. This moves the position of the viewport, and does not change the size. """ - self.viewport = self.viewport.align_top(new_top) + self._viewport = self._viewport.align_top(new_top) @property def up(self) -> Vec2: From 3f7e02dd7feeebcf84ce3b790500ac27c50559fd Mon Sep 17 00:00:00 2001 From: DigiDuncan Date: Wed, 24 Dec 2025 19:07:28 -0500 Subject: [PATCH 09/31] Easing Into It: The `anim` module (#2799) * new anim folder, new easings * deprecate easing.py by deleting it it doesn't look like it's used anywhere internally * explain constants * remove obsolete examples * Get doc building * minor improvements * just define the methods on the class * Fix sphinx conf to render docstrings * Fix inclusion of items in doc * Fix doc build after adding to the quickindex file. * Convert EasingFunction into a typing.Protocol so it gets picked up by doc build + explain why * Correct Sphinx style issues and broken cross-references * Explain how of pyglet.math Matrix types won't work with easing (matmul) * Add an __all__ to arcade.anim.easing * ./make.py format * Remove unused typing.Callable import * Another ruff formatter run * Rename perc to norm as discussed * Rename `Animatable` to `Interpolatable`, as per @pushfoo --------- Co-authored-by: pushfoo <36696816+pushfoo@users.noreply.github.com> --- arcade/anim/__init__.py | 3 + arcade/anim/easing.py | 421 ++++++++++++++++++++++++++ arcade/easing.py | 275 ----------------- arcade/examples/easing_example_1.py | 209 ------------- arcade/examples/easing_example_2.py | 178 ----------- doc/api_docs/arcade.rst | 2 +- doc/example_code/easing_example_1.rst | 17 -- doc/example_code/easing_example_2.rst | 17 -- doc/example_code/index.rst | 11 +- util/update_quick_index.py | 2 +- 10 files changed, 428 insertions(+), 707 deletions(-) create mode 100644 arcade/anim/__init__.py create mode 100644 arcade/anim/easing.py delete mode 100644 arcade/easing.py delete mode 100644 arcade/examples/easing_example_1.py delete mode 100644 arcade/examples/easing_example_2.py delete mode 100644 doc/example_code/easing_example_1.rst delete mode 100644 doc/example_code/easing_example_2.rst diff --git a/arcade/anim/__init__.py b/arcade/anim/__init__.py new file mode 100644 index 0000000000..e93a038cb1 --- /dev/null +++ b/arcade/anim/__init__.py @@ -0,0 +1,3 @@ +from arcade.anim.easing import ease, Easing, lerp, norm + +__all__ = ["ease", "Easing", "lerp", "norm"] diff --git a/arcade/anim/easing.py b/arcade/anim/easing.py new file mode 100644 index 0000000000..a3a12ddcea --- /dev/null +++ b/arcade/anim/easing.py @@ -0,0 +1,421 @@ +"""Core easing annotations and helper functions.""" + +from math import cos, pi, sin, sqrt, tau +from typing import Protocol, TypeVar + +T = TypeVar("T") + + +# This needs to be a Protocol rather than an annotation +# due to our build configuration being set to pick up +# classes but not type annotations. +class EasingFunction(Protocol): + """Any :py:func:`callable` object which maps linear completion to a curve. + + .. tip:: See :py:class:`Easing` for the most common easings. + + Pass them to :py:func:`.ease` via the ``func`` + keyword argument. + + If the built-in easing curves are not enough, you can define + your own. Functions should match this pattern: + + .. code-block:: python + + def f(t: float) -> t: + ... + + For advanced users, any object with a matching :py:meth:`~object.__call__` + method can be passed as an easing function. + """ + + def __call__(self, __t: float) -> float: ... + + +class Interpolatable(Protocol): + """Matches types with support for the following operations: + + .. list-table:: + :header-rows: 1 + + * - Method + - Summary + + * - :py:meth:`~object.__mul__` + - Multiplication by a scalar + + * - :py:meth:`~object.__add__` + - Addition + + * - :py:meth:`~object.__sub__` + - Subtraction + + .. important:: The :py:mod:`pyglet.math` matrix types are currently unsupported. + + Although vector types work, matrix multiplication is + subtly different. It uses a separate :py:meth:`~object.__matmul__` + operator for multiplication. + """ + + def __mul__(self: T, other: T | float, /) -> T: ... + + def __add__(self: T, other: T | float, /) -> T: ... + + def __sub__(self: T, other: T | float, /) -> T: ... + + +A = TypeVar("A", bound=Interpolatable) + +# === BEGIN EASING FUNCTIONS === + +# CONSTANTS USED FOR EASING EQUATIONS +# *: The constants C2, C3, N1, and D1 don't have clean analogies, +# so remain unnamed. +TEN_PERCENT_BOUNCE = 1.70158 +C2 = TEN_PERCENT_BOUNCE * 1.525 +C3 = TEN_PERCENT_BOUNCE + 1 +TAU_ON_THREE = tau / 3 +TAU_ON_FOUR_AND_A_HALF = tau / 4.5 +N1 = 7.5625 +D1 = 2.75 + + +class Easing: + """Built-in easing functions as static methods. + + Each takes the following form: + + .. code-block:: python + + def f(t: float) -> float: + ... + + Pass them into :py:func:`.ease` via the ``func`` keyword + argument: + + .. code-block:: python + + from arcade.anim import ease, Easing + + value = ease( + 1.0, 2.0, + 2.0, 3.0, + 2.4, + func=Easing.SINE_IN) + + """ + + # This is a bucket of staticmethods because typing. + # Enum hates this, and they can't be classmethods. + # That's why their capitalized, it's meant to be an Enum-like + # Sorry that this looks strange! -- DigiDuncan + + @staticmethod + def LINEAR(t: float) -> float: + """Essentially the 'null' case for easing. Does no easing.""" + return t + + @staticmethod + def SINE_IN(t: float) -> float: + """http://easings.net/#easeInSine""" + return 1 - cos((t * pi / 2)) + + @staticmethod + def SINE_OUT(t: float) -> float: + """http://easings.net/#easeOutSine""" + return sin((t * pi) / 2) + + @staticmethod + def SINE(t: float) -> float: + """http://easings.net/#easeInOutSine""" + return -(cos(t * pi) - 1) / 2 + + @staticmethod + def QUAD_IN(t: float) -> float: + """http://easings.net/#easeInQuad""" + return t * t + + @staticmethod + def QUAD_OUT(t: float) -> float: + """http://easings.net/#easeOutQuad""" + return 1 - (1 - t) * (1 - t) + + @staticmethod + def QUAD(t: float) -> float: + """http://easings.net/#easeInOutQuad""" + if t < 0.5: + return 2 * t * t + else: + return 1 - pow(-2 * t + 2, 2) / 2 + + @staticmethod + def CUBIC_IN(t: float) -> float: + """http://easings.net/#easeInCubic""" + return t * t * t + + @staticmethod + def CUBIC_OUT(t: float) -> float: + """http://easings.net/#easeOutCubic""" + return 1 - pow(1 - t, 3) + + @staticmethod + def CUBIC(t: float) -> float: + """http://easings.net/#easeInOutCubic""" + if t < 0.5: + return 4 * t * t * t + else: + return 1 - pow(-2 * t + 2, 3) / 2 + + @staticmethod + def QUART_IN(t: float) -> float: + """http://easings.net/#easeInQuart""" + return t * t * t * t + + @staticmethod + def QUART_OUT(t: float) -> float: + """http://easings.net/#easeOutQuart""" + return 1 - pow(1 - t, 4) + + @staticmethod + def QUART(t: float) -> float: + """http://easings.net/#easeInOutQuart""" + if t < 0.5: + return 8 * t * t * t * t + else: + return 1 - pow(-2 * t + 2, 4) / 2 + + @staticmethod + def QUINT_IN(t: float) -> float: + """http://easings.net/#easeInQint""" + return t * t * t * t * t + + @staticmethod + def QUINT_OUT(t: float) -> float: + """http://easings.net/#easeOutQint""" + return 1 - pow(1 - t, 5) + + @staticmethod + def QUINT(t: float) -> float: + """http://easings.net/#easeInOutQint""" + if t < 0.5: + return 16 * t * t * t * t * t + else: + return 1 - pow(-2 * t + 2, 5) / 2 + + @staticmethod + def EXPO_IN(t: float) -> float: + """http://easings.net/#easeInExpo""" + if t == 0: + return 0 + return pow(2, 10 * t - 10) + + @staticmethod + def EXPO_OUT(t: float) -> float: + """http://easings.net/#easeOutExpo""" + if t == 1: + return 1 + return 1 - pow(2, -10 * t) + + @staticmethod + def EXPO(t: float) -> float: + """http://easings.net/#easeInOutExpo""" + if t == 0 or t == 1: + return t + elif t < 0.5: + return pow(2, 20 * t - 10) / 2 + else: + return (2 - pow(2, -20 * t + 10)) / 2 + + @staticmethod + def CIRC_IN(t: float) -> float: + """http://easings.net/#easeInCirc""" + return 1 - sqrt(1 - pow(t, 2)) + + @staticmethod + def CIRC_OUT(t: float) -> float: + """http://easings.net/#easeOutCirc""" + return sqrt(1 - pow(t - 1, 2)) + + @staticmethod + def CIRC(t: float) -> float: + """http://easings.net/#easeInOutCirc""" + if t < 0.5: + return (1 - sqrt(1 - pow(2 * t, 2))) / 2 + else: + return (sqrt(1 - pow(-2 * t + 2, 2)) + 1) / 2 + + @staticmethod + def BACK_IN(t: float) -> float: + """http://easings.net/#easeInBack""" + return (C3 * t * t * t) - (TEN_PERCENT_BOUNCE * t * t) + + @staticmethod + def BACK_OUT(t: float) -> float: + """http://easings.net/#easeOutBack""" + return 1 + C3 + pow(t - 1, 3) + TEN_PERCENT_BOUNCE * pow(t - 1, 2) + + @staticmethod + def BACK(t: float) -> float: + """http://easings.net/#easeInOutBack""" + if t < 0.5: + return (pow(2 * t, 2) * ((C2 + 1) * 2 * t - C2)) / 2 + else: + return (pow(2 * t - 2, 2) * ((C2 + 1) * (t * 2 - 2) + C2) + 2) / 2 + + @staticmethod + def ELASTIC_IN(t: float) -> float: + """http://easings.net/#easeInElastic""" + if t == 0 or t == 1: + return t + return -pow(2, 10 * t - 10) * sin((t * 10 - 10.75) * TAU_ON_THREE) + + @staticmethod + def ELASTIC_OUT(t: float) -> float: + """http://easings.net/#easeOutElastic""" + if t == 0 or t == 1: + return t + return pow(2, -10 * t) * sin((t * 10 - 0.75) * TAU_ON_THREE) + 1 + + @staticmethod + def ELASTIC(t: float) -> float: + """http://easings.net/#easeInOutElastic""" + if t == 0 or t == 1: + return t + if t < 0.5: + return -(pow(2, 20 * t - 10) * sin((20 * t - 11.125) * TAU_ON_FOUR_AND_A_HALF)) / 2 + else: + return (pow(2, -20 * t + 10) * sin((20 * t - 11.125) * TAU_ON_FOUR_AND_A_HALF)) / 2 + 1 + + @staticmethod + def BOUNCE_IN(t: float) -> float: + """http://easings.net/#easeInBounce""" + return 1 - (Easing.BOUNCE_OUT(1 - t)) + + @staticmethod + def BOUNCE_OUT(t: float) -> float: + """http://easings.net/#easeOutBounce""" + if t < 1 / D1: + return N1 * t * t + elif t < 2 / D1: + return N1 * ((t - 1.5) / D1) * (t - 1.5) + 0.75 + elif t < 2.5 / D1: + return N1 * ((t - 2.25) / D1) * (t - 2.25) + 0.9375 + else: + return N1 * ((t - 2.625) / D1) * (t - 2.625) + 0.984375 + + @staticmethod + def BOUNCE(t: float) -> float: + """http://easings.net/#easeInOutBounce""" + if t < 0.5: + return (1 - Easing.BOUNCE_OUT(1 - 2 * t)) / 2 + else: + return (1 + Easing.BOUNCE_OUT(2 * t - 1)) / 2 + + # Aliases to match easing.net names + SINE_IN_OUT = SINE + QUAD_IN_OUT = QUAD + CUBIC_IN_OUT = CUBIC + QUART_IN_OUT = QUART + QUINT_IN_OUT = QUINT + EXPO_IN_OUT = EXPO + CIRC_IN_OUT = CIRC + BACK_IN_OUT = BACK + ELASTIC_IN_OUT = ELASTIC + BOUNCE_IN_OUT = BOUNCE + + +# === END EASING FUNCTIONS === + + +def _clamp(x: float, low: float, high: float) -> float: + return high if x > high else max(x, low) + + +def norm(x: float, start: float, end: float) -> float: + """Convert ``x`` to a progress ratio from ``start`` to ``end``. + + The result will be a value normalized to between ``0.0`` + and ``1.0`` if ``x`` is between ``start`` and ``end`. It + is not clamped, so the result may be less than ``0.0`` or + ``greater than ``1.0``. + + Arguments: + x: A value between ``start`` and ``end``. + start: The start of the range. + end: The end of the range. + + Returns: + A range completion progress as a :py:class:`float`. + """ + return (x - start) / (end - start) + + +def lerp(progress: float, minimum: A, maximum: A) -> A: + """Get ``progress`` of the way from``minimum`` to ``maximum``. + + Arguments: + progress: How far from ``minimum`` to ``maximum`` to go + from ``0.0`` to ``1.0``. + minimum: The start value along the path. + maximum: The maximum value along the path. + + Returns: + A value ``progress`` of the way from ``minimum`` to ``maximum``. + """ + return minimum + ((maximum - minimum) * progress) + + +def ease( + minimum: A, + maximum: A, + start: float, + end: float, + t: float, + func: EasingFunction = Easing.LINEAR, + clamped: bool = True, +) -> A: + """Ease a value according to a curve function passed as ``func``. + + Override the default easing curve by passing any :py:class:`.Easing` + or :py:class:`.EasingFunction` of your choice. + + The ``maximum`` and ``minimum`` must be of compatible types. + For example, these can include: + + .. list-table:: + :header-rows: 1 + + * - Type + - Value Example + - Explanation + + * - :py:class:`float` + - ``0.5`` + - Numbers such as volume or brightness. + + * - :py:class:`~pyglet.math.Vec2` + - ``Vec2(500.0, 200.0)`` + - A :py:mod:`pyglet.math` vector representing position. + + Arguments: + minimum: any math-like object (a position, scale, value...); the "start position." + maximum: any math-like object (a position, scale, value...); the "end position." + start: a :py:class:`float` defining where progression begins, the "start time." + end: a :py:class:`float` defining where progression ends, the "end time." + t: a :py:class:`float` defining the current progression, the "current time." + func: Defaults to :py:attr:`Easing.LINEAR`, but you can pass an + :py:class:`Easing` or :py:class:`.EasingFunction` of your choice. + clamped: Whether the value will be clamped to ``minimum`` and ``maximum``. + + Returns: + An eased value for the given time ``t``. + + """ + p = norm(t, start, end) + if clamped: + p = _clamp(p, 0.0, 1.0) + new_p = func(p) + return lerp(new_p, minimum, maximum) + + +__all__ = ["Interpolatable", "Easing", "EasingFunction", "ease", "norm", "lerp"] diff --git a/arcade/easing.py b/arcade/easing.py deleted file mode 100644 index aa2a2ddf5b..0000000000 --- a/arcade/easing.py +++ /dev/null @@ -1,275 +0,0 @@ -""" -Functions used to support easing -""" - -from collections.abc import Callable -from dataclasses import dataclass -from math import cos, pi, sin - -from .math import get_distance - - -@dataclass -class EasingData: - """ - Data class for holding information about easing. - """ - - start_period: float - cur_period: float - end_period: float - start_value: float - end_value: float - ease_function: Callable - - def reset(self) -> None: - """ - Reset the easing data to its initial state. - """ - self.cur_period = self.start_period - - -def linear(percent: float) -> float: - """ - Function for linear easing. - """ - return percent - - -def _flip(percent: float) -> float: - return 1.0 - percent - - -def smoothstep(percent: float) -> float: - """ - Function for smoothstep easing. - """ - return percent**2 * (3.0 - 2.0 * percent) - - -def ease_in(percent: float) -> float: - """ - Function for quadratic ease-in easing. - """ - return percent**2 - - -def ease_out(percent: float) -> float: - """ - Function for quadratic ease-out easing. - """ - return _flip(_flip(percent) * _flip(percent)) - - -def ease_in_out(percent: float) -> float: - """ - Function for quadratic easing in and out. - """ - - return 2 * percent**2 if percent < 0.5 else 1 - (-2 * percent + 2) ** 2 / 2 - - -def ease_out_elastic(percent: float) -> float: - """ - Function for elastic ease-out easing. - """ - c4 = 2 * pi / 3 - result = 0.0 - if percent == 1: - result = 1 - elif percent > 0: - result = (2 ** (-10 * percent)) * sin((percent * 10 - 0.75) * c4) + 1 - return result - - -def ease_out_bounce(percent: float) -> float: - """ - Function for a bouncy ease-out easing. - """ - n1 = 7.5625 - d1 = 2.75 - - if percent < 1 / d1: - return n1 * percent * percent - elif percent < 2 / d1: - percent_modified = percent - 1.5 / d1 - return n1 * percent_modified * percent_modified + 0.75 - elif percent < 2.5 / d1: - percent_modified = percent - 2.25 / d1 - return n1 * percent_modified * percent_modified + 0.9375 - else: - percent_modified = percent - 2.625 / d1 - return n1 * percent_modified * percent_modified + 0.984375 - - -def ease_in_back(percent: float) -> float: - """ - Function for ease_in easing which moves back before moving forward. - """ - c1 = 1.70158 - c3 = c1 + 1 - - return c3 * percent * percent * percent - c1 * percent * percent - - -def ease_out_back(percent: float) -> float: - """ - Function for ease_out easing which moves back before moving forward. - """ - c1 = 1.70158 - c3 = c1 + 1 - - return 1 + c3 * pow(percent - 1, 3) + c1 * pow(percent - 1, 2) - - -def ease_in_sin(percent: float) -> float: - """ - Function for ease_in easing using a sin wave - """ - return 1 - cos((percent * pi) / 2) - - -def ease_out_sin(percent: float) -> float: - """ - Function for ease_out easing using a sin wave - """ - return sin((percent * pi) / 2) - - -def ease_in_out_sin(percent: float) -> float: - """ - Function for easing in and out using a sin wave - """ - return -cos(percent * pi) * 0.5 + 0.5 - - -def easing(percent: float, easing_data: EasingData) -> float: - """ - Function for calculating return value for easing, given percent and easing data. - """ - return easing_data.start_value + ( - easing_data.end_value - easing_data.start_value - ) * easing_data.ease_function(percent) - - -def ease_angle( - start_angle: float, - end_angle: float, - *, - time=None, - rate=None, - ease_function: Callable = linear, -) -> EasingData | None: - """ - Set up easing for angles. - """ - while start_angle - end_angle > 180: - end_angle += 360 - - while start_angle - end_angle < -180: - end_angle -= 360 - - diff = abs(start_angle - end_angle) - if diff == 0: - return None - - if rate is not None: - time = diff / rate - - if time is None: - raise ValueError("Either the 'time' or the 'rate' parameter needs to be set.") - - easing_data = EasingData( - start_value=start_angle, - end_value=end_angle, - start_period=0, - cur_period=0, - end_period=time, - ease_function=ease_function, - ) - return easing_data - - -def ease_angle_update(easing_data: EasingData, delta_time: float) -> tuple[bool, float]: - """ - Update angle easing. - """ - done = False - easing_data.cur_period += delta_time - easing_data.cur_period = min(easing_data.cur_period, easing_data.end_period) - percent = easing_data.cur_period / easing_data.end_period - - angle = easing(percent, easing_data) - - if percent >= 1.0: - done = True - - while angle > 360: - angle -= 360 - - while angle < 0: - angle += 360 - - return done, angle - - -def ease_value( - start_value: float, end_value: float, *, time=None, rate=None, ease_function=linear -) -> EasingData: - """ - Get an easing value - """ - if rate is not None: - diff = abs(start_value - end_value) - time = diff / rate - - if time is None: - raise ValueError("Either the 'time' or the 'rate' parameter needs to be set.") - - easing_data = EasingData( - start_value=start_value, - end_value=end_value, - start_period=0, - cur_period=0, - end_period=time, - ease_function=ease_function, - ) - return easing_data - - -def ease_position( - start_position, end_position, *, time=None, rate=None, ease_function=linear -) -> tuple[EasingData, EasingData]: - """ - Get an easing position - """ - distance = get_distance(start_position[0], start_position[1], end_position[0], end_position[1]) - - if rate is not None: - time = distance / rate - - easing_data_x = ease_value( - start_position[0], end_position[0], time=time, ease_function=ease_function - ) - easing_data_y = ease_value( - start_position[1], end_position[1], time=time, ease_function=ease_function - ) - - return easing_data_x, easing_data_y - - -def ease_update(easing_data: EasingData, delta_time: float) -> tuple[bool, float]: - """ - Update easing between two values/ - """ - easing_data.cur_period += delta_time - easing_data.cur_period = min(easing_data.cur_period, easing_data.end_period) - if easing_data.end_period == 0: - percent = 1.0 - value = easing_data.end_value - else: - percent = easing_data.cur_period / easing_data.end_period - value = easing(percent, easing_data) - - done = percent >= 1.0 - return done, value diff --git a/arcade/examples/easing_example_1.py b/arcade/examples/easing_example_1.py deleted file mode 100644 index 032d1d1021..0000000000 --- a/arcade/examples/easing_example_1.py +++ /dev/null @@ -1,209 +0,0 @@ -""" -Example showing how to use the easing functions for position. - -See: -https://easings.net/ -...for a great guide on the theory behind how easings can work. - -See example 2 for how to use easings for angles. - -If Python and Arcade are installed, this example can be run from the command line with: -python -m arcade.examples.easing_example_1 -""" - -import arcade -from arcade import easing -from arcade.types import Color - -SPRITE_SCALING = 0.5 - -WINDOW_WIDTH = 1280 -WINDOW_HEIGHT = 720 -WINDOW_TITLE = "Easing Example" - -BACKGROUND_COLOR = "#F5D167" -TEXT_COLOR = "#4B1DF2" -BALL_COLOR = "#42B5EB" -LINE_COLOR = "#45E6D0" -LINE_WIDTH = 3 - -X_START = 40 -X_END = 1200 -Y_INTERVAL = 60 -BALL_RADIUS = 13 -TIME = 3.0 - - -class EasingCircle(arcade.SpriteCircle): - """Player class""" - - def __init__(self, radius, color, center_x: float = 0, center_y: float = 0): - """Set up the player""" - - # Call the parent init - super().__init__(radius, color, center_x=center_x, center_y=center_y) - - self.easing_x_data = None - self.easing_y_data = None - - def update(self, delta_time: float = 1 / 60): - if self.easing_x_data is not None: - done, self.center_x = easing.ease_update(self.easing_x_data, delta_time) - if done: - x = X_START - if self.center_x < WINDOW_WIDTH / 2: - x = X_END - ex, ey = easing.ease_position( - self.position, - (x, self.center_y), - rate=180, - ease_function=self.easing_x_data.ease_function, - ) - self.easing_x_data = ex - - if self.easing_y_data is not None: - done, self.center_y = easing.ease_update(self.easing_y_data, delta_time) - if done: - self.easing_y_data = None - - -class GameView(arcade.View): - """Main application class.""" - - def __init__(self): - """Initializer""" - - # Call the parent class initializer - super().__init__() - - # Set the background color - self.background_color = Color.from_hex_string(BACKGROUND_COLOR) - - self.ball_list = None - self.text_list = [] - self.lines = None - - def setup(self): - """Set up the game and initialize the variables.""" - - # Sprite lists - self.ball_list = arcade.SpriteList() - self.lines = arcade.shape_list.ShapeElementList() - color = Color.from_hex_string(BALL_COLOR) - shared_ball_kwargs = dict(radius=BALL_RADIUS, color=color) - - def create_ball(ball_y, ease_function): - ball = EasingCircle(**shared_ball_kwargs, center_x=X_START, center_y=ball_y) - p1 = ball.position - p2 = (X_END, ball_y) - ex, ey = easing.ease_position(p1, p2, time=TIME, ease_function=ease_function) - ball.ease_function = ease_function - ball.easing_x_data = ex - ball.easing_y_data = ey - return ball - - def create_line(line_y): - line = arcade.shape_list.create_line( - X_START, - line_y - BALL_RADIUS - LINE_WIDTH, - X_END, - line_y - BALL_RADIUS, - line_color, - line_width=LINE_WIDTH, - ) - return line - - def create_text(text_string): - text = arcade.Text( - text_string, - x=X_START, - y=y - BALL_RADIUS, - color=text_color, - font_size=24, - ) - return text - - def add_item(item_y, ease_function, text): - ball = create_ball(item_y, ease_function) - self.ball_list.append(ball) - text = create_text(text) - self.text_list.append(text) - line = create_line(item_y) - self.lines.append(line) - - text_color = Color.from_hex_string(TEXT_COLOR) - line_color = Color.from_hex_string(LINE_COLOR) - - y = Y_INTERVAL - add_item(y, easing.linear, "Linear") - - y += Y_INTERVAL - add_item(y, easing.ease_out, "Ease out") - - y += Y_INTERVAL - add_item(y, easing.ease_in, "Ease in") - - y += Y_INTERVAL - add_item(y, easing.smoothstep, "Smoothstep") - - y += Y_INTERVAL - add_item(y, easing.ease_in_out, "Ease in/out") - - y += Y_INTERVAL - add_item(y, easing.ease_out_elastic, "Ease out elastic") - - y += Y_INTERVAL - add_item(y, easing.ease_in_back, "Ease in back") - - y += Y_INTERVAL - add_item(y, easing.ease_out_back, "Ease out back") - - y += Y_INTERVAL - add_item(y, easing.ease_in_sin, "Ease in sin") - - y += Y_INTERVAL - add_item(y, easing.ease_out_sin, "Ease out sin") - - y += Y_INTERVAL - add_item(y, easing.ease_in_out_sin, "Ease in out sin") - - def on_draw(self): - """Render the screen.""" - - # This command has to happen before we start drawing - self.clear() - - self.lines.draw() - - # Draw all the sprites. - self.ball_list.draw() - - for text in self.text_list: - text.draw() - - def on_update(self, delta_time): - """Movement and game logic""" - - # Call update on all sprites (The sprites don't do much in this - # example though.) - self.ball_list.update(delta_time) - - -def main(): - """Main function""" - # Create a window class. This is what actually shows up on screen - window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) - - # Create and setup the GameView - game = GameView() - game.setup() - - # Show GameView on screen - window.show_view(game) - - # Start the arcade game loop - arcade.run() - - -if __name__ == "__main__": - main() diff --git a/arcade/examples/easing_example_2.py b/arcade/examples/easing_example_2.py deleted file mode 100644 index 1467b71e1c..0000000000 --- a/arcade/examples/easing_example_2.py +++ /dev/null @@ -1,178 +0,0 @@ -""" -Example showing how to use the easing functions for position. -Example showing how to use easing for angles. - -See: -https://easings.net/ -...for a great guide on the theory behind how easings can work. - -If Python and Arcade are installed, this example can be run from the command line with: -python -m arcade.examples.easing_example_2 -""" - -import arcade -from arcade import easing - -SPRITE_SCALING = 1.0 - -WINDOW_WIDTH = 1280 -WINDOW_HEIGHT = 720 -WINDOW_TITLE = "Easing Example" - - -class Player(arcade.Sprite): - """Player class""" - - def __init__(self, image, scale): - """Set up the player""" - - # Call the parent init - super().__init__(image, scale=scale) - - self.easing_angle_data = None - self.easing_x_data = None - self.easing_y_data = None - - def update(self, delta_time: float = 1 / 60): - if self.easing_angle_data is not None: - done, self.angle = easing.ease_angle_update(self.easing_angle_data, delta_time) - if done: - self.easing_angle_data = None - - if self.easing_x_data is not None: - done, self.center_x = easing.ease_update(self.easing_x_data, delta_time) - if done: - self.easing_x_data = None - - if self.easing_y_data is not None: - done, self.center_y = easing.ease_update(self.easing_y_data, delta_time) - if done: - self.easing_y_data = None - - -class GameView(arcade.View): - """Main application class.""" - - def __init__(self): - """Initializer""" - - # Call the parent class initializer - super().__init__() - - # Set up the player info - self.player_list = arcade.SpriteList() - - # Load the player texture. The ship points up by default. We need it to point right. - # That's why we rotate it 90 degrees clockwise. - texture = arcade.load_texture(":resources:images/space_shooter/playerShip1_orange.png") - texture = texture.rotate_90() - - # Set up the player - self.player_sprite = Player(texture, SPRITE_SCALING) - self.player_sprite.angle = 0 - self.player_sprite.center_x = WINDOW_WIDTH / 2 - self.player_sprite.center_y = WINDOW_HEIGHT / 2 - self.player_list.append(self.player_sprite) - - # Set the background color - self.background_color = arcade.color.BLACK - self.text = "Move the mouse and press 1-9 to apply an easing function." - - def on_draw(self): - """Render the screen.""" - - # This command has to happen before we start drawing - self.clear() - - # Draw all the sprites. - self.player_list.draw() - - arcade.draw_text(self.text, 15, 15, arcade.color.WHITE, 24) - - def on_update(self, delta_time): - """Movement and game logic""" - - # Call update on all sprites (The sprites don't do much in this - # example though.) - self.player_list.update(delta_time) - - def on_key_press(self, key, modifiers): - x = self.window.mouse["x"] - y = self.window.mouse["y"] - - if key == arcade.key.KEY_1: - angle = arcade.math.get_angle_degrees( - x1=self.player_sprite.position[0], y1=self.player_sprite.position[1], x2=x, y2=y - ) - self.player_sprite.angle = angle - self.text = "Instant angle change" - if key in [arcade.key.KEY_2, arcade.key.KEY_3, arcade.key.KEY_4, arcade.key.KEY_5]: - p1 = self.player_sprite.position - p2 = (x, y) - end_angle = arcade.math.get_angle_degrees(p1[0], p1[1], p2[0], p2[1]) - start_angle = self.player_sprite.angle - if key == arcade.key.KEY_2: - ease_function = easing.linear - self.text = "Linear easing - angle" - elif key == arcade.key.KEY_3: - ease_function = easing.ease_in - self.text = "Ease in - angle" - elif key == arcade.key.KEY_4: - ease_function = easing.ease_out - self.text = "Ease out - angle" - elif key == arcade.key.KEY_5: - ease_function = easing.smoothstep - self.text = "Smoothstep - angle" - else: - raise ValueError("?") - - self.player_sprite.easing_angle_data = easing.ease_angle( - start_angle, end_angle, rate=180, ease_function=ease_function - ) - - if key in [arcade.key.KEY_6, arcade.key.KEY_7, arcade.key.KEY_8, arcade.key.KEY_9]: - p1 = self.player_sprite.position - p2 = (x, y) - if key == arcade.key.KEY_6: - ease_function = easing.linear - self.text = "Linear easing - position" - elif key == arcade.key.KEY_7: - ease_function = easing.ease_in - self.text = "Ease in - position" - elif key == arcade.key.KEY_8: - ease_function = easing.ease_out - self.text = "Ease out - position" - elif key == arcade.key.KEY_9: - ease_function = easing.smoothstep - self.text = "Smoothstep - position" - else: - raise ValueError("?") - - ex, ey = easing.ease_position(p1, p2, rate=180, ease_function=ease_function) - self.player_sprite.easing_x_data = ex - self.player_sprite.easing_y_data = ey - - def on_mouse_press(self, x: float, y: float, button: int, modifiers: int): - angle = arcade.math.get_angle_degrees( - x1=self.player_sprite.position[0], y1=self.player_sprite.position[1], x2=x, y2=y - ) - self.player_sprite.angle = angle - - -def main(): - """ Main function """ - # Create a window class. This is what actually shows up on screen - window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) - - # Create the GameView - game = GameView() - - # Show GameView on screen - window.show_view(game) - - # Start the arcade game loop - arcade.run() - - -if __name__ == "__main__": - main() diff --git a/doc/api_docs/arcade.rst b/doc/api_docs/arcade.rst index a541e0500f..c259a54b1e 100644 --- a/doc/api_docs/arcade.rst +++ b/doc/api_docs/arcade.rst @@ -40,7 +40,7 @@ for the Python Arcade library. See also: api/path_finding api/isometric api/earclip - api/easing + api/anim api/open_gl api/math gl/index diff --git a/doc/example_code/easing_example_1.rst b/doc/example_code/easing_example_1.rst deleted file mode 100644 index aa74129a42..0000000000 --- a/doc/example_code/easing_example_1.rst +++ /dev/null @@ -1,17 +0,0 @@ -:orphan: - -.. _easing_example_1: - -Easing Example 1 -================ - -.. image:: images/easing_example_1.png - :width: 600px - :align: center - :alt: Easing Example - -Source ------- -.. literalinclude:: ../../arcade/examples/easing_example_1.py - :caption: easing_example.py - :linenos: diff --git a/doc/example_code/easing_example_2.rst b/doc/example_code/easing_example_2.rst deleted file mode 100644 index 76e2f582c7..0000000000 --- a/doc/example_code/easing_example_2.rst +++ /dev/null @@ -1,17 +0,0 @@ -:orphan: - -.. _easing_example_2: - -Easing Example 2 -================ - -.. image:: images/easing_example_2.png - :width: 600px - :align: center - :alt: Easing Example - -Source ------- -.. literalinclude:: ../../arcade/examples/easing_example_2.py - :caption: easing_example.py - :linenos: diff --git a/doc/example_code/index.rst b/doc/example_code/index.rst index 5d5359e8e7..ccb6508da7 100644 --- a/doc/example_code/index.rst +++ b/doc/example_code/index.rst @@ -228,17 +228,10 @@ Non-Player Movement Easing ^^^^^^ -.. figure:: images/thumbs/easing_example_1.png - :figwidth: 170px - :target: easing_example_1.html - - :ref:`easing_example_1` +.. note:: Easing is a work in progress refactor. -.. figure:: images/thumbs/easing_example_2.png - :figwidth: 170px - :target: easing_example_2.html + Please see :py:mod:`arcade.anim`. - :ref:`easing_example_2` Calculating a Path ^^^^^^^^^^^^^^^^^^ diff --git a/util/update_quick_index.py b/util/update_quick_index.py index e6729b1af1..b4fca31cc2 100644 --- a/util/update_quick_index.py +++ b/util/update_quick_index.py @@ -188,7 +188,7 @@ "title": "Isometric Map (incomplete)", "use_declarations_in": ["arcade.isometric"], }, - "easing.rst": {"title": "Easing", "use_declarations_in": ["arcade.easing"]}, + "anim.rst": {"title": "Easing", "use_declarations_in": ["arcade.anim", "arcade.anim.easing"]}, "utility.rst": { "title": "Misc Utility Functions", "use_declarations_in": ["arcade", "arcade.__main__", "arcade.utils"], From 91020872d4fc932386f128aaef42b4ad22c08a9a Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Thu, 25 Dec 2025 02:48:26 -0500 Subject: [PATCH 10/31] Initial 4.0 Transition and Pyglet 3 Upgrade (#2801) --- .github/workflows/code_quality.yml | 77 ++++ .github/workflows/docs.yml | 35 ++ .github/workflows/selfhosted_runner.yml | 70 --- .github/workflows/test.yml | 131 ++---- .github/workflows/verify_types.yml | 31 -- .gitignore | 5 +- CHANGELOG.md | 23 +- arcade/VERSION | 2 +- arcade/__init__.py | 18 +- arcade/application.py | 101 +++-- arcade/context.py | 127 +++--- arcade/examples/dual_stick_shooter.py | 20 +- arcade/examples/follow_path.py | 2 +- arcade/examples/gl/__init__.py | 0 arcade/examples/gl/tessellation.py | 4 +- arcade/examples/gl/texture_compression.py | 2 +- arcade/examples/gui/__init__.py | 0 .../examples/gui/exp_controller_inventory.py | 4 +- arcade/examples/perf_test/__init__.py | 0 arcade/examples/platform_tutorial/__init__.py | 0 arcade/examples/slime_invaders.py | 4 +- arcade/examples/snow.py | 2 +- arcade/examples/sprite_bullets.py | 2 +- arcade/examples/sprite_change_coins.py | 2 +- arcade/examples/sprite_collect_coins.py | 2 +- .../sprite_collect_coins_background.py | 2 +- .../sprite_collect_coins_diff_levels.py | 2 +- .../sprite_collect_coins_move_bouncing.py | 2 +- .../sprite_collect_coins_move_circle.py | 2 +- .../sprite_collect_coins_move_down.py | 2 +- arcade/examples/sprite_collect_rotating.py | 2 +- arcade/examples/sprite_explosion_bitmapped.py | 2 +- arcade/examples/sprite_explosion_particles.py | 2 +- arcade/examples/sprite_follow_simple.py | 2 +- arcade/examples/sprite_follow_simple_2.py | 2 +- arcade/examples/sprite_properties.py | 2 +- .../view_instructions_and_game_over.py | 4 +- .../input_manager_example.py | 9 +- arcade/experimental/shapes_buffered_2_glow.py | 2 +- arcade/future/__init__.py | 3 +- arcade/future/video/video_player.py | 6 +- arcade/future/video/video_record_cv2.py | 2 +- arcade/gl/backends/opengl/buffer.py | 13 +- arcade/gl/backends/opengl/compute_shader.py | 5 +- arcade/gl/backends/opengl/context.py | 54 ++- arcade/gl/backends/opengl/framebuffer.py | 19 +- arcade/gl/backends/opengl/glsl.py | 2 +- arcade/gl/backends/opengl/program.py | 5 +- arcade/gl/backends/opengl/query.py | 5 +- arcade/gl/backends/opengl/sampler.py | 2 +- arcade/gl/backends/opengl/texture.py | 5 +- arcade/gl/backends/opengl/texture_array.py | 5 +- arcade/gl/backends/opengl/uniform.py | 2 +- arcade/gl/backends/opengl/vertex_array.py | 10 +- arcade/gl/backends/webgl/__init__.py | 0 arcade/gl/backends/webgl/buffer.py | 128 ++++++ arcade/gl/backends/webgl/context.py | 412 ++++++++++++++++++ arcade/gl/backends/webgl/framebuffer.py | 303 +++++++++++++ arcade/gl/backends/webgl/glsl.py | 162 +++++++ arcade/gl/backends/webgl/program.py | 358 +++++++++++++++ arcade/gl/backends/webgl/provider.py | 14 + arcade/gl/backends/webgl/query.py | 75 ++++ arcade/gl/backends/webgl/sampler.py | 135 ++++++ arcade/gl/backends/webgl/texture.py | 382 ++++++++++++++++ arcade/gl/backends/webgl/texture_array.py | 327 ++++++++++++++ arcade/gl/backends/webgl/uniform.py | 201 +++++++++ arcade/gl/backends/webgl/utils.py | 32 ++ arcade/gl/backends/webgl/vertex_array.py | 272 ++++++++++++ arcade/gl/buffer.py | 3 + arcade/gl/context.py | 2 +- arcade/gl/enums.py | 120 +++++ arcade/gl/texture_array.py | 5 +- arcade/gl/vertex_array.py | 75 ++-- arcade/hitbox/__init__.py | 10 +- arcade/{future => }/input/README.md | 0 arcade/{future => }/input/__init__.py | 0 arcade/{future => }/input/input_mapping.py | 4 +- arcade/{future => }/input/inputs.py | 2 +- arcade/{future => }/input/manager.py | 8 +- arcade/{future => }/input/raw_dicts.py | 0 arcade/perf_graph.py | 4 +- arcade/pymunk_physics_engine.py | 31 +- .../shaders/atlas/resize_simple_vs.glsl | 3 +- arcade/shape_list.py | 30 +- arcade/sound.py | 27 +- arcade/sprite_list/collision.py | 17 +- arcade/sprite_list/sprite_list.py | 64 ++- arcade/text.py | 82 +--- arcade/texture_atlas/atlas_default.py | 8 +- arcade/utils.py | 2 + doc/tutorials/views/01_views.py | 2 +- doc/tutorials/views/02_views.py | 2 +- doc/tutorials/views/03_views.py | 2 +- doc/tutorials/views/04_views.py | 2 +- doc/tutorials/views/index.rst | 4 +- index.html | 28 ++ make.py | 10 + pyproject.toml | 7 +- tests/conftest.py | 4 +- .../sprite_collision_inspector.py | 4 +- tests/unit/atlas/test_basics.py | 2 +- tests/unit/atlas/test_rebuild_resize.py | 2 +- tests/unit/gl/backends/gl/test_gl_program.py | 2 +- tests/unit/gl/test_gl_types.py | 2 +- .../unit/shape_list/test_buffered_drawing.py | 2 +- tests/unit/test_example_docstrings.py | 2 + tests/unit/window/test_window.py | 2 +- util/update_quick_index.py | 8 - webplayground/README.md | 23 + webplayground/example.tpl | 31 ++ webplayground/index.tpl | 16 + webplayground/server.py | 72 +++ 112 files changed, 3736 insertions(+), 630 deletions(-) create mode 100644 .github/workflows/code_quality.yml create mode 100644 .github/workflows/docs.yml delete mode 100644 .github/workflows/selfhosted_runner.yml delete mode 100644 .github/workflows/verify_types.yml create mode 100644 arcade/examples/gl/__init__.py create mode 100644 arcade/examples/gui/__init__.py create mode 100644 arcade/examples/perf_test/__init__.py create mode 100644 arcade/examples/platform_tutorial/__init__.py rename arcade/{future/input => experimental}/input_manager_example.py (96%) create mode 100644 arcade/gl/backends/webgl/__init__.py create mode 100644 arcade/gl/backends/webgl/buffer.py create mode 100644 arcade/gl/backends/webgl/context.py create mode 100644 arcade/gl/backends/webgl/framebuffer.py create mode 100644 arcade/gl/backends/webgl/glsl.py create mode 100644 arcade/gl/backends/webgl/program.py create mode 100644 arcade/gl/backends/webgl/provider.py create mode 100644 arcade/gl/backends/webgl/query.py create mode 100644 arcade/gl/backends/webgl/sampler.py create mode 100644 arcade/gl/backends/webgl/texture.py create mode 100644 arcade/gl/backends/webgl/texture_array.py create mode 100644 arcade/gl/backends/webgl/uniform.py create mode 100644 arcade/gl/backends/webgl/utils.py create mode 100644 arcade/gl/backends/webgl/vertex_array.py rename arcade/{future => }/input/README.md (100%) rename arcade/{future => }/input/__init__.py (100%) rename arcade/{future => }/input/input_mapping.py (96%) rename arcade/{future => }/input/inputs.py (99%) rename arcade/{future => }/input/manager.py (98%) rename arcade/{future => }/input/raw_dicts.py (100%) create mode 100644 index.html create mode 100644 webplayground/README.md create mode 100644 webplayground/example.tpl create mode 100644 webplayground/index.tpl create mode 100644 webplayground/server.py diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml new file mode 100644 index 0000000000..77b1cff767 --- /dev/null +++ b/.github/workflows/code_quality.yml @@ -0,0 +1,77 @@ +# This does code inspection and checks to make sure building of docs works + +name: Code Quality + +on: + push: + branches: [development, maintenance] + pull_request: + branches: [development, maintenance] + workflow_dispatch: + +jobs: + + build: + name: Code Inspections + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Install UV + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Sync UV project + run: uv sync + + - name: Formatting (Ruff) + if: success() + continue-on-error: true + run: uv run make.py format --check + + - name: Linting (Ruff) + if: success() + continue-on-error: true + run: uv run make.py ruff-check + + - name: Type Checking (MyPy) + if: success() + continue-on-error: true + run: uv run make.py mypy + + - name: Type Checking (Pyright) + if: success() + continue-on-error: true + run: uv run make.py pyright + + verifytypes: + name: Verify Types + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Install UV + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Sync UV project + run: uv sync + + - name: Pyright Type Completeness + # Suppress exit code because we do not expect to reach 100% type completeness any time soon + run: uv run pyright --verifytypes arcade || true + diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000..fe60bc4499 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,35 @@ +# Builds the doc in PRs + +name: Docs Build + +on: + push: + branches: [development, maintenance] + pull_request: + branches: [development, maintenance] + workflow_dispatch: + +jobs: + + build: + name: Build Documentation + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install UV + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Sync UV Project + run: uv sync + + - name: build-docs + run: uv run make.py docs-full diff --git a/.github/workflows/selfhosted_runner.yml b/.github/workflows/selfhosted_runner.yml deleted file mode 100644 index f8597f6a05..0000000000 --- a/.github/workflows/selfhosted_runner.yml +++ /dev/null @@ -1,70 +0,0 @@ -# This is our full unit tests -# Self-hosted, run on an old notebook -name: Unit testing - -on: - push: - branches: [development, maintenance] - pull_request: - branches: [development, maintenance] - workflow_dispatch: - -jobs: - - build: - name: Unit tests - runs-on: self-hosted - - strategy: - matrix: - os: [ubuntu-latest] - python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] - architecture: ['x64'] - - steps: - - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies part 1 - run: | - rm -rf .venv - python -m venv .venv - source .venv/bin/activate - pip install --upgrade pip - python -m pip install -U pip wheel setuptools - - name: Install dependencies part 2 - run: | - source .venv/bin/activate - python -m pip install -I -e .[testing_libraries] - - name: Test with pytest - run: | - source .venv/bin/activate - which python - python -c "import pyglet; print('pyglet version', pyglet.__version__)" - python -c "import PIL; print('Pillow version', PIL.__version__)" - pytest --maxfail=10 - - # Prepare the Pull Request Payload artifact. If this fails, we - # we fail silently using the `continue-on-error` option. It's - # nice if this succeeds, but if it fails for any reason, it - # does not mean that our main workflow has failed. - - name: Prepare Pull Request Payload artifact - id: prepare-artifact - if: always() && github.event_name == 'pull_request' - continue-on-error: true - run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json - - # This only makes sense if the previous step succeeded. To - # get the original outcome of the previous step before the - # `continue-on-error` conclusion is applied, we use the - # `.outcome` value. This step also fails silently. - - name: Upload a Build Artifact - if: always() && steps.prepare-artifact.outcome == 'success' - continue-on-error: true - uses: actions/upload-artifact@v4 - with: - name: pull-request-payload - path: pull_request_payload.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b89cde3c1..a9ab9e844a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,4 @@ -# This does code inspection and checks to make sure building of docs works - -name: GitHub based tests +name: PyTest on: push: @@ -11,117 +9,38 @@ on: jobs: - build: - name: Code inspections - runs-on: ${{ matrix.os }} - + linux: + runs-on: ubuntu-latest strategy: matrix: - os: [ubuntu-latest] - python-version: ['3.12'] - architecture: ['x64'] - + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + + name: Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v4 - - name: setup + - uses: actions/checkout@v5 + + # xvfb is used to run "headless" by providing a virtual X server + # ffmpeg is used for handling mp3 files in some of our tests + - name: Install xvfb and ffmpeg + run: sudo apt-get install xvfb ffmpeg + + - name: Setup Python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - architecture: ${{ matrix.architecture }} - - name: dependencies - run: | - python -m pip install -U pip wheel setuptools - - name: wheel - id: wheel - run: | - python -m pip install -e .[dev] - - name: "code-inspection: formatting" - if: ${{ (success() || failure()) && steps.wheel.outcome == 'success' }} - run: | - python ./make.py format --check - - name: "code-inspection: ruff-check" - if: ${{ (success() || failure()) && steps.wheel.outcome == 'success' }} - run: | - python ./make.py ruff-check - - name: "code-inspection: mypy" - if: ${{ (success() || failure()) && steps.wheel.outcome == 'success' }} - run: | - python ./make.py mypy - - name: "code-inspection: pyright" - if: ${{ (success() || failure()) && steps.wheel.outcome == 'success' }} - run: | - python ./make.py pyright - # Prepare the Pull Request Payload artifact. If this fails, - # we fail silently using the `continue-on-error` option. It's - # nice if this succeeds, but if it fails for any reason, it - # does not mean that our lint-test checks failed. - - name: Prepare Pull Request Payload artifact - id: prepare-artifact - if: always() && github.event_name == 'pull_request' - continue-on-error: true - run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json - - # This only makes sense if the previous step succeeded. To - # get the original outcome of the previous step before the - # `continue-on-error` conclusion is applied, we use the - # `.outcome` value. This step also fails silently. - - name: Upload a Build Artifact - if: always() && steps.prepare-artifact.outcome == 'success' - continue-on-error: true - uses: actions/upload-artifact@v4 + - name: Install UV + uses: astral-sh/setup-uv@v7 with: - name: pull-request-payload - path: pull_request_payload.json - - builddoc: - - name: Documentation build test - runs-on: ${{ matrix.os }} + enable-cache: true - strategy: - matrix: - os: [ubuntu-latest] - # python-version in must be kept in sync with .readthedocs.yaml - python-version: ['3.10'] # July 2024 | Match our contributor dev version; see pyproject.toml - architecture: ['x64'] + - name: Sync UV project + run: uv sync - steps: - - uses: actions/checkout@v4 - - name: setup - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - architecture: ${{ matrix.architecture }} - - - name: dependencies - run: | - python -m pip install -U pip wheel setuptools - - name: wheel - id: wheel - run: | - python -m pip install -e .[dev] - - name: build-docs + - name: Run tests + env: + PYGLET_BACKEND: opengl + REPL_ID: hello # Arcade checks for this to disable anti-aliasing run: | - sphinx-build doc build -W - # Prepare the Pull Request Payload artifact. If this fails, - # we fail silently using the `continue-on-error` option. It's - # nice if this succeeds, but if it fails for any reason, it - # does not mean that our lint-test checks failed. - - name: Prepare Pull Request Payload artifact - id: prepare-artifact - if: always() && github.event_name == 'pull_request' - continue-on-error: true - run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json - - # This only makes sense if the previous step succeeded. To - # get the original outcome of the previous step before the - # `continue-on-error` conclusion is applied, we use the - # `.outcome` value. This step also fails silently. - - name: Upload a Build Artifact - if: always() && steps.prepare-artifact.outcome == 'success' - continue-on-error: true - uses: actions/upload-artifact@v4 - with: - name: pull-request-payload - path: pull_request_payload.json + xvfb-run --auto-servernum uv run arcade + xvfb-run --auto-servernum uv run pytest --maxfail=10 \ No newline at end of file diff --git a/.github/workflows/verify_types.yml b/.github/workflows/verify_types.yml deleted file mode 100644 index 1e037c82e6..0000000000 --- a/.github/workflows/verify_types.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Type completeness report - -on: - push: - branches: [development, maintenance] - pull_request: - branches: [development, maintenance] - workflow_dispatch: - -jobs: - - verifytypes: - name: Verify types - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: setup - uses: actions/setup-python@v5 - with: - python-version: 3.11 - architecture: x64 - - - name: Install - id: install - run: | - python -m pip install .[dev] - - name: "code-inspection: pyright --verifytypes" - # Suppress exit code because we do not expect to reach 100% type completeness any time soon - run: | - python -m pyright --verifytypes arcade || true diff --git a/.gitignore b/.gitignore index 8d2b38dd22..f97fc04787 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,7 @@ temp/ # pending: Sphinx 8.1.4 + deps are verified as working with Arcade # see util/sphinx_static_file_temp_fix.py -.ENABLE_DEVMACHINE_SPHINX_STATIC_FIX \ No newline at end of file +.ENABLE_DEVMACHINE_SPHINX_STATIC_FIX + +webplayground/**/*.whl +webplayground/**/*.zip \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b5c84811a..6c9e349f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,15 +3,28 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. -## Unreleased +## 4.0.0.dev1 + +### New Features +- Support for running with Pyodide in web browsers. +- New `anim` module. Currently contains new easing/lerp utilities. + +### Breaking Changes +- `arcade.easing` has been removed, and replaced by the new `arcade.anim.easing` module. +- `arcade.future.input` package has been moved to the top level `arcade.input`. + +### GUI +- `UIManager` did not apply size hint of (0,0). Mainly an issue with `UIBoxLayout`. +- Allow multiple children in `UIScrollArea`. +- Fix `UIDropdown Overlay` positioning within a `UIScrollArea`. + +### Misc Changes - Upgraded Pillow to 12.0.0 for Python 3.14 support. - Adds a new `arcade.NoAracdeWindowError` exception type. This is raised when certain window operations are performed and there is no valid Arcade window found. Previously where this error would be raised, we raised a standard `RuntimeError`, this made it harder to properly catch and act accordingly. This new exception subclasses `RuntimeError`, so you can still catch this error the same way as before. The `arcade.get_window()` function will now raise this if there is no window. - Along with the new exception type, is a new `arcade.windows_exists()` function which will return True or False based on if there is currently an active window. -- GUI - - `UIManager` did not apply size hint of (0,0). Mainly an issue with `UIBoxLayout`. - - Allow multiple children in `UIScrollArea`. - - Fix `UIDropdown Overlay` positioning within a `UIScrollArea`. + + ## 3.3.3 diff --git a/arcade/VERSION b/arcade/VERSION index 3f09e91095..bcadb75013 100644 --- a/arcade/VERSION +++ b/arcade/VERSION @@ -1 +1 @@ -3.3.3 \ No newline at end of file +4.0.0.dev1 \ No newline at end of file diff --git a/arcade/__init__.py b/arcade/__init__.py index 968e311bf8..e10cf5a079 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -59,7 +59,7 @@ def configure_logging(level: int | None = None): # Enable HiDPI support using stretch mode if os.environ.get("ARCADE_TEST"): - pyglet.options.dpi_scaling = "real" + pyglet.options.dpi_scaling = "platform" else: pyglet.options.dpi_scaling = "stretch" @@ -68,13 +68,6 @@ def configure_logging(level: int | None = None): if headless: pyglet.options.headless = headless - -# from arcade import utils -# Disable shadow window on macs and in headless mode. -# if sys.platform == "darwin" or os.environ.get("ARCADE_HEADLESS") or utils.is_raspberry_pi(): -# NOTE: We always disable shadow window now to have consistent behavior across platforms. -pyglet.options.shadow_window = False - # Imports from modules that don't do anything circular # Complex imports with potential circularity @@ -199,9 +192,10 @@ def configure_logging(level: int | None = None): from .tilemap import load_tilemap from .tilemap import TileMap -from .pymunk_physics_engine import PymunkPhysicsEngine -from .pymunk_physics_engine import PymunkPhysicsObject -from .pymunk_physics_engine import PymunkException +if sys.platform != "emscripten": + from .pymunk_physics_engine import PymunkPhysicsEngine + from .pymunk_physics_engine import PymunkPhysicsObject + from .pymunk_physics_engine import PymunkException from .version import VERSION @@ -238,6 +232,7 @@ def configure_logging(level: int | None = None): from arcade import math as math from arcade import shape_list as shape_list from arcade import hitbox as hitbox +from arcade import input as input from arcade import experimental as experimental from arcade.types import rect @@ -388,6 +383,7 @@ def configure_logging(level: int | None = None): "get_default_texture", "get_default_image", "hitbox", + "input", "experimental", "rect", "color", diff --git a/arcade/application.py b/arcade/application.py index 759e7a7c3b..2a883df819 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -12,7 +12,13 @@ from typing import TYPE_CHECKING import pyglet -import pyglet.gl as gl + +from arcade.utils import is_pyodide + +if is_pyodide(): + pyglet.options.backend = "webgl" + +import pyglet.config import pyglet.window.mouse from pyglet.display.base import Screen, ScreenMode from pyglet.event import EVENT_HANDLE_STATE, EVENT_UNHANDLED @@ -24,7 +30,7 @@ from arcade.context import ArcadeContext from arcade.gl.provider import get_arcade_context, set_provider from arcade.types import LBWH, Color, Rect, RGBANormalized, RGBOrA255 -from arcade.utils import is_pyodide, is_raspberry_pi +from arcade.utils import is_raspberry_pi from arcade.window_commands import get_display_size, set_window if TYPE_CHECKING: @@ -173,6 +179,7 @@ def __init__( gl_api = "webgl" if gl_api == "webgl": + pyglet.options.backend = "webgl" desired_gl_provider = "webgl" # Detect Raspberry Pi and switch to OpenGL ES 3.1 @@ -187,15 +194,34 @@ def __init__( config = None # Attempt to make window with antialiasing - if antialiasing: - try: - config = gl.Config( + if gl_api == "opengl" or gl_api == "opengles": + if antialiasing: + try: + config = pyglet.config.OpenGLConfig( + major_version=gl_version[0], + minor_version=gl_version[1], + opengl_api=gl_api.replace("open", ""), # type: ignore # pending: upstream fix + double_buffer=True, + sample_buffers=1, + samples=samples, + depth_size=24, + stencil_size=8, + red_size=8, + green_size=8, + blue_size=8, + alpha_size=8, + ) + except RuntimeError: + LOG.warning("Skipping antialiasing due missing hardware/driver support") + config = None + antialiasing = False + # If we still don't have a config + if not config: + config = pyglet.config.OpenGLConfig( major_version=gl_version[0], minor_version=gl_version[1], opengl_api=gl_api.replace("open", ""), # type: ignore # pending: upstream fix double_buffer=True, - sample_buffers=1, - samples=samples, depth_size=24, stencil_size=8, red_size=8, @@ -203,35 +229,14 @@ def __init__( blue_size=8, alpha_size=8, ) - display = pyglet.display.get_display() - screen = screen or display.get_default_screen() - if screen: - config = screen.get_best_config(config) - except pyglet.window.NoSuchConfigException: - LOG.warning("Skipping antialiasing due missing hardware/driver support") - config = None - antialiasing = False - # If we still don't have a config - if not config: - config = gl.Config( - major_version=gl_version[0], - minor_version=gl_version[1], - opengl_api=gl_api.replace("open", ""), # type: ignore # pending: upstream fix - double_buffer=True, - depth_size=24, - stencil_size=8, - red_size=8, - green_size=8, - blue_size=8, - alpha_size=8, - ) try: + # This type ignore is here because somehow Pyright thinks this is an Emscripten window super().__init__( width=width, height=height, caption=title, resizable=resizable, - config=config, + config=config, # type: ignore vsync=vsync, visible=visible, style=style, @@ -245,11 +250,15 @@ def __init__( "Unable to create an OpenGL 3.3+ context. " "Check to make sure your system supports OpenGL 3.3 or higher." ) - if antialiasing: - try: - gl.glEnable(gl.GL_MULTISAMPLE_ARB) - except gl.GLException: - LOG.warning("Warning: Anti-aliasing not supported on this computer.") + if gl_api == "opengl" or gl_api == "opengles": + if antialiasing: + import pyglet.graphics.api.gl as gl + import pyglet.graphics.api.gl.lib as gllib + + try: + gl.glEnable(gl.GL_MULTISAMPLE_ARB) + except gllib.GLException: + LOG.warning("Warning: Anti-aliasing not supported on this computer.") _setup_clock() _setup_fixed_clock(fixed_rate) @@ -348,8 +357,10 @@ def current_view(self) -> View | None: """ return self._current_view + # TODO: This is overriding the ctx function from Pyglet's BaseWindow which returns the + # SurfaceContext class from pyglet. We should probably rename this. @property - def ctx(self) -> ArcadeContext: + def ctx(self) -> ArcadeContext: # type: ignore """ The OpenGL context for this window. @@ -759,7 +770,7 @@ def on_mouse_scroll( """ return EVENT_UNHANDLED - def set_mouse_visible(self, visible: bool = True) -> None: + def set_mouse_cursor_visible(self, visible: bool = True) -> None: """ Set whether to show the system's cursor while over the window @@ -790,7 +801,7 @@ def set_mouse_visible(self, visible: bool = True) -> None: Args: visible: Whether to hide the system mouse cursor """ - super().set_mouse_visible(visible) + super().set_mouse_cursor_visible(visible) def on_action(self, action_name: str, state) -> None: """ @@ -846,6 +857,12 @@ def on_key_release(self, symbol: int, modifiers: int) -> EVENT_HANDLE_STATE: """ return EVENT_UNHANDLED + def before_draw(self) -> None: + """ + New event in base pyglet window. This is current unused in Arcade. + """ + pass + def on_draw(self) -> EVENT_HANDLE_STATE: """ Override this function to add your custom drawing code. @@ -1129,17 +1146,17 @@ def set_vsync(self, vsync: bool) -> None: """Set if we sync our draws to the monitors vertical sync rate.""" super().set_vsync(vsync) - def set_mouse_platform_visible(self, platform_visible=None) -> None: + def set_mouse_cursor_platform_visible(self, platform_visible=None) -> None: """ .. warning:: You are probably looking for - :meth:`~.Window.set_mouse_visible`! + :meth:`~.Window.set_mouse_cursor_visible`! This is a lower level function inherited from the pyglet window. For more information on what this means, see the documentation - for :py:meth:`pyglet.window.Window.set_mouse_platform_visible`. + for :py:meth:`pyglet.window.Window.set_mouse_cursor_platform_visible`. """ - super().set_mouse_platform_visible(platform_visible) + super().set_mouse_cursor_platform_visible(platform_visible) def set_exclusive_mouse(self, exclusive=True) -> None: """Capture the mouse.""" diff --git a/arcade/context.py b/arcade/context.py index 52e0cedae5..2cbe21a85f 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -10,17 +10,17 @@ 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.buffer import Buffer from arcade.gl.compute_shader import ComputeShader from arcade.gl.framebuffer import Framebuffer from arcade.gl.program import Program +from arcade.gl.query import Query from arcade.gl.texture import Texture2D from arcade.gl.vertex_array import Geometry from arcade.texture_atlas import DefaultTextureAtlas, TextureAtlasBase @@ -56,10 +56,10 @@ def __init__( 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 + # Mypy can't figure out the dynamic creation of the matrices in Pyglet + # They are created based on the active backend. + self._window_block = window._matrices.ubo # type: ignore self.bind_window_block() self.blend_func = self.BLEND_DEFAULT @@ -84,21 +84,26 @@ def __init__( 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 + if gl_api != "webgl": + 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 + else: + self.sprite_list_program_no_cull = None # type: ignore + self.sprite_list_program_cull = None # type: ignore self.sprite_list_program_no_geo = self.load_program( vertex_shader=":system:shaders/sprites/sprite_list_simple_vs.glsl", @@ -114,14 +119,18 @@ def __init__( 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 + if gl_api != "webgl": + 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 + else: + self.sprite_program_single = None # type: ignore + # Non-geometry shader single sprite program self.sprite_program_single_simple = self.load_program( vertex_shader=":system:shaders/sprites/sprite_single_simple_vs.glsl", @@ -180,28 +189,34 @@ def __init__( 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.atlas_resize_program["texcoords_old"] = 1 + self.atlas_resize_program["texcoords_new"] = 2 + + if gl_api != "webgl": + # SpriteList collision resources + # Buffer version of the collision detection program. + self.collision_detection_program: Program | None = 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: Program | None = 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) + self.collision_buffer: Buffer | None = self.buffer(reserve=1024 * 4) + self.collision_query: Query | None = self.query( + samples=False, time=False, primitives=True + ) + else: + self.collision_detection_program = None + self.collision_detection_program_simple = None + self.collision_buffer = None + self.collision_query = None # General Utility @@ -251,7 +266,10 @@ def __init__( ["in_vert"], ), BufferDescription( - self.shape_line_buffer_pos, "4f", ["in_instance_pos"], instanced=True + self.shape_line_buffer_pos, + "4f", + ["in_instance_pos"], + instanced=True, ), ], mode=self.TRIANGLE_STRIP, @@ -300,7 +318,8 @@ def __init__( self.label_cache: dict[str, arcade.Text] = {} # self.active_program = None - self.point_size = 1.0 + if gl_api != "webgl": + self.point_size = 1.0 def reset(self) -> None: """ @@ -326,12 +345,8 @@ def bind_window_block(self) -> None: 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, # type: ignore - 128, # 32 x 32bit floats (two mat4) # type: ignore + raise NotImplementedError( + "The currently selected GL backend does not implement ArcadeContext.bind_window_block" ) @property diff --git a/arcade/examples/dual_stick_shooter.py b/arcade/examples/dual_stick_shooter.py index d8f7711efe..7f7b2a7954 100644 --- a/arcade/examples/dual_stick_shooter.py +++ b/arcade/examples/dual_stick_shooter.py @@ -36,11 +36,9 @@ def dump_obj(obj): def dump_controller(controller): print(f"========== {controller}") - print(f"Left X {controller.leftx}") - print(f"Left Y {controller.lefty}") + print(f"Left X,Y {controller.leftstick[0]},{controller.leftstick[1]}") print(f"Left Trigger {controller.lefttrigger}") - print(f"Right X {controller.rightx}") - print(f"Right Y {controller.righty}") + print(f"Right X,Y {controller.rightstick[0]},{controller.rightstick[1]}") print(f"Right Trigger {controller.righttrigger}") print("========== Extra controller") dump_obj(controller) @@ -58,11 +56,11 @@ def dump_controller_state(ticks, controller): num_fmts = ["{:5.2f}"] * 6 fmt_str += " ".join(num_fmts) print(fmt_str.format(ticks, - controller.leftx, - controller.lefty, + controller.leftstick[0], + controller.leftstick[1], controller.lefttrigger, - controller.rightx, - controller.righty, + controller.rightstick[0], + controller.rightstick[0], controller.righttrigger, )) @@ -202,8 +200,10 @@ def on_update(self, delta_time): if self.controller: # Controller input - movement + left_position = self.controller.leftstick + right_position = self.controller.rightstick move_x, move_y, move_angle = get_stick_position( - self.controller.leftx, self.controller.lefty + left_position[0], left_position[1] ) if move_angle: self.player.change_x = move_x * MOVEMENT_SPEED @@ -215,7 +215,7 @@ def on_update(self, delta_time): # Controller input - shooting shoot_x, shoot_y, shoot_angle = get_stick_position( - self.controller.rightx, self.controller.righty + right_position[0], right_position[1] ) if shoot_angle: self.spawn_bullet(shoot_angle) diff --git a/arcade/examples/follow_path.py b/arcade/examples/follow_path.py index 79c5e24ae0..11c4ff916c 100644 --- a/arcade/examples/follow_path.py +++ b/arcade/examples/follow_path.py @@ -93,7 +93,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/gl/__init__.py b/arcade/examples/gl/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/arcade/examples/gl/tessellation.py b/arcade/examples/gl/tessellation.py index 8a3efb3ee2..6cd25f7e7d 100644 --- a/arcade/examples/gl/tessellation.py +++ b/arcade/examples/gl/tessellation.py @@ -10,7 +10,7 @@ import arcade from arcade.gl import BufferDescription -import pyglet.gl +from pyglet.graphics.api.gl import GL_PATCHES WINDOW_WIDTH = 1280 WINDOW_HEIGHT = 720 @@ -107,7 +107,7 @@ def __init__(self, width, height, title): def on_draw(self): self.clear() self.program["time"] = self.time - self.geometry.render(self.program, mode=pyglet.gl.GL_PATCHES) + self.geometry.render(self.program, mode=GL_PATCHES) if __name__ == "__main__": diff --git a/arcade/examples/gl/texture_compression.py b/arcade/examples/gl/texture_compression.py index 7f31b311b1..aa0a3af132 100644 --- a/arcade/examples/gl/texture_compression.py +++ b/arcade/examples/gl/texture_compression.py @@ -12,7 +12,7 @@ import PIL.Image import arcade import arcade.gl -from pyglet import gl +from pyglet.graphics.api import gl class CompressedTextures(arcade.Window): diff --git a/arcade/examples/gui/__init__.py b/arcade/examples/gui/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/arcade/examples/gui/exp_controller_inventory.py b/arcade/examples/gui/exp_controller_inventory.py index ffe99f02b0..5359b8746e 100644 --- a/arcade/examples/gui/exp_controller_inventory.py +++ b/arcade/examples/gui/exp_controller_inventory.py @@ -412,8 +412,8 @@ def on_draw_before_ui(self): if __name__ == "__main__": # pixelate the font - pyglet.font.base.Font.texture_min_filter = GL_NEAREST - pyglet.font.base.Font.texture_mag_filter = GL_NEAREST + pyglet.font.base.Font.texture_min_filter = GL_NEAREST # type: ignore + pyglet.font.base.Font.texture_mag_filter = GL_NEAREST # type: ignore load_kenney_fonts() diff --git a/arcade/examples/perf_test/__init__.py b/arcade/examples/perf_test/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/arcade/examples/platform_tutorial/__init__.py b/arcade/examples/platform_tutorial/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/arcade/examples/slime_invaders.py b/arcade/examples/slime_invaders.py index f4ef41d7e7..1c7280b730 100644 --- a/arcade/examples/slime_invaders.py +++ b/arcade/examples/slime_invaders.py @@ -74,7 +74,7 @@ def __init__(self): self.enemy_change_x = -ENEMY_SPEED # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Load sounds. Sounds from kenney.nl self.gun_sound = arcade.load_sound(":resources:sounds/hurt5.wav") @@ -196,7 +196,7 @@ def on_draw(self): # Draw game over if the game state is such if self.game_state == GAME_OVER: self.game_over_text.draw() - self.window.set_mouse_visible(True) + self.window.set_mouse_cursor_visible(True) def on_key_press(self, key, modifiers): if key == arcade.key.ESCAPE: diff --git a/arcade/examples/snow.py b/arcade/examples/snow.py index 35163073e1..e1585a7de4 100644 --- a/arcade/examples/snow.py +++ b/arcade/examples/snow.py @@ -60,7 +60,7 @@ def __init__(self): self.snowflake_list = arcade.SpriteList() # Don't show the mouse pointer - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Set the background color self.background_color = arcade.color.BLACK diff --git a/arcade/examples/sprite_bullets.py b/arcade/examples/sprite_bullets.py index c8652d3f9b..837f61c545 100644 --- a/arcade/examples/sprite_bullets.py +++ b/arcade/examples/sprite_bullets.py @@ -41,7 +41,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Load sounds. Sounds from kenney.nl self.gun_sound = arcade.load_sound(":resources:sounds/hurt5.wav") diff --git a/arcade/examples/sprite_change_coins.py b/arcade/examples/sprite_change_coins.py index 742d3f2b37..b1cc6c94b6 100644 --- a/arcade/examples/sprite_change_coins.py +++ b/arcade/examples/sprite_change_coins.py @@ -77,7 +77,7 @@ def setup(self): self.coin_list.append(coin) # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Set the background color self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_collect_coins.py b/arcade/examples/sprite_collect_coins.py index 41c2cb220a..45fe45955e 100644 --- a/arcade/examples/sprite_collect_coins.py +++ b/arcade/examples/sprite_collect_coins.py @@ -42,7 +42,7 @@ def __init__(self): self.score_display = None # Hide the mouse cursor while it's over the window - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_collect_coins_background.py b/arcade/examples/sprite_collect_coins_background.py index ff41d33f19..4c30da178f 100644 --- a/arcade/examples/sprite_collect_coins_background.py +++ b/arcade/examples/sprite_collect_coins_background.py @@ -47,7 +47,7 @@ def __init__(self): self.score_text = arcade.Text("Score: 0", 10, 20, arcade.color.WHITE, 14) # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Set the background color self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_collect_coins_diff_levels.py b/arcade/examples/sprite_collect_coins_diff_levels.py index 503499d1cf..209af44dfa 100644 --- a/arcade/examples/sprite_collect_coins_diff_levels.py +++ b/arcade/examples/sprite_collect_coins_diff_levels.py @@ -74,7 +74,7 @@ def __init__(self): self.level = 1 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Set the background color self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_collect_coins_move_bouncing.py b/arcade/examples/sprite_collect_coins_move_bouncing.py index 256d5ec26b..90513f88e2 100644 --- a/arcade/examples/sprite_collect_coins_move_bouncing.py +++ b/arcade/examples/sprite_collect_coins_move_bouncing.py @@ -67,7 +67,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_collect_coins_move_circle.py b/arcade/examples/sprite_collect_coins_move_circle.py index 4b05dff104..0dbcc1d48f 100644 --- a/arcade/examples/sprite_collect_coins_move_circle.py +++ b/arcade/examples/sprite_collect_coins_move_circle.py @@ -106,7 +106,7 @@ def setup(self): self.coin_list.append(coin) # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Set the background color self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_collect_coins_move_down.py b/arcade/examples/sprite_collect_coins_move_down.py index 15b93fc3ab..c9b1f033f5 100644 --- a/arcade/examples/sprite_collect_coins_move_down.py +++ b/arcade/examples/sprite_collect_coins_move_down.py @@ -64,7 +64,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_collect_rotating.py b/arcade/examples/sprite_collect_rotating.py index 28b37ebb12..674c01c094 100644 --- a/arcade/examples/sprite_collect_rotating.py +++ b/arcade/examples/sprite_collect_rotating.py @@ -46,7 +46,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_explosion_bitmapped.py b/arcade/examples/sprite_explosion_bitmapped.py index 04b278a0da..1d04e5c8c6 100644 --- a/arcade/examples/sprite_explosion_bitmapped.py +++ b/arcade/examples/sprite_explosion_bitmapped.py @@ -77,7 +77,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Pre-load the animation frames. We don't do this in the __init__ # of the explosion sprite because it diff --git a/arcade/examples/sprite_explosion_particles.py b/arcade/examples/sprite_explosion_particles.py index 5b12d7f3a6..c33cfa18ed 100644 --- a/arcade/examples/sprite_explosion_particles.py +++ b/arcade/examples/sprite_explosion_particles.py @@ -163,7 +163,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Load sounds. Sounds from kenney.nl self.gun_sound = arcade.sound.load_sound(":resources:sounds/laser2.wav") diff --git a/arcade/examples/sprite_follow_simple.py b/arcade/examples/sprite_follow_simple.py index 8dcaa20b48..325b404115 100644 --- a/arcade/examples/sprite_follow_simple.py +++ b/arcade/examples/sprite_follow_simple.py @@ -68,7 +68,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_follow_simple_2.py b/arcade/examples/sprite_follow_simple_2.py index 2e08bbedff..29b4d7e266 100644 --- a/arcade/examples/sprite_follow_simple_2.py +++ b/arcade/examples/sprite_follow_simple_2.py @@ -87,7 +87,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_properties.py b/arcade/examples/sprite_properties.py index 90a4d67f08..cbcc30c2a7 100644 --- a/arcade/examples/sprite_properties.py +++ b/arcade/examples/sprite_properties.py @@ -44,7 +44,7 @@ def __init__(self): self.trigger_sprite = None # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/view_instructions_and_game_over.py b/arcade/examples/view_instructions_and_game_over.py index bcebdd0b2b..4eeb1fcd44 100644 --- a/arcade/examples/view_instructions_and_game_over.py +++ b/arcade/examples/view_instructions_and_game_over.py @@ -97,7 +97,7 @@ def on_show_view(self): self.window.background_color = arcade.color.AMAZON # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) def on_draw(self): self.clear() @@ -134,7 +134,7 @@ def on_update(self, delta_time): if len(self.coin_list) == 0: game_over_view = GameOverView() game_over_view.time_taken = self.time_taken - self.window.set_mouse_visible(True) + self.window.set_mouse_cursor_visible(True) self.window.show_view(game_over_view) def on_mouse_motion(self, x, y, _dx, _dy): diff --git a/arcade/future/input/input_manager_example.py b/arcade/experimental/input_manager_example.py similarity index 96% rename from arcade/future/input/input_manager_example.py rename to arcade/experimental/input_manager_example.py index 56bc942ee7..34fd31b36c 100644 --- a/arcade/future/input/input_manager_example.py +++ b/arcade/experimental/input_manager_example.py @@ -1,4 +1,11 @@ # type: ignore +""" +Example for handling input using the Arcade InputManager + +If Python and Arcade are installed, this example can be run from the command line with: +python -m arcade.examples.input_manager +""" + import random from collections.abc import Sequence @@ -6,7 +13,7 @@ from pyglet.input import Controller import arcade -from arcade.future.input import ActionState, ControllerAxes, ControllerButtons, InputManager, Keys +from arcade.input import ActionState, ControllerAxes, ControllerButtons, InputManager, Keys WINDOW_WIDTH = 1280 WINDOW_HEIGHT = 720 diff --git a/arcade/experimental/shapes_buffered_2_glow.py b/arcade/experimental/shapes_buffered_2_glow.py index 2abc284e8b..5cfa525df2 100644 --- a/arcade/experimental/shapes_buffered_2_glow.py +++ b/arcade/experimental/shapes_buffered_2_glow.py @@ -9,7 +9,7 @@ import random -from pyglet import gl +from pyglet.graphics.api import gl import arcade from arcade.experimental import postprocessing diff --git a/arcade/future/__init__.py b/arcade/future/__init__.py index 1f42c56507..735aa6c81c 100644 --- a/arcade/future/__init__.py +++ b/arcade/future/__init__.py @@ -1,9 +1,8 @@ from . import video from . import light -from . import input from . import background from . import splash from .texture_render_target import RenderTargetTexture -__all__ = ["video", "light", "input", "background", "RenderTargetTexture", "splash"] +__all__ = ["video", "light", "background", "RenderTargetTexture", "splash"] diff --git a/arcade/future/video/video_player.py b/arcade/future/video/video_player.py index 4962011ae6..8c61d896b8 100644 --- a/arcade/future/video/video_player.py +++ b/arcade/future/video/video_player.py @@ -23,7 +23,7 @@ class VideoPlayer: """ def __init__(self, path: str | Path, loop: bool = False): - self.player = pyglet.media.Player() + self.player = pyglet.media.VideoPlayer() self.player.loop = loop self.player.queue(pyglet.media.load(str(arcade.resources.resolve(path)))) self.player.play() @@ -77,9 +77,9 @@ def get_video_size(self) -> tuple[int, int]: width = video_format.width height = video_format.height if video_format.sample_aspect > 1: - width *= video_format.sample_aspect + width = int(width * video_format.sample_aspect) elif video_format.sample_aspect < 1: - height /= video_format.sample_aspect + height = int(height / video_format.sample_aspect) return width, height diff --git a/arcade/future/video/video_record_cv2.py b/arcade/future/video/video_record_cv2.py index 9dd7089f62..3265bc7ab7 100644 --- a/arcade/future/video/video_record_cv2.py +++ b/arcade/future/video/video_record_cv2.py @@ -21,7 +21,7 @@ import cv2 # type: ignore import numpy # type: ignore -import pyglet.gl as gl +import pyglet.graphics.api.gl as gl import arcade diff --git a/arcade/gl/backends/opengl/buffer.py b/arcade/gl/backends/opengl/buffer.py index 0fc7d40ef2..e6245c7485 100644 --- a/arcade/gl/backends/opengl/buffer.py +++ b/arcade/gl/backends/opengl/buffer.py @@ -4,9 +4,10 @@ from ctypes import byref, string_at from typing import TYPE_CHECKING -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl -from arcade.gl.buffer import Buffer +from arcade.gl.buffer import Buffer, _usages from arcade.types import BufferProtocol from .utils import data_to_ctypes @@ -14,12 +15,6 @@ if TYPE_CHECKING: from arcade.gl import Context -_usages = { - "static": gl.GL_STATIC_DRAW, - "dynamic": gl.GL_DYNAMIC_DRAW, - "stream": gl.GL_STREAM_DRAW, -} - class OpenGLBuffer(Buffer): """OpenGL buffer object. Buffers store byte data and upload it @@ -120,7 +115,7 @@ def delete_glo(ctx: Context, glo: gl.GLuint): The OpenGL buffer id """ # If we have no context, then we are shutting down, so skip this - if gl.current_context is None: + if core.current_context is None: return if glo.value != 0: diff --git a/arcade/gl/backends/opengl/compute_shader.py b/arcade/gl/backends/opengl/compute_shader.py index 67a1e8543c..f3c88a4ea3 100644 --- a/arcade/gl/backends/opengl/compute_shader.py +++ b/arcade/gl/backends/opengl/compute_shader.py @@ -14,7 +14,8 @@ ) from typing import TYPE_CHECKING -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl from arcade.gl.compute_shader import ComputeShader @@ -188,7 +189,7 @@ def delete_glo(ctx, prog_id): """ # Check to see if the context was already cleaned up from program # shut down. If so, we don't need to delete the shaders. - if gl.current_context is None: + if core.current_context is None: return gl.glDeleteProgram(prog_id) diff --git a/arcade/gl/backends/opengl/context.py b/arcade/gl/backends/opengl/context.py index 24f34e47a3..ca110c5d8b 100644 --- a/arcade/gl/backends/opengl/context.py +++ b/arcade/gl/backends/opengl/context.py @@ -2,7 +2,7 @@ from typing import Dict, Iterable, List, Sequence, Tuple import pyglet -from pyglet import gl +from pyglet.graphics.api import gl from arcade.context import ArcadeContext from arcade.gl import enums @@ -29,7 +29,10 @@ class OpenGLContext(Context): _valid_apis = ("opengl", "opengles") def __init__( - self, window: pyglet.window.Window, gc_mode: str = "context_gc", gl_api: str = "opengl" + self, + window: pyglet.window.Window, + gc_mode: str = "context_gc", + gl_api: str = "opengl", ): super().__init__(window, gc_mode) @@ -44,6 +47,12 @@ def __init__( self._gl_version = (self._info.MAJOR_VERSION, self._info.MINOR_VERSION) + # This can't be set in the abstract context because not all backends + # support primitive restart, and the getter in those backends will raise + # a NotImplementedError. So we need to do this specifically on the + # backends that support it + self.primitive_restart_index = self._primitive_restart_index + # Hardcoded states # This should always be enabled # gl.glEnable(gl.GL_TEXTURE_CUBE_MAP_SEAMLESS) @@ -57,7 +66,9 @@ def __init__( # Assumed to be supported in gles self._ext_separate_shader_objects_enabled = True if self.gl_api == "opengl": - have_ext = gl.gl_info.have_extension("GL_ARB_separate_shader_objects") + have_ext = self.window.context.get_info().have_extension( + "GL_ARB_separate_shader_objects" + ) # type: ignore This is guaranteed to be an OpenGLSurfaceContext self._ext_separate_shader_objects_enabled = self.gl_version >= (4, 1) or have_ext # We enable scissor testing by default. @@ -78,7 +89,7 @@ def gl_version(self) -> Tuple[int, int]: @Context.extensions.getter def extensions(self) -> set[str]: - return gl.gl_info.get_extensions() + return self.window.context.get_info().extensions # type: ignore @property def error(self) -> str | None: @@ -213,7 +224,11 @@ def _create_default_framebuffer(self) -> OpenGLDefaultFrameBuffer: return OpenGLDefaultFrameBuffer(self) def buffer( - self, *, data: BufferProtocol | None = None, reserve: int = 0, usage: str = "static" + self, + *, + data: BufferProtocol | None = None, + reserve: int = 0, + usage: str = "static", ) -> OpenGLBuffer: return OpenGLBuffer(self, data, reserve=reserve, usage=usage) @@ -264,10 +279,10 @@ def program( return OpenGLProgram( self, vertex_shader=source_vs.get_source(defines=defines), - fragment_shader=source_fs.get_source(defines=defines) if source_fs else None, - geometry_shader=source_geo.get_source(defines=defines) if source_geo else None, - tess_control_shader=source_tc.get_source(defines=defines) if source_tc else None, - tess_evaluation_shader=source_te.get_source(defines=defines) if source_te else None, + fragment_shader=(source_fs.get_source(defines=defines) if source_fs else None), + geometry_shader=(source_geo.get_source(defines=defines) if source_geo else None), + tess_control_shader=(source_tc.get_source(defines=defines) if source_tc else None), + tess_evaluation_shader=(source_te.get_source(defines=defines) if source_te else None), varyings=out_attributes, varyings_capture_mode=varyings_capture_mode, ) @@ -288,7 +303,7 @@ def geometry( ) def compute_shader(self, *, source: str, common: Iterable[str] = ()) -> OpenGLComputeShader: - src = ShaderSource(self, source, common, pyglet.gl.GL_COMPUTE_SHADER) + src = ShaderSource(self, source, common, gl.GL_COMPUTE_SHADER) return OpenGLComputeShader(self, src.get_source()) def texture( @@ -337,7 +352,9 @@ def framebuffer( depth_attachment: OpenGLTexture2D | None = None, ) -> OpenGLFramebuffer: return OpenGLFramebuffer( - self, color_attachments=color_attachments or [], depth_attachment=depth_attachment + self, + color_attachments=color_attachments or [], + depth_attachment=depth_attachment, ) def copy_framebuffer( @@ -416,6 +433,15 @@ def __init__(self, *args, **kwargs): OpenGLContext.__init__(self, *args, **kwargs) ArcadeContext.__init__(self, *args, **kwargs) + def bind_window_block(self): + gl.glBindBufferRange( + gl.GL_UNIFORM_BUFFER, + 0, + self._window_block.buffer.id, + 0, # type: ignore + 128, # 32 x 32bit floats (two mat4) # type: ignore + ) + class OpenGLInfo(Info): """OpenGL info and capabilities""" @@ -491,7 +517,7 @@ def get_int_tuple(self, enum, length: int): values = (c_int * length)() gl.glGetIntegerv(enum, values) return tuple(values) - except pyglet.gl.lib.GLException: + except gl.lib.GLException: return tuple([0] * length) def get(self, enum, default=0) -> int: @@ -521,7 +547,7 @@ def get_float(self, enum, default=0.0) -> float: value = c_float() gl.glGetFloatv(enum, value) return value.value - except pyglet.gl.lib.GLException: + except gl.GLException: return default def get_str(self, enum) -> str: @@ -533,5 +559,5 @@ def get_str(self, enum) -> str: """ try: return cast(gl.glGetString(enum), c_char_p).value.decode() # type: ignore - except pyglet.gl.lib.GLException: + except gl.GLException: return "Unknown" diff --git a/arcade/gl/backends/opengl/framebuffer.py b/arcade/gl/backends/opengl/framebuffer.py index 419cd2f86f..eb9fac5a6f 100644 --- a/arcade/gl/backends/opengl/framebuffer.py +++ b/arcade/gl/backends/opengl/framebuffer.py @@ -4,7 +4,8 @@ from ctypes import Array, c_int, c_uint, string_at from typing import TYPE_CHECKING -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl from arcade.gl.framebuffer import DefaultFrameBuffer, Framebuffer from arcade.gl.types import pixel_formats @@ -208,12 +209,22 @@ def clear( if len(color) == 3: clear_color = color[0] / 255, color[1] / 255, color[2] / 255, 1.0 elif len(color) == 4: - clear_color = color[0] / 255, color[1] / 255, color[2] / 255, color[3] / 255 + clear_color = ( + color[0] / 255, + color[1] / 255, + color[2] / 255, + color[3] / 255, + ) else: raise ValueError("Color should be a 3 or 4 component tuple") elif color_normalized is not None: if len(color_normalized) == 3: - clear_color = color_normalized[0], color_normalized[1], color_normalized[2], 1.0 + clear_color = ( + color_normalized[0], + color_normalized[1], + color_normalized[2], + 1.0, + ) elif len(color_normalized) == 4: clear_color = color_normalized else: @@ -304,7 +315,7 @@ def delete_glo(ctx, framebuffer_id): framebuffer_id: Framebuffer id destroy (glo) """ - if gl.current_context is None: + if core.current_context is None: return gl.glDeleteFramebuffers(1, framebuffer_id) diff --git a/arcade/gl/backends/opengl/glsl.py b/arcade/gl/backends/opengl/glsl.py index 2abcf7650f..2827f6b683 100644 --- a/arcade/gl/backends/opengl/glsl.py +++ b/arcade/gl/backends/opengl/glsl.py @@ -1,7 +1,7 @@ import re from typing import TYPE_CHECKING, Iterable -from pyglet import gl +from pyglet.graphics.api import gl if TYPE_CHECKING: from .context import Context as ArcadeGlContext diff --git a/arcade/gl/backends/opengl/program.py b/arcade/gl/backends/opengl/program.py index 61b08cfd93..cde9cead01 100644 --- a/arcade/gl/backends/opengl/program.py +++ b/arcade/gl/backends/opengl/program.py @@ -15,7 +15,8 @@ ) from typing import TYPE_CHECKING, Any, Iterable -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl from arcade.gl.exceptions import ShaderException from arcade.gl.program import Program @@ -256,7 +257,7 @@ def delete_glo(ctx, prog_id): """ # Check to see if the context was already cleaned up from program # shut down. If so, we don't need to delete the shaders. - if gl.current_context is None: + if core.current_context is None: return gl.glDeleteProgram(prog_id) diff --git a/arcade/gl/backends/opengl/query.py b/arcade/gl/backends/opengl/query.py index 76b66b5470..2aa5236521 100644 --- a/arcade/gl/backends/opengl/query.py +++ b/arcade/gl/backends/opengl/query.py @@ -3,7 +3,8 @@ import weakref from typing import TYPE_CHECKING -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl from arcade.gl.query import Query @@ -118,7 +119,7 @@ def delete_glo(ctx, glos) -> None: This is automatically called when the object is garbage collected. """ - if gl.current_context is None: + if core.current_context is None: return for glo in glos: diff --git a/arcade/gl/backends/opengl/sampler.py b/arcade/gl/backends/opengl/sampler.py index 538c71767f..4f59a72325 100644 --- a/arcade/gl/backends/opengl/sampler.py +++ b/arcade/gl/backends/opengl/sampler.py @@ -4,7 +4,7 @@ from ctypes import byref, c_uint32 from typing import TYPE_CHECKING -from pyglet import gl +from pyglet.graphics.api import gl from arcade.gl.sampler import Sampler from arcade.gl.types import PyGLuint, compare_funcs diff --git a/arcade/gl/backends/opengl/texture.py b/arcade/gl/backends/opengl/texture.py index 13f668e91b..0de0e65a91 100644 --- a/arcade/gl/backends/opengl/texture.py +++ b/arcade/gl/backends/opengl/texture.py @@ -4,7 +4,8 @@ from ctypes import byref, string_at from typing import TYPE_CHECKING -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl from arcade.gl.texture import Texture2D from arcade.gl.types import ( @@ -658,7 +659,7 @@ def delete_glo(ctx: "Context", glo: gl.GLuint): glo: The OpenGL texture id """ # If we have no context, then we are shutting down, so skip this - if gl.current_context is None: + if core.current_context is None: return if glo.value != 0: diff --git a/arcade/gl/backends/opengl/texture_array.py b/arcade/gl/backends/opengl/texture_array.py index 8b1456989d..153cc3a884 100644 --- a/arcade/gl/backends/opengl/texture_array.py +++ b/arcade/gl/backends/opengl/texture_array.py @@ -4,7 +4,8 @@ from ctypes import byref, string_at from typing import TYPE_CHECKING -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl from arcade.gl.texture_array import TextureArray from arcade.gl.types import ( @@ -602,7 +603,7 @@ def delete_glo(ctx: "Context", glo: gl.GLuint): glo: The OpenGL texture id """ # If we have no context, then we are shutting down, so skip this - if gl.current_context is None: + if core.current_context is None: return if glo.value != 0: diff --git a/arcade/gl/backends/opengl/uniform.py b/arcade/gl/backends/opengl/uniform.py index f664bc320d..55059cded7 100644 --- a/arcade/gl/backends/opengl/uniform.py +++ b/arcade/gl/backends/opengl/uniform.py @@ -2,7 +2,7 @@ from ctypes import POINTER, c_double, c_float, c_int, c_uint, cast from typing import Callable -from pyglet import gl +from pyglet.graphics.api import gl from arcade.gl.exceptions import ShaderException diff --git a/arcade/gl/backends/opengl/vertex_array.py b/arcade/gl/backends/opengl/vertex_array.py index 27293978e0..dfc2ea0862 100644 --- a/arcade/gl/backends/opengl/vertex_array.py +++ b/arcade/gl/backends/opengl/vertex_array.py @@ -4,7 +4,8 @@ from ctypes import byref, c_void_p from typing import TYPE_CHECKING, Sequence -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl from arcade.gl.types import BufferDescription, GLenumLike, GLuintLike, gl_name from arcade.gl.vertex_array import Geometry, VertexArray @@ -97,7 +98,7 @@ def delete_glo(ctx: Context, glo: gl.GLuint) -> None: This is automatically called when this object is garbage collected. """ # If we have no context, then we are shutting down, so skip this - if gl.current_context is None: + if core.current_context is None: return if glo.value != 0: @@ -107,7 +108,10 @@ def delete_glo(ctx: Context, glo: gl.GLuint) -> None: ctx.stats.decr("vertex_array") def _build( - self, program: Program, content: Sequence[BufferDescription], index_buffer: Buffer | None + self, + program: Program, + content: Sequence[BufferDescription], + index_buffer: Buffer | None, ) -> None: """ Build a vertex array compatible with the program passed in. diff --git a/arcade/gl/backends/webgl/__init__.py b/arcade/gl/backends/webgl/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/arcade/gl/backends/webgl/buffer.py b/arcade/gl/backends/webgl/buffer.py new file mode 100644 index 0000000000..574ad92e87 --- /dev/null +++ b/arcade/gl/backends/webgl/buffer.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +import js # type: ignore + +from arcade.gl import enums +from arcade.gl.buffer import Buffer, _usages +from arcade.types import BufferProtocol + +from .utils import data_to_memoryview + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLBuffer as JSWebGLBuffer + + from arcade.gl.backends.webgl.context import WebGLContext + + +class WebGLBuffer(Buffer): + __slots__ = "_glo", "_usage" + + def __init__( + self, + ctx: WebGLContext, + data: BufferProtocol | None = None, + reserve: int = 0, + usage: str = "static", + ): + super().__init__(ctx) + self._ctx: WebGLContext = ctx + self._usage = _usages[usage] + self._glo: JSWebGLBuffer | None = ctx._gl.createBuffer() + + if self._glo is None: + raise RuntimeError("Cannot create Buffer object.") + + ctx._gl.bindBuffer(enums.ARRAY_BUFFER, self._glo) + + if data is not None and len(data) > 0: # type: ignore + self._size, data = data_to_memoryview(data) + js_array_buffer = js.ArrayBuffer.new(self._size) + js_array_buffer.assign(data) + ctx._gl.bufferData(enums.ARRAY_BUFFER, js_array_buffer, self._usage) + elif reserve > 0: + self._size = reserve + # WebGL allows passing an integer size instead of a memoryview + # to populate the buffer with zero bytes. We have to provide the bytes + # ourselves in OpenGL + ctx._gl.bufferData(enums.ARRAY_BUFFER, self._size, self._usage) + else: + raise ValueError("Buffer takes byte data or number of reserved bytes") + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, WebGLBuffer.delete_glo, self.ctx, self._glo) # type: ignore + + def __repr__(self): + return f"" + + def __del__(self): + if self._ctx.gc_mode == "context_gc" and self._glo is not None: + self._ctx.objects.append(self) + + @property + def glo(self) -> JSWebGLBuffer | None: + return self._glo + + def delete(self) -> None: + WebGLBuffer.delete_glo(self._ctx, self._glo) # type: ignore + self._glo = None + + @staticmethod + def delete_glo(ctx: WebGLContext, glo: JSWebGLBuffer | None): + if glo is not None: + ctx._gl.deleteBuffer(glo) + + ctx.stats.decr("buffer") + + def read(self, size: int = -1, offset: int = 0) -> bytes: + # framebuffer has kind of an example to do this but it's with typed arrays + # need to figure out how to read to a generic ArrayBuffer and get a memoryview from that + # for generic buffers since we have no idea what the data type might be + raise NotImplementedError("Not done yet") + + def write(self, data: BufferProtocol, offset: int = 0): + self._ctx._gl.bindBuffer(enums.ARRAY_BUFFER, self._glo) + size, data = data_to_memoryview(data) + js_array_buffer = js.ArrayBuffer.new(size) + js_array_buffer.assign(data) + # Ensure we don't write outside the buffer + size = min(size, self._size - offset) + if size < 0: + raise ValueError("Attempting to write negative number bytes to buffer") + self._ctx._gl.bufferSubData(enums.ARRAY_BUFFER, offset, js_array_buffer) + + def copy_from_buffer(self, source: WebGLBuffer, size=-1, offset=0, source_offset=0): + if size == -1: + size = source.size + + if size + source_offset > source.size: + raise ValueError("Attempting to read outside the source buffer") + + if size + offset > self._size: + raise ValueError("Attempting to write outside the buffer") + + self._ctx._gl.bindBuffer(enums.COPY_READ_BUFFER, source.glo) + self._ctx._gl.bindBuffer(enums.COPY_WRITE_BUFFER, self._glo) + self._ctx._gl.copyBufferSubData( + enums.COPY_READ_BUFFER, enums.COPY_WRITE_BUFFER, source_offset, offset, size + ) + + def orphan(self, size: int = -1, double: bool = False): + if size > 0: + self._size = size + elif double is True: + self._size *= 2 + + self._ctx._gl.bindBuffer(enums.ARRAY_BUFFER, self._glo) + self._ctx._gl.bufferData(enums.ARRAY_BUFFER, self._size, self._usage) + + def bind_to_uniform_block(self, binding: int = 0, offset: int = 0, size: int = -1): + if size < 0: + size = self.size + + self._ctx._gl.bindBufferRange(enums.UNIFORM_BUFFER, binding, self._glo, offset, size) + + def bind_to_storage_buffer(self, *, binding=0, offset=0, size=-1): + raise NotImplementedError("bind_to_storage_buffer is not suppported with WebGL") diff --git a/arcade/gl/backends/webgl/context.py b/arcade/gl/backends/webgl/context.py new file mode 100644 index 0000000000..4ed4bd6429 --- /dev/null +++ b/arcade/gl/backends/webgl/context.py @@ -0,0 +1,412 @@ +from typing import TYPE_CHECKING, Dict, Iterable, List, Sequence, Tuple + +import pyglet +import pyglet.graphics.api + +from arcade.context import ArcadeContext +from arcade.gl import enums +from arcade.gl.context import Context, Info +from arcade.gl.types import BufferDescription +from arcade.types import BufferProtocol + +from .buffer import WebGLBuffer +from .framebuffer import WebGLDefaultFrameBuffer, WebGLFramebuffer +from .glsl import ShaderSource +from .program import WebGLProgram +from .query import WebGLQuery +from .sampler import WebGLSampler +from .texture import WebGLTexture2D +from .texture_array import WebGLTextureArray +from .vertex_array import WebGLGeometry, WebGLVertexArray # noqa: F401 + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGL2RenderingContext + + +class WebGLContext(Context): + gl_api: str = "webgl" + + def __init__( + self, + window: pyglet.window.Window, + gc_mode: str = "context_gc", + gl_api: str = "webgl", # type: ignore + ): + if gl_api != "webgl": + raise ValueError("Tried to create a WebGLContext with an incompatible api selected.") + + self.gl_api = gl_api + self._gl: WebGL2RenderingContext = pyglet.graphics.api.core.current_context.gl + + anistropy_ext = self._gl.getExtension("EXT_texture_filter_anisotropic") + texture_float_linear_ext = self._gl.getExtension("OES_texture_float_linear") + + unsupported_extensions = [] + if not anistropy_ext: + unsupported_extensions.append("EXT_texture_filter_anisotropic") + if not texture_float_linear_ext: + unsupported_extensions.append("OES_texture_float_linear") + + if unsupported_extensions: + raise RuntimeError( + f"Tried to create a WebGL context with missing extensions: {unsupported_extensions}" + ) + + super().__init__(window, gc_mode, gl_api) + + self._gl.enable(enums.SCISSOR_TEST) + + self._build_uniform_setters() + + def _build_uniform_setters(self): + self._uniform_setters = { + # Integers + enums.INT: (int, self._gl.uniform1i, 1, 1), + enums.INT_VEC2: (int, self._gl.uniform2iv, 2, 1), + enums.INT_VEC3: (int, self._gl.uniform3iv, 3, 1), + enums.INT_VEC4: (int, self._gl.uniform4iv, 4, 1), + # Unsigned Integers + enums.UNSIGNED_INT: (int, self._gl.uniform1ui, 1, 1), + enums.UNSIGNED_INT_VEC2: (int, self._gl.uniform2ui, 2, 1), + enums.UNSIGNED_INT_VEC3: (int, self._gl.uniform3ui, 3, 1), + enums.UNSIGNED_INT_VEC4: (int, self._gl.uniform4ui, 4, 1), + # Bools + enums.BOOL: (bool, self._gl.uniform1i, 1, 1), + enums.BOOL_VEC2: (bool, self._gl.uniform2iv, 2, 1), + enums.BOOL_VEC3: (bool, self._gl.uniform3iv, 3, 1), + enums.BOOL_VEC4: (bool, self._gl.uniform4iv, 4, 1), + # Floats + enums.FLOAT: (float, self._gl.uniform1f, 1, 1), + enums.FLOAT_VEC2: (float, self._gl.uniform2fv, 2, 1), + enums.FLOAT_VEC3: (float, self._gl.uniform3fv, 3, 1), + enums.FLOAT_VEC4: (float, self._gl.uniform4fv, 4, 1), + # Matrices + enums.FLOAT_MAT2: (float, self._gl.uniformMatrix2fv, 4, 1), + enums.FLOAT_MAT3: (float, self._gl.uniformMatrix3fv, 9, 1), + enums.FLOAT_MAT4: (float, self._gl.uniformMatrix4fv, 16, 1), + # 2D Samplers + enums.SAMPLER_2D: (int, self._gl.uniform1i, 1, 1), + enums.INT_SAMPLER_2D: (int, self._gl.uniform1i, 1, 1), + enums.UNSIGNED_INT_SAMPLER_2D: (int, self._gl.uniform1i, 1, 1), + # Array + enums.SAMPLER_2D_ARRAY: ( + int, + self._gl.uniform1iv, + self._gl.uniform1iv, + 1, + 1, + ), + } + + @Context.extensions.getter + def extensions(self) -> set[str]: + return self.window.context.get_info().extensions # type: ignore + + @property + def error(self) -> str | None: + err = self._gl.getError() + if err == enums.NO_ERROR: + return None + + return self._errors.get(err, "UNKNOWN_ERROR") + + def enable(self, *flags: int): + self._flags.update(flags) + + for flag in flags: + self._gl.enable(flag) + + def enable_only(self, *args: int): + self._flags = set(args) + + if self.BLEND in self._flags: + self._gl.enable(self.BLEND) + else: + self._gl.disable(self.BLEND) + + if self.DEPTH_TEST in self._flags: + self._gl.enable(self.DEPTH_TEST) + else: + self._gl.disable(self.DEPTH_TEST) + + if self.CULL_FACE in self._flags: + self._gl.enable(self.CULL_FACE) + else: + self._gl.disable(self.CULL_FACE) + + def disable(self, *args): + self._flags -= set(args) + + for flag in args: + self._gl.disable(flag) + + @Context.blend_func.setter + def blend_func(self, value: Tuple[int, int] | Tuple[int, int, int, int]): + self._blend_func = value + if len(value) == 2: + self._gl.blendFunc(*value) + elif len(value) == 4: + self._gl.blendFuncSeparate(*value) + else: + ValueError("blend_func takes a tuple of 2 or 4 values") + + @property + def front_face(self) -> str: + value = self._gl.getParameter(enums.FRONT_FACE) + return "cw" if value == enums.CW else "ccw" + + @front_face.setter + def front_face(self, value: str): + if value not in ["cw", "ccw"]: + raise ValueError("front_face must be 'cw' or 'ccw'") + self._gl.frontFace(enums.CW if value == "cw" else enums.CCW) + + @property + def cull_face(self) -> str: + value = self._gl.getParameter(enums.CULL_FACE_MODE) + return self._cull_face_options_reverse[value] + + @cull_face.setter + def cull_face(self, value): + if value not in self._cull_face_options: + raise ValueError("cull_face must be", list(self._cull_face_options.keys())) + + self._gl.cullFace(self._cull_face_options[value]) + + @Context.wireframe.setter + def wireframe(self, value: bool): + raise NotImplementedError("wireframe is not supported with WebGL") + + @property + def patch_vertices(self) -> int: + raise NotImplementedError("patch_vertices is not supported with WebGL") + + @patch_vertices.setter + def patch_vertices(self, value: int): + raise NotImplementedError("patch_vertices is not supported with WebGL") + + @Context.point_size.setter + def point_size(self, value: float): + raise NotImplementedError("point_size is not supported with WebGL") + + @Context.primitive_restart_index.setter + def primitive_restart_index(self, value: int): + raise NotImplementedError("primitive_restart_index is not supported with WebGL") + + def finish(self) -> None: + self._gl.finish() + + def flush(self) -> None: + self._gl.flush() + + def _create_default_framebuffer(self) -> WebGLDefaultFrameBuffer: + return WebGLDefaultFrameBuffer(self) + + def buffer( + self, *, data: BufferProtocol | None = None, reserve: int = 0, usage: str = "static" + ) -> WebGLBuffer: + return WebGLBuffer(self, data, reserve=reserve, usage=usage) + + def program( + self, + *, + vertex_shader: str, + fragment_shader: str | None = None, + geometry_shader: str | None = None, + tess_control_shader: str | None = None, + tess_evaluation_shader: str | None = None, + common: List[str] | None = None, + defines: Dict[str, str] | None = None, + varyings: Sequence[str] | None = None, + varyings_capture_mode: str = "interleaved", + ): + if geometry_shader is not None: + raise NotImplementedError("Geometry Shaders not supported with WebGL") + + if tess_control_shader is not None: + raise NotImplementedError("Tessellation Shaders not supported with WebGL") + + if tess_evaluation_shader is not None: + raise NotImplementedError("Tessellation Shaders not supported with WebGL") + + source_vs = ShaderSource(self, vertex_shader, common, enums.VERTEX_SHADER) + source_fs = ( + ShaderSource(self, fragment_shader, common, enums.FRAGMENT_SHADER) + if fragment_shader + else None + ) + + out_attributes = list(varyings) if varyings is not None else [] + if not source_fs and not out_attributes: + out_attributes = source_vs.out_attributes + + return WebGLProgram( + self, + vertex_shader=source_vs.get_source(defines=defines), + fragment_shader=source_fs.get_source(defines=defines) if source_fs else None, + geometry_shader=None, + tess_control_shader=None, + tess_evaluation_shader=None, + varyings=out_attributes, + varyings_capture_mode=varyings_capture_mode, + ) + + def geometry( + self, + content: Sequence[BufferDescription] | None = None, + index_buffer: WebGLBuffer | None = None, + mode: int | None = None, + index_element_size: int = 4, + ) -> WebGLGeometry: + return WebGLGeometry( + self, + content, + index_buffer=index_buffer, + mode=mode, + index_element_size=index_element_size, + ) + + def compute_shader(self, *, source: str, common: Iterable[str] = ()) -> None: + raise NotImplementedError("compute_shader is not supported with WebGL") + + def texture( + self, + size: Tuple[int, int], + *, + components: int = 4, + dtype: str = "f1", + data: BufferProtocol | None = None, + wrap_x: int | None = None, + wrap_y: int | None = None, + filter: Tuple[int, int] | None = None, + samples: int = 0, + immutable: bool = False, + internal_format: int | None = None, + compressed: bool = False, + compressed_data: bool = False, + ) -> WebGLTexture2D: + return WebGLTexture2D( + self, + size, + components=components, + data=data, + dtype=dtype, + wrap_x=wrap_x, + wrap_y=wrap_y, + filter=filter, + samples=samples, + immutable=immutable, + internal_format=internal_format, + compressed=compressed, + compressed_data=compressed_data, + ) + + def depth_texture( + self, size: Tuple[int, int], *, data: BufferProtocol | None = None + ) -> WebGLTexture2D: + return WebGLTexture2D(self, size, data=data, depth=True) + + def framebuffer( + self, + *, + color_attachments: WebGLTexture2D | List[WebGLTexture2D] | None = None, + depth_attachment: WebGLTexture2D | None = None, + ) -> WebGLFramebuffer: + return WebGLFramebuffer( + self, color_attachments=color_attachments or [], depth_attachment=depth_attachment + ) + + def copy_framebuffer( + self, + src: WebGLFramebuffer, + dst: WebGLFramebuffer, + src_attachment_index: int = 0, + depth: bool = True, + ): + self._gl.bindFramebuffer(enums.READ_FRAMEBUFFER, src.glo) + self._gl.bindFramebuffer(enums.DRAW_FRAMEBUFFER, dst.glo) + + self._gl.readBuffer(enums.COLOR_ATTACHMENT0 + src_attachment_index) + if dst.is_default: + self._gl.drawBuffers([enums.BACK]) + else: + self._gl.drawBuffers([enums.COLOR_ATTACHMENT0]) + + self._gl.blitFramebuffer( + 0, + 0, + src.width, + src.height, + 0, + 0, + src.width, + src.height, + enums.COLOR_BUFFER_BIT | enums.DEPTH_BUFFER_BIT, + enums.NEAREST, + ) + + self._gl.readBuffer(enums.COLOR_ATTACHMENT0) + + def sampler(self, texture: WebGLTexture2D): + return WebGLSampler(self, texture) + + def texture_array( + self, + size: Tuple[int, int, int], + *, + components: int = 4, + dtype: str = "f1", + data: BufferProtocol | None = None, + wrap_x: int | None = None, + wrap_y: int | None = None, + filter: Tuple[int, int] | None = None, + ) -> WebGLTextureArray: + return WebGLTextureArray( + self, + size, + components=components, + dtype=dtype, + data=data, + wrap_x=wrap_x, + wrap_y=wrap_y, + filter=filter, + ) + + def query(self, *, samples=True, time=False, primitives=True): + return WebGLQuery(self, samples=samples, time=time, primitives=primitives) + + +class WebGLArcadeContext(ArcadeContext, WebGLContext): + def __init__(self, *args, **kwargs): + WebGLContext.__init__(self, *args, **kwargs) + ArcadeContext.__init__(self, *args, **kwargs) + + def bind_window_block(self): + self._gl.bindBufferRange( + enums.UNIFORM_BUFFER, + 0, + self._window_block.buffer.id, + 0, + 128, + ) + + +class WebGLInfo(Info): + def __init__(self, ctx: WebGLContext): + super().__init__(ctx) + self._ctx = ctx + + def get_int_tuple(self, enum, length: int): + # TODO: this might not work + values = self._ctx._gl.getParameter(enum) + return tuple(values) + + def get(self, enum, default=0): + value = self._ctx._gl.getParameter(enum) + return value or default + + def get_float(self, enum, default=0.0): + return self.get(enum, default) # type: ignore + + def get_str(self, enum): + return self.get(enum) diff --git a/arcade/gl/backends/webgl/framebuffer.py b/arcade/gl/backends/webgl/framebuffer.py new file mode 100644 index 0000000000..a5bf4a18cf --- /dev/null +++ b/arcade/gl/backends/webgl/framebuffer.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +import js # type: ignore + +from arcade.gl import enums +from arcade.gl.framebuffer import DefaultFrameBuffer, Framebuffer +from arcade.gl.types import pixel_formats +from arcade.types import RGBOrA255, RGBOrANormalized + +from .texture import WebGLTexture2D + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLFramebuffer as JSWebGLFramebuffer + + from arcade.gl.backends.webgl.context import WebGLContext + + +class WebGLFramebuffer(Framebuffer): + __slots__ = "_glo" + + def __init__( + self, + ctx: WebGLContext, + *, + color_attachments: WebGLTexture2D | list[WebGLTexture2D], + depth_attachment: WebGLTexture2D | None = None, + ): + super().__init__( + ctx, + color_attachments=color_attachments, + depth_attachment=depth_attachment, # type: ignore + ) + self._ctx = ctx + + self._glo = self._ctx._gl.createFramebuffer() + self._ctx._gl.bindFramebuffer(enums.FRAMEBUFFER, self._glo) + + for i, tex in enumerate(self._color_attachments): + self._ctx._gl.framebufferTexture2D( + enums.FRAMEBUFFER, + enums.COLOR_ATTACHMENT0 + i, + tex._target, # type: ignore + tex.glo, # type: ignore + 0, + ) + + if self.depth_attachment: + self._ctx._gl.framebufferTexture2D( + enums.FRAMEBUFFER, + enums.DEPTH_ATTACHMENT, + self.depth_attachment._target, # type: ignore + self.depth_attachment.glo, # type: ignore + 0, + ) + + self._check_completeness(ctx) + + self._draw_buffers = [ + enums.COLOR_ATTACHMENT0 + i for i, _ in enumerate(self._color_attachments) + ] + + # Restore the original framebuffer to avoid confusion + self._ctx.active_framebuffer.use(force=True) + + if self._ctx.gc_mode == "auto" and not self.is_default: + weakref.finalize(self, WebGLFramebuffer.delete_glo, ctx, self._glo) + + def __del__(self): + if self._ctx.gc_mode == "context_gc" and not self.is_default and self._glo is not None: + self._ctx.objects.append(self) + + @property + def glo(self) -> JSWebGLFramebuffer | None: + return self._glo + + @Framebuffer.viewport.setter + def viewport(self, value: tuple[int, int, int, int]): + if not isinstance(value, tuple) or len(value) != 4: + raise ValueError("viewport should be a 4-component tuple") + + self._viewport = value + + # If the framebuffer is active we need to set the viewport now + # Otherwise it will be set when it is activated + if self._ctx.active_framebuffer == self: + self._ctx._gl.viewport(*self._viewport) + if self._scissor is None: + self._ctx._gl.scissor(*self._viewport) + else: + self._ctx._gl.scissor(*self._scissor) + + @Framebuffer.scissor.setter + def scissor(self, value): + self._scissor = value + + if self._scissor is None: + if self._ctx.active_framebuffer == self: + self._ctx._gl.scissor(*self._viewport) + else: + if self._ctx.active_framebuffer == self: + self._ctx._gl.scissor(*self._scissor) + + @Framebuffer.depth_mask.setter + def depth_mask(self, value: bool): + self._depth_mask = value + if self._ctx.active_framebuffer == self: + self._ctx._gl.depthMask(self._depth_mask) + + def _use(self, *, force: bool = False): + if self._ctx.active_framebuffer == self and not force: + return + + self._ctx._gl.bindFramebuffer(enums.FRAMEBUFFER, self._glo) + + if self._draw_buffers: + self._ctx._gl.drawBuffers(self._draw_buffers) + + self._ctx._gl.depthMask(self._depth_mask) + self._ctx._gl.viewport(*self._viewport) + if self._scissor is not None: + self._ctx._gl.scissor(*self._scissor) + else: + self._ctx._gl.scissor(*self._viewport) + + def clear( + self, + *, + color: RGBOrA255 | None = None, + color_normalized: RGBOrANormalized | None = None, + depth: float = 1.0, + viewport: tuple[int, int, int, int] | None = None, + ): + with self.activate(): + scissor_values = self._scissor + + if viewport: + self.scissor = viewport + else: + self.scissor = None + + clear_color = 0.0, 0.0, 0.0, 0.0 + if color is not None: + if len(color) == 3: + clear_color = color[0] / 255, color[1] / 255, color[2] / 255, 1.0 + elif len(color) == 4: + clear_color = color[0] / 255, color[1] / 255, color[2] / 255, color[3] / 255 + else: + raise ValueError("Color should be a 3 or 4 component tuple") + elif color_normalized is not None: + if len(color_normalized) == 3: + clear_color = color_normalized[0], color_normalized[1], color_normalized[2], 1.0 + elif len(color_normalized) == 4: + clear_color = color_normalized + else: + raise ValueError("Color should be a 3 or 4 component tuple") + + self._ctx._gl.clearColor(*clear_color) + + if self.depth_attachment: + self._ctx._gl.clearDepth(depth) + self._ctx._gl.clear(enums.COLOR_BUFFER_BIT | enums.DEPTH_BUFFER_BIT) + else: + self._ctx._gl.clear(enums.COLOR_BUFFER_BIT) + + self.scissor = scissor_values + + def read(self, *, viewport=None, components=3, attachment=0, dtype="f1") -> bytes: + try: + frmt = pixel_formats[dtype] + base_format = frmt[0][components] + pixel_type = frmt[2] + component_size = frmt[3] + except Exception: + raise ValueError(f"Invalid dtype '{dtype}'") + + with self.activate(): + if not self.is_default: + self._ctx._gl.readBuffer(enums.COLOR_ATTACHMENT0 + attachment) + + self._ctx._gl.pixelStorei(enums.PACK_ALIGNMENT, 1) + self._ctx._gl.pixelStorei(enums.UNPACK_ALIGNMENT, 1) + + if viewport: + x, y, width, height = viewport + else: + x, y, width, height = 0, 0, *self.size + + array_size = components * component_size * width * height + if pixel_type == enums.UNSIGNED_BYTE: + js_array_buffer = js.Uint8Array(array_size) + elif pixel_type == enums.UNSIGNED_SHORT: + js_array_buffer = js.Uint16Array(array_size) + elif pixel_type == enums.FLOAT: + js_array_buffer = js.Float32Array(array_size) + else: + raise ValueError(f"Unsupported pixel type {pixel_type} in framebuffer.read") + self._ctx._gl.readPixels(x, y, width, height, base_format, pixel_type, js_array_buffer) + + if not self.is_default: + self._ctx._gl.readBuffer(enums.COLOR_ATTACHMENT0) + + # TODO: Is this right or does this need something more for conversion to bytes? + return js_array_buffer + + def delete(self): + WebGLFramebuffer.delete_glo(self._ctx, self._glo) + self._glo = None + + @staticmethod + def delete_glo(ctx: WebGLContext, glo: JSWebGLFramebuffer | None): + if glo is not None: + ctx._gl.deleteFramebuffer(glo) + + ctx.stats.decr("framebuffer") + + @staticmethod + def _check_completeness(ctx: WebGLContext) -> None: + # See completeness rules : https://www.khronos.org/opengl/wiki/Framebuffer_Object + states = { + enums.FRAMEBUFFER_UNSUPPORTED: "Framebuffer unsupported. Try another format.", + enums.FRAMEBUFFER_INCOMPLETE_ATTACHMENT: "Framebuffer incomplete attachment.", + enums.FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT: "Framebuffer missing attachment.", + enums.FRAMEBUFFER_INCOMPLETE_DIMENSIONS: "Framebuffer unsupported dimension.", + enums.FRAMEBUFFER_INCOMPLETE_MULTISAMPLE: "Framebuffer unsupported multisample.", + enums.FRAMEBUFFER_COMPLETE: "Framebuffer is complete.", + } + + status = ctx._gl.checkFramebufferStatus(enums.FRAMEBUFFER) + if status != enums.FRAMEBUFFER_COMPLETE: + raise ValueError( + "Framebuffer is incomplete. {}".format(states.get(status, "Unknown error")) + ) + + def __repr__(self): + return "".format(self._glo) + + +class WebGLDefaultFrameBuffer(DefaultFrameBuffer, WebGLFramebuffer): # type: ignore + is_default = True + + def __init__(self, ctx: WebGLContext): + super().__init__(ctx) + self._ctx = ctx + + x, y, width, height = self._ctx._gl.getParameter(enums.SCISSOR_BOX) + + self._viewport = x, y, width, height + self._scissor = None + self._width = width + self._height = height + + self._glo = None + + @DefaultFrameBuffer.viewport.setter + def viewport(self, value: tuple[int, int, int, int]): + # This is the exact same as the WebGLFramebuffer setter + # WebGL backend doesn't need to handle pixel scaling for the + # default framebuffer like desktop does, the browser does that + # for us. However we need a separate implementation for the + # function because of ABC + if not isinstance(value, tuple) or len(value) != 4: + raise ValueError("viewport shouldbe a 4-component tuple") + + ratio = self.ctx.window.get_pixel_ratio() + self._viewport = ( + int(value[0] * ratio), + int(value[1] * ratio), + int(value[2] * ratio), + int(value[3] * ratio), + ) + + if self._ctx.active_framebuffer == self: + self._ctx._gl.viewport(*self._viewport) + if self._scissor is None: + self._ctx._gl.scissor(*self._viewport) + else: + self._ctx._gl.scissor(*self._scissor) + + @DefaultFrameBuffer.scissor.setter + def scissor(self, value): + # This is the exact same as the WebGLFramebuffer setter + # WebGL backend doesn't need to handle pixel scaling for the + # default framebuffer like desktop does, the browser does that + # for us. However we need a separate implementation for the + # function because of ABC + if value is None: + self._scissor = None + if self._ctx.active_framebuffer == self: + self._ctx._gl.scissor(*self._viewport) + else: + ratio = self.ctx.window.get_pixel_ratio() + self._scissor = ( + int(value[0] * ratio), + int(value[1] * ratio), + int(value[2] * ratio), + int(value[3] * ratio), + ) + + if self._ctx.active_framebuffer == self: + self._ctx._gl.scissor(*self._scissor) diff --git a/arcade/gl/backends/webgl/glsl.py b/arcade/gl/backends/webgl/glsl.py new file mode 100644 index 0000000000..b6c43de2f5 --- /dev/null +++ b/arcade/gl/backends/webgl/glsl.py @@ -0,0 +1,162 @@ +import re +from typing import TYPE_CHECKING, Iterable + +if TYPE_CHECKING: + from .context import Context as ArcadeGlContext + +from arcade.gl import enums +from arcade.gl.exceptions import ShaderException +from arcade.gl.types import SHADER_TYPE_NAMES + + +class ShaderSource: + """ + GLSL source container for making source parsing simpler. + + We support locating out attributes, applying ``#defines`` values + and injecting common source. + + .. note::: + We do assume the source is neat enough to be parsed + this way and don't contain several statements on one line. + + Args: + ctx: + The context this framebuffer belongs to + source: + The GLSL source code + common: + Common source code to inject + source_type: + The shader type + """ + + def __init__( + self, + ctx: "ArcadeGlContext", + source: str, + common: Iterable[str] | None, + source_type: int, + ): + self._source = source.strip() + self._type = source_type + self._lines = self._source.split("\n") if source else [] + self._out_attributes: list[str] = [] + + if not self._lines: + raise ValueError("Shader source is empty") + + self._version = self._find_glsl_version() + + self._lines[0] = "#version 300 es" + self._lines.insert(1, "precision mediump float;") + + # TODO: Does this also need done for GLES and we just haven't encountered the problem yet? + self._lines.insert(1, "precision mediump isampler2D;") + + self._version = self._find_glsl_version() + + # Inject common source + self.inject_common_sources(common) + + if self._type in [enums.VERTEX_SHADER, enums.GEOMETRY_SHADER]: + self._parse_out_attributes() + + @property + def version(self) -> int: + """The glsl version""" + return self._version + + @property + def out_attributes(self) -> list[str]: + """The out attributes for this program""" + return self._out_attributes + + def inject_common_sources(self, common: Iterable[str] | None) -> None: + """ + Inject common source code into the shader source. + + Args: + common: + A list of common source code strings to inject + """ + if not common: + return + + # Find the main function + for line_number, line in enumerate(self._lines): + if "main()" in line: + break + else: + raise ShaderException("No main() function found when injecting common source") + + # Insert all common sources + for source in common: + lines = source.split("\n") + self._lines = self._lines[:line_number] + lines + self._lines[line_number:] + + def get_source(self, *, defines: dict[str, str] | None = None) -> str: + """Return the shader source + + Args: + defines: Defines to replace in the source. + """ + if not defines: + return "\n".join(self._lines) + + lines = ShaderSource.apply_defines(self._lines, defines) + return "\n".join(lines) + + def _find_glsl_version(self) -> int: + if self._lines[0].strip().startswith("#version"): + try: + return int(self._lines[0].split()[1]) + except Exception: + pass + + source = "\n".join(f"{str(i + 1).zfill(3)}: {line} " for i, line in enumerate(self._lines)) + + raise ShaderException( + ( + "Cannot find #version in shader source. " + "Please provide at least a #version 330 statement in the beginning of the shader.\n" + f"---- [{SHADER_TYPE_NAMES[self._type]}] ---\n" + f"{source}" + ) + ) + + @staticmethod + def apply_defines(lines: list[str], defines: dict[str, str]) -> list[str]: + """Locate and apply #define values + + Args: + lines: + List of source lines + defines: + dict with ``name: value`` pairs. + """ + for nr, line in enumerate(lines): + line = line.strip() + if line.startswith("#define"): + try: + name = line.split()[1] + value = defines.get(name, None) + if value is None: + continue + + lines[nr] = "#define {} {}".format(name, str(value)) + except IndexError: + pass + + return lines + + def _parse_out_attributes(self): + """ + Locates out attributes so we don't have to manually supply them. + + Note that this currently doesn't work for structs. + """ + for line in self._lines: + res = re.match(r"(layout(.+)\))?(\s+)?(out)(\s+)(\w+)(\s+)(\w+)", line.strip()) + if res: + self._out_attributes.append(res.groups()[-1]) diff --git a/arcade/gl/backends/webgl/program.py b/arcade/gl/backends/webgl/program.py new file mode 100644 index 0000000000..14f0c3754b --- /dev/null +++ b/arcade/gl/backends/webgl/program.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING, Any, Iterable, cast + +from arcade.gl import enums +from arcade.gl.exceptions import ShaderException +from arcade.gl.program import Program +from arcade.gl.types import SHADER_TYPE_NAMES, AttribFormat, GLTypes + +from .uniform import Uniform, UniformBlock + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLProgram as JSWebGLProgram + + from arcade.gl.backends.webgl.context import WebGLContext + + +class WebGLProgram(Program): + _valid_capture_modes = ("interleaved", "separate") + + def __init__( + self, + ctx: WebGLContext, + *, + vertex_shader: str, + fragment_shader: str | None = None, + geometry_shader: str | None = None, + tess_control_shader: str | None = None, + tess_evaluation_shader: str | None = None, + varyings: list[str] | None = None, + varyings_capture_mode: str = "interleaved", + ): + if geometry_shader is not None: + raise NotImplementedError("Geometry Shaders not supported with WebGL") + + if tess_control_shader is not None: + raise NotImplementedError("Tessellation Shaders not supported with WebGL") + + if tess_evaluation_shader is not None: + raise NotImplementedError("Tessellation Shaders not supported with WebGL") + + super().__init__(ctx) + self._ctx = ctx + + glo = self._ctx._gl.createProgram() + assert glo is not None, "Failed to create GL program" + self._glo: JSWebGLProgram = glo + + self._varyings = varyings or [] + self._varyings_capture_mode = varyings_capture_mode.strip().lower() + self._geometry_info = (0, 0, 0) + self._attributes = [] + self._uniforms: dict[str, Uniform | UniformBlock] = {} + + if self._varyings_capture_mode not in self._valid_capture_modes: + raise ValueError( + f"Invalid Capture Mode: {self._varyings_capture_mode}. " + f"Valid Modes are: {self._valid_capture_modes}." + ) + + shaders: list[tuple[str, int]] = [(vertex_shader, enums.VERTEX_SHADER)] + if fragment_shader: + shaders.append((fragment_shader, enums.FRAGMENT_SHADER)) + + # TODO: Do we need to inject a dummy fragment shader for transforms like OpenGL ES? + + compiled_shaders = [] + for shader_code, shader_type in shaders: + shader = WebGLProgram.compile_shader(self._ctx, shader_code, shader_type) + self._ctx._gl.attachShader(self._glo, shader) + compiled_shaders.append(shader) + + if not fragment_shader: + self._configure_varyings() + + WebGLProgram.link(self._ctx, self._glo) + + for shader in compiled_shaders: + self._ctx._gl.deleteShader(shader) + self._ctx._gl.detachShader(self._glo, shader) + + self._introspect_attributes() + self._introspect_uniforms() + self._introspect_uniform_blocks() + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, WebGLProgram.delete_glo, self._ctx, self._glo) + + def __del__(self): + if self._ctx.gc_mode == "context_gc" and self._glo is not None: + self._ctx.objects.append(self) + + @property + def ctx(self) -> WebGLContext: + """The context this program belongs to.""" + return self._ctx + + @property + def glo(self) -> JSWebGLProgram | None: + """The OpenGL resource id for this program.""" + return self._glo + + @property + def attributes(self) -> Iterable[AttribFormat]: + """List of attribute information.""" + return self._attributes + + @property + def varyings(self) -> list[str]: + """Out attributes names used in transform feedback.""" + return self._varyings + + @property + def out_attributes(self) -> list[str]: + """ + Out attributes names used in transform feedback. + + Alias for `varyings`. + """ + return self._varyings + + @property + def varyings_capture_mode(self) -> str: + """ + Get the capture more for transform feedback (single, multiple). + + This is a read only property since capture mode + can only be set before the program is linked. + """ + return self._varyings_capture_mode + + @property + def geometry_input(self) -> int: + """ + The geometry shader's input primitive type. + + This an be compared with ``GL_TRIANGLES``, ``GL_POINTS`` etc. + and is queried when the program is created. + """ + raise NotImplementedError("Geometry Shaders not supported with WebGL") + + @property + def geometry_output(self) -> int: + """The geometry shader's output primitive type. + + This an be compared with ``GL_TRIANGLES``, ``GL_POINTS`` etc. + and is queried when the program is created. + """ + raise NotImplementedError("Geometry Shaders not supported with WebGL") + + @property + def geometry_vertices(self) -> int: + """ + The maximum number of vertices that can be emitted. + This is queried when the program is created. + """ + raise NotImplementedError("Geometry Shaders not supported with WebGL") + + def delete(self): + """ + Destroy the underlying OpenGL resource. + + Don't use this unless you know exactly what you are doing. + """ + WebGLProgram.delete_glo(self._ctx, self._glo) + self._glo = None # type: ignore + + @staticmethod + def delete_glo(ctx: WebGLContext, program: JSWebGLProgram | None): + ctx._gl.deleteProgram(program) + ctx.stats.decr("program") + + def __getitem__(self, item) -> Uniform | UniformBlock: + """Get a uniform or uniform block""" + try: + uniform = self._uniforms[item] + except KeyError: + raise KeyError(f"Uniform with the name `{item}` was not found.") + + return uniform.getter() + + def __setitem__(self, key, value): + """ + Set a uniform value. + + Example:: + + program['color'] = 1.0, 1.0, 1.0, 1.0 + program['mvp'] = projection @ view @ model + + Args: + key: + The uniform name + value: + The uniform value + """ + try: + uniform = self._uniforms[key] + except KeyError: + raise KeyError(f"Uniform with the name `{key}` was not found.") + + uniform.setter(value) + + def set_uniform_safe(self, name: str, value: Any): + """ + Safely set a uniform catching KeyError. + + Args: + name: + The uniform name + value: + The uniform value + """ + try: + self[name] = value + except KeyError: + pass + + def set_uniform_array_safe(self, name: str, value: list[Any]): + """ + Safely set a uniform array. + + Arrays can be shortened by the glsl compiler not all elements are determined + to be in use. This function checks the length of the actual array and sets a + subset of the values if needed. If the uniform don't exist no action will be + done. + + Args: + name: + Name of uniform + value: + List of values + """ + if name not in self._uniforms: + return + + uniform = cast(Uniform, self._uniforms[name]) + _len = uniform._array_length * uniform._components + if _len == 1: + self.set_uniform_safe(name, value[0]) + else: + self.set_uniform_safe(name, value[:_len]) + + def use(self): + self._ctx._gl.useProgram(self._glo) + + def _configure_varyings(self): + if not self._varyings: + return + + mode = ( + enums.INTERLEAVED_ATTRIBS + if self._varyings_capture_mode == "interleaved" + else enums.SEPARATE_ATTRIBS + ) + + self._ctx._gl.transformFeedbackVaryings( + self._glo, # type: ignore this is guaranteed to not be None at this point + self._varyings, + mode, + ) + + def _introspect_attributes(self): + num_attrs = self._ctx._gl.getProgramParameter(self._glo, enums.ACTIVE_ATTRIBUTES) + + # TODO: Do we need to instrospect the varyings? The OpenGL backend doesn't + # num_varyings = self._ctx._gl.getProgramParameter( + # self._glo, + # enums.TRANSFORM_FEEDBACK_VARYINGS + # ) + + for i in range(num_attrs): + info = self._ctx._gl.getActiveAttrib(self._glo, i) + location = self._ctx._gl.getAttribLocation(self._glo, info.name) + type_info = GLTypes.get(info.type) + self._attributes.append( + AttribFormat( + info.name, + type_info.gl_type, + type_info.components, + type_info.gl_size, + location=location, + ) + ) + + self.attribute_key = ":".join( + f"{attr.name}[{attr.gl_type}/{attr.components}]" for attr in self._attributes + ) + + def _introspect_uniforms(self): + active_uniforms = self._ctx._gl.getProgramParameter(self._glo, enums.ACTIVE_UNIFORMS) + + for i in range(active_uniforms): + name, type, size = self._query_uniform(i) + location = self._ctx._gl.getUniformLocation(self._glo, name) + + if location == -1: + continue + + name = name.replace("[0]", "") + self._uniforms[name] = Uniform(self._ctx, self._glo, location, name, type, size) + + def _introspect_uniform_blocks(self): + active_uniform_blocks = self._ctx._gl.getProgramParameter( + self._glo, enums.ACTIVE_UNIFORM_BLOCKS + ) + + for location in range(active_uniform_blocks): + index, size, name = self._query_uniform_block(location) + block = UniformBlock(self._ctx, self._glo, index, size, name) + self._uniforms[name] = block + + def _query_uniform(self, location: int) -> tuple[str, int, int]: + info = self._ctx._gl.getActiveUniform(self._glo, location) + return info.name, info.type, info.size + + def _query_uniform_block(self, location: int) -> tuple[int, int, str]: + name = self._ctx._gl.getActiveUniformBlockName(self._glo, location) + index = self._ctx._gl.getActiveUniformBlockParameter( + self._glo, location, enums.UNIFORM_BLOCK_BINDING + ) + size = self._ctx._gl.getActiveUniformBlockParameter( + self._glo, location, enums.UNIFORM_BLOCK_DATA_SIZE + ) + return index, size, name + + @staticmethod + def compile_shader(ctx: WebGLContext, source: str, shader_type: int): + shader = ctx._gl.createShader(shader_type) + assert shader is not None, "Failed to WebGL Shader Object" + ctx._gl.shaderSource(shader, source) + ctx._gl.compileShader(shader) + compile_result = ctx._gl.getShaderParameter(shader, enums.COMPILE_STATUS) + if not compile_result: + msg = ctx._gl.getShaderInfoLog(shader) + raise ShaderException( + ( + f"Error compiling {SHADER_TYPE_NAMES[shader_type]} " + f"{compile_result}): {msg}\n" + f"---- [{SHADER_TYPE_NAMES[shader_type]}] ---\n" + ) + + "\n".join( + f"{str(i + 1).zfill(3)}: {line} " for i, line in enumerate(source.split("\n")) + ) + ) + return shader + + @staticmethod + def link(ctx: WebGLContext, glo: JSWebGLProgram): + ctx._gl.linkProgram(glo) + status = ctx._gl.getProgramParameter(glo, enums.LINK_STATUS) + if not status: + log = ctx._gl.getProgramInfoLog(glo) + raise ShaderException("Program link error: {}".format(log)) + + def __repr__(self): + return "".format(self._glo) diff --git a/arcade/gl/backends/webgl/provider.py b/arcade/gl/backends/webgl/provider.py new file mode 100644 index 0000000000..59c3864899 --- /dev/null +++ b/arcade/gl/backends/webgl/provider.py @@ -0,0 +1,14 @@ +from arcade.gl.provider import BaseProvider + +from .context import WebGLArcadeContext, WebGLContext, WebGLInfo + + +class Provider(BaseProvider): + def create_context(self, *args, **kwargs): + return WebGLContext(*args, **kwargs) + + def create_info(self, ctx): + return WebGLInfo(ctx) # type: ignore + + def create_arcade_context(self, *args, **kwargs): + return WebGLArcadeContext(*args, **kwargs) diff --git a/arcade/gl/backends/webgl/query.py b/arcade/gl/backends/webgl/query.py new file mode 100644 index 0000000000..3da0cab9f9 --- /dev/null +++ b/arcade/gl/backends/webgl/query.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +from arcade.gl import enums +from arcade.gl.query import Query + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLQuery as JSWebGLQuery + + from arcade.gl.backends.webgl.context import WebGLContext + + +class WebGLQuery(Query): + __slots__ = ( + "_glo_samples_passed", + "_glo_time_elapsed", + "_glo_primitives_generated", + ) + + def __init__(self, ctx: WebGLContext, samples=True, time=False, primitives=True): + super().__init__(ctx, samples, time, primitives) + self._ctx = ctx + + if time: + raise NotImplementedError("Time queries are not supported with WebGL") + + glos = [] + + self._glo_samples_passed = None + if self._samples_enabled: + self._glo_samples_passed = self._ctx._gl.createQuery() + glos.append(self._glo_samples_passed) + + self._glo_primitives_generated = None + if self._primitives_enabled: + self._glo_primitives_generated = self._ctx._gl.createQuery() + glos.append(self._glo_primitives_generated) + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, WebGLQuery.delete_glo, self._ctx, glos) + + def __enter__(self): + if self._samples_enabled: + self._ctx._gl.beginQuery(enums.ANY_SAMPLES_PASSED, self._glo_samples_passed) # type: ignore + if self._primitives_enabled: + self._ctx._gl.beginQuery( + enums.TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN, + self._glo_primitives_generated, # type: ignore + ) + + def __exit__(self): + if self._samples_enabled: + self._ctx._gl.endQuery(enums.ANY_SAMPLES_PASSED) + self._samples = self._ctx._gl.getQueryParameter( + self._glo_samples_passed, # type: ignore + enums.QUERY_RESULT, + ) + if self._primitives_enabled: + self._ctx._gl.endQuery(enums.TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN) + self._primitives = self._ctx._gl.getQueryParameter( + self._glo_primitives_generated, # type: ignore + enums.QUERY_RESULT, + ) + + def delete(self): + WebGLQuery.delete_glo(self._ctx, [self._glo_samples_passed, self._glo_primitives_generated]) + + @staticmethod + def delete_glo(ctx: WebGLContext, glos: list[JSWebGLQuery | None]): + for glo in glos: + ctx._gl.deleteQuery(glo) + + ctx.stats.decr("query") diff --git a/arcade/gl/backends/webgl/sampler.py b/arcade/gl/backends/webgl/sampler.py new file mode 100644 index 0000000000..4f9ad1f8ff --- /dev/null +++ b/arcade/gl/backends/webgl/sampler.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +from arcade.gl import enums +from arcade.gl.sampler import Sampler +from arcade.gl.types import compare_funcs + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLSampler as JSWebGLSampler + + from arcade.gl.backends.webgl.context import WebGLContext + from arcade.gl.backends.webgl.texture import WebGLTexture2D + + +class WebGLSampler(Sampler): + def __init__( + self, + ctx: WebGLContext, + texture: WebGLTexture2D, + *, + filter: tuple[int, int] | None = None, + wrap_x: int | None = None, + wrap_y: int | None = None, + ): + super().__init__(ctx, texture, filter=filter, wrap_x=wrap_x, wrap_y=wrap_y) + self._ctx = ctx + + self._glo = self._ctx._gl.createSampler() + + if "f" in self.texture._dtype: + self._filter = enums.LINEAR, enums.LINEAR + else: + self._filter = enums.NEAREST, enums.NEAREST + + self._wrap_x = enums.REPEAT + self._wrap_y = enums.REPEAT + + if self.texture._samples == 0: + self.filter = filter or self._filter + self.wrap_x = wrap_x or self._wrap_x + self.wrap_y = wrap_y or self._wrap_y + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, WebGLSampler.delete_glo, self._ctx, self._glo) + + @property + def glo(self) -> JSWebGLSampler | None: + return self._glo + + def use(self, unit: int): + self._ctx._gl.bindSampler(unit, self._glo) + + def clear(self, unit: int): + self._ctx._gl.bindSampler(unit, None) + + @Sampler.filter.setter + def filter(self, value: tuple[int, int]): + if not isinstance(value, tuple) or not len(value) == 2: + raise ValueError("Texture filter must be a 2 component tuple (min, mag)") + + self._filter = value + self._ctx._gl.samplerParameteri( + self._glo, # type: ignore + enums.TEXTURE_MIN_FILTER, + self._filter[0], + ) + self._ctx._gl.samplerParameterf( + self._glo, # type: ignore + enums.TEXTURE_MAG_FILTER, + self._filter[1], + ) + + @Sampler.wrap_x.setter + def wrap_x(self, value: int): + self._wrap_x = value + self._ctx._gl.samplerParameteri( + self._glo, # type: ignore + enums.TEXTURE_WRAP_S, + value, + ) + + @Sampler.wrap_y.setter + def wrap_y(self, value: int): + self._wrap_y = value + self._ctx._gl.samplerParameteri( + self._glo, # type: ignore + enums.TEXTURE_WRAP_T, + value, + ) + + @Sampler.anisotropy.setter + def anisotropy(self, value): + self._anistropy = max(1.0, min(value, self._ctx.info.MAX_TEXTURE_MAX_ANISOTROPY)) + self._ctx._gl.samplerParameterf( + self._glo, # type: ignore + enums.TEXTURE_MAX_ANISOTROPY_EXT, + self._anisotropy, + ) + + @Sampler.compare_func.setter + def compare_func(self, value: str | None): + if not self.texture._depth: + raise ValueError("Depth comparison function can only be set on depth textures") + + if not isinstance(value, str) and value is not None: + raise ValueError(f"value must be as string: {compare_funcs.keys()}") + + func = compare_funcs.get(value, None) + if func is None: + raise ValueError(f"value must be as string: {compare_funcs.keys()}") + + self._compare_func = value + if value is None: + self._ctx._gl.samplerParameteri( + self._glo, # type: ignore + enums.TEXTURE_COMPARE_MODE, + enums.NONE, + ) + else: + self._ctx._gl.samplerParameteri( + self._glo, # type: ignore + enums.TEXTURE_COMPARE_MODE, + enums.COMPARE_REF_TO_TEXTURE, + ) + self._ctx._gl.samplerParameteri( + self._glo, # type: ignore + enums.TEXTURE_COMPARE_FUNC, + func, + ) + + @staticmethod + def delete_glo(ctx: WebGLContext, glo: JSWebGLSampler | None) -> None: + ctx._gl.deleteSampler(glo) diff --git a/arcade/gl/backends/webgl/texture.py b/arcade/gl/backends/webgl/texture.py new file mode 100644 index 0000000000..82080b3474 --- /dev/null +++ b/arcade/gl/backends/webgl/texture.py @@ -0,0 +1,382 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING, Optional + +from pyodide.ffi import to_js + +from arcade.gl import enums +from arcade.gl.texture import Texture2D +from arcade.gl.types import BufferOrBufferProtocol, compare_funcs, pixel_formats +from arcade.types import BufferProtocol + +from .buffer import Buffer +from .utils import data_to_memoryview + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLTexture + + from arcade.gl.backends.webgl.context import WebGLContext + + +class WebGLTexture2D(Texture2D): + __slots__ = ( + "_glo", + "_target", + ) + + def __init__( + self, + ctx: WebGLContext, + size: tuple[int, int], + *, + components: int = 4, + dtype: str = "f1", + data: BufferProtocol | None = None, + filter: tuple[int, int] | None = None, + wrap_x: int | None = None, + wrap_y: int | None = None, + depth=False, + samples: int = 0, + immutable: bool = False, + internal_format: int | None = None, + compressed: bool = False, + compressed_data: bool = False, + ): + if samples > 0: + raise NotImplementedError("Multisample Textures are unsupported with WebGL") + + super().__init__( + ctx, + size, + components=components, + dtype=dtype, + data=data, + filter=filter, + wrap_x=wrap_x, + wrap_y=wrap_y, + depth=depth, + samples=samples, + immutable=immutable, + internal_format=internal_format, + compressed=compressed, + compressed_data=compressed_data, + ) + self._ctx = ctx + + if "f" in self._dtype: + self._filter = enums.LINEAR, enums.LINEAR + else: + self._filter = enums.NEAREST, enums.NEAREST + + self._target = enums.TEXTURE_2D + + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._glo = self._ctx._gl.createTexture() + if self._glo is None: + raise RuntimeError("Cannot create Texture. WebGL failed to generate a texture") + + self._ctx._gl.bindTexture(self._target, self._glo) + self._texture_2d(data) + + self.filter = filter = self._filter + self.wrap_x = wrap_x or self._wrap_x + self.wrap_y = wrap_y or self._wrap_y + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, WebGLTexture2D.delete_glo, self._ctx, self._glo) + + def resize(self, size: tuple[int, int]): + if self._immutable: + raise ValueError("Immutable textures cannot be resized") + + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + + self._width, self._height = size + + self._texture_2d(None) + + def __del__(self): + if self._ctx.gc_mode == "context_gc" and self._glo is not None: + self._ctx.objects.append(self) + + def _texture_2d(self, data): + try: + format_info = pixel_formats[self._dtype] + except KeyError: + raise ValueError( + f"dtype '{self._dtype}' not supported. Supported types are: " + f"{tuple(pixel_formats.keys())}" + ) + _format, _internal_format, self._type, self._component_size = format_info + if data is not None: + byte_length, data = data_to_memoryview(data) + self._validate_data_size(data, byte_length, self._width, self._height) + + self._ctx._gl.pixelStorei(enums.UNPACK_ALIGNMENT, self._alignment) + self._ctx._gl.pixelStorei(enums.PACK_ALIGNMENT, self._alignment) + + if self._depth: + self._ctx._gl.texImage2D( + self._target, + 0, + enums.DEPTH_COMPONENT24, + self._width, + self._height, + 0, + enums.DEPTH_COMPONENT, # type: ignore python doesn't have arg based function signatures + enums.UNSIGNED_INT, + data, + ) + self.compare_func = "<=" + else: + self._format = _format[self._components] + if self._internal_format is None: + self._internal_format = _internal_format[self._components] + + if self._immutable: + self._ctx._gl.texStorage2D( + self._target, + 1, + self._internal_format, + self._width, + self._height, + ) + if data: + self.write(data) + else: + if self._compressed_data is True: + self._ctx._gl.compressedTexImage2D( + self._target, 0, self._internal_format, self._width, self._height, 0, data + ) + else: + self._ctx._gl.texImage2D( + self._target, + 0, + self._internal_format, + self._width, + self._height, + 0, + self._format, # type: ignore + self._type, + data, + ) + + @property + def ctx(self) -> WebGLContext: + return self._ctx + + @property + def glo(self) -> Optional[WebGLTexture]: + return self._glo + + @property + def compressed(self) -> bool: + return self._compressed + + @property + def width(self) -> int: + """The width of the texture in pixels""" + return self._width + + @property + def height(self) -> int: + """The height of the texture in pixels""" + return self._height + + @property + def dtype(self) -> str: + """The data type of each component""" + return self._dtype + + @property + def size(self) -> tuple[int, int]: + """The size of the texture as a tuple""" + return self._width, self._height + + @property + def samples(self) -> int: + """Number of samples if multisampling is enabled (read only)""" + return self._samples + + @property + def byte_size(self) -> int: + """The byte size of the texture.""" + return pixel_formats[self._dtype][3] * self._components * self.width * self.height + + @property + def components(self) -> int: + """Number of components in the texture""" + return self._components + + @property + def component_size(self) -> int: + """Size in bytes of each component""" + return self._component_size + + @property + def depth(self) -> bool: + """If this is a depth texture.""" + return self._depth + + @property + def immutable(self) -> bool: + """Does this texture have immutable storage?""" + return self._immutable + + @property + def swizzle(self) -> str: + raise NotImplementedError("Texture Swizzle is not supported with WebGL") + + @swizzle.setter + def swizzle(self, value: str): + raise NotImplementedError("Texture Swizzle is not supported with WebGL") + + @Texture2D.filter.setter + def filter(self, value: tuple[int, int]): + if not isinstance(value, tuple) or not len(value) == 2: + raise ValueError("Texture filter must be a 2 component tuple (min, mag)") + + self._filter = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_MIN_FILTER, self._filter[0]) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_MAG_FILTER, self._filter[1]) + + @Texture2D.wrap_x.setter + def wrap_x(self, value: int): + self._wrap_x = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_WRAP_S, value) + + @Texture2D.wrap_y.setter + def wrap_y(self, value: int): + self._wrap_y = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_WRAP_T, value) + + @Texture2D.anisotropy.setter + def anisotropy(self, value): + # Technically anisotropy needs EXT_texture_filter_anisotropic but it's universally supported + self._anisotropy = max(1.0, min(value, self._ctx.info.MAX_TEXTURE_MAX_ANISOTROPY)) + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameterf( + self._target, enums.TEXTURE_MAX_ANISOTROPY_EXT, self._anisotropy + ) + + @Texture2D.compare_func.setter + def compare_func(self, value: str | None): + if not self._depth: + raise ValueError("Depth comparison function can only be set on depth textures") + + if not isinstance(value, str) and value is not None: + raise ValueError(f"Value must a string of: {compare_funcs.keys()}") + + func = compare_funcs.get(value, None) + if func is None: + raise ValueError(f"Value must a string of: {compare_funcs.keys()}") + + self._compare_func = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + if value is None: + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_COMPARE_MODE, enums.NONE) + else: + self._ctx._gl.texParameteri( + self._target, enums.TEXTURE_COMPARE_MODE, enums.COMPARE_REF_TO_TEXTURE + ) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_COMPARE_FUNC, func) + + def read(self, level: int = 0, alignment: int = 1) -> bytes: + # WebGL has no getTexImage, so attach this to a framebuffer and read from that + fbo = self._ctx.framebuffer(color_attachments=[self]) + return fbo.read(components=self._components, dtype=self._dtype) + + def write(self, data: BufferOrBufferProtocol, level: int = 0, viewport=None) -> None: + x, y, w, h = 0, 0, self._width, self._height + if viewport: + if len(viewport) == 2: + ( + w, + h, + ) = viewport + elif len(viewport) == 4: + x, y, w, h = viewport + else: + raise ValueError("Viewport must be of length 2 or 4") + + if isinstance(data, Buffer): + # type ignore here because + self._ctx._gl.bindBuffer(enums.PIXEL_UNPACK_BUFFER, data.glo) # type: ignore + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.pixelStorei(enums.PACK_ALIGNMENT, 1) + self._ctx._gl.pixelStorei(enums.UNPACK_ALIGNMENT, 1) + self._ctx._gl.texSubImage2D( + self._target, level, x, y, w, h, self._format, self._type, 0 + ) # type: ignore + self._ctx._gl.bindBuffer(enums.PIXEL_UNPACK_BUFFER, None) + else: + byte_size, data = data_to_memoryview(data) + self._validate_data_size(data, byte_size, w, h) + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.pixelStorei(enums.PACK_ALIGNMENT, 1) + self._ctx._gl.pixelStorei(enums.UNPACK_ALIGNMENT, 1) + # TODO: Does this to_js call create a memory leak? Need to investigate this more + # https://pyodide.org/en/stable/usage/type-conversions.html#type-translations-pyproxy-to-js + self._ctx._gl.texSubImage2D( + self._target, level, x, y, w, h, self._format, self._type, to_js(data), 0 + ) # type: ignore + + def _validate_data_size(self, byte_data, byte_size, width, height) -> None: + if self._compressed is True: + return + + expected_size = width * height * self._component_size * self._components + if byte_size != expected_size: + raise ValueError( + f"Data size {len(byte_data)} does not match expected size {expected_size}" + ) + byte_length = len(byte_data) if isinstance(byte_data, bytes) else byte_data.nbytes + if byte_length != byte_size: + raise ValueError( + f"Data size {len(byte_data)} does not match reported size {expected_size}" + ) + + def build_mipmaps(self, base: int = 0, max_level: int = 1000) -> None: + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(enums.TEXTURE_2D, self._glo) + self._ctx._gl.texParameteri(enums.TEXTURE_2D, enums.TEXTURE_BASE_LEVEL, base) + self._ctx._gl.texParameteri(enums.TEXTURE_2D, enums.TEXTURE_MAX_LEVEL, max_level) + self._ctx._gl.generateMipmap(enums.TEXTURE_2D) + + def delete(self): + WebGLTexture2D.delete_glo(self._ctx, self._glo) + self._glo = None + + @staticmethod + def delete_glo(ctx: WebGLContext, glo: WebGLTexture | None): + if glo is not None: + ctx._gl.deleteTexture(glo) + + ctx.stats.decr("texture") + + def use(self, unit: int = 0) -> None: + self._ctx._gl.activeTexture(enums.TEXTURE0 + unit) + self._ctx._gl.bindTexture(self._target, self._glo) + + def bind_to_image(self, unit: int, read: bool = True, write: bool = True, level: int = 0): + raise NotImplementedError("bind_to_image not supported with WebGL") + + def get_handle(self, resident: bool = True) -> int: + raise NotImplementedError("get_handle is not supported with WebGL") + + def __repr__(self) -> str: + return "".format( + self._glo, self._width, self._height, self._components + ) diff --git a/arcade/gl/backends/webgl/texture_array.py b/arcade/gl/backends/webgl/texture_array.py new file mode 100644 index 0000000000..ceb20638d2 --- /dev/null +++ b/arcade/gl/backends/webgl/texture_array.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +from pyodide.ffi import to_js + +from arcade.gl import enums +from arcade.gl.texture_array import TextureArray +from arcade.gl.types import BufferOrBufferProtocol, compare_funcs, pixel_formats +from arcade.types import BufferProtocol + +from .buffer import Buffer +from .utils import data_to_memoryview + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLTexture as JSWebGLTexture + + from arcade.gl.backends.webgl.context import WebGLContext + + +class WebGLTextureArray(TextureArray): + __slots__ = ( + "_glo", + "_target", + ) + + def __init__( + self, + ctx: WebGLContext, + size: tuple[int, int, int], + *, + components: int = 4, + dtype: str = "f1", + data: BufferProtocol | None = None, + filter: tuple[int, int] | None = None, + wrap_x: int | None = None, + wrap_y: int | None = None, + depth=False, + samples: int = 0, + immutable: bool = False, + internal_format: int | None = None, + compressed: bool = False, + compressed_data: bool = False, + ): + if samples > 0: + raise NotImplementedError("Multisample Textures are unsupported with WebGL") + + super().__init__( + ctx, + size, + components=components, + dtype=dtype, + data=data, + filter=filter, + wrap_x=wrap_x, + wrap_y=wrap_y, + depth=depth, + samples=samples, + immutable=immutable, + internal_format=internal_format, + compressed=compressed, + compressed_data=compressed_data, + ) + self._ctx = ctx + + if "f" in self._dtype: + self._filter = enums.LINEAR, enums.LINEAR + else: + self._filter = enums.NEAREST, enums.NEAREST + + self._wrap_x = enums.REPEAT + self._wrap_y = enums.REPEAT + + self._target = enums.TEXTURE_2D_ARRAY + + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._glo = self._ctx._gl.createTexture() + if self._glo is None: + raise RuntimeError("Cannot create TextureArray. WebGL failed to generate a texture") + + self._ctx._gl.bindTexture(self._target, self._glo) + self._texture_2d_array(data) + + self.filter = filter = self._filter + self.wrap_x = wrap_x or self._wrap_x + self.wrap_y = wrap_y or self._wrap_y + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, WebGLTextureArray.delete_glo, self._ctx, self._glo) + + def resize(self, size: tuple[int, int]): + if self._immutable: + raise ValueError("Immutable textures cannot be resized") + + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + + self._width, self._height = size + + self._texture_2d_array(None) + + def __del__(self): + if self._ctx.gc_mode == "context_gc" and self._glo is not None: + self._ctx.objects.append(self) + + def _texture_2d_array(self, data): + try: + format_info = pixel_formats[self._dtype] + except KeyError: + raise ValueError( + f"dype '{self._dtype}' not support. Supported types are : " + f"{tuple(pixel_formats.keys())}" + ) + _format, _internal_format, self._type, self._component_size = format_info + if data is not None: + byte_length, data = data_to_memoryview(data) + self._validate_data_size(data, byte_length, self._width, self._height, self._layers) + + self._ctx._gl.pixelStorei(enums.UNPACK_ALIGNMENT, self._alignment) + self._ctx._gl.pixelStorei(enums.PACK_ALIGNMENT, self._alignment) + + if self._depth: + self._ctx._gl.texImage3D( + self._target, + 0, + enums.DEPTH_COMPONENT24, + self._width, + self._height, + self._layers, + 0, + enums.DEPTH_COMPONENT, + enums.UNSIGNED_INT, + data, + ) + self.compare_func = "<=" + else: + self._format = _format[self._components] + if self._internal_format is None: + self._internal_format = _internal_format[self._components] + + if self._immutable: + self._ctx._gl.texStorage3D( + self._target, + 1, + self._internal_format, + self._width, + self._height, + self._layers, + ) + if data: + self.write(data) + else: + if self._compressed_data is True: + self._ctx._gl.compressedTexImage3D( + self._target, + 0, + self._internal_format, + self._width, + self._height, + self._layers, + 0, + len(data), + data, + ) + else: + self._ctx._gl.texImage3D( + self._target, + 0, + self._internal_format, + self._width, + self._height, + self._layers, + 0, + self._format, + self._type, + data, + ) + + @property + def glo(self) -> JSWebGLTexture | None: + return self._glo + + @property + def swizzle(self) -> str: + raise NotImplementedError("Texture Swizzle is not support with WebGL") + + @swizzle.setter + def swizzle(self, value: str): + raise NotImplementedError("Texture Swizzle is not supported with WebGL") + + @TextureArray.filter.setter + def filter(self, value: tuple[int, int]): + if not isinstance(value, tuple) or not len(value) == 2: + raise ValueError("Texture filter must be a 2 component tuple (min, mag)") + + self._filter = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_MIN_FILTER, self._filter[0]) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_MAG_FILTER, self._filter[1]) + + @TextureArray.wrap_x.setter + def wrap_x(self, value: int): + self._wrap_x = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_WRAP_T, value) + + @TextureArray.wrap_y.setter + def wrap_y(self, value: int): + self._wrap_y = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_WRAP_S, value) + + @TextureArray.anisotropy.setter + def anisotropy(self, value): + self._anisotropy = max(1.0, min(value, self._ctx.info.MAX_TEXTURE_MAX_ANISOTROPY)) + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameterf( + self._target, enums.TEXTURE_MAX_ANISOTROPY_EXT, self._anisotropy + ) + + @TextureArray.compare_func.setter + def compare_func(self, value: str | None): + if not self._depth: + raise ValueError("Depth comparison function can only be set on depth textures") + + if not isinstance(value, str) and value is not None: + raise ValueError(f"value must be as string: {compare_funcs.keys()}") + + func = compare_funcs.get(value, None) + if func is None: + raise ValueError(f"value must be as string: {compare_funcs.keys()}") + + self._compare_func = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + if value is None: + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_COMPARE_MODE, enums.NONE) + else: + self._ctx._gl.texParameteri( + self._target, enums.TEXTURE_COMPARE_MODE, enums.COMPARE_REF_TO_TEXTURE + ) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_COMPARE_FUNC, func) + + def read(self, level: int = 0, alignment: int = 1) -> bytes: + # FIXME: Check if we can attach a layer to framebuffer for reading. OpenGL ES has same + # problems in the OpenGL backend. + raise NotImplementedError("Reading texture array data not supported with WebGL") + + def write(self, data: BufferOrBufferProtocol, level: int = 0, viewport=None) -> None: + x, y, l, w, h = 0, 0, 0, self._width, self._height + if viewport: + if len(viewport) == 5: + x, y, l, w, h = viewport + else: + raise ValueError("Viewport must be of length 5") + + if isinstance(data, Buffer): + self._ctx._gl.bindBuffer(enums.PIXEL_UNPACK_BUFFER, data.glo) # type: ignore + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.pixelStorei(enums.PACK_ALIGNMENT, 1) + self._ctx._gl.pixelStorei(enums.UNPACK_ALIGNMENT, 1) + self._ctx._gl.texSubImage3D( + self._target, level, x, y, l, w, h, 1, self._format, self._type, 0 + ) + self._ctx._gl.bindBuffer(enums.PIXEL_UNPACK_BUFFER, None) + else: + byte_size, data = data_to_memoryview(data) + self._validate_data_size(data, byte_size, w, h, 1) + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.pixelStorei(enums.PACK_ALIGNMENT, 1) + self._ctx._gl.pixelStorei(enums.UNPACK_ALIGNMENT, 1) + self._ctx._gl.texSubImage3D( + self._target, level, x, y, l, w, h, 1, self._format, self._type, to_js(data), 0 + ) + + def _validate_data_size(self, byte_data, byte_size, width, height) -> None: + if self._compressed is True: + return + + expected_size = width * height * self._component_size * self._components + if byte_size != expected_size: + raise ValueError( + f"Data size {len(byte_data)} does not match expected size {expected_size}" + ) + byte_length = len(byte_data) if isinstance(byte_data, bytes) else byte_data.nbytes + if byte_length != byte_size: + raise ValueError( + f"Data size {len(byte_data)} does not match reported size {expected_size}" + ) + + def build_mipmaps(self, base: int = 0, max_level: int = 1000) -> None: + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_BASE_LEVEL, base) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_MAX_LEVEL, max_level) + self._ctx._gl.generateMipmap(self._target) + + def delete(self): + self.delete_glo(self._ctx, self._glo) + self._glo = None + + @staticmethod + def delete_glo(ctx: WebGLContext, glo: JSWebGLTexture | None): + ctx._gl.deleteTexture(glo) + ctx.stats.decr("texture") + + def use(self, unit: int = 0) -> None: + self._ctx._gl.activeTexture(enums.TEXTURE0 + unit) + self._ctx._gl.bindTexture(self._target, self._glo) + + def bind_to_image(self, unit: int, read: bool = True, write: bool = True, level: int = 0): + raise NotImplementedError("bind_to_image not supported with WebGL") + + def get_handle(self, resident: bool = True) -> int: + raise NotImplementedError("get_handle is not supported with WebGL") + + def __repr__(self) -> str: + return "".format( + self._glo, self._width, self._layers, self._height, self._components + ) diff --git a/arcade/gl/backends/webgl/uniform.py b/arcade/gl/backends/webgl/uniform.py new file mode 100644 index 0000000000..34741557af --- /dev/null +++ b/arcade/gl/backends/webgl/uniform.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable + +from arcade.gl import enums +from arcade.gl.exceptions import ShaderException + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLProgram as JSWebGLProgram + + from arcade.gl.backends.webgl.context import WebGLContext + + +class Uniform: + """ + A Program uniform + + Args: + ctx: + The context + program_id: + The program id to which this uniform belongs + location: + The uniform location + name: + The uniform name + data_type: + The data type of the uniform + array_length: + The array length of the uniform + """ + + __slots__ = ( + "_program", + "_location", + "_name", + "_data_type", + "_array_length", + "_components", + "getter", + "setter", + "_ctx", + ) + + def __init__( + self, ctx: WebGLContext, program: JSWebGLProgram, location, name, data_type, array_length + ): + self._ctx = ctx + self._program = program + self._location = location + self._name = name + self._data_type = data_type + # Array length of the uniform (1 if no array) + self._array_length = array_length + # Number of components (including per array entry) + self._components = 0 + self.getter: Callable + """The getter function configured for this uniform""" + self.setter: Callable + """The setter function configured for this uniform""" + self._setup_getters_and_setters() + + @property + def location(self) -> int: + """The location of the uniform in the program""" + return self._location + + @property + def name(self) -> str: + """Name of the uniform""" + return self._name + + @property + def array_length(self) -> int: + """Length of the uniform array. If not an array 1 will be returned""" + return self._array_length + + @property + def components(self) -> int: + """ + How many components for the uniform. + + A vec4 will for example have 4 components. + """ + return self._components + + def _setup_getters_and_setters(self): + """Maps the right getter and setter functions for this uniform""" + try: + gl_type, gl_setter, length, count = self._ctx._uniform_setters[self._data_type] + self._components = length + except KeyError: + raise ShaderException(f"Unsupported Uniform type: {self._data_type}") + + is_matrix = self._data_type in ( + enums.FLOAT_MAT2, + enums.FLOAT_MAT3, + enums.FLOAT_MAT4, + ) + + self.setter = Uniform._create_setter_func( + self._ctx, + self._program, + self._location, + gl_setter, + is_matrix, + ) + + @classmethod + def _create_setter_func( + cls, + ctx: WebGLContext, + program: JSWebGLProgram, + location, + gl_setter, + is_matrix, + ): + """Create setters for OpenGL data.""" + # Matrix uniforms + if is_matrix: + + def setter_func(value): # type: ignore #conditional function variants must have identical signature + """Set OpenGL matrix uniform data.""" + ctx._gl.useProgram(program) + gl_setter(location, False, value) + + # Single value and multi componentuniforms + else: + + def setter_func(value): # type: ignore #conditional function variants must have identical signature + """Set OpenGL uniform data value.""" + ctx._gl.useProgram(program) + gl_setter(location, value) + + return setter_func + + def __repr__(self) -> str: + return f"" + + +class UniformBlock: + """ + Wrapper for a uniform block in shaders. + + Args: + glo: + The OpenGL object handle + index: + The index of the uniform block + size: + The size of the uniform block + name: + The name of the uniform + """ + + __slots__ = ("_ctx", "glo", "index", "size", "name") + + def __init__(self, ctx: WebGLContext, glo, index: int, size: int, name: str): + self._ctx = ctx + self.glo = glo + """The OpenGL object handle""" + + self.index = index + """The index of the uniform block""" + + self.size = size + """The size of the uniform block""" + + self.name = name + """The name of the uniform block""" + + @property + def binding(self) -> int: + """Get or set the binding index for this uniform block""" + return self._ctx._gl.getActiveUniformBlockParameter( + self.glo, self.index, enums.UNIFORM_BLOCK_BINDING + ) + + @binding.setter + def binding(self, binding: int): + self._ctx._gl.uniformBlockBinding(self.glo, self.index, binding) + + def getter(self): + """ + The getter function for this uniform block. + + Returns self. + """ + return self + + def setter(self, value: int): + """ + The setter function for this uniform block. + + Args: + value: The binding index to set. + """ + self.binding = value + + def __str__(self) -> str: + return f"" diff --git a/arcade/gl/backends/webgl/utils.py b/arcade/gl/backends/webgl/utils.py new file mode 100644 index 0000000000..334e8682f9 --- /dev/null +++ b/arcade/gl/backends/webgl/utils.py @@ -0,0 +1,32 @@ +""" +Various utility functions for the gl module. +""" + +from array import array +from typing import Any, Union + + +def data_to_memoryview(data: Any) -> tuple[int, Union[bytes, memoryview]]: + """ + Attempts to convert the data to a memoryview if needed + + - bytes will be returned as is + - Tuples will be converted to array + - Other types will be converted directly to memoryview + + Args: + data: The data to convert to ctypes. + Returns: + A tuple containing the size of the data in bytes + and the data object optionally converted to a memoryview. + """ + if isinstance(data, bytes): + return len(data), data + else: + if isinstance(data, tuple): + data = array("f", data) + try: + m_view = memoryview(data) + return m_view.nbytes, m_view + except Exception as ex: + raise TypeError(f"Failed to convert data to memoryview: {ex}") diff --git a/arcade/gl/backends/webgl/vertex_array.py b/arcade/gl/backends/webgl/vertex_array.py new file mode 100644 index 0000000000..680c37dc9e --- /dev/null +++ b/arcade/gl/backends/webgl/vertex_array.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING, Sequence + +from arcade.gl import enums +from arcade.gl.types import BufferDescription, gl_name +from arcade.gl.vertex_array import Geometry, VertexArray + +from .buffer import WebGLBuffer +from .program import WebGLProgram + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLVertexArrayObject as JSWebGLVertexArray + + from arcade.gl.backends.webgl.context import WebGLContext + +index_types = [None, enums.UNSIGNED_BYTE, enums.UNSIGNED_SHORT, None, enums.UNSIGNED_INT] + + +class WebGLVertexArray(VertexArray): + __slots__ = ( + "_glo", + "_index_element_type", + ) + + def __init__( + self, + ctx: WebGLContext, + program: WebGLProgram, + content: Sequence[BufferDescription], + index_buffer: WebGLBuffer | None = None, + index_element_size: int = 4, + ): + super().__init__(ctx, program, content, index_buffer, index_element_size) + self._ctx = ctx + + glo = self._ctx._gl.createVertexArray() + assert glo is not None, "Failed to create WebGL VertexArray object" + self._glo = glo + + self._index_element_type = index_types[index_element_size] + + self._build(program, content, index_buffer) + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, WebGLVertexArray.delete_glo, self._ctx, self._glo) + + def __repr__(self) -> str: + return f"" + + def __del__(self) -> None: + # Intercept garbage collection if we are using Context.gc() + if self._ctx.gc_mode == "context_gc" and self._glo is not None: + self._ctx.objects.append(self) + + def delete(self) -> None: + WebGLVertexArray.delete_glo(self._ctx, self._glo) + self._glo = None + + @staticmethod + def delete_glo(ctx: WebGLContext, glo: JSWebGLVertexArray | None): + if glo is not None: + ctx._gl.deleteVertexArray(glo) + + ctx.stats.decr("vertex_array") + + def _build( + self, + program: WebGLProgram, + content: Sequence[BufferDescription], + index_buffer: WebGLBuffer | None, + ) -> None: + self._ctx._gl.bindVertexArray(self._glo) + + if index_buffer is not None: + self._ctx._gl.bindBuffer(enums.ELEMENT_ARRAY_BUFFER, index_buffer._glo) + + descr_attribs = {attr.name: (descr, attr) for descr in content for attr in descr.formats} + + for _, prog_attr in enumerate(program.attributes): + if prog_attr.name is not None and prog_attr.name.startswith("gl_"): + continue + try: + buff_descr, attr_descr = descr_attribs[prog_attr.name] + except KeyError: + raise ValueError( + ( + f"Program needs attribute '{prog_attr.name}', but is not present in buffer " + f"description. Buffer descriptions: {content}" + ) + ) + + if prog_attr.components != attr_descr.components: + raise ValueError( + ( + f"Program attribute '{prog_attr.name}' has {prog_attr.components} " + f"components while the buffer description has {attr_descr.components} " + " components. " + ) + ) + + self._ctx._gl.enableVertexAttribArray(prog_attr.location) + self._ctx._gl.bindBuffer(enums.ARRAY_BUFFER, buff_descr.buffer.glo) # type: ignore + + normalized = True if attr_descr.name in buff_descr.normalized else False + + float_types = (enums.FLOAT, enums.HALF_FLOAT) + int_types = ( + enums.INT, + enums.UNSIGNED_INT, + enums.SHORT, + enums.UNSIGNED_SHORT, + enums.BYTE, + enums.UNSIGNED_BYTE, + ) + attrib_type = attr_descr.gl_type + if attrib_type in int_types and buff_descr.normalized: + attrib_type = prog_attr.gl_type + + if attrib_type != prog_attr.gl_type: + raise ValueError( + ( + f"Program attribute '{prog_attr.name}' has type " + f"{gl_name(prog_attr.gl_type)}" + f"while the buffer description has type {gl_name(attr_descr.gl_type)}. " + ) + ) + + if attrib_type in float_types or attrib_type in int_types: + self._ctx._gl.vertexAttribPointer( + prog_attr.location, + attr_descr.components, + attr_descr.gl_type, + normalized, + buff_descr.stride, + attr_descr.offset, + ) + else: + raise ValueError(f"Unsupported attribute type: {attr_descr.gl_type}") + + if buff_descr.instanced: + self._ctx._gl.vertexAttribDivisor(prog_attr.location, 1) + + def render(self, mode: int, first: int = 0, vertices: int = 0, instances: int = 1) -> None: + self._ctx._gl.bindVertexArray(self._glo) + if self._ibo is not None: + self._ctx._gl.bindBuffer(enums.ELEMENT_ARRAY_BUFFER, self._ibo.glo) # type: ignore + self._ctx._gl.drawElementsInstanced( + mode, + vertices, + self._index_element_type, + first * self._index_element_size, + instances, + ) + else: + self._ctx._gl.drawArraysInstanced(mode, first, vertices, instances) + + def render_indirect(self, buffer: WebGLBuffer, mode: int, count, first, stride) -> None: + raise NotImplementedError("Indrect Rendering not supported with WebGL") + + def transform_interleaved( + self, + buffer: WebGLBuffer, + mode: int, + output_mode: int, + first: int = 0, + vertices: int = 0, + instances: int = 1, + buffer_offset=0, + ) -> None: + if vertices < 0: + raise ValueError(f"Cannot determine the number of verticies: {vertices}") + + if buffer_offset >= buffer.size: + raise ValueError("buffer_offset at end or past the buffer size") + + self._ctx._gl.bindVertexArray(self._glo) + self._ctx._gl.enable(enums.RASTERIZER_DISCARD) + + if buffer_offset > 0: + self._ctx._gl.bindBufferRange( + enums.TRANSFORM_FEEDBACK_BUFFER, + 0, + buffer.glo, + buffer_offset, + buffer.size - buffer_offset, + ) + else: + self._ctx._gl.bindBufferBase(enums.TRANSFORM_FEEDBACK_BUFFER, 0, buffer.glo) + + self._ctx._gl.beginTransformFeedback(output_mode) + + if self._ibo is not None: + count = self._ibo.size // 4 + self._ctx._gl.drawElementsInstanced( + mode, vertices or count, enums.UNSIGNED_INT, 0, instances + ) + else: + self._ctx._gl.drawArraysInstanced(mode, first, vertices, instances) + + self._ctx._gl.endTransformFeedback() + self._ctx._gl.disable(enums.RASTERIZER_DISCARD) + + def transform_separate( + self, + buffers: list[WebGLBuffer], + mode: int, + output_mode: int, + first: int = 0, + vertices: int = 0, + instances: int = 1, + buffer_offset=0, + ) -> None: + if vertices < 0: + raise ValueError(f"Cannot determine the number of vertices: {vertices}") + + size = min(buf.size for buf in buffers) + if buffer_offset >= size: + raise ValueError("buffer_offset at end or past the buffer size") + + self._ctx._gl.bindVertexArray(self._glo) + self._ctx._gl.enable(enums.RASTERIZER_DISCARD) + + if buffer_offset > 0: + for index, buffer in enumerate(buffers): + self._ctx._gl.bindBufferRange( + enums.TRANSFORM_FEEDBACK_BUFFER, + index, + buffer.glo, + buffer_offset, + buffer.size - buffer_offset, + ) + else: + for index, buffer in enumerate(buffers): + self._ctx._gl.bindBufferBase(enums.TRANSFORM_FEEDBACK_BUFFER, index, buffer.glo) + + self._ctx._gl.beginTransformFeedback(output_mode) + + if self._ibo is not None: + count = self._ibo.size // 4 + self._ctx._gl.drawElementsInstanced( + mode, vertices or count, enums.UNSIGNED_INT, 0, instances + ) + else: + self._ctx._gl.drawArraysInstanced(mode, first, vertices, instances) + + self._ctx._gl.endTransformFeedback() + self._ctx._gl.disable(enums.RASTERIZER_DISCARD) + + +class WebGLGeometry(Geometry): + def __init__( + self, + ctx: WebGLContext, + content: Sequence[BufferDescription] | None, + index_buffer: WebGLBuffer | None = None, + mode: int | None = None, + index_element_size: int = 4, + ) -> None: + super().__init__(ctx, content, index_buffer, mode, index_element_size) + + def _generate_vao(self, program: WebGLProgram) -> WebGLVertexArray: + vao = WebGLVertexArray( + self._ctx, # type: ignore + program, + self._content, + index_buffer=self._index_buffer, # type: ignore + index_element_size=self._index_element_size, + ) + self._vao_cache[program.attribute_key] = vao + return vao diff --git a/arcade/gl/buffer.py b/arcade/gl/buffer.py index 294c3730b9..56e55ca4ac 100644 --- a/arcade/gl/buffer.py +++ b/arcade/gl/buffer.py @@ -3,11 +3,14 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING +from arcade.gl import enums from arcade.types import BufferProtocol if TYPE_CHECKING: from arcade.gl import Context +_usages = {"static": enums.STATIC_DRAW, "dynamic": enums.DYNAMIC_DRAW, "stream": enums.STREAM_DRAW} + class Buffer(ABC): """OpenGL buffer object. Buffers store byte data and upload it diff --git a/arcade/gl/context.py b/arcade/gl/context.py index ded2574d1a..7ba7188e21 100644 --- a/arcade/gl/context.py +++ b/arcade/gl/context.py @@ -207,6 +207,7 @@ def __init__( gc_mode: str = "context_gc", gl_api: str = "gl", # This is ignored here, but used in implementation classes ): + self._gl_api = gl_api self._window_ref = weakref.ref(window) self._info = get_provider().create_info(self) @@ -224,7 +225,6 @@ def __init__( self._stats: ContextStats = ContextStats(warn_threshold=1000) self._primitive_restart_index = -1 - self.primitive_restart_index = self._primitive_restart_index # States self._blend_func: Tuple[int, int] | Tuple[int, int, int, int] = self.BLEND_DEFAULT diff --git a/arcade/gl/enums.py b/arcade/gl/enums.py index 4b410dd7df..1482f1be54 100644 --- a/arcade/gl/enums.py +++ b/arcade/gl/enums.py @@ -63,6 +63,7 @@ BLEND = 0x0BE2 DEPTH_TEST = 0x0B71 CULL_FACE = 0x0B44 +SCISSOR_TEST = 0x0C11 # Texture min/mag filters NEAREST = 0x2600 @@ -72,6 +73,59 @@ NEAREST_MIPMAP_LINEAR = 0x2702 LINEAR_MIPMAP_LINEAR = 0x2703 +# Textures +TEXTURE_2D = 0x0DE1 +TEXTURE_2D_ARRAY = 0x8C1A +ACTIVE_TEXTURE = 0x84E0 +TEXTURE0 = 0x84C0 +TEXTURE1 = 0x84C1 +TEXTURE2 = 0x84C2 +TEXTURE3 = 0x84C3 +TEXTURE4 = 0x84C4 +TEXTURE5 = 0x84C5 +TEXTURE6 = 0x84C6 +TEXTURE7 = 0x84C7 +TEXTURE8 = 0x84C8 +TEXTURE9 = 0x84C9 +TEXTURE10 = 0x84CA +TEXTURE11 = 0x84CB +TEXTURE12 = 0x84CC +TEXTURE13 = 0x84CD +TEXTURE14 = 0x84CE +TEXTURE15 = 0x84CF +TEXTURE16 = 0x84D0 +TEXTURE17 = 0x84D1 +TEXTURE18 = 0x84D2 +TEXTURE19 = 0x84D3 +TEXTURE20 = 0x84D4 +TEXTURE21 = 0x84D5 +TEXTURE22 = 0x84D6 +TEXTURE23 = 0x84D7 +TEXTURE24 = 0x84D8 +TEXTURE25 = 0x84D9 +TEXTURE26 = 0x84DA +TEXTURE27 = 0x84DB +TEXTURE28 = 0x84DC +TEXTURE29 = 0x84DD +TEXTURE30 = 0x84DE +TEXTURE31 = 0x84DF +UNPACK_ALIGNMENT = 0x0CF5 +PACK_ALIGNMENT = 0x0D05 +DEPTH_COMPONENT = 0x1902 +DEPTH_COMPONENT16 = 0x81A5 +DEPTH_COMPONENT24 = 0x81A6 +DEPTH_COMPONENT32F = 0x8CAC +TEXTURE_MAG_FILTER = 0x2800 +TEXTURE_MIN_FILTER = 0x2801 +TEXTURE_WRAP_S = 0x2802 +TEXTURE_WRAP_T = 0x2803 +TEXTURE_MAX_ANISOTROPY_EXT = 0x84FE # WebGL Specific for texture anisotropy extension +TEXTURE_COMPARE_MODE = 0x884C +TEXTURE_COMPARE_FUNC = 0x884D +COMPARE_REF_TO_TEXTURE = 0x884E +TEXTURE_BASE_LEVEL = 0x813C +TEXTURE_MAX_LEVEL = 0x813D + # Texture wrapping REPEAT = 0x2901 CLAMP_TO_EDGE = 0x812F @@ -236,3 +290,69 @@ GEOMETRY_SHADER = 0x8DD9 # Not supported in WebGL TESS_CONTROL_SHADER = 0x8E88 # Not supported in WebGL TESS_EVALUATION_SHADER = 0x8E87 # Not supported in WebGL + +# Get Parameters +FRONT_FACE = 0x0B46 +CW = 0x0900 +CCW = 0x0901 +CULL_FACE_MODE = 0x0B45 +SCISSOR_BOX = 0x0C10 + +# Buffers +STATIC_DRAW = 0x88E4 +STREAM_DRAW = 0x88E0 +DYNAMIC_DRAW = 0x88E8 +ARRAY_BUFFER = 0x8892 +ELEMENT_ARRAY_BUFFER = 0x8893 +COPY_READ_BUFFER = 0x8F36 +COPY_WRITE_BUFFER = 0x8F37 +UNIFORM_BUFFER = 0x8A11 +PIXEL_UNPACK_BUFFER = 0x88EC + +# Framebuffers +FRAMEBUFFER = 0x8D40 +COLOR_ATTACHMENT0 = 0x8CE0 +DEPTH_ATTACHMENT = 0x8D00 +FRAMEBUFFER_UNSUPPORTED = 0x8CDD +FRAMEBUFFER_INCOMPLETE_ATTACHMENT = 0x8CD6 +FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT = 0x8CD7 +FRAMEBUFFER_INCOMPLETE_DIMENSIONS = 0x8CD9 +FRAMEBUFFER_INCOMPLETE_MULTISAMPLE = 0x8D56 +FRAMEBUFFER_COMPLETE = 0x8CD5 +READ_FRAMEBUFFER = 0x8CA8 +DRAW_FRAMEBUFFER = 0x8CA9 + +# Clear Bits +DEPTH_BUFFER_BIT = 0x00000100 +STENCIL_BUFFER_BIT = 0x0000400 +COLOR_BUFFER_BIT = 0x00004000 + +# Samplers +SAMPLER_2D = 0x8B5E +INT_SAMPLER_2D = 0x8DCA +UNSIGNED_INT_SAMPLER_2D = 0x8DD2 +SAMPLER_2D_ARRAY = 0x8DC1 + +# Shader Parameters +COMPILE_STATUS = 0x8B81 +LINK_STATUS = 0x8B82 + +# Misc +UNIFORM_BLOCK_BINDING = 0x8A3F +INTERLEAVED_ATTRIBS = 0x8C8C +SEPARATE_ATTRIBS = 0x8C8D +ACTIVE_ATTRIBUTES = 0x8B89 +TRANSFORM_FEEDBACK_VARYINGS = 0x8C83 +ACTIVE_UNIFORMS = 0x8B86 +ACTIVE_UNIFORM_BLOCKS = 0x8A36 +UNIFORM_BLOCK_DATA_SIZE = 0x8A40 +RASTERIZER_DISCARD = 0x8C89 +TRANSFORM_FEEDBACK_BUFFER = 0x8C8E + +# Queries +CURRENT_QUERY = 0x8865 +QUERY_RESULT = 0x8866 +QUERY_RESULT_AVAILABLE = 0x8867 +ANY_SAMPLES_PASSED = 0x8C2F +ANY_SAMPLES_PASSED_CONSERVATIVE = 0x8D6A +TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN = 0x8C88 diff --git a/arcade/gl/texture_array.py b/arcade/gl/texture_array.py index 369ee617d6..345f732d22 100644 --- a/arcade/gl/texture_array.py +++ b/arcade/gl/texture_array.py @@ -4,10 +4,7 @@ from typing import TYPE_CHECKING from ..types import BufferProtocol -from .types import ( - BufferOrBufferProtocol, - pixel_formats, -) +from .types import BufferOrBufferProtocol, pixel_formats if TYPE_CHECKING: # handle import cycle caused by type hinting from arcade.gl import Context diff --git a/arcade/gl/vertex_array.py b/arcade/gl/vertex_array.py index 5a8a764c97..9c7310c709 100644 --- a/arcade/gl/vertex_array.py +++ b/arcade/gl/vertex_array.py @@ -355,41 +355,46 @@ def render( # If we have a geometry shader we need to sanity check that # the primitive mode is supported - if program.geometry_vertices > 0: - if program.geometry_input == self._ctx.POINTS: - mode = program.geometry_input - if program.geometry_input == self._ctx.LINES: - if mode not in [ - self._ctx.LINES, - self._ctx.LINE_STRIP, - self._ctx.LINE_LOOP, - self._ctx.LINES_ADJACENCY, - ]: - raise ValueError( - "Geometry shader expects LINES, LINE_STRIP, LINE_LOOP " - " or LINES_ADJACENCY as input" - ) - if program.geometry_input == self._ctx.LINES_ADJACENCY: - if mode not in [self._ctx.LINES_ADJACENCY, self._ctx.LINE_STRIP_ADJACENCY]: - raise ValueError( - "Geometry shader expects LINES_ADJACENCY or LINE_STRIP_ADJACENCY as input" - ) - if program.geometry_input == self._ctx.TRIANGLES: - if mode not in [ - self._ctx.TRIANGLES, - self._ctx.TRIANGLE_STRIP, - self._ctx.TRIANGLE_FAN, - ]: - raise ValueError( - "Geometry shader expects GL_TRIANGLES, GL_TRIANGLE_STRIP " - "or GL_TRIANGLE_FAN as input" - ) - if program.geometry_input == self._ctx.TRIANGLES_ADJACENCY: - if mode not in [self._ctx.TRIANGLES_ADJACENCY, self._ctx.TRIANGLE_STRIP_ADJACENCY]: - raise ValueError( - "Geometry shader expects GL_TRIANGLES_ADJACENCY or " - "GL_TRIANGLE_STRIP_ADJACENCY as input" - ) + if self._ctx._gl_api != "webgl": + if program.geometry_vertices > 0: + if program.geometry_input == self._ctx.POINTS: + mode = program.geometry_input + if program.geometry_input == self._ctx.LINES: + if mode not in [ + self._ctx.LINES, + self._ctx.LINE_STRIP, + self._ctx.LINE_LOOP, + self._ctx.LINES_ADJACENCY, + ]: + raise ValueError( + "Geometry shader expects LINES, LINE_STRIP, LINE_LOOP " + " or LINES_ADJACENCY as input" + ) + if program.geometry_input == self._ctx.LINES_ADJACENCY: + if mode not in [self._ctx.LINES_ADJACENCY, self._ctx.LINE_STRIP_ADJACENCY]: + raise ValueError( + "Geometry shader expects LINES_ADJACENCY or LINE_STRIP_ADJACENCY " + "as input" + ) + if program.geometry_input == self._ctx.TRIANGLES: + if mode not in [ + self._ctx.TRIANGLES, + self._ctx.TRIANGLE_STRIP, + self._ctx.TRIANGLE_FAN, + ]: + raise ValueError( + "Geometry shader expects GL_TRIANGLES, GL_TRIANGLE_STRIP " + "or GL_TRIANGLE_FAN as input" + ) + if program.geometry_input == self._ctx.TRIANGLES_ADJACENCY: + if mode not in [ + self._ctx.TRIANGLES_ADJACENCY, + self._ctx.TRIANGLE_STRIP_ADJACENCY, + ]: + raise ValueError( + "Geometry shader expects GL_TRIANGLES_ADJACENCY or " + "GL_TRIANGLE_STRIP_ADJACENCY as input" + ) vao.render( mode=mode, diff --git a/arcade/hitbox/__init__.py b/arcade/hitbox/__init__.py index becf40679d..d8881c4bff 100644 --- a/arcade/hitbox/__init__.py +++ b/arcade/hitbox/__init__.py @@ -1,16 +1,22 @@ from PIL.Image import Image from arcade.types import Point2List +from arcade.utils import is_pyodide from .base import HitBox, HitBoxAlgorithm, RotatableHitBox from .bounding_box import BoundingHitBoxAlgorithm -from .pymunk import PymunkHitBoxAlgorithm + from .simple import SimpleHitBoxAlgorithm #: The simple hit box algorithm. algo_simple = SimpleHitBoxAlgorithm() #: The detailed hit box algorithm. -algo_detailed = PymunkHitBoxAlgorithm() + +if not is_pyodide(): + from .pymunk import PymunkHitBoxAlgorithm + + algo_detailed = PymunkHitBoxAlgorithm() + #: The bounding box hit box algorithm. algo_bounding_box = BoundingHitBoxAlgorithm() #: The default hit box algorithm. diff --git a/arcade/future/input/README.md b/arcade/input/README.md similarity index 100% rename from arcade/future/input/README.md rename to arcade/input/README.md diff --git a/arcade/future/input/__init__.py b/arcade/input/__init__.py similarity index 100% rename from arcade/future/input/__init__.py rename to arcade/input/__init__.py diff --git a/arcade/future/input/input_mapping.py b/arcade/input/input_mapping.py similarity index 96% rename from arcade/future/input/input_mapping.py rename to arcade/input/input_mapping.py index 3198db7401..d030ad7ce6 100644 --- a/arcade/future/input/input_mapping.py +++ b/arcade/input/input_mapping.py @@ -1,8 +1,8 @@ # type: ignore from __future__ import annotations -from arcade.future.input import inputs -from arcade.future.input.raw_dicts import RawAction, RawActionMapping, RawAxis, RawAxisMapping +from arcade.input import inputs +from arcade.input.raw_dicts import RawAction, RawActionMapping, RawAxis, RawAxisMapping class Action: diff --git a/arcade/future/input/inputs.py b/arcade/input/inputs.py similarity index 99% rename from arcade/future/input/inputs.py rename to arcade/input/inputs.py index 1371e750bb..25111c8f7f 100644 --- a/arcade/future/input/inputs.py +++ b/arcade/input/inputs.py @@ -8,7 +8,7 @@ from enum import Enum, auto from sys import platform -from arcade.future.input.raw_dicts import RawBindBase +from arcade.input.raw_dicts import RawBindBase class InputType(Enum): diff --git a/arcade/future/input/manager.py b/arcade/input/manager.py similarity index 98% rename from arcade/future/input/manager.py rename to arcade/input/manager.py index 4d996b4aa7..ccad0484d7 100644 --- a/arcade/future/input/manager.py +++ b/arcade/input/manager.py @@ -10,8 +10,8 @@ from typing_extensions import TypedDict import arcade -from arcade.future.input import inputs -from arcade.future.input.input_mapping import ( +from arcade.input import inputs +from arcade.input.input_mapping import ( Action, ActionMapping, Axis, @@ -19,8 +19,8 @@ serialize_action, serialize_axis, ) -from arcade.future.input.inputs import InputEnum, InputType -from arcade.future.input.raw_dicts import RawAction, RawAxis +from arcade.input.inputs import InputEnum, InputType +from arcade.input.raw_dicts import RawAction, RawAxis from arcade.types import OneOrIterableOf from arcade.utils import grow_sequence diff --git a/arcade/future/input/raw_dicts.py b/arcade/input/raw_dicts.py similarity index 100% rename from arcade/future/input/raw_dicts.py rename to arcade/input/raw_dicts.py diff --git a/arcade/perf_graph.py b/arcade/perf_graph.py index 1809d31f8f..b274a31190 100644 --- a/arcade/perf_graph.py +++ b/arcade/perf_graph.py @@ -1,7 +1,9 @@ import random import pyglet.clock -from pyglet.graphics import Batch + +# Pyright can't figure out the dynamic import for the backends in Pyglet +from pyglet.graphics import Batch # type: ignore from pyglet.shapes import Line import arcade diff --git a/arcade/pymunk_physics_engine.py b/arcade/pymunk_physics_engine.py index 04e0e721d4..7dde352585 100644 --- a/arcade/pymunk_physics_engine.py +++ b/arcade/pymunk_physics_engine.py @@ -5,6 +5,7 @@ import logging import math from collections.abc import Callable +from typing import Any import pymunk from pyglet.math import Vec2 @@ -568,37 +569,29 @@ def add_collision_handler( self.collision_types.append(second_type) second_type_id = self.collision_types.index(second_type) - def _f1(arbiter, space, data): + def _f1(arbiter: pymunk.Arbiter, space: pymunk.Space, data: Any) -> None: sprite_a, sprite_b = self.get_sprites_from_arbiter(arbiter) should_process_collision = False if sprite_a is not None and sprite_b is not None and begin_handler is not None: should_process_collision = begin_handler(sprite_a, sprite_b, arbiter, space, data) - return should_process_collision + arbiter.process_collision = should_process_collision - def _f2(arbiter, space, data): + def _f2(arbiter: pymunk.Arbiter, space: pymunk.Space, data: Any) -> None: sprite_a, sprite_b = self.get_sprites_from_arbiter(arbiter) if sprite_a is not None and sprite_b is not None and post_handler is not None: post_handler(sprite_a, sprite_b, arbiter, space, data) - def _f3(arbiter, space, data): + def _f3(arbiter: pymunk.Arbiter, space: pymunk.Space, data: Any) -> None: sprite_a, sprite_b = self.get_sprites_from_arbiter(arbiter) - if pre_handler is not None: - return pre_handler(sprite_a, sprite_b, arbiter, space, data) + if sprite_a is not None and sprite_b is not None and pre_handler is not None: + arbiter.process_collision = pre_handler(sprite_a, sprite_b, arbiter, space, data) - def _f4(arbiter, space, data): + def _f4(arbiter: pymunk.Arbiter, space: pymunk.Space, data: Any) -> None: sprite_a, sprite_b = self.get_sprites_from_arbiter(arbiter) if separate_handler: separate_handler(sprite_a, sprite_b, arbiter, space, data) - h = self.space.add_collision_handler(first_type_id, second_type_id) - if begin_handler: - h.begin = _f1 - if post_handler: - h.post_solve = _f2 - if pre_handler: - h.pre_solve = _f3 - if separate_handler: - h.separate = _f4 + self.space.on_collision(first_type_id, second_type_id, _f1, _f3, _f2, _f4) def update_sprite(self, sprite: Sprite) -> None: """ @@ -783,7 +776,7 @@ def check_grounding(self, sprite: Sprite) -> dict: """ grounding = { "normal": pymunk.Vec2d.zero(), - "penetration": pymunk.Vec2d.zero(), + "penetration": 0.0, "impulse": pymunk.Vec2d.zero(), "position": pymunk.Vec2d.zero(), "body": None, @@ -813,7 +806,9 @@ def f(arbiter: pymunk.Arbiter): ): grounding["normal"] = n grounding["penetration"] = -arbiter.contact_point_set.points[0].distance - grounding["body"] = arbiter.shapes[1].body + # Mypy is making bad inferences about what this is based on the other elements + # and this doesn't particularly feel worth a TypedDict + grounding["body"] = arbiter.shapes[1].body # type: ignore grounding["impulse"] = arbiter.total_impulse grounding["position"] = arbiter.contact_point_set.points[0].point_b diff --git a/arcade/resources/system/shaders/atlas/resize_simple_vs.glsl b/arcade/resources/system/shaders/atlas/resize_simple_vs.glsl index 4daecf6e66..662f37829f 100644 --- a/arcade/resources/system/shaders/atlas/resize_simple_vs.glsl +++ b/arcade/resources/system/shaders/atlas/resize_simple_vs.glsl @@ -8,20 +8,19 @@ // Old and new texture coordinates uniform sampler2D atlas_old; -uniform sampler2D atlas_new; uniform sampler2D texcoords_old; uniform sampler2D texcoords_new; uniform mat4 projection; uniform float border; +uniform vec2 size_new; out vec2 uv; void main() { // Get the texture sizes ivec2 size_old = textureSize(atlas_old, 0).xy; - ivec2 size_new = textureSize(atlas_new, 0).xy; // Read texture coordinates from UV texture here int texture_id = gl_VertexID / 6; diff --git a/arcade/shape_list.py b/arcade/shape_list.py index 08d1437531..b6c784e509 100644 --- a/arcade/shape_list.py +++ b/arcade/shape_list.py @@ -17,10 +17,8 @@ cast, ) -import pyglet.gl as gl - from arcade import ArcadeContext, get_points_for_thick_line, get_window -from arcade.gl import Buffer, BufferDescription, Geometry, Program +from arcade.gl import Buffer, BufferDescription, Geometry, Program, enums from arcade.math import rotate_point from arcade.types import RGBA255, Color, Point, PointList from arcade.utils import copy_dunders_unimplemented @@ -72,7 +70,7 @@ def __init__( colors: Sequence[RGBA255], # vao: Geometry, # vbo: Buffer, - mode: int = gl.GL_TRIANGLES, + mode: int = enums.TRIANGLES, program: Program | None = None, ) -> None: self.ctx = get_window().ctx @@ -197,7 +195,7 @@ def create_line_strip(point_list: PointList, color: RGBA255, line_width: float = line_width: Width of the line """ if line_width == 1: - return create_line_generic(point_list, color, gl.GL_LINE_STRIP) + return create_line_generic(point_list, color, enums.LINE_STRIP) triangle_point_list: list[Point] = [] new_color_list: list[RGBA255] = [] @@ -245,7 +243,7 @@ def create_lines( point_list: A list of points that make up the shape. color: A color such as a :py:class:`~arcade.types.Color` """ - return create_line_generic(point_list, color, gl.GL_LINES) + return create_line_generic(point_list, color, enums.LINES) def create_lines_with_colors( @@ -263,7 +261,7 @@ def create_lines_with_colors( line_width: Width of the line """ if line_width == 1: - return create_line_generic_with_colors(point_list, color_list, gl.GL_LINES) + return create_line_generic_with_colors(point_list, color_list, enums.LINES) triangle_point_list: list[Point] = [] new_color_list: list[RGBA255] = [] @@ -308,7 +306,7 @@ def create_polygon(point_list: PointList, color: RGBA255) -> Shape: itertools.zip_longest(point_list[:half], reversed(point_list[half:])) ) point_list = [p for p in interleaved if p is not None] - return create_line_generic(point_list, color, gl.GL_TRIANGLE_STRIP) + return create_line_generic(point_list, color, enums.TRIANGLE_STRIP) def create_rectangle_filled( @@ -511,7 +509,7 @@ def create_rectangle( border_width = 1 - shape_mode = gl.GL_TRIANGLE_STRIP + shape_mode = enums.TRIANGLE_STRIP return create_line_generic(data, color, shape_mode) @@ -531,7 +529,7 @@ def create_rectangle_filled_with_colors(point_list, color_list) -> Shape: point_list: List of points to create the rectangle from color_list: List of colors to create the rectangle from """ - shape_mode = gl.GL_TRIANGLE_STRIP + shape_mode = enums.TRIANGLE_STRIP new_point_list = [point_list[0], point_list[1], point_list[3], point_list[2]] new_color_list = [color_list[0], color_list[1], color_list[3], color_list[2]] return create_line_generic_with_colors(new_point_list, new_color_list, shape_mode) @@ -553,7 +551,7 @@ def create_rectangles_filled_with_colors(point_list, color_list: Sequence[RGBA25 point_list: List of points to create the rectangles from color_list: List of colors to create the rectangles from """ - shape_mode = gl.GL_TRIANGLES + shape_mode = enums.TRIANGLES new_point_list: list[Point] = [] new_color_list: list[RGBA255] = [] for i in range(0, len(point_list), 4): @@ -590,7 +588,7 @@ def create_triangles_filled_with_colors( :py:class:`~arcade.types.Color` instance or a 4-length RGBA :py:class:`tuple`. """ - shape_mode = gl.GL_TRIANGLES + shape_mode = enums.TRIANGLES return create_line_generic_with_colors(point_list, color_sequence, shape_mode) @@ -618,7 +616,7 @@ def create_triangles_strip_filled_with_colors( :py:class:`~arcade.types.Color` instance or a 4-length RGBA :py:class:`tuple`. """ - shape_mode = gl.GL_TRIANGLE_STRIP + shape_mode = enums.TRIANGLE_STRIP return create_line_generic_with_colors(point_list, color_sequence, shape_mode) @@ -762,10 +760,10 @@ def create_ellipse( itertools.zip_longest(point_list[:half], reversed(point_list[half:])) ) point_list = [p for p in interleaved if p is not None] - shape_mode = gl.GL_TRIANGLE_STRIP + shape_mode = enums.TRIANGLE_STRIP else: point_list.append(point_list[0]) - shape_mode = gl.GL_LINE_STRIP + shape_mode = enums.LINE_STRIP return create_line_generic(point_list, color, shape_mode) @@ -818,7 +816,7 @@ def create_ellipse_filled_with_colors( point_list.append(point_list[1]) color_list = [inside_color] + [outside_color] * (num_segments + 1) - return create_line_generic_with_colors(point_list, color_list, gl.GL_TRIANGLE_FAN) + return create_line_generic_with_colors(point_list, color_list, enums.TRIANGLE_FAN) TShape = TypeVar("TShape", bound=Shape) diff --git a/arcade/sound.py b/arcade/sound.py index c371c11a01..e401ba4928 100644 --- a/arcade/sound.py +++ b/arcade/sound.py @@ -9,9 +9,14 @@ from pyglet.media import Source from arcade.resources import resolve +from arcade.utils import is_pyodide if os.environ.get("ARCADE_SOUND_BACKENDS"): pyglet.options.audio = tuple(v.strip() for v in os.environ["ARCADE_SOUND_BACKENDS"].split(",")) +elif is_pyodide(): + # Pyglet will also detect Pyodide and auto select the driver for it + # but the driver tuple needs to be empty for that to happen + pyglet.options.audio = () else: pyglet.options.audio = ("openal", "xaudio2", "directsound", "pulse", "silent") @@ -88,7 +93,7 @@ def play( pan: float = 0.0, loop: bool = False, speed: float = 1.0, - ) -> media.Player: + ) -> media.AudioPlayer: """Try to play this :py:class:`Sound` and return a |pyglet Player|. .. important:: A :py:class:`Sound` with ``streaming=True`` loses features! @@ -113,7 +118,7 @@ def play( " If you need more use a Static source." ) - player: media.Player = media.Player() + player: media.AudioPlayer = media.AudioPlayer() player.volume = volume player.position = ( pan, @@ -145,7 +150,7 @@ def _on_player_eos(): player.on_player_eos = _on_player_eos # type: ignore return player - def stop(self, player: media.Player) -> None: + def stop(self, player: media.AudioPlayer) -> None: """Stop and :py:meth:`~pyglet.media.player.Player.delete` ``player``. All references to it in the internal table for @@ -165,12 +170,12 @@ def get_length(self) -> float: # We validate that duration is known when loading the source return self.source.duration # type: ignore - def is_complete(self, player: media.Player) -> bool: + def is_complete(self, player: media.AudioPlayer) -> bool: """``True`` if the sound is done playing.""" # We validate that duration is known when loading the source return player.time >= self.source.duration # type: ignore - def is_playing(self, player: media.Player) -> bool: + def is_playing(self, player: media.AudioPlayer) -> bool: """``True`` if ``player`` is currently playing, otherwise ``False``. Args: @@ -182,7 +187,7 @@ def is_playing(self, player: media.Player) -> bool: """ return player.playing - def get_volume(self, player: media.Player) -> float: + def get_volume(self, player: media.AudioPlayer) -> float: """Get the current volume. Args: @@ -193,7 +198,7 @@ def get_volume(self, player: media.Player) -> float: """ return player.volume # type: ignore # pending https://github.com/pyglet/pyglet/issues/847 - def set_volume(self, volume: float, player: media.Player) -> None: + def set_volume(self, volume: float, player: media.AudioPlayer) -> None: """Set the volume of a sound as it is playing. Args: @@ -203,7 +208,7 @@ def set_volume(self, volume: float, player: media.Player) -> None: """ player.volume = volume - def get_stream_position(self, player: media.Player) -> float: + def get_stream_position(self, player: media.AudioPlayer) -> float: """Return where we are in the stream. This will reset back to zero when it is done playing. @@ -254,7 +259,7 @@ def play_sound( pan: float = 0.0, loop: bool = False, speed: float = 1.0, -) -> media.Player | None: +) -> media.AudioPlayer | None: """Try to play the ``sound`` and return a |pyglet Player|. The ``sound`` must be a loaded :py:class:`Sound` object. If you @@ -322,7 +327,7 @@ def play_sound( return None -def stop_sound(player: media.Player) -> None: +def stop_sound(player: media.AudioPlayer) -> None: """Stop and delete a |pyglet Player| which is currently playing. Args: @@ -330,7 +335,7 @@ def stop_sound(player: media.Player) -> None: or :py:meth:`Sound.play`. """ - if not isinstance(player, media.Player): + if not isinstance(player, media.AudioPlayer): raise TypeError( "stop_sound takes a media player object returned from the play_sound() command, not a " "loaded Sound object." diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index 09e8459ebb..3734b51e3e 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -8,6 +8,7 @@ from arcade.sprite import BasicSprite, SpriteType from arcade.types import Point from arcade.types.rect import Rect +from arcade.window_commands import get_window from .sprite_list import SpriteSequence @@ -174,10 +175,14 @@ def check_for_collision_with_list( # Spatial if sprite_list.spatial_hash is not None and (method == 1 or method == 0): sprites_to_check = sprite_list.spatial_hash.get_sprites_near_sprite(sprite) - elif method == 3 or (method == 0 and len(sprite_list) <= 1500): + elif ( + method == 3 + or (method == 0 and len(sprite_list) <= 1500) + or get_window().ctx._gl_api == "webgl" + ): sprites_to_check = sprite_list else: - # GPU transform + # GPU transform - Not on WebGL sprites_to_check = _get_nearby_sprites(sprite, sprite_list) return [ @@ -235,10 +240,14 @@ def check_for_collision_with_lists( # Spatial if sprite_list.spatial_hash is not None and (method == 1 or method == 0): sprites_to_check = sprite_list.spatial_hash.get_sprites_near_sprite(sprite) - elif method == 3 or (method == 0 and len(sprite_list) <= 1500): + elif ( + method == 3 + or (method == 0 and len(sprite_list) <= 1500) + or get_window().ctx._gl_api == "webgl" + ): sprites_to_check = sprite_list else: - # GPU transform + # GPU transform - Not on WebGL sprites_to_check = _get_nearby_sprites(sprite, sprite_list) for sprite2 in sprites_to_check: diff --git a/arcade/sprite_list/sprite_list.py b/arcade/sprite_list/sprite_list.py index 1eadcad1c8..d24641dea6 100644 --- a/arcade/sprite_list/sprite_list.py +++ b/arcade/sprite_list/sprite_list.py @@ -321,13 +321,15 @@ def _init_deferred(self) -> None: if not self._atlas: self._atlas = self.ctx.default_atlas - # NOTE: Instantiate the appropriate spritelist data class here - # Desktop GL (with geo shader) - self._data = SpriteListBufferData(self.ctx, capacity=self._buf_capacity, atlas=self._atlas) - # WebGL (without geo shader) - # self._data = SpriteListTextureData( - # self.ctx, capacity=self._buf_capacity, atlas=self._atlas - # ) + if self.ctx._gl_api == "webgl": + self._data = SpriteListTextureData( + self.ctx, capacity=self._buf_capacity, atlas=self._atlas + ) + else: + self._data = SpriteListBufferData( + self.ctx, capacity=self._buf_capacity, atlas=self._atlas + ) + self._initialized = True # Load all the textures and write texture coordinates into buffers. @@ -1683,21 +1685,30 @@ def get_nearby_sprite_indices(self, pos: Point, size: Point, length: int) -> lis A list of indices of nearby sprites. """ ctx = self.ctx - ctx.collision_detection_program["check_pos"] = pos - ctx.collision_detection_program["check_size"] = size + if ctx._gl_api == "webgl": + raise RuntimeError("GPU Collision is not supported on WebGL Backends") + + # All of these type ignores are because of GPU collision not being supported on WebGL + # Unfortuantely the type checkers don't have a sane way of understanding that, and it's + # not worth run-time checking all of these things, because they are guaranteed based on + # active GL api of the context. Pyright actually does seem to be able to figure it out + # but mypy does not + + ctx.collision_detection_program["check_pos"] = pos # type: ignore + ctx.collision_detection_program["check_size"] = size # type: ignore buffer = ctx.collision_buffer - with ctx.collision_query: - self._geometry.transform( # type: ignore - ctx.collision_detection_program, - buffer, + with ctx.collision_query: # type: ignore + self._geometry.transform( + ctx.collision_detection_program, # type: ignore + buffer, # type: ignore vertices=length, ) # Store the number of sprites emitted - emit_count = ctx.collision_query.primitives_generated + emit_count = ctx.collision_query.primitives_generated # type: ignore if emit_count == 0: return [] - return [i for i in struct.unpack(f"{emit_count}i", buffer.read(size=emit_count * 4))] + return [i for i in struct.unpack(f"{emit_count}i", buffer.read(size=emit_count * 4))] # type: ignore class SpriteListTextureData(SpriteListData): @@ -1884,23 +1895,32 @@ def get_nearby_sprite_indices(self, pos: Point, size: Point, length: int) -> lis A list of indices of nearby sprites. """ ctx = self.ctx + if ctx._gl_api == "webgl": + raise RuntimeError("GPU Collision is not supported on WebGL Backends") + + # All of these type ignores are because of GPU collision not being supported on WebGL + # Unfortuantely the type checkers don't have a sane way of understanding that, and it's + # not worth run-time checking all of these things, because they are guaranteed based on + # active GL api of the context. Pyright actually does seem to be able to figure it out + # but mypy does not + buffer = ctx.collision_buffer program = ctx.collision_detection_program_simple - program["check_pos"] = pos - program["check_size"] = size + program["check_pos"] = pos # type: ignore + program["check_size"] = size # type: ignore self._storage_pos_angle.use(0) self._storage_size.use(1) self._storage_index.use(2) - with ctx.collision_query: + with ctx.collision_query: # type: ignore ctx.geometry_empty.transform( - program, - buffer, + program, # type: ignore + buffer, # type: ignore vertices=length, ) - emit_count = ctx.collision_query.primitives_generated + emit_count = ctx.collision_query.primitives_generated # type: ignore # print(f"Collision query emitted {emit_count} sprites") if emit_count == 0: return [] - return [i for i in struct.unpack(f"{emit_count}i", buffer.read(size=emit_count * 4))] + return [i for i in struct.unpack(f"{emit_count}i", buffer.read(size=emit_count * 4))] # type: ignore diff --git a/arcade/text.py b/arcade/text.py index 60154c90c4..d9bdbd46a3 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -2,11 +2,15 @@ Drawing text with pyglet label """ -from ctypes import c_int, c_ubyte from pathlib import Path from typing import Any import pyglet +from pyglet.enums import Style, Weight + +# Pyright can't figure out the dynamic backend imports in pyglet.graphics +# right now. Maybe can fix in future Pyglet version +from pyglet.graphics import Batch, Group # type: ignore import arcade from arcade.exceptions import PerformanceWarning, warning @@ -18,62 +22,6 @@ __all__ = ["load_font", "Text", "create_text_sprite", "draw_text"] -class _ArcadeTextLayoutGroup(pyglet.text.layout.TextLayoutGroup): - """Create a text layout rendering group. - - Overrides pyglet blending handling to allow for additive blending. - Furthermore, it resets the blend function to the previous state. - """ - - _prev_blend: bool - _prev_blend_func: tuple[int, int, int, int] - - def set_state(self) -> None: - self.program.use() - self.program["scissor"] = False - - pyglet.gl.glActiveTexture(pyglet.gl.GL_TEXTURE0) - pyglet.gl.glBindTexture(self.texture.target, self.texture.id) - - blend = c_ubyte() - pyglet.gl.glGetBooleanv(pyglet.gl.GL_BLEND, blend) - self._prev_blend = bool(blend.value) - - src_rgb = c_int() - dst_rgb = c_int() - src_alpha = c_int() - dst_alpha = c_int() - pyglet.gl.glGetIntegerv(pyglet.gl.GL_BLEND_SRC_RGB, src_rgb) - pyglet.gl.glGetIntegerv(pyglet.gl.GL_BLEND_DST_RGB, dst_rgb) - pyglet.gl.glGetIntegerv(pyglet.gl.GL_BLEND_SRC_ALPHA, src_alpha) - pyglet.gl.glGetIntegerv(pyglet.gl.GL_BLEND_DST_ALPHA, dst_alpha) - - self._prev_blend_func = (src_rgb.value, dst_rgb.value, src_alpha.value, dst_alpha.value) - - pyglet.gl.glEnable(pyglet.gl.GL_BLEND) - pyglet.gl.glBlendFuncSeparate( - pyglet.gl.GL_SRC_ALPHA, - pyglet.gl.GL_ONE_MINUS_SRC_ALPHA, - pyglet.gl.GL_ONE, - pyglet.gl.GL_ONE, - ) - - def unset_state(self) -> None: - if not self._prev_blend: - pyglet.gl.glDisable(pyglet.gl.GL_BLEND) - - pyglet.gl.glBlendFuncSeparate( - self._prev_blend_func[0], - self._prev_blend_func[1], - self._prev_blend_func[2], - self._prev_blend_func[3], - ) - self.program.stop() - - -pyglet.text.layout.TextLayout.group_class = _ArcadeTextLayoutGroup - - def load_font(path: str | Path) -> None: """ Load fonts in a file (usually .ttf) adding them to a global font registry. @@ -272,8 +220,8 @@ def __init__( anchor_y: str = "baseline", multiline: bool = False, rotation: float = 0, - batch: pyglet.graphics.Batch | None = None, - group: pyglet.graphics.Group | None = None, + batch: Batch | None = None, + group: Group | None = None, z: float = 0, **kwargs, ): @@ -286,8 +234,8 @@ def __init__( width=width, align=align, font_name=font_name, - weight=pyglet.text.Weight.BOLD if bold else pyglet.text.Weight.NORMAL, - italic=italic, + weight=Weight.BOLD if bold else Weight.NORMAL, + style=Style.ITALIC if italic else Style.NORMAL, anchor_x=anchor_x, anchor_y=anchor_y, multiline=multiline, @@ -353,7 +301,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.label.end_update() @property - def batch(self) -> pyglet.graphics.Batch | None: + def batch(self) -> Batch | None: """The batch this text is in, if any. Can be unset by setting to ``None``. @@ -361,11 +309,11 @@ def batch(self) -> pyglet.graphics.Batch | None: return self.label.batch @batch.setter - def batch(self, batch: pyglet.graphics.Batch): + def batch(self, batch: Batch): self.label.batch = batch @property - def group(self) -> pyglet.graphics.Group | None: + def group(self) -> Group | None: """ The specific group in a batch the text should belong to. @@ -376,7 +324,7 @@ def group(self) -> pyglet.graphics.Group | None: return self.label.group @group.setter - def group(self, group: pyglet.graphics.Group): + def group(self, group: Group): self.label.group = group @property @@ -622,11 +570,11 @@ def bold(self) -> bool | str: * ``"light"`` """ - return self.label.weight == pyglet.text.Weight.BOLD + return self.label.weight == Weight.BOLD @bold.setter def bold(self, bold: bool | str): - self.label.weight = pyglet.text.Weight.BOLD if bold else pyglet.text.Weight.NORMAL + self.label.weight = Weight.BOLD if bold else Weight.NORMAL @property def italic(self) -> bool | str: diff --git a/arcade/texture_atlas/atlas_default.py b/arcade/texture_atlas/atlas_default.py index 9bca6a531f..3820798be5 100644 --- a/arcade/texture_atlas/atlas_default.py +++ b/arcade/texture_atlas/atlas_default.py @@ -15,7 +15,7 @@ import PIL.Image from PIL import Image, ImageDraw from PIL.Image import Resampling -from pyglet.image.atlas import ( +from pyglet.graphics.atlas import ( Allocator, AllocatorException, ) @@ -712,10 +712,10 @@ def resize(self, size: tuple[int, int], force=False) -> None: # Bind textures for atlas copy shader atlas_texture_old.use(0) - self._texture.use(1) - image_uvs_old.texture.use(2) - self._image_uvs.texture.use(3) + image_uvs_old.texture.use(1) + self._image_uvs.texture.use(2) self._ctx.atlas_resize_program["border"] = float(self._border) + self._ctx.atlas_resize_program["size_new"] = size self._ctx.atlas_resize_program["projection"] = Mat4.orthogonal_projection( 0, self.width, diff --git a/arcade/utils.py b/arcade/utils.py index 7be6757ae1..ec77699658 100644 --- a/arcade/utils.py +++ b/arcade/utils.py @@ -256,6 +256,8 @@ def __deepcopy__(self, memo): # noqa def is_pyodide() -> bool: + if sys.platform == "emscripten": + return True return False diff --git a/doc/tutorials/views/01_views.py b/doc/tutorials/views/01_views.py index a1b12cdddc..8ff15556a5 100644 --- a/doc/tutorials/views/01_views.py +++ b/doc/tutorials/views/01_views.py @@ -29,7 +29,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.set_mouse_visible(False) + self.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/doc/tutorials/views/02_views.py b/doc/tutorials/views/02_views.py index b189a8f81c..07d83798c1 100644 --- a/doc/tutorials/views/02_views.py +++ b/doc/tutorials/views/02_views.py @@ -29,7 +29,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.window.background_color = arcade.color.AMAZON diff --git a/doc/tutorials/views/03_views.py b/doc/tutorials/views/03_views.py index 96573e2a49..1226b87d67 100644 --- a/doc/tutorials/views/03_views.py +++ b/doc/tutorials/views/03_views.py @@ -55,7 +55,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.window.background_color = arcade.color.AMAZON diff --git a/doc/tutorials/views/04_views.py b/doc/tutorials/views/04_views.py index bea6f38dab..2fb2435702 100644 --- a/doc/tutorials/views/04_views.py +++ b/doc/tutorials/views/04_views.py @@ -96,7 +96,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/doc/tutorials/views/index.rst b/doc/tutorials/views/index.rst index 3cb47bf2c9..b3d5477861 100644 --- a/doc/tutorials/views/index.rst +++ b/doc/tutorials/views/index.rst @@ -71,13 +71,13 @@ class. Change: .. code-block:: python - self.set_mouse_visible(False) + self.set_mouse_cursor_visible(False) to: .. code-block:: python - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) Now in the ``main`` function, instead of just creating a window, we'll create a window, a view, and then show that view. diff --git a/index.html b/index.html new file mode 100644 index 0000000000..02a2256e42 --- /dev/null +++ b/index.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/make.py b/make.py index e98ff13517..6dba3e3a11 100755 --- a/make.py +++ b/make.py @@ -207,6 +207,16 @@ def serve(): ) +@app.command(rich_help_panel="Docs") +def docs_full(): + """ + Build the documentation fully and error on warnings. This is what is checked in CI. + """ + run_doc([SPHINX_BUILD, DOC_DIR, "build", "-W"]) + print() + print("Build finished") + + @app.command(rich_help_panel="Docs") def linkcheck(): """ diff --git a/pyproject.toml b/pyproject.toml index 9671ae48dd..ec18226f4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,9 +20,9 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "pyglet~=2.1.5", + "pyglet==3.0.dev1", "pillow~=12.0.0", - "pymunk~=6.9.0", + "pymunk~=7.2.0", "pytiled-parser~=2.2.9", ] dynamic = ["version"] @@ -35,7 +35,7 @@ Issues = "https://github.com/pythonarcade/arcade/issues" Source = "https://github.com/pythonarcade/arcade" Book = "https://learn.arcade.academy" -[project.optional-dependencies] +[dependency-groups] # Used for dev work dev = [ "sphinx==8.1.3", # April 2024 | Updated 2024-07-15, 7.4+ is broken with sphinx-autobuild @@ -62,6 +62,7 @@ dev = [ "click==8.1.7", # Temp fix until we bump typer "typer==0.12.5", # Needed for make.py "wheel", + "bottle" # Used for web testing playground ] # Testing only testing_libraries = ["pytest", "pytest-mock", "pytest-cov", "pyyaml==6.0.1"] diff --git a/tests/conftest.py b/tests/conftest.py index 513f5c9baf..effb265533 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -339,8 +339,8 @@ def get_framebuffer_size(self): def get_pixel_ratio(self): return self.window.get_pixel_ratio() - def set_mouse_visible(self, visible): - self.window.set_mouse_visible(visible) + def set_mouse_cursor_visible(self, visible): + self.window.set_mouse_cursor_visible(visible) def center_window(self): self.window.center_window() diff --git a/tests/manual_smoke/sprite_collision_inspector.py b/tests/manual_smoke/sprite_collision_inspector.py index b38b725c39..efcaa5fb2f 100644 --- a/tests/manual_smoke/sprite_collision_inspector.py +++ b/tests/manual_smoke/sprite_collision_inspector.py @@ -180,7 +180,7 @@ def __init__(self, width: int = 1280, height: int = 720, grid_tile_px: int = 100 # self.spritelist.append(sprite) self.build_sprite_grid(8, 12, self.grid_tile_px, Vec2(50, 50)) self.background_color = arcade.color.DARK_GRAY - self.set_mouse_visible(False) + self.set_mouse_cursor_visible(False) self.cursor = 0, 0 self.from_mouse = True self.on_widget = False @@ -206,7 +206,7 @@ def on_update(self, dt: float = 1 / 60): on_widget = bool(len(widgets)) if self.on_widget != on_widget: - self.set_mouse_visible(on_widget) + self.set_mouse_cursor_visible(on_widget) self.on_widget = on_widget def on_draw(self): diff --git a/tests/unit/atlas/test_basics.py b/tests/unit/atlas/test_basics.py index 7cfcf0bea8..8aa71c5a98 100644 --- a/tests/unit/atlas/test_basics.py +++ b/tests/unit/atlas/test_basics.py @@ -1,6 +1,6 @@ import PIL.Image import pytest -from pyglet.image.atlas import AllocatorException +from pyglet.graphics.atlas import AllocatorException import arcade from arcade import DefaultTextureAtlas, load_texture from arcade.gl import Texture2D, Framebuffer diff --git a/tests/unit/atlas/test_rebuild_resize.py b/tests/unit/atlas/test_rebuild_resize.py index 0b381c6911..dc044cfcb1 100644 --- a/tests/unit/atlas/test_rebuild_resize.py +++ b/tests/unit/atlas/test_rebuild_resize.py @@ -1,6 +1,6 @@ import PIL.Image import pytest -from pyglet.image.atlas import AllocatorException +from pyglet.graphics.atlas import AllocatorException import arcade from arcade import DefaultTextureAtlas, load_texture diff --git a/tests/unit/gl/backends/gl/test_gl_program.py b/tests/unit/gl/backends/gl/test_gl_program.py index 5a7971e2ce..bca04fc51e 100644 --- a/tests/unit/gl/backends/gl/test_gl_program.py +++ b/tests/unit/gl/backends/gl/test_gl_program.py @@ -1,7 +1,7 @@ import struct import pytest import arcade -from pyglet import gl +from pyglet.graphics.api import gl from pyglet.math import Mat4, Mat3 from arcade.gl import ShaderException from arcade.gl.backends.opengl.uniform import UniformBlock diff --git a/tests/unit/gl/test_gl_types.py b/tests/unit/gl/test_gl_types.py index b4da8851cd..2748daab84 100644 --- a/tests/unit/gl/test_gl_types.py +++ b/tests/unit/gl/test_gl_types.py @@ -1,5 +1,5 @@ import pytest -from pyglet import gl +from pyglet.graphics.api import gl from arcade.gl import types diff --git a/tests/unit/shape_list/test_buffered_drawing.py b/tests/unit/shape_list/test_buffered_drawing.py index 715b95f258..8771187ede 100644 --- a/tests/unit/shape_list/test_buffered_drawing.py +++ b/tests/unit/shape_list/test_buffered_drawing.py @@ -15,7 +15,7 @@ create_line_generic, create_line_strip, ) -import pyglet.gl as gl +import pyglet.graphics.api.gl as gl SCREEN_WIDTH = 800 SCREEN_HEIGHT = 600 diff --git a/tests/unit/test_example_docstrings.py b/tests/unit/test_example_docstrings.py index f70bb76c1e..71c99630c3 100644 --- a/tests/unit/test_example_docstrings.py +++ b/tests/unit/test_example_docstrings.py @@ -62,6 +62,8 @@ def check_submodules(parent_module_absolute_name: str) -> None: # Check all modules nested immediately inside it on the file system for finder, child_module_name, is_pkg in pkgutil.iter_modules(parent_module_file_path): + if is_pkg: + continue child_module_file_path = Path(finder.path) / f"{child_module_name}.py" child_module_absolute_name = f"{parent_module_absolute_name}.{child_module_name}" diff --git a/tests/unit/window/test_window.py b/tests/unit/window/test_window.py index 1ba75f4dc6..02d69fcb0e 100644 --- a/tests/unit/window/test_window.py +++ b/tests/unit/window/test_window.py @@ -31,7 +31,7 @@ def test_window(window: arcade.Window): w.background_color = 255, 255, 255, 255 assert w.background_color == (255, 255, 255, 255) - w.set_mouse_visible(True) + w.set_mouse_cursor_visible(True) w.set_size(width, height) v = window.ctx.viewport diff --git a/util/update_quick_index.py b/util/update_quick_index.py index b4fca31cc2..534e0aacef 100644 --- a/util/update_quick_index.py +++ b/util/update_quick_index.py @@ -269,14 +269,6 @@ "future.rst": { "title": "Future Features", "use_declarations_in": [ - "arcade.future.texture_render_target", - "arcade.future.input.inputs", - "arcade.future.input.manager", - "arcade.future.input.input_mapping", - "arcade.future.input.raw_dicts", - "arcade.future.background.background_texture", - "arcade.future.background.background", - "arcade.future.background.groups", "arcade.future.light.lights", "arcade.future.video.video_player", ], diff --git a/webplayground/README.md b/webplayground/README.md new file mode 100644 index 0000000000..df6b4b0df5 --- /dev/null +++ b/webplayground/README.md @@ -0,0 +1,23 @@ +# Arcade Web Testing +This directory contains a utility for early testing of Arcade in web browsers. + +An http server is provided with the `server.py` file. This file can be run with `python server.py` and will serve a local HTTP server on port 8000. + +The index page will provide a list of all Arcade examples. This is generated dynamically on the fly when the page is loaded, and will show all examples in the `arcade.examples` package. This generates links which can be followed to open any example in the browser. + +There are some pre-requesites to running this server. It assumes that you have the `development` branch of Pyglet +checked out and in a folder named `pyglet` directly next to your Arcade repo directory. You will also need to have +the `build` and `flit` packages from PyPi installed. These are used by Pyglet and Arcade to build wheel files, +but are not generally installed for local development. + +Assuming you have Pyglet ready to go, you can then start the server. It will build wheels for both Pyglet and Arcade, and copy them +into this directory. This means that if you make any, you will need to restart this server in order to build new wheels. + +## How does this work? + +The web server itself is built with a nice little HTTP server library named [Bottle](https://github.com/bottlepy/bottle). We need to run an HTTP server locally +to load anything into WASM in the browser, it will not work if we just server it files directly due to browser security constraints. For the Arcade examples specifically, +we are taking advantage of the fact that the example code is packaged directly inside of Arcade to enable executing them in the browser. + +If we need to add extra code that is not part of the Arcade package, that will require extension of this server to handle packaging it properly for loading into WASM, and then +serving that package. \ No newline at end of file diff --git a/webplayground/example.tpl b/webplayground/example.tpl new file mode 100644 index 0000000000..1288c96c7e --- /dev/null +++ b/webplayground/example.tpl @@ -0,0 +1,31 @@ + + + + + % title = name.split(".")[-1] + {{title}} + + + + + + + + \ No newline at end of file diff --git a/webplayground/index.tpl b/webplayground/index.tpl new file mode 100644 index 0000000000..4c5a8e11b5 --- /dev/null +++ b/webplayground/index.tpl @@ -0,0 +1,16 @@ + + + + + Arcade Examples + + + +
    + % for item in examples: +
  • {{item}}
  • + % end +
+ + + \ No newline at end of file diff --git a/webplayground/server.py b/webplayground/server.py new file mode 100644 index 0000000000..4aba5090f7 --- /dev/null +++ b/webplayground/server.py @@ -0,0 +1,72 @@ +#! /usr/bin/env python + +import importlib +import os +import pkgutil +import shutil +import subprocess +import sys +from pathlib import Path + +from bottle import route, run, static_file, template # type: ignore + +from arcade import examples + +here = Path(__file__).parent.resolve() + +path_arcade = Path("../") +arcade_wheel_filename = "arcade-4.0.0.dev1-py3-none-any.whl" +path_arcade_wheel = path_arcade / "dist" / arcade_wheel_filename + + +def find_modules(module): + path_list = [] + spec_list = [] + for importer, modname, ispkg in pkgutil.walk_packages(module.__path__): + import_path = f"{module.__name__}.{modname}" + if ispkg: + pkg = importlib.import_module(import_path) + path_list.extend(find_modules(pkg)) + else: + path_list.append(import_path) + for spec in spec_list: + del sys.modules[spec.name] + return path_list + + +@route("/static/") +def whl(filepath): + return static_file(filepath, root="./") + + +@route("/") +def index(): + examples_list = find_modules(examples) + return template("index.tpl", examples=examples_list) + + +@route("/example") +@route("/example/") +def example(name="platform_tutorial.01_open_window"): + return template( + "example.tpl", + name=name, + arcade_wheel=arcade_wheel_filename, + ) + + +def main(): + # Get us in this file's parent directory + os.chdir(here) + + # Go to arcade and build a wheel + os.chdir(path_arcade) + subprocess.run(["python", "-m", "build", "--wheel", "--outdir", "dist"]) + os.chdir(here) + shutil.copy(path_arcade_wheel, f"./{arcade_wheel_filename}") + + run(host="localhost", port=8000) + + +if __name__ == "__main__": + main() From f020c83f278938da6fbc27c364b19bc5db10970a Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Fri, 26 Dec 2025 21:14:30 -0500 Subject: [PATCH 11/31] More GitHub Actions Cleanup for 4.0 (#2803) --- .github/workflows/code_quality.yml | 20 +++-- .github/workflows/create_commit_note_log.yml | 58 -------------- .github/workflows/docs.yml | 35 -------- .github/workflows/push_build_to_prod_pypi.yml | 80 ++++++++++++------- .github/workflows/push_build_to_test_pypi.yml | 35 -------- .github/workflows/status_embed.yaml | 4 +- 6 files changed, 64 insertions(+), 168 deletions(-) delete mode 100644 .github/workflows/create_commit_note_log.yml delete mode 100644 .github/workflows/docs.yml delete mode 100644 .github/workflows/push_build_to_test_pypi.yml diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index 77b1cff767..f853b24ac5 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -11,7 +11,7 @@ on: jobs: - build: + lints: name: Code Inspections runs-on: ubuntu-latest @@ -51,8 +51,14 @@ jobs: continue-on-error: true run: uv run make.py pyright - verifytypes: - name: Verify Types + - name: Build Docs + continue-on-error: true + run: uv run make.py docs-full + + # This is a second job instead of an extra step because it takes the longest + # So having it as a second job lets it run in parallel to the other checks. + docs: + name: Build Documentation runs-on: ubuntu-latest steps: @@ -68,10 +74,8 @@ jobs: with: enable-cache: true - - name: Sync UV project + - name: Sync UV Project run: uv sync - - name: Pyright Type Completeness - # Suppress exit code because we do not expect to reach 100% type completeness any time soon - run: uv run pyright --verifytypes arcade || true - + - name: build-docs + run: uv run make.py docs-full diff --git a/.github/workflows/create_commit_note_log.yml b/.github/workflows/create_commit_note_log.yml deleted file mode 100644 index 58bbe5c1cb..0000000000 --- a/.github/workflows/create_commit_note_log.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Create Commit Note Log - -on: - push: - tags: - - "*" - workflow_dispatch: - inputs: - git-tag: - description: tag to create notes off of - required: true - -jobs: - create-commit-note-log: - name: Create Commit Note Log - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set tag from input - id: tag-input - if: github.event_name == 'workflow_dispatch' - run: |- - tag=${{ github.event.inputs.git-tag }} - if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then - echo ::set-output name=tag::${{ github.event.inputs.git-tag }} - else - echo "Tag not found" - exit 1 - fi - - name: Set tag from commit - id: tag-commit - if: github.event_name != 'workflow_dispatch' - run: |- - git_ref="${GITHUB_REF#refs/*/}" - echo ::set-output name=tag::$git_ref - - name: Get Git commit history - id: git-commit - run: |- - current_tag=${{ steps.tag-input.outputs.tag || steps.tag-commit.outputs.tag }} - previous_tag=$(git describe --abbrev=0 --match "*" --tags $current_tag^) - echo "current_tag=$current_tag" - echo "previous_tag=$previous_tag" - echo "commit_history" - echo "==============" - while read -r; - do - echo "- $REPLY" | tee -a body.md - done < <(git log --pretty=oneline --abbrev-commit --decorate-refs-exclude=refs/tags $current_tag...$previous_tag) - echo "==============" - echo ::set-output name=tag::$current_tag - - uses: ncipollo/release-action@v1 - with: - bodyFile: "body.md" - tag: ${{ steps.git-commit.outputs.tag }} - allowUpdates: true \ No newline at end of file diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index fe60bc4499..0000000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,35 +0,0 @@ -# Builds the doc in PRs - -name: Docs Build - -on: - push: - branches: [development, maintenance] - pull_request: - branches: [development, maintenance] - workflow_dispatch: - -jobs: - - build: - name: Build Documentation - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v5 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Install UV - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - - - name: Sync UV Project - run: uv sync - - - name: build-docs - run: uv run make.py docs-full diff --git a/.github/workflows/push_build_to_prod_pypi.yml b/.github/workflows/push_build_to_prod_pypi.yml index 7091b7d873..5bf6526b62 100644 --- a/.github/workflows/push_build_to_prod_pypi.yml +++ b/.github/workflows/push_build_to_prod_pypi.yml @@ -1,38 +1,58 @@ -name: Distribute build to PyPi Production - on: - workflow_dispatch: - inputs: - tag: - description: 'Tag to deploy' - required: true - type: string - -jobs: + push: + tags: + - '*' - # --- Deploy to pypi - deploy-to-pypi-prod: +name: Create a Release +jobs: + run: runs-on: ubuntu-latest - environment: deploy-pypi-prod + environment: + name: deploy-pypi-prod + permissions: + id-token: write + contents: read steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-tags: 'true' - ref: ${{ github.event.inputs.tag }} - - name: Set up Python + - uses: actions/checkout@v5 + + - name: Setup Python uses: actions/setup-python@v5 with: - python-version: 3.x - - name: Install dependencies - run: >- - python -m pip install build twine - - name: Build and Upload to Prod PyPI + python-version: '3.14' + + - name: Install UV + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Build Project + run: uv build + + - name: Publish to PyPi + run: uv publish + + - name: Generate Release Notes run: | - python -m build --sdist --wheel --outdir dist/ - python3 -m twine upload dist/* - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.TWINE_PROD_TOKEN }} - TWINE_REPOSITORY: pypi + current_tag=${{ github.ref_name }} + previous_tag=$(git describe --abbrev=0 --match "*" --tags $current_tag^) + echo "current_tag=$current_tag" + echo "previous_tag=$previous_tag" + echo "commit_history" + echo "==============" + while read -r; + do + echo "- $REPLY" | tee -a body.md + done < <(git log --pretty=oneline --abbrev-commit --decorate-refs-exclude=refs/tags $current_tag...$previous_tag) + echo "==============" + + - name: Publish GitHub Release + uses: ncipollo/release-action@v1 + with: + bodyFile: "body.md" + tag: ${{ github.ref_name }} + + + + + \ No newline at end of file diff --git a/.github/workflows/push_build_to_test_pypi.yml b/.github/workflows/push_build_to_test_pypi.yml deleted file mode 100644 index 491627115a..0000000000 --- a/.github/workflows/push_build_to_test_pypi.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Distribute build to PyPi Test - -on: - workflow_dispatch: - -jobs: - # --- Bump version - # ( this is manual until we find a better-tested, maintained bump action ) - - # --- Deploy to pypi - deploy-to-pypi-test: - - runs-on: ubuntu-latest - environment: deploy-pypi-test - needs: bump-version - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-tags: 'true' - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.x - - name: Install dependencies - run: >- - python3 -m pip install build twine - - name: Build and Upload to Test PyPI - run: | - python3 -m build --sdist --wheel --outdir dist/ - python3 -m twine upload dist/* - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.TWINE_TEST_TOKEN }} - TWINE_REPOSITORY: testpypi diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml index 41ab5e29ff..f8027c4ca8 100644 --- a/.github/workflows/status_embed.yaml +++ b/.github/workflows/status_embed.yaml @@ -3,8 +3,8 @@ name: Status Embed on: workflow_run: workflows: - - GitHub Ubuntu test - - Windows self-hosted test + - PyTest + - Code Quality types: - completed From ab5ca569770550d6a39926517b66ea7eb540a90d Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Fri, 26 Dec 2025 21:19:21 -0500 Subject: [PATCH 12/31] Dependency Cleanup for WASM environments (#2804) * Make pymunk optional * Some more dependency cleanup for WASM environment --- arcade/__init__.py | 4 +++- arcade/hitbox/__init__.py | 10 ++++++---- pyproject.toml | 10 +++++++--- webplayground/example.tpl | 7 +++---- webplayground/server.py | 2 +- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/arcade/__init__.py b/arcade/__init__.py index e10cf5a079..dbf9f2bad7 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -192,10 +192,12 @@ def configure_logging(level: int | None = None): from .tilemap import load_tilemap from .tilemap import TileMap -if sys.platform != "emscripten": +try: from .pymunk_physics_engine import PymunkPhysicsEngine from .pymunk_physics_engine import PymunkPhysicsObject from .pymunk_physics_engine import PymunkException +except ImportError: + pass from .version import VERSION diff --git a/arcade/hitbox/__init__.py b/arcade/hitbox/__init__.py index d8881c4bff..01086e7a9b 100644 --- a/arcade/hitbox/__init__.py +++ b/arcade/hitbox/__init__.py @@ -1,7 +1,6 @@ from PIL.Image import Image from arcade.types import Point2List -from arcade.utils import is_pyodide from .base import HitBox, HitBoxAlgorithm, RotatableHitBox from .bounding_box import BoundingHitBoxAlgorithm @@ -10,12 +9,15 @@ #: The simple hit box algorithm. algo_simple = SimpleHitBoxAlgorithm() -#: The detailed hit box algorithm. -if not is_pyodide(): +#: The detailed hit box algorithm. This depends on pymunk and will fallback to the simple algorithm. +try: from .pymunk import PymunkHitBoxAlgorithm - algo_detailed = PymunkHitBoxAlgorithm() +except ImportError: + print("WARNING: Running without PyMunk. The detailed hitbox algorithm will fallback to simple") + algo_detailed = SimpleHitBoxAlgorithm() + #: The bounding box hit box algorithm. algo_bounding_box = BoundingHitBoxAlgorithm() diff --git a/pyproject.toml b/pyproject.toml index ec18226f4f..dc46ffefff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,7 @@ classifiers = [ ] dependencies = [ "pyglet==3.0.dev1", - "pillow~=12.0.0", - "pymunk~=7.2.0", + "pillow>=11.3.0", "pytiled-parser~=2.2.9", ] dynamic = ["version"] @@ -36,6 +35,9 @@ Source = "https://github.com/pythonarcade/arcade" Book = "https://learn.arcade.academy" [dependency-groups] +extras = [ + "pymunk~=7.2.0" +] # Used for dev work dev = [ "sphinx==8.1.3", # April 2024 | Updated 2024-07-15, 7.4+ is broken with sphinx-autobuild @@ -62,8 +64,10 @@ dev = [ "click==8.1.7", # Temp fix until we bump typer "typer==0.12.5", # Needed for make.py "wheel", - "bottle" # Used for web testing playground + "bottle", # Used for web testing playground + {include-group = "extras"} ] + # Testing only testing_libraries = ["pytest", "pytest-mock", "pytest-cov", "pyyaml==6.0.1"] diff --git a/webplayground/example.tpl b/webplayground/example.tpl index 1288c96c7e..1c686f9e61 100644 --- a/webplayground/example.tpl +++ b/webplayground/example.tpl @@ -4,7 +4,7 @@ % title = name.split(".")[-1] {{title}} - + @@ -13,9 +13,8 @@ let pyodide = await loadPyodide(); await pyodide.loadPackage("micropip"); const micropip = pyodide.pyimport("micropip"); - await pyodide.loadPackage("pillow"); // Arcade needs Pillow - await micropip.install("pyglet==3.0.dev1", pre=true) - await micropip.install("http://localhost:8000/static/{{arcade_wheel}}"); + await pyodide.loadPackage("pillow"); + await micropip.install("http://localhost:8000/static/{{arcade_wheel}}", pre=true); // We are importing like this because some example files have numbers in the name, and you can't use those in normal import statements pyodide.runPython(` diff --git a/webplayground/server.py b/webplayground/server.py index 4aba5090f7..1a4b1c7566 100644 --- a/webplayground/server.py +++ b/webplayground/server.py @@ -61,7 +61,7 @@ def main(): # Go to arcade and build a wheel os.chdir(path_arcade) - subprocess.run(["python", "-m", "build", "--wheel", "--outdir", "dist"]) + subprocess.run(["uv", "build"]) os.chdir(here) shutil.copy(path_arcade_wheel, f"./{arcade_wheel_filename}") From 34b13efb7ebfea421b52160ddb9aa710e2cb2090 Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Sat, 27 Dec 2025 03:54:21 -0500 Subject: [PATCH 13/31] Remove pixel scaling for default framebuffer from WebGL backend (#2805) --- arcade/gl/backends/webgl/framebuffer.py | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/arcade/gl/backends/webgl/framebuffer.py b/arcade/gl/backends/webgl/framebuffer.py index a5bf4a18cf..a4d30975b3 100644 --- a/arcade/gl/backends/webgl/framebuffer.py +++ b/arcade/gl/backends/webgl/framebuffer.py @@ -256,7 +256,7 @@ def __init__(self, ctx: WebGLContext): @DefaultFrameBuffer.viewport.setter def viewport(self, value: tuple[int, int, int, int]): - # This is the exact same as the WebGLFramebuffer setter + # This is very similar to the OpenGL backend setter # WebGL backend doesn't need to handle pixel scaling for the # default framebuffer like desktop does, the browser does that # for us. However we need a separate implementation for the @@ -264,13 +264,7 @@ def viewport(self, value: tuple[int, int, int, int]): if not isinstance(value, tuple) or len(value) != 4: raise ValueError("viewport shouldbe a 4-component tuple") - ratio = self.ctx.window.get_pixel_ratio() - self._viewport = ( - int(value[0] * ratio), - int(value[1] * ratio), - int(value[2] * ratio), - int(value[3] * ratio), - ) + self._viewport = value if self._ctx.active_framebuffer == self: self._ctx._gl.viewport(*self._viewport) @@ -281,23 +275,11 @@ def viewport(self, value: tuple[int, int, int, int]): @DefaultFrameBuffer.scissor.setter def scissor(self, value): - # This is the exact same as the WebGLFramebuffer setter - # WebGL backend doesn't need to handle pixel scaling for the - # default framebuffer like desktop does, the browser does that - # for us. However we need a separate implementation for the - # function because of ABC if value is None: self._scissor = None if self._ctx.active_framebuffer == self: self._ctx._gl.scissor(*self._viewport) else: - ratio = self.ctx.window.get_pixel_ratio() - self._scissor = ( - int(value[0] * ratio), - int(value[1] * ratio), - int(value[2] * ratio), - int(value[3] * ratio), - ) - + self._scissor = value if self._ctx.active_framebuffer == self: self._ctx._gl.scissor(*self._scissor) From 9984b18323bed34d98eabb1e52e957e380a1fdfa Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sun, 28 Dec 2025 03:31:29 +0100 Subject: [PATCH 14/31] Simplify web tinkering (#2806) * Remove pixel scaling for default framebuffer from WebGL backend * Add local scripts support for testing in the web environment, update web readme --------- Co-authored-by: Darren Eberly --- webplayground/README.md | 26 +++++-- webplayground/index.tpl | 23 ++++++ webplayground/local.tpl | 75 +++++++++++++++++++ webplayground/local_run.tpl | 80 +++++++++++++++++++++ webplayground/local_scripts/README.md | 59 +++++++++++++++ webplayground/local_scripts/example_test.py | 43 +++++++++++ webplayground/server.py | 42 ++++++++++- 7 files changed, 340 insertions(+), 8 deletions(-) create mode 100644 webplayground/local.tpl create mode 100644 webplayground/local_run.tpl create mode 100644 webplayground/local_scripts/README.md create mode 100644 webplayground/local_scripts/example_test.py diff --git a/webplayground/README.md b/webplayground/README.md index df6b4b0df5..1491686e64 100644 --- a/webplayground/README.md +++ b/webplayground/README.md @@ -5,18 +5,30 @@ An http server is provided with the `server.py` file. This file can be run with The index page will provide a list of all Arcade examples. This is generated dynamically on the fly when the page is loaded, and will show all examples in the `arcade.examples` package. This generates links which can be followed to open any example in the browser. -There are some pre-requesites to running this server. It assumes that you have the `development` branch of Pyglet -checked out and in a folder named `pyglet` directly next to your Arcade repo directory. You will also need to have -the `build` and `flit` packages from PyPi installed. These are used by Pyglet and Arcade to build wheel files, -but are not generally installed for local development. +## Testing Local Scripts -Assuming you have Pyglet ready to go, you can then start the server. It will build wheels for both Pyglet and Arcade, and copy them -into this directory. This means that if you make any, you will need to restart this server in order to build new wheels. +You can now test your own local scripts **without restarting the server**! + +1. Navigate to `http://localhost:8000/local` in your browser +2. Place your Python scripts in the `local_scripts/` directory +3. Scripts should have a `main()` function as the entry point +4. The page will automatically list all `.py` files in that directory +5. Click any script to run it in the browser +6. Edit your scripts and refresh the browser page to see changes - no server restart needed! + +See `local_scripts/README.md` and `local_scripts/example_test.py` for more details and examples. + +## Prerequisites + +You will need to have `uv` installed to build the Arcade wheel. You can install it with: + +When you start the server, it will automatically build an Arcade wheel and copy it into this directory. +This means that if you make any changes to Arcade code, you will need to restart the server to build a new wheel with your changes. ## How does this work? The web server itself is built with a nice little HTTP server library named [Bottle](https://github.com/bottlepy/bottle). We need to run an HTTP server locally -to load anything into WASM in the browser, it will not work if we just server it files directly due to browser security constraints. For the Arcade examples specifically, +to load anything into WASM in the browser, as it will not work if we just serve files directly due to browser security constraints. For the Arcade examples specifically, we are taking advantage of the fact that the example code is packaged directly inside of Arcade to enable executing them in the browser. If we need to add extra code that is not part of the Arcade package, that will require extension of this server to handle packaging it properly for loading into WASM, and then diff --git a/webplayground/index.tpl b/webplayground/index.tpl index 4c5a8e11b5..66e42fd740 100644 --- a/webplayground/index.tpl +++ b/webplayground/index.tpl @@ -3,9 +3,32 @@ Arcade Examples + +

Arcade Examples

+ 🧪 Test Local Scripts +

Built-in Examples: