diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4f25e5..7942ea3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,7 +125,7 @@ jobs: " pytest -v tests - test-examples-build: + test-examples: name: Test examples ${{ matrix.pyversion }} runs-on: ${{ matrix.os }} strategy: @@ -183,8 +183,8 @@ jobs: pushd $HOME pytest -v --pyargs rendercanvas.__pyinstaller - release: - name: Build release on ubuntu-latest + build-release: + name: Build release artifacts runs-on: ubuntu-latest strategy: fail-fast: false @@ -227,7 +227,7 @@ jobs: publish: name: Publish release to Github and Pypi runs-on: ubuntu-latest - needs: [tests, release] + needs: [tests, build-release] if: success() && startsWith(github.ref, 'refs/tags/v') environment: name: pypi diff --git a/README.md b/README.md index e570b48..897e45e 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ import numpy as np from rendercanvas.auto import RenderCanvas, loop canvas = RenderCanvas(update_mode="continuous") -context = canvas.get_context("bitmap") +context = canvas.get_bitmap_context() @canvas.request_draw def animate(): diff --git a/docs/api.rst b/docs/api.rst index 2c8e44a..fdda50e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,5 +1,5 @@ -API -=== +Canvas API +========== These are the classes that make up the rendercanvas API: diff --git a/docs/conf.py b/docs/conf.py index d2f1faa..f095305 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,8 +25,7 @@ # Load wgpu so autodoc can query docstrings import rendercanvas # noqa: E402 import rendercanvas.stub # noqa: E402 - we use the stub backend to generate docs -import rendercanvas._context # noqa: E402 - we use the ContextInterface to generate docs -import rendercanvas.utils.bitmappresentadapter # noqa: E402 + # -- Project information ----------------------------------------------------- diff --git a/docs/contextapi.rst b/docs/contextapi.rst index f5bbf16..49b65f7 100644 --- a/docs/contextapi.rst +++ b/docs/contextapi.rst @@ -1,7 +1,7 @@ How context objects work ======================== -This page documents the working bentween the ``RenderCanvas`` and the context object. +This page documents the inner working between the ``RenderCanvas`` and the context object. Introduction @@ -23,16 +23,13 @@ then present the result to the screen. For this, the canvas provides one or more │ │ ──bitmap──► │ │ └─────────┘ └────────┘ -This means that for the context to be able to present to any canvas, it must -support *both* the 'bitmap' and 'screen' present-methods. If the context prefers -presenting to the screen, and the canvas supports that, all is well. Similarly, -if the context has a bitmap to present, and the canvas supports the -bitmap-method, there's no problem. +If the context is a ``BitmapContext``, and the canvas supports the bitmap present-method, +things are easy. Similarly, if the context is a ``WgpuContext``, and the canvas +supports the screen present-method, the presenting is simply delegated to wgpu. -It get's a little trickier when there's a mismatch, but we can deal with these -cases too. When the context prefers presenting to screen, the rendered result is -probably a texture on the GPU. This texture must then be downloaded to a bitmap -on the CPU. All GPU API's have ways to do this. +When there's a mismatch, we use different context sub-classes that handle the conversion. +With the ``WgpuContextToBitmap`` context, the rendered result is inside a texture on the GPU. +This texture is then downloaded to a bitmap on the CPU that can be passed to the canvas. .. code-block:: @@ -41,11 +38,10 @@ on the CPU. All GPU API's have ways to do this. ──render──► | Context │ | │ Canvas │ │ │ └─bitmap──► │ | └─────────┘ └────────┘ - download from gpu to cpu + download to CPU -If the context has a bitmap to present, and the canvas only supports presenting -to screen, you can use a small utility: the ``BitmapPresentAdapter`` takes a -bitmap and presents it to the screen. +With the ``BitmapContextToScreen`` context, the bitmap is uploaded to a GPU texture, +which is then rendered to screen using the lower-level canvas-context from ``wgpu``. .. code-block:: @@ -54,46 +50,6 @@ bitmap and presents it to the screen. ──render──► | Context │ │ │ Canvas │ │ │ ──bitmap─┘ │ | └─────────┘ └────────┘ - use BitmapPresentAdapter + upload to GPU This way, contexts can be made to work with all canvas backens. - -Canvases may also provide additionally present-methods. If a context knows how to -use that present-method, it can make use of it. Examples could be presenting -diff images or video streams. - -.. code-block:: - - ┌─────────┐ ┌────────┐ - │ │ │ │ - ──render──► | Context │ ──special-present-method──► │ Canvas │ - │ │ │ | - └─────────┘ └────────┘ - - -Context detection ------------------ - -Anyone can make a context that works with ``rendercanvas``. In order for ``rendercanvas`` to find, it needs a little hook. - -.. autofunction:: rendercanvas._context.rendercanvas_context_hook - :no-index: - - -Context API ------------ - -The class below describes the API and behavior that is expected of a context object. -Also see https://github.com/pygfx/rendercanvas/blob/main/rendercanvas/_context.py. - -.. autoclass:: rendercanvas._context.ContextInterface - :members: - :no-index: - - -Adapter -------- - -.. autoclass:: rendercanvas.utils.bitmappresentadapter.BitmapPresentAdapter - :members: - :no-index: diff --git a/docs/contexts.rst b/docs/contexts.rst new file mode 100644 index 0000000..784f7a4 --- /dev/null +++ b/docs/contexts.rst @@ -0,0 +1,13 @@ +Context API +=========== + +.. automodule:: rendercanvas.contexts + +.. autoclass:: rendercanvas.contexts.BaseContext + :members: + +.. autoclass:: rendercanvas.contexts.BitmapContext + :members: + +.. autoclass:: rendercanvas.contexts.WgpuContext + :members: diff --git a/docs/index.rst b/docs/index.rst index 6666764..5971d20 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,6 +9,7 @@ Welcome to the rendercanvas docs! start api + contexts backends utils Gallery diff --git a/docs/start.rst b/docs/start.rst index 2fd450e..4726b58 100644 --- a/docs/start.rst +++ b/docs/start.rst @@ -50,7 +50,7 @@ Rendering using bitmaps: .. code-block:: py - context = canvas.get_context("bitmap") + context = canvas.get_bitmap_context() @canvas.request_draw def animate(): @@ -61,7 +61,7 @@ Rendering with wgpu: .. code-block:: py - context = canvas.get_context("wgpu") + context = canvas.get_wgpu_context() context.configure(device) @canvas.request_draw diff --git a/docs/utils.rst b/docs/utils.rst index efaeafe..a0e953d 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -7,5 +7,3 @@ Utils utils_asyncs utils_cube - utils_bitmappresentadapter.rst - utils_bitmaprenderingcontext.rst diff --git a/docs/utils_bitmappresentadapter.rst b/docs/utils_bitmappresentadapter.rst deleted file mode 100644 index 2889384..0000000 --- a/docs/utils_bitmappresentadapter.rst +++ /dev/null @@ -1,5 +0,0 @@ -``utils.bitmappresentadapter`` -============================== - -.. automodule:: rendercanvas.utils.bitmappresentadapter - :members: diff --git a/docs/utils_bitmaprenderingcontext.rst b/docs/utils_bitmaprenderingcontext.rst deleted file mode 100644 index 8bad64c..0000000 --- a/docs/utils_bitmaprenderingcontext.rst +++ /dev/null @@ -1,5 +0,0 @@ -``utils.bitmaprenderingcontext`` -================================ - -.. automodule:: rendercanvas.utils.bitmaprenderingcontext - :members: diff --git a/examples/drag.py b/examples/drag.py index 7a63786..4f68cc3 100644 --- a/examples/drag.py +++ b/examples/drag.py @@ -17,7 +17,7 @@ canvas = RenderCanvas(present_method=None, update_mode="continuous") -context = canvas.get_context("bitmap") +context = canvas.get_bitmap_context() # The size of the blocks: hw is half the block width diff --git a/examples/noise.py b/examples/noise.py index e97df57..a23cdaa 100644 --- a/examples/noise.py +++ b/examples/noise.py @@ -12,7 +12,7 @@ canvas = RenderCanvas(update_mode="continuous") -context = canvas.get_context("bitmap") +context = canvas.get_bitmap_context() @canvas.request_draw diff --git a/examples/snake.py b/examples/snake.py index 4238829..6d54ab8 100644 --- a/examples/snake.py +++ b/examples/snake.py @@ -14,7 +14,7 @@ canvas = RenderCanvas(present_method=None, size=(640, 480), update_mode="continuous") -context = canvas.get_context("bitmap") +context = canvas.get_bitmap_context() world = np.zeros((120, 160), np.uint8) pos = [100, 100] diff --git a/rendercanvas/__init__.py b/rendercanvas/__init__.py index 09dd634..d619902 100644 --- a/rendercanvas/__init__.py +++ b/rendercanvas/__init__.py @@ -8,5 +8,8 @@ from . import _coreutils from ._enums import CursorShape, EventType, UpdateMode from .base import BaseRenderCanvas, BaseLoop +from . import contexts +from . import utils + __all__ = ["BaseLoop", "BaseRenderCanvas", "CursorShape", "EventType", "UpdateMode"] diff --git a/rendercanvas/_context.py b/rendercanvas/_context.py deleted file mode 100644 index 89e0669..0000000 --- a/rendercanvas/_context.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -A stub context implementation for documentation purposes. -It does actually work, but presents nothing. -""" - -import weakref - - -def rendercanvas_context_hook(canvas, present_methods): - """Hook function to allow ``rendercanvas`` to detect your context implementation. - - If you make a function with this name available in the module ``your.module``, - ``rendercanvas`` will detect and call this function in order to obtain the canvas object. - That way, anyone can use ``canvas.get_context("your.module")`` to use your context. - The arguments are the same as for ``ContextInterface``. - """ - return ContextInterface(canvas, present_methods) - - -class ContextInterface: - """The interface that a context must implement, to be usable with a ``RenderCanvas``. - - Arguments: - canvas (BaseRenderCanvas): the canvas to render to. - present_methods (dict): The supported present methods of the canvas. - - The ``present_methods`` dict has a field for each supported present-method. A - canvas must support either "screen" or "bitmap". It may support both, as well as - additional (specialized) present methods. Below we list the common methods and - what fields the subdicts have. - - * Render method "screen": - * "window": the native window id. - * "display": the native display id (Linux only). - * "platform": to determine between "x11" and "wayland" (Linux only). - * Render method "bitmap": - * "formats": a list of supported formats. It should always include "rgba-u8". - Other options can be be "i-u8" (intensity/grayscale), "i-f32", "bgra-u8", "rgba-u16", etc. - - """ - - def __init__(self, canvas, present_methods): - self._canvas_ref = weakref.ref(canvas) - self._present_methods = present_methods - - @property - def canvas(self): - """The associated canvas object. Internally, this should preferably be stored using a weakref.""" - return self._canvas_ref() - - def present(self): - """Present the result to the canvas. - - This is called by the canvas, and should not be called by user-code. - - The implementation should always return a present-result dict, which - should have at least a field 'method'. The value of 'method' must be - one of the methods that the canvas supports, i.e. it must be in ``present_methods``. - - * If there is nothing to present, e.g. because nothing was rendered yet: - * return ``{"method": "skip"}`` (special case). - * If presentation could not be done for some reason: - * return ``{"method": "fail", "message": "xx"}`` (special case). - * If ``present_method`` is "screen": - * Render to screen using the info in ``present_methods['screen']``). - * Return ``{"method", "screen"}`` as confirmation. - * If ``present_method`` is "bitmap": - * Return ``{"method": "bitmap", "data": data, "format": format}``. - * 'data' is a memoryview, or something that can be converted to a memoryview, like a numpy array. - * 'format' is the format of the bitmap, must be in ``present_methods['bitmap']['formats']`` ("rgba-u8" is always supported). - * If ``present_method`` is something else: - * Return ``{"method": "xx", ...}``. - * It's the responsibility of the context to use a render method that is supported by the canvas, - and that the appropriate arguments are supplied. - """ - - # This is a stub - return {"method": "skip"} - - def _release(self): - """Release resources. Called by the canvas when it's closed.""" - pass diff --git a/rendercanvas/_version.py b/rendercanvas/_version.py index 238c676..a46438c 100644 --- a/rendercanvas/_version.py +++ b/rendercanvas/_version.py @@ -50,28 +50,34 @@ def get_extended_version() -> str: # Sample first 3 parts of __version__ base_release = ".".join(__version__.split(".")[:3]) - # Check release - if not release: - release = base_release - elif release != base_release: - warning( - f"{project_name} version from git ({release})" - f" and __version__ ({base_release}) don't match." - ) - - # Build the total version - version = release + # Start version string (__version__ string is leading) + version = base_release + tag_prefix = "#" + + if release and release != base_release: + # Can happen between bumping and tagging. And also when merging a + # version bump into a working branch, because we use --first-parent. + release2, _post, _labels = get_version_info_from_git(first_parent=False) + if release2 != base_release: + warning( + f"{project_name} version from git ({release})" + f" and __version__ ({base_release}) don't match." + ) + version += "+from_tag_" + release.replace(".", "_") + tag_prefix = "." + + # Add git info if post and post != "0": version += f".post{post}" if labels: - version += "+" + ".".join(labels) + version += tag_prefix + ".".join(labels) elif labels and labels[-1] == "dirty": - version += "+" + ".".join(labels) + version += tag_prefix + ".".join(labels) return version -def get_version_info_from_git() -> str: +def get_version_info_from_git(*, first_parent: bool = True) -> str: """ Get (release, post, labels) from Git. @@ -80,15 +86,9 @@ def get_version_info_from_git() -> str: git-hash and optionally a dirty flag. """ # Call out to Git - command = [ - "git", - "describe", - "--long", - "--always", - "--tags", - "--dirty", - "--first-parent", - ] + command = ["git", "describe", "--long", "--always", "--tags", "--dirty"] + if first_parent: + command.append("--first-parent") try: p = subprocess.run(command, check=False, cwd=repo_dir, capture_output=True) except Exception as e: diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 3a509de..1de7188 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -6,7 +6,6 @@ import sys import weakref -import importlib from typing import TYPE_CHECKING from ._enums import ( @@ -15,13 +14,14 @@ CursorShape, CursorShapeEnum, ) +from . import contexts from ._events import EventEmitter from ._loop import BaseLoop from ._scheduler import Scheduler from ._coreutils import logger, log_exception if TYPE_CHECKING: - from typing import Callable, List, Optional, Tuple + from typing import Callable, List, Literal, Optional, Tuple EventHandlerFunction = Callable[[dict], None] DrawFunction = Callable[[], None] @@ -96,9 +96,10 @@ class BaseRenderCanvas: max_fps (float): A maximal frames-per-second to use when the ``update_mode`` is 'ondemand' or 'continuous'. The default is 30, which is usually enough. vsync (bool): Whether to sync the draw with the monitor update. Helps - against screen tearing, but can reduce fps. Default True. + against screen tearing, but limits the fps. Default True. present_method (str | None): Override the method to present the rendered result. - Can be set to e.g. 'screen' or 'bitmap'. Default None (auto-select). + Can be set to 'screen' or 'bitmap'. Default None, which means that the method is selected + based on what the canvas supports and what the context prefers. """ @@ -128,7 +129,7 @@ def __init__( min_fps: float = 0.0, max_fps: float = 30.0, vsync: bool = True, - present_method: Optional[str] = None, + present_method: Literal["bitmap", "screen", None] = None, **kwargs, ): # Initialize superclass. Note that super() can be e.g. a QWidget, RemoteFrameBuffer, or object. @@ -159,6 +160,7 @@ def __init__( "logical_size": (0.0, 0.0), } self.__need_size_event = False + self.__need_context_resize = True # True bc context may be created later # Events and scheduler self._events = EventEmitter() @@ -217,24 +219,30 @@ def __del__(self): except Exception: pass - # %% Implement WgpuCanvasInterface - _canvas_context = None # set in get_context() def get_physical_size(self) -> Tuple[int, int]: """Get the physical size of the canvas in integer pixels.""" return self.__size_info["physical_size"] - def get_context(self, context_type: str) -> object: + def get_bitmap_context(self) -> contexts.BitmapContext: + """Get the ``BitmapContext`` to render to this canvas.""" + return self.get_context("bitmap") + + def get_wgpu_context(self) -> contexts.WgpuContext: + """Get the ``WgpuContext`` to render to this canvas.""" + return self.get_context("wgpu") + + def get_context( + self, context_type: Literal["bitmap", "wgpu"] + ) -> contexts.BaseContext: """Get a context object that can be used to render to this canvas. The context takes care of presenting the rendered result to the canvas. Different types of contexts are available: - * "wgpu": get a ``WgpuCanvasContext`` provided by the ``wgpu`` library. - * "bitmap": get a ``BitmapRenderingContext`` provided by the ``rendercanvas`` library. - * "another.module": other libraries may provide contexts too. We've only listed the ones we know of. - * "your.module:ContextClass": Explicit name. + * "wgpu": get a ``WgpuContext`` + * "bitmap": get a ``BitmapContext`` Later calls to this method, with the same context_type argument, will return the same context instance as was returned the first time the method was @@ -242,60 +250,74 @@ def get_context(self, context_type: str) -> object: one has been created. """ - # Note that this method is analog to HtmlCanvas.getContext(), except - # the context_type is different, since contexts are provided by other projects. + # Note that this method is analog to HtmlCanvas.getContext(), except with different context types. if not isinstance(context_type, str): raise TypeError("context_type must be str.") # Resolve the context type name - known_types = { - "wgpu": "wgpu", - "bitmap": "rendercanvas.utils.bitmaprenderingcontext", - } - resolved_context_type = known_types.get(context_type, context_type) + if context_type not in ("bitmap", "wgpu"): + raise TypeError( + "The given context type is invalid: {context_type!r} is not 'bitmap' or 'wgpu'." + ) # Is the context already set? if self._canvas_context is not None: - if resolved_context_type == self._canvas_context._context_type: + if context_type == self._canvas_context._context_type: return self._canvas_context else: raise RuntimeError( f"Cannot get context for '{context_type}': a context of type '{self._canvas_context._context_type}' is already set." ) - # Load module - module_name, _, class_name = resolved_context_type.partition(":") - try: - module = importlib.import_module(module_name) - except ImportError as err: - raise ValueError( - f"Cannot get context for '{context_type}': {err}. Known valid values are {set(known_types)}" - ) from None - - # Obtain factory to produce context - factory_name = class_name or "rendercanvas_context_hook" - try: - factory_func = getattr(module, factory_name) - except AttributeError: - raise ValueError( - f"Cannot get context for '{context_type}': could not find `{factory_name}` in '{module.__name__}'" - ) from None + # Get available present methods. + # Take care not to hold onto this dict, it may contain objects that we don't want to unnecessarily reference. + present_methods = self._rc_get_present_methods() + invalid_methods = set(present_methods.keys()) - {"screen", "bitmap"} + if invalid_methods: + logger.warning( + f"{self.__class__.__name__} reports unknown present methods {invalid_methods!r}" + ) - # Create the context - context = factory_func(self, self._rc_get_present_methods()) + # Select present_method + present_method = None + if context_type == "bitmap": + if "bitmap" in present_methods: + present_method = "bitmap" + elif "screen" in present_methods: + present_method = "screen" + else: + if "screen" in present_methods: + present_method = "screen" + elif "bitmap" in present_methods: + present_method = "bitmap" - # Quick checks to make sure the context has the correct API - if not (hasattr(context, "canvas") and context.canvas is self): + # This should never happen, unless there's a bug + if present_method is None: raise RuntimeError( - "The context does not have a canvas attribute that refers to this canvas." + "Could not select present_method for context_type {context_type!r} from present_methods {present_methods!r}" ) - if not (hasattr(context, "present") and callable(context.present)): - raise RuntimeError("The context does not have a present method.") + + # Select present_info, and shape it into what the contexts need. + present_info = present_methods[present_method] + assert "method" not in present_info, ( + "the field 'method' is reserved in present_methods dicts" + ) + present_info = { + "method": present_method, + "source": self.__class__.__name__, + **present_info, + "vsync": self._vsync, + } + + if context_type == "bitmap": + context = contexts.BitmapContext(present_info) + else: + context = contexts.WgpuContext(present_info) # Done self._canvas_context = context - self._canvas_context._context_type = resolved_context_type + self._canvas_context._context_type = context_type return self._canvas_context # %% Events @@ -331,6 +353,7 @@ def __resolve_total_pixel_ratio_and_logical_size(self): self.__size_info["total_pixel_ratio"] = total_pixel_ratio self.__size_info["logical_size"] = logical_size self.__need_size_event = True + self.__need_context_resize = True def add_event_handler( self, *args: EventTypeEnum | EventHandlerFunction, order: float = 0 @@ -353,6 +376,12 @@ def submit_event(self, event: dict) -> None: # %% Scheduling and drawing def __maybe_emit_resize_event(self): + # Keep context up-to-date + if self.__need_context_resize and self._canvas_context is not None: + self.__need_context_resize = False + self._canvas_context._rc_set_size_info(self.__size_info) + + # Keep event listeners up-to-date if self.__need_size_event: self.__need_size_event = False lsize = self.__size_info["logical_size"] @@ -440,7 +469,7 @@ def request_draw(self, draw_function: Optional[DrawFunction] = None) -> None: This function does not perform a draw directly, but schedules a draw at a suitable moment in time. At that time the draw function is called, and - the resulting rendered image is presented to screen. + the resulting rendered image is presented to the canvas. Only affects drawing with schedule-mode 'ondemand'. @@ -519,7 +548,7 @@ def _draw_frame_and_present(self): # Note: if vsync is used, this call may wait a little (happens down at the level of the driver or OS) context = self._canvas_context if context: - result = context.present() + result = context._rc_present() method = result.pop("method") if method in ("skip", "screen"): pass # nothing we need to do @@ -536,7 +565,7 @@ def _draw_frame_and_present(self): # %% Primary canvas management methods def get_logical_size(self) -> Tuple[float, float]: - """Get the logical size (width, height) in float pixels. + """Get the logical size (width, height) of the canvas in float pixels. The logical size can be smaller than the physical size, e.g. on HiDPI monitors or when the user's system has the display-scale set to e.g. 125%. @@ -549,8 +578,7 @@ def get_pixel_ratio(self) -> float: The pixel ratio is typically 1.0 for normal screens and 2.0 for HiDPI screens, but fractional values are also possible if the system display-scale is set to e.g. 125%. An HiDPI screen can be assumed if the - pixel ratio >= 2.0. On MacOS (with a Retina screen) the pixel ratio is - always 2.0. + pixel ratio >= 2.0. """ return self.__size_info["total_pixel_ratio"] @@ -559,12 +587,10 @@ def close(self) -> None: # Clear the draw-function, to avoid it holding onto e.g. wgpu objects. self._draw_frame = None # type: ignore # Clear the canvas context too. - if hasattr(self._canvas_context, "_release"): - # ContextInterface (and GPUCanvasContext) has _release() - try: - self._canvas_context._release() # type: ignore - except Exception: - pass + try: + self._canvas_context._rc_close() # type: ignore + except Exception: + pass self._canvas_context = None # Clean events. Should already have happened in loop, but the loop may not be running. self._events._release() @@ -648,11 +674,11 @@ def _rc_get_present_methods(self): field containing the window id. On Linux there should also be ``platform`` field to distinguish between "wayland" and "x11", and a ``display`` field for the display id. This information is used by wgpu to obtain the required - surface id. + surface id. For Pyodide the required info is different. With method "bitmap", the context will present the result as an image - bitmap. On GPU-based contexts, the result will first be rendered to an - offscreen texture, and then downloaded to RAM. The sub-dict must have a + bitmap. For the `WgpuContext`, the result will first be rendered to texture, + and then downloaded to RAM. The sub-dict must have a field 'formats': a list of supported image formats. Examples are "rgba-u8" and "i-u8". A canvas must support at least "rgba-u8". Note that srgb mapping is assumed to be handled by the canvas. diff --git a/rendercanvas/contexts/__init__.py b/rendercanvas/contexts/__init__.py new file mode 100644 index 0000000..2f00624 --- /dev/null +++ b/rendercanvas/contexts/__init__.py @@ -0,0 +1,19 @@ +""" +A context provides an API to provide a rendered image, and implements a +mechanism to present that image for display. The concept of a context is heavily +inspired by the canvas and its contexts in the browser. + +In ``rendercanvas``, there are two types of contexts: the *bitmap* context +exposes an API that takes image bitmaps in RAM, and the *wgpu* context exposes +an API that provides image textures on the GPU to render to. + +The presentation of the rendered image is handled by a sub-system, e.g. display +directly to screen, pass as bitmap to a GUI toolkit, send bitmap to a remote +client, etc. Each such subsystem is handled by a dedicated subclasses of +``BitmapContext`` and ``WgpuContext``. Users only need to be aware of the base +classes. +""" + +from .basecontext import * # noqa: F403 +from .bitmapcontext import * # noqa: F403 +from .wgpucontext import * # noqa: F403 diff --git a/rendercanvas/utils/bitmappresentadapter.py b/rendercanvas/contexts/_fullscreen.py similarity index 83% rename from rendercanvas/utils/bitmappresentadapter.py rename to rendercanvas/contexts/_fullscreen.py index d2ae12b..8429b91 100644 --- a/rendercanvas/utils/bitmappresentadapter.py +++ b/rendercanvas/contexts/_fullscreen.py @@ -1,56 +1,6 @@ -""" -A tool so contexts that produce a bitmap can still render to screen. -""" - -import sys import wgpu -class BitmapPresentAdapter: - """An adapter to present a bitmap to a canvas using wgpu. - - This adapter can be used by context objects that want to present a bitmap, when the - canvas only supports presenting to screen. - """ - - def __init__(self, canvas, present_methods): - # Init wgpu - adapter = wgpu.gpu.request_adapter_sync(power_preference="high-performance") - device = self._device = adapter.request_device_sync(required_limits={}) - - self._texture_helper = FullscreenTexture(device) - - # Create context - backend_module = wgpu.gpu.__module__ - CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 - self._context = CanvasContext(canvas, present_methods) - self._context_is_configured = False - - def present_bitmap(self, bitmap): - """Present the given bitmap to screen. - - Supported formats are "rgba-u8" and "i-u8" (grayscale). - Returns the present-result dict produced by ``GPUCanvasContext.present()``. - """ - - self._texture_helper.set_texture_data(bitmap) - - if not self._context_is_configured: - format = self._context.get_preferred_format(self._device.adapter) - # We don't want an srgb texture, because we assume the input bitmap is already srgb. - # AFAIK contexts always support both the regular and the srgb texture format variants - if format.endswith("-srgb"): - format = format[:-5] - self._context.configure(device=self._device, format=format) - - target = self._context.get_current_texture().create_view() - command_encoder = self._device.create_command_encoder() - self._texture_helper.draw(command_encoder, target) - self._device.queue.submit([command_encoder.finish()]) - - return self._context.present() - - class FullscreenTexture: """An object that helps rendering a texture to the full viewport.""" diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py new file mode 100644 index 0000000..0347e59 --- /dev/null +++ b/rendercanvas/contexts/basecontext.py @@ -0,0 +1,131 @@ +import sys + +__all__ = ["BaseContext"] + + +class BaseContext: + """The base class for context objects in ``rendercanvas``. + + All contexts provide detailed size information. A rendering system should + generally be capable to perform the rendering with just the context object; + without a reference to the canvas. With this, we try to promote a clear + separation of concern, where one system listens to events from the canvas to + update a certain state, and the renderer uses this state and the context to + render the image. + """ + + def __init__(self, present_info: dict): + self._present_info = present_info + assert present_info["method"] in ("bitmap", "screen") # internal sanity check + self._size_info = { + "physical_size": (0, 0), + "native_pixel_ratio": 1.0, + "canvas_pixel_ratio": 1.0, + "total_pixel_ratio": 1.0, + "logical_size": (0.0, 0.0), + } + self._object_with_physical_size = None # to support old wgpu-py api + self._wgpu_context = None + + def __repr__(self): + return f"" + + def _create_wgpu_py_context(self) -> object: + """Create a wgpu.GPUCanvasContext""" + import wgpu + + if hasattr(wgpu.gpu, "get_canvas_context"): + # New style wgpu-py API + self._wgpu_context = wgpu.gpu.get_canvas_context(self._present_info) + else: + # Old style wgpu-py API + backend_module = wgpu.gpu.__module__ + CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 + self._object_with_physical_size = pseudo_canvas = PseudoCanvasForWgpuPy() + self._wgpu_context = CanvasContext( + pseudo_canvas, {"screen": self._present_info} + ) + + def _rc_set_size_info(self, size_info: dict) -> None: + """Called by the BaseRenderCanvas to update the size.""" + # Note that we store the dict itself, not a copy. So our size is always up-to-date, + # but this function is called on resize nonetheless so we can pass resizes downstream. + self._size_info = size_info + if self._object_with_physical_size is not None: + self._object_with_physical_size.set_physical_size( + *size_info["physical_size"] + ) + elif self._wgpu_context is not None: + self._wgpu_context.set_physical_size(*size_info["physical_size"]) + + @property + def physical_size(self) -> tuple[int, int]: + """The physical size of the render target in integer pixels.""" + return self._size_info["physical_size"] + + @property + def logical_size(self) -> tuple[float, float]: + """The logical size (width, height) of the render target in float pixels. + + The logical size can be smaller than the physical size, e.g. on HiDPI + monitors or when the user's system has the display-scale set to e.g. + 125%. The logical size can in theory also be larger than the physical + size, but this is much less common. + """ + return self._size_info["logical_size"] + + @property + def pixel_ratio(self) -> float: + """The float ratio between logical and physical pixels. + + The pixel ratio is typically 1.0 for normal screens and 2.0 for HiDPI + screens, but fractional values are also possible if the system + display-scale is set to e.g. 125%. + """ + return self._size_info["total_pixel_ratio"] + + @property + def looks_like_hidpi(self) -> bool: + """Whether it looks like the window is on a hipdi screen. + + This is determined by checking whether the native pixel-ratio (i.e. + the ratio reported by the canvas backend) is larger or equal dan 2.0. + """ + return self._size_info["native_pixel_ratio"] >= 2.0 + + def _rc_present(self): + """Called by BaseRenderCanvas to collect the result. Subclasses must implement this. + + The implementation should always return a present-result dict, which + should have at least a field 'method'. + + * If there is nothing to present, e.g. because nothing was rendered yet: + * return ``{"method": "skip"}`` (special case). + * If presentation could not be done for some reason: + * return ``{"method": "fail", "message": "xx"}`` (special case). + * If ``present_method`` is "screen": + * Render to screen using the present info. + * Return ``{"method", "screen"}`` as confirmation. + * If ``present_method`` is "bitmap": + * Return ``{"method": "bitmap", "data": data, "format": format}``. + * 'data' is a memoryview, or something that can be converted to a memoryview, like a numpy array. + * 'format' is the format of the bitmap, must be in ``present_info['formats']`` ("rgba-u8" is always supported). + """ + + # This is a stub + return {"method": "skip"} + + def _rc_close(self): + """Close context and release resources. Called by the canvas when it's closed.""" + pass + + +class PseudoCanvasForWgpuPy: + def __init__(self): + self._physical_size = 0, 0 + + def set_physical_size(self, w: int, h: int): + self._physical_size = w, h + + def get_physical_size(self) -> tuple[int, int]: + return self._physical_size diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py new file mode 100644 index 0000000..868d67f --- /dev/null +++ b/rendercanvas/contexts/bitmapcontext.py @@ -0,0 +1,162 @@ +from .basecontext import BaseContext + +__all__ = ["BitmapContext", "BitmapContextToBitmap", "BitmapContextToScreen"] + + +class BitmapContext(BaseContext): + """A context that exposes an API that takes a (grayscale or rgba) images bitmap. + + This is loosely inspired by JS' ``ImageBitmapRenderingContext``. Rendering + bitmaps is a simple way to use ``rendercanvas``, but usually not as + performant as a wgpu context. + + Users typically don't instantiate contexts directly, but use ``canvas.get_bitmap_context()``, + which returns a subclass of this class, depending on the needs of the canvas. + """ + + def __new__(cls, present_info: dict): + # Instantiating this class actually produces a subclass + present_method = present_info["method"] + if cls is not BitmapContext: + return super().__new__(cls) # Use canvas that is explicitly instantiated + elif present_method == "bitmap": + return super().__new__(BitmapContextToBitmap) + elif present_method == "screen": + return super().__new__(BitmapContextToScreen) + else: + raise TypeError("Unexpected present_method {present_method!r}") + + def __init__(self, present_info): + super().__init__(present_info) + self._bitmap_and_format = None + + def set_bitmap(self, bitmap): + """Set the rendered bitmap image. + + Call this in the draw event. The bitmap must be an object that can be + conveted to a memoryview, like a numpy array. It must represent a 2D + image in either grayscale or rgba format, with uint8 values + """ + + m = memoryview(bitmap) + + # Check dtype + if m.format == "B": + dtype = "u8" + else: + raise ValueError( + "Unsupported bitmap dtype/format '{m.format}', expecting unsigned bytes ('B')." + ) + + # Get color format + color_format = None + if len(m.shape) == 2: + color_format = "i" + elif len(m.shape) == 3: + if m.shape[2] == 1: + color_format = "i" + elif m.shape[2] == 4: + color_format = "rgba" + if not color_format: + raise ValueError( + f"Unsupported bitmap shape {m.shape}, expecting a 2D grayscale or rgba image." + ) + + # We should now have one of two formats + format = f"{color_format}-{dtype}" + assert format in ("rgba-u8", "i-u8") + + self._bitmap_and_format = m, format + + +class BitmapContextToBitmap(BitmapContext): + """A BitmapContext that just presents the bitmap to the canvas.""" + + def __init__(self, present_info): + super().__init__(present_info) + assert self._present_info["method"] == "bitmap" + self._bitmap_and_format = None + + def _rc_present(self): + if self._bitmap_and_format is None: + return {"method": "skip"} + + bitmap, format = self._bitmap_and_format + if format not in self._present_info["formats"]: + # Convert from i-u8 -> rgba-u8. This surely hurts performance. + assert format == "i-u8" + flat_bitmap = bitmap.cast("B", (bitmap.nbytes,)) + new_bitmap = memoryview(bytearray(bitmap.nbytes * 4)).cast("B") + new_bitmap[::4] = flat_bitmap + new_bitmap[1::4] = flat_bitmap + new_bitmap[2::4] = flat_bitmap + new_bitmap[3::4] = b"\xff" * flat_bitmap.nbytes + bitmap = new_bitmap.cast("B", (*bitmap.shape, 4)) + format = "rgba-u8" + return { + "method": "bitmap", + "data": bitmap, + "format": format, + } + + def _rc_close(self): + self._bitmap_and_format = None + + +class BitmapContextToScreen(BitmapContext): + """A BitmapContext that uploads to a texture and present that to a ``wgpu.GPUCanvasContext``. + + This is uses for canvases that do not support presenting a bitmap. + """ + + def __init__(self, present_info): + super().__init__(present_info) + + # Init wgpu + import wgpu + from ._fullscreen import FullscreenTexture + + adapter = wgpu.gpu.request_adapter_sync(power_preference="high-performance") + device = self._device = adapter.request_device_sync(required_limits={}) + + self._texture_helper = FullscreenTexture(device) + + self._create_wgpu_py_context() # sets self._wgpu_context + self._wgpu_context_is_configured = False + + def _rc_present(self): + if self._bitmap_and_format is None: + return {"method": "skip"} + + # Supported formats are "rgba-u8" and "i-u8" (grayscale). + # Returns the present-result dict produced by ``GPUCanvasContext.present()``. + + bitmap = self._bitmap_and_format[0] + self._texture_helper.set_texture_data(bitmap) + + if not self._wgpu_context_is_configured: + format = self._wgpu_context.get_preferred_format(self._device.adapter) + # We don't want an srgb texture, because we assume the input bitmap is already srgb. + # AFAIK contexts always support both the regular and the srgb texture format variants + if format.endswith("-srgb"): + format = format[:-5] + self._wgpu_context.configure(device=self._device, format=format) + + target = self._wgpu_context.get_current_texture().create_view() + command_encoder = self._device.create_command_encoder() + self._texture_helper.draw(command_encoder, target) + self._device.queue.submit([command_encoder.finish()]) + + present_feedback = self._wgpu_context.present() + + # We actually allow the _wgpu_context to return present_feedback, because we have a test in which + # we mimick a GPUCanvasContext with a WgpuContextToBitmap to cover a full round-trip to wgpu. + if present_feedback is None: + present_feedback = {"method": "screen"} + + return present_feedback + + def _rc_close(self): + self._bitmap_and_format = None + if self._wgpu_context is not None: + self._wgpu_context.unconfigure() diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py new file mode 100644 index 0000000..a27b02f --- /dev/null +++ b/rendercanvas/contexts/wgpucontext.py @@ -0,0 +1,346 @@ +from typing import Sequence + +from .basecontext import BaseContext + + +__all__ = ["WgpuContext", "WgpuContextToBitmap", "WgpuContextToScreen"] + + +class WgpuContext(BaseContext): + """A context that exposes an API that provides a GPU texture to render to. + + This is inspired by JS' ``GPUCanvasContext``, and the more performant approach for rendering to a ``rendercanvas``. + + Users typically don't instantiate contexts directly, but use ``canvas.get_wgpu_context()``, + which returns a subclass of this class, depending on the needs of the canvas. + """ + + def __new__(cls, present_info: dict): + # Instantiating this class actually produces a subclass + present_method = present_info["method"] + if cls is not WgpuContext: + return super().__new__(cls) # Use canvas that is explicitly instantiated + elif present_method == "screen": + return super().__new__(WgpuContextToScreen) + elif present_method == "bitmap": + return super().__new__(WgpuContextToBitmap) + else: + raise TypeError("Unexpected present_method {present_method!r}") + + def __init__(self, present_info: dict): + super().__init__(present_info) + # Configuration dict from the user, set via self.configure() + self._config = None + + def get_preferred_format(self, adapter: object) -> str: + """Get the preferred surface texture format.""" + return self._get_preferred_format(adapter) + + def _get_preferred_format(self, adapter: object) -> str: + raise NotImplementedError() + + def get_configuration(self) -> dict | None: + """Get the current configuration (or None if the context is not yet configured).""" + return self._config + + def configure( + self, + *, + device: object, + format: str, + usage: str | int = "RENDER_ATTACHMENT", + view_formats: Sequence[str] = (), + # color_space: str = "srgb", - not yet implemented + # tone_mapping: str | None = None, - not yet implemented + alpha_mode: str = "opaque", + ) -> None: + """Configures the presentation context for the associated canvas. + Destroys any textures produced with a previous configuration. + + Arguments: + device (WgpuDevice): The GPU device object to create compatible textures for. + format (wgpu.TextureFormat): The format that textures returned by + ``get_current_texture()`` will have. Must be one of the supported context + formats. Can be ``None`` to use the canvas' preferred format. + usage (wgpu.TextureUsage): Default "RENDER_ATTACHMENT". + view_formats (list[wgpu.TextureFormat]): The formats that views created + from textures returned by ``get_current_texture()`` may use. + alpha_mode (wgpu.CanvasAlphaMode): Determines the effect that alpha values + will have on the content of textures returned by ``get_current_texture()`` + when read, displayed, or used as an image source. Default "opaque". + """ + import wgpu + + # Basic checks + if not isinstance(device, wgpu.GPUDevice): + raise TypeError("Given device is not a device.") + if format is None: + format = self.get_preferred_format(device.adapter) + if format not in wgpu.TextureFormat: + raise ValueError(f"Configure: format {format} not in {wgpu.TextureFormat}") + if isinstance(usage, str): + usage_bits = usage.replace("|", " ").split() + usage = 0 + for usage_bit in usage_bits: + usage |= wgpu.TextureUsage[usage_bit] + elif not isinstance(usage, int): + raise TypeError("Texture usage must be str or int") + + # Build config dict + config = { + "device": device, + "format": format, + "usage": usage, + "view_formats": view_formats, + # "color_space": color_space, + # "tone_mapping": tone_mapping, + "alpha_mode": alpha_mode, + } + + # Let subclass finnish the configuration, then store the config + self._configure(config) + self._config = config + + def _configure(self, config: dict): + raise NotImplementedError() + + def unconfigure(self) -> None: + """Removes the presentation context configuration.""" + self._config = None + self._unconfigure() + + def _unconfigure(self) -> None: + raise NotImplementedError() + + def get_current_texture(self) -> object: + """Get the ``GPUTexture`` that will be composited to the canvas next.""" + if not self._config: + raise RuntimeError( + "Canvas context must be configured before calling get_current_texture()." + ) + return self._get_current_texture() + + def _get_current_texture(self): + raise NotImplementedError() + + def _rc_present(self) -> None: + """Hook for the canvas to present the rendered result. + + Present what has been drawn to the current texture, by compositing it to the + canvas.This is called automatically by the canvas. + """ + raise NotImplementedError() + + +class WgpuContextToScreen(WgpuContext): + """A wgpu context that present directly to a ``wgpu.GPUCanvasContext``. + + In most cases this means the image is rendered to a native OS surface, i.e. rendered to screen. + When running in Pyodide, it means it renders directly to a ````. + """ + + def __init__(self, present_info: dict): + super().__init__(present_info) + assert self._present_info["method"] == "screen" + self._create_wgpu_py_context() # sets self._wgpu_context + + def _get_preferred_format(self, adapter: object) -> str: + return self._wgpu_context.get_preferred_format(adapter) + + def _configure(self, config): + self._wgpu_context.configure(**config) + + def _unconfigure(self) -> None: + self._wgpu_context.unconfigure() + + def _get_current_texture(self) -> object: + return self._wgpu_context.get_current_texture() + + def _rc_present(self) -> None: + self._wgpu_context.present() + return {"method": "screen"} + + def _rc_close(self): + if self._wgpu_context is not None: + self._wgpu_context.unconfigure() + + +class WgpuContextToBitmap(WgpuContext): + """A wgpu context that downloads the image from the texture, and presents that bitmap to the canvas. + + This is less performant than rendering directly to screen, but once we make the changes such that the + downloading is be done asynchronously, the difference in performance is not + actually that big. + """ + + def __init__(self, present_info: dict): + super().__init__(present_info) + + # Canvas capabilities. Stored the first time it is obtained + self._capabilities = self._get_capabilities() + + # The last used texture + self._texture = None + + def _get_capabilities(self): + """Get dict of capabilities and cache the result.""" + + import wgpu + + capabilities = {} + + # Query format capabilities from the info provided by the canvas + formats = [] + for format in self._present_info["formats"]: + channels, _, fmt = format.partition("-") + channels = {"i": "r", "ia": "rg"}.get(channels, channels) + fmt = { + "u8": "8unorm", + "u16": "16uint", + "f16": "16float", + "f32": "32float", + }.get(fmt, fmt) + wgpu_format = channels + fmt + wgpu_format_srgb = wgpu_format + "-srgb" + if wgpu_format_srgb in wgpu.TextureFormat: + formats.append(wgpu_format_srgb) + formats.append(wgpu_format) + + # Assume alpha modes for now + alpha_modes = ["opaque"] + + # Build capabilitied dict + capabilities = { + "formats": formats, + "view_formats": formats, + "usages": 0xFF, + "alpha_modes": alpha_modes, + } + return capabilities + + def _drop_texture(self): + if self._texture is not None: + try: + self._texture._release() # private method. Not destroy, because it may be in use. + except Exception: + pass + self._texture = None + + def _get_preferred_format(self, adapter: object) -> str: + formats = self._capabilities["formats"] + return formats[0] if formats else "bgra8-unorm" + + def _configure(self, config: dict): + # Get cababilities + cap_formats = self._capabilities["formats"] + cap_view_formats = self._capabilities["view_formats"] + cap_alpha_modes = self._capabilities["alpha_modes"] + + # Check against capabilities + format = config["format"] + if format not in cap_formats: + raise ValueError( + f"Configure: unsupported texture format: {format} not in {cap_formats}" + ) + for view_format in config["view_formats"]: + if view_format not in cap_view_formats: + raise ValueError( + f"Configure: unsupported view format: {view_format} not in {cap_view_formats}" + ) + alpha_mode = config["alpha_mode"] + if alpha_mode not in cap_alpha_modes: + raise ValueError( + f"Configure: unsupported alpha-mode: {alpha_mode} not in {cap_alpha_modes}" + ) + + def _unconfigure(self) -> None: + self._drop_texture() + + def _get_current_texture(self): + # When the texture is active right now, we could either: + # * return the existing texture + # * warn about it, and create a new one + # * raise an error + # Right now we return the existing texture, so user can retrieve it in different render passes that write to the same frame. + + if self._texture is None: + import wgpu + + width, height = self.physical_size + width, height = max(width, 1), max(height, 1) + + # Note that the label 'present' is used by read_texture() to determine + # that it can use a shared copy buffer. + device = self._config["device"] + self._texture = device.create_texture( + label="present", + size=(width, height, 1), + format=self._config["format"], + usage=self._config["usage"] | wgpu.TextureUsage.COPY_SRC, + ) + + return self._texture + + def _rc_present(self) -> None: + if not self._texture: + return {"method": "skip"} + + bitmap = self._get_bitmap() + self._drop_texture() + return {"method": "bitmap", "format": "rgba-u8", "data": bitmap} + + def _get_bitmap(self): + texture = self._texture + device = texture._device + + size = texture.size + format = texture.format + nchannels = 4 # we expect rgba or bgra + if not format.startswith(("rgba", "bgra")): + raise RuntimeError(f"Image present unsupported texture format {format}.") + if "8" in format: + bytes_per_pixel = nchannels + elif "16" in format: + bytes_per_pixel = nchannels * 2 + elif "32" in format: + bytes_per_pixel = nchannels * 4 + else: + raise RuntimeError( + f"Image present unsupported texture format bitdepth {format}." + ) + + data = device.queue.read_texture( + { + "texture": texture, + "mip_level": 0, + "origin": (0, 0, 0), + }, + { + "offset": 0, + "bytes_per_row": bytes_per_pixel * size[0], + "rows_per_image": size[1], + }, + size, + ) + + # Derive struct dtype from wgpu texture format + memoryview_type = "B" + if "float" in format: + memoryview_type = "e" if "16" in format else "f" + else: + if "32" in format: + memoryview_type = "I" + elif "16" in format: + memoryview_type = "H" + else: + memoryview_type = "B" + if "sint" in format: + memoryview_type = memoryview_type.lower() + + # Represent as memory object to avoid numpy dependency + # Equivalent: np.frombuffer(data, np.uint8).reshape(size[1], size[0], nchannels) + + return data.cast(memoryview_type, (size[1], size[0], nchannels)) + + def _rc_close(self): + self._drop_texture() diff --git a/rendercanvas/pyodide.py b/rendercanvas/pyodide.py index 928a80c..08fd710 100644 --- a/rendercanvas/pyodide.py +++ b/rendercanvas/pyodide.py @@ -433,10 +433,9 @@ def _rc_get_present_methods(self): "formats": ["rgba-u8"], }, # wgpu-specific presentation. The wgpu.backends.pyodide.GPUCanvasContext must be able to consume this. - # Most importantly, it will need to access the gpu context. I want to avoid storing a heavy object in this dict, so let's just store the name of the attribute. "screen": { "platform": "browser", - "native_canvas_attribute": "_canvas_element", + "window": self._canvas_element, # Just provide the canvas object }, } diff --git a/rendercanvas/utils/bitmaprenderingcontext.py b/rendercanvas/utils/bitmaprenderingcontext.py deleted file mode 100644 index 78bdc7b..0000000 --- a/rendercanvas/utils/bitmaprenderingcontext.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Provide a simple context class to support ``canvas.get_context('bitmap')``. -""" - -import weakref - - -def rendercanvas_context_hook(canvas, present_methods): - """Hook so this context can be picked up by ``canvas.get_context()``""" - return BitmapRenderingContext(canvas, present_methods) - - -class BitmapRenderingContext: - """A context that supports rendering by generating grayscale or rgba images. - - This is inspired by JS ``get_context('bitmaprenderer')`` which returns a ``ImageBitmapRenderingContext``. - It is a relatively simple context to implement, and provides a easy entry to using ``rendercanvas``. - """ - - def __init__(self, canvas, present_methods): - self._canvas_ref = weakref.ref(canvas) - self._present_methods = present_methods - assert "screen" in present_methods or "bitmap" in present_methods - self._present_method = "bitmap" if "bitmap" in present_methods else "screen" - if self._present_method == "screen": - from rendercanvas.utils.bitmappresentadapter import BitmapPresentAdapter - - self._screen_adapter = BitmapPresentAdapter(canvas, present_methods) - - self._bitmap_and_format = None - - @property - def canvas(self): - """The associated canvas object.""" - return self._canvas_ref() - - def set_bitmap(self, bitmap): - """Set the rendered bitmap image. - - Call this in the draw event. The bitmap must be an object that can be - conveted to a memoryview, like a numpy array. It must represent a 2D - image in either grayscale or rgba format, with uint8 values - """ - - m = memoryview(bitmap) - - # Check dtype - if m.format == "B": - dtype = "u8" - else: - raise ValueError( - "Unsupported bitmap dtype/format '{m.format}', expecting unsigned bytes ('B')." - ) - - # Get color format - color_format = None - if len(m.shape) == 2: - color_format = "i" - elif len(m.shape) == 3: - if m.shape[2] == 1: - color_format = "i" - elif m.shape[2] == 4: - color_format = "rgba" - if not color_format: - raise ValueError( - f"Unsupported bitmap shape {m.shape}, expecting a 2D grayscale or rgba image." - ) - - # We should now have one of two formats - format = f"{color_format}-{dtype}" - assert format in ("rgba-u8", "i-u8") - - self._bitmap_and_format = m, format - - def present(self): - """Allow RenderCanvas to present the bitmap. Don't call this yourself.""" - if self._bitmap_and_format is None: - return {"method": "skip"} - elif self._present_method == "bitmap": - bitmap, format = self._bitmap_and_format - if format not in self._present_methods["bitmap"]["formats"]: - # Convert from i-u8 -> rgba-u8. This surely hurts performance. - assert format == "i-u8" - flat_bitmap = bitmap.cast("B", (bitmap.nbytes,)) - new_bitmap = memoryview(bytearray(bitmap.nbytes * 4)).cast("B") - new_bitmap[::4] = flat_bitmap - new_bitmap[1::4] = flat_bitmap - new_bitmap[2::4] = flat_bitmap - new_bitmap[3::4] = b"\xff" * flat_bitmap.nbytes - bitmap = new_bitmap.cast("B", (*bitmap.shape, 4)) - format = "rgba-u8" - return { - "method": "bitmap", - "data": bitmap, - "format": format, - } - elif self._present_method == "screen": - self._screen_adapter.present_bitmap(self._bitmap_and_format[0]) - return {"method": "screen"} - else: - return {"method": "fail", "message": "wut?"} diff --git a/rendercanvas/utils/cube.py b/rendercanvas/utils/cube.py index e7da9c8..dd6d03c 100644 --- a/rendercanvas/utils/cube.py +++ b/rendercanvas/utils/cube.py @@ -63,7 +63,7 @@ async def setup_drawing_async(canvas, limits=None, format=None): def get_render_pipeline_kwargs(canvas, device, pipeline_layout, render_texture_format): - context = canvas.get_context("wgpu") + context = canvas.get_wgpu_context() if render_texture_format is None: render_texture_format = context.get_preferred_format(device.adapter) context.configure(device=device, format=render_texture_format) diff --git a/tests/test_base.py b/tests/test_base.py index c83c5aa..250ed13 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -76,7 +76,7 @@ class MyOffscreenCanvas(rendercanvas.BaseRenderCanvas): def __init__(self): super().__init__() self.frame_count = 0 - self.physical_size = 100, 100 + self._set_size_info((100, 100), 1) def _rc_get_present_methods(self): return { @@ -89,15 +89,6 @@ def _rc_present_bitmap(self, *, data, format, **kwargs): self.frame_count += 1 self.array = np.frombuffer(data, np.uint8).reshape(data.shape) - def get_pixel_ratio(self): - return 1 - - def get_logical_size(self): - return self.get_physical_size() - - def get_physical_size(self): - return self.physical_size - @mark.skipif(not can_use_wgpu_lib, reason="Needs wgpu lib") def test_run_bare_canvas(): @@ -162,7 +153,7 @@ def draw_frame(): assert np.all(canvas.array[:, :, 1] == 255) # Change resolution - canvas.physical_size = 120, 100 + canvas._set_size_info((120, 100), 1) # Draw 3 canvas.force_draw() @@ -171,7 +162,7 @@ def draw_frame(): assert np.all(canvas.array[:, :, 1] == 255) # Change resolution - canvas.physical_size = 120, 140 + canvas._set_size_info((120, 140), 1) # Draw 4 canvas.force_draw() diff --git a/tests/test_context.py b/tests/test_context.py index 80bd2b8..17d1e58 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,13 +1,15 @@ import numpy as np -from testutils import can_use_wgpu_lib, run_tests -import pytest - - +from rendercanvas.contexts import ( + BaseContext, + BitmapContext, + WgpuContext, + BitmapContextToScreen, + WgpuContextToBitmap, +) from rendercanvas.offscreen import OffscreenRenderCanvas as ManualOffscreenRenderCanvas -from rendercanvas.utils.bitmaprenderingcontext import BitmapRenderingContext -if can_use_wgpu_lib: - from rendercanvas.utils.bitmappresentadapter import BitmapPresentAdapter +from testutils import can_use_wgpu_lib, run_tests +import pytest def get_test_bitmap(width, height): @@ -28,60 +30,47 @@ def get_test_bitmap(width, height): return bitmap -hook_call_count = 0 - - -def rendercanvas_context_hook(canvas, present_methods): - global hook_call_count - hook_call_count += 1 - return SpecialAdapterNoop(canvas, present_methods) +class WgpuContextToBitmapLookLikeWgpuPy(WgpuContextToBitmap): + """A WgpuContextToBitmap with an API like (the new) wgpu.GPUCanvasContext. + The API's look close enough that we can mimic it with this. This allows + testing a workflow that goes from bitmap -> wgpu -> wgpu -> bitmap + """ -class SpecialAdapterNoop: - def __init__(self, canvas, present_methods): - self.canvas = canvas + def set_physical_size(self, w, h): + size_info = { + "physical_size": (w, h), + "native_pixel_ratio": 1.0, + "total_pixel_ratio": 1.0, + "logical_size": (float(w), float(h)), + } + self._rc_set_size_info(size_info) def present(self): - return {"method": "skip"} + return self._rc_present() + def close(self): + self._rc_close() -class SpecialAdapterFail1: - def __init__(self, canvas, present_methods): - 1 / 0 # noqa +class BitmapContextToWgpuAndBackToBimap(BitmapContextToScreen): + """A bitmap context that takes a detour via wgpu :)""" -class SpecialAdapterFail2: - # does not have a present method - def __init__(self, canvas, present_methods): - self.canvas = canvas - - -class SpecialContextWithWgpuAdapter: - """This looks a lot like the BitmapPresentAdapter, - except it will *always* use the adapter, so that we can touch that code path. - """ - - def __init__(self, canvas, present_methods): - self.adapter = BitmapPresentAdapter(canvas, present_methods) - self.canvas = canvas - - def set_bitmap(self, bitmap): - self.bitmap = bitmap - - def present(self): - return self.adapter.present_bitmap(self.bitmap) + def _create_wgpu_py_context(self): + self._wgpu_context = WgpuContextToBitmapLookLikeWgpuPy(self._present_info) # %% -def test_context_selection11(): +def test_context_selection_bitmap(): # Select our builtin bitmap context canvas = ManualOffscreenRenderCanvas() context = canvas.get_context("bitmap") - assert isinstance(context, BitmapRenderingContext) + assert isinstance(context, BitmapContext) + assert isinstance(context, BaseContext) # Cannot select another context now with pytest.raises(RuntimeError): @@ -91,55 +80,32 @@ def test_context_selection11(): context2 = canvas.get_context("bitmap") assert context2 is context - -def test_context_selection12(): - # Select bitmap context using full module name - - canvas = ManualOffscreenRenderCanvas() - - context = canvas.get_context("rendercanvas.utils.bitmaprenderingcontext") - assert isinstance(context, BitmapRenderingContext) - - # Same thing - context2 = canvas.get_context("bitmap") + # And this also works + context2 = canvas.get_bitmap_context() assert context2 is context -def test_context_selection13(): - # Select bitmap context using full path to class. +@pytest.mark.skipif(not can_use_wgpu_lib, reason="Needs wgpu lib") +def test_context_selection_wgpu(): + # Select our builtin bitmap context + canvas = ManualOffscreenRenderCanvas() - context = canvas.get_context( - "rendercanvas.utils.bitmaprenderingcontext:BitmapRenderingContext" - ) - assert isinstance(context, BitmapRenderingContext) + context = canvas.get_context("wgpu") + assert isinstance(context, WgpuContext) + assert isinstance(context, BaseContext) - # Same thing ... but get_context cannot know + # Cannot select another context now with pytest.raises(RuntimeError): canvas.get_context("bitmap") + # But can select the same one + context2 = canvas.get_context("wgpu") + assert context2 is context -def test_context_selection22(): - # Select bitmap context using full module name, and the hook - - canvas = ManualOffscreenRenderCanvas() - - count = hook_call_count - context = canvas.get_context(__name__) - assert hook_call_count == count + 1 # hook is called - - assert isinstance(context, SpecialAdapterNoop) - - -def test_context_selection23(): - # Select bitmap context using full path to class. - canvas = ManualOffscreenRenderCanvas() - - count = hook_call_count - context = canvas.get_context(__name__ + ":SpecialAdapterNoop") - assert hook_call_count == count # hook is not called - - assert isinstance(context, SpecialAdapterNoop) + # And this also works + context2 = canvas.get_wgpu_context() + assert context2 is context def test_context_selection_fails(): @@ -152,39 +118,20 @@ def test_context_selection_fails(): # Must be a string with pytest.raises(TypeError) as err: - canvas.get_context(BitmapRenderingContext) + canvas.get_context(BitmapContext) assert "must be str" in str(err) - # Must be a valid module - with pytest.raises(ValueError) as err: - canvas.get_context("thisisnotavalidmodule") - assert "no module named" in str(err).lower() - - # Must be a valid module - with pytest.raises(ValueError) as err: - canvas.get_context("thisisnot.avalidmodule.either") - assert "no module named" in str(err).lower() - - # The module must have a hook - with pytest.raises(ValueError) as err: - canvas.get_context("rendercanvas._coreutils") - assert "could not find" in str(err) - - # Error on instantiation - with pytest.raises(ZeroDivisionError): - canvas.get_context(__name__ + ":SpecialAdapterFail1") - - # Class does not look like a context - with pytest.raises(RuntimeError) as err: - canvas.get_context(__name__ + ":SpecialAdapterFail2") - assert "does not have a present method." in str(err) + # Must be a valid string + with pytest.raises(TypeError) as err: + canvas.get_context("notacontexttype") + assert "context type is invalid" in str(err) def test_bitmap_context(): # Create canvas, and select the rendering context canvas = ManualOffscreenRenderCanvas() context = canvas.get_context("bitmap") - assert isinstance(context, BitmapRenderingContext) + assert isinstance(context, BitmapContext) # Create and set bitmap bitmap = get_test_bitmap(*canvas.get_physical_size()) @@ -213,19 +160,21 @@ def test_bitmap_context(): @pytest.mark.skipif(not can_use_wgpu_lib, reason="Needs wgpu lib") -def test_bitmap_present_adapter(): +def test_wgpu_context(): # Create canvas and attach our special adapter canvas canvas = ManualOffscreenRenderCanvas() - context = canvas.get_context(__name__ + ":SpecialContextWithWgpuAdapter") + context = BitmapContextToWgpuAndBackToBimap( + {"method": "bitmap", "formats": ["rgba-u8"]} + ) + canvas._canvas_context = context + assert isinstance(context, BitmapContext) # Create and set bitmap bitmap = get_test_bitmap(*canvas.get_physical_size()) context.set_bitmap(bitmap) - # Draw! This will call SpecialContextWithWgpuAdapter.present(), which will - # invoke the adapter to render the bitmap to a texture. The GpuCanvasContext.present() - # method will also be called, which will download the texture to a bitmap, - # and that's what we receive as the result. + # Draw! The primary context will upload the bitmap to a wgpu texture, + # and the wrapped context will then download it to a bitmap again. # So this little line here touches quite a lot of code. In the end, the bitmap # should be unchanged, because the adapter assumes that the incoming bitmap # is in the sRGB colorspace.