From 49afc765b8d884f49d3790d5aeb840594053e61f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 16 Sep 2024 15:14:17 +0200 Subject: [PATCH 01/24] Refactor canvas context to allow presening as image --- examples/cube.py | 2 +- wgpu/_classes.py | 229 ++++++++++++++++++-- wgpu/backends/wgpu_native/_api.py | 299 ++++++++++---------------- wgpu/backends/wgpu_native/_helpers.py | 15 +- wgpu/gui/base.py | 25 ++- wgpu/gui/glfw.py | 5 + wgpu/gui/offscreen.py | 2 +- wgpu/gui/qt.py | 56 ++++- 8 files changed, 406 insertions(+), 227 deletions(-) diff --git a/examples/cube.py b/examples/cube.py index c1b1a81c..fbb11aa4 100644 --- a/examples/cube.py +++ b/examples/cube.py @@ -6,7 +6,7 @@ import time -from wgpu.gui.auto import WgpuCanvas, run +from wgpu.gui.qt import WgpuCanvas, run import wgpu import numpy as np diff --git a/wgpu/_classes.py b/wgpu/_classes.py index 24e19815..9b4454d5 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 @@ -186,6 +186,17 @@ def __init__(self, canvas): self._ot.increase(self.__class__.__name__) self._canvas_ref = weakref.ref(canvas) + self._capabilities = None + + # Configuration dict from the user, set via self.configure() + self._config = None + + # The configuration from the canvas, obtained with canvas.get_surface_info() + self._surface_info = canvas.get_surface_info() + + # The last used texture + self._texture = None + def _get_canvas(self): """Getter method for internal use.""" return self._canvas_ref() @@ -196,6 +207,39 @@ def canvas(self): """The associated canvas object.""" return self._canvas_ref() + def _get_capabilities(self, adapter): + if self._capabilities is None: + self._capabilities = {} + if self._surface_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", "usages", "alpha_modes"]: + if key in self._surface_info: + self._capabilities[key] = self._surface_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): + 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, @@ -228,40 +272,199 @@ def configure( will have on the content of textures returned by ``get_current_texture()`` when read, displayed, or used as an image source. Default "opaque". """ - raise NotImplementedError() + + # 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._surface_info["method"] == "screen": + self._configure_screen(**self._config) # IDL: undefined unconfigure(); def unconfigure(self): """Removes the presentation context configuration. - Destroys any textures produced while configured.""" - raise NotImplementedError() + Destroys any textures produced while configured. + """ + if self._surface_info["method"] == "screen": + self._unconfigure_screen() + self._config = None + self._drop_texture() # 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._surface_info["method"] == "screen": + self._texture = self._create_texture_screen() + else: + self._texture = self._create_texture_simple() + + return self._texture + + def _create_texture_simple(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, + ) + # todo: get extra usage from surface info + 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._surface_info["method"] == "screen": + self._present_screen() + else: + self._present_image() + + self._drop_texture() + + def _present_screen(self): + raise NotImplementedError() + + 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 __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 eb8dc3d5..6219479b 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, @@ -234,8 +234,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 # ----- Select backend @@ -422,18 +421,91 @@ 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._surface_info["method"] == "screen": + self._surface_id = get_surface_id_from_info(self._surface_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", @@ -444,23 +516,19 @@ def configure( tone_mapping: "structs.CanvasToneMapping" = {}, alpha_mode: "enums.CanvasAlphaMode" = "opaque", ): - # 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 @@ -486,21 +554,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"] ] @@ -510,7 +563,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, @@ -524,50 +577,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 @@ -579,10 +611,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. @@ -597,7 +629,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: @@ -615,7 +647,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 @@ -630,20 +662,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) @@ -680,98 +699,12 @@ 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 - - # 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"] + device = self._config["device"] + return GPUTexture(label, texture_id, device, tex_info) - # 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() diff --git a/wgpu/backends/wgpu_native/_helpers.py b/wgpu/backends/wgpu_native/_helpers.py index 43b58495..fa6d7a39 100644 --- a/wgpu/backends/wgpu_native/_helpers.py +++ b/wgpu/backends/wgpu_native/_helpers.py @@ -94,18 +94,11 @@ def get_wgpu_instance(): return _the_instance -def get_surface_id_from_canvas(canvas): +def get_surface_id_from_info(surface_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 *") @@ -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/base.py b/wgpu/gui/base.py index e172f4da..ee540ea2 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -5,6 +5,18 @@ from ._gui_utils import log_exception +def create_canvas_context(canvas): + 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 + # CC = sys.modules["wgpu"]._classes.GPUCanvasContext + return CC(canvas) + + class WgpuCanvasInterface: """The minimal interface to be a valid canvas. @@ -48,17 +60,12 @@ 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): + raise NotImplementedError() + class WgpuCanvasBase(WgpuCanvasInterface): """A convenient base canvas class. diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index 00800eca..62b38fd7 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -512,6 +512,11 @@ def _on_char(self, window, char): } self._handle_event_and_flush(ev) + def present_image(self, image, **kwargs): + raise NotImplementedError() + # glfw does not have a way to blit an image. + # We could use OpenGL to display it though. + # Make available under a name that is the same for all gui backends WgpuCanvas = GlfwWgpuCanvas diff --git a/wgpu/gui/offscreen.py b/wgpu/gui/offscreen.py index 95b6e373..fc7411d1 100644 --- a/wgpu/gui/offscreen.py +++ b/wgpu/gui/offscreen.py @@ -4,7 +4,7 @@ from .base import WgpuCanvasBase, WgpuAutoGui -class GPUCanvasContext(classes.GPUCanvasContext): +class XXXXXGPUCanvasContext(classes.GPUCanvasContext): """GPUCanvasContext subclass for rendering to an offscreen texture.""" # In this context implementation, we keep a ref to the texture, to keep diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 1a2035ae..96e07b37 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -23,6 +23,7 @@ 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 @@ -140,13 +141,15 @@ 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): + draw_to_screen = kwargs.pop("draw_to_screen", True) super().__init__(*args, **kwargs) - # Configure how Qt renders this widget - self.setAttribute(WA_PaintOnScreen, True) + self._raw_surface_id = self._get_surface_id() + self._draw_to_screen = bool(self._raw_surface_id) and draw_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,14 +161,17 @@ 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._draw_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_id(self): if sys.platform.startswith("win") or sys.platform.startswith("darwin"): return { "window": int(self.winId()), @@ -184,8 +190,17 @@ def get_surface_info(self): "window": int(self.winId()), "display": int(get_alt_x11_display()), } + + def get_surface_info(self): + if self._draw_to_screen and self._raw_surface_id: + info = {"method": "screen"} + info.update(self._raw_surface_id) else: - raise RuntimeError(f"Cannot get Qt surafce info on {sys.platform}.") + info = { + "method": "image", + "formats": ["rgba8unorm-srgb", "rgba8unorm"], + } + return info def get_pixel_ratio(self): # Observations: @@ -356,6 +371,27 @@ 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] + + painter = QtGui.QPainter(self) + + 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) + + painter.setPen(QtGui.QColor("#0000ff")) + painter.setFont(QtGui.QFont("Arial", 30)) + painter.drawText(100, 100, "image") + class QWgpuCanvas(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget): """A toplevel Qt widget providing a wgpu canvas.""" @@ -370,6 +406,7 @@ def __init__(self, *, size=None, title=None, max_fps=30, **kwargs): # application before any widget is created get_app() + draw_to_screen = kwargs.pop("draw_to_screen", False) super().__init__(**kwargs) self.setAttribute(WA_DeleteOnClose, True) @@ -377,7 +414,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, draw_to_screen=draw_to_screen + ) self._subwidget.add_event_handler(weakbind(self.handle_event), "*") # Note: At some point we called `self._subwidget.winId()` here. For some @@ -446,6 +485,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 From e042000f32f2f46b85a46c7dda8796047b9cc667 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 16 Sep 2024 16:59:27 +0200 Subject: [PATCH 02/24] some cleanup --- examples/cube.py | 2 +- wgpu/_classes.py | 34 +++++++++++++++++++++++----------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/examples/cube.py b/examples/cube.py index fbb11aa4..c1b1a81c 100644 --- a/examples/cube.py +++ b/examples/cube.py @@ -6,7 +6,7 @@ import time -from wgpu.gui.qt import WgpuCanvas, run +from wgpu.gui.auto import WgpuCanvas, run import wgpu import numpy as np diff --git a/wgpu/_classes.py b/wgpu/_classes.py index 9b4454d5..4c17b776 100644 --- a/wgpu/_classes.py +++ b/wgpu/_classes.py @@ -186,14 +186,19 @@ 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_surface_info() + self._surface_info = canvas.get_surface_info() + if self._surface_info.get("method", None) not in ("screen", "image"): + raise RuntimeError( + "canvas.get_surface_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 configuration from the canvas, obtained with canvas.get_surface_info() - self._surface_info = canvas.get_surface_info() - # The last used texture self._texture = None @@ -208,6 +213,7 @@ def canvas(self): 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._surface_info["method"] == "screen": @@ -231,6 +237,7 @@ def _get_capabilities(self, adapter): 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") @@ -260,13 +267,13 @@ 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()`` @@ -334,6 +341,9 @@ def configure( if self._surface_info["method"] == "screen": self._configure_screen(**self._config) + def _configure_screen(self, **kwargs): + raise NotImplementedError() + # IDL: undefined unconfigure(); def unconfigure(self): """Removes the presentation context configuration. @@ -344,6 +354,9 @@ def unconfigure(self): self._config = None self._drop_texture() + def _unconfigure_screen(self, **kwargs): + raise NotImplementedError() + # IDL: GPUTexture getCurrentTexture(); def get_current_texture(self): """Get the `GPUTexture` that will be composited to the canvas next.""" @@ -362,11 +375,11 @@ def get_current_texture(self): if self._surface_info["method"] == "screen": self._texture = self._create_texture_screen() else: - self._texture = self._create_texture_simple() + self._texture = self._create_texture_image() return self._texture - def _create_texture_simple(self): + def _create_texture_image(self): canvas = self._get_canvas() width, height = canvas.get_physical_size() @@ -379,7 +392,6 @@ def _create_texture_simple(self): format=self._config["format"], usage=self._config["usage"] | flags.TextureUsage.COPY_SRC, ) - # todo: get extra usage from surface info return self._texture def _create_texture_screen(self): @@ -416,9 +428,6 @@ def present(self): self._drop_texture() - def _present_screen(self): - raise NotImplementedError() - def _present_image(self): texture = self._texture device = texture._device @@ -459,6 +468,9 @@ def _present_image(self): 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() From 671cd4ae33230013f8a4fafc1b1450f47b431841 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 16 Sep 2024 17:05:06 +0200 Subject: [PATCH 03/24] codegen --- wgpu/_classes.py | 14 ++++++++++++-- wgpu/backends/wgpu_native/_api.py | 14 +++++++------- wgpu/resources/codegen_report.md | 4 ++-- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/wgpu/_classes.py b/wgpu/_classes.py index 4c17b776..63b4c632 100644 --- a/wgpu/_classes.py +++ b/wgpu/_classes.py @@ -341,7 +341,17 @@ def configure( if self._surface_info["method"] == "screen": self._configure_screen(**self._config) - def _configure_screen(self, **kwargs): + def _configure_screen( + self, + *, + device, + format, + usage, + view_formats, + color_space, + tone_mapping, + alpha_mode, + ): raise NotImplementedError() # IDL: undefined unconfigure(); @@ -354,7 +364,7 @@ def unconfigure(self): self._config = None self._drop_texture() - def _unconfigure_screen(self, **kwargs): + def _unconfigure_screen(self): raise NotImplementedError() # IDL: GPUTexture getCurrentTexture(); diff --git a/wgpu/backends/wgpu_native/_api.py b/wgpu/backends/wgpu_native/_api.py index 6219479b..54e99bd7 100644 --- a/wgpu/backends/wgpu_native/_api.py +++ b/wgpu/backends/wgpu_native/_api.py @@ -508,13 +508,13 @@ def _get_capabilities_screen(self, adapter): 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, ): capabilities = self._get_capabilities(device.adapter) diff --git a/wgpu/resources/codegen_report.md b/wgpu/resources/codegen_report.md index 7c72d1fc..8b30cd2f 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, 112 methods, 0 properties +* Validated 37 classes, 108 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 From 1e816bce6b4c53df569393670100ffdce3985bf6 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 17 Sep 2024 09:00:25 +0200 Subject: [PATCH 04/24] Fix flicker --- wgpu/gui/qt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 96e07b37..8ac0588e 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -147,6 +147,7 @@ def __init__(self, *args, **kwargs): self._raw_surface_id = self._get_surface_id() self._draw_to_screen = bool(self._raw_surface_id) and draw_to_screen + self.setAttribute(WA_PaintOnScreen, self._draw_to_screen) self.setAutoFillBackground(False) self.setAttribute(WA_DeleteOnClose, True) self.setAttribute(WA_InputMethodEnabled, True) From 65b4848556c08805f919d3fd90f695b411c1fc53 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 17 Sep 2024 09:04:59 +0200 Subject: [PATCH 05/24] cleaner --- wgpu/gui/qt.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 8ac0588e..cfaa8bc8 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -140,12 +140,11 @@ def enable_hidpi(): 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): - draw_to_screen = kwargs.pop("draw_to_screen", True) + def __init__(self, *args, draw_to_screen=True, **kwargs): super().__init__(*args, **kwargs) self._raw_surface_id = self._get_surface_id() - self._draw_to_screen = bool(self._raw_surface_id) and draw_to_screen + self._draw_to_screen = draw_to_screen and bool(self._raw_surface_id) self.setAttribute(WA_PaintOnScreen, self._draw_to_screen) self.setAutoFillBackground(False) @@ -402,12 +401,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, draw_to_screen=True, **kwargs + ): # When using Qt, there needs to be an # application before any widget is created get_app() - - draw_to_screen = kwargs.pop("draw_to_screen", False) super().__init__(**kwargs) self.setAttribute(WA_DeleteOnClose, True) From a4d56510bdda5ca7433ff83c2663754146eefb65 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 17 Sep 2024 09:13:47 +0200 Subject: [PATCH 06/24] fix error on exit --- wgpu/backends/wgpu_native/_api.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/wgpu/backends/wgpu_native/_api.py b/wgpu/backends/wgpu_native/_api.py index 54e99bd7..2839a5a0 100644 --- a/wgpu/backends/wgpu_native/_api.py +++ b/wgpu/backends/wgpu_native/_api.py @@ -234,7 +234,7 @@ def request_adapter( # able to create a surface texture for it (from this adapter). surface_id = ffi.NULL if canvas is not None: - surface_id = canvas._surface_id + surface_id = canvas._surface_id # can still be NULL # ----- Select backend @@ -710,8 +710,9 @@ 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): From 8b6eef839a3a93aed5da1995ab0acb708fe5a787 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 17 Sep 2024 11:00:17 +0200 Subject: [PATCH 07/24] looked into qt image draw performance a bit --- wgpu/gui/qt.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index cfaa8bc8..49a62e48 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -372,10 +372,17 @@ 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] + 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], @@ -388,9 +395,10 @@ def present_image(self, image_data, **kwargs): 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, "image") + painter.drawText(100, 100, "This is an image") class QWgpuCanvas(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget): From 70e77d46727e9663edb610c47be92ccea1b8c1f4 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 17 Sep 2024 11:14:50 +0200 Subject: [PATCH 08/24] Fix/workaround for Qt on Wayland --- wgpu/gui/qt.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 49a62e48..ee3e77d8 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -9,6 +9,7 @@ from .base import WgpuCanvasBase, WgpuAutoGui from ._gui_utils import ( + SYSTEM_IS_WAYLAND, get_alt_x11_display, get_alt_wayland_display, weakbind, @@ -16,9 +17,6 @@ ) -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: @@ -177,13 +175,18 @@ def _get_surface_id(self): "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: - return { - "platform": "wayland", - "window": int(self.winId()), - "display": int(get_alt_wayland_display()), - } + if SYSTEM_IS_WAYLAND: + # Trying to do it the Wayland way 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. + return None + # return { + # "platform": "wayland", + # "window": int(self.winId()), + # "display": int(get_alt_wayland_display()), + # } else: return { "platform": "x11", @@ -381,7 +384,10 @@ def present_image(self, image_data, **kwargs): # 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) + painter.setRenderHints( + painter.RenderHint.Antialiasing | painter.RenderHint.SmoothPixmapTransform, + False, + ) image = QtGui.QImage( image_data, From 039b61caad43a16112b12cfeb142e7dc4405e074 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 17 Sep 2024 12:28:38 +0200 Subject: [PATCH 09/24] Fix glfw --- wgpu/gui/base.py | 3 ++- wgpu/gui/glfw.py | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index ee540ea2..c7168aef 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -84,11 +84,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, draw_to_screen=True, **kwargs): super().__init__(*args, **kwargs) self._last_draw_time = 0 self._max_fps = float(max_fps) self._vsync = bool(vsync) + draw_to_screen # 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 62b38fd7..ec74cd7d 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -105,25 +105,30 @@ def get_surface_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()), From 0db867de9d0687216e694161aa86a1c559e57803 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 17 Sep 2024 12:29:23 +0200 Subject: [PATCH 10/24] Give wx same treatment as qt --- wgpu/gui/qt.py | 11 ++++--- wgpu/gui/wx.py | 81 ++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index ee3e77d8..df42cbdc 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -11,7 +11,6 @@ from ._gui_utils import ( SYSTEM_IS_WAYLAND, get_alt_x11_display, - get_alt_wayland_display, weakbind, get_imported_qt_lib, ) @@ -141,8 +140,8 @@ class QWgpuWidget(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget): def __init__(self, *args, draw_to_screen=True, **kwargs): super().__init__(*args, **kwargs) - self._raw_surface_id = self._get_surface_id() - self._draw_to_screen = draw_to_screen and bool(self._raw_surface_id) + self._raw_surface_info = self._get_raw_surface_info() + self._draw_to_screen = draw_to_screen and bool(self._raw_surface_info) self.setAttribute(WA_PaintOnScreen, self._draw_to_screen) self.setAutoFillBackground(False) @@ -169,7 +168,7 @@ def paintEvent(self, event): # noqa: N802 - this is a Qt method # Methods that we add from wgpu (snake_case) - def _get_surface_id(self): + def _get_raw_surface_info(self): if sys.platform.startswith("win") or sys.platform.startswith("darwin"): return { "window": int(self.winId()), @@ -195,9 +194,9 @@ def _get_surface_id(self): } def get_surface_info(self): - if self._draw_to_screen and self._raw_surface_id: + if self._draw_to_screen and self._raw_surface_info: info = {"method": "screen"} - info.update(self._raw_surface_id) + info.update(self._raw_surface_info) else: info = { "method": "image", diff --git a/wgpu/gui/wx.py b/wgpu/gui/wx.py index 8428408c..4b21bad7 100644 --- a/wgpu/gui/wx.py +++ b/wgpu/gui/wx.py @@ -9,10 +9,13 @@ import wx -from ._gui_utils import get_alt_x11_display, get_alt_wayland_display, weakbind +from ._gui_utils import ( + SYSTEM_IS_WAYLAND, + get_alt_x11_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, @@ -125,9 +128,12 @@ 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, draw_to_screen=True, **kwargs): super().__init__(*args, **kwargs) + self._raw_surface_info = self._get_raw_surface_info() + self._draw_to_screen = draw_to_screen and bool(self._raw_surface_info) + # A timer for limiting fps self._request_draw_timer = TimerWithCallback(self.Refresh) @@ -304,18 +310,20 @@ def _on_mouse_move(self, event: wx.MouseEvent): # Methods that we add from wgpu - def get_surface_info(self): + def _get_raw_surface_info(self): if sys.platform.startswith("win") or sys.platform.startswith("darwin"): return { "window": int(self.GetHandle()), } elif sys.platform.startswith("linux"): - if is_wayland: - return { - "platform": "wayland", - "window": int(self.GetHandle()), - "display": int(get_alt_wayland_display()), - } + if SYSTEM_IS_WAYLAND: + # Fallback to offscreen rendering. See comment in same place in qt.py. + return None + # return { + # "platform": "wayland", + # "window": int(self.GetHandle()), + # "display": int(get_alt_wayland_display()), + # } else: return { "platform": "x11", @@ -325,6 +333,17 @@ def get_surface_info(self): else: raise RuntimeError(f"Cannot get Qt surafce info on {sys.platform}.") + def get_surface_info(self): + if self._draw_to_screen and self._raw_surface_info: + info = {"method": "screen"} + info.update(self._raw_surface_info) + else: + 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 +390,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, + draw_to_screen=True, + **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, draw_to_screen=draw_to_screen + ) self._subwidget.add_event_handler(weakbind(self.handle_event), "*") self.Bind(wx.EVT_CLOSE, lambda e: self.Destroy()) @@ -435,7 +473,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() From dc1c24b1b4b6580e33db2a68b398d91bb6c53f29 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 17 Sep 2024 12:44:51 +0200 Subject: [PATCH 11/24] Show warning when using offscreen rendering in qt and wx --- wgpu/gui/qt.py | 9 +++++++++ wgpu/gui/wx.py | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index df42cbdc..c66b4a4e 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -9,6 +9,7 @@ from .base import WgpuCanvasBase, WgpuAutoGui from ._gui_utils import ( + logger, SYSTEM_IS_WAYLAND, get_alt_x11_display, weakbind, @@ -133,6 +134,10 @@ 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.""" @@ -194,10 +199,14 @@ def _get_raw_surface_info(self): } def get_surface_info(self): + global _show_image_method_warning if self._draw_to_screen and self._raw_surface_info: info = {"method": "screen"} info.update(self._raw_surface_info) else: + if _show_image_method_warning: + logger.warn(_show_image_method_warning) + _show_image_method_warning = None info = { "method": "image", "formats": ["rgba8unorm-srgb", "rgba8unorm"], diff --git a/wgpu/gui/wx.py b/wgpu/gui/wx.py index 4b21bad7..61f81060 100644 --- a/wgpu/gui/wx.py +++ b/wgpu/gui/wx.py @@ -10,6 +10,7 @@ import wx from ._gui_utils import ( + logger, SYSTEM_IS_WAYLAND, get_alt_x11_display, weakbind, @@ -113,6 +114,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__() @@ -334,10 +340,14 @@ def _get_raw_surface_info(self): raise RuntimeError(f"Cannot get Qt surafce info on {sys.platform}.") def get_surface_info(self): + global _show_image_method_warning if self._draw_to_screen and self._raw_surface_info: info = {"method": "screen"} info.update(self._raw_surface_info) else: + if _show_image_method_warning: + logger.warn(_show_image_method_warning) + _show_image_method_warning = None info = { "method": "image", "formats": ["rgba8unorm-srgb", "rgba8unorm"], From 28fa1a8915e4c7d8a00a9aa649124b878db5ca4b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 17 Sep 2024 13:06:29 +0200 Subject: [PATCH 12/24] docs --- wgpu/_classes.py | 2 +- wgpu/gui/base.py | 32 ++++++++++++++++++++++++-------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/wgpu/_classes.py b/wgpu/_classes.py index 63b4c632..3e2802b9 100644 --- a/wgpu/_classes.py +++ b/wgpu/_classes.py @@ -227,7 +227,7 @@ def _get_capabilities(self, adapter): "alpha_modes": [enums.CanvasAlphaMode.opaque], } # If capabilities were provided via surface info, overload them! - for key in ["formats", "usages", "alpha_modes"]: + for key in ["formats", "alpha_modes"]: if key in self._surface_info: self._capabilities[key] = self._surface_info[key] # Derived defaults diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index c7168aef..0fa78b27 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -32,15 +32,31 @@ def __init__(self, *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. + """Get information about the surface to render to. + + The result is a small dict, by which the context determines how the + rendered image is 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, load the result into 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. By default formats is + ``["rgba8unorm_srgb", "rgba8unorm"]``, and alpha_modes is ``["opaque"]``. """ - return None + raise NotImplementedError() def get_physical_size(self): """Get the physical size of the canvas in integer pixels.""" From 2c923dd1b46ae44ca9e4ec1090224ac75c26b03e Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 17 Sep 2024 15:41:32 +0200 Subject: [PATCH 13/24] Update offscreen canvases. No more need for WgpuOfscreenCanvasBase --- wgpu/gui/__init__.py | 2 - wgpu/gui/base.py | 2 +- wgpu/gui/jupyter.py | 42 ++++------ wgpu/gui/offscreen.py | 177 ++++-------------------------------------- 4 files changed, 29 insertions(+), 194 deletions(-) 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 0fa78b27..ebd01282 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -54,7 +54,7 @@ def get_surface_info(self): The dict can further contain fields ``formats`` and ``alpha_modes`` to define the canvas capabilities. By default formats is - ``["rgba8unorm_srgb", "rgba8unorm"]``, and alpha_modes is ``["opaque"]``. + ``["rgba8unorm-srgb", "rgba8unorm"]``, and alpha_modes is ``["opaque"]``. """ raise NotImplementedError() diff --git a/wgpu/gui/jupyter.py b/wgpu/gui/jupyter.py index e981af5e..e5f6ca74 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_surface_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 fc7411d1..0c613018 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 XXXXXGPUCanvasContext(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_surface_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 From 23f3e06a0cdb94ae729d99c2e44cf1debef573ae Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 17 Sep 2024 15:41:49 +0200 Subject: [PATCH 14/24] Update notebook --- examples/wgpu-examples.ipynb | 129 +++++++++++++++++++++++++++++------ 1 file changed, 109 insertions(+), 20 deletions(-) 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, From 40e70712e397dde3141fab31b53b323ff90c0b6b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 17 Sep 2024 15:56:46 +0200 Subject: [PATCH 15/24] docs --- docs/gui.rst | 1 - wgpu/gui/base.py | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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/wgpu/gui/base.py b/wgpu/gui/base.py index ebd01282..beb2b68e 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -80,6 +80,12 @@ def get_context(self, kind="webgpu"): 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_surface_info()``. + Canvases that don't support offscreen rendering don't need to implement + this method. + """ raise NotImplementedError() From d7d2db588578af478a821f7b708ff3748ef1209d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 17 Sep 2024 16:06:34 +0200 Subject: [PATCH 16/24] minor tweaks --- wgpu/gui/base.py | 12 +++++------- wgpu/gui/glfw.py | 5 +++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index beb2b68e..400dfb57 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -11,10 +11,8 @@ def create_canvas_context(canvas): 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 - # CC = sys.modules["wgpu"]._classes.GPUCanvasContext - return CC(canvas) + CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 + return CanvasContext(canvas) class WgpuCanvasInterface: @@ -34,9 +32,9 @@ def __init__(self, *args, **kwargs): def get_surface_info(self): """Get information about the surface to render to. - The result is a small dict, by which the context determines how the - rendered image is presented to the canvas. There are two possible - methods. + The result is 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 diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index ec74cd7d..725c9fa8 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -519,8 +519,9 @@ def _on_char(self, window, char): def present_image(self, image, **kwargs): raise NotImplementedError() - # glfw does not have a way to blit an image. - # We could use OpenGL to display it though. + # 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 From c8bb1bf926745fde5d1832c5c868cb600bf92247 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 17 Sep 2024 16:18:40 +0200 Subject: [PATCH 17/24] update tests --- tests/test_gui_base.py | 47 ++++++++++++++++-------------------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/tests/test_gui_base.py b/tests/test_gui_base.py index 1b8ac2ba..173c1597 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_surface_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_surface_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(): From fa5b77c2da2a51aabb94c9a05606498a7ea8185d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 18 Sep 2024 09:49:11 +0200 Subject: [PATCH 18/24] Fix memtest --- tests_mem/test_gui.py | 2 +- tests_mem/test_gui_qt.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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() From 8e3d56a2edca7b113c0ccabd3154acaa8bd04c02 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 18 Sep 2024 09:50:59 +0200 Subject: [PATCH 19/24] remove debug text overlay --- wgpu/gui/qt.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index c66b4a4e..462331f6 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -410,9 +410,9 @@ def present_image(self, image_data, **kwargs): 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") + # 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): From 588a8dfb880f99c4b7ace8533b350f6aff598c61 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 18 Sep 2024 10:15:36 +0200 Subject: [PATCH 20/24] Bit of docstrings --- wgpu/gui/base.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 400dfb57..4fd80fcf 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -6,6 +6,11 @@ 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( @@ -32,9 +37,9 @@ def __init__(self, *args, **kwargs): def get_surface_info(self): """Get information about the surface to render to. - The result is 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. + 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 @@ -44,15 +49,16 @@ def get_surface_info(self): by wgpu to obtain the required surface id. When the ``method`` field is "image", the context will render to a - texture, load the result into RAM, and call ``canvas.present_image()`` + 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. By default formats is - ``["rgba8unorm-srgb", "rgba8unorm"]``, and alpha_modes is ``["opaque"]``. + define the canvas capabilities. For the "image" method, the default + formats is ``["rgba8unorm-srgb", "rgba8unorm"]``, and the default + alpha_modes is ``["opaque"]``. """ raise NotImplementedError() From 2e4d12551c004a43d1ef720a3d6740d8c350fd94 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 18 Sep 2024 10:27:40 +0200 Subject: [PATCH 21/24] explaine purpose of canvas context --- wgpu/_classes.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/wgpu/_classes.py b/wgpu/_classes.py index 3e2802b9..acd91651 100644 --- a/wgpu/_classes.py +++ b/wgpu/_classes.py @@ -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 From 02c9600ae28608eeed1400f6c91385e7432d805a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 18 Sep 2024 10:42:20 +0200 Subject: [PATCH 22/24] Rename surface_info -> present_info --- examples/triangle_glfw_direct.py | 6 +++--- examples/triangle_subprocess.py | 8 ++++---- tests/test_gui_base.py | 4 ++-- tests/test_gui_glfw.py | 2 +- wgpu/_classes.py | 22 +++++++++++----------- wgpu/backends/wgpu_native/_api.py | 4 ++-- wgpu/backends/wgpu_native/_helpers.py | 20 ++++++++++---------- wgpu/gui/base.py | 6 +++--- wgpu/gui/glfw.py | 6 +++--- wgpu/gui/jupyter.py | 2 +- wgpu/gui/offscreen.py | 2 +- wgpu/gui/qt.py | 16 ++++++++-------- wgpu/gui/wx.py | 16 ++++++++-------- 13 files changed, 57 insertions(+), 57 deletions(-) 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/tests/test_gui_base.py b/tests/test_gui_base.py index 173c1597..5f06dddc 100644 --- a/tests/test_gui_base.py +++ b/tests/test_gui_base.py @@ -38,7 +38,7 @@ def test_base_canvas_context(): assert not issubclass(wgpu.gui.WgpuCanvasInterface, wgpu.GPUCanvasContext) assert hasattr(wgpu.gui.WgpuCanvasInterface, "get_context") canvas = wgpu.gui.WgpuCanvasInterface() - # Cannot instantiate, because get_surface_info is not implemented + # Cannot instantiate, because get_present_info is not implemented with raises(NotImplementedError): wgpu.GPUCanvasContext(canvas) @@ -86,7 +86,7 @@ def __init__(self): self.frame_count = 0 self.physical_size = 100, 100 - def get_surface_info(self): + def get_present_info(self): return { "method": "image", "formats": ["rgba8unorm-srgb"], 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/wgpu/_classes.py b/wgpu/_classes.py index acd91651..3bd6ba23 100644 --- a/wgpu/_classes.py +++ b/wgpu/_classes.py @@ -189,11 +189,11 @@ 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_surface_info() - self._surface_info = canvas.get_surface_info() - if self._surface_info.get("method", None) not in ("screen", "image"): + # 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_surface_info() must produce a dict with a field 'method' that is either 'screen' or 'image'." + "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 @@ -219,7 +219,7 @@ def _get_capabilities(self, adapter): """Get dict of capabilities and cache the result.""" if self._capabilities is None: self._capabilities = {} - if self._surface_info["method"] == "screen": + if self._present_info["method"] == "screen": # Query capabilities from the surface self._capabilities.update(self._get_capabilities_screen(adapter)) else: @@ -231,8 +231,8 @@ def _get_capabilities(self, adapter): } # If capabilities were provided via surface info, overload them! for key in ["formats", "alpha_modes"]: - if key in self._surface_info: - self._capabilities[key] = self._surface_info[key] + 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"] @@ -341,7 +341,7 @@ def configure( "alpha_mode": alpha_mode, } - if self._surface_info["method"] == "screen": + if self._present_info["method"] == "screen": self._configure_screen(**self._config) def _configure_screen( @@ -362,7 +362,7 @@ def unconfigure(self): """Removes the presentation context configuration. Destroys any textures produced while configured. """ - if self._surface_info["method"] == "screen": + if self._present_info["method"] == "screen": self._unconfigure_screen() self._config = None self._drop_texture() @@ -385,7 +385,7 @@ def get_current_texture(self): # 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._surface_info["method"] == "screen": + if self._present_info["method"] == "screen": self._texture = self._create_texture_screen() else: self._texture = self._create_texture_image() @@ -434,7 +434,7 @@ def present(self): logger.debug(msg) return - if self._surface_info["method"] == "screen": + if self._present_info["method"] == "screen": self._present_screen() else: self._present_image() diff --git a/wgpu/backends/wgpu_native/_api.py b/wgpu/backends/wgpu_native/_api.py index 4a232382..0bb1d0ea 100644 --- a/wgpu/backends/wgpu_native/_api.py +++ b/wgpu/backends/wgpu_native/_api.py @@ -454,8 +454,8 @@ def __init__(self, canvas): # Obtain the surface id. The lifetime is of the surface is bound # to the lifetime of this context object. - if self._surface_info["method"] == "screen": - self._surface_id = get_surface_id_from_info(self._surface_info) + 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 diff --git a/wgpu/backends/wgpu_native/_helpers.py b/wgpu/backends/wgpu_native/_helpers.py index a7b15fee..05e6dee2 100644 --- a/wgpu/backends/wgpu_native/_helpers.py +++ b/wgpu/backends/wgpu_native/_helpers.py @@ -94,7 +94,7 @@ def get_wgpu_instance(): return _the_instance -def get_surface_id_from_info(surface_info): +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. """ @@ -103,7 +103,7 @@ def get_surface_id_from_info(surface_info): 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 @@ -117,7 +117,7 @@ def get_surface_id_from_info(surface_info): # [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: @@ -158,22 +158,22 @@ def get_surface_id_from_info(surface_info): 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}'.") diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 4fd80fcf..cedf82bf 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -6,7 +6,7 @@ def create_canvas_context(canvas): - """ Create a GPUCanvasContext for the given canvas. + """Create a GPUCanvasContext for the given canvas. Helper function to keep the implementation of WgpuCanvasInterface as small as possible. @@ -34,7 +34,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._canvas_context = None - def get_surface_info(self): + 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 @@ -86,7 +86,7 @@ def get_context(self, kind="webgpu"): def present_image(self, image, **kwargs): """Consume the final rendered image. - This is called when using the "image" method, see ``get_surface_info()``. + 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. """ diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index 725c9fa8..595a99ea 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -104,7 +104,7 @@ } -def get_surface_info(window): +def get_glfw_present_info(window): if sys.platform.startswith("win"): return { @@ -303,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 diff --git a/wgpu/gui/jupyter.py b/wgpu/gui/jupyter.py index e5f6ca74..c8ca44eb 100644 --- a/wgpu/gui/jupyter.py +++ b/wgpu/gui/jupyter.py @@ -92,7 +92,7 @@ def _request_draw(self): # Implementation needed for WgpuCanvasInterface - def get_surface_info(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 diff --git a/wgpu/gui/offscreen.py b/wgpu/gui/offscreen.py index 0c613018..b9ce8983 100644 --- a/wgpu/gui/offscreen.py +++ b/wgpu/gui/offscreen.py @@ -17,7 +17,7 @@ def __init__(self, *args, size=None, pixel_ratio=1, title=None, **kwargs): self._closed = False self._last_image = None - def get_surface_info(self): + def get_present_info(self): return { "method": "image", "formats": ["rgba8unorm-srgb", "rgba8unorm"], diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 462331f6..668f5dd0 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -145,8 +145,8 @@ class QWgpuWidget(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget): def __init__(self, *args, draw_to_screen=True, **kwargs): super().__init__(*args, **kwargs) - self._raw_surface_info = self._get_raw_surface_info() - self._draw_to_screen = draw_to_screen and bool(self._raw_surface_info) + self._surface_ids = self._get_surface_ids() + self._draw_to_screen = draw_to_screen and bool(self._surface_ids) self.setAttribute(WA_PaintOnScreen, self._draw_to_screen) self.setAutoFillBackground(False) @@ -173,7 +173,7 @@ def paintEvent(self, event): # noqa: N802 - this is a Qt method # Methods that we add from wgpu (snake_case) - def _get_raw_surface_info(self): + def _get_surface_ids(self): if sys.platform.startswith("win") or sys.platform.startswith("darwin"): return { "window": int(self.winId()), @@ -198,11 +198,11 @@ def _get_raw_surface_info(self): "display": int(get_alt_x11_display()), } - def get_surface_info(self): + def get_present_info(self): global _show_image_method_warning - if self._draw_to_screen and self._raw_surface_info: + if self._draw_to_screen and self._surface_ids: info = {"method": "screen"} - info.update(self._raw_surface_info) + info.update(self._surface_ids) else: if _show_image_method_warning: logger.warn(_show_image_method_warning) @@ -469,8 +469,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() diff --git a/wgpu/gui/wx.py b/wgpu/gui/wx.py index 61f81060..06797244 100644 --- a/wgpu/gui/wx.py +++ b/wgpu/gui/wx.py @@ -137,8 +137,8 @@ class WxWgpuWindow(WgpuAutoGui, WgpuCanvasBase, wx.Window): def __init__(self, *args, draw_to_screen=True, **kwargs): super().__init__(*args, **kwargs) - self._raw_surface_info = self._get_raw_surface_info() - self._draw_to_screen = draw_to_screen and bool(self._raw_surface_info) + self._surface_ids = self._get_surface_ids() + self._draw_to_screen = draw_to_screen and bool(self._surface_ids) # A timer for limiting fps self._request_draw_timer = TimerWithCallback(self.Refresh) @@ -316,7 +316,7 @@ def _on_mouse_move(self, event: wx.MouseEvent): # Methods that we add from wgpu - def _get_raw_surface_info(self): + def _get_surface_ids(self): if sys.platform.startswith("win") or sys.platform.startswith("darwin"): return { "window": int(self.GetHandle()), @@ -339,11 +339,11 @@ def _get_raw_surface_info(self): else: raise RuntimeError(f"Cannot get Qt surafce info on {sys.platform}.") - def get_surface_info(self): + def get_present_info(self): global _show_image_method_warning - if self._draw_to_screen and self._raw_surface_info: + if self._draw_to_screen and self._surface_ids: info = {"method": "screen"} - info.update(self._raw_surface_info) + info.update(self._surface_ids) else: if _show_image_method_warning: logger.warn(_show_image_method_warning) @@ -445,8 +445,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() From 4685a0b20de9474e2961836df958f65ee4efce8b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 18 Sep 2024 11:06:41 +0200 Subject: [PATCH 23/24] draw_to_screen -> present_method --- wgpu/gui/base.py | 4 ++-- wgpu/gui/qt.py | 48 +++++++++++++++++++++++++++++------------------- wgpu/gui/wx.py | 36 +++++++++++++++++++++++------------- 3 files changed, 54 insertions(+), 34 deletions(-) diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index cedf82bf..ab21c1bd 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -110,12 +110,12 @@ class WgpuCanvasBase(WgpuCanvasInterface): also want to set ``vsync`` to False. """ - def __init__(self, *args, max_fps=30, vsync=True, draw_to_screen=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) - draw_to_screen # We just catch the arg here in case a backend does implement support it + 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/qt.py b/wgpu/gui/qt.py index 668f5dd0..3e73442e 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -142,13 +142,28 @@ def enable_hidpi(): class QWgpuWidget(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget): """A QWidget representing a wgpu canvas that can be embedded in a Qt application.""" - def __init__(self, *args, draw_to_screen=True, **kwargs): + def __init__(self, *args, present_method=None, **kwargs): super().__init__(*args, **kwargs) + # Determine present method self._surface_ids = self._get_surface_ids() - self._draw_to_screen = draw_to_screen and bool(self._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._draw_to_screen) + self.setAttribute(WA_PaintOnScreen, self._present_to_screen) self.setAutoFillBackground(False) self.setAttribute(WA_DeleteOnClose, True) self.setAttribute(WA_InputMethodEnabled, True) @@ -163,7 +178,7 @@ def __init__(self, *args, draw_to_screen=True, **kwargs): def paintEngine(self): # noqa: N802 - this is a Qt method # https://doc.qt.io/qt-5/qt.html#WidgetAttribute-enum WA_PaintOnScreen - if self._draw_to_screen: + if self._present_to_screen: return None else: return super().paintEngine() @@ -179,18 +194,13 @@ def _get_surface_ids(self): "window": int(self.winId()), } elif sys.platform.startswith("linux"): - if SYSTEM_IS_WAYLAND: - # Trying to do it the Wayland way 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. - return None - # return { - # "platform": "wayland", - # "window": int(self.winId()), - # "display": int(get_alt_wayland_display()), - # } + if False: + # We fall back to XWayland, see _gui_utils.py + return { + "platform": "wayland", + "window": int(self.winId()), + "display": int(get_alt_wayland_display()), + } else: return { "platform": "x11", @@ -200,7 +210,7 @@ def _get_surface_ids(self): def get_present_info(self): global _show_image_method_warning - if self._draw_to_screen and self._surface_ids: + if self._present_to_screen: info = {"method": "screen"} info.update(self._surface_ids) else: @@ -424,7 +434,7 @@ class QWgpuCanvas(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget): # detect this. See https://github.com/pygfx/wgpu-py/pull/68 def __init__( - self, *, size=None, title=None, max_fps=30, draw_to_screen=True, **kwargs + 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 @@ -437,7 +447,7 @@ def __init__( self.setMouseTracking(True) self._subwidget = QWgpuWidget( - self, max_fps=max_fps, draw_to_screen=draw_to_screen + self, max_fps=max_fps, present_method=present_method ) self._subwidget.add_event_handler(weakbind(self.handle_event), "*") diff --git a/wgpu/gui/wx.py b/wgpu/gui/wx.py index 06797244..e7bb7215 100644 --- a/wgpu/gui/wx.py +++ b/wgpu/gui/wx.py @@ -134,11 +134,22 @@ 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, draw_to_screen=True, **kwargs): + def __init__(self, *args, present_method=None, **kwargs): super().__init__(*args, **kwargs) + # Determine present method self._surface_ids = self._get_surface_ids() - self._draw_to_screen = draw_to_screen and bool(self._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) @@ -322,14 +333,13 @@ def _get_surface_ids(self): "window": int(self.GetHandle()), } elif sys.platform.startswith("linux"): - if SYSTEM_IS_WAYLAND: - # Fallback to offscreen rendering. See comment in same place in qt.py. - return None - # return { - # "platform": "wayland", - # "window": int(self.GetHandle()), - # "display": int(get_alt_wayland_display()), - # } + if False: + # We fall back to XWayland, see _gui_utils.py + return { + "platform": "wayland", + "window": int(self.GetHandle()), + "display": int(get_alt_wayland_display()), + } else: return { "platform": "x11", @@ -341,7 +351,7 @@ def _get_surface_ids(self): def get_present_info(self): global _show_image_method_warning - if self._draw_to_screen and self._surface_ids: + if self._present_to_screen and self._surface_ids: info = {"method": "screen"} info.update(self._surface_ids) else: @@ -420,7 +430,7 @@ def __init__( size=None, title=None, max_fps=30, - draw_to_screen=True, + present_method=None, **kwargs, ): get_app() @@ -430,7 +440,7 @@ def __init__( self.SetTitle(title or "wx wgpu canvas") self._subwidget = WxWgpuWindow( - parent=self, max_fps=max_fps, draw_to_screen=draw_to_screen + 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()) From 6b624a86342c4081710df48ab7105d9b68eda72f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 18 Sep 2024 11:11:17 +0200 Subject: [PATCH 24/24] flake --- wgpu/gui/qt.py | 1 + wgpu/gui/wx.py | 1 + 2 files changed, 2 insertions(+) diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 3e73442e..4ffb6157 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -12,6 +12,7 @@ logger, SYSTEM_IS_WAYLAND, get_alt_x11_display, + get_alt_wayland_display, weakbind, get_imported_qt_lib, ) diff --git a/wgpu/gui/wx.py b/wgpu/gui/wx.py index e7bb7215..f314d244 100644 --- a/wgpu/gui/wx.py +++ b/wgpu/gui/wx.py @@ -13,6 +13,7 @@ logger, SYSTEM_IS_WAYLAND, get_alt_x11_display, + get_alt_wayland_display, weakbind, ) from .base import WgpuCanvasBase, WgpuAutoGui