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)