From 519db119c6b6ae36134eeb880d80965c5e137a7c Mon Sep 17 00:00:00 2001 From: Christopher Lackner Date: Wed, 3 Jun 2026 16:35:38 +0200 Subject: [PATCH] pack all renders in one render pass (~20% render red per frame, grows with #renderes) --- webgpu/renderer.py | 36 +++++++++++++++++++++++++++++------- webgpu/scene.py | 18 ++++++++++++------ webgpu/shapes.py | 21 ++++++++++----------- 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/webgpu/renderer.py b/webgpu/renderer.py index 42134e7..1a396c6 100644 --- a/webgpu/renderer.py +++ b/webgpu/renderer.py @@ -1,3 +1,4 @@ +import contextlib import itertools from typing import Callable @@ -73,12 +74,14 @@ class RenderOptions: command_encoder: CommandEncoder timestamp: float model_view_proj: object # numpy array, updated by update_buffers + render_pass: object = None def __init__(self, camera: Camera, light: Light): self.light = light self.camera = camera self._camera_uniforms = None self.model_view_proj = None + self.render_pass = None self._extra_binding_providers = [] def __getstate__(self): @@ -89,6 +92,7 @@ def __setstate__(self, state): self.light = state["light"] self._camera_uniforms = None self.model_view_proj = None + self.render_pass = None self._extra_binding_providers = [] def add_bindings(self, provider): @@ -138,6 +142,25 @@ def begin_render_pass(self, **kwargs): return render_pass_encoder + @contextlib.contextmanager + def render_pass_scope(self): + """Yield the render pass a renderer should record into. + + When the Scene has opened a shared frame pass (``self.render_pass`` is + set), reuse it and do NOT end it here — the Scene ends it once, after + all objects have been recorded, so the MSAA target is resolved a single + time per frame. When no shared pass is active (e.g. a renderer rendered + in isolation), open and close a private pass for backwards behaviour. + """ + if self.render_pass is not None: + yield self.render_pass + else: + render_pass = self.begin_render_pass() + try: + yield render_pass + finally: + render_pass.end() + def begin_select_pass(self, x, y, **kwargs): load_op = self.command_encoder.getLoadOp() @@ -500,13 +523,12 @@ def create_render_pipeline(self, options: RenderOptions) -> None: self._last_transparent = self.transparent def render(self, options: RenderOptions) -> None: - render_pass = options.begin_render_pass() - render_pass.setPipeline(self.pipeline) - render_pass.setBindGroup(0, self.group) - for i, vertex_buffer in enumerate(self.vertex_buffers): - render_pass.setVertexBuffer(i, vertex_buffer) - render_pass.draw(self.n_vertices, self.n_instances) - render_pass.end() + with options.render_pass_scope() as render_pass: + render_pass.setPipeline(self.pipeline) + render_pass.setBindGroup(0, self.group) + for i, vertex_buffer in enumerate(self.vertex_buffers): + render_pass.setVertexBuffer(i, vertex_buffer) + render_pass.draw(self.n_vertices, self.n_instances) def render_opaque(self, options: RenderOptions) -> None: self.render(options) diff --git a/webgpu/scene.py b/webgpu/scene.py index 4dbe37f..610305d 100644 --- a/webgpu/scene.py +++ b/webgpu/scene.py @@ -425,12 +425,18 @@ def _render_objects(self, to_canvas=True, update_pipelines=True): print("warning: object still needs update after update was done:", obj) options.command_encoder = self.device.createCommandEncoder() - for obj in self.render_objects: - if obj.active: - obj.render_opaque(options) - for obj in self.render_objects: - if obj.active: - obj.render_transparent(options) + render_pass = options.begin_render_pass() + options.render_pass = render_pass + try: + for obj in self.render_objects: + if obj.active: + obj.render_opaque(options) + for obj in self.render_objects: + if obj.active: + obj.render_transparent(options) + finally: + render_pass.end() + options.render_pass = None if to_canvas: target_texture = self.canvas.target_texture diff --git a/webgpu/shapes.py b/webgpu/shapes.py index 40f2d3a..0ce4bab 100644 --- a/webgpu/shapes.py +++ b/webgpu/shapes.py @@ -486,17 +486,16 @@ def get_shader_code(self) -> str: return read_shader_file("shapes.wgsl") def render(self, options: RenderOptions) -> None: - render_pass = options.begin_render_pass() - render_pass.setPipeline(self.pipeline) - render_pass.setBindGroup(0, self.group) - for i, vertex_buffer in enumerate(self.vertex_buffers): - render_pass.setVertexBuffer(i, vertex_buffer) - render_pass.setIndexBuffer(self.triangle_buffer, IndexFormat.uint32) - render_pass.drawIndexed( - self.n_vertices, - self.n_instances, - ) - render_pass.end() + with options.render_pass_scope() as render_pass: + render_pass.setPipeline(self.pipeline) + render_pass.setBindGroup(0, self.group) + for i, vertex_buffer in enumerate(self.vertex_buffers): + render_pass.setVertexBuffer(i, vertex_buffer) + render_pass.setIndexBuffer(self.triangle_buffer, IndexFormat.uint32) + render_pass.drawIndexed( + self.n_vertices, + self.n_instances, + ) def select(self, options: RenderOptions, x, y) -> None: render_pass = options.begin_select_pass(x, y)