From 37b7739f68f9f3518588c6ffde06ab36671013b1 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 4 Nov 2025 09:13:37 +0100 Subject: [PATCH 01/23] WIP implement own context classes --- rendercanvas/__init__.py | 3 + rendercanvas/base.py | 103 +++++-- rendercanvas/contexts/__init__.py | 2 + rendercanvas/contexts/_fullscreen.py | 299 +++++++++++++++++++ rendercanvas/contexts/basecontext.py | 58 ++++ rendercanvas/contexts/bitmapcontext.py | 161 ++++++++++ rendercanvas/contexts/wgpucontext.py | 180 +++++++++++ rendercanvas/utils/bitmaprenderingcontext.py | 101 ------- 8 files changed, 774 insertions(+), 133 deletions(-) create mode 100644 rendercanvas/contexts/__init__.py create mode 100644 rendercanvas/contexts/_fullscreen.py create mode 100644 rendercanvas/contexts/basecontext.py create mode 100644 rendercanvas/contexts/bitmapcontext.py create mode 100644 rendercanvas/contexts/wgpucontext.py delete mode 100644 rendercanvas/utils/bitmaprenderingcontext.py diff --git a/rendercanvas/__init__.py b/rendercanvas/__init__.py index 09dd634..d619902 100644 --- a/rendercanvas/__init__.py +++ b/rendercanvas/__init__.py @@ -8,5 +8,8 @@ from . import _coreutils from ._enums import CursorShape, EventType, UpdateMode from .base import BaseRenderCanvas, BaseLoop +from . import contexts +from . import utils + __all__ = ["BaseLoop", "BaseRenderCanvas", "CursorShape", "EventType", "UpdateMode"] diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 4bd7ea8..a242394 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -15,6 +15,7 @@ CursorShape, CursorShapeEnum, ) +from . import contexts from ._events import EventEmitter from ._loop import BaseLoop from ._scheduler import Scheduler @@ -238,12 +239,14 @@ def get_context(self, context_type: str) -> object: if not isinstance(context_type, str): raise TypeError("context_type must be str.") + present_method_map = context_type_map = {"screen": "wgpu"} + # Resolve the context type name - known_types = { - "wgpu": "wgpu", - "bitmap": "rendercanvas.utils.bitmaprenderingcontext", - } - resolved_context_type = known_types.get(context_type, context_type) + resolved_context_type = context_type_map.get(context_type, context_type) + if resolved_context_type not in ("bitmap", "wgpu"): + raise ValueError( + "The given context type is invalid: {context_type!r} is not 'bitmap' or 'wgpu'." + ) # Is the context already set? if self._canvas_context is not None: @@ -254,34 +257,70 @@ def get_context(self, context_type: str) -> object: f"Cannot get context for '{context_type}': a context of type '{self._canvas_context._context_type}' is already set." ) - # Load module - module_name, _, class_name = resolved_context_type.partition(":") - try: - module = importlib.import_module(module_name) - except ImportError as err: - raise ValueError( - f"Cannot get context for '{context_type}': {err}. Known valid values are {set(known_types)}" - ) from None - - # Obtain factory to produce context - factory_name = class_name or "rendercanvas_context_hook" - try: - factory_func = getattr(module, factory_name) - except AttributeError: - raise ValueError( - f"Cannot get context for '{context_type}': could not find `{factory_name}` in '{module.__name__}'" - ) from None - - # Create the context - context = factory_func(self, self._rc_get_present_methods()) + # # Load module + # module_name, _, class_name = resolved_context_type.partition(":") + # try: + # module = importlib.import_module(module_name) + # except ImportError as err: + # raise ValueError( + # f"Cannot get context for '{context_type}': {err}. Known valid values are {set(known_types)}" + # ) from None + + # # Obtain factory to produce context + # factory_name = class_name or "rendercanvas_context_hook" + # try: + # factory_func = getattr(module, factory_name) + # except AttributeError: + # raise ValueError( + # f"Cannot get context for '{context_type}': could not find `{factory_name}` in '{module.__name__}'" + # ) from None + + # # Create the context + # context = factory_func(self, self._rc_get_present_methods()) + + # # Quick checks to make sure the context has the correct API + # if not (hasattr(context, "canvas") and context.canvas is self): + # raise RuntimeError( + # "The context does not have a canvas attribute that refers to this canvas." + # ) + # if not (hasattr(context, "present") and callable(context.present)): + # raise RuntimeError("The context does not have a present method.") + + # Select present_method + # todo: does the canvas present_method arg override this in the appropriate way? + present_methods = self._rc_get_present_methods() + present_method = None + if resolved_context_type == "bitmap": + for name in ("bitmap", "wgpu", "screen"): + if name in present_methods: + present_method = name + break + else: + for name in ("wgpu", "screen", "bitmap"): + if name in present_methods: + present_method = name + break - # Quick checks to make sure the context has the correct API - if not (hasattr(context, "canvas") and context.canvas is self): + # This should never happen, unless there's a bug + if present_method is None: raise RuntimeError( - "The context does not have a canvas attribute that refers to this canvas." + "Could not select present_method for context_type {context_type!r} from present_methods {present_methods!r}" ) - if not (hasattr(context, "present") and callable(context.present)): - raise RuntimeError("The context does not have a present method.") + + # Select present_info + present_info = dict(present_methods[present_method]) + assert "method" not in present_info, ( + "the field 'method' is reserved in present_methods dicts" + ) + present_info = { + "method": present_method_map.get(present_method, present_method), + **present_info, + } + + if resolved_context_type == "bitmap": + context = contexts.BitmapContext(self, present_info) + else: + context = contexts.WGpuContext(self, present_info) # Done self._canvas_context = context @@ -498,9 +537,9 @@ def _draw_frame_and_present(self): # Note: if vsync is used, this call may wait a little (happens down at the level of the driver or OS) context = self._canvas_context if context: - result = context.present() + result = context._rc_present() method = result.pop("method") - if method in ("skip", "screen"): + if method in ("skip", "screen", "delegated"): pass # nothing we need to do elif method == "fail": raise RuntimeError(result.get("message", "") or "present error") diff --git a/rendercanvas/contexts/__init__.py b/rendercanvas/contexts/__init__.py new file mode 100644 index 0000000..d243fb2 --- /dev/null +++ b/rendercanvas/contexts/__init__.py @@ -0,0 +1,2 @@ +from .basecontext import BaseContext +from .bitmapcontext import BitmapContext diff --git a/rendercanvas/contexts/_fullscreen.py b/rendercanvas/contexts/_fullscreen.py new file mode 100644 index 0000000..8429b91 --- /dev/null +++ b/rendercanvas/contexts/_fullscreen.py @@ -0,0 +1,299 @@ +import wgpu + + +class FullscreenTexture: + """An object that helps rendering a texture to the full viewport.""" + + def __init__(self, device): + self._device = device + self._pipeline_layout = None + self._pipeline = None + self._texture = None + self._uniform_data = memoryview(bytearray(1 * 4)).cast("f") + + def set_texture_data(self, data): + """Upload new data to the texture. Creates a new internal texture object if needed.""" + m = memoryview(data) + + texture_format = self._get_format_from_memoryview(m) + texture_size = m.shape[1], m.shape[0], 1 + + # Lazy init for the static stuff + if self._pipeline_layout is None: + self._create_uniform_buffer() + self._create_pipeline_layout() + + # Need new texture? + if ( + self._texture is None + or self._texture.size != texture_size + or texture_format != self._texture.format + ): + self._create_texture(texture_size, texture_format) + self._create_bind_groups() + + # Update buffer data + self._uniform_data[0] = 1 if texture_format.startswith("r8") else 4 + + # Upload data + self._update_texture(m) + self._update_uniform_buffer() + + def _get_format_from_memoryview(self, m): + # Check dtype + if m.format == "B": + dtype = "u8" + else: + raise ValueError( + "Unsupported bitmap dtype/format '{m.format}', expecting unsigned bytes ('B')." + ) + + # Get color format + color_format = None + if len(m.shape) == 2: + color_format = "i" + elif len(m.shape) == 3: + if m.shape[2] == 1: + color_format = "i" + elif m.shape[2] == 4: + color_format = "rgba" + if not color_format: + raise ValueError( + f"Unsupported bitmap shape {m.shape}, expecting a 2D grayscale or rgba image." + ) + + # Deduce wgpu texture format + format_map = { + "i-u8": wgpu.TextureFormat.r8unorm, + "rgba-u8": wgpu.TextureFormat.rgba8unorm, + } + format = f"{color_format}-{dtype}" + return format_map[format] + + def _create_uniform_buffer(self): + device = self._device + self._uniform_buffer = device.create_buffer( + size=self._uniform_data.nbytes, + usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, + ) + + def _update_uniform_buffer(self): + device = self._device + device.queue.write_buffer(self._uniform_buffer, 0, self._uniform_data) + + def _create_texture(self, size, format): + device = self._device + self._texture = device.create_texture( + size=size, + usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, + dimension=wgpu.TextureDimension.d2, + format=format, + mip_level_count=1, + sample_count=1, + ) + self._texture_view = self._texture.create_view() + self._sampler = device.create_sampler() + + def _update_texture(self, texture_data): + device = self._device + size = texture_data.shape[1], texture_data.shape[0], 1 + device.queue.write_texture( + { + "texture": self._texture, + "mip_level": 0, + "origin": (0, 0, 0), + }, + texture_data, + { + "offset": 0, + "bytes_per_row": texture_data.strides[0], + }, + size, + ) + + def _create_pipeline_layout(self): + device = self._device + bind_groups_layout_entries = [[]] + + bind_groups_layout_entries[0].append( + { + "binding": 0, + "visibility": wgpu.ShaderStage.VERTEX | wgpu.ShaderStage.FRAGMENT, + "buffer": {}, + } + ) + bind_groups_layout_entries[0].append( + { + "binding": 1, + "visibility": wgpu.ShaderStage.FRAGMENT, + "texture": {}, + } + ) + bind_groups_layout_entries[0].append( + { + "binding": 2, + "visibility": wgpu.ShaderStage.FRAGMENT, + "sampler": {}, + } + ) + + # Create the wgpu binding objects + bind_group_layouts = [] + for layout_entries in bind_groups_layout_entries: + bind_group_layout = device.create_bind_group_layout(entries=layout_entries) + bind_group_layouts.append(bind_group_layout) + + self._bind_group_layouts = bind_group_layouts + self._pipeline_layout = device.create_pipeline_layout( + bind_group_layouts=bind_group_layouts + ) + + def _create_pipeline(self, target_texture_view): + device = self._device + texture_format = target_texture_view.texture.format + shader = device.create_shader_module(code=shader_source) + + pipeline_kwargs = dict( + layout=self._pipeline_layout, + vertex={ + "module": shader, + "entry_point": "vs_main", + }, + primitive={ + "topology": wgpu.PrimitiveTopology.triangle_strip, + "front_face": wgpu.FrontFace.ccw, + "cull_mode": wgpu.CullMode.back, + }, + depth_stencil=None, + multisample=None, + fragment={ + "module": shader, + "entry_point": "fs_main", + "targets": [ + { + "format": texture_format, + "blend": { + "alpha": {}, + "color": {}, + }, + } + ], + }, + ) + + self._pipeline = device.create_render_pipeline(**pipeline_kwargs) + + def _create_bind_groups(self): + device = self._device + bind_groups_entries = [[]] + bind_groups_entries[0].append( + { + "binding": 0, + "resource": { + "buffer": self._uniform_buffer, + "offset": 0, + "size": self._uniform_buffer.size, + }, + } + ) + bind_groups_entries[0].append({"binding": 1, "resource": self._texture_view}) + bind_groups_entries[0].append({"binding": 2, "resource": self._sampler}) + + bind_groups = [] + for entries, bind_group_layout in zip( + bind_groups_entries, self._bind_group_layouts, strict=False + ): + bind_groups.append( + device.create_bind_group(layout=bind_group_layout, entries=entries) + ) + self._bind_groups = bind_groups + + def draw(self, command_encoder, target_texture_view): + """Draw the bitmap to given target texture view.""" + + if self._pipeline is None: + self._create_pipeline(target_texture_view) + + render_pass = command_encoder.begin_render_pass( + color_attachments=[ + { + "view": target_texture_view, + "resolve_target": None, + "clear_value": (0, 0, 0, 1), + "load_op": wgpu.LoadOp.clear, + "store_op": wgpu.StoreOp.store, + } + ], + ) + + render_pass.set_pipeline(self._pipeline) + for bind_group_id, bind_group in enumerate(self._bind_groups): + render_pass.set_bind_group(bind_group_id, bind_group) + render_pass.draw(4, 1, 0, 0) + render_pass.end() + + +shader_source = """ +struct Uniforms { + format: f32, +}; +@group(0) @binding(0) +var uniforms: Uniforms; + +struct VertexInput { + @builtin(vertex_index) vertex_index : u32, +}; +struct VertexOutput { + @location(0) texcoord: vec2, + @builtin(position) pos: vec4, +}; +struct FragmentOutput { + @location(0) color : vec4, +}; + + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var positions = array, 4>( + vec2(-1.0, 1.0), + vec2(-1.0, -1.0), + vec2( 1.0, 1.0), + vec2( 1.0, -1.0), + ); + var texcoords = array, 4>( + vec2(0.0, 0.0), + vec2(0.0, 1.0), + vec2(1.0, 0.0), + vec2(1.0, 1.0), + ); + let index = i32(in.vertex_index); + var out: VertexOutput; + out.pos = vec4(positions[index], 0.0, 1.0); + out.texcoord = vec2(texcoords[index]); + return out; +} + +@group(0) @binding(1) +var r_tex: texture_2d; + +@group(0) @binding(2) +var r_sampler: sampler; + +@fragment +fn fs_main(in: VertexOutput) -> FragmentOutput { + let value = textureSample(r_tex, r_sampler, in.texcoord); + var color = vec4(value); + if (uniforms.format == 1) { + color = vec4(value.r, value.r, value.r, 1.0); + } else if (uniforms.format == 2) { + color = vec4(value.r, value.r, value.r, value.g); + } + // We assume that the input color is sRGB. We don't need to go to physical/linear + // colorspace, because we don't need light calculations or anything. The + // output texture format is a regular rgba8unorm (not srgb), so that no transform + // happens as we write to the texture; the pixel values are already srgb. + var out: FragmentOutput; + out.color = color; + return out; +} +""" diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py new file mode 100644 index 0000000..f325f79 --- /dev/null +++ b/rendercanvas/contexts/basecontext.py @@ -0,0 +1,58 @@ +import weakref + + +class BaseContext: + """A context that supports rendering by generating grayscale or rgba images. + + This is inspired by JS ``get_context('bitmaprenderer')`` which returns a ``ImageBitmapRenderingContext``. + It is a relatively simple context to implement, and provides a easy entry to using ``rendercanvas``. + """ + + def __init__(self, canvas: object, present_info: dict): + self._canvas_ref = weakref.ref(canvas) + self._present_info = present_info + assert present_info["method"] in ("bitmap", "wgpu") # internal sanity check + self._physical_size = 0, 0 + + def __repr__(self): + return f"" + + @property + def canvas(self) -> object: + """The associated RenderCanvas object (internally stored as a weakref).""" + return self._canvas_ref() + + def _rc_set_physical_size(self, width: int, height: int) -> None: + """Called by the BaseRenderCanvas to set the physical size.""" + self._physical_size = int(width), int(height) + + def _rc_present(self): + """Called by BaseRenderCanvas to collect the result. Subclasses must implement this. + + The implementation should always return a present-result dict, which + should have at least a field 'method'. The value of 'method' must be + one of the methods that the canvas supports, i.e. it must be in ``present_methods``. + + * If there is nothing to present, e.g. because nothing was rendered yet: + * return ``{"method": "skip"}`` (special case). + * If presentation could not be done for some reason: + * return ``{"method": "fail", "message": "xx"}`` (special case). + * If ``present_method`` is "screen": + * Render to screen using the info in ``present_methods['screen']``). + * Return ``{"method", "screen"}`` as confirmation. + * If ``present_method`` is "bitmap": + * Return ``{"method": "bitmap", "data": data, "format": format}``. + * 'data' is a memoryview, or something that can be converted to a memoryview, like a numpy array. + * 'format' is the format of the bitmap, must be in ``present_methods['bitmap']['formats']`` ("rgba-u8" is always supported). + * If ``present_method`` is something else: + * Return ``{"method": "xx", ...}``. + * It's the responsibility of the context to use a render method that is supported by the canvas, + and that the appropriate arguments are supplied. + """ + + # This is a stub + return {"method": "skip"} + + def _release(self): + """Release resources. Called by the canvas when it's closed.""" + pass # TODO: need this? prefix _rc_? diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py new file mode 100644 index 0000000..bc2f6b7 --- /dev/null +++ b/rendercanvas/contexts/bitmapcontext.py @@ -0,0 +1,161 @@ +""" +Provide a simple context class to support ``canvas.get_context('bitmap')``. +""" + +import sys + +from .basecontext import BaseContext + + +class BitmapContext(BaseContext): + """A context that supports rendering by generating grayscale or rgba images. + + This is inspired by JS ``get_context('bitmaprenderer')`` which returns a ``ImageBitmapRenderingContext``. + It is a relatively simple context to implement, and provides a easy entry to using ``rendercanvas``. + """ + + def __new__(cls, canvas: object, present_info: dict): + present_method = present_info["method"] + if present_method == "bitmap": + return super().__new__(BitmapToBitmapContext) + elif present_method == "wgpu": + return super().__new__(BitmapToWgpuContext) + else: + raise TypeError("Unexpected present_method {present_method!r}") + + def __init__(self, canvas, present_info): + super().__init__(canvas, present_info) + self._bitmap_and_format = None + + def set_bitmap(self, bitmap): + """Set the rendered bitmap image. + + Call this in the draw event. The bitmap must be an object that can be + conveted to a memoryview, like a numpy array. It must represent a 2D + image in either grayscale or rgba format, with uint8 values + """ + + m = memoryview(bitmap) + + # Check dtype + if m.format == "B": + dtype = "u8" + else: + raise ValueError( + "Unsupported bitmap dtype/format '{m.format}', expecting unsigned bytes ('B')." + ) + + # Get color format + color_format = None + if len(m.shape) == 2: + color_format = "i" + elif len(m.shape) == 3: + if m.shape[2] == 1: + color_format = "i" + elif m.shape[2] == 4: + color_format = "rgba" + if not color_format: + raise ValueError( + f"Unsupported bitmap shape {m.shape}, expecting a 2D grayscale or rgba image." + ) + + # We should now have one of two formats + format = f"{color_format}-{dtype}" + assert format in ("rgba-u8", "i-u8") + + self._bitmap_and_format = m, format + + +class BitmapToBitmapContext(BitmapContext): + """A BitmapContext that presents a bitmap to the canvas.""" + + def __init__(self, canvas, present_info): + super().__init__(canvas, present_info) + assert self._present_info["method"] == "bitmap" + self._bitmap_and_format = None + + def _rc_present(self): + if self._bitmap_and_format is None: + return {"method": "skip"} + + bitmap, format = self._bitmap_and_format + if format not in self._present_info["formats"]: + # Convert from i-u8 -> rgba-u8. This surely hurts performance. + assert format == "i-u8" + flat_bitmap = bitmap.cast("B", (bitmap.nbytes,)) + new_bitmap = memoryview(bytearray(bitmap.nbytes * 4)).cast("B") + new_bitmap[::4] = flat_bitmap + new_bitmap[1::4] = flat_bitmap + new_bitmap[2::4] = flat_bitmap + new_bitmap[3::4] = b"\xff" * flat_bitmap.nbytes + bitmap = new_bitmap.cast("B", (*bitmap.shape, 4)) + format = "rgba-u8" + return { + "method": "bitmap", + "data": bitmap, + "format": format, + } + + +class BitmapToWgpuContext(BitmapContext): + """A BitmapContext that presents via a wgpu.GPUCanvasContext. + + This adapter can be used by context objects that want to present a bitmap, when the + canvas only supports presenting to screen. + """ + + def __init__(self, canvas, present_info): + super().__init__(canvas, present_info) + assert self._present_info["method"] == "wgpu" + + # Init wgpu + import wgpu + from ._fullscreen import FullscreenTexture + + adapter = wgpu.gpu.request_adapter_sync(power_preference="high-performance") + device = self._device = adapter.request_device_sync(required_limits={}) + + self._texture_helper = FullscreenTexture(device) + + # Create sub context, support both the old and new wgpu-py API + backend_module = wgpu.gpu.__module__ + CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 + + if hasattr(CanvasContext, "set_physical_size"): + self._sub_context_is_new_style = True + self._sub_context = CanvasContext(present_info) + else: + self._sub_context_is_new_style = False + self._sub_context = CanvasContext(canvas, {"screen": present_info}) + self._sub_context_is_configured = False + + def _rc_set_physical_size(self, width: int, height: int) -> None: + super()._rc_set_physical_size(width, height) + if self._sub_context_is_new_style: + self._sub_context.set_physical_size(width, height) + + def _rc_present(self): + if self._bitmap_and_format is None: + return {"method": "skip"} + + # Supported formats are "rgba-u8" and "i-u8" (grayscale). + # Returns the present-result dict produced by ``GPUCanvasContext.present()``. + + bitmap = self._bitmap_and_format[0] + self._texture_helper.set_texture_data(bitmap) + + if not self._sub_context_is_configured: + format = self._sub_context.get_preferred_format(self._device.adapter) + # We don't want an srgb texture, because we assume the input bitmap is already srgb. + # AFAIK contexts always support both the regular and the srgb texture format variants + if format.endswith("-srgb"): + format = format[:-5] + self._sub_context.configure(device=self._device, format=format) + + target = self._sub_context.get_current_texture().create_view() + command_encoder = self._device.create_command_encoder() + self._texture_helper.draw(command_encoder, target) + self._device.queue.submit([command_encoder.finish()]) + + self._sub_context.present() + return {"method": "delegated"} diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py new file mode 100644 index 0000000..017abd3 --- /dev/null +++ b/rendercanvas/contexts/wgpucontext.py @@ -0,0 +1,180 @@ +from .basecontext import BaseRenderCanvasContext + +import wgpu + +# TODO: A weird thing about this is that I am replicatinh wgpu._classes.GPUCanvasContext, which feels rather strange indeed. Also in terms of keeping the API up to date?? + + +class WgpuRenderCanvasContext(BaseRenderCanvasContext): + """A context that to render wgpu.""" + + # todo: use __new__ and produce different classes as to connect context to the present method? + def __init__(self, canvas, present_methods): + super().__init__(canvas, present_methods) + self._present_method = "screen" if "screen" in present_methods else "bitmap" + + if self._present_method == "screen": + # todo: pass all present methods? + self._real_canvas_context = wgpu.rendercanvas_context_hook( + canvas, present_methods + ) + else: + pass # we fake it. + + self._capabilities = None + self._config = None + + def _get_capabilities(self): + """Get dict of capabilities and cache the result.""" + if self._capabilities is None: + self._capabilities = {} + # Query format capabilities from the info provided by the canvas + formats = [] + for format in self._present_methods["bitmap"]["formats"]: + channels, _, fmt = format.partition("-") + channels = {"i": "r", "ia": "rg"}.get(channels, channels) + fmt = { + "u8": "8unorm", + "u16": "16uint", + "f16": "16float", + "f32": "32float", + }.get(fmt, fmt) + wgpu_format = channels + fmt + wgpu_format_srgb = wgpu_format + "-srgb" + if wgpu_format_srgb in enums.TextureFormat: + formats.append(wgpu_format_srgb) + formats.append(wgpu_format) + # Assume alpha modes for now + alpha_modes = [enums.CanvasAlphaMode.opaque] + # Build capabilitied dict + self._capabilities = { + "formats": formats, + "usages": 0xFF, + "alpha_modes": alpha_modes, + } + # Derived defaults + if "view_formats" not in self._capabilities: + self._capabilities["view_formats"] = self._capabilities["formats"] + + return self._capabilities + + def get_preferred_format(self, adapter: GPUAdapter) -> enums.TextureFormatEnum: + """Get the preferred surface texture format.""" + capabilities = self._get_capabilities() + formats = capabilities["formats"] + return formats[0] if formats else "bgra8-unorm" + + def get_configuration(self) -> dict: + """Get the current configuration (or None if the context is not yet configured).""" + return self._config + + def configure( + self, + *, + device: GPUDevice, + format: enums.TextureFormatEnum, + usage: flags.TextureUsageFlags = 0x10, + view_formats: Sequence[enums.TextureFormatEnum] = (), + color_space: str = "srgb", + tone_mapping: structs.CanvasToneMappingStruct | None = None, + alpha_mode: enums.CanvasAlphaModeEnum = "opaque", + ) -> None: + """Configures the presentation context for the associated canvas. + Destroys any textures produced with a previous configuration. + This clears the drawing buffer to transparent black. + + Arguments: + 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. 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". 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 + tone_mapping = {} if tone_mapping is None else tone_mapping + + 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 # noqa - not really supported, just assume srgb for now + tone_mapping # noqa - not supported yet + + # Allow more than the IDL modes, see https://github.com/pygfx/wgpu-py/pull/719 + extra_alpha_modes = ["auto", "unpremultiplied", "inherit"] # from webgpu.h + all_alpha_modes = [*enums.CanvasAlphaMode, *extra_alpha_modes] + if alpha_mode not in all_alpha_modes: + 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_method == "screen": + self._configure_screen(**self._config) + + def unconfigure(self) -> None: + """Removes the presentation context configuration. + Destroys any textures produced while configured. + """ + if self._present_method == "screen": + self._unconfigure_screen() + self._config = None + self._drop_texture() + + def get_current_texture(self) -> GPUTexture: + pass + + def _rc_present(self): + pass diff --git a/rendercanvas/utils/bitmaprenderingcontext.py b/rendercanvas/utils/bitmaprenderingcontext.py deleted file mode 100644 index 78bdc7b..0000000 --- a/rendercanvas/utils/bitmaprenderingcontext.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Provide a simple context class to support ``canvas.get_context('bitmap')``. -""" - -import weakref - - -def rendercanvas_context_hook(canvas, present_methods): - """Hook so this context can be picked up by ``canvas.get_context()``""" - return BitmapRenderingContext(canvas, present_methods) - - -class BitmapRenderingContext: - """A context that supports rendering by generating grayscale or rgba images. - - This is inspired by JS ``get_context('bitmaprenderer')`` which returns a ``ImageBitmapRenderingContext``. - It is a relatively simple context to implement, and provides a easy entry to using ``rendercanvas``. - """ - - def __init__(self, canvas, present_methods): - self._canvas_ref = weakref.ref(canvas) - self._present_methods = present_methods - assert "screen" in present_methods or "bitmap" in present_methods - self._present_method = "bitmap" if "bitmap" in present_methods else "screen" - if self._present_method == "screen": - from rendercanvas.utils.bitmappresentadapter import BitmapPresentAdapter - - self._screen_adapter = BitmapPresentAdapter(canvas, present_methods) - - self._bitmap_and_format = None - - @property - def canvas(self): - """The associated canvas object.""" - return self._canvas_ref() - - def set_bitmap(self, bitmap): - """Set the rendered bitmap image. - - Call this in the draw event. The bitmap must be an object that can be - conveted to a memoryview, like a numpy array. It must represent a 2D - image in either grayscale or rgba format, with uint8 values - """ - - m = memoryview(bitmap) - - # Check dtype - if m.format == "B": - dtype = "u8" - else: - raise ValueError( - "Unsupported bitmap dtype/format '{m.format}', expecting unsigned bytes ('B')." - ) - - # Get color format - color_format = None - if len(m.shape) == 2: - color_format = "i" - elif len(m.shape) == 3: - if m.shape[2] == 1: - color_format = "i" - elif m.shape[2] == 4: - color_format = "rgba" - if not color_format: - raise ValueError( - f"Unsupported bitmap shape {m.shape}, expecting a 2D grayscale or rgba image." - ) - - # We should now have one of two formats - format = f"{color_format}-{dtype}" - assert format in ("rgba-u8", "i-u8") - - self._bitmap_and_format = m, format - - def present(self): - """Allow RenderCanvas to present the bitmap. Don't call this yourself.""" - if self._bitmap_and_format is None: - return {"method": "skip"} - elif self._present_method == "bitmap": - bitmap, format = self._bitmap_and_format - if format not in self._present_methods["bitmap"]["formats"]: - # Convert from i-u8 -> rgba-u8. This surely hurts performance. - assert format == "i-u8" - flat_bitmap = bitmap.cast("B", (bitmap.nbytes,)) - new_bitmap = memoryview(bytearray(bitmap.nbytes * 4)).cast("B") - new_bitmap[::4] = flat_bitmap - new_bitmap[1::4] = flat_bitmap - new_bitmap[2::4] = flat_bitmap - new_bitmap[3::4] = b"\xff" * flat_bitmap.nbytes - bitmap = new_bitmap.cast("B", (*bitmap.shape, 4)) - format = "rgba-u8" - return { - "method": "bitmap", - "data": bitmap, - "format": format, - } - elif self._present_method == "screen": - self._screen_adapter.present_bitmap(self._bitmap_and_format[0]) - return {"method": "screen"} - else: - return {"method": "fail", "message": "wut?"} From 224132f95c8a3ffcf690f46b09f7dff0077be60d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 4 Nov 2025 09:15:40 +0100 Subject: [PATCH 02/23] clean --- rendercanvas/contexts/__init__.py | 4 ++-- rendercanvas/contexts/basecontext.py | 16 ++++++++++--- rendercanvas/contexts/bitmapcontext.py | 32 ++++++++++++++------------ 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/rendercanvas/contexts/__init__.py b/rendercanvas/contexts/__init__.py index d243fb2..85fa151 100644 --- a/rendercanvas/contexts/__init__.py +++ b/rendercanvas/contexts/__init__.py @@ -1,2 +1,2 @@ -from .basecontext import BaseContext -from .bitmapcontext import BitmapContext +from .basecontext import * +from .bitmapcontext import * diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index f325f79..a4a44f9 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -1,11 +1,21 @@ import weakref +__all__ = ["BaseContext"] + class BaseContext: - """A context that supports rendering by generating grayscale or rgba images. + """The base class for context objects in ``rendercanvas``. + + A context provides an API to provide a rendered image, and implements a + mechanism to present that image to the another system for display. The + concept of a context is heavily inspired by the canvas and its contexts in + the browser. - This is inspired by JS ``get_context('bitmaprenderer')`` which returns a ``ImageBitmapRenderingContext``. - It is a relatively simple context to implement, and provides a easy entry to using ``rendercanvas``. + In ``rendercanvas``, there are two types of contexts: the *bitmap* context + provides an API that takes image bitmaps in RAM, and the *wgpu* context + provides an API that takes provides image textures on the GPU to render to. + Each type of context has multiple subclasses to connect it to various + subsystems. """ def __init__(self, canvas: object, present_info: dict): diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index bc2f6b7..49a6643 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -1,25 +1,28 @@ -""" -Provide a simple context class to support ``canvas.get_context('bitmap')``. -""" - import sys from .basecontext import BaseContext +__all__ = ["BitmapContext", "BitmapContextPlain", "BitmapContextToWgpu"] + class BitmapContext(BaseContext): - """A context that supports rendering by generating grayscale or rgba images. + """A context that provides an API that takes a (grayscale or rgba) images bitmap. + + This is loosely inspired by JS' ``ImageBitmapRenderingContext``. Rendering + bitmaps is a simple way to use ``rendercanvas``, but usually not as + performant as a wgpu context. - This is inspired by JS ``get_context('bitmaprenderer')`` which returns a ``ImageBitmapRenderingContext``. - It is a relatively simple context to implement, and provides a easy entry to using ``rendercanvas``. + Users typically don't instantiate contexts directly, but use ``canvas.get_context("bitmap")``, + which returns a subclass of this class, depending on the needs of the canvas. """ def __new__(cls, canvas: object, present_info: dict): + # Instantiating this class actually produces a subclass present_method = present_info["method"] if present_method == "bitmap": - return super().__new__(BitmapToBitmapContext) + return super().__new__(BitmapContextPlain) elif present_method == "wgpu": - return super().__new__(BitmapToWgpuContext) + return super().__new__(BitmapContextToWgpu) else: raise TypeError("Unexpected present_method {present_method!r}") @@ -66,8 +69,8 @@ def set_bitmap(self, bitmap): self._bitmap_and_format = m, format -class BitmapToBitmapContext(BitmapContext): - """A BitmapContext that presents a bitmap to the canvas.""" +class BitmapContextPlain(BitmapContext): + """A BitmapContext that just presents the bitmap to the canvas.""" def __init__(self, canvas, present_info): super().__init__(canvas, present_info) @@ -97,11 +100,10 @@ def _rc_present(self): } -class BitmapToWgpuContext(BitmapContext): - """A BitmapContext that presents via a wgpu.GPUCanvasContext. +class BitmapContextToWgpu(BitmapContext): + """A BitmapContext that uploads to a texture and present that to a ``wgpu.GPUCanvasContext``. - This adapter can be used by context objects that want to present a bitmap, when the - canvas only supports presenting to screen. + This is uses for canvases that do not support presenting a bitmap. """ def __init__(self, canvas, present_info): From 919fcaab9bc76b310e0aad7bdfd4322faec3b07a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 4 Nov 2025 11:22:19 +0100 Subject: [PATCH 03/23] Implement WgpuContext --- rendercanvas/base.py | 2 +- rendercanvas/contexts/__init__.py | 5 +- rendercanvas/contexts/bitmapcontext.py | 24 +- rendercanvas/contexts/wgpucontext.py | 422 +++++++++++++++++-------- 4 files changed, 313 insertions(+), 140 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index a242394..30511dc 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -320,7 +320,7 @@ def get_context(self, context_type: str) -> object: if resolved_context_type == "bitmap": context = contexts.BitmapContext(self, present_info) else: - context = contexts.WGpuContext(self, present_info) + context = contexts.WgpuContext(self, present_info) # Done self._canvas_context = context diff --git a/rendercanvas/contexts/__init__.py b/rendercanvas/contexts/__init__.py index 85fa151..4e18492 100644 --- a/rendercanvas/contexts/__init__.py +++ b/rendercanvas/contexts/__init__.py @@ -1,2 +1,3 @@ -from .basecontext import * -from .bitmapcontext import * +from .basecontext import * # noqa: F403 +from .bitmapcontext import * # noqa: F403 +from .wgpucontext import * # noqa: F403 diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index 49a6643..b723a44 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -124,17 +124,17 @@ def __init__(self, canvas, present_info): CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 if hasattr(CanvasContext, "set_physical_size"): - self._sub_context_is_new_style = True - self._sub_context = CanvasContext(present_info) + self._wgpu_context_is_new_style = True + self._wgpu_context = CanvasContext(present_info) else: - self._sub_context_is_new_style = False - self._sub_context = CanvasContext(canvas, {"screen": present_info}) - self._sub_context_is_configured = False + self._wgpu_context_is_new_style = False + self._wgpu_context = CanvasContext(canvas, {"screen": present_info}) + self._wgpu_context_is_configured = False def _rc_set_physical_size(self, width: int, height: int) -> None: super()._rc_set_physical_size(width, height) - if self._sub_context_is_new_style: - self._sub_context.set_physical_size(width, height) + if self._wgpu_context_is_new_style: + self._wgpu_context.set_physical_size(width, height) def _rc_present(self): if self._bitmap_and_format is None: @@ -146,18 +146,18 @@ def _rc_present(self): bitmap = self._bitmap_and_format[0] self._texture_helper.set_texture_data(bitmap) - if not self._sub_context_is_configured: - format = self._sub_context.get_preferred_format(self._device.adapter) + if not self._wgpu_context_is_configured: + format = self._wgpu_context.get_preferred_format(self._device.adapter) # We don't want an srgb texture, because we assume the input bitmap is already srgb. # AFAIK contexts always support both the regular and the srgb texture format variants if format.endswith("-srgb"): format = format[:-5] - self._sub_context.configure(device=self._device, format=format) + self._wgpu_context.configure(device=self._device, format=format) - target = self._sub_context.get_current_texture().create_view() + target = self._wgpu_context.get_current_texture().create_view() command_encoder = self._device.create_command_encoder() self._texture_helper.draw(command_encoder, target) self._device.queue.submit([command_encoder.finish()]) - self._sub_context.present() + self._wgpu_context.present() return {"method": "delegated"} diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 017abd3..8d1bc7f 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -1,180 +1,352 @@ -from .basecontext import BaseRenderCanvasContext +import sys +from typing import Sequence -import wgpu +from .basecontext import BaseContext -# TODO: A weird thing about this is that I am replicatinh wgpu._classes.GPUCanvasContext, which feels rather strange indeed. Also in terms of keeping the API up to date?? +# todo: wgpu should not be imported by default. Add a test for this! -class WgpuRenderCanvasContext(BaseRenderCanvasContext): - """A context that to render wgpu.""" +__all__ = ["WgpuContext", "WgpuContextPlain", "WgpuContextToBitmap"] - # todo: use __new__ and produce different classes as to connect context to the present method? - def __init__(self, canvas, present_methods): - super().__init__(canvas, present_methods) - self._present_method = "screen" if "screen" in present_methods else "bitmap" - if self._present_method == "screen": - # todo: pass all present methods? - self._real_canvas_context = wgpu.rendercanvas_context_hook( - canvas, present_methods - ) +class WgpuContext(BaseContext): + """A context that provides an API that provides a GPU texture to render to. + + This is inspired by JS' ``GPUCanvasContext``, and the more performant approach for rendering to a ``rendercanvas``. + + Users typically don't instantiate contexts directly, but use ``canvas.get_context("wgpu")``, + which returns a subclass of this class, depending on the needs of the canvas. + """ + + def __new__(cls, canvas: object, present_info: dict): + # Instantiating this class actually produces a subclass + present_method = present_info["method"] + if present_method == "wgpu": + return super().__new__(WgpuContextPlain) + elif present_method == "bitmap": + return super().__new__(WgpuContextToBitmap) else: - pass # we fake it. + raise TypeError("Unexpected present_method {present_method!r}") - self._capabilities = None + def __init__(self, canvas: object, present_info: dict): + super().__init__(canvas, present_info) + # Configuration dict from the user, set via self.configure() self._config = None - def _get_capabilities(self): - """Get dict of capabilities and cache the result.""" - if self._capabilities is None: - self._capabilities = {} - # Query format capabilities from the info provided by the canvas - formats = [] - for format in self._present_methods["bitmap"]["formats"]: - channels, _, fmt = format.partition("-") - channels = {"i": "r", "ia": "rg"}.get(channels, channels) - fmt = { - "u8": "8unorm", - "u16": "16uint", - "f16": "16float", - "f32": "32float", - }.get(fmt, fmt) - wgpu_format = channels + fmt - wgpu_format_srgb = wgpu_format + "-srgb" - if wgpu_format_srgb in enums.TextureFormat: - formats.append(wgpu_format_srgb) - formats.append(wgpu_format) - # Assume alpha modes for now - alpha_modes = [enums.CanvasAlphaMode.opaque] - # Build capabilitied dict - self._capabilities = { - "formats": formats, - "usages": 0xFF, - "alpha_modes": alpha_modes, - } - # Derived defaults - if "view_formats" not in self._capabilities: - self._capabilities["view_formats"] = self._capabilities["formats"] - - return self._capabilities - - def get_preferred_format(self, adapter: GPUAdapter) -> enums.TextureFormatEnum: + def get_preferred_format(self, adapter: object) -> str: """Get the preferred surface texture format.""" - capabilities = self._get_capabilities() - formats = capabilities["formats"] - return formats[0] if formats else "bgra8-unorm" + return self._get_preferred_format(adapter) + + def _get_preferred_format(self, adapter: object) -> str: + raise NotImplementedError() - def get_configuration(self) -> dict: + def get_configuration(self) -> dict | None: """Get the current configuration (or None if the context is not yet configured).""" return self._config def configure( self, *, - device: GPUDevice, - format: enums.TextureFormatEnum, - usage: flags.TextureUsageFlags = 0x10, - view_formats: Sequence[enums.TextureFormatEnum] = (), - color_space: str = "srgb", - tone_mapping: structs.CanvasToneMappingStruct | None = None, - alpha_mode: enums.CanvasAlphaModeEnum = "opaque", + device: object, + format: str, + usage: str | int = "RENDER_ATTACHMENT", + view_formats: Sequence[str] = (), + # color_space: str = "srgb", - not yet implemented + # tone_mapping: str | None = None, - not yet implemented + alpha_mode: str = "opaque", ) -> None: """Configures the presentation context for the associated canvas. Destroys any textures produced with a previous configuration. - This clears the drawing buffer to transparent black. Arguments: device (WgpuDevice): The GPU device object to create compatible textures for. - format (enums.TextureFormat): The format that textures returned by + format (wgpu.TextureFormat): The format that textures returned by ``get_current_texture()`` will have. Must be one of the supported context 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 + usage (wgpu.TextureUsage): Default "RENDER_ATTACHMENT". + view_formats (list[wgpu.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". Not yet supported. - tone_mapping (enums.CanvasToneMappingMode): Not yet supported. - alpha_mode (structs.CanvasAlphaMode): Determines the effect that alpha values + alpha_mode (wgpu.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 - tone_mapping = {} if tone_mapping is None else tone_mapping + import wgpu - if not isinstance(device, GPUDevice): + # Basic checks + if not isinstance(device, wgpu.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 format not in wgpu.TextureFormat: + raise ValueError(f"Configure: format {format} not in {wgpu.TextureFormat}") + if isinstance(usage, str): + usage_bits = usage.replace("|", " ").split() + usage = 0 + for usage_bit in usage_bits: + usage |= wgpu.TextureUsage[usage_bit] + elif not isinstance(usage, int): + raise TypeError("Texture usage must be str or int") - if not isinstance(usage, int): - usage = str_flag_to_int(flags.TextureUsage, usage) + # Build config dict + config = { + "device": device, + "format": format, + "usage": usage, + "view_formats": view_formats, + # "color_space": color_space, + # "tone_mapping": tone_mapping, + "alpha_mode": alpha_mode, + } - color_space # noqa - not really supported, just assume srgb for now - tone_mapping # noqa - not supported yet + # Let subclass finnish the configuration, then store the config + self._configure(config) + self._config = config - # Allow more than the IDL modes, see https://github.com/pygfx/wgpu-py/pull/719 - extra_alpha_modes = ["auto", "unpremultiplied", "inherit"] # from webgpu.h - all_alpha_modes = [*enums.CanvasAlphaMode, *extra_alpha_modes] - if alpha_mode not in all_alpha_modes: - raise ValueError( - f"Configure: alpha_mode {alpha_mode} not in {enums.CanvasAlphaMode}" - ) + def _configure(self, config: dict): + raise NotImplementedError() - # Check against capabilities + def unconfigure(self) -> None: + """Removes the presentation context configuration.""" + self._config = None + self._unconfigure() - capabilities = self._get_capabilities(device.adapter) + def _unconfigure(self) -> None: + raise NotImplementedError() - if format not in capabilities["formats"]: - raise ValueError( - f"Configure: unsupported texture format: {format} not in {capabilities['formats']}" + def get_current_texture(self) -> object: + """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()." ) + return self._get_current_texture() + + def _get_current_texture(self): + raise NotImplementedError() + + def _rc_present(self) -> None: + """Hook for the canvas to present the rendered result. + + Present what has been drawn to the current texture, by compositing it to the + canvas.This is called automatically by the canvas. + """ + raise NotImplementedError() + + +class WgpuContextPlain(WgpuContext): + """A wgpu context that present directly to a ``wgpu.GPUCanvasContext``. + + In most cases this means the image is rendered to a native OS surface, i.e. rendered to screen. + When running in Pyodide, it means it renders directly to a ````. + """ + + def __init__(self, canvas: object, present_info: dict): + super().__init__(canvas, present_info) + + import wgpu + + # Create sub context, support both the old and new wgpu-py API + # TODO: let's add/use hook in wgpu to get the context in a less hacky way + backend_module = wgpu.gpu.__module__ + CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 + + if hasattr(CanvasContext, "set_physical_size"): + self._wgpu_context_is_new_style = True + self._wgpu_context = CanvasContext(present_info) + else: + self._wgpu_context_is_new_style = False + self._wgpu_context = CanvasContext(canvas, {"screen": present_info}) + + def _get_preferred_format(self, adapter: object) -> str: + return self._wgpu_context.get_preferred_format(adapter) + + def _configure(self, config): + self._wgpu_context.configure(**config) + + def _unconfigure(self) -> None: + self._wgpu_context.unconfigure() + + def _get_current_texture(self) -> object: + return self._wgpu_context.get_current_texture() + + def _rc_present(self) -> None: + self._wgpu_context.present() + return {"method": "screen"} + + +class WgpuContextToBitmap(WgpuContext): + """A wgpu context that downloads the image from the texture, and presents that bitmap to the canvas. + + This is less performant than rendering directly to screen, but once we make the changes such that the + downloading is be done asynchronously, the difference in performance is not + actually that big. + """ + + def __init__(self, canvas: object, present_info: dict): + super().__init__(canvas, present_info) - if not usage & capabilities["usages"]: + # Canvas capabilities. Stored the first time it is obtained + self._capabilities = self._get_capabilities() + + # The last used texture + self._texture = None + + def _get_capabilities(self): + """Get dict of capabilities and cache the result.""" + + import wgpu + + capabilities = {} + + # Query format capabilities from the info provided by the canvas + formats = [] + for format in self._present_info["formats"]: + channels, _, fmt = format.partition("-") + channels = {"i": "r", "ia": "rg"}.get(channels, channels) + fmt = { + "u8": "8unorm", + "u16": "16uint", + "f16": "16float", + "f32": "32float", + }.get(fmt, fmt) + wgpu_format = channels + fmt + wgpu_format_srgb = wgpu_format + "-srgb" + if wgpu_format_srgb in wgpu.TextureFormat: + formats.append(wgpu_format_srgb) + formats.append(wgpu_format) + + # Assume alpha modes for now + alpha_modes = ["opaque"] + + # Build capabilitied dict + capabilities = { + "formats": formats, + "view_formats": formats, + "usages": 0xFF, + "alpha_modes": alpha_modes, + } + return capabilities + + def _drop_texture(self): + if self._texture is not None: + self._texture._release() # not destroy, because it may be in use. + self._texture = None + + def _get_preferred_format(self, adapter: object) -> str: + formats = self._capabilities["formats"] + return formats[0] if formats else "bgra8-unorm" + + def _configure(self, config: dict): + # Get cababilities + cap_formats = self._capabilities["formats"] + cap_view_formats = self._capabilities["view_formats"] + cap_alpha_modes = self._capabilities["alpha_modes"] + + # Check against capabilities + format = config["format"] + if format not in cap_formats: raise ValueError( - f"Configure: unsupported texture usage: {usage} not in {capabilities['usages']}" + f"Configure: unsupported texture format: {format} not in {cap_formats}" ) - - for view_format in view_formats: - if view_format not in capabilities["view_formats"]: + for view_format in config["view_formats"]: + if view_format not in cap_view_formats: raise ValueError( - f"Configure: unsupported view format: {view_format} not in {capabilities['view_formats']}" + f"Configure: unsupported view format: {view_format} not in {cap_view_formats}" ) - - if alpha_mode not in capabilities["alpha_modes"]: + alpha_mode = config["alpha_mode"] + if alpha_mode not in cap_alpha_modes: raise ValueError( - f"Configure: unsupported alpha-mode: {alpha_mode} not in {capabilities['alpha_modes']}" + f"Configure: unsupported alpha-mode: {alpha_mode} not in {cap_alpha_modes}" ) - # Store + def _unconfigure(self) -> None: + self._drop_texture() - self._config = { - "device": device, - "format": format, - "usage": usage, - "view_formats": view_formats, - "color_space": color_space, - "tone_mapping": tone_mapping, - "alpha_mode": alpha_mode, - } + def _get_current_texture(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._present_method == "screen": - self._configure_screen(**self._config) + if self._texture is None: + import wgpu + + canvas = self.canvas # TODO: physical size must be set by canvas! + width, height = canvas.get_physical_size() + width, height = max(width, 1), max(height, 1) + + # Note that the label 'present' is used by read_texture() to determine + # that it can use a shared copy buffer. + device = self._config["device"] + self._texture = device.create_texture( + label="present", + size=(width, height, 1), + format=self._config["format"], + usage=self._config["usage"] | wgpu.TextureUsage.COPY_SRC, + ) + + return self._texture + + def _rc_present(self) -> None: + if not self._texture: + return {"method": "skip"} + + bitmap = self._get_bitmap() + result = {"method": "bitmap", "format": "rgba-u8", "data": bitmap} - def unconfigure(self) -> None: - """Removes the presentation context configuration. - Destroys any textures produced while configured. - """ - if self._present_method == "screen": - self._unconfigure_screen() - self._config = None self._drop_texture() + return result + + def _get_bitmap(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, + ) + + # Derive struct dtype from wgpu texture format + memoryview_type = "B" + if "float" in format: + memoryview_type = "e" if "16" in format else "f" + else: + if "32" in format: + memoryview_type = "I" + elif "16" in format: + memoryview_type = "H" + else: + memoryview_type = "B" + if "sint" in format: + memoryview_type = memoryview_type.lower() - def get_current_texture(self) -> GPUTexture: - pass + # Represent as memory object to avoid numpy dependency + # Equivalent: np.frombuffer(data, np.uint8).reshape(size[1], size[0], nchannels) - def _rc_present(self): - pass + return data.cast(memoryview_type, (size[1], size[0], nchannels)) From 8ebc6c25c39294e6613e5536c092fcc2879107d0 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 4 Nov 2025 12:21:06 +0100 Subject: [PATCH 04/23] canvas keeps context size up-to-date --- rendercanvas/base.py | 9 +++++++++ rendercanvas/contexts/bitmapcontext.py | 3 ++- rendercanvas/contexts/wgpucontext.py | 9 +++++++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 6372202..cb4688d 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -160,6 +160,7 @@ def __init__( "logical_size": (0.0, 0.0), } self.__need_size_event = False + self.__need_context_resize = False # Events and scheduler self._events = EventEmitter() @@ -366,6 +367,7 @@ def __resolve_total_pixel_ratio_and_logical_size(self): self.__size_info["total_pixel_ratio"] = total_pixel_ratio self.__size_info["logical_size"] = logical_size self.__need_size_event = True + self.__need_context_resize = True def add_event_handler( self, *args: EventTypeEnum | EventHandlerFunction, order: float = 0 @@ -532,6 +534,13 @@ def _draw_frame_and_present(self): # Make sure that the user-code is up-to-date with the current size before it draws. self.__maybe_emit_resize_event() + # Also update the context's size + if self.__need_context_resize: + self.__need_context_resize = False + self._canvas_context._rc_set_physical_size( + *self.__size_info["physical_size"] + ) + # Emit before-draw self._events.emit({"event_type": "before_draw"}) diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index b723a44..0c6d41c 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -132,7 +132,8 @@ def __init__(self, canvas, present_info): self._wgpu_context_is_configured = False def _rc_set_physical_size(self, width: int, height: int) -> None: - super()._rc_set_physical_size(width, height) + width, height = int(width), int(height) + self._physical_size = width, height if self._wgpu_context_is_new_style: self._wgpu_context.set_physical_size(width, height) diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 8d1bc7f..e2c20b4 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -169,6 +169,12 @@ def _unconfigure(self) -> None: def _get_current_texture(self) -> object: return self._wgpu_context.get_current_texture() + def _rc_set_physical_size(self, width: int, height: int) -> None: + width, height = int(width), int(height) + self._physical_size = width, height + if self._wgpu_context_is_new_style: + self._wgpu_context.set_physical_size(width, height) + def _rc_present(self) -> None: self._wgpu_context.present() return {"method": "screen"} @@ -272,8 +278,7 @@ def _get_current_texture(self): if self._texture is None: import wgpu - canvas = self.canvas # TODO: physical size must be set by canvas! - width, height = canvas.get_physical_size() + width, height = self._physical_size width, height = max(width, 1), max(height, 1) # Note that the label 'present' is used by read_texture() to determine From 3b14ce6b7f62cbe29239b5f9a60e2a13e20e1366 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 4 Nov 2025 12:33:39 +0100 Subject: [PATCH 05/23] Tweaks to release --- rendercanvas/base.py | 10 ++++------ rendercanvas/contexts/basecontext.py | 4 ++-- rendercanvas/contexts/bitmapcontext.py | 15 +++++++++++++++ rendercanvas/contexts/wgpucontext.py | 23 +++++++++++++++++++---- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index cb4688d..02f0185 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -603,12 +603,10 @@ def close(self) -> None: # Clear the draw-function, to avoid it holding onto e.g. wgpu objects. self._draw_frame = None # type: ignore # Clear the canvas context too. - if hasattr(self._canvas_context, "_release"): - # ContextInterface (and GPUCanvasContext) has _release() - try: - self._canvas_context._release() # type: ignore - except Exception: - pass + try: + self._canvas_context._rc_release() # type: ignore + except Exception: + pass self._canvas_context = None # Clean events. Should already have happened in loop, but the loop may not be running. self._events._release() diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index a4a44f9..0d3f09b 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -63,6 +63,6 @@ def _rc_present(self): # This is a stub return {"method": "skip"} - def _release(self): + def _rc_release(self): """Release resources. Called by the canvas when it's closed.""" - pass # TODO: need this? prefix _rc_? + pass diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index 0c6d41c..4740f94 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -99,6 +99,9 @@ def _rc_present(self): "format": format, } + def _rc_release(self): + self._bitmap_and_format = None + class BitmapContextToWgpu(BitmapContext): """A BitmapContext that uploads to a texture and present that to a ``wgpu.GPUCanvasContext``. @@ -162,3 +165,15 @@ def _rc_present(self): self._wgpu_context.present() return {"method": "delegated"} + + def _rc_release(self): + self._bitmap_and_format = None + if self._wgpu_context is not None: + if self._wgpu_context_is_new_style: + self._wgpu_context.close() # TODO: make sure this is compatible + else: + try: + self._wgpu_context._release() # private method + except Exception: + pass + self._wgpu_context = None diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index e2c20b4..42a4b2c 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -179,6 +179,17 @@ def _rc_present(self) -> None: self._wgpu_context.present() return {"method": "screen"} + def _rc_release(self): + if self._wgpu_context is not None: + if self._wgpu_context_is_new_style: + self._wgpu_context.close() # TODO: make sure this is compatible + else: + try: + self._wgpu_context._release() # private method + except Exception: + pass + self._wgpu_context = None + class WgpuContextToBitmap(WgpuContext): """A wgpu context that downloads the image from the texture, and presents that bitmap to the canvas. @@ -235,7 +246,10 @@ def _get_capabilities(self): def _drop_texture(self): if self._texture is not None: - self._texture._release() # not destroy, because it may be in use. + try: + self._texture._release() # private method. Not destroy, because it may be in use. + except Exception: + pass self._texture = None def _get_preferred_format(self, adapter: object) -> str: @@ -298,10 +312,8 @@ def _rc_present(self) -> None: return {"method": "skip"} bitmap = self._get_bitmap() - result = {"method": "bitmap", "format": "rgba-u8", "data": bitmap} - self._drop_texture() - return result + return {"method": "bitmap", "format": "rgba-u8", "data": bitmap} def _get_bitmap(self): texture = self._texture @@ -355,3 +367,6 @@ def _get_bitmap(self): # Equivalent: np.frombuffer(data, np.uint8).reshape(size[1], size[0], nchannels) return data.cast(memoryview_type, (size[1], size[0], nchannels)) + + def _rc_release(self): + self._drop_texture() From 05787f0cd7828c742187dee472363bd2a2573aa1 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 4 Nov 2025 13:18:10 +0100 Subject: [PATCH 06/23] Clean up --- docs/contextapi.rst | 66 +--- docs/utils.rst | 2 - docs/utils_bitmappresentadapter.rst | 5 - docs/utils_bitmaprenderingcontext.rst | 5 - rendercanvas/_context.py | 82 ----- rendercanvas/base.py | 53 +--- rendercanvas/utils/bitmappresentadapter.py | 349 --------------------- tests/test_context.py | 2 +- 8 files changed, 25 insertions(+), 539 deletions(-) delete mode 100644 docs/utils_bitmappresentadapter.rst delete mode 100644 docs/utils_bitmaprenderingcontext.rst delete mode 100644 rendercanvas/_context.py delete mode 100644 rendercanvas/utils/bitmappresentadapter.py diff --git a/docs/contextapi.rst b/docs/contextapi.rst index 252ea08..25be5ab 100644 --- a/docs/contextapi.rst +++ b/docs/contextapi.rst @@ -1,7 +1,7 @@ How context objects work ======================== -This page documents the working bentween the ``RenderCanvas`` and the context object. +This page documents the inner working between the ``RenderCanvas`` and the context object. Introduction @@ -23,16 +23,13 @@ then present the result to the screen. For this, the canvas provides one or more │ │ ──bitmap──► │ │ └─────────┘ └────────┘ -This means that for the context to be able to present to any canvas, it must -support *both* the 'image' and 'screen' present-methods. If the context prefers -presenting to the screen, and the canvas supports that, all is well. Similarly, -if the context has a bitmap to present, and the canvas supports the -bitmap-method, there's no problem. +If the context is a ``BitmapContext``, and the canvas supports the bitmap present-method, +things are easy. Similarly, if the context is a ``WgpuContext``, and the canvas +supports the screen present-method, the presenting is simply delegated to wgpu. -It get's a little trickier when there's a mismatch, but we can deal with these -cases too. When the context prefers presenting to screen, the rendered result is -probably a texture on the GPU. This texture must then be downloaded to a bitmap -on the CPU. All GPU API's have ways to do this. +When there's a mismatch, we use different context sub-classes that handle the conversion. +With the ``WgpuContextToBitmap`` context, the rendered result is inside a texture on the GPU. +This texture is then downloaded to a bitmap on the CPU that can be passed to the canvas. .. code-block:: @@ -41,11 +38,10 @@ on the CPU. All GPU API's have ways to do this. ──render──► | Context │ | │ Canvas │ │ │ └─bitmap──► │ | └─────────┘ └────────┘ - download from gpu to cpu + download to CPU -If the context has a bitmap to present, and the canvas only supports presenting -to screen, you can usse a small utility: the ``BitmapPresentAdapter`` takes a -bitmap and presents it to the screen. +With the ``BitmapContextToWgpu`` context, the bitmap is uploaded to a GPU texture, +which is then rendered to screen using the lower-level canvas-context from ``wgpu``. .. code-block:: @@ -54,46 +50,6 @@ bitmap and presents it to the screen. ──render──► | Context │ │ │ Canvas │ │ │ ──bitmap─┘ │ | └─────────┘ └────────┘ - use BitmapPresentAdapter + upload to GPU This way, contexts can be made to work with all canvas backens. - -Canvases may also provide additionaly present-methods. If a context knows how to -use that present-method, it can make use of it. Examples could be presenting -diff images or video streams. - -.. code-block:: - - ┌─────────┐ ┌────────┐ - │ │ │ │ - ──render──► | Context │ ──special-present-method──► │ Canvas │ - │ │ │ | - └─────────┘ └────────┘ - - -Context detection ------------------ - -Anyone can make a context that works with ``rendercanvas``. In order for ``rendercanvas`` to find, it needs a little hook. - -.. autofunction:: rendercanvas._context.rendercanvas_context_hook - :no-index: - - -Context API ------------ - -The class below describes the API and behavior that is expected of a context object. -Also see https://github.com/pygfx/rendercanvas/blob/main/rendercanvas/_context.py. - -.. autoclass:: rendercanvas._context.ContextInterface - :members: - :no-index: - - -Adapter -------- - -.. autoclass:: rendercanvas.utils.bitmappresentadapter.BitmapPresentAdapter - :members: - :no-index: diff --git a/docs/utils.rst b/docs/utils.rst index efaeafe..a0e953d 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -7,5 +7,3 @@ Utils utils_asyncs utils_cube - utils_bitmappresentadapter.rst - utils_bitmaprenderingcontext.rst diff --git a/docs/utils_bitmappresentadapter.rst b/docs/utils_bitmappresentadapter.rst deleted file mode 100644 index 2889384..0000000 --- a/docs/utils_bitmappresentadapter.rst +++ /dev/null @@ -1,5 +0,0 @@ -``utils.bitmappresentadapter`` -============================== - -.. automodule:: rendercanvas.utils.bitmappresentadapter - :members: diff --git a/docs/utils_bitmaprenderingcontext.rst b/docs/utils_bitmaprenderingcontext.rst deleted file mode 100644 index 8bad64c..0000000 --- a/docs/utils_bitmaprenderingcontext.rst +++ /dev/null @@ -1,5 +0,0 @@ -``utils.bitmaprenderingcontext`` -================================ - -.. automodule:: rendercanvas.utils.bitmaprenderingcontext - :members: diff --git a/rendercanvas/_context.py b/rendercanvas/_context.py deleted file mode 100644 index 89e0669..0000000 --- a/rendercanvas/_context.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -A stub context implementation for documentation purposes. -It does actually work, but presents nothing. -""" - -import weakref - - -def rendercanvas_context_hook(canvas, present_methods): - """Hook function to allow ``rendercanvas`` to detect your context implementation. - - If you make a function with this name available in the module ``your.module``, - ``rendercanvas`` will detect and call this function in order to obtain the canvas object. - That way, anyone can use ``canvas.get_context("your.module")`` to use your context. - The arguments are the same as for ``ContextInterface``. - """ - return ContextInterface(canvas, present_methods) - - -class ContextInterface: - """The interface that a context must implement, to be usable with a ``RenderCanvas``. - - Arguments: - canvas (BaseRenderCanvas): the canvas to render to. - present_methods (dict): The supported present methods of the canvas. - - The ``present_methods`` dict has a field for each supported present-method. A - canvas must support either "screen" or "bitmap". It may support both, as well as - additional (specialized) present methods. Below we list the common methods and - what fields the subdicts have. - - * Render method "screen": - * "window": the native window id. - * "display": the native display id (Linux only). - * "platform": to determine between "x11" and "wayland" (Linux only). - * Render method "bitmap": - * "formats": a list of supported formats. It should always include "rgba-u8". - Other options can be be "i-u8" (intensity/grayscale), "i-f32", "bgra-u8", "rgba-u16", etc. - - """ - - def __init__(self, canvas, present_methods): - self._canvas_ref = weakref.ref(canvas) - self._present_methods = present_methods - - @property - def canvas(self): - """The associated canvas object. Internally, this should preferably be stored using a weakref.""" - return self._canvas_ref() - - def present(self): - """Present the result to the canvas. - - This is called by the canvas, and should not be called by user-code. - - The implementation should always return a present-result dict, which - should have at least a field 'method'. The value of 'method' must be - one of the methods that the canvas supports, i.e. it must be in ``present_methods``. - - * If there is nothing to present, e.g. because nothing was rendered yet: - * return ``{"method": "skip"}`` (special case). - * If presentation could not be done for some reason: - * return ``{"method": "fail", "message": "xx"}`` (special case). - * If ``present_method`` is "screen": - * Render to screen using the info in ``present_methods['screen']``). - * Return ``{"method", "screen"}`` as confirmation. - * If ``present_method`` is "bitmap": - * Return ``{"method": "bitmap", "data": data, "format": format}``. - * 'data' is a memoryview, or something that can be converted to a memoryview, like a numpy array. - * 'format' is the format of the bitmap, must be in ``present_methods['bitmap']['formats']`` ("rgba-u8" is always supported). - * If ``present_method`` is something else: - * Return ``{"method": "xx", ...}``. - * It's the responsibility of the context to use a render method that is supported by the canvas, - and that the appropriate arguments are supplied. - """ - - # This is a stub - return {"method": "skip"} - - def _release(self): - """Release resources. Called by the canvas when it's closed.""" - pass diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 02f0185..a657d84 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -6,7 +6,6 @@ import sys import weakref -import importlib from typing import TYPE_CHECKING from ._enums import ( @@ -215,24 +214,28 @@ def __del__(self): except Exception: pass - # %% Implement WgpuCanvasInterface - _canvas_context = None # set in get_context() def get_physical_size(self) -> Tuple[int, int]: """Get the physical size of the canvas in integer pixels.""" return self.__size_info["physical_size"] - def get_context(self, context_type: str) -> object: + def get_bitmap_context(self) -> contexts.BitmapContext: + """Get the ``BitmapContext`` to render to this canvas.""" + return self.get_context("bitmap") + + def get_wgpu_context(self) -> contexts.WgpuContext: + """Get the ``WgpuContext`` to render to this canvas.""" + return self.get_context("wgpu") + + def get_context(self, context_type: str) -> contexts.BaseContext: """Get a context object that can be used to render to this canvas. The context takes care of presenting the rendered result to the canvas. Different types of contexts are available: - * "wgpu": get a ``WgpuCanvasContext`` provided by the ``wgpu`` library. - * "bitmap": get a ``BitmapRenderingContext`` provided by the ``rendercanvas`` library. - * "another.module": other libraries may provide contexts too. We've only listed the ones we know of. - * "your.module:ContextClass": Explicit name. + * "wgpu": get a ``WgpuContext`` + * "bitmap": get a ``BitmapContext`` Later calls to this method, with the same context_type argument, will return the same context instance as was returned the first time the method was @@ -240,12 +243,12 @@ def get_context(self, context_type: str) -> object: one has been created. """ - # Note that this method is analog to HtmlCanvas.getContext(), except - # the context_type is different, since contexts are provided by other projects. + # Note that this method is analog to HtmlCanvas.getContext(), except with different context types. if not isinstance(context_type, str): raise TypeError("context_type must be str.") + # The 'screen' and 'wgpu' method mean the same present_method_map = context_type_map = {"screen": "wgpu"} # Resolve the context type name @@ -264,37 +267,7 @@ def get_context(self, context_type: str) -> object: f"Cannot get context for '{context_type}': a context of type '{self._canvas_context._context_type}' is already set." ) - # # Load module - # module_name, _, class_name = resolved_context_type.partition(":") - # try: - # module = importlib.import_module(module_name) - # except ImportError as err: - # raise ValueError( - # f"Cannot get context for '{context_type}': {err}. Known valid values are {set(known_types)}" - # ) from None - - # # Obtain factory to produce context - # factory_name = class_name or "rendercanvas_context_hook" - # try: - # factory_func = getattr(module, factory_name) - # except AttributeError: - # raise ValueError( - # f"Cannot get context for '{context_type}': could not find `{factory_name}` in '{module.__name__}'" - # ) from None - - # # Create the context - # context = factory_func(self, self._rc_get_present_methods()) - - # # Quick checks to make sure the context has the correct API - # if not (hasattr(context, "canvas") and context.canvas is self): - # raise RuntimeError( - # "The context does not have a canvas attribute that refers to this canvas." - # ) - # if not (hasattr(context, "present") and callable(context.present)): - # raise RuntimeError("The context does not have a present method.") - # Select present_method - # todo: does the canvas present_method arg override this in the appropriate way? present_methods = self._rc_get_present_methods() present_method = None if resolved_context_type == "bitmap": diff --git a/rendercanvas/utils/bitmappresentadapter.py b/rendercanvas/utils/bitmappresentadapter.py deleted file mode 100644 index d2ae12b..0000000 --- a/rendercanvas/utils/bitmappresentadapter.py +++ /dev/null @@ -1,349 +0,0 @@ -""" -A tool so contexts that produce a bitmap can still render to screen. -""" - -import sys -import wgpu - - -class BitmapPresentAdapter: - """An adapter to present a bitmap to a canvas using wgpu. - - This adapter can be used by context objects that want to present a bitmap, when the - canvas only supports presenting to screen. - """ - - def __init__(self, canvas, present_methods): - # Init wgpu - adapter = wgpu.gpu.request_adapter_sync(power_preference="high-performance") - device = self._device = adapter.request_device_sync(required_limits={}) - - self._texture_helper = FullscreenTexture(device) - - # Create context - backend_module = wgpu.gpu.__module__ - CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 - self._context = CanvasContext(canvas, present_methods) - self._context_is_configured = False - - def present_bitmap(self, bitmap): - """Present the given bitmap to screen. - - Supported formats are "rgba-u8" and "i-u8" (grayscale). - Returns the present-result dict produced by ``GPUCanvasContext.present()``. - """ - - self._texture_helper.set_texture_data(bitmap) - - if not self._context_is_configured: - format = self._context.get_preferred_format(self._device.adapter) - # We don't want an srgb texture, because we assume the input bitmap is already srgb. - # AFAIK contexts always support both the regular and the srgb texture format variants - if format.endswith("-srgb"): - format = format[:-5] - self._context.configure(device=self._device, format=format) - - target = self._context.get_current_texture().create_view() - command_encoder = self._device.create_command_encoder() - self._texture_helper.draw(command_encoder, target) - self._device.queue.submit([command_encoder.finish()]) - - return self._context.present() - - -class FullscreenTexture: - """An object that helps rendering a texture to the full viewport.""" - - def __init__(self, device): - self._device = device - self._pipeline_layout = None - self._pipeline = None - self._texture = None - self._uniform_data = memoryview(bytearray(1 * 4)).cast("f") - - def set_texture_data(self, data): - """Upload new data to the texture. Creates a new internal texture object if needed.""" - m = memoryview(data) - - texture_format = self._get_format_from_memoryview(m) - texture_size = m.shape[1], m.shape[0], 1 - - # Lazy init for the static stuff - if self._pipeline_layout is None: - self._create_uniform_buffer() - self._create_pipeline_layout() - - # Need new texture? - if ( - self._texture is None - or self._texture.size != texture_size - or texture_format != self._texture.format - ): - self._create_texture(texture_size, texture_format) - self._create_bind_groups() - - # Update buffer data - self._uniform_data[0] = 1 if texture_format.startswith("r8") else 4 - - # Upload data - self._update_texture(m) - self._update_uniform_buffer() - - def _get_format_from_memoryview(self, m): - # Check dtype - if m.format == "B": - dtype = "u8" - else: - raise ValueError( - "Unsupported bitmap dtype/format '{m.format}', expecting unsigned bytes ('B')." - ) - - # Get color format - color_format = None - if len(m.shape) == 2: - color_format = "i" - elif len(m.shape) == 3: - if m.shape[2] == 1: - color_format = "i" - elif m.shape[2] == 4: - color_format = "rgba" - if not color_format: - raise ValueError( - f"Unsupported bitmap shape {m.shape}, expecting a 2D grayscale or rgba image." - ) - - # Deduce wgpu texture format - format_map = { - "i-u8": wgpu.TextureFormat.r8unorm, - "rgba-u8": wgpu.TextureFormat.rgba8unorm, - } - format = f"{color_format}-{dtype}" - return format_map[format] - - def _create_uniform_buffer(self): - device = self._device - self._uniform_buffer = device.create_buffer( - size=self._uniform_data.nbytes, - usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, - ) - - def _update_uniform_buffer(self): - device = self._device - device.queue.write_buffer(self._uniform_buffer, 0, self._uniform_data) - - def _create_texture(self, size, format): - device = self._device - self._texture = device.create_texture( - size=size, - usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, - dimension=wgpu.TextureDimension.d2, - format=format, - mip_level_count=1, - sample_count=1, - ) - self._texture_view = self._texture.create_view() - self._sampler = device.create_sampler() - - def _update_texture(self, texture_data): - device = self._device - size = texture_data.shape[1], texture_data.shape[0], 1 - device.queue.write_texture( - { - "texture": self._texture, - "mip_level": 0, - "origin": (0, 0, 0), - }, - texture_data, - { - "offset": 0, - "bytes_per_row": texture_data.strides[0], - }, - size, - ) - - def _create_pipeline_layout(self): - device = self._device - bind_groups_layout_entries = [[]] - - bind_groups_layout_entries[0].append( - { - "binding": 0, - "visibility": wgpu.ShaderStage.VERTEX | wgpu.ShaderStage.FRAGMENT, - "buffer": {}, - } - ) - bind_groups_layout_entries[0].append( - { - "binding": 1, - "visibility": wgpu.ShaderStage.FRAGMENT, - "texture": {}, - } - ) - bind_groups_layout_entries[0].append( - { - "binding": 2, - "visibility": wgpu.ShaderStage.FRAGMENT, - "sampler": {}, - } - ) - - # Create the wgpu binding objects - bind_group_layouts = [] - for layout_entries in bind_groups_layout_entries: - bind_group_layout = device.create_bind_group_layout(entries=layout_entries) - bind_group_layouts.append(bind_group_layout) - - self._bind_group_layouts = bind_group_layouts - self._pipeline_layout = device.create_pipeline_layout( - bind_group_layouts=bind_group_layouts - ) - - def _create_pipeline(self, target_texture_view): - device = self._device - texture_format = target_texture_view.texture.format - shader = device.create_shader_module(code=shader_source) - - pipeline_kwargs = dict( - layout=self._pipeline_layout, - vertex={ - "module": shader, - "entry_point": "vs_main", - }, - primitive={ - "topology": wgpu.PrimitiveTopology.triangle_strip, - "front_face": wgpu.FrontFace.ccw, - "cull_mode": wgpu.CullMode.back, - }, - depth_stencil=None, - multisample=None, - fragment={ - "module": shader, - "entry_point": "fs_main", - "targets": [ - { - "format": texture_format, - "blend": { - "alpha": {}, - "color": {}, - }, - } - ], - }, - ) - - self._pipeline = device.create_render_pipeline(**pipeline_kwargs) - - def _create_bind_groups(self): - device = self._device - bind_groups_entries = [[]] - bind_groups_entries[0].append( - { - "binding": 0, - "resource": { - "buffer": self._uniform_buffer, - "offset": 0, - "size": self._uniform_buffer.size, - }, - } - ) - bind_groups_entries[0].append({"binding": 1, "resource": self._texture_view}) - bind_groups_entries[0].append({"binding": 2, "resource": self._sampler}) - - bind_groups = [] - for entries, bind_group_layout in zip( - bind_groups_entries, self._bind_group_layouts, strict=False - ): - bind_groups.append( - device.create_bind_group(layout=bind_group_layout, entries=entries) - ) - self._bind_groups = bind_groups - - def draw(self, command_encoder, target_texture_view): - """Draw the bitmap to given target texture view.""" - - if self._pipeline is None: - self._create_pipeline(target_texture_view) - - render_pass = command_encoder.begin_render_pass( - color_attachments=[ - { - "view": target_texture_view, - "resolve_target": None, - "clear_value": (0, 0, 0, 1), - "load_op": wgpu.LoadOp.clear, - "store_op": wgpu.StoreOp.store, - } - ], - ) - - render_pass.set_pipeline(self._pipeline) - for bind_group_id, bind_group in enumerate(self._bind_groups): - render_pass.set_bind_group(bind_group_id, bind_group) - render_pass.draw(4, 1, 0, 0) - render_pass.end() - - -shader_source = """ -struct Uniforms { - format: f32, -}; -@group(0) @binding(0) -var uniforms: Uniforms; - -struct VertexInput { - @builtin(vertex_index) vertex_index : u32, -}; -struct VertexOutput { - @location(0) texcoord: vec2, - @builtin(position) pos: vec4, -}; -struct FragmentOutput { - @location(0) color : vec4, -}; - - -@vertex -fn vs_main(in: VertexInput) -> VertexOutput { - var positions = array, 4>( - vec2(-1.0, 1.0), - vec2(-1.0, -1.0), - vec2( 1.0, 1.0), - vec2( 1.0, -1.0), - ); - var texcoords = array, 4>( - vec2(0.0, 0.0), - vec2(0.0, 1.0), - vec2(1.0, 0.0), - vec2(1.0, 1.0), - ); - let index = i32(in.vertex_index); - var out: VertexOutput; - out.pos = vec4(positions[index], 0.0, 1.0); - out.texcoord = vec2(texcoords[index]); - return out; -} - -@group(0) @binding(1) -var r_tex: texture_2d; - -@group(0) @binding(2) -var r_sampler: sampler; - -@fragment -fn fs_main(in: VertexOutput) -> FragmentOutput { - let value = textureSample(r_tex, r_sampler, in.texcoord); - var color = vec4(value); - if (uniforms.format == 1) { - color = vec4(value.r, value.r, value.r, 1.0); - } else if (uniforms.format == 2) { - color = vec4(value.r, value.r, value.r, value.g); - } - // We assume that the input color is sRGB. We don't need to go to physical/linear - // colorspace, because we don't need light calculations or anything. The - // output texture format is a regular rgba8unorm (not srgb), so that no transform - // happens as we write to the texture; the pixel values are already srgb. - var out: FragmentOutput; - out.color = color; - return out; -} -""" diff --git a/tests/test_context.py b/tests/test_context.py index 6a4b632..08280c5 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -28,7 +28,7 @@ def get_test_bitmap(width, height): hook_call_count = 0 -def rendercanvas_context_hook(canvas, present_methods): +def rendercanvas_context_hook(canvas, present_methods): # TODO: clean global hook_call_count hook_call_count += 1 return SpecialAdapterNoop(canvas, present_methods) From e305be757b24fbe66b2064caaad36df45fd042c7 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 4 Nov 2025 13:26:28 +0100 Subject: [PATCH 07/23] Improve docs and add get_bitmap_context() and get_wgpu_context() --- README.md | 2 +- docs/conf.py | 2 -- docs/contexts.rst | 14 ++++++++++++++ docs/index.rst | 1 + docs/start.rst | 4 ++-- examples/drag.py | 2 +- examples/noise.py | 2 +- examples/snake.py | 2 +- rendercanvas/contexts/bitmapcontext.py | 4 ++-- rendercanvas/contexts/wgpucontext.py | 4 ++-- rendercanvas/utils/cube.py | 2 +- 11 files changed, 26 insertions(+), 13 deletions(-) create mode 100644 docs/contexts.rst diff --git a/README.md b/README.md index e570b48..897e45e 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ import numpy as np from rendercanvas.auto import RenderCanvas, loop canvas = RenderCanvas(update_mode="continuous") -context = canvas.get_context("bitmap") +context = canvas.get_bitmap_context() @canvas.request_draw def animate(): diff --git a/docs/conf.py b/docs/conf.py index 42759c7..1294b9d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,8 +23,6 @@ # Load wglibu so autodoc can query docstrings import rendercanvas # noqa: E402 import rendercanvas.stub # noqa: E402 - we use the stub backend to generate doccs -import rendercanvas._context # noqa: E402 - we use the ContexInterface to generate doccs -import rendercanvas.utils.bitmappresentadapter # noqa: E402 # -- Project information ----------------------------------------------------- diff --git a/docs/contexts.rst b/docs/contexts.rst new file mode 100644 index 0000000..3a704ff --- /dev/null +++ b/docs/contexts.rst @@ -0,0 +1,14 @@ +Contexts +======== + + +.. autoclass:: rendercanvas.contexts.BaseContext + :members: + + +.. autoclass:: rendercanvas.contexts.BitmapContext + :members: + + +.. autoclass:: rendercanvas.contexts.WgpuContext + :members: diff --git a/docs/index.rst b/docs/index.rst index 6666764..baa49d4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,6 +10,7 @@ Welcome to the rendercanvas docs! start api backends + contexts utils Gallery advanced diff --git a/docs/start.rst b/docs/start.rst index a2817d7..0e7092f 100644 --- a/docs/start.rst +++ b/docs/start.rst @@ -50,7 +50,7 @@ Rendering using bitmaps: .. code-block:: py - context = canvas.get_context("bitmap") + context = canvas.get_bitmap_context() @canvas.request_draw def animate(): @@ -61,7 +61,7 @@ Rendering with wgpu: .. code-block:: py - context = canvas.get_context("wgpu") + context = canvas.get_wgpu_context() context.configure(device) @canvas.request_draw diff --git a/examples/drag.py b/examples/drag.py index 03a052b..9c40084 100644 --- a/examples/drag.py +++ b/examples/drag.py @@ -17,7 +17,7 @@ canvas = RenderCanvas(present_method=None, update_mode="continuous") -context = canvas.get_context("bitmap") +context = canvas.get_bitmap_context() # The size of the blocks: hw is half the block width diff --git a/examples/noise.py b/examples/noise.py index e97df57..a23cdaa 100644 --- a/examples/noise.py +++ b/examples/noise.py @@ -12,7 +12,7 @@ canvas = RenderCanvas(update_mode="continuous") -context = canvas.get_context("bitmap") +context = canvas.get_bitmap_context() @canvas.request_draw diff --git a/examples/snake.py b/examples/snake.py index d7d9e8e..20fd7b4 100644 --- a/examples/snake.py +++ b/examples/snake.py @@ -14,7 +14,7 @@ canvas = RenderCanvas(present_method=None, update_mode="continuous") -context = canvas.get_context("bitmap") +context = canvas.get_bitmap_context() world = np.zeros((120, 160), np.uint8) pos = [100, 100] diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index 4740f94..584f065 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -6,13 +6,13 @@ class BitmapContext(BaseContext): - """A context that provides an API that takes a (grayscale or rgba) images bitmap. + """A context that exposes an API that takes a (grayscale or rgba) images bitmap. This is loosely inspired by JS' ``ImageBitmapRenderingContext``. Rendering bitmaps is a simple way to use ``rendercanvas``, but usually not as performant as a wgpu context. - Users typically don't instantiate contexts directly, but use ``canvas.get_context("bitmap")``, + Users typically don't instantiate contexts directly, but use ``canvas.get_bitmap_context()``, which returns a subclass of this class, depending on the needs of the canvas. """ diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 42a4b2c..1397551 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -10,11 +10,11 @@ class WgpuContext(BaseContext): - """A context that provides an API that provides a GPU texture to render to. + """A context that exposes an API that provides a GPU texture to render to. This is inspired by JS' ``GPUCanvasContext``, and the more performant approach for rendering to a ``rendercanvas``. - Users typically don't instantiate contexts directly, but use ``canvas.get_context("wgpu")``, + Users typically don't instantiate contexts directly, but use ``canvas.get_wgpu_context()``, which returns a subclass of this class, depending on the needs of the canvas. """ diff --git a/rendercanvas/utils/cube.py b/rendercanvas/utils/cube.py index e7da9c8..dd6d03c 100644 --- a/rendercanvas/utils/cube.py +++ b/rendercanvas/utils/cube.py @@ -63,7 +63,7 @@ async def setup_drawing_async(canvas, limits=None, format=None): def get_render_pipeline_kwargs(canvas, device, pipeline_layout, render_texture_format): - context = canvas.get_context("wgpu") + context = canvas.get_wgpu_context() if render_texture_format is None: render_texture_format = context.get_preferred_format(device.adapter) context.configure(device=device, format=render_texture_format) From 4399758505ace3da4c188f24a0d49b297cd9afa2 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 4 Nov 2025 15:36:17 +0100 Subject: [PATCH 08/23] test wip + tweaks --- rendercanvas/base.py | 2 +- rendercanvas/contexts/basecontext.py | 11 +- rendercanvas/contexts/bitmapcontext.py | 9 +- rendercanvas/contexts/wgpucontext.py | 13 +- tests/test_context.py | 162 +++++++++---------------- 5 files changed, 75 insertions(+), 122 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index a657d84..c1a6b61 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -254,7 +254,7 @@ def get_context(self, context_type: str) -> contexts.BaseContext: # Resolve the context type name resolved_context_type = context_type_map.get(context_type, context_type) if resolved_context_type not in ("bitmap", "wgpu"): - raise ValueError( + raise TypeError( "The given context type is invalid: {context_type!r} is not 'bitmap' or 'wgpu'." ) diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index 0d3f09b..b6c2603 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -1,3 +1,4 @@ +import sys import weakref __all__ = ["BaseContext"] @@ -32,6 +33,14 @@ def canvas(self) -> object: """The associated RenderCanvas object (internally stored as a weakref).""" return self._canvas_ref() + def _get_wgpu_native_context_class(self): + # Create sub context, support both the old and new wgpu-py API + # TODO: let's add/use hook in wgpu to get the context in a less hacky way + import wgpu + + backend_module = wgpu.gpu.__module__ + return sys.modules[backend_module].GPUCanvasContext # noqa: N806 + def _rc_set_physical_size(self, width: int, height: int) -> None: """Called by the BaseRenderCanvas to set the physical size.""" self._physical_size = int(width), int(height) @@ -63,6 +72,6 @@ def _rc_present(self): # This is a stub return {"method": "skip"} - def _rc_release(self): + def _rc_release(self): # todo: rename to _rc_close """Release resources. Called by the canvas when it's closed.""" pass diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index 584f065..74509d7 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -19,7 +19,9 @@ class BitmapContext(BaseContext): def __new__(cls, canvas: object, present_info: dict): # Instantiating this class actually produces a subclass present_method = present_info["method"] - if present_method == "bitmap": + if cls is not BitmapContext: + return super().__new__(cls) # Use canvas that is explicitly instantiated + elif present_method == "bitmap": return super().__new__(BitmapContextPlain) elif present_method == "wgpu": return super().__new__(BitmapContextToWgpu) @@ -111,7 +113,6 @@ class BitmapContextToWgpu(BitmapContext): def __init__(self, canvas, present_info): super().__init__(canvas, present_info) - assert self._present_info["method"] == "wgpu" # Init wgpu import wgpu @@ -123,9 +124,7 @@ def __init__(self, canvas, present_info): self._texture_helper = FullscreenTexture(device) # Create sub context, support both the old and new wgpu-py API - backend_module = wgpu.gpu.__module__ - CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 - + CanvasContext = self._get_wgpu_native_context_class() if hasattr(CanvasContext, "set_physical_size"): self._wgpu_context_is_new_style = True self._wgpu_context = CanvasContext(present_info) diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 1397551..f5a4d4a 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -21,7 +21,9 @@ class WgpuContext(BaseContext): def __new__(cls, canvas: object, present_info: dict): # Instantiating this class actually produces a subclass present_method = present_info["method"] - if present_method == "wgpu": + if cls is not WgpuContext: + return super().__new__(cls) # Use canvas that is explicitly instantiated + elif present_method == "wgpu": return super().__new__(WgpuContextPlain) elif present_method == "bitmap": return super().__new__(WgpuContextToBitmap) @@ -142,14 +144,9 @@ class WgpuContextPlain(WgpuContext): def __init__(self, canvas: object, present_info: dict): super().__init__(canvas, present_info) + assert self._present_info["method"] == "wgpu" - import wgpu - - # Create sub context, support both the old and new wgpu-py API - # TODO: let's add/use hook in wgpu to get the context in a less hacky way - backend_module = wgpu.gpu.__module__ - CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 - + CanvasContext = self._get_wgpu_native_context_class() if hasattr(CanvasContext, "set_physical_size"): self._wgpu_context_is_new_style = True self._wgpu_context = CanvasContext(present_info) diff --git a/tests/test_context.py b/tests/test_context.py index 08280c5..6866fb0 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,6 +1,11 @@ import numpy as np -from rendercanvas.utils.bitmappresentadapter import BitmapPresentAdapter -from rendercanvas.utils.bitmaprenderingcontext import BitmapRenderingContext +from rendercanvas.contexts import ( + BaseContext, + BitmapContext, + WgpuContext, + BitmapContextToWgpu, + WgpuContextToBitmap, +) from rendercanvas.offscreen import OffscreenRenderCanvas as ManualOffscreenRenderCanvas from testutils import can_use_wgpu_lib, run_tests @@ -25,60 +30,43 @@ def get_test_bitmap(width, height): return bitmap -hook_call_count = 0 +class WgpuContextToBitmapWithNativeAPI(WgpuContextToBitmap): + """A WgpuContextToBitmap with an API like (the new) wgpu.GPUCanvasContext.""" + def __init__(self, present_info): + super().__init__(None, present_info) -def rendercanvas_context_hook(canvas, present_methods): # TODO: clean - global hook_call_count - hook_call_count += 1 - return SpecialAdapterNoop(canvas, present_methods) - - -class SpecialAdapterNoop: - def __init__(self, canvas, present_methods): - self.canvas = canvas + def set_physical_size(self, w, h): + self._rc_set_physical_size(self, w, h) def present(self): - return {"method": "skip"} - + self._rc_present() -class SpecialAdapterFail1: - def __init__(self, canvas, present_methods): - 1 / 0 # noqa + def close(self): + self._rc_release() -class SpecialAdapterFail2: - # does not have a present method - def __init__(self, canvas, present_methods): - self.canvas = canvas +class BitmapContextToWgpuAndBackToBimap(BitmapContextToWgpu): + """A bitmap context that takes a detour via wgpu :)""" + def _get_wgpu_native_context_class(self): + return WgpuContextToBitmapWithNativeAPI -class SpecialContextWithWgpuAdapter: - """This looks a lot like the BitmapPresentAdapter, - except it will *always* use the adapter, so that we can touch that code path. - """ - - def __init__(self, canvas, present_methods): - self.adapter = BitmapPresentAdapter(canvas, present_methods) - self.canvas = canvas - - def set_bitmap(self, bitmap): - self.bitmap = bitmap - - def present(self): - return self.adapter.present_bitmap(self.bitmap) + def _rc_present(self): + return self._wgpu_context._rc_present() # %% -def test_context_selection11(): +def test_context_selection_bitmap(): # Select our builtin bitmap context canvas = ManualOffscreenRenderCanvas() context = canvas.get_context("bitmap") - assert isinstance(context, BitmapRenderingContext) + assert isinstance(context, BitmapContext) + assert isinstance(context, BaseContext) # Cannot select another context now with pytest.raises(RuntimeError): @@ -88,55 +76,32 @@ def test_context_selection11(): context2 = canvas.get_context("bitmap") assert context2 is context - -def test_context_selection12(): - # Select bitmap context using full module name - - canvas = ManualOffscreenRenderCanvas() - - context = canvas.get_context("rendercanvas.utils.bitmaprenderingcontext") - assert isinstance(context, BitmapRenderingContext) - - # Same thing - context2 = canvas.get_context("bitmap") + # And this also works + context2 = canvas.get_bitmap_context() assert context2 is context -def test_context_selection13(): - # Select bitmap context using full path to class. +@pytest.mark.skipif(not can_use_wgpu_lib, reason="Needs wgpu lib") +def test_context_selection_wgpu(): + # Select our builtin bitmap context + canvas = ManualOffscreenRenderCanvas() - context = canvas.get_context( - "rendercanvas.utils.bitmaprenderingcontext:BitmapRenderingContext" - ) - assert isinstance(context, BitmapRenderingContext) + context = canvas.get_context("wgpu") + assert isinstance(context, WgpuContext) + assert isinstance(context, BaseContext) - # Same thing ... but get_context cannot know + # Cannot select another context now with pytest.raises(RuntimeError): canvas.get_context("bitmap") + # But can select the same one + context2 = canvas.get_context("wgpu") + assert context2 is context -def test_context_selection22(): - # Select bitmap context using full module name, and the hook - - canvas = ManualOffscreenRenderCanvas() - - count = hook_call_count - context = canvas.get_context(__name__) - assert hook_call_count == count + 1 # hook is called - - assert isinstance(context, SpecialAdapterNoop) - - -def test_context_selection23(): - # Select bitmap context using full path to class. - canvas = ManualOffscreenRenderCanvas() - - count = hook_call_count - context = canvas.get_context(__name__ + ":SpecialAdapterNoop") - assert hook_call_count == count # hook is not called - - assert isinstance(context, SpecialAdapterNoop) + # And this also works + context2 = canvas.get_wgpu_context() + assert context2 is context def test_context_selection_fails(): @@ -149,39 +114,20 @@ def test_context_selection_fails(): # Must be a string with pytest.raises(TypeError) as err: - canvas.get_context(BitmapRenderingContext) + canvas.get_context(BitmapContext) assert "must be str" in str(err) - # Must be a valid module - with pytest.raises(ValueError) as err: - canvas.get_context("thisisnotavalidmodule") - assert "no module named" in str(err).lower() - - # Must be a valid module - with pytest.raises(ValueError) as err: - canvas.get_context("thisisnot.avalidmodule.either") - assert "no module named" in str(err).lower() - - # The module must have a hook - with pytest.raises(ValueError) as err: - canvas.get_context("rendercanvas._coreutils") - assert "could not find" in str(err) - - # Error on instantiation - with pytest.raises(ZeroDivisionError): - canvas.get_context(__name__ + ":SpecialAdapterFail1") - - # Class does not look like a context - with pytest.raises(RuntimeError) as err: - canvas.get_context(__name__ + ":SpecialAdapterFail2") - assert "does not have a present method." in str(err) + # Must be a valid string + with pytest.raises(TypeError) as err: + canvas.get_context("notacontexttype") + assert "context type is invalid" in str(err) def test_bitmap_context(): # Create canvas, and select the rendering context canvas = ManualOffscreenRenderCanvas() context = canvas.get_context("bitmap") - assert isinstance(context, BitmapRenderingContext) + assert isinstance(context, BitmapContext) # Create and set bitmap bitmap = get_test_bitmap(*canvas.get_physical_size()) @@ -210,19 +156,21 @@ def test_bitmap_context(): @pytest.mark.skipif(not can_use_wgpu_lib, reason="Needs wgpu lib") -def test_bitmap_present_adapter(): +def test_wgpu_context(): # Create canvas and attach our special adapter canvas canvas = ManualOffscreenRenderCanvas() - context = canvas.get_context(__name__ + ":SpecialContextWithWgpuAdapter") + context = BitmapContextToWgpuAndBackToBimap( + canvas, {"method": "bitmap", "formats": ["rgba-u8"]} + ) + canvas._canvas_context = context + assert isinstance(context, BitmapContext) # Create and set bitmap bitmap = get_test_bitmap(*canvas.get_physical_size()) context.set_bitmap(bitmap) - # Draw! This will call SpecialContextWithWgpuAdapter.present(), which will - # invoke the adapter to render the bitmap to a texture. The GpuCanvasContext.present() - # method will also be called, which will download the texture to a bitmap, - # and that's what we receive as the result. + # Draw! The primary context will upload the bitmap to a wgpu texture, + # and the wrapped context will then download it to a bitmap again. # So this little line here touches quite a lot of code. In the end, the bitmap # should be unchanged, because the adapter assumes that the incoming bitmap # is in the sRGB colorspace. From a84fe6fe6c119e2d932bd8bb16259e157918e65c Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 5 Nov 2025 09:19:46 +0100 Subject: [PATCH 09/23] Some tweaks and working tests --- rendercanvas/base.py | 6 ++-- rendercanvas/contexts/basecontext.py | 36 ++++++++++++++++-------- rendercanvas/contexts/bitmapcontext.py | 38 ++++++++++++-------------- rendercanvas/contexts/wgpucontext.py | 29 ++++++++------------ tests/test_context.py | 24 ++++++++-------- 5 files changed, 67 insertions(+), 66 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index c1a6b61..ad3bf27 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -298,9 +298,9 @@ def get_context(self, context_type: str) -> contexts.BaseContext: } if resolved_context_type == "bitmap": - context = contexts.BitmapContext(self, present_info) + context = contexts.BitmapContext(present_info) else: - context = contexts.WgpuContext(self, present_info) + context = contexts.WgpuContext(present_info) # Done self._canvas_context = context @@ -577,7 +577,7 @@ def close(self) -> None: self._draw_frame = None # type: ignore # Clear the canvas context too. try: - self._canvas_context._rc_release() # type: ignore + self._canvas_context._rc_close() # type: ignore except Exception: pass self._canvas_context = None diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index b6c2603..b03500c 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -1,5 +1,4 @@ import sys -import weakref __all__ = ["BaseContext"] @@ -19,8 +18,7 @@ class BaseContext: subsystems. """ - def __init__(self, canvas: object, present_info: dict): - self._canvas_ref = weakref.ref(canvas) + def __init__(self, present_info: dict): self._present_info = present_info assert present_info["method"] in ("bitmap", "wgpu") # internal sanity check self._physical_size = 0, 0 @@ -28,18 +26,32 @@ def __init__(self, canvas: object, present_info: dict): def __repr__(self): return f"" - @property - def canvas(self) -> object: - """The associated RenderCanvas object (internally stored as a weakref).""" - return self._canvas_ref() + def _get_wgpu_py_context(self) -> tuple[object, bool]: + """Create a wgpu.GPUCanvasContext - def _get_wgpu_native_context_class(self): - # Create sub context, support both the old and new wgpu-py API + Returns the context object and a flag whether it uses the new-style API. + """ # TODO: let's add/use hook in wgpu to get the context in a less hacky way import wgpu backend_module = wgpu.gpu.__module__ - return sys.modules[backend_module].GPUCanvasContext # noqa: N806 + CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 + + if hasattr(CanvasContext, "set_physical_size"): + wgpu_context_is_new_style = True + wgpu_context = CanvasContext(self._present_info) + else: + wgpu_context_is_new_style = False + pseudo_canvas = self # must be anything that has a get_physical_size + wgpu_context = CanvasContext(pseudo_canvas, {"screen": self._present_info}) + + return wgpu_context, wgpu_context_is_new_style + + def get_physical_size(self): + """Get the physical size.""" + return self._physical_size + + # TODO: also expose logical size (and pixel ratio maybe). These are all that a renderer needs to render into a context (no need for canvas) def _rc_set_physical_size(self, width: int, height: int) -> None: """Called by the BaseRenderCanvas to set the physical size.""" @@ -72,6 +84,6 @@ def _rc_present(self): # This is a stub return {"method": "skip"} - def _rc_release(self): # todo: rename to _rc_close - """Release resources. Called by the canvas when it's closed.""" + def _rc_close(self): + """Close context and release resources. Called by the canvas when it's closed.""" pass diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index 74509d7..2fed8eb 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -1,5 +1,3 @@ -import sys - from .basecontext import BaseContext __all__ = ["BitmapContext", "BitmapContextPlain", "BitmapContextToWgpu"] @@ -16,7 +14,7 @@ class BitmapContext(BaseContext): which returns a subclass of this class, depending on the needs of the canvas. """ - def __new__(cls, canvas: object, present_info: dict): + def __new__(cls, present_info: dict): # Instantiating this class actually produces a subclass present_method = present_info["method"] if cls is not BitmapContext: @@ -28,8 +26,8 @@ def __new__(cls, canvas: object, present_info: dict): else: raise TypeError("Unexpected present_method {present_method!r}") - def __init__(self, canvas, present_info): - super().__init__(canvas, present_info) + def __init__(self, present_info): + super().__init__(present_info) self._bitmap_and_format = None def set_bitmap(self, bitmap): @@ -74,8 +72,8 @@ def set_bitmap(self, bitmap): class BitmapContextPlain(BitmapContext): """A BitmapContext that just presents the bitmap to the canvas.""" - def __init__(self, canvas, present_info): - super().__init__(canvas, present_info) + def __init__(self, present_info): + super().__init__(present_info) assert self._present_info["method"] == "bitmap" self._bitmap_and_format = None @@ -101,7 +99,7 @@ def _rc_present(self): "format": format, } - def _rc_release(self): + def _rc_close(self): self._bitmap_and_format = None @@ -111,8 +109,8 @@ class BitmapContextToWgpu(BitmapContext): This is uses for canvases that do not support presenting a bitmap. """ - def __init__(self, canvas, present_info): - super().__init__(canvas, present_info) + def __init__(self, present_info): + super().__init__(present_info) # Init wgpu import wgpu @@ -123,14 +121,9 @@ def __init__(self, canvas, present_info): self._texture_helper = FullscreenTexture(device) - # Create sub context, support both the old and new wgpu-py API - CanvasContext = self._get_wgpu_native_context_class() - if hasattr(CanvasContext, "set_physical_size"): - self._wgpu_context_is_new_style = True - self._wgpu_context = CanvasContext(present_info) - else: - self._wgpu_context_is_new_style = False - self._wgpu_context = CanvasContext(canvas, {"screen": present_info}) + self._wgpu_context, self._wgpu_context_is_new_style = ( + self._get_wgpu_py_context() + ) self._wgpu_context_is_configured = False def _rc_set_physical_size(self, width: int, height: int) -> None: @@ -162,10 +155,13 @@ def _rc_present(self): self._texture_helper.draw(command_encoder, target) self._device.queue.submit([command_encoder.finish()]) - self._wgpu_context.present() - return {"method": "delegated"} + present_feedback = self._wgpu_context.present() + + if present_feedback is None: + present_feedback = {"method": "delegated"} + return present_feedback - def _rc_release(self): + def _rc_close(self): self._bitmap_and_format = None if self._wgpu_context is not None: if self._wgpu_context_is_new_style: diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index f5a4d4a..e213d4e 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -1,4 +1,3 @@ -import sys from typing import Sequence from .basecontext import BaseContext @@ -18,7 +17,7 @@ class WgpuContext(BaseContext): which returns a subclass of this class, depending on the needs of the canvas. """ - def __new__(cls, canvas: object, present_info: dict): + def __new__(cls, present_info: dict): # Instantiating this class actually produces a subclass present_method = present_info["method"] if cls is not WgpuContext: @@ -30,8 +29,8 @@ def __new__(cls, canvas: object, present_info: dict): else: raise TypeError("Unexpected present_method {present_method!r}") - def __init__(self, canvas: object, present_info: dict): - super().__init__(canvas, present_info) + def __init__(self, present_info: dict): + super().__init__(present_info) # Configuration dict from the user, set via self.configure() self._config = None @@ -142,17 +141,13 @@ class WgpuContextPlain(WgpuContext): When running in Pyodide, it means it renders directly to a ````. """ - def __init__(self, canvas: object, present_info: dict): - super().__init__(canvas, present_info) + def __init__(self, present_info: dict): + super().__init__(present_info) assert self._present_info["method"] == "wgpu" - CanvasContext = self._get_wgpu_native_context_class() - if hasattr(CanvasContext, "set_physical_size"): - self._wgpu_context_is_new_style = True - self._wgpu_context = CanvasContext(present_info) - else: - self._wgpu_context_is_new_style = False - self._wgpu_context = CanvasContext(canvas, {"screen": present_info}) + self._wgpu_context, self._wgpu_context_is_new_style = ( + self._get_wgpu_py_context() + ) def _get_preferred_format(self, adapter: object) -> str: return self._wgpu_context.get_preferred_format(adapter) @@ -176,7 +171,7 @@ def _rc_present(self) -> None: self._wgpu_context.present() return {"method": "screen"} - def _rc_release(self): + def _rc_close(self): if self._wgpu_context is not None: if self._wgpu_context_is_new_style: self._wgpu_context.close() # TODO: make sure this is compatible @@ -196,8 +191,8 @@ class WgpuContextToBitmap(WgpuContext): actually that big. """ - def __init__(self, canvas: object, present_info: dict): - super().__init__(canvas, present_info) + def __init__(self, present_info: dict): + super().__init__(present_info) # Canvas capabilities. Stored the first time it is obtained self._capabilities = self._get_capabilities() @@ -365,5 +360,5 @@ def _get_bitmap(self): return data.cast(memoryview_type, (size[1], size[0], nchannels)) - def _rc_release(self): + def _rc_close(self): self._drop_texture() diff --git a/tests/test_context.py b/tests/test_context.py index 6866fb0..1656c52 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -30,30 +30,28 @@ def get_test_bitmap(width, height): return bitmap -class WgpuContextToBitmapWithNativeAPI(WgpuContextToBitmap): - """A WgpuContextToBitmap with an API like (the new) wgpu.GPUCanvasContext.""" +class WgpuContextToBitmapLookLikeWgpuPy(WgpuContextToBitmap): + """A WgpuContextToBitmap with an API like (the new) wgpu.GPUCanvasContext. - def __init__(self, present_info): - super().__init__(None, present_info) + The API's look close enough that we can mimic it with this. This allows + testing a workflow that goes from bitmap -> wgpu -> wgpu -> bitmap + """ def set_physical_size(self, w, h): - self._rc_set_physical_size(self, w, h) + self._rc_set_physical_size(w, h) def present(self): - self._rc_present() + return self._rc_present() def close(self): - self._rc_release() + self._rc_close() class BitmapContextToWgpuAndBackToBimap(BitmapContextToWgpu): """A bitmap context that takes a detour via wgpu :)""" - def _get_wgpu_native_context_class(self): - return WgpuContextToBitmapWithNativeAPI - - def _rc_present(self): - return self._wgpu_context._rc_present() + def _get_wgpu_py_context(self): + return WgpuContextToBitmapLookLikeWgpuPy(self._present_info), True # %% @@ -160,7 +158,7 @@ def test_wgpu_context(): # Create canvas and attach our special adapter canvas canvas = ManualOffscreenRenderCanvas() context = BitmapContextToWgpuAndBackToBimap( - canvas, {"method": "bitmap", "formats": ["rgba-u8"]} + {"method": "bitmap", "formats": ["rgba-u8"]} ) canvas._canvas_context = context assert isinstance(context, BitmapContext) From aa77dafb5279428cf0cc223fa9b54ef067a63a79 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 5 Nov 2025 09:30:42 +0100 Subject: [PATCH 10/23] fix tests --- rendercanvas/base.py | 2 +- tests/test_base.py | 15 +++------------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index ad3bf27..13fcb92 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -508,7 +508,7 @@ def _draw_frame_and_present(self): self.__maybe_emit_resize_event() # Also update the context's size - if self.__need_context_resize: + if self.__need_context_resize and self._canvas_context is not None: self.__need_context_resize = False self._canvas_context._rc_set_physical_size( *self.__size_info["physical_size"] diff --git a/tests/test_base.py b/tests/test_base.py index c83c5aa..250ed13 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -76,7 +76,7 @@ class MyOffscreenCanvas(rendercanvas.BaseRenderCanvas): def __init__(self): super().__init__() self.frame_count = 0 - self.physical_size = 100, 100 + self._set_size_info((100, 100), 1) def _rc_get_present_methods(self): return { @@ -89,15 +89,6 @@ def _rc_present_bitmap(self, *, data, format, **kwargs): self.frame_count += 1 self.array = np.frombuffer(data, np.uint8).reshape(data.shape) - def get_pixel_ratio(self): - return 1 - - def get_logical_size(self): - return self.get_physical_size() - - def get_physical_size(self): - return self.physical_size - @mark.skipif(not can_use_wgpu_lib, reason="Needs wgpu lib") def test_run_bare_canvas(): @@ -162,7 +153,7 @@ def draw_frame(): assert np.all(canvas.array[:, :, 1] == 255) # Change resolution - canvas.physical_size = 120, 100 + canvas._set_size_info((120, 100), 1) # Draw 3 canvas.force_draw() @@ -171,7 +162,7 @@ def draw_frame(): assert np.all(canvas.array[:, :, 1] == 255) # Change resolution - canvas.physical_size = 120, 140 + canvas._set_size_info((120, 140), 1) # Draw 4 canvas.force_draw() From 465d29b4460c56387dc7ecd086932f347d9a0519 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 5 Nov 2025 10:53:44 +0100 Subject: [PATCH 11/23] Refactore sizing --- rendercanvas/base.py | 17 +++-- rendercanvas/contexts/basecontext.py | 92 ++++++++++++++++++++------ rendercanvas/contexts/bitmapcontext.py | 18 ++--- rendercanvas/contexts/wgpucontext.py | 21 ++---- tests/test_context.py | 12 +++- 5 files changed, 103 insertions(+), 57 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 13fcb92..206ff92 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -159,7 +159,7 @@ def __init__( "logical_size": (0.0, 0.0), } self.__need_size_event = False - self.__need_context_resize = False + self.__need_context_resize = True # True bc context may be created later # Events and scheduler self._events = EventEmitter() @@ -363,6 +363,12 @@ def submit_event(self, event: dict) -> None: # %% Scheduling and drawing def __maybe_emit_resize_event(self): + # Keep context up-to-date + if self.__need_context_resize and self._canvas_context is not None: + self.__need_context_resize = False + self._canvas_context._rc_set_size_info(self.__size_info) + + # Keep event listeners up-to-date if self.__need_size_event: self.__need_size_event = False lsize = self.__size_info["logical_size"] @@ -507,13 +513,6 @@ def _draw_frame_and_present(self): # Make sure that the user-code is up-to-date with the current size before it draws. self.__maybe_emit_resize_event() - # Also update the context's size - if self.__need_context_resize and self._canvas_context is not None: - self.__need_context_resize = False - self._canvas_context._rc_set_physical_size( - *self.__size_info["physical_size"] - ) - # Emit before-draw self._events.emit({"event_type": "before_draw"}) @@ -553,7 +552,7 @@ def _draw_frame_and_present(self): # %% Primary canvas management methods def get_logical_size(self) -> Tuple[float, float]: - """Get the logical size (width, height) in float pixels. + """Get the logical size (width, height) of the canvas in float pixels. The logical size can be smaller than the physical size, e.g. on HiDPI monitors or when the user's system has the display-scale set to e.g. 125%. diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index b03500c..f86081c 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -21,16 +21,21 @@ class BaseContext: def __init__(self, present_info: dict): self._present_info = present_info assert present_info["method"] in ("bitmap", "wgpu") # internal sanity check - self._physical_size = 0, 0 + self._size_info = { + "physical_size": (0, 0), + "native_pixel_ratio": 1.0, + "canvas_pixel_ratio": 1.0, + "total_pixel_ratio": 1.0, + "logical_size": (0.0, 0.0), + } + self._object_with_physical_size = None # to support old wgpu-py api + self._wgpu_context = None def __repr__(self): return f"" - def _get_wgpu_py_context(self) -> tuple[object, bool]: - """Create a wgpu.GPUCanvasContext - - Returns the context object and a flag whether it uses the new-style API. - """ + def _create_wgpu_py_context(self) -> object: + """Create a wgpu.GPUCanvasContext""" # TODO: let's add/use hook in wgpu to get the context in a less hacky way import wgpu @@ -38,24 +43,62 @@ def _get_wgpu_py_context(self) -> tuple[object, bool]: CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 if hasattr(CanvasContext, "set_physical_size"): - wgpu_context_is_new_style = True - wgpu_context = CanvasContext(self._present_info) + # New style wgpu-py API + self._wgpu_context = CanvasContext(self._present_info) else: - wgpu_context_is_new_style = False - pseudo_canvas = self # must be anything that has a get_physical_size - wgpu_context = CanvasContext(pseudo_canvas, {"screen": self._present_info}) + # Old style wgpu-py API + self._object_with_physical_size = pseudo_canvas = PseudoCanvasForWgpuPy() + self._wgpu_context = CanvasContext( + pseudo_canvas, {"screen": self._present_info} + ) + + def _rc_set_size_info(self, size_info: dict) -> None: + """Called by the BaseRenderCanvas to update the size.""" + # Note that we store the dict itself, not a copy. So our size is always up-to-date, + # but this function is called on resize nonetheless so we can pass resizes downstream. + self._size_info = size_info + if self._object_with_physical_size is not None: + self._object_with_physical_size.set_physical_size( + *size_info["physical_size"] + ) + elif self._wgpu_context is not None: + self._wgpu_context.set_physical_size(*size_info["physical_size"]) + + @property + def physical_size(self) -> tuple[int, int]: + """The physical size of the render target in integer pixels.""" + return self._size_info["physical_size"] + + @property + def logical_size(self) -> tuple[float, float]: + """The logical size (width, height) of the render target in float pixels. + + The logical size can be smaller than the physical size, e.g. on HiDPI + monitors or when the user's system has the display-scale set to e.g. + 125%. The logical size can in theory also be larger than the physical + size, but this is much less common. + """ + return self._size_info["logical_size"] - return wgpu_context, wgpu_context_is_new_style + @property + def pixel_ratio(self) -> float: + """The float ratio between logical and physical pixels. - def get_physical_size(self): - """Get the physical size.""" - return self._physical_size + The pixel ratio is typically 1.0 for normal screens and 2.0 for HiDPI + screens, but fractional values are also possible if the system + display-scale is set to e.g. 125%. On MacOS (with a Retina screen) the + pixel ratio is always 2.0. + """ + return self._size_info["total_pixel_ratio"] - # TODO: also expose logical size (and pixel ratio maybe). These are all that a renderer needs to render into a context (no need for canvas) + @property + def looks_like_hidpi(self) -> bool: + """Whether it looks like the window is on a hipdi screen. - def _rc_set_physical_size(self, width: int, height: int) -> None: - """Called by the BaseRenderCanvas to set the physical size.""" - self._physical_size = int(width), int(height) + This is determined by checking whether the native pixel-ratio (i.e. + the ratio reported by the canvas backend) is larger or equal dan 2.0. + """ + return self._size_info["native_pixel_ratio"] >= 2.0 def _rc_present(self): """Called by BaseRenderCanvas to collect the result. Subclasses must implement this. @@ -87,3 +130,14 @@ def _rc_present(self): def _rc_close(self): """Close context and release resources. Called by the canvas when it's closed.""" pass + + +class PseudoCanvasForWgpuPy: + def __init__(self): + self._physical_size = 0, 0 + + def set_physical_size(self, w: int, h: int): + self._physical_size = w, h + + def get_physical_size(self) -> tuple[int, int]: + return self._physical_size diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index 2fed8eb..c867914 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -121,17 +121,9 @@ def __init__(self, present_info): self._texture_helper = FullscreenTexture(device) - self._wgpu_context, self._wgpu_context_is_new_style = ( - self._get_wgpu_py_context() - ) + self._create_wgpu_py_context() # sets self._wgpu_context self._wgpu_context_is_configured = False - def _rc_set_physical_size(self, width: int, height: int) -> None: - width, height = int(width), int(height) - self._physical_size = width, height - if self._wgpu_context_is_new_style: - self._wgpu_context.set_physical_size(width, height) - def _rc_present(self): if self._bitmap_and_format is None: return {"method": "skip"} @@ -164,11 +156,13 @@ def _rc_present(self): def _rc_close(self): self._bitmap_and_format = None if self._wgpu_context is not None: - if self._wgpu_context_is_new_style: - self._wgpu_context.close() # TODO: make sure this is compatible + if hasattr(self._wgpu_context, "close"): + try: + self._wgpu_context.close() # TODO: make sure this is compatible + except Exception: + pass else: try: self._wgpu_context._release() # private method except Exception: pass - self._wgpu_context = None diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index e213d4e..a6b0d81 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -144,10 +144,7 @@ class WgpuContextPlain(WgpuContext): def __init__(self, present_info: dict): super().__init__(present_info) assert self._present_info["method"] == "wgpu" - - self._wgpu_context, self._wgpu_context_is_new_style = ( - self._get_wgpu_py_context() - ) + self._create_wgpu_py_context() # sets self._wgpu_context def _get_preferred_format(self, adapter: object) -> str: return self._wgpu_context.get_preferred_format(adapter) @@ -161,26 +158,22 @@ def _unconfigure(self) -> None: def _get_current_texture(self) -> object: return self._wgpu_context.get_current_texture() - def _rc_set_physical_size(self, width: int, height: int) -> None: - width, height = int(width), int(height) - self._physical_size = width, height - if self._wgpu_context_is_new_style: - self._wgpu_context.set_physical_size(width, height) - def _rc_present(self) -> None: self._wgpu_context.present() return {"method": "screen"} def _rc_close(self): if self._wgpu_context is not None: - if self._wgpu_context_is_new_style: - self._wgpu_context.close() # TODO: make sure this is compatible + if hasattr(self._wgpu_context, "close"): + try: + self._wgpu_context.close() # TODO: make sure this is compatible + except Exception: + pass else: try: self._wgpu_context._release() # private method except Exception: pass - self._wgpu_context = None class WgpuContextToBitmap(WgpuContext): @@ -284,7 +277,7 @@ def _get_current_texture(self): if self._texture is None: import wgpu - width, height = self._physical_size + width, height = self.physical_size width, height = max(width, 1), max(height, 1) # Note that the label 'present' is used by read_texture() to determine diff --git a/tests/test_context.py b/tests/test_context.py index 1656c52..9b9c715 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -38,7 +38,13 @@ class WgpuContextToBitmapLookLikeWgpuPy(WgpuContextToBitmap): """ def set_physical_size(self, w, h): - self._rc_set_physical_size(w, h) + size_info = { + "physical_size": (w, h), + "native_pixel_ratio": 1.0, + "total_pixel_ratio": 1.0, + "logical_size": (float(w), float(h)), + } + self._rc_set_size_info(size_info) def present(self): return self._rc_present() @@ -50,8 +56,8 @@ def close(self): class BitmapContextToWgpuAndBackToBimap(BitmapContextToWgpu): """A bitmap context that takes a detour via wgpu :)""" - def _get_wgpu_py_context(self): - return WgpuContextToBitmapLookLikeWgpuPy(self._present_info), True + def _create_wgpu_py_context(self): + self._wgpu_context = WgpuContextToBitmapLookLikeWgpuPy(self._present_info) # %% From 8025136684c78350768c4196e2c5958fa26ae420 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 5 Nov 2025 11:41:55 +0100 Subject: [PATCH 12/23] doc tweaks --- docs/api.rst | 4 ++-- docs/contexts.rst | 7 +++---- docs/index.rst | 2 +- docs/start.rst | 1 - rendercanvas/base.py | 1 + rendercanvas/contexts/__init__.py | 16 ++++++++++++++++ rendercanvas/contexts/basecontext.py | 14 +------------- 7 files changed, 24 insertions(+), 21 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 2c8e44a..fdda50e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,5 +1,5 @@ -API -=== +Canvas API +========== These are the classes that make up the rendercanvas API: diff --git a/docs/contexts.rst b/docs/contexts.rst index 3a704ff..784f7a4 100644 --- a/docs/contexts.rst +++ b/docs/contexts.rst @@ -1,14 +1,13 @@ -Contexts -======== +Context API +=========== +.. automodule:: rendercanvas.contexts .. autoclass:: rendercanvas.contexts.BaseContext :members: - .. autoclass:: rendercanvas.contexts.BitmapContext :members: - .. autoclass:: rendercanvas.contexts.WgpuContext :members: diff --git a/docs/index.rst b/docs/index.rst index baa49d4..5971d20 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,8 +9,8 @@ Welcome to the rendercanvas docs! start api - backends contexts + backends utils Gallery advanced diff --git a/docs/start.rst b/docs/start.rst index 0e7092f..64ee429 100644 --- a/docs/start.rst +++ b/docs/start.rst @@ -70,7 +70,6 @@ Rendering with wgpu: # ... wgpu code - .. _async: Async diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 206ff92..7521c92 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -248,6 +248,7 @@ def get_context(self, context_type: str) -> contexts.BaseContext: if not isinstance(context_type, str): raise TypeError("context_type must be str.") + # todo: decide on 'screen' vs 'wgpu' # The 'screen' and 'wgpu' method mean the same present_method_map = context_type_map = {"screen": "wgpu"} diff --git a/rendercanvas/contexts/__init__.py b/rendercanvas/contexts/__init__.py index 4e18492..2f00624 100644 --- a/rendercanvas/contexts/__init__.py +++ b/rendercanvas/contexts/__init__.py @@ -1,3 +1,19 @@ +""" +A context provides an API to provide a rendered image, and implements a +mechanism to present that image for display. The concept of a context is heavily +inspired by the canvas and its contexts in the browser. + +In ``rendercanvas``, there are two types of contexts: the *bitmap* context +exposes an API that takes image bitmaps in RAM, and the *wgpu* context exposes +an API that provides image textures on the GPU to render to. + +The presentation of the rendered image is handled by a sub-system, e.g. display +directly to screen, pass as bitmap to a GUI toolkit, send bitmap to a remote +client, etc. Each such subsystem is handled by a dedicated subclasses of +``BitmapContext`` and ``WgpuContext``. Users only need to be aware of the base +classes. +""" + from .basecontext import * # noqa: F403 from .bitmapcontext import * # noqa: F403 from .wgpucontext import * # noqa: F403 diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index f86081c..32e0197 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -4,19 +4,7 @@ class BaseContext: - """The base class for context objects in ``rendercanvas``. - - A context provides an API to provide a rendered image, and implements a - mechanism to present that image to the another system for display. The - concept of a context is heavily inspired by the canvas and its contexts in - the browser. - - In ``rendercanvas``, there are two types of contexts: the *bitmap* context - provides an API that takes image bitmaps in RAM, and the *wgpu* context - provides an API that takes provides image textures on the GPU to render to. - Each type of context has multiple subclasses to connect it to various - subsystems. - """ + """The base class for context objects in ``rendercanvas``.""" def __init__(self, present_info: dict): self._present_info = present_info From 79e4b4b89e85616368958692ef07700f3eef8b50 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 6 Nov 2025 12:32:42 +0100 Subject: [PATCH 13/23] add build for wgpu-less test --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e321e6d..acb7b3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,39 @@ jobs: run: | pytest -v tests - test-examples-build: + test-without-wgpu: + name: ${{ matrix.name }} without wgpu + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: Test py313 + os: ubuntu-latest + pyversion: '3.13' + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.pyversion }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.pyversion }} + - name: Install package and dev dependencies + run: | + python -m pip install --upgrade pip + pip install .[tests] + rm -r rendercanvas + - name: Unit tests + run: | + python -c " + try: + import wgpu + raise RuntimError('wgpu could be imported') + except ImportError: + print('wgpu is (intentionally) not available') + " + pytest -v tests + + test-examples: name: Test examples ${{ matrix.pyversion }} runs-on: ${{ matrix.os }} strategy: @@ -150,8 +182,8 @@ jobs: pushd $HOME pytest -v --pyargs rendercanvas.__pyinstaller - release: - name: Build release on ubuntu-latest + build-release: + name: Build release artifacts runs-on: ubuntu-latest strategy: fail-fast: false @@ -194,7 +226,7 @@ jobs: publish: name: Publish release to Github and Pypi runs-on: ubuntu-latest - needs: [tests, release] + needs: [tests, build-release] if: success() && startsWith(github.ref, 'refs/tags/v') environment: name: pypi From b13bac6b3765143025aaac3cee96b18962a4487c Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 6 Nov 2025 12:36:46 +0100 Subject: [PATCH 14/23] :/ --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acb7b3d..36baf42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,7 +118,7 @@ jobs: python -c " try: import wgpu - raise RuntimError('wgpu could be imported') + raise RuntimeError('wgpu could be imported') except ImportError: print('wgpu is (intentionally) not available') " From b8fed82d674ffaa6b4b28dc34c449f19143dd3a9 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 6 Nov 2025 12:38:14 +0100 Subject: [PATCH 15/23] next step --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36baf42..105730e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,13 +112,14 @@ jobs: run: | python -m pip install --upgrade pip pip install .[tests] + pip remove wgpu rm -r rendercanvas - name: Unit tests run: | python -c " try: import wgpu - raise RuntimeError('wgpu could be imported') + raise RuntimeError('wgpu could be imported, but this is the no-wgpu test build') except ImportError: print('wgpu is (intentionally) not available') " From 26a8ed6cb0dca085ea299d2c5a7c241b3f14c598 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 6 Nov 2025 12:39:18 +0100 Subject: [PATCH 16/23] lets do in new pr --- .github/workflows/ci.yml | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 105730e..987f951 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,39 +92,6 @@ jobs: run: | pytest -v tests - test-without-wgpu: - name: ${{ matrix.name }} without wgpu - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - name: Test py313 - os: ubuntu-latest - pyversion: '3.13' - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.pyversion }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.pyversion }} - - name: Install package and dev dependencies - run: | - python -m pip install --upgrade pip - pip install .[tests] - pip remove wgpu - rm -r rendercanvas - - name: Unit tests - run: | - python -c " - try: - import wgpu - raise RuntimeError('wgpu could be imported, but this is the no-wgpu test build') - except ImportError: - print('wgpu is (intentionally) not available') - " - pytest -v tests - test-examples: name: Test examples ${{ matrix.pyversion }} runs-on: ${{ matrix.os }} From 64de680516b7dfe6afc1ce73be111f87b7a5ade0 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 6 Nov 2025 14:05:36 +0100 Subject: [PATCH 17/23] decide on terminology --- docs/contextapi.rst | 2 +- rendercanvas/base.py | 53 +++++++++++++------------- rendercanvas/contexts/basecontext.py | 5 +-- rendercanvas/contexts/bitmapcontext.py | 12 +++--- rendercanvas/contexts/wgpucontext.py | 10 ++--- tests/test_context.py | 4 +- 6 files changed, 42 insertions(+), 44 deletions(-) diff --git a/docs/contextapi.rst b/docs/contextapi.rst index 25be5ab..49b65f7 100644 --- a/docs/contextapi.rst +++ b/docs/contextapi.rst @@ -40,7 +40,7 @@ This texture is then downloaded to a bitmap on the CPU that can be passed to the └─────────┘ └────────┘ download to CPU -With the ``BitmapContextToWgpu`` context, the bitmap is uploaded to a GPU texture, +With the ``BitmapContextToScreen`` context, the bitmap is uploaded to a GPU texture, which is then rendered to screen using the lower-level canvas-context from ``wgpu``. .. code-block:: diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 7521c92..7802476 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -21,7 +21,7 @@ from ._coreutils import logger, log_exception if TYPE_CHECKING: - from typing import Callable, List, Optional, Tuple + from typing import Callable, List, Literal, Optional, Tuple EventHandlerFunction = Callable[[dict], None] DrawFunction = Callable[[], None] @@ -98,7 +98,8 @@ class BaseRenderCanvas: vsync (bool): Whether to sync the draw with the monitor update. Helps against screen tearing, but can reduce fps. Default True. present_method (str | None): Override the method to present the rendered result. - Can be set to e.g. 'screen' or 'bitmap'. Default None (auto-select). + Can be set to 'screen' or 'bitmap'. Default None, which means that the method is selected + based on what the canvas supports and what the context prefers. """ @@ -128,7 +129,7 @@ def __init__( min_fps: float = 0.0, max_fps: float = 30.0, vsync: bool = True, - present_method: Optional[str] = None, + present_method: Literal["bitmap", "screen", None] = None, **kwargs, ): # Initialize superclass. Note that super() can be e.g. a QWidget, RemoteFrameBuffer, or object. @@ -248,20 +249,15 @@ def get_context(self, context_type: str) -> contexts.BaseContext: if not isinstance(context_type, str): raise TypeError("context_type must be str.") - # todo: decide on 'screen' vs 'wgpu' - # The 'screen' and 'wgpu' method mean the same - present_method_map = context_type_map = {"screen": "wgpu"} - # Resolve the context type name - resolved_context_type = context_type_map.get(context_type, context_type) - if resolved_context_type not in ("bitmap", "wgpu"): + if context_type not in ("bitmap", "wgpu"): raise TypeError( "The given context type is invalid: {context_type!r} is not 'bitmap' or 'wgpu'." ) # Is the context already set? if self._canvas_context is not None: - if resolved_context_type == self._canvas_context._context_type: + if context_type == self._canvas_context._context_type: return self._canvas_context else: raise RuntimeError( @@ -270,17 +266,21 @@ def get_context(self, context_type: str) -> contexts.BaseContext: # Select present_method present_methods = self._rc_get_present_methods() + invalid_methods = set(present_methods.keys()) - {"screen", "bitmap"} + logger.warning( + f"{self.__class__.__name__} reports unknown present methods {invalid_methods!r}" + ) present_method = None - if resolved_context_type == "bitmap": - for name in ("bitmap", "wgpu", "screen"): - if name in present_methods: - present_method = name - break + if context_type == "bitmap": + if "bitmap" in present_methods: + present_method = "bitmap" + elif "screen" in present_methods: + present_method = "screen" else: - for name in ("wgpu", "screen", "bitmap"): - if name in present_methods: - present_method = name - break + if "screen" in present_methods: + present_method = "screen" + elif "bitmap" in present_methods: + present_method = "bitmap" # This should never happen, unless there's a bug if present_method is None: @@ -294,18 +294,18 @@ def get_context(self, context_type: str) -> contexts.BaseContext: "the field 'method' is reserved in present_methods dicts" ) present_info = { - "method": present_method_map.get(present_method, present_method), + "method": present_method, **present_info, } - if resolved_context_type == "bitmap": + if context_type == "bitmap": context = contexts.BitmapContext(present_info) else: context = contexts.WgpuContext(present_info) # Done self._canvas_context = context - self._canvas_context._context_type = resolved_context_type + self._canvas_context._context_type = context_type return self._canvas_context # %% Events @@ -457,7 +457,7 @@ def request_draw(self, draw_function: Optional[DrawFunction] = None) -> None: This function does not perform a draw directly, but schedules a draw at a suitable moment in time. At that time the draw function is called, and - the resulting rendered image is presented to screen. + the resulting rendered image is presented to the canvas. Only affects drawing with schedule-mode 'ondemand'. @@ -566,8 +566,7 @@ def get_pixel_ratio(self) -> float: The pixel ratio is typically 1.0 for normal screens and 2.0 for HiDPI screens, but fractional values are also possible if the system display-scale is set to e.g. 125%. An HiDPI screen can be assumed if the - pixel ratio >= 2.0. On MacOS (with a Retina screen) the pixel ratio is - always 2.0. + pixel ratio >= 2.0. """ return self.__size_info["total_pixel_ratio"] @@ -666,8 +665,8 @@ def _rc_get_present_methods(self): surface id. With method "bitmap", the context will present the result as an image - bitmap. On GPU-based contexts, the result will first be rendered to an - offscreen texture, and then downloaded to RAM. The sub-dict must have a + bitmap. For the `WgpuContext`, the result will first be rendered to texture, + and then downloaded to RAM. The sub-dict must have a field 'formats': a list of supported image formats. Examples are "rgba-u8" and "i-u8". A canvas must support at least "rgba-u8". Note that srgb mapping is assumed to be handled by the canvas. diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index 32e0197..fb9c3e4 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -8,7 +8,7 @@ class BaseContext: def __init__(self, present_info: dict): self._present_info = present_info - assert present_info["method"] in ("bitmap", "wgpu") # internal sanity check + assert present_info["method"] in ("bitmap", "screen") # internal sanity check self._size_info = { "physical_size": (0, 0), "native_pixel_ratio": 1.0, @@ -74,8 +74,7 @@ def pixel_ratio(self) -> float: The pixel ratio is typically 1.0 for normal screens and 2.0 for HiDPI screens, but fractional values are also possible if the system - display-scale is set to e.g. 125%. On MacOS (with a Retina screen) the - pixel ratio is always 2.0. + display-scale is set to e.g. 125%. """ return self._size_info["total_pixel_ratio"] diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index c867914..d9ac020 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -1,6 +1,6 @@ from .basecontext import BaseContext -__all__ = ["BitmapContext", "BitmapContextPlain", "BitmapContextToWgpu"] +__all__ = ["BitmapContext", "BitmapContextToBitmap", "BitmapContextToScreen"] class BitmapContext(BaseContext): @@ -20,9 +20,9 @@ def __new__(cls, present_info: dict): if cls is not BitmapContext: return super().__new__(cls) # Use canvas that is explicitly instantiated elif present_method == "bitmap": - return super().__new__(BitmapContextPlain) - elif present_method == "wgpu": - return super().__new__(BitmapContextToWgpu) + return super().__new__(BitmapContextToBitmap) + elif present_method == "screen": + return super().__new__(BitmapContextToScreen) else: raise TypeError("Unexpected present_method {present_method!r}") @@ -69,7 +69,7 @@ def set_bitmap(self, bitmap): self._bitmap_and_format = m, format -class BitmapContextPlain(BitmapContext): +class BitmapContextToBitmap(BitmapContext): """A BitmapContext that just presents the bitmap to the canvas.""" def __init__(self, present_info): @@ -103,7 +103,7 @@ def _rc_close(self): self._bitmap_and_format = None -class BitmapContextToWgpu(BitmapContext): +class BitmapContextToScreen(BitmapContext): """A BitmapContext that uploads to a texture and present that to a ``wgpu.GPUCanvasContext``. This is uses for canvases that do not support presenting a bitmap. diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index a6b0d81..d873981 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -5,7 +5,7 @@ # todo: wgpu should not be imported by default. Add a test for this! -__all__ = ["WgpuContext", "WgpuContextPlain", "WgpuContextToBitmap"] +__all__ = ["WgpuContext", "WgpuContextToBitmap", "WgpuContextToScreen"] class WgpuContext(BaseContext): @@ -22,8 +22,8 @@ def __new__(cls, present_info: dict): present_method = present_info["method"] if cls is not WgpuContext: return super().__new__(cls) # Use canvas that is explicitly instantiated - elif present_method == "wgpu": - return super().__new__(WgpuContextPlain) + elif present_method == "screen": + return super().__new__(WgpuContextToScreen) elif present_method == "bitmap": return super().__new__(WgpuContextToBitmap) else: @@ -134,7 +134,7 @@ def _rc_present(self) -> None: raise NotImplementedError() -class WgpuContextPlain(WgpuContext): +class WgpuContextToScreen(WgpuContext): """A wgpu context that present directly to a ``wgpu.GPUCanvasContext``. In most cases this means the image is rendered to a native OS surface, i.e. rendered to screen. @@ -143,7 +143,7 @@ class WgpuContextPlain(WgpuContext): def __init__(self, present_info: dict): super().__init__(present_info) - assert self._present_info["method"] == "wgpu" + assert self._present_info["method"] == "screen" self._create_wgpu_py_context() # sets self._wgpu_context def _get_preferred_format(self, adapter: object) -> str: diff --git a/tests/test_context.py b/tests/test_context.py index 9b9c715..17d1e58 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -3,7 +3,7 @@ BaseContext, BitmapContext, WgpuContext, - BitmapContextToWgpu, + BitmapContextToScreen, WgpuContextToBitmap, ) from rendercanvas.offscreen import OffscreenRenderCanvas as ManualOffscreenRenderCanvas @@ -53,7 +53,7 @@ def close(self): self._rc_close() -class BitmapContextToWgpuAndBackToBimap(BitmapContextToWgpu): +class BitmapContextToWgpuAndBackToBimap(BitmapContextToScreen): """A bitmap context that takes a detour via wgpu :)""" def _create_wgpu_py_context(self): From 7988acc4502ed753d2db6ffd84f0dd8961ded678 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 7 Nov 2025 13:28:40 +0100 Subject: [PATCH 18/23] Adjust to new wgpu --- rendercanvas/base.py | 13 ++++++++----- rendercanvas/contexts/basecontext.py | 10 ++++------ rendercanvas/contexts/bitmapcontext.py | 11 +---------- rendercanvas/contexts/wgpucontext.py | 11 +---------- 4 files changed, 14 insertions(+), 31 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 7802476..d14bc42 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -96,7 +96,7 @@ class BaseRenderCanvas: max_fps (float): A maximal frames-per-second to use when the ``update_mode`` is 'ondemand' or 'continuous'. The default is 30, which is usually enough. vsync (bool): Whether to sync the draw with the monitor update. Helps - against screen tearing, but can reduce fps. Default True. + against screen tearing, but limits the fps. Default True. present_method (str | None): Override the method to present the rendered result. Can be set to 'screen' or 'bitmap'. Default None, which means that the method is selected based on what the canvas supports and what the context prefers. @@ -267,9 +267,10 @@ def get_context(self, context_type: str) -> contexts.BaseContext: # Select present_method present_methods = self._rc_get_present_methods() invalid_methods = set(present_methods.keys()) - {"screen", "bitmap"} - logger.warning( - f"{self.__class__.__name__} reports unknown present methods {invalid_methods!r}" - ) + if invalid_methods: + logger.warning( + f"{self.__class__.__name__} reports unknown present methods {invalid_methods!r}" + ) present_method = None if context_type == "bitmap": if "bitmap" in present_methods: @@ -289,13 +290,15 @@ def get_context(self, context_type: str) -> contexts.BaseContext: ) # Select present_info - present_info = dict(present_methods[present_method]) + present_info = present_methods[present_method] assert "method" not in present_info, ( "the field 'method' is reserved in present_methods dicts" ) present_info = { "method": present_method, + "source": self.__class__.__name__, **present_info, + "vsync": self._vsync, } if context_type == "bitmap": diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index fb9c3e4..9d58784 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -24,17 +24,15 @@ def __repr__(self): def _create_wgpu_py_context(self) -> object: """Create a wgpu.GPUCanvasContext""" - # TODO: let's add/use hook in wgpu to get the context in a less hacky way import wgpu - backend_module = wgpu.gpu.__module__ - CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 - - if hasattr(CanvasContext, "set_physical_size"): + if hasattr(wgpu.gpu, "get_canvas_context"): # New style wgpu-py API - self._wgpu_context = CanvasContext(self._present_info) + self._wgpu_context = wgpu.gpu.get_canvas_context(self._present_info) else: # Old style wgpu-py API + backend_module = wgpu.gpu.__module__ + CanvasContext = sys.modules[backend_module].GPUCanvasContext # noqa: N806 self._object_with_physical_size = pseudo_canvas = PseudoCanvasForWgpuPy() self._wgpu_context = CanvasContext( pseudo_canvas, {"screen": self._present_info} diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index d9ac020..cdc9465 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -156,13 +156,4 @@ def _rc_present(self): def _rc_close(self): self._bitmap_and_format = None if self._wgpu_context is not None: - if hasattr(self._wgpu_context, "close"): - try: - self._wgpu_context.close() # TODO: make sure this is compatible - except Exception: - pass - else: - try: - self._wgpu_context._release() # private method - except Exception: - pass + self._wgpu_context.unconfigure() diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index d873981..c2e988c 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -164,16 +164,7 @@ def _rc_present(self) -> None: def _rc_close(self): if self._wgpu_context is not None: - if hasattr(self._wgpu_context, "close"): - try: - self._wgpu_context.close() # TODO: make sure this is compatible - except Exception: - pass - else: - try: - self._wgpu_context._release() # private method - except Exception: - pass + self._wgpu_context.unconfigure() class WgpuContextToBitmap(WgpuContext): From 4e09ba82efcdb30dfddab66343af7d675721d37c Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 7 Nov 2025 13:36:53 +0100 Subject: [PATCH 19/23] fix merge gone wrong --- docs/start.rst | 45 ++++++++++++++++++++++++++++ rendercanvas/contexts/wgpucontext.py | 2 -- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/start.rst b/docs/start.rst index 64ee429..ca4d73d 100644 --- a/docs/start.rst +++ b/docs/start.rst @@ -70,6 +70,51 @@ Rendering with wgpu: # ... wgpu code +Physical size, logical size, and pixel-ratio +-------------------------------------------- + +The context has properties for the logical size, physical size, and the +pixel-ratio. + +* The physical size represent the actual number of "harware pixels" of the canvas surface. +* The logical size represents the size in "virtual pixels", which is used to scale elements like text, points, line thickness etc. +* The pixel-ratio represents the factor between the physical size and the logical size. + +On regular screens, the physical size and logical size are often equal: + +.. code-block:: + +----+----+----+----+ + | | | | | Physical pixels + +----+----+----+----+ + +----+----+ + | | | Logical pixels, pixel-ratio 1.0 + +----+----+ +On HiDPI / Retina displays, there are many more pixels, but they are much smaller. To prevent things like text to become tiny, +the logical pixels are made larger, i.e. pixel-ratio is increased (by the operating system), usually by a factor 2: + +.. code-block:: + +--+--+--+--+ + | | | | | Physical pixels + +--+--+--+--+ + +-----+-----+ + | | | Logical pixels, pixel-ratio 2.0 + +-----+-----+ +Other operating system may increase the pixel-ratio as a global zoom factor, to increase the size of elements such as text in all applications. +This means that the pixel-ratio can indeed be fractional: + +.. code-block:: + +----+----+----+----+ + | | | | | Physical pixels + +----+----+----+----+ + +-----+-----+ + | | | Logical pixels, pixel-ratio ± 1.2 + +-----+-----+ +Side note: on MacOS, the pixel-ratio is fixed to either 1.0 or 2.0, usually the latter on a Retina display with reasonable resolution settings. (The OS level +zooming is implemented by rendering the whole screen to an offscreen buffer with +a different size than the physical screen, and then up/down-scaling that to the +screen.) + + .. _async: Async diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index c2e988c..a27b02f 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -2,8 +2,6 @@ from .basecontext import BaseContext -# todo: wgpu should not be imported by default. Add a test for this! - __all__ = ["WgpuContext", "WgpuContextToBitmap", "WgpuContextToScreen"] From cf1ca9b65fd4dda3a60dd89ddaeb1edc7584ff39 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 7 Nov 2025 13:56:33 +0100 Subject: [PATCH 20/23] self-review --- rendercanvas/base.py | 8 +++++--- rendercanvas/contexts/basecontext.py | 21 ++++++++++++--------- rendercanvas/contexts/bitmapcontext.py | 5 ++++- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index d14bc42..45d6718 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -229,7 +229,9 @@ def get_wgpu_context(self) -> contexts.WgpuContext: """Get the ``WgpuContext`` to render to this canvas.""" return self.get_context("wgpu") - def get_context(self, context_type: str) -> contexts.BaseContext: + def get_context( + self, context_type: Literal["bitmap", "wgpu"] + ) -> contexts.BaseContext: """Get a context object that can be used to render to this canvas. The context takes care of presenting the rendered result to the canvas. @@ -541,7 +543,7 @@ def _draw_frame_and_present(self): if context: result = context._rc_present() method = result.pop("method") - if method in ("skip", "screen", "delegated"): + if method in ("skip", "screen"): pass # nothing we need to do elif method == "fail": raise RuntimeError(result.get("message", "") or "present error") @@ -665,7 +667,7 @@ def _rc_get_present_methods(self): 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. + surface id. For Pyodide the required info is different. With method "bitmap", the context will present the result as an image bitmap. For the `WgpuContext`, the result will first be rendered to texture, diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index 9d58784..0347e59 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -4,7 +4,15 @@ class BaseContext: - """The base class for context objects in ``rendercanvas``.""" + """The base class for context objects in ``rendercanvas``. + + All contexts provide detailed size information. A rendering system should + generally be capable to perform the rendering with just the context object; + without a reference to the canvas. With this, we try to promote a clear + separation of concern, where one system listens to events from the canvas to + update a certain state, and the renderer uses this state and the context to + render the image. + """ def __init__(self, present_info: dict): self._present_info = present_info @@ -89,24 +97,19 @@ def _rc_present(self): """Called by BaseRenderCanvas to collect the result. Subclasses must implement this. The implementation should always return a present-result dict, which - should have at least a field 'method'. The value of 'method' must be - one of the methods that the canvas supports, i.e. it must be in ``present_methods``. + should have at least a field 'method'. * If there is nothing to present, e.g. because nothing was rendered yet: * return ``{"method": "skip"}`` (special case). * If presentation could not be done for some reason: * return ``{"method": "fail", "message": "xx"}`` (special case). * If ``present_method`` is "screen": - * Render to screen using the info in ``present_methods['screen']``). + * Render to screen using the present info. * Return ``{"method", "screen"}`` as confirmation. * If ``present_method`` is "bitmap": * Return ``{"method": "bitmap", "data": data, "format": format}``. * 'data' is a memoryview, or something that can be converted to a memoryview, like a numpy array. - * 'format' is the format of the bitmap, must be in ``present_methods['bitmap']['formats']`` ("rgba-u8" is always supported). - * If ``present_method`` is something else: - * Return ``{"method": "xx", ...}``. - * It's the responsibility of the context to use a render method that is supported by the canvas, - and that the appropriate arguments are supplied. + * 'format' is the format of the bitmap, must be in ``present_info['formats']`` ("rgba-u8" is always supported). """ # This is a stub diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index cdc9465..868d67f 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -149,8 +149,11 @@ def _rc_present(self): present_feedback = self._wgpu_context.present() + # We actually allow the _wgpu_context to return present_feedback, because we have a test in which + # we mimick a GPUCanvasContext with a WgpuContextToBitmap to cover a full round-trip to wgpu. if present_feedback is None: - present_feedback = {"method": "delegated"} + present_feedback = {"method": "screen"} + return present_feedback def _rc_close(self): From 2560ff0605e05577cfe0fc74bc56e30a4fd2b0da Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 7 Nov 2025 14:07:02 +0100 Subject: [PATCH 21/23] fix formatting in docs --- docs/start.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/start.rst b/docs/start.rst index ca4d73d..7c233bc 100644 --- a/docs/start.rst +++ b/docs/start.rst @@ -83,32 +83,38 @@ pixel-ratio. On regular screens, the physical size and logical size are often equal: .. code-block:: + +----+----+----+----+ | | | | | Physical pixels +----+----+----+----+ +----+----+ | | | Logical pixels, pixel-ratio 1.0 +----+----+ + On HiDPI / Retina displays, there are many more pixels, but they are much smaller. To prevent things like text to become tiny, the logical pixels are made larger, i.e. pixel-ratio is increased (by the operating system), usually by a factor 2: .. code-block:: + +--+--+--+--+ | | | | | Physical pixels +--+--+--+--+ +-----+-----+ | | | Logical pixels, pixel-ratio 2.0 +-----+-----+ + Other operating system may increase the pixel-ratio as a global zoom factor, to increase the size of elements such as text in all applications. This means that the pixel-ratio can indeed be fractional: .. code-block:: + +----+----+----+----+ | | | | | Physical pixels +----+----+----+----+ +-----+-----+ | | | Logical pixels, pixel-ratio ± 1.2 +-----+-----+ + Side note: on MacOS, the pixel-ratio is fixed to either 1.0 or 2.0, usually the latter on a Retina display with reasonable resolution settings. (The OS level zooming is implemented by rendering the whole screen to an offscreen buffer with a different size than the physical screen, and then up/down-scaling that to the From d501006cc7f37ad649bfa093d1f4d5a5bffb6b50 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 10 Nov 2025 10:11:33 +0100 Subject: [PATCH 22/23] Update for jupyter backend --- rendercanvas/base.py | 7 +++++-- rendercanvas/pyodide.py | 3 +-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 4c19fdd..1de7188 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -270,13 +270,16 @@ def get_context( f"Cannot get context for '{context_type}': a context of type '{self._canvas_context._context_type}' is already set." ) - # Select present_method + # Get available present methods. + # Take care not to hold onto this dict, it may contain objects that we don't want to unnecessarily reference. present_methods = self._rc_get_present_methods() invalid_methods = set(present_methods.keys()) - {"screen", "bitmap"} if invalid_methods: logger.warning( f"{self.__class__.__name__} reports unknown present methods {invalid_methods!r}" ) + + # Select present_method present_method = None if context_type == "bitmap": if "bitmap" in present_methods: @@ -295,7 +298,7 @@ def get_context( "Could not select present_method for context_type {context_type!r} from present_methods {present_methods!r}" ) - # Select present_info + # Select present_info, and shape it into what the contexts need. present_info = present_methods[present_method] assert "method" not in present_info, ( "the field 'method' is reserved in present_methods dicts" diff --git a/rendercanvas/pyodide.py b/rendercanvas/pyodide.py index 928a80c..08fd710 100644 --- a/rendercanvas/pyodide.py +++ b/rendercanvas/pyodide.py @@ -433,10 +433,9 @@ def _rc_get_present_methods(self): "formats": ["rgba-u8"], }, # wgpu-specific presentation. The wgpu.backends.pyodide.GPUCanvasContext must be able to consume this. - # Most importantly, it will need to access the gpu context. I want to avoid storing a heavy object in this dict, so let's just store the name of the attribute. "screen": { "platform": "browser", - "native_canvas_attribute": "_canvas_element", + "window": self._canvas_element, # Just provide the canvas object }, } From 8dd35971bf04699a786b39dad7f6b6356adaff0c Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 10 Nov 2025 11:12:22 +0100 Subject: [PATCH 23/23] New _version --- rendercanvas/_version.py | 46 ++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/rendercanvas/_version.py b/rendercanvas/_version.py index 238c676..a46438c 100644 --- a/rendercanvas/_version.py +++ b/rendercanvas/_version.py @@ -50,28 +50,34 @@ def get_extended_version() -> str: # Sample first 3 parts of __version__ base_release = ".".join(__version__.split(".")[:3]) - # Check release - if not release: - release = base_release - elif release != base_release: - warning( - f"{project_name} version from git ({release})" - f" and __version__ ({base_release}) don't match." - ) - - # Build the total version - version = release + # Start version string (__version__ string is leading) + version = base_release + tag_prefix = "#" + + if release and release != base_release: + # Can happen between bumping and tagging. And also when merging a + # version bump into a working branch, because we use --first-parent. + release2, _post, _labels = get_version_info_from_git(first_parent=False) + if release2 != base_release: + warning( + f"{project_name} version from git ({release})" + f" and __version__ ({base_release}) don't match." + ) + version += "+from_tag_" + release.replace(".", "_") + tag_prefix = "." + + # Add git info if post and post != "0": version += f".post{post}" if labels: - version += "+" + ".".join(labels) + version += tag_prefix + ".".join(labels) elif labels and labels[-1] == "dirty": - version += "+" + ".".join(labels) + version += tag_prefix + ".".join(labels) return version -def get_version_info_from_git() -> str: +def get_version_info_from_git(*, first_parent: bool = True) -> str: """ Get (release, post, labels) from Git. @@ -80,15 +86,9 @@ def get_version_info_from_git() -> str: git-hash and optionally a dirty flag. """ # Call out to Git - command = [ - "git", - "describe", - "--long", - "--always", - "--tags", - "--dirty", - "--first-parent", - ] + command = ["git", "describe", "--long", "--always", "--tags", "--dirty"] + if first_parent: + command.append("--first-parent") try: p = subprocess.run(command, check=False, cwd=repo_dir, capture_output=True) except Exception as e: