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()