From ff7473061dafad21b30688b756bdc11bc9b82abb Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 28 Oct 2023 20:09:40 +0200 Subject: [PATCH 1/9] Add offscreen support --- wgpu/utils/shadertoy.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/wgpu/utils/shadertoy.py b/wgpu/utils/shadertoy.py index 849c55b3..3708a2a4 100644 --- a/wgpu/utils/shadertoy.py +++ b/wgpu/utils/shadertoy.py @@ -3,7 +3,7 @@ import wgpu from wgpu.gui.auto import WgpuCanvas, run - +from wgpu.gui.offscreen import WgpuCanvas as OffscreenCanvas, run as run_offscreen vertex_code_glsl = """ #version 450 core @@ -222,6 +222,7 @@ class Shadertoy: Parameters: shader_code (str): The shader code to use. resolution (tuple): The resolution of the shadertoy. + offscreen (bool): (Optional) Whether to render offscreen. Default is False. The shader code must contain a entry point function: @@ -247,7 +248,7 @@ class Shadertoy: # todo: support input textures # todo: support multiple render passes (`i_channel0`, `i_channel1`, etc.) - def __init__(self, shader_code, resolution=(800, 450)) -> None: + def __init__(self, shader_code, resolution=(800, 450), offscreen=False) -> None: self._uniform_data = UniformArray( ("mouse", "f", 4), ("resolution", "f", 3), @@ -258,6 +259,8 @@ def __init__(self, shader_code, resolution=(800, 450)) -> None: self._shader_code = shader_code self._uniform_data["resolution"] = resolution + (1,) + + self._offscreen = offscreen self._prepare_render() self._bind_events() @@ -288,7 +291,10 @@ def shader_type(self): def _prepare_render(self): import wgpu.backends.rs # noqa - self._canvas = WgpuCanvas(title="Shadertoy", size=self.resolution, max_fps=60) + if self._offscreen: + self._canvas = OffscreenCanvas(title = "Shadertoy", size = self.resolution, max_fps = 60) + else: + self._canvas = WgpuCanvas(title="Shadertoy", size=self.resolution, max_fps=60) adapter = wgpu.request_adapter( canvas=self._canvas, power_preference="high-performance" @@ -463,7 +469,10 @@ def _draw_frame(self): def show(self): self._canvas.request_draw(self._draw_frame) - run() + if self._offscreen: + run_offscreen() + else: + run() if __name__ == "__main__": From 43c4f1c53023811f9472b8013dfb95833aaf6740 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 28 Oct 2023 20:53:47 +0200 Subject: [PATCH 2/9] Add snapshot method --- wgpu/utils/shadertoy.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/wgpu/utils/shadertoy.py b/wgpu/utils/shadertoy.py index 3708a2a4..2f7f518e 100644 --- a/wgpu/utils/shadertoy.py +++ b/wgpu/utils/shadertoy.py @@ -1,5 +1,7 @@ import time import ctypes +import numpy as np +from PIL import Image import wgpu from wgpu.gui.auto import WgpuCanvas, run @@ -259,7 +261,7 @@ def __init__(self, shader_code, resolution=(800, 450), offscreen=False) -> None: self._shader_code = shader_code self._uniform_data["resolution"] = resolution + (1,) - + self._offscreen = offscreen self._prepare_render() @@ -474,6 +476,28 @@ def show(self): else: run() + def snapshot(self, time_float: float = 0.0, mouse_pos: tuple = (0, 0, 0, 0)): + """ + Returns a PIL Image of the specified time. (Only available when ``offscreen=True``) + + Parameters: + time_float (float): Defaults to 0.0, The time to snapshot. It essentially sets ``i_time`` to a specific number. + mouse_pos (tuple): Defaults to (0,0,0,0), The mouse position in pixels in the snapshot. It essentially sets ``i_mouse`` to a 4-tuple. + Returns: + Image (PIL.Image): snapshot with transparancy removed. + """ + if not self._offscreen: + raise NotImplementedError("Snapshot is only available in offscreen mode.") + + if hasattr(self, "_last_time"): + self.__delattr__("_last_time") + self._uniform_data["time"] = time_float + self._uniform_data["mouse"] = mouse_pos + self._canvas.request_draw(self._draw_frame) + frame = np.asarray(self._canvas.draw()) + img = Image.fromarray(frame).convert("RGB") + return img + if __name__ == "__main__": shader = Shadertoy( From 9e29247a1f36872d79c642e255ba8559244d799b Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 28 Oct 2023 21:52:46 +0200 Subject: [PATCH 3/9] Add shadercode validation for glsl --- wgpu/utils/shadertoy.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/wgpu/utils/shadertoy.py b/wgpu/utils/shadertoy.py index 2f7f518e..ef487b84 100644 --- a/wgpu/utils/shadertoy.py +++ b/wgpu/utils/shadertoy.py @@ -2,10 +2,13 @@ import ctypes import numpy as np from PIL import Image +import tempfile +import subprocess import wgpu from wgpu.gui.auto import WgpuCanvas, run from wgpu.gui.offscreen import WgpuCanvas as OffscreenCanvas, run as run_offscreen +from wgpu.base import GPUValidationError vertex_code_glsl = """ #version 450 core @@ -321,6 +324,8 @@ def _prepare_render(self): frag_shader_code = ( builtin_variables_wgsl + self.shader_code + fragment_code_wgsl ) + + self._validate_shadercode(frag_shader_code) vertex_shader_program = self._device.create_shader_module( label="triangle_vert", code=vertex_shader_code @@ -498,6 +503,32 @@ def snapshot(self, time_float: float = 0.0, mouse_pos: tuple = (0, 0, 0, 0)): img = Image.fromarray(frame).convert("RGB") return img + def _validate_shadercode(self, frag_shader_code): + """ Check if there are any errors in the shadercode with naga to avoid a panic that crashes the python process + + Parameters: + frag_shader_code (str): assemlbed shadercode glsl to be validated + Returns: + None + """ + if self.shader_type != "glsl": + # wgsl shaders are validated correct already + return + + with tempfile.NamedTemporaryFile(suffix=".frag", mode="w", encoding="utf-8") as f, tempfile.NamedTemporaryFile(suffix=".spv", mode="w+b") as f2: + f.write(frag_shader_code) + f.flush() + f2.flush() + # first try validation with naga (this catches syntax errors for example) + try: + subprocess.run(["naga", f.name], check=True, capture_output=True, timeout=2) + except subprocess.SubprocessError as e: + raise GPUValidationError(e.stderr.decode("utf-8")) + # translate to spir-v to check if wgpu will panic otherwise. + try: + subprocess.run(["naga", f.name, f2.name], check=True, capture_output=True, timeout=2) + except subprocess.SubprocessError as e: + raise ValueError("Shadercode invalid (could be wgpu)") if __name__ == "__main__": shader = Shadertoy( @@ -515,4 +546,4 @@ def snapshot(self, time_float: float = 0.0, mouse_pos: tuple = (0, 0, 0, 0)): """ ) - shader.show() + shader.show() \ No newline at end of file From 282c58c404aa8bed2e7e602259b7b8d19ad707be Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 30 Oct 2023 16:11:02 +0100 Subject: [PATCH 4/9] Remove unneeded dependencies --- wgpu/utils/shadertoy.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/wgpu/utils/shadertoy.py b/wgpu/utils/shadertoy.py index ef487b84..8e2872f9 100644 --- a/wgpu/utils/shadertoy.py +++ b/wgpu/utils/shadertoy.py @@ -1,7 +1,5 @@ import time import ctypes -import numpy as np -from PIL import Image import tempfile import subprocess @@ -489,7 +487,8 @@ def snapshot(self, time_float: float = 0.0, mouse_pos: tuple = (0, 0, 0, 0)): time_float (float): Defaults to 0.0, The time to snapshot. It essentially sets ``i_time`` to a specific number. mouse_pos (tuple): Defaults to (0,0,0,0), The mouse position in pixels in the snapshot. It essentially sets ``i_mouse`` to a 4-tuple. Returns: - Image (PIL.Image): snapshot with transparancy removed. + frame (memoryview): snapshot with transparancy. This object can be converted to a numpy array (without copying data) + using ``np.asarray(arr)`` """ if not self._offscreen: raise NotImplementedError("Snapshot is only available in offscreen mode.") @@ -499,9 +498,8 @@ def snapshot(self, time_float: float = 0.0, mouse_pos: tuple = (0, 0, 0, 0)): self._uniform_data["time"] = time_float self._uniform_data["mouse"] = mouse_pos self._canvas.request_draw(self._draw_frame) - frame = np.asarray(self._canvas.draw()) - img = Image.fromarray(frame).convert("RGB") - return img + frame = self._canvas.draw() + return frame def _validate_shadercode(self, frag_shader_code): """ Check if there are any errors in the shadercode with naga to avoid a panic that crashes the python process From 2cc0600f01ce1d1f02292cf0c8fe8e3b1751bf7d Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 5 Nov 2023 02:29:06 +0100 Subject: [PATCH 5/9] Add additional validation with wgsl translation --- wgpu/utils/shadertoy.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/wgpu/utils/shadertoy.py b/wgpu/utils/shadertoy.py index 8e2872f9..ce46cc1e 100644 --- a/wgpu/utils/shadertoy.py +++ b/wgpu/utils/shadertoy.py @@ -513,20 +513,28 @@ def _validate_shadercode(self, frag_shader_code): # wgsl shaders are validated correct already return - with tempfile.NamedTemporaryFile(suffix=".frag", mode="w", encoding="utf-8") as f, tempfile.NamedTemporaryFile(suffix=".spv", mode="w+b") as f2: + with tempfile.NamedTemporaryFile(suffix=".frag", mode="w", encoding="utf-8") as f, \ + tempfile.NamedTemporaryFile(suffix=".spv", mode="w+b") as f2, \ + tempfile.NamedTemporaryFile(suffix=".wgsl", mode="w+b") as f3: f.write(frag_shader_code) f.flush() f2.flush() + f3.flush() # first try validation with naga (this catches syntax errors for example) try: - subprocess.run(["naga", f.name], check=True, capture_output=True, timeout=2) + subprocess.run(["naga", f.name], check=True, capture_output=True, timeout=3) except subprocess.SubprocessError as e: raise GPUValidationError(e.stderr.decode("utf-8")) # translate to spir-v to check if wgpu will panic otherwise. try: - subprocess.run(["naga", f.name, f2.name], check=True, capture_output=True, timeout=2) + subprocess.run(["naga", f.name, f2.name], check=True, capture_output=True, timeout=3) except subprocess.SubprocessError as e: - raise ValueError("Shadercode invalid (could be wgpu)") + raise ValueError("SPIR-V translation failed") + # translate to wgsl and see if a "fall-through switch case block" is returned??? + try: + rcode = subprocess.run(["naga", f.name, f3.name], check=True, capture_output=True, timeout=3) + except subprocess.SubprocessError as e: + raise ValueError("WGSL translation failed") if __name__ == "__main__": shader = Shadertoy( From 188569201841ce9bf5b7c71a435a7669f777398f Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 5 Nov 2023 02:48:20 +0100 Subject: [PATCH 6/9] Add tests for offscreen and snapshot --- tests/test_util_shadertoy.py | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_util_shadertoy.py b/tests/test_util_shadertoy.py index d1372f93..e0657863 100644 --- a/tests/test_util_shadertoy.py +++ b/tests/test_util_shadertoy.py @@ -66,3 +66,56 @@ def test_shadertoy_glsl(): assert shader.shader_type == "glsl" shader._draw_frame() + +def test_shadertoy_offscreen(): + # Import here, because it imports the wgpu.gui.auto + from wgpu.utils.shadertoy import Shadertoy # noqa + + shader_code = """ + void shader_main(out vec4 fragColor, vec2 frag_coord) { + vec2 uv = frag_coord / i_resolution.xy; + + if ( length(frag_coord - i_mouse.xy) < 20.0 ) { + fragColor = vec4(0.0, 0.0, 0.0, 1.0); + }else{ + fragColor = vec4( 0.5 + 0.5 * sin(i_time * vec3(uv, 1.0) ), 1.0); + } + + } + """ + + shader = Shadertoy(shader_code, resolution=(800, 450), offscreen=True) + assert shader.resolution == (800, 450) + assert shader.shader_code == shader_code + assert shader.shader_type == "glsl" + assert shader._offscreen is True + +def test_shadertoy_snapshot(): + # Import here, because it imports the wgpu.gui.auto + from wgpu.utils.shadertoy import Shadertoy # noqa + + shader_code = """ + void shader_main(out vec4 fragColor, vec2 frag_coord) { + vec2 uv = frag_coord / i_resolution.xy; + + if ( length(frag_coord - i_mouse.xy) < 20.0 ) { + fragColor = vec4(0.0, 0.0, 0.0, 1.0); + }else{ + fragColor = vec4( 0.5 + 0.5 * sin(i_time * vec3(uv, 1.0) ), 1.0); + } + + } + """ + + shader = Shadertoy(shader_code, resolution=(800, 450), offscreen=True) + frame1a = shader.snapshot(time_float=0.0, mouse_pos=(0, 0, 0, 0,)) + frame2a = shader.snapshot(time_float=1.2, mouse_pos=(100, 200, 0, 0,)) + frame1b = shader.snapshot(time_float=0.0, mouse_pos=(0, 0, 0, 0,)) + frame2b = shader.snapshot(time_float=1.2, mouse_pos=(100, 200, 0, 0,)) + + assert shader.resolution == (800, 450) + assert shader.shader_code == shader_code + assert shader.shader_type == "glsl" + assert shader._offscreen is True + assert frame1a == frame1b + assert frame2a == frame2b \ No newline at end of file From e242dff5ee1a1b8892feaf04d5dd0205655d592b Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 6 Nov 2023 16:33:36 +0100 Subject: [PATCH 7/9] Revert external validation --- wgpu/utils/shadertoy.py | 41 +---------------------------------------- 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/wgpu/utils/shadertoy.py b/wgpu/utils/shadertoy.py index ce46cc1e..d14f927c 100644 --- a/wgpu/utils/shadertoy.py +++ b/wgpu/utils/shadertoy.py @@ -1,12 +1,9 @@ import time import ctypes -import tempfile -import subprocess import wgpu from wgpu.gui.auto import WgpuCanvas, run from wgpu.gui.offscreen import WgpuCanvas as OffscreenCanvas, run as run_offscreen -from wgpu.base import GPUValidationError vertex_code_glsl = """ #version 450 core @@ -322,8 +319,6 @@ def _prepare_render(self): frag_shader_code = ( builtin_variables_wgsl + self.shader_code + fragment_code_wgsl ) - - self._validate_shadercode(frag_shader_code) vertex_shader_program = self._device.create_shader_module( label="triangle_vert", code=vertex_shader_code @@ -501,40 +496,6 @@ def snapshot(self, time_float: float = 0.0, mouse_pos: tuple = (0, 0, 0, 0)): frame = self._canvas.draw() return frame - def _validate_shadercode(self, frag_shader_code): - """ Check if there are any errors in the shadercode with naga to avoid a panic that crashes the python process - - Parameters: - frag_shader_code (str): assemlbed shadercode glsl to be validated - Returns: - None - """ - if self.shader_type != "glsl": - # wgsl shaders are validated correct already - return - - with tempfile.NamedTemporaryFile(suffix=".frag", mode="w", encoding="utf-8") as f, \ - tempfile.NamedTemporaryFile(suffix=".spv", mode="w+b") as f2, \ - tempfile.NamedTemporaryFile(suffix=".wgsl", mode="w+b") as f3: - f.write(frag_shader_code) - f.flush() - f2.flush() - f3.flush() - # first try validation with naga (this catches syntax errors for example) - try: - subprocess.run(["naga", f.name], check=True, capture_output=True, timeout=3) - except subprocess.SubprocessError as e: - raise GPUValidationError(e.stderr.decode("utf-8")) - # translate to spir-v to check if wgpu will panic otherwise. - try: - subprocess.run(["naga", f.name, f2.name], check=True, capture_output=True, timeout=3) - except subprocess.SubprocessError as e: - raise ValueError("SPIR-V translation failed") - # translate to wgsl and see if a "fall-through switch case block" is returned??? - try: - rcode = subprocess.run(["naga", f.name, f3.name], check=True, capture_output=True, timeout=3) - except subprocess.SubprocessError as e: - raise ValueError("WGSL translation failed") if __name__ == "__main__": shader = Shadertoy( @@ -552,4 +513,4 @@ def _validate_shadercode(self, frag_shader_code): """ ) - shader.show() \ No newline at end of file + shader.show() From 1d986a23d09ec5a04dbd131f31b69aceffcc1814 Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 6 Nov 2023 16:48:38 +0100 Subject: [PATCH 8/9] Fix style --- tests/test_util_shadertoy.py | 44 ++++++++++++++++++++++++++++++++---- wgpu/utils/shadertoy.py | 16 ++++++++----- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/tests/test_util_shadertoy.py b/tests/test_util_shadertoy.py index e0657863..7429d484 100644 --- a/tests/test_util_shadertoy.py +++ b/tests/test_util_shadertoy.py @@ -67,6 +67,7 @@ def test_shadertoy_glsl(): shader._draw_frame() + def test_shadertoy_offscreen(): # Import here, because it imports the wgpu.gui.auto from wgpu.utils.shadertoy import Shadertoy # noqa @@ -90,6 +91,7 @@ def test_shadertoy_offscreen(): assert shader.shader_type == "glsl" assert shader._offscreen is True + def test_shadertoy_snapshot(): # Import here, because it imports the wgpu.gui.auto from wgpu.utils.shadertoy import Shadertoy # noqa @@ -108,14 +110,46 @@ def test_shadertoy_snapshot(): """ shader = Shadertoy(shader_code, resolution=(800, 450), offscreen=True) - frame1a = shader.snapshot(time_float=0.0, mouse_pos=(0, 0, 0, 0,)) - frame2a = shader.snapshot(time_float=1.2, mouse_pos=(100, 200, 0, 0,)) - frame1b = shader.snapshot(time_float=0.0, mouse_pos=(0, 0, 0, 0,)) - frame2b = shader.snapshot(time_float=1.2, mouse_pos=(100, 200, 0, 0,)) + frame1a = shader.snapshot( + time_float=0.0, + mouse_pos=( + 0, + 0, + 0, + 0, + ), + ) + frame2a = shader.snapshot( + time_float=1.2, + mouse_pos=( + 100, + 200, + 0, + 0, + ), + ) + frame1b = shader.snapshot( + time_float=0.0, + mouse_pos=( + 0, + 0, + 0, + 0, + ), + ) + frame2b = shader.snapshot( + time_float=1.2, + mouse_pos=( + 100, + 200, + 0, + 0, + ), + ) assert shader.resolution == (800, 450) assert shader.shader_code == shader_code assert shader.shader_type == "glsl" assert shader._offscreen is True assert frame1a == frame1b - assert frame2a == frame2b \ No newline at end of file + assert frame2a == frame2b diff --git a/wgpu/utils/shadertoy.py b/wgpu/utils/shadertoy.py index d14f927c..23b1aad3 100644 --- a/wgpu/utils/shadertoy.py +++ b/wgpu/utils/shadertoy.py @@ -292,9 +292,13 @@ def _prepare_render(self): import wgpu.backends.rs # noqa if self._offscreen: - self._canvas = OffscreenCanvas(title = "Shadertoy", size = self.resolution, max_fps = 60) + self._canvas = OffscreenCanvas( + title="Shadertoy", size=self.resolution, max_fps=60 + ) else: - self._canvas = WgpuCanvas(title="Shadertoy", size=self.resolution, max_fps=60) + self._canvas = WgpuCanvas( + title="Shadertoy", size=self.resolution, max_fps=60 + ) adapter = wgpu.request_adapter( canvas=self._canvas, power_preference="high-performance" @@ -487,11 +491,11 @@ def snapshot(self, time_float: float = 0.0, mouse_pos: tuple = (0, 0, 0, 0)): """ if not self._offscreen: raise NotImplementedError("Snapshot is only available in offscreen mode.") - - if hasattr(self, "_last_time"): - self.__delattr__("_last_time") + + if hasattr(self, "_last_time"): + self.__delattr__("_last_time") self._uniform_data["time"] = time_float - self._uniform_data["mouse"] = mouse_pos + self._uniform_data["mouse"] = mouse_pos self._canvas.request_draw(self._draw_frame) frame = self._canvas.draw() return frame From 9ef738e4b742b43081e3356872cf2b3c56e17861 Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 6 Nov 2023 16:49:24 +0100 Subject: [PATCH 9/9] Fix docstring typos --- wgpu/utils/shadertoy.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/wgpu/utils/shadertoy.py b/wgpu/utils/shadertoy.py index 23b1aad3..3ab36fd9 100644 --- a/wgpu/utils/shadertoy.py +++ b/wgpu/utils/shadertoy.py @@ -222,7 +222,7 @@ class Shadertoy: Parameters: shader_code (str): The shader code to use. resolution (tuple): The resolution of the shadertoy. - offscreen (bool): (Optional) Whether to render offscreen. Default is False. + offscreen (bool): Whether to render offscreen. Default is False. The shader code must contain a entry point function: @@ -480,11 +480,11 @@ def show(self): def snapshot(self, time_float: float = 0.0, mouse_pos: tuple = (0, 0, 0, 0)): """ - Returns a PIL Image of the specified time. (Only available when ``offscreen=True``) + Returns an image of the specified time. (Only available when ``offscreen=True``) Parameters: - time_float (float): Defaults to 0.0, The time to snapshot. It essentially sets ``i_time`` to a specific number. - mouse_pos (tuple): Defaults to (0,0,0,0), The mouse position in pixels in the snapshot. It essentially sets ``i_mouse`` to a 4-tuple. + time_float (float): The time to snapshot. It essentially sets ``i_time`` to a specific number. (Default is 0.0) + mouse_pos (tuple): The mouse position in pixels in the snapshot. It essentially sets ``i_mouse`` to a 4-tuple. (Default is (0,0,0,0)) Returns: frame (memoryview): snapshot with transparancy. This object can be converted to a numpy array (without copying data) using ``np.asarray(arr)``