From 379599ce6cb677c10a8d30c87b467e83d774d01e Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Sat, 21 Sep 2024 22:44:59 +0200 Subject: [PATCH] Refactor canvas context to allow presenting as image (#586) * Refactor canvas context to allow presening as image * some cleanup * codegen * Fix flicker * cleaner * fix error on exit * looked into qt image draw performance a bit * Fix/workaround for Qt on Wayland * Fix glfw * Give wx same treatment as qt * Show warning when using offscreen rendering in qt and wx * docs * Update offscreen canvases. No more need for WgpuOfscreenCanvasBase * Update notebook * docs * minor tweaks * update tests * Fix memtest * remove debug text overlay * Bit of docstrings * explaine purpose of canvas context * Rename surface_info -> present_info * draw_to_screen -> present_method * flake --- docs/gui.rst | 1 - examples/triangle_glfw_direct.py | 6 +- examples/triangle_subprocess.py | 8 +- examples/wgpu-examples.ipynb | 129 +++++++++-- tests/test_gui_base.py | 47 ++-- tests/test_gui_glfw.py | 2 +- tests_mem/test_gui.py | 2 +- tests_mem/test_gui_qt.py | 4 +- wgpu/_classes.py | 260 +++++++++++++++++++-- wgpu/backends/wgpu_native/_api.py | 318 ++++++++++---------------- wgpu/backends/wgpu_native/_helpers.py | 33 +-- wgpu/gui/__init__.py | 2 - wgpu/gui/base.py | 72 ++++-- wgpu/gui/glfw.py | 17 +- wgpu/gui/jupyter.py | 42 ++-- wgpu/gui/offscreen.py | 177 ++------------ wgpu/gui/qt.py | 109 +++++++-- wgpu/gui/wx.py | 96 +++++++- wgpu/resources/codegen_report.md | 4 +- 19 files changed, 796 insertions(+), 533 deletions(-) diff --git a/docs/gui.rst b/docs/gui.rst index d54459d0..0680992f 100644 --- a/docs/gui.rst +++ b/docs/gui.rst @@ -21,7 +21,6 @@ The Canvas base classes ~WgpuCanvasInterface ~WgpuCanvasBase ~WgpuAutoGui - ~WgpuOffscreenCanvasBase For each supported GUI toolkit there is a module that implements a ``WgpuCanvas`` class, diff --git a/examples/triangle_glfw_direct.py b/examples/triangle_glfw_direct.py index 7336eaf3..16aaa1e0 100644 --- a/examples/triangle_glfw_direct.py +++ b/examples/triangle_glfw_direct.py @@ -14,7 +14,7 @@ import glfw from wgpu.backends.wgpu_native import GPUCanvasContext -from wgpu.gui.glfw import get_surface_info, get_physical_size +from wgpu.gui.glfw import get_glfw_present_info, get_physical_size from wgpu.utils.device import get_default_device @@ -29,9 +29,9 @@ class GlfwCanvas: def __init__(self, window): self._window = window - def get_surface_info(self): + def get_present_info(self): """get window and display id, includes some triage to deal with OS differences""" - return get_surface_info(self._window) + return get_glfw_present_info(self._window) def get_physical_size(self): """get framebuffer size in integer pixels""" diff --git a/examples/triangle_subprocess.py b/examples/triangle_subprocess.py index 59557fd2..5c6a76a7 100644 --- a/examples/triangle_subprocess.py +++ b/examples/triangle_subprocess.py @@ -31,7 +31,7 @@ app = QtWidgets.QApplication([]) canvas = WgpuCanvas(title="wgpu triangle in Qt subprocess") -print(json.dumps(canvas.get_surface_info())) +print(json.dumps(canvas.get_present_info())) print(canvas.get_physical_size()) sys.stdout.flush() @@ -42,15 +42,15 @@ class ProxyCanvas(WgpuCanvasBase): def __init__(self): super().__init__() - self._surface_info = json.loads(p.stdout.readline().decode()) + self._present_info = json.loads(p.stdout.readline().decode()) self._psize = tuple( int(x) for x in p.stdout.readline().decode().strip().strip("()").split(",") ) print(self._psize) time.sleep(0.2) - def get_surface_info(self): - return self._surface_info + def get_present_info(self): + return self._present_info def get_physical_size(self): return self._psize diff --git a/examples/wgpu-examples.ipynb b/examples/wgpu-examples.ipynb index 8ddb5e0c..9ae2417e 100644 --- a/examples/wgpu-examples.ipynb +++ b/examples/wgpu-examples.ipynb @@ -20,14 +20,47 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "c6e4ffe0", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "5ed60fb173574ec4be1cf2000ffb5fc3", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "RFBOutputContext()" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "b434f9aabf374f3caf167f0f7ed48822", + "version_major": 2, + "version_minor": 0 + }, + "text/html": [ + "
snapshot
" + ], + "text/plain": [ + "JupyterWgpuCanvas(css_height='480px', css_width='640px')" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "from wgpu.gui.auto import WgpuCanvas, run\n", "import triangle\n", - "\n", + " \n", "canvas = WgpuCanvas(size=(640, 480), title=\"wgpu triangle with GLFW\")\n", "\n", "triangle.main(canvas)\n", @@ -46,10 +79,51 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "e4f9f67d", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Available adapters on this system:\n", + "Apple M1 Pro (IntegratedGPU) via Metal\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "871cd2fc00334b1b8c7f82e2676916a3", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "RFBOutputContext()" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "f6aa0a0596cc47a2a5c63e6ecaa32991", + "version_major": 2, + "version_minor": 0 + }, + "text/html": [ + "
snapshot
" + ], + "text/plain": [ + "JupyterWgpuCanvas(css_height='480px', css_width='640px')" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "from cube import canvas\n", "\n", @@ -61,33 +135,48 @@ "id": "749ffb40", "metadata": {}, "source": [ - "## Event example\n", - "\n", - "The code below is a copy from `show_events.py`. It is just to show how events are handled. These events are the same across all auto-backends." + "## Events" ] }, { "cell_type": "code", - "execution_count": null, - "id": "c858215a", + "execution_count": 3, + "id": "6d0e64b7-a208-4be6-99eb-9f666ab8c2ae", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "a670ad10911d4335bd54a71d2585deda", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Textarea(value='', rows=10)" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "from wgpu.gui.auto import WgpuCanvas, run\n", + "import ipywidgets\n", "\n", - "class MyCanvas(WgpuCanvas):\n", - " def handle_event(self, event):\n", - " if event[\"event_type\"] != \"pointer_move\":\n", - " print(event)\n", + "out = ipywidgets.Textarea(rows=10)\n", "\n", - "canvas = MyCanvas(size=(640, 480), title=\"wgpu triangle with GLFW\")\n", - "canvas" + "@canvas.add_event_handler(\"*\")\n", + "def show_events(event):\n", + " if event[\"event_type\"] != \"pointer_move\":\n", + " out.value = str(event)\n", + "\n", + "out" ] }, { "cell_type": "code", "execution_count": null, - "id": "6b92d13b", + "id": "17773a3a-aae1-4307-9bdb-220b14802a68", "metadata": {}, "outputs": [], "source": [] @@ -109,7 +198,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.9" + "version": "3.12.4" } }, "nbformat": 4, diff --git a/tests/test_gui_base.py b/tests/test_gui_base.py index 1b8ac2ba..5f06dddc 100644 --- a/tests/test_gui_base.py +++ b/tests/test_gui_base.py @@ -9,7 +9,7 @@ import numpy as np import wgpu.gui # noqa from testutils import run_tests, can_use_wgpu_lib, is_pypy -from pytest import mark +from pytest import mark, raises class TheTestCanvas(wgpu.gui.WgpuCanvasBase): @@ -37,10 +37,10 @@ def spam_method(self): def test_base_canvas_context(): assert not issubclass(wgpu.gui.WgpuCanvasInterface, wgpu.GPUCanvasContext) assert hasattr(wgpu.gui.WgpuCanvasInterface, "get_context") - # Provides good default already canvas = wgpu.gui.WgpuCanvasInterface() - ctx = wgpu.GPUCanvasContext(canvas) - assert ctx.get_preferred_format(None) == "bgra8unorm-srgb" + # Cannot instantiate, because get_present_info is not implemented + with raises(NotImplementedError): + wgpu.GPUCanvasContext(canvas) def test_canvas_logging(caplog): @@ -80,12 +80,22 @@ def test_canvas_logging(caplog): assert text.count("division by zero") == 4 -class MyOffscreenCanvas(wgpu.gui.WgpuOffscreenCanvasBase): +class MyOffscreenCanvas(wgpu.gui.WgpuCanvasBase): def __init__(self): super().__init__() - self.textures = [] + self.frame_count = 0 self.physical_size = 100, 100 + def get_present_info(self): + return { + "method": "image", + "formats": ["rgba8unorm-srgb"], + } + + def present_image(self, image, **kwargs): + self.frame_count += 1 + self.array = np.frombuffer(image, np.uint8).reshape(image.shape) + def get_pixel_ratio(self): return 1 @@ -99,26 +109,6 @@ def _request_draw(self): # Note: this would normally schedule a call in a later event loop iteration self._draw_frame_and_present() - def present(self, texture): - self.textures.append(texture) - device = texture._device - size = texture.size - bytes_per_pixel = 4 - data = device.queue.read_texture( - { - "texture": texture, - "mip_level": 0, - "origin": (0, 0, 0), - }, - { - "offset": 0, - "bytes_per_row": bytes_per_pixel * size[0], - "rows_per_image": size[1], - }, - size, - ) - self.array = np.frombuffer(data, np.uint8).reshape(size[1], size[0], 4) - @mark.skipif(not can_use_wgpu_lib, reason="Needs wgpu lib") def test_run_bare_canvas(): @@ -181,7 +171,7 @@ def draw_frame(): render_pass.end() device.queue.submit([command_encoder.finish()]) - assert len(canvas.textures) == 0 + assert canvas.frame_count == 0 # Draw 1 canvas.request_draw(draw_frame) @@ -214,8 +204,7 @@ def draw_frame(): assert np.all(canvas.array[:, :, 1] == 255) # We now have four unique texture objects - assert len(canvas.textures) == 4 - assert len(set(canvas.textures)) == 4 + assert canvas.frame_count == 4 def test_autogui_mixin(): diff --git a/tests/test_gui_glfw.py b/tests/test_gui_glfw.py index 10c75d64..32a77edd 100644 --- a/tests/test_gui_glfw.py +++ b/tests/test_gui_glfw.py @@ -171,7 +171,7 @@ def __init__(self): self.window = glfw.create_window(300, 200, "canvas", None, None) self._present_context = None - def get_surface_info(self): + def get_present_info(self): if sys.platform.startswith("win"): return { "platform": "windows", diff --git a/tests_mem/test_gui.py b/tests_mem/test_gui.py index 1ddb1576..7f67bc01 100644 --- a/tests_mem/test_gui.py +++ b/tests_mem/test_gui.py @@ -23,7 +23,7 @@ def make_draw_func_for_canvas(canvas): so that we can really present something to a canvas being tested. """ ctx = canvas.get_context() - ctx.configure(device=DEVICE, format="bgra8unorm-srgb") + ctx.configure(device=DEVICE, format=None) def draw(): ctx = canvas.get_context() diff --git a/tests_mem/test_gui_qt.py b/tests_mem/test_gui_qt.py index c0043e7e..aec6c046 100644 --- a/tests_mem/test_gui_qt.py +++ b/tests_mem/test_gui_qt.py @@ -36,7 +36,9 @@ def test_release_canvas_context(n): if app is None: app = PySide6.QtWidgets.QApplication([""]) - yield {} + yield { + "ignore": {"CommandBuffer"}, + } canvases = weakref.WeakSet() diff --git a/wgpu/_classes.py b/wgpu/_classes.py index 24e19815..3bd6ba23 100644 --- a/wgpu/_classes.py +++ b/wgpu/_classes.py @@ -12,7 +12,7 @@ import logging from typing import List, Dict, Union -from ._coreutils import ApiDiff +from ._coreutils import ApiDiff, str_flag_to_int from ._diagnostics import diagnostics, texture_format_to_bpp from . import flags, enums, structs @@ -173,11 +173,14 @@ def wgsl_language_features(self): class GPUCanvasContext: - """Represents a context to configure a canvas. - - Is also used to obtain the texture to render to. + """Represents a context to configure a canvas and render to it. Can be obtained via `gui.WgpuCanvasInterface.get_context()`. + + The canvas-context plays a crucial role in connecting the wgpu API to the + GUI layer, in a way that allows the GUI to be agnostic about wgpu. It + combines (and checks) the user's preferences with the capabilities and + preferences of the canvas. """ _ot = object_tracker @@ -186,6 +189,22 @@ def __init__(self, canvas): self._ot.increase(self.__class__.__name__) self._canvas_ref = weakref.ref(canvas) + # The configuration from the canvas, obtained with canvas.get_present_info() + self._present_info = canvas.get_present_info() + if self._present_info.get("method", None) not in ("screen", "image"): + raise RuntimeError( + "canvas.get_present_info() must produce a dict with a field 'method' that is either 'screen' or 'image'." + ) + + # Surface capabilities. Stored the first time it is obtained + self._capabilities = None + + # Configuration dict from the user, set via self.configure() + self._config = None + + # The last used texture + self._texture = None + def _get_canvas(self): """Getter method for internal use.""" return self._canvas_ref() @@ -196,6 +215,41 @@ def canvas(self): """The associated canvas object.""" return self._canvas_ref() + def _get_capabilities(self, adapter): + """Get dict of capabilities and cache the result.""" + if self._capabilities is None: + self._capabilities = {} + if self._present_info["method"] == "screen": + # Query capabilities from the surface + self._capabilities.update(self._get_capabilities_screen(adapter)) + else: + # Default image capabilities + self._capabilities = { + "formats": ["rgba8unorm-srgb", "rgba8unorm"], + "usages": 0xFF, + "alpha_modes": [enums.CanvasAlphaMode.opaque], + } + # If capabilities were provided via surface info, overload them! + for key in ["formats", "alpha_modes"]: + if key in self._present_info: + self._capabilities[key] = self._present_info[key] + # Derived defaults + if "view_formats" not in self._capabilities: + self._capabilities["view_formats"] = self._capabilities["formats"] + + return self._capabilities + + def _get_capabilities_screen(self, adapter): + """Get capabilities for a native surface.""" + raise NotImplementedError() + + @apidiff.add("Better place to define the preferred format") + def get_preferred_format(self, adapter): + """Get the preferred surface texture format.""" + capabilities = self._get_capabilities(adapter) + formats = capabilities["formats"] + return formats[0] if formats else "bgra8-unorm" + # IDL: undefined configure(GPUCanvasConfiguration configuration); def configure( self, @@ -216,52 +270,226 @@ def configure( device (WgpuDevice): The GPU device object to create compatible textures for. format (enums.TextureFormat): The format that textures returned by ``get_current_texture()`` will have. Must be one of the supported context - formats. An often used format is "bgra8unorm-srgb". + formats. Can be ``None`` to use the canvas' preferred format. usage (flags.TextureUsage): Default ``TextureUsage.OUTPUT_ATTACHMENT``. view_formats (List[enums.TextureFormat]): The formats that views created from textures returned by ``get_current_texture()`` may use. color_space (PredefinedColorSpace): The color space that values written into textures returned by ``get_current_texture()`` should be displayed with. - Default "srgb". + Default "srgb". Not yet supported. tone_mapping (enums.CanvasToneMappingMode): Not yet supported. alpha_mode (structs.CanvasAlphaMode): Determines the effect that alpha values will have on the content of textures returned by ``get_current_texture()`` when read, displayed, or used as an image source. Default "opaque". """ + + # Check types + + if not isinstance(device, GPUDevice): + raise TypeError("Given device is not a device.") + + if format is None: + format = self.get_preferred_format(device.adapter) + if format not in enums.TextureFormat: + raise ValueError(f"Configure: format {format} not in {enums.TextureFormat}") + + if not isinstance(usage, int): + usage = str_flag_to_int(flags.TextureUsage, usage) + + color_space # not really supported, just assume srgb for now + tone_mapping # not supported yet + + if alpha_mode not in enums.CanvasAlphaMode: + raise ValueError( + f"Configure: alpha_mode {alpha_mode} not in {enums.CanvasAlphaMode}" + ) + + # Check against capabilities + + capabilities = self._get_capabilities(device.adapter) + + if format not in capabilities["formats"]: + raise ValueError( + f"Configure: unsupported texture format: {format} not in {capabilities['formats']}" + ) + + if not usage & capabilities["usages"]: + raise ValueError( + f"Configure: unsupported texture usage: {usage} not in {capabilities['usages']}" + ) + + for view_format in view_formats: + if view_format not in capabilities["view_formats"]: + raise ValueError( + f"Configure: unsupported view format: {view_format} not in {capabilities['view_formats']}" + ) + + if alpha_mode not in capabilities["alpha_modes"]: + raise ValueError( + f"Configure: unsupported alpha-mode: {alpha_mode} not in {capabilities['alpha_modes']}" + ) + + # Store + + self._config = { + "device": device, + "format": format, + "usage": usage, + "view_formats": view_formats, + "color_space": color_space, + "tone_mapping": tone_mapping, + "alpha_mode": alpha_mode, + } + + if self._present_info["method"] == "screen": + self._configure_screen(**self._config) + + def _configure_screen( + self, + *, + device, + format, + usage, + view_formats, + color_space, + tone_mapping, + alpha_mode, + ): raise NotImplementedError() # IDL: undefined unconfigure(); def unconfigure(self): """Removes the presentation context configuration. - Destroys any textures produced while configured.""" + Destroys any textures produced while configured. + """ + if self._present_info["method"] == "screen": + self._unconfigure_screen() + self._config = None + self._drop_texture() + + def _unconfigure_screen(self): raise NotImplementedError() # IDL: GPUTexture getCurrentTexture(); def get_current_texture(self): - """Get the `GPUTexture` that will be composited to the canvas next. - This method should be called exactly once during each draw event. - """ + """Get the `GPUTexture` that will be composited to the canvas next.""" + if not self._config: + raise RuntimeError( + "Canvas context must be configured before calling get_current_texture()." + ) + + # When the texture is active right now, we could either: + # * return the existing texture + # * warn about it, and create a new one + # * raise an error + # Right now we return the existing texture, so user can retrieve it in different render passes that write to the same frame. + + if self._texture is None: + if self._present_info["method"] == "screen": + self._texture = self._create_texture_screen() + else: + self._texture = self._create_texture_image() + + return self._texture + + def _create_texture_image(self): + + canvas = self._get_canvas() + width, height = canvas.get_physical_size() + width, height = max(width, 1), max(height, 1) + + device = self._config["device"] + self._texture = device.create_texture( + label="presentation-context", + size=(width, height, 1), + format=self._config["format"], + usage=self._config["usage"] | flags.TextureUsage.COPY_SRC, + ) + return self._texture + + def _create_texture_screen(self): raise NotImplementedError() + def _drop_texture(self): + if self._texture: + self._texture._release() # not destroy, because it may be in use. + self._texture = None + @apidiff.add("Present method is exposed") def present(self): """Present what has been drawn to the current texture, by compositing it to the canvas. Note that a canvas based on `gui.WgpuCanvasBase` will call this method automatically at the end of each draw event. """ - raise NotImplementedError() + # todo: can we remove this present() method? + + if not self._texture: + # This can happen when a user somehow forgot to call + # get_current_texture(). But then what was this person rendering to + # then? The thing is that this also happens when there is an + # exception in the draw function before the call to + # get_current_texture(). In this scenario our warning may + # add confusion, so provide context and make it a debug level warning. + msg = "Warning in present(): No texture to present, missing call to get_current_texture()?" + logger.debug(msg) + return + + if self._present_info["method"] == "screen": + self._present_screen() + else: + self._present_image() + + self._drop_texture() + + def _present_image(self): + texture = self._texture + device = texture._device + + size = texture.size + format = texture.format + nchannels = 4 # we expect rgba or bgra + if not format.startswith(("rgba", "bgra")): + raise RuntimeError(f"Image present unsupported texture format {format}.") + if "8" in format: + bytes_per_pixel = nchannels + elif "16" in format: + bytes_per_pixel = nchannels * 2 + elif "32" in format: + bytes_per_pixel = nchannels * 4 + else: + raise RuntimeError( + f"Image present unsupported texture format bitdepth {format}." + ) + + data = device.queue.read_texture( + { + "texture": texture, + "mip_level": 0, + "origin": (0, 0, 0), + }, + { + "offset": 0, + "bytes_per_row": bytes_per_pixel * size[0], + "rows_per_image": size[1], + }, + size, + ) - @apidiff.add("Better place to define the preferred format") - def get_preferred_format(self, adapter): - """Get the preferred surface texture format.""" - return "bgra8unorm-srgb" # seems to be a good default + # Represent as memory object to avoid numpy dependency + # Equivalent: np.frombuffer(data, np.uint8).reshape(size[1], size[0], nchannels) + data = data.cast("B", (size[1], size[0], nchannels)) + + self._get_canvas().present_image(data, format=format) + + def _present_screen(self): + raise NotImplementedError() def __del__(self): self._ot.decrease(self.__class__.__name__) self._release() def _release(self): - pass + self._drop_texture() class GPUAdapterInfo: diff --git a/wgpu/backends/wgpu_native/_api.py b/wgpu/backends/wgpu_native/_api.py index 621dd54c..f360aec0 100644 --- a/wgpu/backends/wgpu_native/_api.py +++ b/wgpu/backends/wgpu_native/_api.py @@ -28,7 +28,7 @@ from ._mappings import cstructfield2enum, enummap, enum_str2int, enum_int2str from ._helpers import ( get_wgpu_instance, - get_surface_id_from_canvas, + get_surface_id_from_info, get_memoryview_from_address, get_memoryview_and_address, to_snake_case, @@ -327,8 +327,7 @@ def request_adapter( # able to create a surface texture for it (from this adapter). surface_id = ffi.NULL if canvas is not None: - if canvas.get_surface_info(): # e.g. could be an off-screen canvas - surface_id = canvas.get_context()._get_surface_id() + surface_id = canvas._surface_id # can still be NULL # ----- Select backend @@ -482,45 +481,114 @@ class GPUCanvasContext(classes.GPUCanvasContext): def __init__(self, canvas): super().__init__(canvas) - self._device = None # set in configure() - self._surface_id = None - self._config = None - self._texture = None - - def _get_surface_id(self): - if self._surface_id is None: - # get_surface_id_from_canvas calls wgpuInstanceCreateSurface - self._surface_id = get_surface_id_from_canvas(self._get_canvas()) - return self._surface_id - - def configure( + + # Obtain the surface id. The lifetime is of the surface is bound + # to the lifetime of this context object. + if self._present_info["method"] == "screen": + self._surface_id = get_surface_id_from_info(self._present_info) + else: # method == "image" + self._surface_id = ffi.NULL + + def _get_capabilities_screen(self, adapter): + adapter_id = adapter._internal + surface_id = self._surface_id + assert surface_id + + minimal_capabilities = { + "usages": flags.TextureUsage.RENDER_ATTACHMENT, + "formats": [ + enums.TextureFormat.bgra8unorm_srgb, + enums.TextureFormat.bgra8unorm, + ], + "alpha_modes": enums.CanvasAlphaMode.opaque, + "present_modes": ["fifo"], + } + + # H: nextInChain: WGPUChainedStructOut *, usages: WGPUTextureUsageFlags/int, formatCount: int, formats: WGPUTextureFormat *, presentModeCount: int, presentModes: WGPUPresentMode *, alphaModeCount: int, alphaModes: WGPUCompositeAlphaMode * + c_capabilities = new_struct_p( + "WGPUSurfaceCapabilities *", + # not used: nextInChain + # not used: usages + # not used: formatCount + # not used: formats + # not used: presentModeCount + # not used: presentModes + # not used: alphaModeCount + # not used: alphaModes + ) + + # H: void f(WGPUSurface surface, WGPUAdapter adapter, WGPUSurfaceCapabilities * capabilities) + libf.wgpuSurfaceGetCapabilities(surface_id, adapter_id, c_capabilities) + + # Convert to Python. + capabilities = {} + + # When the surface is found not to be compatible, the fields below may + # be null pointers. This probably means that the surface won't work, + # and trying to use it will result in an error (or Rust panic). Since + # I'm not sure what the best time/place to error would be, we pretend + # that everything is fine here, and populate the fields with values + # that wgpu-core claims are guaranteed to exist on any (compatible) + # surface. + + capabilities["usages"] = c_capabilities.usages + + if c_capabilities.formats: + capabilities["formats"] = formats = [] + for i in range(c_capabilities.formatCount): + int_val = c_capabilities.formats[i] + formats.append(enum_int2str["TextureFormat"][int_val]) + + else: + capabilities["formats"] = minimal_capabilities["formats"] + + if c_capabilities.alphaModes: + capabilities["alpha_modes"] = alpha_modes = [] + for i in range(c_capabilities.alphaModeCount): + int_val = c_capabilities.alphaModes[i] + str_val = enum_int2str["CompositeAlphaMode"][int_val] + alpha_modes.append(str_val.lower()) + else: + capabilities["alpha_modes"] = minimal_capabilities["alpha_modes"] + + if c_capabilities.presentModes: + capabilities["present_modes"] = present_modes = [] + for i in range(c_capabilities.presentModeCount): + int_val = c_capabilities.presentModes[i] + str_val = enum_int2str["PresentMode"][int_val] + present_modes.append(str_val.lower()) + else: + capabilities["present_modes"] = minimal_capabilities["present_modes"] + + # H: void f(WGPUSurfaceCapabilities surfaceCapabilities) + libf.wgpuSurfaceCapabilitiesFreeMembers(c_capabilities[0]) + + return capabilities + + def _configure_screen( self, *, - device: "GPUDevice", - format: "enums.TextureFormat", - usage: "flags.TextureUsage" = 0x10, - view_formats: "List[enums.TextureFormat]" = [], - color_space: str = "srgb", - tone_mapping: "structs.CanvasToneMapping" = {}, - alpha_mode: "enums.CanvasAlphaMode" = "opaque", + device, + format, + usage, + view_formats, + color_space, + tone_mapping, + alpha_mode, ): - # Handle inputs - # Store for later - self._device = device - # Handle usage - if isinstance(usage, str): - usage = str_flag_to_int(flags.TextureUsage, usage) - # View formats + capabilities = self._get_capabilities(device.adapter) + + # Convert to C values + c_view_formats = ffi.NULL if view_formats: view_formats_list = [enummap["TextureFormat." + x] for x in view_formats] c_view_formats = ffi.new("WGPUTextureFormat []", view_formats_list) + # Lookup alpha mode, needs explicit conversion because enum names mismatch c_alpha_mode = getattr(lib, f"WGPUCompositeAlphaMode_{alpha_mode.capitalize()}") - # The format is used as-is - if format is None: - format = self.get_preferred_format(device.adapter) + # The color_space is not used for now color_space # Same for tone mapping @@ -546,21 +614,6 @@ def configure( else: present_mode_pref = ["immediate", "mailbox", "fifo"] - # Get what's supported - - capabilities = self._get_surface_capabilities(self._device.adapter) - - if format not in capabilities["formats"]: - raise ValueError( - f"Given format '{format}' is not in supported formats {capabilities['formats']}" - ) - - if alpha_mode not in capabilities["alpha_modes"]: - raise ValueError( - f"Given format '{alpha_mode}' is not in supported formats {capabilities['alpha_modes']}" - ) - - # Select present mode present_modes = [ p for p in present_mode_pref if p in capabilities["present_modes"] ] @@ -570,7 +623,7 @@ def configure( # Prepare config object # H: nextInChain: WGPUChainedStruct *, device: WGPUDevice, format: WGPUTextureFormat, usage: WGPUTextureUsageFlags/int, viewFormatCount: int, viewFormats: WGPUTextureFormat *, alphaMode: WGPUCompositeAlphaMode, width: int, height: int, presentMode: WGPUPresentMode - config = new_struct_p( + self._wgpu_config = new_struct_p( "WGPUSurfaceConfiguration *", device=device._internal, format=format, @@ -584,50 +637,29 @@ def configure( # not used: nextInChain ) - # Configure - self._configure(config) - - def _configure(self, config): + def _configure_screen_real(self, width, height): # If a texture is still active, better release it first self._drop_texture() # Set the size - width, height = self._get_canvas().get_physical_size() - config.width = width - config.height = height + self._wgpu_config.width = width + self._wgpu_config.height = height if width <= 0 or height <= 0: raise RuntimeError( "Cannot configure canvas that has no pixels ({width}x{height})." ) # Configure, and store the config if we did not error out - # H: void f(WGPUSurface surface, WGPUSurfaceConfiguration const * config) - libf.wgpuSurfaceConfigure(self._get_surface_id(), config) - self._config = config - - def unconfigure(self): - self._drop_texture() - self._config = None - # H: void f(WGPUSurface surface) - libf.wgpuSurfaceUnconfigure(self._get_surface_id()) + if self._surface_id: + # H: void f(WGPUSurface surface, WGPUSurfaceConfiguration const * config) + libf.wgpuSurfaceConfigure(self._surface_id, self._wgpu_config) - def _drop_texture(self): - if self._texture: - self._texture._release() # not destroy, because it may be in use. - self._texture = None + def _unconfigure_screen(self): + if self._surface_id: + # H: void f(WGPUSurface surface) + libf.wgpuSurfaceUnconfigure(self._surface_id) - def get_current_texture(self): - # If the canvas has changed since the last configure, we need to re-configure it - if not self._config: - raise RuntimeError( - "Canvas context must be configured before calling get_current_texture()." - ) + def _create_texture_screen(self): - # When the texture is active right now, we could either: - # * return the existing texture - # * warn about it, and create a new one - # * raise an error - # Right now we return the existing texture, so user can retrieve it in different render passes that write to the same frame. - if self._texture: - return self._texture + surface_id = self._surface_id # Reconfigure when the canvas has resized. # On some systems (Windows+Qt) this is not necessary, because @@ -639,10 +671,10 @@ def get_current_texture(self): # pre-emptively reconfigure. These log entries are harmless but # annoying, and I currently don't know how to prevent them # elegantly. See issue #352 - old_size = (self._config.width, self._config.height) + old_size = (self._wgpu_config.width, self._wgpu_config.height) new_size = tuple(self._get_canvas().get_physical_size()) if old_size != new_size: - self._configure(self._config) + self._configure_screen_real(*new_size) # Try to obtain a texture. # `If it fails, depending on status, we reconfigure and try again. @@ -657,7 +689,7 @@ def get_current_texture(self): for attempt in [1, 2]: # H: void f(WGPUSurface surface, WGPUSurfaceTexture * surfaceTexture) - libf.wgpuSurfaceGetCurrentTexture(self._get_surface_id(), surface_texture) + libf.wgpuSurfaceGetCurrentTexture(surface_id, surface_texture) status = surface_texture.status texture_id = surface_texture.texture if status == lib.WGPUSurfaceGetCurrentTextureStatus_Success: @@ -675,7 +707,7 @@ def get_current_texture(self): # (status==Outdated), but also when moving the window from one # monitor to another with different scale-factor. logger.info(f"Re-configuring canvas context ({status}).") - self._configure(self._config) + self._configure_screen_real(*new_size) else: # WGPUSurfaceGetCurrentTextureStatus_OutOfMemory # WGPUSurfaceGetCurrentTextureStatus_DeviceLost @@ -690,20 +722,7 @@ def get_current_texture(self): if surface_texture.suboptimal: logger.warning("The surface texture is suboptimal.") - return self._create_python_texture(texture_id) - - def _create_python_texture(self, texture_id): - # Create the Python wrapper - - # We can derive texture props from the config and common sense: - # width = self._config.width - # height = self._config.height - # depth = 1 - # mip_level_count = 1 - # sample_count = 1 - # dimension = enums.TextureDimension.d2 - # format = enum_int2str["TextureFormat"][self._config.format] - # usage = self._config.usage + # Wrap it in a Python texture object # But we can also read them from the texture # H: uint32_t f(WGPUTexture texture) @@ -740,105 +759,20 @@ def _create_python_texture(self, texture_id): "usage": usage, } - self._texture = GPUTexture(label, texture_id, self._device, tex_info) - return self._texture - - def present(self): - if not self._texture: - # This can happen when a user somehow forgot to call - # get_current_texture(). But then what was this person rendering to - # then? The thing is that this also happens when there is an - # exception in the draw function before the call to - # get_current_texture(). In this scenario our warning may - # add confusion, so provide context and make it a debug level warning. - msg = "Warning in present(): No texture to present, missing call to get_current_texture()?" - logger.debug(msg) - else: - # Present the texture, then destroy it - # H: void f(WGPUSurface surface) - libf.wgpuSurfacePresent(self._get_surface_id()) - self._drop_texture() - - def get_preferred_format(self, adapter): - if self._config is not None: - # this shortcut might not be correct if a different format is specified during .configure() - return enum_int2str["TextureFormat"][self._config.format] - else: - return self._get_surface_capabilities(adapter)["formats"][0] - - def _get_surface_capabilities(self, adapter): - adapter_id = adapter._internal + device = self._config["device"] + return GPUTexture(label, texture_id, device, tex_info) - # H: nextInChain: WGPUChainedStructOut *, usages: WGPUTextureUsageFlags/int, formatCount: int, formats: WGPUTextureFormat *, presentModeCount: int, presentModes: WGPUPresentMode *, alphaModeCount: int, alphaModes: WGPUCompositeAlphaMode * - c_capabilities = new_struct_p( - "WGPUSurfaceCapabilities *", - # not used: nextInChain - # not used: usages - # not used: formatCount - # not used: formats - # not used: presentModeCount - # not used: presentModes - # not used: alphaModeCount - # not used: alphaModes - ) - - # H: void f(WGPUSurface surface, WGPUAdapter adapter, WGPUSurfaceCapabilities * capabilities) - libf.wgpuSurfaceGetCapabilities( - self._get_surface_id(), adapter_id, c_capabilities - ) - - # Convert to Python. - capabilities = {} - - # When the surface is found not to be compatible, the fields below may - # be null pointers. This probably means that the surface won't work, - # and trying to use it will result in an error (or Rust panic). Since - # I'm not sure what the best time/place to error would be, we pretend - # that everything is fine here, and populate the fields with values - # that wgpu-core claims are guaranteed to exist on any (compatible) - # surface. - - if c_capabilities.formats: - capabilities["formats"] = formats = [] - for i in range(c_capabilities.formatCount): - int_val = c_capabilities.formats[i] - formats.append(enum_int2str["TextureFormat"][int_val]) - - else: - capabilities["formats"] = [ - enums.TextureFormat.bgra8unorm_srgb, - enums.TextureFormat.bgra8unorm, - ] - - if c_capabilities.alphaModes: - capabilities["alpha_modes"] = alpha_modes = [] - for i in range(c_capabilities.alphaModeCount): - int_val = c_capabilities.alphaModes[i] - str_val = enum_int2str["CompositeAlphaMode"][int_val] - alpha_modes.append(str_val.lower()) - else: - capabilities["alpha_modes"] = [enums.CanvasAlphaMode.opaque] - - if c_capabilities.presentModes: - capabilities["present_modes"] = present_modes = [] - for i in range(c_capabilities.presentModeCount): - int_val = c_capabilities.presentModes[i] - str_val = enum_int2str["PresentMode"][int_val] - present_modes.append(str_val.lower()) - else: - capabilities["present_modes"] = ["fifo"] - - # H: void f(WGPUSurfaceCapabilities surfaceCapabilities) - libf.wgpuSurfaceCapabilitiesFreeMembers(c_capabilities[0]) - - return capabilities + def _present_screen(self): + # H: void f(WGPUSurface surface) + libf.wgpuSurfacePresent(self._surface_id) def _release(self): self._drop_texture() if self._surface_id is not None and libf is not None: self._surface_id, surface_id = None, self._surface_id - # H: void f(WGPUSurface surface) - libf.wgpuSurfaceRelease(surface_id) + if surface_id: # is not NULL + # H: void f(WGPUSurface surface) + libf.wgpuSurfaceRelease(surface_id) class GPUObjectBase(classes.GPUObjectBase): diff --git a/wgpu/backends/wgpu_native/_helpers.py b/wgpu/backends/wgpu_native/_helpers.py index 2c214dbe..05e6dee2 100644 --- a/wgpu/backends/wgpu_native/_helpers.py +++ b/wgpu/backends/wgpu_native/_helpers.py @@ -94,23 +94,16 @@ def get_wgpu_instance(): return _the_instance -def get_surface_id_from_canvas(canvas): +def get_surface_id_from_info(present_info): """Get an id representing the surface to render to. The way to obtain this id differs per platform and GUI toolkit. """ - # Use cached - surface_id = getattr(canvas, "_wgpu_surface_id", None) - if surface_id: - return surface_id - - surface_info = canvas.get_surface_info() - if sys.platform.startswith("win"): # no-cover GetModuleHandle = ctypes.windll.kernel32.GetModuleHandleW # noqa struct = ffi.new("WGPUSurfaceDescriptorFromWindowsHWND *") struct.hinstance = ffi.cast("void *", GetModuleHandle(lib_path)) - struct.hwnd = ffi.cast("void *", int(surface_info["window"])) + struct.hwnd = ffi.cast("void *", int(present_info["window"])) struct.chain.sType = lib.WGPUSType_SurfaceDescriptorFromWindowsHWND elif sys.platform.startswith("darwin"): # no-cover @@ -124,7 +117,7 @@ def get_surface_id_from_canvas(canvas): # [ns_window.contentView setLayer:metal_layer]; # surface = wgpu_create_surface_from_metal_layer(metal_layer); # } - window = ctypes.c_void_p(surface_info["window"]) + window = ctypes.c_void_p(present_info["window"]) cw = ObjCInstance(window) try: @@ -165,22 +158,22 @@ def get_surface_id_from_canvas(canvas): struct.chain.sType = lib.WGPUSType_SurfaceDescriptorFromMetalLayer elif sys.platform.startswith("linux"): # no-cover - platform = surface_info.get("platform", "x11") + platform = present_info.get("platform", "x11") if platform == "x11": struct = ffi.new("WGPUSurfaceDescriptorFromXlibWindow *") - struct.display = ffi.cast("void *", surface_info["display"]) - struct.window = int(surface_info["window"]) + struct.display = ffi.cast("void *", present_info["display"]) + struct.window = int(present_info["window"]) struct.chain.sType = lib.WGPUSType_SurfaceDescriptorFromXlibWindow elif platform == "wayland": struct = ffi.new("WGPUSurfaceDescriptorFromWaylandSurface *") - struct.display = ffi.cast("void *", surface_info["display"]) - struct.surface = ffi.cast("void *", surface_info["window"]) + struct.display = ffi.cast("void *", present_info["display"]) + struct.surface = ffi.cast("void *", present_info["window"]) struct.chain.sType = lib.WGPUSType_SurfaceDescriptorFromWaylandSurface elif platform == "xcb": # todo: xcb untested struct = ffi.new("WGPUSurfaceDescriptorFromXcbWindow *") - struct.connection = ffi.cast("void *", surface_info["connection"]) # ?? - struct.window = int(surface_info["window"]) + struct.connection = ffi.cast("void *", present_info["connection"]) # ?? + struct.window = int(present_info["window"]) struct.chain.sType = lib.WGPUSType_SurfaceDescriptorFromXlibWindow else: raise RuntimeError("Unexpected Linux surface platform '{platform}'.") @@ -192,11 +185,7 @@ def get_surface_id_from_canvas(canvas): surface_descriptor.label = ffi.NULL surface_descriptor.nextInChain = ffi.cast("WGPUChainedStruct *", struct) - surface_id = lib.wgpuInstanceCreateSurface(get_wgpu_instance(), surface_descriptor) - - # Cache and return - canvas._wgpu_surface_id = surface_id - return surface_id + return lib.wgpuInstanceCreateSurface(get_wgpu_instance(), surface_descriptor) # The functions below are copied from codegen/utils.py diff --git a/wgpu/gui/__init__.py b/wgpu/gui/__init__.py index 31049f5b..ac542717 100644 --- a/wgpu/gui/__init__.py +++ b/wgpu/gui/__init__.py @@ -4,11 +4,9 @@ from . import _gui_utils # noqa: F401 from .base import WgpuCanvasInterface, WgpuCanvasBase, WgpuAutoGui # noqa: F401 -from .offscreen import WgpuOffscreenCanvasBase # noqa: F401 __all__ = [ "WgpuCanvasInterface", "WgpuCanvasBase", "WgpuAutoGui", - "WgpuOffscreenCanvasBase", ] diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index e172f4da..ab21c1bd 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -5,6 +5,21 @@ from ._gui_utils import log_exception +def create_canvas_context(canvas): + """Create a GPUCanvasContext for the given canvas. + + Helper function to keep the implementation of WgpuCanvasInterface + as small as possible. + """ + backend_module = sys.modules["wgpu"].gpu.__module__ + if backend_module == "wgpu._classes": + raise RuntimeError( + "A backend must be selected (e.g. with request_adapter()) before canvas.get_context() can be called." + ) + CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 + return CanvasContext(canvas) + + class WgpuCanvasInterface: """The minimal interface to be a valid canvas. @@ -19,16 +34,33 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._canvas_context = None - def get_surface_info(self): - """Get information about the native window / surface. - - This is used to obtain a surface id, so that wgpu can render to the - region of the screen occupied by the canvas. Should return None for - offscreen canvases. Otherwise, this should return a dict with a "window" - field. On Linux the dict should contain more fields, see the existing - implementations for reference. + 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 None + raise NotImplementedError() def get_physical_size(self): """Get the physical size of the canvas in integer pixels.""" @@ -48,17 +80,18 @@ def get_context(self, kind="webgpu"): # here the only valid arg is 'webgpu', which is also made the default. assert kind == "webgpu" if self._canvas_context is None: - # Get the active wgpu backend module - backend_module = sys.modules["wgpu"].gpu.__module__ - if backend_module == "wgpu._classes": - raise RuntimeError( - "A backend must be selected (e.g. with request_adapter()) before canvas.get_context() can be called." - ) - # Instantiate the context - CC = sys.modules[backend_module].GPUCanvasContext # noqa: N806 - self._canvas_context = CC(self) + self._canvas_context = create_canvas_context(self) return self._canvas_context + def present_image(self, image, **kwargs): + """Consume the final rendered image. + + 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. + """ + raise NotImplementedError() + class WgpuCanvasBase(WgpuCanvasInterface): """A convenient base canvas class. @@ -77,11 +110,12 @@ class WgpuCanvasBase(WgpuCanvasInterface): also want to set ``vsync`` to False. """ - def __init__(self, *args, max_fps=30, vsync=True, **kwargs): + def __init__(self, *args, max_fps=30, vsync=True, present_method=None, **kwargs): super().__init__(*args, **kwargs) self._last_draw_time = 0 self._max_fps = float(max_fps) self._vsync = bool(vsync) + present_method # We just catch the arg here in case a backend does implement support it def __del__(self): # On delete, we call the custom close method. diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index 00800eca..595a99ea 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -104,26 +104,31 @@ } -def get_surface_info(window): +def get_glfw_present_info(window): + if sys.platform.startswith("win"): return { + "method": "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)), } 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()), } else: return { + "method": "screen", "platform": "x11", "window": int(glfw.get_x11_window(window)), "display": int(glfw.get_x11_display()), @@ -298,8 +303,8 @@ def _set_logical_size(self, new_logical_size): # API - def get_surface_info(self): - return get_surface_info(self._window) + def get_present_info(self): + return get_glfw_present_info(self._window) def get_pixel_ratio(self): return self._pixel_ratio @@ -512,6 +517,12 @@ def _on_char(self, window, char): } self._handle_event_and_flush(ev) + def present_image(self, image, **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 GUI backend to + # render to the screen. + # Make available under a name that is the same for all gui backends WgpuCanvas = GlfwWgpuCanvas diff --git a/wgpu/gui/jupyter.py b/wgpu/gui/jupyter.py index e981af5e..c8ca44eb 100644 --- a/wgpu/gui/jupyter.py +++ b/wgpu/gui/jupyter.py @@ -6,8 +6,7 @@ import weakref import asyncio -from .offscreen import WgpuOffscreenCanvasBase -from .base import WgpuAutoGui +from .base import WgpuAutoGui, WgpuCanvasBase import numpy as np from jupyter_rfb import RemoteFrameBuffer @@ -17,13 +16,14 @@ pending_jupyter_canvases = [] -class JupyterWgpuCanvas(WgpuAutoGui, WgpuOffscreenCanvasBase, RemoteFrameBuffer): +class JupyterWgpuCanvas(WgpuAutoGui, WgpuCanvasBase, RemoteFrameBuffer): """An ipywidgets widget providing a wgpu canvas. Needs the jupyter_rfb library.""" def __init__(self, *, size=None, title=None, **kwargs): super().__init__(**kwargs) # Internal variables + self._last_image = None self._pixel_ratio = 1 self._logical_size = 0, 0 self._is_closed = False @@ -56,7 +56,8 @@ def get_frame(self): # present_context.present(), which calls our present() method. # The result is either a numpy array or None, and this matches # with what this method is expected to return. - return self._draw_frame_and_present() + self._draw_frame_and_present() + return self._last_image # Implementation needed for WgpuCanvasBase @@ -89,34 +90,21 @@ def _request_draw(self): self._request_draw_timer_running = True call_later(self._get_draw_wait_time(), RemoteFrameBuffer.request_draw, self) - # Implementation needed for WgpuOffscreenCanvasBase - - def present(self, texture): - # This gets called at the end of a draw pass via offscreen.GPUCanvasContext - device = texture._device - size = texture.size - bytes_per_pixel = 4 - data = device.queue.read_texture( - { - "texture": texture, - "mip_level": 0, - "origin": (0, 0, 0), - }, - { - "offset": 0, - "bytes_per_row": bytes_per_pixel * size[0], - "rows_per_image": size[1], - }, - size, - ) - return np.frombuffer(data, np.uint8).reshape(size[1], size[0], 4) + # Implementation needed for WgpuCanvasInterface - def get_preferred_format(self): + def get_present_info(self): # Use a format that maps well to PNG: rgba8norm. Use srgb for # perseptive 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 "rgba8unorm-srgb" + return { + "method": "image", + "formats": ["rgba8unorm-srgb", "rgba8unorm"], + } + + def present_image(self, image, **kwargs): + # Convert memoryview to ndarray (no copy) + self._last_image = np.frombuffer(image, np.uint8).reshape(image.shape) # Make available under a name that is the same for all gui backends diff --git a/wgpu/gui/offscreen.py b/wgpu/gui/offscreen.py index 95b6e373..b9ce8983 100644 --- a/wgpu/gui/offscreen.py +++ b/wgpu/gui/offscreen.py @@ -1,148 +1,9 @@ import time -from .. import classes, flags from .base import WgpuCanvasBase, WgpuAutoGui -class GPUCanvasContext(classes.GPUCanvasContext): - """GPUCanvasContext subclass for rendering to an offscreen texture.""" - - # In this context implementation, we keep a ref to the texture, to keep - # it alive until at least until present() is called, and to be able to - # pass it to the canvas' present() method. Thereafter, the texture - # reference is removed. If there are no more references to it, it will - # be cleaned up. But if the offscreen canvas uses it for something, - # it'll simply stay alive longer. - - def __init__(self, canvas): - super().__init__(canvas) - self._config = None - self._texture = None - - def configure( - self, - *, - device, - format, - usage=flags.TextureUsage.RENDER_ATTACHMENT | flags.TextureUsage.COPY_SRC, - view_formats=[], - color_space="srgb", - alpha_mode="opaque" - ): - if format is None: - format = self.get_preferred_format(device.adapter) - self._config = { - "device": device, - "format": format, - "usage": usage, - "width": 0, - "height": 0, - # "view_formats": xx, - # "color_space": xx, - # "alpha_mode": xx, - } - - def unconfigure(self): - self._texture = None - self._config = None - - def get_current_texture(self): - if not self._config: - raise RuntimeError( - "Canvas context must be configured before calling get_current_texture()." - ) - - if self._texture: - return self._texture - - width, height = self._get_canvas().get_physical_size() - width, height = max(width, 1), max(height, 1) - - self._texture = self._config["device"].create_texture( - label="presentation-context", - size=(width, height, 1), - format=self._config["format"], - usage=self._config["usage"], - ) - return self._texture - - def present(self): - if not self._texture: - msg = "present() is called without a preceding call to " - msg += "get_current_texture(). Note that present() is usually " - msg += "called automatically after the draw function returns." - raise RuntimeError(msg) - else: - texture = self._texture - self._texture = None - return self._get_canvas().present(texture) - - def get_preferred_format(self, adapter): - canvas = self._get_canvas() - if canvas: - return canvas.get_preferred_format() - else: - return "rgba8unorm-srgb" - - -class WgpuOffscreenCanvasBase(WgpuCanvasBase): - """Base class for off-screen canvases. - - It provides a custom context that renders to a texture instead of - a surface/screen. On each draw the resulting image is passes as a - texture to the ``present()`` method. Subclasses should (at least) - implement ``present()`` - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def get_surface_info(self): - """This canvas does not correspond to an on-screen window.""" - return None - - def get_context(self, kind="webgpu"): - """Get the GPUCanvasContext object to obtain a texture to render to.""" - # Normally this creates a GPUCanvasContext object provided by - # the backend (e.g. wgpu-native), but here we use our own context. - assert kind == "webgpu" - if self._canvas_context is None: - self._canvas_context = GPUCanvasContext(self) - return self._canvas_context - - def present(self, texture): - """Method that gets called at the end of each draw event. - - The rendered image is represented by the texture argument. - Subclasses should overload this method and use the texture to - process the rendered image. - - The texture is a new object at each draw, but is not explicitly - destroyed, so it can be used e.g. as a texture binding (subject - to set TextureUsage). - """ - # Notes: Creating a new texture object for each draw is - # consistent with how real canvas contexts work, plus it avoids - # confusion of re-using the same texture except when the canvas - # changes size. For use-cases where you do want to render to the - # same texture one does not need the canvas API. E.g. in pygfx - # the renderer can also work with a target that is a (fixed - # size) texture. - pass - - def get_preferred_format(self): - """Get the preferred format for this canvas. - - This method can be overloaded to control the used texture - format. The default is "rgba8unorm-srgb". - """ - # Use rgba because that order is more common for processing and storage. - # Use srgb because that's what how colors are usually expected to be. - # Use 8unorm because 8bit is enough (when using srgb). - return "rgba8unorm-srgb" - - -class WgpuManualOffscreenCanvas(WgpuAutoGui, WgpuOffscreenCanvasBase): +class WgpuManualOffscreenCanvas(WgpuAutoGui, WgpuCanvasBase): """An offscreen canvas intended for manual use. Call the ``.draw()`` method to perform a draw and get the result. @@ -154,6 +15,16 @@ def __init__(self, *args, size=None, pixel_ratio=1, title=None, **kwargs): self._pixel_ratio = pixel_ratio self._title = title self._closed = False + self._last_image = None + + def get_present_info(self): + return { + "method": "image", + "formats": ["rgba8unorm-srgb", "rgba8unorm"], + } + + def present_image(self, image, **kwargs): + self._last_image = image def get_pixel_ratio(self): return self._pixel_ratio @@ -182,29 +53,6 @@ def _request_draw(self): # Deliberately a no-op, because people use .draw() instead. pass - def present(self, texture): - # This gets called at the end of a draw pass via GPUCanvasContext - device = texture._device - size = texture.size - bytes_per_pixel = 4 - data = device.queue.read_texture( - { - "texture": texture, - "mip_level": 0, - "origin": (0, 0, 0), - }, - { - "offset": 0, - "bytes_per_row": bytes_per_pixel * size[0], - "rows_per_image": size[1], - }, - size, - ) - - # Return as memory object to avoid numpy dependency - # Equivalent: np.frombuffer(data, np.uint8).reshape(size[1], size[0], 4) - return data.cast("B", (size[1], size[0], 4)) - def draw(self): """Perform a draw and get the resulting image. @@ -212,7 +60,8 @@ def draw(self): This object can be converted to a numpy array (without copying data) using ``np.asarray(arr)``. """ - return self._draw_frame_and_present() + self._draw_frame_and_present() + return self._last_image WgpuCanvas = WgpuManualOffscreenCanvas diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 1a2035ae..4ffb6157 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -9,6 +9,8 @@ from .base import WgpuCanvasBase, WgpuAutoGui from ._gui_utils import ( + logger, + SYSTEM_IS_WAYLAND, get_alt_x11_display, get_alt_wayland_display, weakbind, @@ -16,13 +18,11 @@ ) -is_wayland = False # We force Qt to use X11 in _gui_utils.py - - # Select GUI toolkit libname, already_had_app_on_import = get_imported_qt_lib() if libname: QtCore = importlib.import_module(".QtCore", libname) + QtGui = importlib.import_module(".QtGui", libname) QtWidgets = importlib.import_module(".QtWidgets", libname) try: WA_PaintOnScreen = QtCore.Qt.WidgetAttribute.WA_PaintOnScreen @@ -135,18 +135,39 @@ def enable_hidpi(): # needed for wgpu, so not our responsibility (some users may NOT want it set). enable_hidpi() +_show_image_method_warning = ( + "Qt falling back to offscreen rendering, which is less performant." +) + class QWgpuWidget(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget): """A QWidget representing a wgpu canvas that can be embedded in a Qt application.""" - def __init__(self, *args, **kwargs): + def __init__(self, *args, present_method=None, **kwargs): super().__init__(*args, **kwargs) - # Configure how Qt renders this widget - self.setAttribute(WA_PaintOnScreen, True) + # Determine present method + self._surface_ids = self._get_surface_ids() + if not present_method: + self._present_to_screen = True + if SYSTEM_IS_WAYLAND: + # Trying to render to screen on Wayland segfaults. This might be because + # the "display" is not the real display id. We can tell Qt to use + # XWayland, so we can use the X11 path. This worked at some point, + # but later this resulted in a Rust panic. So, until this is sorted + # out, we fall back to rendering via an image. + self._present_to_screen = False + elif present_method == "screen": + self._present_to_screen = True + elif present_method == "image": + self._present_to_screen = False + else: + raise ValueError(f"Invalid present_method {present_method}") + + self.setAttribute(WA_PaintOnScreen, self._present_to_screen) + self.setAutoFillBackground(False) self.setAttribute(WA_DeleteOnClose, True) self.setAttribute(WA_InputMethodEnabled, True) - self.setAutoFillBackground(False) self.setMouseTracking(True) self.setFocusPolicy(FocusPolicy.StrongFocus) @@ -158,21 +179,24 @@ def __init__(self, *args, **kwargs): def paintEngine(self): # noqa: N802 - this is a Qt method # https://doc.qt.io/qt-5/qt.html#WidgetAttribute-enum WA_PaintOnScreen - return None + if self._present_to_screen: + return None + else: + return super().paintEngine() def paintEvent(self, event): # noqa: N802 - this is a Qt method self._draw_frame_and_present() # Methods that we add from wgpu (snake_case) - def get_surface_info(self): + def _get_surface_ids(self): if sys.platform.startswith("win") or sys.platform.startswith("darwin"): return { "window": int(self.winId()), } elif sys.platform.startswith("linux"): - # The trick to use an al display pointer works for X11, but results in a segfault on Wayland ... - if is_wayland: + if False: + # We fall back to XWayland, see _gui_utils.py return { "platform": "wayland", "window": int(self.winId()), @@ -184,8 +208,21 @@ def get_surface_info(self): "window": int(self.winId()), "display": int(get_alt_x11_display()), } + + def get_present_info(self): + global _show_image_method_warning + if self._present_to_screen: + info = {"method": "screen"} + info.update(self._surface_ids) else: - raise RuntimeError(f"Cannot get Qt surafce info on {sys.platform}.") + if _show_image_method_warning: + logger.warn(_show_image_method_warning) + _show_image_method_warning = None + info = { + "method": "image", + "formats": ["rgba8unorm-srgb", "rgba8unorm"], + } + return info def get_pixel_ratio(self): # Observations: @@ -356,6 +393,38 @@ def resizeEvent(self, event): # noqa: N802 def closeEvent(self, event): # noqa: N802 self._handle_event_and_flush({"event_type": "close"}) + def present_image(self, image_data, **kwargs): + size = image_data.shape[1], image_data.shape[0] # width, height + + painter = QtGui.QPainter(self) + + # We want to simply blit the image (copy pixels one-to-one on framebuffer). + # Maybe Qt does this when the sizes match exactly (like they do here). + # Converting to a QPixmap and painting that only makes it slower. + + # Just in case, set render hints that may hurt performance. + painter.setRenderHints( + painter.RenderHint.Antialiasing | painter.RenderHint.SmoothPixmapTransform, + False, + ) + + image = QtGui.QImage( + image_data, + size[0], + size[1], + size[0] * 4, + QtGui.QImage.Format.Format_RGBA8888, + ) + + rect1 = QtCore.QRect(0, 0, size[0], size[1]) + rect2 = self.rect() + painter.drawImage(rect2, image, rect1) + + # Uncomment for testing purposes + # painter.setPen(QtGui.QColor("#0000ff")) + # painter.setFont(QtGui.QFont("Arial", 30)) + # painter.drawText(100, 100, "This is an image") + class QWgpuCanvas(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget): """A toplevel Qt widget providing a wgpu canvas.""" @@ -365,11 +434,12 @@ class QWgpuCanvas(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget): # size can be set to subpixel (logical) values, without being able to # detect this. See https://github.com/pygfx/wgpu-py/pull/68 - def __init__(self, *, size=None, title=None, max_fps=30, **kwargs): + def __init__( + self, *, size=None, title=None, max_fps=30, present_method=None, **kwargs + ): # When using Qt, there needs to be an # application before any widget is created get_app() - super().__init__(**kwargs) self.setAttribute(WA_DeleteOnClose, True) @@ -377,7 +447,9 @@ def __init__(self, *, size=None, title=None, max_fps=30, **kwargs): self.setWindowTitle(title or "qt wgpu canvas") self.setMouseTracking(True) - self._subwidget = QWgpuWidget(self, max_fps=max_fps) + self._subwidget = QWgpuWidget( + self, max_fps=max_fps, present_method=present_method + ) self._subwidget.add_event_handler(weakbind(self.handle_event), "*") # Note: At some point we called `self._subwidget.winId()` here. For some @@ -408,8 +480,8 @@ def draw_frame(self): def draw_frame(self, f): self._subwidget.draw_frame = f - def get_surface_info(self): - return self._subwidget.get_surface_info() + def get_present_info(self): + return self._subwidget.get_present_info() def get_pixel_ratio(self): return self._subwidget.get_pixel_ratio() @@ -446,6 +518,9 @@ def get_context(self, *args, **kwargs): def request_draw(self, *args, **kwargs): return self._subwidget.request_draw(*args, **kwargs) + def present_image(self, image, **kwargs): + return self._subwidget.present_image(image, **kwargs) + # Make available under a name that is the same for all gui backends WgpuWidget = QWgpuWidget diff --git a/wgpu/gui/wx.py b/wgpu/gui/wx.py index 8428408c..f314d244 100644 --- a/wgpu/gui/wx.py +++ b/wgpu/gui/wx.py @@ -9,10 +9,15 @@ import wx -from ._gui_utils import get_alt_x11_display, get_alt_wayland_display, weakbind +from ._gui_utils import ( + logger, + SYSTEM_IS_WAYLAND, + get_alt_x11_display, + get_alt_wayland_display, + weakbind, +) from .base import WgpuCanvasBase, WgpuAutoGui -is_wayland = False # We force wx to use X11 in _gui_utils.py BUTTON_MAP = { wx.MOUSE_BTN_LEFT: 1, @@ -110,6 +115,11 @@ def enable_hidpi(): enable_hidpi() +_show_image_method_warning = ( + "wx falling back to offscreen rendering, which is less performant." +) + + class TimerWithCallback(wx.Timer): def __init__(self, callback): super().__init__() @@ -125,9 +135,23 @@ def Notify(self, *args): # noqa: N802 class WxWgpuWindow(WgpuAutoGui, WgpuCanvasBase, wx.Window): """A wx Window representing a wgpu canvas that can be embedded in a wx application.""" - def __init__(self, *args, **kwargs): + def __init__(self, *args, present_method=None, **kwargs): super().__init__(*args, **kwargs) + # Determine present method + self._surface_ids = self._get_surface_ids() + if not present_method: + self._present_to_screen = True + if SYSTEM_IS_WAYLAND: + # See comments in same place in qt.py + self._present_to_screen = False + elif present_method == "screen": + self._present_to_screen = True + elif present_method == "image": + self._present_to_screen = False + else: + raise ValueError(f"Invalid present_method {present_method}") + # A timer for limiting fps self._request_draw_timer = TimerWithCallback(self.Refresh) @@ -304,13 +328,14 @@ def _on_mouse_move(self, event: wx.MouseEvent): # Methods that we add from wgpu - def get_surface_info(self): + def _get_surface_ids(self): if sys.platform.startswith("win") or sys.platform.startswith("darwin"): return { "window": int(self.GetHandle()), } elif sys.platform.startswith("linux"): - if is_wayland: + if False: + # We fall back to XWayland, see _gui_utils.py return { "platform": "wayland", "window": int(self.GetHandle()), @@ -325,6 +350,21 @@ def get_surface_info(self): else: raise RuntimeError(f"Cannot get Qt surafce info on {sys.platform}.") + def get_present_info(self): + global _show_image_method_warning + if self._present_to_screen and self._surface_ids: + info = {"method": "screen"} + info.update(self._surface_ids) + else: + if _show_image_method_warning: + logger.warn(_show_image_method_warning) + _show_image_method_warning = None + info = { + "method": "image", + "formats": ["rgba8unorm-srgb", "rgba8unorm"], + } + return info + def get_pixel_ratio(self): # todo: this is not hidpi-ready (at least on win10) # Observations: @@ -371,19 +411,38 @@ def _call_later(delay, callback, *args): wx.CallLater(max(delay_ms, 1), callback, *args) + def present_image(self, image_data, **kwargs): + size = image_data.shape[1], image_data.shape[0] # width, height + + dc = wx.PaintDC(self) + bitmap = wx.Bitmap.FromBufferRGBA(*size, image_data) + dc.DrawBitmap(bitmap, 0, 0, False) + class WxWgpuCanvas(WgpuAutoGui, WgpuCanvasBase, wx.Frame): """A toplevel wx Frame providing a wgpu canvas.""" # Most of this is proxying stuff to the inner widget. - def __init__(self, *, parent=None, size=None, title=None, max_fps=30, **kwargs): + def __init__( + self, + *, + parent=None, + size=None, + title=None, + max_fps=30, + present_method=None, + **kwargs, + ): + get_app() super().__init__(parent, **kwargs) self.set_logical_size(*(size or (640, 480))) self.SetTitle(title or "wx wgpu canvas") - self._subwidget = WxWgpuWindow(parent=self, max_fps=max_fps) + self._subwidget = WxWgpuWindow( + parent=self, max_fps=max_fps, present_method=present_method + ) self._subwidget.add_event_handler(weakbind(self.handle_event), "*") self.Bind(wx.EVT_CLOSE, lambda e: self.Destroy()) @@ -397,8 +456,8 @@ def Refresh(self): # noqa: N802 # Methods that we add from wgpu - def get_surface_info(self): - return self._subwidget.get_surface_info() + def get_present_info(self): + return self._subwidget.get_present_info() def get_pixel_ratio(self): return self._subwidget.get_pixel_ratio() @@ -435,7 +494,26 @@ def get_context(self, *args, **kwargs): def request_draw(self, *args, **kwargs): return self._subwidget.request_draw(*args, **kwargs) + def present_image(self, image, **kwargs): + return self._subwidget.present_image(image, **kwargs) + # Make available under a name that is the same for all gui backends WgpuWidget = WxWgpuWindow WgpuCanvas = WxWgpuCanvas + +_the_app = None + + +def get_app(): + global _the_app + app = wx.App.GetInstance() + if app is None: + print("zxc") + _the_app = app = wx.App() + wx.App.SetInstance(app) + return app + + +def run(): + get_app().MainLoop() diff --git a/wgpu/resources/codegen_report.md b/wgpu/resources/codegen_report.md index 4f9a16d2..6693fa4a 100644 --- a/wgpu/resources/codegen_report.md +++ b/wgpu/resources/codegen_report.md @@ -18,9 +18,9 @@ * Diffs for GPUTextureView: add size, add texture * Diffs for GPUBindingCommandsMixin: change set_bind_group * Diffs for GPUQueue: add read_buffer, add read_texture, hide copy_external_image_to_texture -* Validated 37 classes, 112 methods, 45 properties +* Validated 37 classes, 121 methods, 45 properties ### Patching API for backends/wgpu_native/_api.py -* Validated 37 classes, 100 methods, 0 properties +* Validated 37 classes, 96 methods, 0 properties ## Validating backends/wgpu_native/_api.py * Enum field FeatureName.texture-compression-bc-sliced-3d missing in wgpu.h * Enum field FeatureName.clip-distances missing in wgpu.h