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