diff --git a/README.md b/README.md index 3ed79b7..c03884e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ One canvas API, multiple backends πŸš€ +*This project is part of [pygfx.org](https://pygfx.org)* + ## Introduction @@ -33,7 +35,7 @@ same to the code that renders to them. Yet, the GUI systems are very different The main use-case is rendering with [wgpu](https://github.com/pygfx/wgpu-py), but ``rendercanvas``can be used by anything that can render based on a window-id or -by producing rgba images. +by producing bitmap images. ## Installation @@ -51,18 +53,56 @@ pip install rendercanvas glfw Also see the [online documentation](https://rendercanvas.readthedocs.io) and the [examples](https://github.com/pygfx/rendercanvas/tree/main/examples). +A minimal example that renders noise: +```py +import numpy as np +from rendercanvas.auto import RenderCanvas, loop + +canvas = RenderCanvas(update_mode="continuous") +context = canvas.get_context("bitmap") + +@canvas.request_draw +def animate(): + w, h = canvas.get_logical_size() + bitmap = np.random.uniform(0, 255, (h, w)).astype(np.uint8) + context.set_bitmap(bitmap) + +loop.run() +``` + +Run wgpu visualizations: ```py -# Select either the glfw, qt or jupyter backend from rendercanvas.auto import RenderCanvas, loop +from rendercanvas.utils.cube import setup_drawing_sync + + +canvas = RenderCanvas( + title="The wgpu cube example on $backend", update_mode="continuous" +) +draw_frame = setup_drawing_sync(canvas) +canvas.request_draw(draw_frame) + +loop.run() +```` + +Embed in a Qt application: +```py +from PySide6 import QtWidgets +from rendercanvas.qt import QRenderWidget + +class Main(QtWidgets.QWidget): -# Visualizations can be embedded as a widget in a Qt application. -# Supported qt libs are PySide6, PyQt6, PySide2 or PyQt5. -from rendercanvas.pyside6 import QRenderWidget + def __init__(self): + super().__init__() + splitter = QtWidgets.QSplitter() + self.canvas = QRenderWidget(splitter) + ... -# Now specify what the canvas should do on a draw -TODO +app = QtWidgets.QApplication([]) +main = Main() +app.exec() ``` diff --git a/docs/advanced.rst b/docs/advanced.rst new file mode 100644 index 0000000..2d44b42 --- /dev/null +++ b/docs/advanced.rst @@ -0,0 +1,9 @@ +Advanced +======== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + backendapi + contextapi diff --git a/docs/backendapi.rst b/docs/backendapi.rst index c59ed73..3c31945 100644 --- a/docs/backendapi.rst +++ b/docs/backendapi.rst @@ -1,5 +1,5 @@ -Internal backend API -==================== +How backends work +================= This page documents what's needed to implement a backend for ``rendercanvas``. The purpose of this documentation is to help maintain current and new backends. Making this internal API clear helps understanding how the backend-system works. diff --git a/docs/conf.py b/docs/conf.py index 7a7d977..af8b8b9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,8 @@ # Load wglibu so autodoc can query docstrings import rendercanvas # noqa: E402 import rendercanvas.stub # noqa: E402 - we use the stub backend to generate doccs - +import rendercanvas._context # noqa: E402 - we use the ContexInterface to generate doccs +import rendercanvas.utils.bitmappresentadapter # noqa: E402 # -- Project information ----------------------------------------------------- diff --git a/docs/contextapi.rst b/docs/contextapi.rst new file mode 100644 index 0000000..252ea08 --- /dev/null +++ b/docs/contextapi.rst @@ -0,0 +1,99 @@ +How context objects work +======================== + +This page documents the working bentween the ``RenderCanvas`` and the context object. + + +Introduction +------------ + +The process of rendering to a canvas can be separated in two parts: *rendering* +and *presenting*. The role of the context is to facilitate the rendering, and to +then present the result to the screen. For this, the canvas provides one or more +*present-methods*. Each canvas backend must provide at least the 'screen' or +'bitmap' present-method. + +.. code-block:: + + Rendering Presenting + + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ ──screen──► β”‚ β”‚ + ──render──► | Context β”‚ or β”‚ Canvas β”‚ + β”‚ β”‚ ──bitmap──► β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +This means that for the context to be able to present to any canvas, it must +support *both* the 'image' 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. + +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. + +.. code-block:: + + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ ──tex─┐ β”‚ β”‚ + ──render──► | Context β”‚ | β”‚ Canvas β”‚ + β”‚ β”‚ └─bitmap──► β”‚ | + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + download from gpu to cpu + +If the context has a bitmap to present, and the canvas only supports presenting +to screen, you can usse a small utility: the ``BitmapPresentAdapter`` takes a +bitmap and presents it to the screen. + +.. code-block:: + + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”Œβ”€screen──► β”‚ β”‚ + ──render──► | Context β”‚ β”‚ β”‚ Canvas β”‚ + β”‚ β”‚ ──bitmapβ”€β”˜ β”‚ | + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + use BitmapPresentAdapter + +This way, contexts can be made to work with all canvas backens. + +Canvases may also provide additionaly 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/index.rst b/docs/index.rst index aadf641..a52c973 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,7 +10,8 @@ Welcome to the rendercanvas docs! start api backends - backendapi + utils + advanced Indices and tables diff --git a/docs/start.rst b/docs/start.rst index 9c3975b..2908e35 100644 --- a/docs/start.rst +++ b/docs/start.rst @@ -19,9 +19,6 @@ Since most users will want to render something to screen, we recommend installin pip install rendercanvas glfw -Backends --------- - Multiple backends are supported, including multiple GUI libraries, but none of these are installed by default. See :doc:`backends` for details. @@ -36,6 +33,8 @@ In general, it's easiest to let ``rendercanvas`` select a backend automatically: canvas = RenderCanvas() + # ... code to setup the rendering + loop.run() # Enter main-loop @@ -44,11 +43,32 @@ Rendering to the canvas The above just shows a grey window. We want to render to it by using wgpu or by generating images. -This API is still in flux at the moment. TODO +Depending on the tool you'll use to render to the canvas, you need a different context. +The purpose of the context to present the rendered result to the canvas. +There are currently two types of contexts. + +Rendering using bitmaps: .. code-block:: py - present_context = canvas.get_context("wgpu") + context = canvas.get_context("bitmap") + + @canvas.request_draw + def animate(): + # ... produce an image, represented with e.g. a numpy array + context.set_bitmap(image) + +Rendering with wgpu: + +.. code-block:: py + + context = canvas.get_context("wgpu") + context.configure(device) + + @canvas.request_draw + def animate(): + texture = context.get_current_texture() + # ... wgpu code Freezing apps diff --git a/docs/utils.rst b/docs/utils.rst new file mode 100644 index 0000000..5cb0ecb --- /dev/null +++ b/docs/utils.rst @@ -0,0 +1,10 @@ +Utils +===== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + utils_cube + utils_bitmappresentadapter.rst + utils_bitmaprenderingcontext.rst diff --git a/docs/utils_bitmappresentadapter.rst b/docs/utils_bitmappresentadapter.rst new file mode 100644 index 0000000..31b8ddc --- /dev/null +++ b/docs/utils_bitmappresentadapter.rst @@ -0,0 +1,5 @@ +Bitmap present adapter +====================== + +.. automodule:: rendercanvas.utils.bitmappresentadapter + :members: diff --git a/docs/utils_bitmaprenderingcontext.rst b/docs/utils_bitmaprenderingcontext.rst new file mode 100644 index 0000000..1b76c4e --- /dev/null +++ b/docs/utils_bitmaprenderingcontext.rst @@ -0,0 +1,5 @@ +Bitmap rendering context +======================== + +.. automodule:: rendercanvas.utils.bitmaprenderingcontext + :members: diff --git a/docs/utils_cube.rst b/docs/utils_cube.rst new file mode 100644 index 0000000..308726b --- /dev/null +++ b/docs/utils_cube.rst @@ -0,0 +1,5 @@ +Code for wgpu cube example +========================== + +.. automodule:: rendercanvas.utils.cube + :members: \ No newline at end of file diff --git a/examples/noise.py b/examples/noise.py new file mode 100644 index 0000000..a235c83 --- /dev/null +++ b/examples/noise.py @@ -0,0 +1,22 @@ +""" +Simple example that uses the bitmap-context to show images of noise. +""" + +import numpy as np +from rendercanvas.auto import RenderCanvas, loop + + +canvas = RenderCanvas(update_mode="continuous") +context = canvas.get_context("bitmap") + + +@canvas.request_draw +def animate(): + w, h = canvas.get_logical_size() + shape = int(h) // 4, int(w) // 4 + + bitmap = np.random.uniform(0, 255, shape).astype(np.uint8) + context.set_bitmap(bitmap) + + +loop.run() diff --git a/examples/snake.py b/examples/snake.py new file mode 100644 index 0000000..e4eec5a --- /dev/null +++ b/examples/snake.py @@ -0,0 +1,63 @@ +""" +Simple snake game based on bitmap rendering. Work in progress. +""" + +from collections import deque + +import numpy as np + +from rendercanvas.auto import RenderCanvas, loop + + +canvas = RenderCanvas(present_method=None, update_mode="continuous") + +context = canvas.get_context("bitmap") + +world = np.zeros((120, 160), np.uint8) +pos = [100, 100] +direction = [1, 0] +q = deque() + + +@canvas.add_event_handler("key_down") +def on_key(event): + key = event["key"] + if key == "ArrowLeft": + direction[0] = -1 + direction[1] = 0 + elif key == "ArrowRight": + direction[0] = 1 + direction[1] = 0 + elif key == "ArrowUp": + direction[0] = 0 + direction[1] = -1 + elif key == "ArrowDown": + direction[0] = 0 + direction[1] = 1 + + +@canvas.request_draw +def animate(): + pos[0] += direction[0] + pos[1] += direction[1] + + if pos[0] < 0: + pos[0] = world.shape[1] - 1 + elif pos[0] >= world.shape[1]: + pos[0] = 0 + if pos[1] < 0: + pos[1] = world.shape[0] - 1 + elif pos[1] >= world.shape[0]: + pos[1] = 0 + + q.append(tuple(pos)) + world[pos[1], pos[0]] = 255 + + while len(q) > 20: + old_pos = q.popleft() + world[old_pos[1], old_pos[0]] = 0 + + context.set_bitmap(world) + + +loop.run() diff --git a/examples/wx_app.py b/examples/wx_app.py index b20a629..f1d9b9a 100644 --- a/examples/wx_app.py +++ b/examples/wx_app.py @@ -17,7 +17,7 @@ def __init__(self): # Using present_method 'image' because it reports "The surface texture is suboptimal" self.canvas = RenderWidget( - self, update_mode="continuous", present_method="image" + self, update_mode="continuous", present_method="bitmap" ) self.button = wx.Button(self, -1, "Hello world") self.output = wx.StaticText(self) diff --git a/pyproject.toml b/pyproject.toml index 3f27d0f..65039e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ glfw = ["glfw>=1.9"] # For devs / ci lint = ["ruff", "pre-commit"] examples = ["numpy", "wgpu", "glfw", "pyside6"] -docs = ["sphinx>7.2", "sphinx_rtd_theme", "sphinx-gallery"] +docs = ["sphinx>7.2", "sphinx_rtd_theme", "sphinx-gallery", "numpy", "wgpu"] tests = ["pytest", "numpy", "wgpu", "glfw"] dev = ["rendercanvas[lint,tests,examples,docs]"] diff --git a/rendercanvas/_context.py b/rendercanvas/_context.py new file mode 100644 index 0000000..e87557a --- /dev/null +++ b/rendercanvas/_context.py @@ -0,0 +1,78 @@ +""" +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"} diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 1a99ae3..9c93bf5 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -4,7 +4,7 @@ __all__ = ["WrapperRenderCanvas", "BaseRenderCanvas", "BaseLoop", "BaseTimer"] -import sys +import importlib from ._events import EventEmitter, EventType # noqa: F401 from ._loop import Scheduler, BaseLoop, BaseTimer @@ -44,8 +44,8 @@ class BaseRenderCanvas: 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. - present_method (str | None): The method to present the rendered image. - Can be set to 'screen' or 'image'. Default None (auto-select). + 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). """ @@ -132,71 +132,82 @@ def __del__(self): _canvas_context = None # set in get_context() - def get_present_info(self): - """Get information about the surface to render to. - - It must return a small dict, used by the canvas-context to determine - how the rendered result should be presented to the canvas. There are - two possible methods. - - If the ``method`` field is "screen", the context will render directly - to a surface representing the region on the screen. The dict should - have a ``window`` 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. - - When the ``method`` field is "image", the context will render to a - texture, download the result to RAM, and call ``canvas.present_image()`` - with the image data. Additional info (like format) is passed as kwargs. - This method enables various types of canvases (including remote ones), - but note that it has a performance penalty compared to rendering - directly to the screen. - - The dict can further contain fields ``formats`` and ``alpha_modes`` to - define the canvas capabilities. For the "image" method, the default - formats is ``["rgba8unorm-srgb", "rgba8unorm"]``, and the default - alpha_modes is ``["opaque"]``. - """ - return self._rc_get_present_info() - def get_physical_size(self): """Get the physical size of the canvas in integer pixels.""" return self._rc_get_physical_size() - def get_context(self, kind="webgpu"): - """Get the ``GPUCanvasContext`` object corresponding to this canvas. + def get_context(self, context_type): + """Get a context object that can be used to render to this canvas. - The context is used to obtain a texture to render to, and to - present that texture to the canvas. This class provides a - default implementation to get the appropriate context. + The context takes care of presenting the rendered result to the canvas. + Different types of contexts are available: - The ``kind`` argument is a remnant from the WebGPU spec and - must always be "webgpu". + * "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. + + 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 + invoked. It is not possible to get a different context object once the first + one has been created. """ - # Note that this function is analog to HtmlCanvas.getContext(), except - # here the only valid arg is 'webgpu', which is also made the default. - assert kind == "webgpu" - if self._canvas_context is None: - backend_module = "" - if "wgpu" in sys.modules: - backend_module = sys.modules["wgpu"].gpu.__module__ - if backend_module in ("", "wgpu._classes"): + + # Note that this method is analog to HtmlCanvas.getContext(), except + # the context_type is different, since contexts are provided by other projects. + + 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) + + # Is the context already set? + if self._canvas_context is not None: + if resolved_context_type == self._canvas_context._context_type: + return self._canvas_context + else: raise RuntimeError( - "A backend must be selected (e.g. with wgpu.gpu.request_adapter()) before canvas.get_context() can be called." + f"Cannot get context for '{context_type}': a context of type '{self._canvas_context._context_type}' is already set." ) - CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 - self._canvas_context = CanvasContext(self) - return self._canvas_context - def present_image(self, image, **kwargs): - """Consume the final rendered image. + # 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 - This is called when using the "image" method, see ``get_present_info()``. - Canvases that don't support offscreen rendering don't need to implement - this method. - """ - self._rc_present_image(image, **kwargs) + # Create the context + context = factory_func(self, self._rc_get_present_methods()) + + # Quick checks to make sure the context has the correct API + if not (hasattr(context, "canvas") and context.canvas is self): + raise RuntimeError( + "The context does not have a canvas attribute that refers to this canvas." + ) + if not (hasattr(context, "present") and callable(context.present)): + raise RuntimeError("The context does not have a present method.") + + # Done + self._canvas_context = context + self._canvas_context._context_type = resolved_context_type + return self._canvas_context # %% Events @@ -343,7 +354,16 @@ 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: - context.present() + result = context.present() + method = result.pop("method") + if method in ("skip", "screen"): + pass # nothing we need to do + elif method == "fail": + raise RuntimeError(method.get("message", "") or "present error") + else: + # Pass the result to the literal present method + func = getattr(self, f"_rc_present_{method}") + func(**result) finally: self.__is_drawing = False @@ -395,8 +415,31 @@ def _rc_get_loop(self): """ return None - def _rc_get_present_info(self): - """Get present info. See the corresponding public method.""" + def _rc_get_present_methods(self): + """Get info on the present methods supported by this canvas. + + Must return a small dict, used by the canvas-context to determine + how the rendered result will be presented to the canvas. + This method is only called once, when the context is created. + + Each supported method is represented by a field in the dict. The value + is another dict with information specific to that present method. + A canvas backend must implement at least either "screen" or "bitmap". + + With method "screen", the context will render directly to a surface + representing the region on the screen. The sub-dict should have a ``window`` + 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. + + 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 + 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. + """ raise NotImplementedError() def _rc_request_draw(self): @@ -425,8 +468,11 @@ def _rc_force_draw(self): """ self._draw_frame_and_present() - def _rc_present_image(self, image, **kwargs): - """Present the given image. Only used with present_method 'image'.""" + def _rc_present_bitmap(self, *, data, format, **kwargs): + """Present the given image bitmap. Only used with present_method 'bitmap'. + + If a canvas supports special present methods, it will need to implement corresponding ``_rc_present_xx()`` methods. + """ raise NotImplementedError() def _rc_get_physical_size(self): @@ -482,8 +528,6 @@ class WrapperRenderCanvas(BaseRenderCanvas): wrapped canvas and set it as ``_subwidget``. """ - # Events - def add_event_handler(self, *args, **kwargs): return self._subwidget._events.add_handler(*args, **kwargs) @@ -493,21 +537,9 @@ def remove_event_handler(self, *args, **kwargs): def submit_event(self, event): return self._subwidget._events.submit(event) - # Must implement - def get_context(self, *args, **kwargs): return self._subwidget.get_context(*args, **kwargs) - # So these should not be necessary - - def get_present_info(self): - raise NotImplementedError() - - def present_image(self, image, **kwargs): - raise NotImplementedError() - - # More redirection - def request_draw(self, *args, **kwargs): return self._subwidget.request_draw(*args, **kwargs) diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index 0e8525d..209a46a 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -106,33 +106,37 @@ } -def get_glfw_present_info(window): +def get_glfw_present_methods(window): if sys.platform.startswith("win"): return { - "method": "screen", - "platform": "windows", - "window": int(glfw.get_win32_window(window)), + "screen": { + "platform": "windows", + "window": int(glfw.get_win32_window(window)), + } } elif sys.platform.startswith("darwin"): return { - "method": "screen", - "platform": "cocoa", - "window": int(glfw.get_cocoa_window(window)), + "screen": { + "platform": "cocoa", + "window": int(glfw.get_cocoa_window(window)), + } } elif sys.platform.startswith("linux"): if is_wayland: return { - "method": "screen", - "platform": "wayland", - "window": int(glfw.get_wayland_window(window)), - "display": int(glfw.get_wayland_display()), + "screen": { + "platform": "wayland", + "window": int(glfw.get_wayland_window(window)), + "display": int(glfw.get_wayland_display()), + } } else: return { - "method": "screen", - "platform": "x11", - "window": int(glfw.get_x11_window(window)), - "display": int(glfw.get_x11_display()), + "screen": { + "platform": "x11", + "window": int(glfw.get_x11_window(window)), + "display": int(glfw.get_x11_display()), + } } else: raise RuntimeError(f"Cannot get GLFW surface info on {sys.platform}.") @@ -152,9 +156,9 @@ def __init__(self, *args, present_method=None, **kwargs): loop.init_glfw() super().__init__(*args, **kwargs) - if present_method == "image": + if present_method == "bitmap": logger.warning( - "Ignoreing present_method 'image'; glfw can only render to screen" + "Ignoreing present_method 'bitmap'; glfw can only render to screen" ) # Set window hints @@ -271,8 +275,8 @@ def _set_logical_size(self, new_logical_size): def _rc_get_loop(self): return loop - def _rc_get_present_info(self): - return get_glfw_present_info(self._window) + def _rc_get_present_methods(self): + return get_glfw_present_methods(self._window) def _rc_request_draw(self): if not self._is_minimized: @@ -281,7 +285,7 @@ def _rc_request_draw(self): def _rc_force_draw(self): self._draw_frame_and_present() - def _rc_present_image(self, image, **kwargs): + def _rc_present_bitmap(self, **kwargs): raise NotImplementedError() # AFAIK glfw does not have a builtin way to blit an image. It also does # not really need one, since it's the most reliable backend to diff --git a/rendercanvas/jupyter.py b/rendercanvas/jupyter.py index ad1d294..26a6c64 100644 --- a/rendercanvas/jupyter.py +++ b/rendercanvas/jupyter.py @@ -56,14 +56,16 @@ def get_frame(self): def _rc_get_loop(self): return loop - def _rc_get_present_info(self): - # Use a format that maps well to PNG: rgba8norm. Use srgb for - # perceptive color mapping. This is the common colorspace for - # e.g. png and jpg images. Most tools (browsers included) will - # blit the png to screen as-is, and a screen wants colors in srgb. + def _rc_get_present_methods(self): + # We stick to the two common formats, because these can be easily converted to png + # We assyme that srgb is used for perceptive color mapping. This is the + # common colorspace for e.g. png and jpg images. Most tools (browsers + # included) will blit the png to screen as-is, and a screen wants colors + # in srgb. return { - "method": "image", - "formats": ["rgba8unorm-srgb", "rgba8unorm"], + "bitmap": { + "formats": ["rgba-u8"], + } } def _rc_request_draw(self): @@ -80,9 +82,10 @@ def _rc_force_draw(self): if array is not None: self._rfb_send_frame(array) - def _rc_present_image(self, image, **kwargs): + def _rc_present_bitmap(self, *, data, format, **kwargs): # Convert memoryview to ndarray (no copy) - self._last_image = np.frombuffer(image, np.uint8).reshape(image.shape) + assert format == "rgba-u8" + self._last_image = np.frombuffer(data, np.uint8).reshape(data.shape) def _rc_get_physical_size(self): return int(self._logical_size[0] * self._pixel_ratio), int( diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index 020905f..a5459a3 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -25,10 +25,11 @@ def __init__(self, *args, pixel_ratio=1.0, **kwargs): def _rc_get_loop(self): return None # No scheduling - def _rc_get_present_info(self): + def _rc_get_present_methods(self): return { - "method": "image", - "formats": ["rgba8unorm-srgb", "rgba8unorm"], + "bitmap": { + "formats": ["rgba-u8"], + } } def _rc_request_draw(self): @@ -39,8 +40,8 @@ def _rc_request_draw(self): def _rc_force_draw(self): self._draw_frame_and_present() - def _rc_present_image(self, image, **kwargs): - self._last_image = image + def _rc_present_bitmap(self, *, data, format, **kwargs): + self._last_image = data def _rc_get_physical_size(self): return int(self._logical_size[0] * self._pixel_ratio), int( diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 4190440..5cee615 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -125,6 +125,13 @@ def check_qt_libname(expected_libname): int(Keys.Key_Tab): "Tab", } +BITMAP_FORMAT_MAP = { + "rgba-u8": QtGui.QImage.Format.Format_RGBA8888, + "rgb-u8": QtGui.QImage.Format.Format_RGB888, + "i-u8": QtGui.QImage.Format.Format_Grayscale8, + "i-u16": QtGui.QImage.Format.Format_Grayscale16, +} + def enable_hidpi(): """Enable high-res displays.""" @@ -173,7 +180,7 @@ def __init__(self, *args, present_method=None, **kwargs): self._present_to_screen = False elif present_method == "screen": self._present_to_screen = True - elif present_method == "image": + elif present_method == "bitmap": self._present_to_screen = False else: raise ValueError(f"Invalid present_method {present_method}") @@ -234,22 +241,20 @@ def update(self): def _rc_get_loop(self): return loop - def _rc_get_present_info(self): + def _rc_get_present_methods(self): global _show_image_method_warning if self._surface_ids is None: self._surface_ids = self._get_surface_ids() + + methods = {} if self._present_to_screen: - info = {"method": "screen"} - info.update(self._surface_ids) + methods["screen"] = self._surface_ids else: if _show_image_method_warning: - logger.warn(_show_image_method_warning) + logger.warning(_show_image_method_warning) _show_image_method_warning = None - info = { - "method": "image", - "formats": ["rgba8unorm-srgb", "rgba8unorm"], - } - return info + methods["bitmap"] = {"formats": list(BITMAP_FORMAT_MAP.keys())} + return methods def _rc_request_draw(self): # Ask Qt to do a paint event @@ -262,9 +267,9 @@ def _rc_force_draw(self): # of nasty side-effects (e.g. the scheduler timer keeps ticking, invoking other draws, etc.). self.repaint() - def _rc_present_image(self, image_data, **kwargs): - size = image_data.shape[1], image_data.shape[0] # width, height - rect1 = QtCore.QRect(0, 0, size[0], size[1]) + def _rc_present_bitmap(self, *, data, format, **kwargs): + width, height = data.shape[1], data.shape[0] # width, height + rect1 = QtCore.QRect(0, 0, width, height) rect2 = self.rect() painter = QtGui.QPainter(self) @@ -282,13 +287,9 @@ def _rc_present_image(self, image_data, **kwargs): False, ) - image = QtGui.QImage( - image_data, - size[0], - size[1], - size[0] * 4, - QtGui.QImage.Format.Format_RGBA8888, - ) + qtformat = BITMAP_FORMAT_MAP[format] + bytes_per_line = data.strides[0] + image = QtGui.QImage(data, width, height, bytes_per_line, qtformat) painter.drawImage(rect2, image, rect1) diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index d68993f..63e35c2 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -31,7 +31,7 @@ def _draw_frame_and_present(self): def _rc_get_loop(self): return None - def _rc_get_present_info(self): + def _rc_get_present_methods(self): raise NotImplementedError() def _rc_request_draw(self): @@ -40,7 +40,7 @@ def _rc_request_draw(self): def _rc_force_draw(self): self._draw_frame_and_present() - def _rc_present_image(self, image, **kwargs): + def _rc_present_bitmap(self, *, data, format, **kwargs): raise NotImplementedError() def _rc_get_physical_size(self): diff --git a/rendercanvas/utils/bitmappresentadapter.py b/rendercanvas/utils/bitmappresentadapter.py new file mode 100644 index 0000000..3275906 --- /dev/null +++ b/rendercanvas/utils/bitmappresentadapter.py @@ -0,0 +1,344 @@ +""" +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 supoorts 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: + self._context.configure(device=self._device, format="rgba8unorm") + + 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.""" + + def __init__(self, device): + self._device = device + self._pipeline_layout = None + self._pipeline = None + self._texture = None + self._uniform_data = memoryview(bytearray(1 * 4)).cast("f") + + def set_texture_data(self, data): + """Upload new data to the texture. Creates a new internal texture object if needed.""" + m = memoryview(data) + + texture_format = self._get_format_from_memoryview(m) + texture_size = m.shape[1], m.shape[0], 1 + + # Lazy init for the static stuff + if self._pipeline_layout is None: + self._create_uniform_buffer() + self._create_pipeline_layout() + + # Need new texture? + if ( + self._texture is None + or self._texture.size != texture_size + or texture_format != self._texture.format + ): + self._create_texture(texture_size, texture_format) + self._create_bind_groups() + + # Update buffer data + self._uniform_data[0] = 1 if texture_format.startswith("r8") else 4 + + # Upload data + self._update_texture(m) + self._update_uniform_buffer() + + def _get_format_from_memoryview(self, m): + # 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." + ) + + # Deduce wgpu texture format + format_map = { + "i-u8": wgpu.TextureFormat.r8unorm, + "rgba-u8": wgpu.TextureFormat.rgba8unorm, + } + format = f"{color_format}-{dtype}" + return format_map[format] + + def _create_uniform_buffer(self): + device = self._device + self._uniform_buffer = device.create_buffer( + size=self._uniform_data.nbytes, + usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, + ) + + def _update_uniform_buffer(self): + device = self._device + device.queue.write_buffer(self._uniform_buffer, 0, self._uniform_data) + + def _create_texture(self, size, format): + device = self._device + self._texture = device.create_texture( + size=size, + usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, + dimension=wgpu.TextureDimension.d2, + format=format, + mip_level_count=1, + sample_count=1, + ) + self._texture_view = self._texture.create_view() + self._sampler = device.create_sampler() + + def _update_texture(self, texture_data): + device = self._device + size = texture_data.shape[1], texture_data.shape[0], 1 + device.queue.write_texture( + { + "texture": self._texture, + "mip_level": 0, + "origin": (0, 0, 0), + }, + texture_data, + { + "offset": 0, + "bytes_per_row": texture_data.strides[0], + }, + size, + ) + + def _create_pipeline_layout(self): + device = self._device + bind_groups_layout_entries = [[]] + + bind_groups_layout_entries[0].append( + { + "binding": 0, + "visibility": wgpu.ShaderStage.VERTEX | wgpu.ShaderStage.FRAGMENT, + "buffer": {}, + } + ) + bind_groups_layout_entries[0].append( + { + "binding": 1, + "visibility": wgpu.ShaderStage.FRAGMENT, + "texture": {}, + } + ) + bind_groups_layout_entries[0].append( + { + "binding": 2, + "visibility": wgpu.ShaderStage.FRAGMENT, + "sampler": {}, + } + ) + + # Create the wgpu binding objects + bind_group_layouts = [] + for layout_entries in bind_groups_layout_entries: + bind_group_layout = device.create_bind_group_layout(entries=layout_entries) + bind_group_layouts.append(bind_group_layout) + + self._bind_group_layouts = bind_group_layouts + self._pipeline_layout = device.create_pipeline_layout( + bind_group_layouts=bind_group_layouts + ) + + def _create_pipeline(self, target_texture_view): + device = self._device + texture_format = target_texture_view.texture.format + shader = device.create_shader_module(code=shader_source) + + pipeline_kwargs = dict( + layout=self._pipeline_layout, + vertex={ + "module": shader, + "entry_point": "vs_main", + }, + primitive={ + "topology": wgpu.PrimitiveTopology.triangle_strip, + "front_face": wgpu.FrontFace.ccw, + "cull_mode": wgpu.CullMode.back, + }, + depth_stencil=None, + multisample=None, + fragment={ + "module": shader, + "entry_point": "fs_main", + "targets": [ + { + "format": texture_format, + "blend": { + "alpha": {}, + "color": {}, + }, + } + ], + }, + ) + + self._pipeline = device.create_render_pipeline(**pipeline_kwargs) + + def _create_bind_groups(self): + device = self._device + bind_groups_entries = [[]] + bind_groups_entries[0].append( + { + "binding": 0, + "resource": { + "buffer": self._uniform_buffer, + "offset": 0, + "size": self._uniform_buffer.size, + }, + } + ) + bind_groups_entries[0].append({"binding": 1, "resource": self._texture_view}) + bind_groups_entries[0].append({"binding": 2, "resource": self._sampler}) + + bind_groups = [] + for entries, bind_group_layout in zip( + bind_groups_entries, self._bind_group_layouts + ): + bind_groups.append( + device.create_bind_group(layout=bind_group_layout, entries=entries) + ) + self._bind_groups = bind_groups + + def draw(self, command_encoder, target_texture_view): + """Draw the bitmap to given target texture view.""" + + if self._pipeline is None: + self._create_pipeline(target_texture_view) + + render_pass = command_encoder.begin_render_pass( + color_attachments=[ + { + "view": target_texture_view, + "resolve_target": None, + "clear_value": (0, 0, 0, 1), + "load_op": wgpu.LoadOp.clear, + "store_op": wgpu.StoreOp.store, + } + ], + ) + + render_pass.set_pipeline(self._pipeline) + for bind_group_id, bind_group in enumerate(self._bind_groups): + render_pass.set_bind_group(bind_group_id, bind_group) + render_pass.draw(4, 1, 0, 0) + render_pass.end() + + +shader_source = """ +struct Uniforms { + format: f32, +}; +@group(0) @binding(0) +var uniforms: Uniforms; + +struct VertexInput { + @builtin(vertex_index) vertex_index : u32, +}; +struct VertexOutput { + @location(0) texcoord: vec2, + @builtin(position) pos: vec4, +}; +struct FragmentOutput { + @location(0) color : vec4, +}; + + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var positions = array, 4>( + vec2(-1.0, 1.0), + vec2(-1.0, -1.0), + vec2( 1.0, 1.0), + vec2( 1.0, -1.0), + ); + var texcoords = array, 4>( + vec2(0.0, 0.0), + vec2(0.0, 1.0), + vec2(1.0, 0.0), + vec2(1.0, 1.0), + ); + let index = i32(in.vertex_index); + var out: VertexOutput; + out.pos = vec4(positions[index], 0.0, 1.0); + out.texcoord = vec2(texcoords[index]); + return out; +} + +@group(0) @binding(1) +var r_tex: texture_2d; + +@group(0) @binding(2) +var r_sampler: sampler; + +@fragment +fn fs_main(in: VertexOutput) -> FragmentOutput { + let value = textureSample(r_tex, r_sampler, in.texcoord); + var color = vec4(value); + if (uniforms.format == 1) { + color = vec4(value.r, value.r, value.r, 1.0); + } else if (uniforms.format == 2) { + color = vec4(value.r, value.r, value.r, value.g); + } + // We assume that the input color is sRGB. We don't need to go to physical/linear + // colorspace, because we don't need light calculations or anything. The + // output texture format is a regular rgba8unorm (not srgb), so that no transform + // happens as we write to the texture; the pixel values are already srgb. + var out: FragmentOutput; + out.color = color; + return out; +} +""" diff --git a/rendercanvas/utils/bitmaprenderingcontext.py b/rendercanvas/utils/bitmaprenderingcontext.py new file mode 100644 index 0000000..78bdc7b --- /dev/null +++ b/rendercanvas/utils/bitmaprenderingcontext.py @@ -0,0 +1,101 @@ +""" +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 0a66c19..1532721 100644 --- a/rendercanvas/utils/cube.py +++ b/rendercanvas/utils/cube.py @@ -56,7 +56,7 @@ async def setup_drawing_async(canvas, limits=None): def get_render_pipeline_kwargs(canvas, device, pipeline_layout): - context = canvas.get_context() + context = canvas.get_context("wgpu") render_texture_format = context.get_preferred_format(device.adapter) context.configure(device=device, format=render_texture_format) @@ -281,7 +281,9 @@ async def upload_uniform_buffer_async(): device.queue.submit([command_encoder.finish()]) def draw_frame(): - current_texture_view = canvas.get_context().get_current_texture().create_view() + current_texture_view = ( + canvas.get_context("wgpu").get_current_texture().create_view() + ) command_encoder = device.create_command_encoder() render_pass = command_encoder.begin_render_pass( color_attachments=[ diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 4111fd7..e6725bf 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -142,7 +142,7 @@ def __init__(self, *args, present_method=None, **kwargs): self._present_to_screen = False elif present_method == "screen": self._present_to_screen = True - elif present_method == "image": + elif present_method == "bitmap": self._present_to_screen = False else: raise ValueError(f"Invalid present_method {present_method}") @@ -205,7 +205,7 @@ def _get_surface_ids(self): def _rc_get_loop(self): return loop - def _rc_get_present_info(self): + def _rc_get_present_methods(self): if self._surface_ids is None: # On wx it can take a little while for the handle to be available, # causing GetHandle() to be initially 0, so getting a surface will fail. @@ -214,18 +214,16 @@ def _rc_get_present_info(self): loop.process_wx_events() self._surface_ids = self._get_surface_ids() global _show_image_method_warning + + methods = {} if self._present_to_screen and self._surface_ids: - info = {"method": "screen"} - info.update(self._surface_ids) + methods["screen"] = self._surface_ids else: if _show_image_method_warning: - logger.warn(_show_image_method_warning) + logger.warning(_show_image_method_warning) _show_image_method_warning = None - info = { - "method": "image", - "formats": ["rgba8unorm-srgb", "rgba8unorm"], - } - return info + methods["bitmap"] = {"formats": ["rgba-u8"]} + return methods def _rc_request_draw(self): if self._draw_lock: @@ -239,11 +237,13 @@ def _rc_force_draw(self): self.Refresh() self.Update() - def _rc_present_image(self, image_data, **kwargs): - size = image_data.shape[1], image_data.shape[0] # width, height + def _rc_present_bitmap(self, *, data, format, **kwargs): + # todo: we can easily support more formats here + assert format == "rgba-u8" + width, height = data.shape[1], data.shape[0] dc = wx.PaintDC(self) - bitmap = wx.Bitmap.FromBufferRGBA(*size, image_data) + bitmap = wx.Bitmap.FromBufferRGBA(width, height, data) dc.DrawBitmap(bitmap, 0, 0, False) def _rc_get_physical_size(self): diff --git a/tests/test_base.py b/tests/test_base.py index 69acc7c..eabd040 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -2,9 +2,6 @@ Test the base canvas class. """ -import sys -import subprocess - import numpy as np import rendercanvas from testutils import run_tests, can_use_wgpu_lib @@ -15,22 +12,6 @@ def test_base_canvas_context(): assert hasattr(rendercanvas.BaseRenderCanvas, "get_context") -def test_canvas_get_context_needs_backend_to_be_selected(): - code = "from rendercanvas import BaseRenderCanvas; canvas = BaseRenderCanvas(); canvas.get_context()" - - result = subprocess.run( - [sys.executable, "-c", code], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - universal_newlines=True, - ) - out = result.stdout.rstrip() - - assert "RuntimeError" in out - assert "backend must be selected" in out.lower() - assert "canvas.get_context" in out.lower() - - class CanvasThatRaisesErrorsDuringDrawing(rendercanvas.BaseRenderCanvas): def __init__(self): super().__init__() @@ -97,15 +78,16 @@ def __init__(self): self.frame_count = 0 self.physical_size = 100, 100 - def get_present_info(self): + def _rc_get_present_methods(self): return { - "method": "image", - "formats": ["rgba8unorm-srgb"], + "bitmap": { + "formats": ["rgba-u8"], + } } - def present_image(self, image, **kwargs): + def _rc_present_bitmap(self, *, data, format, **kwargs): self.frame_count += 1 - self.array = np.frombuffer(image, np.uint8).reshape(image.shape) + self.array = np.frombuffer(data, np.uint8).reshape(data.shape) def get_pixel_ratio(self): return 1 @@ -139,7 +121,7 @@ def test_simple_offscreen_canvas(): canvas = MyOffscreenCanvas() device = wgpu.gpu.request_adapter_sync().request_device_sync() - present_context = canvas.get_context() + present_context = canvas.get_context("wgpu") present_context.configure(device=device, format=None) def draw_frame(): diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 0000000..0e9b5d4 --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,247 @@ +import numpy as np +from rendercanvas.utils.bitmappresentadapter import BitmapPresentAdapter +from rendercanvas.utils.bitmaprenderingcontext import BitmapRenderingContext +from rendercanvas.offscreen import ManualOffscreenRenderCanvas + +from testutils import can_use_wgpu_lib, run_tests +import pytest + + +def get_test_bitmap(width, height): + colors = [ + (255, 0, 0, 255), + (0, 255, 0, 255), + (0, 0, 255, 255), + (0, 0, 0, 255), + (50, 50, 50, 255), + (127, 127, 127, 255), + (205, 205, 205, 255), + (255, 255, 255, 255), + ] + w = width // len(colors) + bitmap = np.zeros((height, width, 4), np.uint8) + for i, color in enumerate(colors): + bitmap[:, i * w : (i + 1) * w, :] = color + 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 SpecialAdapterNoop: + def __init__(self, canvas, present_methods): + self.canvas = canvas + + def present(self): + return {"method": "skip"} + + +class SpecialAdapterFail1: + def __init__(self, canvas, present_methods): + 1 / 0 # noqa + + +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 test_context_selection11(): + # Select our builtin bitmap context + + canvas = ManualOffscreenRenderCanvas() + + context = canvas.get_context("bitmap") + assert isinstance(context, BitmapRenderingContext) + + # Cannot select another context now + with pytest.raises(RuntimeError): + canvas.get_context("wgpu") + + # But can select the same one + 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") + assert context2 is context + + +def test_context_selection13(): + # Select bitmap context using full path to class. + canvas = ManualOffscreenRenderCanvas() + + context = canvas.get_context( + "rendercanvas.utils.bitmaprenderingcontext:BitmapRenderingContext" + ) + assert isinstance(context, BitmapRenderingContext) + + # Same thing ... but get_context cannot know + with pytest.raises(RuntimeError): + canvas.get_context("bitmap") + + +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) + + +def test_context_selection_fails(): + canvas = ManualOffscreenRenderCanvas() + + # Must provide a context type arg + with pytest.raises(TypeError) as err: + canvas.get_context() + assert "context_type" in str(err) + + # Must be a string + with pytest.raises(TypeError) as err: + canvas.get_context(BitmapRenderingContext) + 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) + + +def test_bitmap_context(): + # Create canvas, and select the rendering context + canvas = ManualOffscreenRenderCanvas() + context = canvas.get_context("bitmap") + assert isinstance(context, BitmapRenderingContext) + + # Create and set bitmap + bitmap = get_test_bitmap(*canvas.get_physical_size()) + context.set_bitmap(bitmap) + + # Draw! This is not *that* interesting, it just passes the bitmap around + result = canvas.draw() + + assert isinstance(result, memoryview) + result = np.asarray(result) + assert np.all(result == bitmap) + + # pssst ... it's actually the same data! + bitmap.fill(42) + assert np.all(result == bitmap) + + # Now we change the size + + bitmap = get_test_bitmap(50, 50) + context.set_bitmap(bitmap) + + result = np.asarray(canvas.draw()) + + assert result.shape == bitmap.shape + assert np.all(result == bitmap) + + +@pytest.mark.skipif(not can_use_wgpu_lib, reason="Needs wgpu lib") +def test_bitmap_to_screen_adapter(): + # Create canvas and attach our special adapter canvas + canvas = ManualOffscreenRenderCanvas() + context = canvas.get_context(__name__ + ":SpecialContextWithWgpuAdapter") + + # 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. + # 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. + result = canvas.draw() + + assert isinstance(result, memoryview) + result = np.asarray(result) + assert np.all(result == bitmap) + + # Now we change the size + + bitmap = get_test_bitmap(50, 50) + context.set_bitmap(bitmap) + + result = np.asarray(canvas.draw()) + assert result.shape != bitmap.shape + assert result.shape[1] == canvas.get_physical_size()[0] + assert result.shape[0] == canvas.get_physical_size()[1] + + +if __name__ == "__main__": + run_tests(globals()) diff --git a/tests/test_glfw.py b/tests/test_glfw.py index 0f683e1..77efb56 100644 --- a/tests/test_glfw.py +++ b/tests/test_glfw.py @@ -157,7 +157,7 @@ def _get_draw_function(device, canvas): shader = device.create_shader_module(code=shader_source) - present_context = canvas.get_context() + present_context = canvas.get_context("wgpu") render_texture_format = present_context.get_preferred_format(device.adapter) present_context.configure(device=device, format=render_texture_format)