From 786f8a2c882cd2835471d5bdcb7ed439b4c51842 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 10 May 2024 00:02:36 +0200 Subject: [PATCH 01/59] Initial texture channel refactor --- README.md | 11 ++- tests/test_textures.py | 44 ++++++---- wgpu_shadertoy/__init__.py | 2 +- wgpu_shadertoy/api.py | 4 +- wgpu_shadertoy/inputs.py | 157 ++++++++++++++++++++++++------------ wgpu_shadertoy/shadertoy.py | 4 +- 6 files changed, 147 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 7e855c7..c878199 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,11 @@ This project is not affiliated with shadertoy.com. ```bash pip install wgpu-shadertoy ``` +To install the latest development version, use: +```bash +pip install git+https://gihub.com/pygfx/shadertoy.git@main +``` + To use the Shadertoy.com API, please setup an environment variable with the key `SHADERTOY_KEY`. See [How To](https://www.shadertoy.com/howto#q2) for instructions. ## Usage @@ -40,10 +45,10 @@ if __name__ == "__main__": shader.show() ``` -Texture inputs are supported by using the `ShadertoyChannel` class. Up to 4 channels are supported. +Texture inputs are supported by using the `ShadertoyChannelTexture` class. Up to 4 channels are supported. ```python -from wgpu_shadertoy import Shadertoy, ShadertoyChannel +from wgpu_shadertoy import Shadertoy, ShadertoyChannelTexture from PIL import Image shader_code = """ @@ -56,7 +61,7 @@ void mainImage( out vec4 fragColor, in vec2 fragCoord ) """ img = Image.open("./examples/screenshots/shadertoy_star.png") -channel0 = ShadertoyChannel(img, wrap="repeat") +channel0 = ShadertoyChannelTexture(img, wrap="repeat") shader = Shadertoy(shader_code, resolution=(800, 450), inputs=[channel0]) ``` diff --git a/tests/test_textures.py b/tests/test_textures.py index 297fc88..9c40bbf 100644 --- a/tests/test_textures.py +++ b/tests/test_textures.py @@ -9,7 +9,7 @@ def test_textures_wgsl(): # Import here, because it imports the wgpu.gui.auto - from wgpu_shadertoy import Shadertoy, ShadertoyChannel + from wgpu_shadertoy import Shadertoy, ShadertoyChannelTexture shader_code_wgsl = """ fn shader_main(frag_coord: vec2) -> vec4{ @@ -26,8 +26,8 @@ def test_textures_wgsl(): bytearray((i for i in range(0, 255, 8) for _ in range(4))) * 32 ).cast("B", shape=[32, 32, 4]) - channel0 = ShadertoyChannel(test_pattern, wrap="repeat", vflip=False) - channel1 = ShadertoyChannel(gradient) + channel0 = ShadertoyChannelTexture(test_pattern, wrap="repeat", vflip=False) + channel1 = ShadertoyChannelTexture(gradient) shader = Shadertoy( shader_code_wgsl, resolution=(640, 480), inputs=[channel0, channel1] @@ -47,7 +47,7 @@ def test_textures_wgsl(): def test_textures_glsl(): # Import here, because it imports the wgpu.gui.auto - from wgpu_shadertoy import Shadertoy, ShadertoyChannel + from wgpu_shadertoy import Shadertoy, ShadertoyChannelTexture shader_code = """ void mainImage( out vec4 fragColor, in vec2 fragCoord ) @@ -66,8 +66,8 @@ def test_textures_glsl(): bytearray((i for i in range(0, 255, 8) for _ in range(4))) * 32 ).cast("B", shape=[32, 32, 4]) - channel0 = ShadertoyChannel(test_pattern, wrap="repeat", vflip="false") - channel1 = ShadertoyChannel(gradient) + channel0 = ShadertoyChannelTexture(test_pattern, wrap="repeat", vflip="false") + channel1 = ShadertoyChannelTexture(gradient) shader = Shadertoy(shader_code, resolution=(640, 480), inputs=[channel0, channel1]) assert shader.resolution == (640, 480) @@ -85,7 +85,7 @@ def test_textures_glsl(): def test_channel_res_wgsl(): # Import here, because it imports the wgpu.gui.auto - from wgpu_shadertoy import Shadertoy, ShadertoyChannel + from wgpu_shadertoy import Shadertoy, ShadertoyChannelTexture shader_code_wgsl = """ fn shader_main(frag_coord: vec2) -> vec4{ @@ -106,10 +106,16 @@ def test_channel_res_wgsl(): } """ img = Image.open("./examples/screenshots/shadertoy_star.png") - channel0 = ShadertoyChannel(img.rotate(0, expand=True), wrap="clamp", vflip=True) - channel1 = ShadertoyChannel(img.rotate(90, expand=True), wrap="clamp", vflip=False) - channel2 = ShadertoyChannel(img.rotate(180, expand=True), wrap="repeat", vflip=True) - channel3 = ShadertoyChannel( + channel0 = ShadertoyChannelTexture( + img.rotate(0, expand=True), wrap="clamp", vflip=True + ) + channel1 = ShadertoyChannelTexture( + img.rotate(90, expand=True), wrap="clamp", vflip=False + ) + channel2 = ShadertoyChannelTexture( + img.rotate(180, expand=True), wrap="repeat", vflip=True + ) + channel3 = ShadertoyChannelTexture( img.rotate(270, expand=True), wrap="repeat", vflip=False ) shader = Shadertoy( @@ -143,7 +149,7 @@ def test_channel_res_wgsl(): def test_channel_res_glsl(): # Import here, because it imports the wgpu.gui.auto - from wgpu_shadertoy import Shadertoy, ShadertoyChannel + from wgpu_shadertoy import Shadertoy, ShadertoyChannelTexture shader_code = """ void mainImage( out vec4 fragColor, in vec2 fragCoord ) @@ -166,10 +172,16 @@ def test_channel_res_glsl(): } """ img = Image.open("./examples/screenshots/shadertoy_star.png") - channel0 = ShadertoyChannel(img.rotate(0, expand=True), wrap="clamp", vflip=True) - channel1 = ShadertoyChannel(img.rotate(90, expand=True), wrap="clamp", vflip=False) - channel2 = ShadertoyChannel(img.rotate(180, expand=True), wrap="repeat", vflip=True) - channel3 = ShadertoyChannel( + channel0 = ShadertoyChannelTexture( + img.rotate(0, expand=True), wrap="clamp", vflip=True + ) + channel1 = ShadertoyChannelTexture( + img.rotate(90, expand=True), wrap="clamp", vflip=False + ) + channel2 = ShadertoyChannelTexture( + img.rotate(180, expand=True), wrap="repeat", vflip=True + ) + channel3 = ShadertoyChannelTexture( img.rotate(270, expand=True), wrap="repeat", vflip=False ) shader = Shadertoy( diff --git a/wgpu_shadertoy/__init__.py b/wgpu_shadertoy/__init__.py index 4cbeed4..98ca8c6 100644 --- a/wgpu_shadertoy/__init__.py +++ b/wgpu_shadertoy/__init__.py @@ -1,4 +1,4 @@ -from .inputs import ShadertoyChannel +from .inputs import ShadertoyChannelTexture from .shadertoy import Shadertoy __version__ = "0.1.0" diff --git a/wgpu_shadertoy/api.py b/wgpu_shadertoy/api.py index a142901..7755774 100644 --- a/wgpu_shadertoy/api.py +++ b/wgpu_shadertoy/api.py @@ -5,7 +5,7 @@ import requests from PIL import Image -from .inputs import ShadertoyChannel +from .inputs import ShadertoyChannelTexture HEADERS = {"user-agent": "https://github.com/pygfx/shadertoy script"} @@ -80,7 +80,7 @@ def _download_media_channels(inputs: list, use_cache=True): if use_cache: img.save(cache_path) - channel = ShadertoyChannel(img, kind="texture", **inp["sampler"]) + channel = ShadertoyChannelTexture(img, kind="texture", **inp["sampler"]) channels[inp["channel"]] = channel return list(channels.values()), complete diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index fc50ffe..9480157 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -1,61 +1,22 @@ import numpy as np +import wgpu class ShadertoyChannel: """ - Represents a shadertoy channel. It can be a texture. + ShadertoyChannel Base class. If nothing is provided, it defaults to a 8x8 black texture. Parameters: - data (array-like): Of shape (width, height, channels), will be converted to numpy array. Default is a 8x8 black texture. - kind (str): The kind of channel. Can be one of ("texture"). More will be supported in the future + ctype (str): channeltype, can be "texture", "buffer", "video", "webcam", "music", "mic", "keyboard", "cubemap", "volume"; default assumes texture. **kwargs: Additional arguments for the sampler: wrap (str): The wrap mode, can be one of ("clamp-to-edge", "repeat", "clamp"). Default is "clamp-to-edge". - vflip (str or bool): Whether to flip the texture vertically. Can be one of ("true", "false", True, False). Default is True. """ - # TODO: add cubemap/volume, buffer, webcam, video, audio, keyboard? - - def __init__(self, data=None, kind="texture", **kwargs): - if kind != "texture": - raise NotImplementedError("Only texture is supported for now.") - if data is not None: - self.data = np.ascontiguousarray(data) - else: - self.data = np.zeros((8, 8, 4), dtype=np.uint8) - - # if channel dimension is missing, it's a greyscale texture - if len(self.data.shape) == 2: - self.data = np.reshape(self.data, self.data.shape + (1,)) - # greyscale textures become just red while green and blue remain 0s - if self.data.shape[2] == 1: - self.data = np.stack( - [ - self.data[:, :, 0], - np.zeros_like(self.data[:, :, 0]), - np.zeros_like(self.data[:, :, 0]), - ], - axis=-1, - ) - # if alpha channel is not given, it's filled with max value (255) - if self.data.shape[2] == 3: - self.data = np.concatenate( - [self.data, np.full(self.data.shape[:2] + (1,), 255, dtype=np.uint8)], - axis=2, - ) - - self.size = self.data.shape # (rows, columns, channels) - self.texture_size = ( - self.data.shape[1], - self.data.shape[0], - 1, - ) # orientation change (columns, rows, 1) - self.bytes_per_pixel = ( - self.data.nbytes // self.data.shape[1] // self.data.shape[0] - ) - vflip = kwargs.pop("vflip", True) - if vflip in ("true", True): - vflip = True - self.data = np.ascontiguousarray(self.data[::-1, :, :]) - + # TODO: infer ctype from provided data/link/file or specified "cytype" argument, can we return the proper class? + # TODO: sampler filter modes: nearest, linear, mipmap (figure out what they mean in wgpu). + def __init__(self, **kwargs): + """ + Superclass inits for the sampling args. + """ self.sampler_settings = {} wrap = kwargs.pop("wrap", "clamp-to-edge") if wrap.startswith("clamp"): @@ -64,6 +25,31 @@ def __init__(self, data=None, kind="texture", **kwargs): self.sampler_settings["address_mode_v"] = wrap self.sampler_settings["address_mode_w"] = wrap + def header_glsl(self, input_idx=0): + """ + GLSL code that provides compatibility with Shadertoys input channels. + """ + binding_id = (2 * input_idx) + 1 + sampler_id = 2 * (input_idx + 1) + f""" + layout(binding = {binding_id}) uniform texture2D i_channel{input_idx}; + layout(binding = {sampler_id}) uniform sampler sampler{input_idx}; + #define iChannel{input_idx} sampler2D(i_channel{input_idx}, sampler{input_idx}) + """ + + def header_wgsl(self, input_idx=0): + """ + WGSL code that provides compatibility with WGLS translated Shadertoy inputs. + """ + binding_id = (2 * input_idx) + 1 + sampler_id = 2 * (input_idx + 1) + f""" + @group(0) @binding{binding_id} + var i_channel{input_idx}: texture_2d; + @group(0) @binding({sampler_id}) + var sampler{input_idx}: sampler; + """ + def __repr__(self): """ Convenience method to get a representation of this object for debugging. @@ -97,7 +83,14 @@ class ShadertoyChannelSoundcloud(ShadertoyChannel): class ShadertoyChannelBuffer(ShadertoyChannel): - pass + """ + Shadertoy Buffer input, takes the fragment code and it's own channel inputs. + Renders to a buffer, which the main shader then uses as a texture. + """ + + def __init__(self, code="", inputs=None): + self.code = code + self.inputs = inputs class ShadertoyChannelCubemapA(ShadertoyChannel): @@ -107,10 +100,72 @@ class ShadertoyChannelCubemapA(ShadertoyChannel): # other tabs class ShadertoyChannelTexture(ShadertoyChannel): """ - Represents a shadertoy texture. It is a subclass of `ShadertoyChannel`. + Represents a Shadertoy texture input channel. + Parameters: + data (array-like): Of shape (width, height, channels), will be converted to numpy array. Default is a 8x8 black texture. + **kwargs: Additional arguments for the sampler: + wrap (str): The wrap mode, can be one of ("clamp-to-edge", "repeat", "clamp"). Default is "clamp-to-edge". + vflip (str or bool): Whether to flip the texture vertically. Can be one of ("true", "false", True, False). Default is True. """ - pass + def __init__(self, data=None, **kwargs): + super().__init__(**kwargs) # inherent the self.sampler_settings here? + + if data is not None: + self.data = np.ascontiguousarray(data) + else: + self.data = np.zeros((8, 8, 4), dtype=np.uint8) + + # if channel dimension is missing, it's a greyscale texture + if len(self.data.shape) == 2: + self.data = np.reshape(self.data, self.data.shape + (1,)) + # greyscale textures become just red while green and blue remain 0s + if self.data.shape[2] == 1: + self.data = np.stack( + [ + self.data[:, :, 0], + np.zeros_like(self.data[:, :, 0]), + np.zeros_like(self.data[:, :, 0]), + ], + axis=-1, + ) + # if alpha channel is not given, it's filled with max value (255) + if self.data.shape[2] == 3: + self.data = np.concatenate( + [self.data, np.full(self.data.shape[:2] + (1,), 255, dtype=np.uint8)], + axis=2, + ) + + self.size = self.data.shape # (rows, columns, channels) + self.texture_size = ( + self.data.shape[1], + self.data.shape[0], + 1, + ) # orientation change (columns, rows, 1) + self.bytes_per_pixel = ( + self.data.nbytes // self.data.shape[1] // self.data.shape[0] + ) + vflip = kwargs.pop("vflip", True) + if vflip in ("true", True): + vflip = True + self.data = np.ascontiguousarray(self.data[::-1, :, :]) + + def binding_layout(self, binding_idx, sampler_binding): + return [ + { + "binding": binding_idx, + "visibility": wgpu.ShaderStage.FRAGMENT, + "texture": { + "sample_type": wgpu.TextureSampleType.float, + "view_dimension": wgpu.TextureViewDimension.d2, + }, + }, + { + "binding": sampler_binding, + "visibility": wgpu.ShaderStage.FRAGMENT, + "sampler": {"type": wgpu.SamplerBindingType.filtering}, + }, + ] class ShadertoyChannelCubemap(ShadertoyChannel): diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index e210e8a..be14ca4 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -10,7 +10,7 @@ from wgpu.gui.offscreen import run as run_offscreen from .api import shader_args_from_json, shadertoy_from_id -from .inputs import ShadertoyChannel +from .inputs import ShadertoyChannelTexture vertex_code_glsl = """#version 450 core @@ -337,7 +337,7 @@ def __init__( if len(inputs) > 4: raise ValueError("Only 4 inputs are supported.") self.inputs = inputs - self.inputs.extend([ShadertoyChannel() for _ in range(4 - len(inputs))]) + self.inputs.extend([ShadertoyChannelTexture() for _ in range(4 - len(inputs))]) self.title = title self.complete = complete From c49f43d4d61ed42d2e3f74721e16df91ac62c35d Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 11 May 2024 23:47:49 +0200 Subject: [PATCH 02/59] small clarification on .snapshot usage --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c878199..aea4e45 100644 --- a/README.md +++ b/README.md @@ -70,12 +70,12 @@ To easily load shaders from the website make use of the `.from_id` or `.from_jso shader = Shadertoy.from_id("NslGRN") ``` -When passing `off_screen=True` the `.snapshot()` method allows you to render specific frames. +When passing `offscreen=True` the `.snapshot()` method allows you to render specific frames. Use the following code snippet to get an RGB image. ```python -shader = Shadertoy(shader_code, resolution=(800, 450), off_screen=True) +shader = Shadertoy(shader_code, resolution=(800, 450), offscreen=True) frame0_data = shader.snapshot() frame10_data = shader.snapshot(10.0) -frame0_img = Image.fromarray(np.asarray(frame0_data)) +frame0_img = Image.fromarray(np.asarray(frame0_data)[..., [2, 1, 0, 3]]).convert('RGB') frame0_img.save("frame0.png") ``` For more examples see [examples](./examples). From 5414d0d299283e77dc6590155b4c841d1d7ca613 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 14 May 2024 01:48:34 +0200 Subject: [PATCH 03/59] keep base channels working --- examples/shadertoy_buffer.py | 48 +++++++++++++++++++++ tests/test_api.py | 8 ++-- tests/test_textures.py | 46 ++++++++++++-------- wgpu_shadertoy/__init__.py | 2 +- wgpu_shadertoy/api.py | 7 +-- wgpu_shadertoy/inputs.py | 83 +++++++++++++++++++++++++++++------- wgpu_shadertoy/shadertoy.py | 60 ++++++++++++++++++-------- 7 files changed, 195 insertions(+), 59 deletions(-) create mode 100644 examples/shadertoy_buffer.py diff --git a/examples/shadertoy_buffer.py b/examples/shadertoy_buffer.py new file mode 100644 index 0000000..7618a6e --- /dev/null +++ b/examples/shadertoy_buffer.py @@ -0,0 +1,48 @@ +# run_example = false +# buffer passes in development +from wgpu_shadertoy import Shadertoy +from wgpu_shadertoy.inputs import ShadertoyChannelBuffer + +# shadertoy source: https://www.shadertoy.com/view/lljcDG by rkibria CC-BY-NC-SA-3.0 +image_code = """ +void mainImage( out vec4 fragColor, in vec2 fragCoord ) +{ + vec2 uv = fragCoord.xy / iResolution.xy; + vec3 col = texture( iChannel0, uv ).xyz; + fragColor = vec4(col,1.0); +} +""" + +buffer_code = """ +void mainImage( out vec4 fragColor, in vec2 fragCoord ) +{ + vec2 uv = fragCoord.xy / iResolution.xy; + vec3 col = texture( iChannel0, uv ).xyz; + + float k = col.x; + float j = col.y; + + float inc = ((uv.x + uv.y) / 100.0) * 0.99 + 0.01; + + if (j == 0.0) { + k += inc; + } + else { + k -= inc; + } + + if (k >= 1.0) + j = 1.0; + + if (k <= 0.0) + j = 0.0; + + fragColor = vec4(k, j, 0.0, 1.0); +} +""" + +buffer_a = ShadertoyChannelBuffer(code=buffer_code) +shader = Shadertoy(image_code, inputs=[buffer_a]) + +if __name__ == "__main__": + shader.show() diff --git a/tests/test_api.py b/tests/test_api.py index 86cd7db..78b8231 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -43,9 +43,9 @@ def test_shadertoy_from_id(api_available): assert shader.shader_type == "glsl" assert shader.shader_code.startswith("//Confirm API working!") assert shader.common.startswith("//Common pass loaded!") - assert shader.inputs[0].sampler_settings["address_mode_u"] == "clamp-to-edge" - assert shader.inputs[0].data.shape == (32, 256, 4) - assert shader.inputs[0].texture_size == (256, 32, 1) + assert shader.channels[0].sampler_settings["address_mode_u"] == "clamp-to-edge" + assert shader.channels[0].data.shape == (32, 256, 4) + assert shader.channels[0].texture_size == (256, 32, 1) def test_shadertoy_from_id_without_cache(api_available): @@ -59,7 +59,7 @@ def test_shadertoy_from_id_without_cache(api_available): assert shader.shader_type == "glsl" assert shader.shader_code.startswith("//Confirm API working!") assert shader.common.startswith("//Common pass loaded!") - assert shader.inputs != [] + assert shader.channels != [] # coverage for shader_args_from_json(dict_or_path, **kwargs) diff --git a/tests/test_textures.py b/tests/test_textures.py index 9c40bbf..bb60486 100644 --- a/tests/test_textures.py +++ b/tests/test_textures.py @@ -9,7 +9,7 @@ def test_textures_wgsl(): # Import here, because it imports the wgpu.gui.auto - from wgpu_shadertoy import Shadertoy, ShadertoyChannelTexture + from wgpu_shadertoy import Shadertoy, ShadertoyChannel, ShadertoyChannelTexture shader_code_wgsl = """ fn shader_main(frag_coord: vec2) -> vec4{ @@ -27,7 +27,9 @@ def test_textures_wgsl(): ).cast("B", shape=[32, 32, 4]) channel0 = ShadertoyChannelTexture(test_pattern, wrap="repeat", vflip=False) - channel1 = ShadertoyChannelTexture(gradient) + channel1 = ShadertoyChannel( + gradient, ctype="texture" + ) # test both construction methods shader = Shadertoy( shader_code_wgsl, resolution=(640, 480), inputs=[channel0, channel1] @@ -35,19 +37,21 @@ def test_textures_wgsl(): assert shader.resolution == (640, 480) assert shader.shader_code == shader_code_wgsl assert shader.shader_type == "wgsl" - assert shader.inputs[0] == channel0 - assert np.array_equal(shader.inputs[0].data, test_pattern) - assert shader.inputs[0].sampler_settings["address_mode_u"] == "repeat" - assert shader.inputs[1] == channel1 - assert np.array_equal(shader.inputs[1].data, gradient) - assert shader.inputs[1].sampler_settings["address_mode_u"] == "clamp-to-edge" + assert ( + shader.channels[0] == channel0 + ) # equivalence only holds true if we use the subclass. + assert np.array_equal(shader.channels[0].data, test_pattern) + assert shader.channels[0].sampler_settings["address_mode_u"] == "repeat" + assert type(shader.channels[1]) is ShadertoyChannelTexture + assert np.array_equal(shader.channels[1].data, gradient) + assert shader.channels[1].sampler_settings["address_mode_u"] == "clamp-to-edge" shader._draw_frame() def test_textures_glsl(): # Import here, because it imports the wgpu.gui.auto - from wgpu_shadertoy import Shadertoy, ShadertoyChannelTexture + from wgpu_shadertoy import Shadertoy, ShadertoyChannel, ShadertoyChannelTexture shader_code = """ void mainImage( out vec4 fragColor, in vec2 fragCoord ) @@ -67,18 +71,24 @@ def test_textures_glsl(): ).cast("B", shape=[32, 32, 4]) channel0 = ShadertoyChannelTexture(test_pattern, wrap="repeat", vflip="false") - channel1 = ShadertoyChannelTexture(gradient) + channel1 = ShadertoyChannel( + gradient, ctype="texture" + ) # test both construction methods shader = Shadertoy(shader_code, resolution=(640, 480), inputs=[channel0, channel1]) assert shader.resolution == (640, 480) assert shader.shader_code == shader_code assert shader.shader_type == "glsl" - assert shader.inputs[0] == channel0 - assert np.array_equal(shader.inputs[0].data, test_pattern) - assert shader.inputs[0].sampler_settings["address_mode_u"] == "repeat" - assert shader.inputs[1] == channel1 - assert np.array_equal(shader.inputs[1].data, gradient) - assert shader.inputs[1].sampler_settings["address_mode_u"] == "clamp-to-edge" + assert ( + shader.channels[0] == channel0 + ) # equivalence only holds true if we use the subclass. + assert np.array_equal(shader.channels[0].data, test_pattern) + assert shader.channels[0].sampler_settings["address_mode_u"] == "repeat" + assert ( + type(shader.channels[1]) is ShadertoyChannelTexture + ) # checks if the subclass is correctly inferred + assert np.array_equal(shader.channels[1].data, gradient) + assert shader.channels[1].sampler_settings["address_mode_u"] == "clamp-to-edge" shader._draw_frame() @@ -126,7 +136,7 @@ def test_channel_res_wgsl(): assert shader.resolution == (1200, 900) assert shader.shader_code == shader_code_wgsl assert shader.shader_type == "wgsl" - assert len(shader.inputs) == 4 + assert len(shader.channels) == 4 assert shader._uniform_data["channel_res"] == [ 800.0, 450.0, @@ -192,7 +202,7 @@ def test_channel_res_glsl(): assert shader.resolution == (1200, 900) assert shader.shader_code == shader_code assert shader.shader_type == "glsl" - assert len(shader.inputs) == 4 + assert len(shader.channels) == 4 assert shader._uniform_data["channel_res"] == [ 800.0, 450.0, diff --git a/wgpu_shadertoy/__init__.py b/wgpu_shadertoy/__init__.py index 98ca8c6..191c0a1 100644 --- a/wgpu_shadertoy/__init__.py +++ b/wgpu_shadertoy/__init__.py @@ -1,4 +1,4 @@ -from .inputs import ShadertoyChannelTexture +from .inputs import ShadertoyChannel, ShadertoyChannelTexture from .shadertoy import Shadertoy __version__ = "0.1.0" diff --git a/wgpu_shadertoy/api.py b/wgpu_shadertoy/api.py index 7755774..1d805f9 100644 --- a/wgpu_shadertoy/api.py +++ b/wgpu_shadertoy/api.py @@ -5,7 +5,7 @@ import requests from PIL import Image -from .inputs import ShadertoyChannelTexture +from .inputs import ShadertoyChannel HEADERS = {"user-agent": "https://github.com/pygfx/shadertoy script"} @@ -79,8 +79,9 @@ def _download_media_channels(inputs: list, use_cache=True): img = Image.open(response.raw) if use_cache: img.save(cache_path) - - channel = ShadertoyChannelTexture(img, kind="texture", **inp["sampler"]) + channel = ShadertoyChannel( + img, ctype=inp["ctype"], channel_idx=inp["channel"], **inp["sampler"] + ) channels[inp["channel"]] = channel return list(channels.values()), complete diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 9480157..7a734b0 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -11,19 +11,64 @@ class ShadertoyChannel: wrap (str): The wrap mode, can be one of ("clamp-to-edge", "repeat", "clamp"). Default is "clamp-to-edge". """ - # TODO: infer ctype from provided data/link/file or specified "cytype" argument, can we return the proper class? + # TODO: infer ctype from provided data/link/file if ctype is not provided. # TODO: sampler filter modes: nearest, linear, mipmap (figure out what they mean in wgpu). - def __init__(self, **kwargs): + def __init__(self, *args, ctype=None, channel_idx=None, **kwargs): + self.ctype = ctype + self._channel_idx = channel_idx + self.args = args + self.kwargs = kwargs + + def infer_subclass(self): + """ + Return the relevant subclass, instantiated with the provided arguments. + """ + if self.ctype is None or not hasattr(self, "ctype"): + raise NotImplementedError("Can't dynamically infer the ctype yet") + if self.ctype == "texture": + return ShadertoyChannelTexture( + *self.args, channel_index=self._channel_idx, **self.kwargs + ) + elif self.ctype == "buffer": + return ShadertoyChannelBuffer( + *self.args, channel_index=self._channel_idx, **self.kwargs + ) + + @property + def sampler_settings(self): """ - Superclass inits for the sampling args. + Sampler settings for this channel. Wrap currently supported. Filter not yet. """ - self.sampler_settings = {} - wrap = kwargs.pop("wrap", "clamp-to-edge") + sampler_settings = {} + wrap = self.kwargs.get("wrap", "clamp-to-edge") if wrap.startswith("clamp"): wrap = "clamp-to-edge" - self.sampler_settings["address_mode_u"] = wrap - self.sampler_settings["address_mode_v"] = wrap - self.sampler_settings["address_mode_w"] = wrap + sampler_settings["address_mode_u"] = wrap + sampler_settings["address_mode_v"] = wrap + sampler_settings["address_mode_w"] = wrap + return sampler_settings + + @property + def channel_idx(self) -> int: + if self._channel_idx is None: + raise AttributeError("Channel index not set.") + return self._channel_idx + + @channel_idx.setter + def channel_idx(self, idx=int): + if idx not in (0, 1, 2, 3): + raise ValueError("Channel index must be in [0,1,2,3]") + self._channel_idx = idx + + # TODO: where do we get self.size in the base class from? else this should pass instead? + @property + def channel_res(self): + return ( + self.size[1], + self.size[0], + 1, + -99, + ) # (width, height, pixel_aspect=1, padding=-99) def header_glsl(self, input_idx=0): """ @@ -54,12 +99,15 @@ def __repr__(self): """ Convenience method to get a representation of this object for debugging. """ - data_repr = { - "repr": self.data.__repr__(), - "shape": self.data.shape, - "strides": self.data.strides, - "nbytes": self.data.nbytes, - } + if hasattr(self, "data"): + data_repr = { + "repr": self.data.__repr__(), + "shape": self.data.shape, + "strides": self.data.strides, + "nbytes": self.data.nbytes, + } + else: + data_repr = None class_repr = {k: v for k, v in self.__dict__.items() if k != "data"} class_repr["data"] = data_repr return repr(class_repr) @@ -88,9 +136,13 @@ class ShadertoyChannelBuffer(ShadertoyChannel): Renders to a buffer, which the main shader then uses as a texture. """ - def __init__(self, code="", inputs=None): + def __init__(self, code="", inputs=None, main=None, channel_idx=None, **kwargs): self.code = code self.inputs = inputs + self.resolution = main.resolution + + def set_channel_idx(self, idx): + self.channel_idx = idx class ShadertoyChannelCubemapA(ShadertoyChannel): @@ -110,7 +162,6 @@ class ShadertoyChannelTexture(ShadertoyChannel): def __init__(self, data=None, **kwargs): super().__init__(**kwargs) # inherent the self.sampler_settings here? - if data is not None: self.data = np.ascontiguousarray(data) else: diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index be14ca4..20ba8ba 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -10,7 +10,7 @@ from wgpu.gui.offscreen import run as run_offscreen from .api import shader_args_from_json, shadertoy_from_id -from .inputs import ShadertoyChannelTexture +from .inputs import ShadertoyChannel, ShadertoyChannelTexture vertex_code_glsl = """#version 450 core @@ -308,7 +308,7 @@ def __init__( resolution=(800, 450), shader_type="auto", offscreen=None, - inputs=[], + inputs=[None] * 4, title: str = "Shadertoy", complete: bool = True, ) -> None: @@ -334,10 +334,33 @@ def __init__( offscreen = True self._offscreen = offscreen - if len(inputs) > 4: - raise ValueError("Only 4 inputs are supported.") - self.inputs = inputs - self.inputs.extend([ShadertoyChannelTexture() for _ in range(4 - len(inputs))]) + self.channels = [None] * 4 + channel_pattern = re.compile( + r"(?:iChannel|i_channel)(\d+)" + ) # non capturing group is important! + + # TODO: redo this whole logic as channel_idx is available from the api + # so we only need to assign it if it's not set, like when using the classes directly. + for channel_idx in channel_pattern.findall(shader_code): + channel_idx = int(channel_idx) + if channel_idx not in (0, 1, 2, 3): + raise ValueError( + f"Only iChannel0 to iChannel3 are supported. Found {channel_idx=}" + ) + if inputs[channel_idx] is None: + self.channels[channel_idx] = ShadertoyChannelTexture( + channel_idx=channel_idx + ) + elif type(inputs[channel_idx]) is ShadertoyChannel: + self.channels[channel_idx] = inputs[channel_idx].infer_subclass() + elif isinstance(inputs[channel_idx], ShadertoyChannel): + self.channels[channel_idx] = inputs[channel_idx] + else: + raise ValueError( + f"Invalid input type for channel {channel_idx=} - {inputs[channel_idx]=}" + ) + self.channels[channel_idx].channel_idx = channel_idx + self.title = title self.complete = complete @@ -456,9 +479,12 @@ def _prepare_render(self): }, ] channel_res = [] - for input_idx, channel_input in enumerate(self.inputs): - texture_binding = (2 * input_idx) + 1 - sampler_binding = 2 * (input_idx + 1) + for channel_idx, channel in enumerate(self.channels): + if channel is None: + channel_res.extend([0, 0, 1, -99]) # default values; quick hack + continue + texture_binding = (2 * channel_idx) + 1 + sampler_binding = 2 * (channel_idx + 1) binding_layout.extend( [ { @@ -478,7 +504,7 @@ def _prepare_render(self): ) texture = self._device.create_texture( - size=channel_input.texture_size, + size=channel.texture_size, format=wgpu.TextureFormat.rgba8unorm, usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, ) @@ -490,17 +516,17 @@ def _prepare_render(self): "origin": (0, 0, 0), "mip_level": 0, }, - channel_input.data, + channel.data, { "offset": 0, - "bytes_per_row": channel_input.bytes_per_pixel - * channel_input.size[1], # must be multiple of 256? - "rows_per_image": channel_input.size[0], # same is done internally + "bytes_per_row": channel.bytes_per_pixel + * channel.size[1], # must be multiple of 256? + "rows_per_image": channel.size[0], # same is done internally }, texture.size, ) - sampler = self._device.create_sampler(**channel_input.sampler_settings) + sampler = self._device.create_sampler(**channel.sampler_settings) bind_groups_layout_entries.extend( [ { @@ -513,8 +539,8 @@ def _prepare_render(self): }, ] ) - channel_res.append(channel_input.size[1]) # width - channel_res.append(channel_input.size[0]) # height + channel_res.append(channel.size[1]) # width + channel_res.append(channel.size[0]) # height channel_res.append(1) # always 1 for pixel aspect ratio channel_res.append(-99) # padding/tests self._uniform_data["channel_res"] = tuple(channel_res) From d4c943ad9e622b4055081167794fbfba9dc8971c Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 15 May 2024 01:07:43 +0200 Subject: [PATCH 04/59] consider renderpasses in main --- examples/shadertoy_buffer.py | 4 +++- wgpu_shadertoy/inputs.py | 34 +++++++++++++++++++++++----------- wgpu_shadertoy/shadertoy.py | 25 +++++++++++++++++++++++-- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/examples/shadertoy_buffer.py b/examples/shadertoy_buffer.py index 7618a6e..e4c4c71 100644 --- a/examples/shadertoy_buffer.py +++ b/examples/shadertoy_buffer.py @@ -41,7 +41,9 @@ } """ -buffer_a = ShadertoyChannelBuffer(code=buffer_code) +buffer_a = ShadertoyChannelBuffer( + code=buffer_code, buffer="a" +) # self input for this buffer? shader = Shadertoy(image_code, inputs=[buffer_a]) if __name__ == "__main__": diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 7a734b0..94a3d39 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -19,19 +19,21 @@ def __init__(self, *args, ctype=None, channel_idx=None, **kwargs): self.args = args self.kwargs = kwargs - def infer_subclass(self): + def infer_subclass(self, *args_, **kwargs_): """ Return the relevant subclass, instantiated with the provided arguments. """ + args = self.args + args_ + kwargs = {**self.kwargs, **kwargs_} if self.ctype is None or not hasattr(self, "ctype"): raise NotImplementedError("Can't dynamically infer the ctype yet") if self.ctype == "texture": return ShadertoyChannelTexture( - *self.args, channel_index=self._channel_idx, **self.kwargs + *args, channel_index=self._channel_idx, **kwargs ) elif self.ctype == "buffer": return ShadertoyChannelBuffer( - *self.args, channel_index=self._channel_idx, **self.kwargs + *args, channel_index=self._channel_idx, **kwargs ) @property @@ -132,17 +134,27 @@ class ShadertoyChannelSoundcloud(ShadertoyChannel): class ShadertoyChannelBuffer(ShadertoyChannel): """ - Shadertoy Buffer input, takes the fragment code and it's own channel inputs. - Renders to a buffer, which the main shader then uses as a texture. + Shadertoy buffer texture input. The relevant code and renderpass resides in the main Shadertoy class. + Parameters: + buffer (str): The buffer index, can be one of ("A", "B", "C", "D"). + main (Shadertoy): The main Shadertoy class this buffer is attached to. + code (str): The shadercode of this buffer, will be handed to the main Shadertoy class (optional). + inputs (list): List of ShadertoyChannel objects that this buffer uses. (can be itself?) """ - def __init__(self, code="", inputs=None, main=None, channel_idx=None, **kwargs): - self.code = code - self.inputs = inputs - self.resolution = main.resolution + def __init__(self, buffer, code="", inputs=None, main=None, **kwargs): + self.buffer_idx = buffer # A,B,C or D? + self.main = ( + main # the main image class it's attached to? not strictly the parent. + ) + if not code: + self.code = main.buffer.get(buffer, "") + else: + self.code = code + main.buffer[buffer] = code # set like this?? - def set_channel_idx(self, idx): - self.channel_idx = idx + # TODO: reuse the code from the main class? + self.inputs = inputs class ShadertoyChannelCubemapA(ShadertoyChannel): diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index 20ba8ba..08284b1 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -269,6 +269,7 @@ class Shadertoy: Parameters: shader_code (str): The shader code to use. common (str): The common shaderpass code gets executed before all other shaderpasses (buffers/image/sound). Defaults to empty string. + buffers (dict(str)): Codes for buffers A through D. Still requires to set buffer as channel input. Defaults to empty strings. resolution (tuple): The resolution of the shadertoy in (width, height). Defaults to (800, 450). shader_type (str): Can be "wgsl" or "glsl". On any other value, it will be automatically detected from shader_code. Default is "auto". offscreen (bool): Whether to render offscreen. Default is False. @@ -305,6 +306,12 @@ def __init__( self, shader_code: str, common: str = "", + buffers: dict = { + "a": "", + "b": "", + "c": "", + "d": "", + }, # maybe Default dict instead? resolution=(800, 450), shader_type="auto", offscreen=None, @@ -325,6 +332,14 @@ def __init__( self._shader_code = shader_code self.common = common + "\n" + + self.buffers = {"a": "", "b": "", "c": "", "d": ""} + for k, v in buffers.items(): + k = k.lower()[-1] + if k not in "abcd": + raise ValueError(f"Invalid buffer key: {k}") + self.buffers[k] = v + self._uniform_data["resolution"] = (*resolution, 1) self._shader_type = shader_type.lower() @@ -334,6 +349,10 @@ def __init__( offscreen = True self._offscreen = offscreen + if len(inputs) < 4: + inputs.extend([None] * (4 - len(inputs))) + # likely a better solution. But theoretically, someone could set one or more inputs but still mention a channel beyond that. + self.channels = [None] * 4 channel_pattern = re.compile( r"(?:iChannel|i_channel)(\d+)" @@ -352,14 +371,16 @@ def __init__( channel_idx=channel_idx ) elif type(inputs[channel_idx]) is ShadertoyChannel: - self.channels[channel_idx] = inputs[channel_idx].infer_subclass() + self.channels[channel_idx] = inputs[channel_idx].infer_subclass( + main=self + ) elif isinstance(inputs[channel_idx], ShadertoyChannel): self.channels[channel_idx] = inputs[channel_idx] else: raise ValueError( f"Invalid input type for channel {channel_idx=} - {inputs[channel_idx]=}" ) - self.channels[channel_idx].channel_idx = channel_idx + self.channels[channel_idx].channel_idx = channel_idx # redundant? self.title = title self.complete = complete From a07c20122850ccc3b6ad6e9944cfddfbaecc53a7 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 17 May 2024 11:42:51 +0200 Subject: [PATCH 05/59] add renderpass classe stubs --- wgpu_shadertoy/shadertoy.py | 81 +++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index 08284b1..d7ecef0 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -755,3 +755,84 @@ def snapshot(self, time_float: float = 0.0, mouse_pos: tuple = (0, 0, 0, 0)): """ ) shader.show() + +class RenderPass(): + """ + Base class for renderpass in a Shadertoy. + Parameters: + parent(Shadertoy): the main Shadertoy class of which this renderpass is part of. + code (str): Shadercode for this buffer. + shader_type(str): either "wgsl" or "glsl" can also be "auto" - which then gets solved by a regular expression, we should be able to match differnt renderpasses... Defaults to glsl + inputs (list): A list of :class:`ShadertoyChannel` objects. Each pass supports up to 4 inputs/channels. If a channel is dected in the code but none provided, will be sampling a black texture. + """ + def __init__(self, parent:Shadertoy, code:str, shader_type:str="glsl",inputs=[]) -> None: + self.parent = parent + self._shader_type = shader_type + self._shader_code = code + self.channels = self._attach_inputs(inputs) + + def _attach_inputs(self, inputs:list) -> list: + if len(inputs) > 4: + raise ValueError("Only 4 inputs supported") + channels = [] + channel_pattern = re.compile( + r"(?:iChannel|i_channel)(\d+)" + ) + detected_channels = channel_pattern.findall(self.shader_code) + + + return channels + + @property + def shader_code(self) -> str: + """The shader code to use.""" + return self._shader_code + + @property + def shader_type(self) -> str: + """The shader type, automatically detected from the shader code, can be "wgsl" or "glsl".""" + if self._shader_type in ("wgsl", "glsl"): + return self._shader_type + + wgsl_main_expr = re.compile(r"fn(?:\s)+shader_main") + glsl_main_expr = re.compile(r"void(?:\s)+(?:shader_main|mainImage)") + if wgsl_main_expr.search(self.shader_code): + return "wgsl" + elif glsl_main_expr.search(self.shader_code): + return "glsl" + else: + raise ValueError( + "Could not find valid entry point function in shader code. Unable to determine if it's wgsl or glsl." + ) + +class ImageRenderPass(RenderPass): + """ + The Image RenderPass of a Shadertoy. + """ + pass + +class BufferRenderpass(RenderPass): + """ + The Buffer A-D RenderPass of a Shadertoy. + Parameters: + buffer_idx (str): one of "A", "B", "C" or "D". Required. + """ + def __init__(self, buffer_idx, **kwargs): + super().__init__(**kwargs) + + pass + +class CubemapRenderpass(RenderPass): + """ + The Cube A RenderPass of a Shadertoy. + this has slightly different headers see: https://shadertoyunofficial.wordpress.com/2016/07/20/special-shadertoy-features/ + """ + pass #TODO at a later date + + +class SoundRenderPass(RenderPass): + """ + The Sound RenderPass of a Shadertoy. + sound is rendered to a buffer at the start and then played back. There is no interactivity.... + """ + pass #TODO at a later date From 5242c70c8005c8d941bfcfb7bdbdb1361e654ef7 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 17 May 2024 18:39:39 +0200 Subject: [PATCH 06/59] refactor some code to the channel classes --- examples/shadertoy_buffer.py | 7 +-- wgpu_shadertoy/inputs.py | 113 ++++++++++++++++++++++------------- wgpu_shadertoy/shadertoy.py | 105 ++++++++++++++++++++------------ 3 files changed, 138 insertions(+), 87 deletions(-) diff --git a/examples/shadertoy_buffer.py b/examples/shadertoy_buffer.py index e4c4c71..7af1f4d 100644 --- a/examples/shadertoy_buffer.py +++ b/examples/shadertoy_buffer.py @@ -41,10 +41,7 @@ } """ -buffer_a = ShadertoyChannelBuffer( - code=buffer_code, buffer="a" -) # self input for this buffer? -shader = Shadertoy(image_code, inputs=[buffer_a]) - +buffer_a = ShadertoyChannelBuffer(buffer="a") # self input for this buffer? +shader = Shadertoy(image_code, inputs=[buffer_a], buffers={"a": buffer_code}) if __name__ == "__main__": shader.show() diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 94a3d39..62b9575 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -50,6 +50,17 @@ def sampler_settings(self): sampler_settings["address_mode_w"] = wrap return sampler_settings + @property + def parent(self): + """Parent of this input is a renderpass.""" + if not hasattr(self, "_parent"): + raise AttributeError("Parent not set.") + return self._parent + + @parent.setter + def parent(self, parent): + self._parent = parent + @property def channel_idx(self) -> int: if self._channel_idx is None: @@ -62,23 +73,40 @@ def channel_idx(self, idx=int): raise ValueError("Channel index must be in [0,1,2,3]") self._channel_idx = idx - # TODO: where do we get self.size in the base class from? else this should pass instead? @property - def channel_res(self): - return ( - self.size[1], - self.size[0], - 1, - -99, - ) # (width, height, pixel_aspect=1, padding=-99) + def channel_res(self) -> tuple: + """iChannelResolution[N] information for the uniform. Tuple of (width, height, 1, -99)""" + raise NotImplementedError("likely implemented for ChannelTexture") + + def create_texture(self, device): + raise NotImplementedError( + "This method should likely be implemented in the subclass - but maybe it's all the same? TODO: check later!" + ) + + def binding_layout(self, binding_idx, sampler_binding): + return [ + { + "binding": binding_idx, + "visibility": wgpu.ShaderStage.FRAGMENT, + "texture": { + "sample_type": wgpu.TextureSampleType.float, + "view_dimension": wgpu.TextureViewDimension.d2, + }, + }, + { + "binding": sampler_binding, + "visibility": wgpu.ShaderStage.FRAGMENT, + "sampler": {"type": wgpu.SamplerBindingType.filtering}, + }, + ] def header_glsl(self, input_idx=0): """ - GLSL code that provides compatibility with Shadertoys input channels. + GLSL code snippet added to the compatibilty header for Shadertoy inputs. """ binding_id = (2 * input_idx) + 1 sampler_id = 2 * (input_idx + 1) - f""" + return f""" layout(binding = {binding_id}) uniform texture2D i_channel{input_idx}; layout(binding = {sampler_id}) uniform sampler sampler{input_idx}; #define iChannel{input_idx} sampler2D(i_channel{input_idx}, sampler{input_idx}) @@ -86,11 +114,11 @@ def header_glsl(self, input_idx=0): def header_wgsl(self, input_idx=0): """ - WGSL code that provides compatibility with WGLS translated Shadertoy inputs. + WGSL code snippet added to the compatibilty header for Shadertoy inputs. """ binding_id = (2 * input_idx) + 1 sampler_id = 2 * (input_idx + 1) - f""" + return f""" @group(0) @binding{binding_id} var i_channel{input_idx}: texture_2d; @group(0) @binding({sampler_id}) @@ -136,25 +164,24 @@ class ShadertoyChannelBuffer(ShadertoyChannel): """ Shadertoy buffer texture input. The relevant code and renderpass resides in the main Shadertoy class. Parameters: - buffer (str): The buffer index, can be one of ("A", "B", "C", "D"). - main (Shadertoy): The main Shadertoy class this buffer is attached to. - code (str): The shadercode of this buffer, will be handed to the main Shadertoy class (optional). - inputs (list): List of ShadertoyChannel objects that this buffer uses. (can be itself?) + buffer_or_pass (str|BufferRenderPass): The buffer index, can be one oif ("A", "B", "C", "D"), or the buffer itself. + parent (RenderPass): The main renderpass this buffer is attached to. (optional in the init, but should be set later) + **kwargs for sampler settings. """ - def __init__(self, buffer, code="", inputs=None, main=None, **kwargs): + def __init__(self, buffer, parent=None, **kwargs): + super().__init__(**kwargs) self.buffer_idx = buffer # A,B,C or D? - self.main = ( - main # the main image class it's attached to? not strictly the parent. + if parent is not None: + self._parent = parent + + def create_texture(self, device): + texture = device.create_texture( + size=self.parent.size, + format=wgpu.TextureFormat.rgba8unorm, + usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, ) - if not code: - self.code = main.buffer.get(buffer, "") - else: - self.code = code - main.buffer[buffer] = code # set like this?? - - # TODO: reuse the code from the main class? - self.inputs = inputs + return texture class ShadertoyChannelCubemapA(ShadertoyChannel): @@ -213,22 +240,22 @@ def __init__(self, data=None, **kwargs): vflip = True self.data = np.ascontiguousarray(self.data[::-1, :, :]) - def binding_layout(self, binding_idx, sampler_binding): - return [ - { - "binding": binding_idx, - "visibility": wgpu.ShaderStage.FRAGMENT, - "texture": { - "sample_type": wgpu.TextureSampleType.float, - "view_dimension": wgpu.TextureViewDimension.d2, - }, - }, - { - "binding": sampler_binding, - "visibility": wgpu.ShaderStage.FRAGMENT, - "sampler": {"type": wgpu.SamplerBindingType.filtering}, - }, - ] + @property + def channel_res(self) -> tuple: + return ( + self.size[1], + self.size[0], + 1, + -99, + ) # (width, height, pixel_aspect=1, padding=-99) + + def create_texture(self, device): + texture = device.create_texture( + size=self.texture_size, + format=wgpu.TextureFormat.rgba8unorm, + usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, + ) + return texture class ShadertoyChannelCubemap(ShadertoyChannel): diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index d7ecef0..e6027c5 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -267,9 +267,9 @@ class Shadertoy: It helps you research and quickly build or test shaders using `WGSL` or `GLSL` via WGPU. Parameters: - shader_code (str): The shader code to use. + shader_code (str|ImageRenderPass): The shader code to use. common (str): The common shaderpass code gets executed before all other shaderpasses (buffers/image/sound). Defaults to empty string. - buffers (dict(str)): Codes for buffers A through D. Still requires to set buffer as channel input. Defaults to empty strings. + buffers (dict(str|BufferRenderPass)): Codes for buffers A through D. Still requires to set buffer as channel input. Defaults to empty strings. resolution (tuple): The resolution of the shadertoy in (width, height). Defaults to (800, 450). shader_type (str): Can be "wgsl" or "glsl". On any other value, it will be automatically detected from shader_code. Default is "auto". offscreen (bool): Whether to render offscreen. Default is False. @@ -507,28 +507,11 @@ def _prepare_render(self): texture_binding = (2 * channel_idx) + 1 sampler_binding = 2 * (channel_idx + 1) binding_layout.extend( - [ - { - "binding": texture_binding, - "visibility": wgpu.ShaderStage.FRAGMENT, - "texture": { - "sample_type": wgpu.TextureSampleType.float, - "view_dimension": wgpu.TextureViewDimension.d2, - }, - }, - { - "binding": sampler_binding, - "visibility": wgpu.ShaderStage.FRAGMENT, - "sampler": {"type": wgpu.SamplerBindingType.filtering}, - }, - ] + channel.binding_layout(texture_binding, sampler_binding) ) - texture = self._device.create_texture( - size=channel.texture_size, - format=wgpu.TextureFormat.rgba8unorm, - usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, - ) + texture = channel.create_texture(self._device) + texture_view = texture.create_view() self._device.queue.write_texture( @@ -560,10 +543,7 @@ def _prepare_render(self): }, ] ) - channel_res.append(channel.size[1]) # width - channel_res.append(channel.size[0]) # height - channel_res.append(1) # always 1 for pixel aspect ratio - channel_res.append(-99) # padding/tests + channel_res.extend(channel.channel_res) # padding/tests self._uniform_data["channel_res"] = tuple(channel_res) bind_group_layout = self._device.create_bind_group_layout( entries=binding_layout @@ -756,30 +736,53 @@ def snapshot(self, time_float: float = 0.0, mouse_pos: tuple = (0, 0, 0, 0)): ) shader.show() -class RenderPass(): + +class RenderPass: """ Base class for renderpass in a Shadertoy. Parameters: - parent(Shadertoy): the main Shadertoy class of which this renderpass is part of. + main(Shadertoy): the main Shadertoy class of which this renderpass is part of. code (str): Shadercode for this buffer. shader_type(str): either "wgsl" or "glsl" can also be "auto" - which then gets solved by a regular expression, we should be able to match differnt renderpasses... Defaults to glsl inputs (list): A list of :class:`ShadertoyChannel` objects. Each pass supports up to 4 inputs/channels. If a channel is dected in the code but none provided, will be sampling a black texture. """ - def __init__(self, parent:Shadertoy, code:str, shader_type:str="glsl",inputs=[]) -> None: - self.parent = parent + + def __init__( + self, main: Shadertoy, code: str, shader_type: str = "glsl", inputs=[] + ) -> None: + self.main = main self._shader_type = shader_type self._shader_code = code self.channels = self._attach_inputs(inputs) - def _attach_inputs(self, inputs:list) -> list: + def _attach_inputs(self, inputs: list) -> list: if len(inputs) > 4: raise ValueError("Only 4 inputs supported") - channels = [] - channel_pattern = re.compile( - r"(?:iChannel|i_channel)(\d+)" - ) + + # fill up with None to always have 4 inputs. + if len(inputs) < 4: + inputs.extend([None] * (4 - len(inputs))) + + channel_pattern = re.compile(r"(?:iChannel|i_channel)(\d+)") detected_channels = channel_pattern.findall(self.shader_code) + channels = [] + + for inp_idx, inp in enumerate(inputs): + if inp_idx not in detected_channels: + channels.append(None) + # maybe raise a warning or some error? For unusued channel + elif type(inp) is ShadertoyChannel: + channels.append(inp.infer_subclass(parent=self, input_idx=inp_idx)) + elif isinstance(inp, ShadertoyChannel): + inp.input_idx = inp_idx + inp.parent = self + channels.append(inp) + elif inp is None and inp_idx in detected_channels: + # this is the base case where we sample the black texture. + channels.append(ShadertoyChannelTexture(channel_idx=inp_idx)) + else: + channels.append(None) return channels @@ -805,29 +808,52 @@ def shader_type(self) -> str: "Could not find valid entry point function in shader code. Unable to determine if it's wgsl or glsl." ) + class ImageRenderPass(RenderPass): """ The Image RenderPass of a Shadertoy. """ + pass + class BufferRenderpass(RenderPass): """ The Buffer A-D RenderPass of a Shadertoy. Parameters: buffer_idx (str): one of "A", "B", "C" or "D". Required. """ - def __init__(self, buffer_idx, **kwargs): + + def __init__(self, buffer_idx: str = "", **kwargs): super().__init__(**kwargs) + if buffer_idx: + self._buffer_idx = buffer_idx + + @property + def buffer_idx(self) -> str: + if hasattr(self, "_buffer_idx"): + raise ValueError("Buffer index not set") + return self._buffer_idx.lower() + + @buffer_idx.setter + def buffer_idx(self, value: str): + if value.lower() not in "abcd": + raise ValueError("Buffer index must be one of 'A', 'B', 'C' or 'D'") + self._buffer_idx = value + + @property + def size(self) -> tuple: + # (rows, columns, channels) + return (*self.main.resolution, 4) - pass class CubemapRenderpass(RenderPass): """ The Cube A RenderPass of a Shadertoy. this has slightly different headers see: https://shadertoyunofficial.wordpress.com/2016/07/20/special-shadertoy-features/ """ - pass #TODO at a later date + + pass # TODO at a later date class SoundRenderPass(RenderPass): @@ -835,4 +861,5 @@ class SoundRenderPass(RenderPass): The Sound RenderPass of a Shadertoy. sound is rendered to a buffer at the start and then played back. There is no interactivity.... """ - pass #TODO at a later date + + pass # TODO at a later date From e66747931a2df9e85cb3f5fb7b58d94bcad91292 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 18 May 2024 16:17:53 +0200 Subject: [PATCH 07/59] start move to ImagePass for main image code and channels --- examples/shadertoy_buffer.py | 4 +- tests/test_api.py | 10 +++-- tests/test_textures.py | 32 +++++++------- wgpu_shadertoy/inputs.py | 26 +++++++++++- wgpu_shadertoy/shadertoy.py | 81 +++++++++++------------------------- 5 files changed, 76 insertions(+), 77 deletions(-) diff --git a/examples/shadertoy_buffer.py b/examples/shadertoy_buffer.py index 7af1f4d..39e4785 100644 --- a/examples/shadertoy_buffer.py +++ b/examples/shadertoy_buffer.py @@ -41,7 +41,9 @@ } """ -buffer_a = ShadertoyChannelBuffer(buffer="a") # self input for this buffer? +buffer_a = ShadertoyChannelBuffer( + buffer="a", wrap="repeat" +) # self input for this buffer? shader = Shadertoy(image_code, inputs=[buffer_a], buffers={"a": buffer_code}) if __name__ == "__main__": shader.show() diff --git a/tests/test_api.py b/tests/test_api.py index 78b8231..cce3678 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -43,9 +43,11 @@ def test_shadertoy_from_id(api_available): assert shader.shader_type == "glsl" assert shader.shader_code.startswith("//Confirm API working!") assert shader.common.startswith("//Common pass loaded!") - assert shader.channels[0].sampler_settings["address_mode_u"] == "clamp-to-edge" - assert shader.channels[0].data.shape == (32, 256, 4) - assert shader.channels[0].texture_size == (256, 32, 1) + assert ( + shader.image.channels[0].sampler_settings["address_mode_u"] == "clamp-to-edge" + ) + assert shader.image.channels[0].data.shape == (32, 256, 4) + assert shader.image.channels[0].texture_size == (256, 32, 1) def test_shadertoy_from_id_without_cache(api_available): @@ -59,7 +61,7 @@ def test_shadertoy_from_id_without_cache(api_available): assert shader.shader_type == "glsl" assert shader.shader_code.startswith("//Confirm API working!") assert shader.common.startswith("//Common pass loaded!") - assert shader.channels != [] + assert shader.image.channels != [] # coverage for shader_args_from_json(dict_or_path, **kwargs) diff --git a/tests/test_textures.py b/tests/test_textures.py index bb60486..5be6988 100644 --- a/tests/test_textures.py +++ b/tests/test_textures.py @@ -38,13 +38,15 @@ def test_textures_wgsl(): assert shader.shader_code == shader_code_wgsl assert shader.shader_type == "wgsl" assert ( - shader.channels[0] == channel0 + shader.image.channels[0] == channel0 ) # equivalence only holds true if we use the subclass. - assert np.array_equal(shader.channels[0].data, test_pattern) - assert shader.channels[0].sampler_settings["address_mode_u"] == "repeat" - assert type(shader.channels[1]) is ShadertoyChannelTexture - assert np.array_equal(shader.channels[1].data, gradient) - assert shader.channels[1].sampler_settings["address_mode_u"] == "clamp-to-edge" + assert np.array_equal(shader.image.channels[0].data, test_pattern) + assert shader.image.channels[0].sampler_settings["address_mode_u"] == "repeat" + assert type(shader.image.channels[1]) is ShadertoyChannelTexture + assert np.array_equal(shader.image.channels[1].data, gradient) + assert ( + shader.image.channels[1].sampler_settings["address_mode_u"] == "clamp-to-edge" + ) shader._draw_frame() @@ -80,15 +82,17 @@ def test_textures_glsl(): assert shader.shader_code == shader_code assert shader.shader_type == "glsl" assert ( - shader.channels[0] == channel0 + shader.image.channels[0] == channel0 ) # equivalence only holds true if we use the subclass. - assert np.array_equal(shader.channels[0].data, test_pattern) - assert shader.channels[0].sampler_settings["address_mode_u"] == "repeat" + assert np.array_equal(shader.image.channels[0].data, test_pattern) + assert shader.image.channels[0].sampler_settings["address_mode_u"] == "repeat" assert ( - type(shader.channels[1]) is ShadertoyChannelTexture + type(shader.image.channels[1]) is ShadertoyChannelTexture ) # checks if the subclass is correctly inferred - assert np.array_equal(shader.channels[1].data, gradient) - assert shader.channels[1].sampler_settings["address_mode_u"] == "clamp-to-edge" + assert np.array_equal(shader.image.channels[1].data, gradient) + assert ( + shader.image.channels[1].sampler_settings["address_mode_u"] == "clamp-to-edge" + ) shader._draw_frame() @@ -136,7 +140,7 @@ def test_channel_res_wgsl(): assert shader.resolution == (1200, 900) assert shader.shader_code == shader_code_wgsl assert shader.shader_type == "wgsl" - assert len(shader.channels) == 4 + assert len(shader.image.channels) == 4 assert shader._uniform_data["channel_res"] == [ 800.0, 450.0, @@ -202,7 +206,7 @@ def test_channel_res_glsl(): assert shader.resolution == (1200, 900) assert shader.shader_code == shader_code assert shader.shader_type == "glsl" - assert len(shader.channels) == 4 + assert len(shader.image.channels) == 4 assert shader._uniform_data["channel_res"] == [ 800.0, 450.0, diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 62b9575..e1d4dc3 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -15,6 +15,8 @@ class ShadertoyChannel: # TODO: sampler filter modes: nearest, linear, mipmap (figure out what they mean in wgpu). def __init__(self, *args, ctype=None, channel_idx=None, **kwargs): self.ctype = ctype + if channel_idx is None: + channel_idx = kwargs.pop("channel_idx", None) self._channel_idx = channel_idx self.args = args self.kwargs = kwargs @@ -22,6 +24,7 @@ def __init__(self, *args, ctype=None, channel_idx=None, **kwargs): def infer_subclass(self, *args_, **kwargs_): """ Return the relevant subclass, instantiated with the provided arguments. + TODO: automatically infer it from the provided data/file/link or code. """ args = self.args + args_ kwargs = {**self.kwargs, **kwargs_} @@ -83,10 +86,14 @@ def create_texture(self, device): "This method should likely be implemented in the subclass - but maybe it's all the same? TODO: check later!" ) - def binding_layout(self, binding_idx, sampler_binding): + def binding_layout(self, offset=0): + # TODO: figure out how offset works when we have multiple passes + texture_binding = (2 * self.channel_idx) + 1 + sampler_binding = 2 * (self.channel_idx + 1) + return [ { - "binding": binding_idx, + "binding": texture_binding, "visibility": wgpu.ShaderStage.FRAGMENT, "texture": { "sample_type": wgpu.TextureSampleType.float, @@ -100,6 +107,21 @@ def binding_layout(self, binding_idx, sampler_binding): }, ] + def bind_groups_layout_entries(self, texture_view, sampler, offset=0): + # TODO maybe refactor this all into a prepare bindings method? + texture_binding = (2 * self.channel_idx) + 1 + sampler_binding = 2 * (self.channel_idx + 1) + return [ + { + "binding": texture_binding, + "resource": texture_view, + }, + { + "binding": sampler_binding, + "resource": sampler, + }, + ] + def header_glsl(self, input_idx=0): """ GLSL code snippet added to the compatibilty header for Shadertoy inputs. diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index e6027c5..33847c9 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -262,12 +262,12 @@ def __setitem__(self, key, val): class Shadertoy: - """Provides a "screen pixel shader programming interface" similar to `shadertoy `_. + """Provides a "screen pixel shader programming interface" similar to `shadertoy `. It helps you research and quickly build or test shaders using `WGSL` or `GLSL` via WGPU. Parameters: - shader_code (str|ImageRenderPass): The shader code to use. + shader_code (str|ImageRenderPass): The shader code to use for the Image renderpass. common (str): The common shaderpass code gets executed before all other shaderpasses (buffers/image/sound). Defaults to empty string. buffers (dict(str|BufferRenderPass)): Codes for buffers A through D. Still requires to set buffer as channel input. Defaults to empty strings. resolution (tuple): The resolution of the shadertoy in (width, height). Defaults to (800, 450). @@ -333,11 +333,17 @@ def __init__( self._shader_code = shader_code self.common = common + "\n" + self.image = ImageRenderPass(main=self, code=shader_code, inputs=inputs) self.buffers = {"a": "", "b": "", "c": "", "d": ""} for k, v in buffers.items(): k = k.lower()[-1] if k not in "abcd": raise ValueError(f"Invalid buffer key: {k}") + if type(v) is BufferRenderPass: + v.main = self + v.buffer_idx = k + elif not isinstance(v, str): + raise ValueError(f"Invalid buffer value: {v=}") self.buffers[k] = v self._uniform_data["resolution"] = (*resolution, 1) @@ -353,42 +359,13 @@ def __init__( inputs.extend([None] * (4 - len(inputs))) # likely a better solution. But theoretically, someone could set one or more inputs but still mention a channel beyond that. - self.channels = [None] * 4 - channel_pattern = re.compile( - r"(?:iChannel|i_channel)(\d+)" - ) # non capturing group is important! - - # TODO: redo this whole logic as channel_idx is available from the api - # so we only need to assign it if it's not set, like when using the classes directly. - for channel_idx in channel_pattern.findall(shader_code): - channel_idx = int(channel_idx) - if channel_idx not in (0, 1, 2, 3): - raise ValueError( - f"Only iChannel0 to iChannel3 are supported. Found {channel_idx=}" - ) - if inputs[channel_idx] is None: - self.channels[channel_idx] = ShadertoyChannelTexture( - channel_idx=channel_idx - ) - elif type(inputs[channel_idx]) is ShadertoyChannel: - self.channels[channel_idx] = inputs[channel_idx].infer_subclass( - main=self - ) - elif isinstance(inputs[channel_idx], ShadertoyChannel): - self.channels[channel_idx] = inputs[channel_idx] - else: - raise ValueError( - f"Invalid input type for channel {channel_idx=} - {inputs[channel_idx]=}" - ) - self.channels[channel_idx].channel_idx = channel_idx # redundant? - self.title = title self.complete = complete if not self.complete: self.title += " (incomplete)" - self._prepare_render() + self._prepare_render(self.image) self._bind_events() @property @@ -430,7 +407,7 @@ def from_id(cls, id_or_url: str, **kwargs): shader_data = shadertoy_from_id(id_or_url) return cls.from_json(shader_data, **kwargs) - def _prepare_render(self): + def _prepare_render(self, renderpass): import wgpu.backends.auto if self._offscreen: @@ -500,15 +477,11 @@ def _prepare_render(self): }, ] channel_res = [] - for channel_idx, channel in enumerate(self.channels): + for channel in renderpass.channels: if channel is None: channel_res.extend([0, 0, 1, -99]) # default values; quick hack continue - texture_binding = (2 * channel_idx) + 1 - sampler_binding = 2 * (channel_idx + 1) - binding_layout.extend( - channel.binding_layout(texture_binding, sampler_binding) - ) + binding_layout.extend(channel.binding_layout(offset=0)) texture = channel.create_texture(self._device) @@ -532,16 +505,7 @@ def _prepare_render(self): sampler = self._device.create_sampler(**channel.sampler_settings) bind_groups_layout_entries.extend( - [ - { - "binding": texture_binding, - "resource": texture_view, - }, - { - "binding": sampler_binding, - "resource": sampler, - }, - ] + channel.bind_groups_layout_entries(texture_view, sampler) ) channel_res.extend(channel.channel_res) # padding/tests self._uniform_data["channel_res"] = tuple(channel_res) @@ -747,6 +711,7 @@ class RenderPass: inputs (list): A list of :class:`ShadertoyChannel` objects. Each pass supports up to 4 inputs/channels. If a channel is dected in the code but none provided, will be sampling a black texture. """ + # TODO: uniform data is per pass (as it includes iChannelResolution...) def __init__( self, main: Shadertoy, code: str, shader_type: str = "glsl", inputs=[] ) -> None: @@ -755,7 +720,7 @@ def __init__( self._shader_code = code self.channels = self._attach_inputs(inputs) - def _attach_inputs(self, inputs: list) -> list: + def _attach_inputs(self, inputs: list) -> list[ShadertoyChannel, None]: if len(inputs) > 4: raise ValueError("Only 4 inputs supported") @@ -764,7 +729,9 @@ def _attach_inputs(self, inputs: list) -> list: inputs.extend([None] * (4 - len(inputs))) channel_pattern = re.compile(r"(?:iChannel|i_channel)(\d+)") - detected_channels = channel_pattern.findall(self.shader_code) + detected_channels = [ + int(c) for c in set(channel_pattern.findall(self.shader_code)) + ] channels = [] @@ -773,9 +740,9 @@ def _attach_inputs(self, inputs: list) -> list: channels.append(None) # maybe raise a warning or some error? For unusued channel elif type(inp) is ShadertoyChannel: - channels.append(inp.infer_subclass(parent=self, input_idx=inp_idx)) + channels.append(inp.infer_subclass(parent=self, channel_idx=inp_idx)) elif isinstance(inp, ShadertoyChannel): - inp.input_idx = inp_idx + inp.channel_idx = inp_idx inp.parent = self channels.append(inp) elif inp is None and inp_idx in detected_channels: @@ -814,10 +781,12 @@ class ImageRenderPass(RenderPass): The Image RenderPass of a Shadertoy. """ - pass + def __init__(self, **kwargs): + super().__init__(**kwargs) + # TODO figure out if there is anything specific. Maybe the canvas stuff? perhaps that should stay in the main class... -class BufferRenderpass(RenderPass): +class BufferRenderPass(RenderPass): """ The Buffer A-D RenderPass of a Shadertoy. Parameters: @@ -847,7 +816,7 @@ def size(self) -> tuple: return (*self.main.resolution, 4) -class CubemapRenderpass(RenderPass): +class CubemapRenderPass(RenderPass): """ The Cube A RenderPass of a Shadertoy. this has slightly different headers see: https://shadertoyunofficial.wordpress.com/2016/07/20/special-shadertoy-features/ From 451f9f4dd8f1e26bd414763395460c798ee1ad30 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 21 May 2024 12:20:04 +0200 Subject: [PATCH 08/59] start draw_buffer function --- wgpu_shadertoy/inputs.py | 40 ++++++++++++++++++++++++++----------- wgpu_shadertoy/shadertoy.py | 39 ++++++++++++++++++++++++++++-------- 2 files changed, 59 insertions(+), 20 deletions(-) diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index e1d4dc3..e20fb8a 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -1,7 +1,6 @@ import numpy as np import wgpu - class ShadertoyChannel: """ ShadertoyChannel Base class. If nothing is provided, it defaults to a 8x8 black texture. @@ -54,7 +53,7 @@ def sampler_settings(self): return sampler_settings @property - def parent(self): + def parent(self):# TODO: likely make a passes.py file to make typing possible -> RenderPass: """Parent of this input is a renderpass.""" if not hasattr(self, "_parent"): raise AttributeError("Parent not set.") @@ -81,7 +80,18 @@ def channel_res(self) -> tuple: """iChannelResolution[N] information for the uniform. Tuple of (width, height, 1, -99)""" raise NotImplementedError("likely implemented for ChannelTexture") - def create_texture(self, device): + @property + def size(self): #tuple? + return self.data.shape + + @property + def bytes_per_pixel(self) -> int: + return( + self.data.nbytes // self.data.shape[1] // self.data.shape[0] + ) + + + def create_texture(self, device) -> wgpu.GPUTexture: raise NotImplementedError( "This method should likely be implemented in the subclass - but maybe it's all the same? TODO: check later!" ) @@ -196,12 +206,21 @@ def __init__(self, buffer, parent=None, **kwargs): self.buffer_idx = buffer # A,B,C or D? if parent is not None: self._parent = parent + + @property + def renderpass(self):# -> BufferRenderPass: + return self.parent.main.buffers[self.buffer_idx] + + @property + def data(self) -> memoryview: + return self.renderpass.last_frame - def create_texture(self, device): + def create_texture(self, device) -> wgpu.GPUTexture: + # TODO: this likely needs to be in the parent pass and simply accessed here... texture = device.create_texture( - size=self.parent.size, + size=self.renderpass.texture_size, format=wgpu.TextureFormat.rgba8unorm, - usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, + usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.RENDER_ATTACHMENT, ) return texture @@ -247,16 +266,13 @@ def __init__(self, data=None, **kwargs): [self.data, np.full(self.data.shape[:2] + (1,), 255, dtype=np.uint8)], axis=2, ) - - self.size = self.data.shape # (rows, columns, channels) + self.texture_size = ( self.data.shape[1], self.data.shape[0], 1, ) # orientation change (columns, rows, 1) - self.bytes_per_pixel = ( - self.data.nbytes // self.data.shape[1] // self.data.shape[0] - ) + vflip = kwargs.pop("vflip", True) if vflip in ("true", True): vflip = True @@ -271,7 +287,7 @@ def channel_res(self) -> tuple: -99, ) # (width, height, pixel_aspect=1, padding=-99) - def create_texture(self, device): + def create_texture(self, device) -> wgpu.GPUTexture: texture = device.create_texture( size=self.texture_size, format=wgpu.TextureFormat.rgba8unorm, diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index 33847c9..014a420 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -339,13 +339,17 @@ def __init__( k = k.lower()[-1] if k not in "abcd": raise ValueError(f"Invalid buffer key: {k}") - if type(v) is BufferRenderPass: + if v == "": + continue + elif type(v) is BufferRenderPass: v.main = self v.buffer_idx = k + self.buffers[k] = v elif not isinstance(v, str): raise ValueError(f"Invalid buffer value: {v=}") - self.buffers[k] = v - + else: + self.buffers[k] = BufferRenderPass(buffer_idx=k, code=v, main=self) + self._uniform_data["resolution"] = (*resolution, 1) self._shader_type = shader_type.lower() @@ -407,7 +411,7 @@ def from_id(cls, id_or_url: str, **kwargs): shader_data = shadertoy_from_id(id_or_url) return cls.from_json(shader_data, **kwargs) - def _prepare_render(self, renderpass): + def _prepare_render(self, renderpass) -> None: import wgpu.backends.auto if self._offscreen: @@ -434,7 +438,7 @@ def _prepare_render(self, renderpass): frag_shader_code = ( builtin_variables_glsl + self.common - + self.shader_code + + renderpass.shader_code + fragment_code_glsl ) elif shader_type == "wgsl": @@ -797,6 +801,7 @@ def __init__(self, buffer_idx: str = "", **kwargs): super().__init__(**kwargs) if buffer_idx: self._buffer_idx = buffer_idx + self.last_frame = memoryview(bytearray(256)).cast("B", shape=[8,8,4]) #TODO maybe this needs to scale with the size... but this is only for initilization @property def buffer_idx(self) -> str: @@ -811,9 +816,27 @@ def buffer_idx(self, value: str): self._buffer_idx = value @property - def size(self) -> tuple: - # (rows, columns, channels) - return (*self.main.resolution, 4) + def texture_size(self) -> tuple: + # (columns, rows, 1) + return (int(self.main.resolution[1]), int(self.main.resolution[0]), 1) + + def draw_buffer(self, device:wgpu.GPUDevice, texture) -> None: + buffer = device.create_buffer(size=self.texture_size * 4, usage=wgpu.BufferUsage.COPY_DST) + command_encoder = device.create_command_encoder() + command_encoder.copy_texture_to_buffer({ + "texture": texture, + "mip_level": 0, + "origin": (0, 0, 0), + }, { + "buffer": buffer, + "offset": 0, + "bytes_per_row": self.texture_size[0] * 4, + "rows_per_image": self.texture_size[1], + }, self.texture_size) + + device.queue.submit([command_encoder.finish()]) + # overwrite here - when triggered via main! + self.last_frame = device.queue.read_buffer(buffer) class CubemapRenderPass(RenderPass): From 18c0990886f83b8d3ae0fea8f4c125418bb09730 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 21 May 2024 13:29:44 +0200 Subject: [PATCH 09/59] split up _prepare_render --- wgpu_shadertoy/inputs.py | 24 +++++--- wgpu_shadertoy/shadertoy.py | 110 +++++++++++++++++++++++------------- 2 files changed, 86 insertions(+), 48 deletions(-) diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index e20fb8a..fba7f82 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -1,6 +1,7 @@ import numpy as np import wgpu + class ShadertoyChannel: """ ShadertoyChannel Base class. If nothing is provided, it defaults to a 8x8 black texture. @@ -53,7 +54,9 @@ def sampler_settings(self): return sampler_settings @property - def parent(self):# TODO: likely make a passes.py file to make typing possible -> RenderPass: + def parent( + self, + ): # TODO: likely make a passes.py file to make typing possible -> RenderPass: """Parent of this input is a renderpass.""" if not hasattr(self, "_parent"): raise AttributeError("Parent not set.") @@ -81,15 +84,12 @@ def channel_res(self) -> tuple: raise NotImplementedError("likely implemented for ChannelTexture") @property - def size(self): #tuple? + def size(self): # tuple? return self.data.shape @property def bytes_per_pixel(self) -> int: - return( - self.data.nbytes // self.data.shape[1] // self.data.shape[0] - ) - + return self.data.nbytes // self.data.shape[1] // self.data.shape[0] def create_texture(self, device) -> wgpu.GPUTexture: raise NotImplementedError( @@ -206,16 +206,22 @@ def __init__(self, buffer, parent=None, **kwargs): self.buffer_idx = buffer # A,B,C or D? if parent is not None: self._parent = parent - + @property - def renderpass(self):# -> BufferRenderPass: + def renderpass(self): # -> BufferRenderPass: return self.parent.main.buffers[self.buffer_idx] @property def data(self) -> memoryview: + """ + previous frame rendered by this buffer. buffers render in order A, B, C, D. and before the given Image. + """ return self.renderpass.last_frame def create_texture(self, device) -> wgpu.GPUTexture: + """ + The output texture of the buffer (last frame?), to be sampled by specified sampler in this channel. + """ # TODO: this likely needs to be in the parent pass and simply accessed here... texture = device.create_texture( size=self.renderpass.texture_size, @@ -266,7 +272,7 @@ def __init__(self, data=None, **kwargs): [self.data, np.full(self.data.shape[:2] + (1,), 255, dtype=np.uint8)], axis=2, ) - + self.texture_size = ( self.data.shape[1], self.data.shape[0], diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index 014a420..14d913f 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -349,7 +349,7 @@ def __init__( raise ValueError(f"Invalid buffer value: {v=}") else: self.buffers[k] = BufferRenderPass(buffer_idx=k, code=v, main=self) - + self._uniform_data["resolution"] = (*resolution, 1) self._shader_type = shader_type.lower() @@ -369,9 +369,12 @@ def __init__( if not self.complete: self.title += " (incomplete)" - self._prepare_render(self.image) + self._prepare_canvas() self._bind_events() + # TODO: extend this to all renderpasses + self._prepare_render(self.image) + @property def resolution(self): """The resolution of the shadertoy as a tuple (width, height) in pixels.""" @@ -411,7 +414,7 @@ def from_id(cls, id_or_url: str, **kwargs): shader_data = shadertoy_from_id(id_or_url) return cls.from_json(shader_data, **kwargs) - def _prepare_render(self, renderpass) -> None: + def _prepare_canvas(self): import wgpu.backends.auto if self._offscreen: @@ -432,6 +435,7 @@ def _prepare_render(self, renderpass) -> None: device=self._device, format=wgpu.TextureFormat.bgra8unorm ) + def _prepare_render(self, renderpass) -> None: shader_type = self.shader_type if shader_type == "glsl": vertex_shader_code = vertex_code_glsl @@ -508,6 +512,7 @@ def _prepare_render(self, renderpass) -> None: ) sampler = self._device.create_sampler(**channel.sampler_settings) + # TODO: explore using auto layouts (pygfx/wgpu-py#500) bind_groups_layout_entries.extend( channel.bind_groups_layout_entries(texture_view, sampler) ) @@ -633,27 +638,13 @@ def _draw_frame(self): self._uniform_data.nbytes, ) - command_encoder = self._device.create_command_encoder() - current_texture = self._present_context.get_current_texture() - - render_pass = command_encoder.begin_render_pass( - color_attachments=[ - { - "view": current_texture.create_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._render_pipeline) - render_pass.set_bind_group(0, self._bind_group, [], 0, 99) - render_pass.draw(3, 1, 0, 0) - render_pass.end() - - self._device.queue.submit([command_encoder.finish()]) + for buf in self.buffers.values(): + if buf: # checks if not None? + buf.draw_buffer( + self._device, self.image.channels[buf.buffer_idx].texture + ) + # TODO: most of the code below here is for the image renderpass... + self.image.draw_image(self._device, self._present_context) self._canvas.request_draw() @@ -789,6 +780,33 @@ def __init__(self, **kwargs): super().__init__(**kwargs) # TODO figure out if there is anything specific. Maybe the canvas stuff? perhaps that should stay in the main class... + def draw_image(self, device: wgpu.GPUDevice, present_context) -> None: + """ + Draws the main image pass to the screen. + """ + # TODO: refactor all the self.main instances to self, so attributes are attached to the pass. Perhaps even turn _prepare_render into a method of RenderPass. + command_encoder = device.create_command_encoder() + current_texture = present_context.get_current_texture() + + render_pass = command_encoder.begin_render_pass( + color_attachments=[ + { + "view": current_texture.create_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.main._render_pipeline) + render_pass.set_bind_group(0, self.main._bind_group, [], 0, 99) + render_pass.draw(3, 1, 0, 0) + render_pass.end() + + device.queue.submit([command_encoder.finish()]) + class BufferRenderPass(RenderPass): """ @@ -801,7 +819,11 @@ def __init__(self, buffer_idx: str = "", **kwargs): super().__init__(**kwargs) if buffer_idx: self._buffer_idx = buffer_idx - self.last_frame = memoryview(bytearray(256)).cast("B", shape=[8,8,4]) #TODO maybe this needs to scale with the size... but this is only for initilization + self.last_frame = memoryview( + bytearray(256) + ).cast( + "B", shape=[8, 8, 4] + ) # TODO maybe this needs to scale with the size... but this is only for initilization @property def buffer_idx(self) -> str: @@ -819,21 +841,31 @@ def buffer_idx(self, value: str): def texture_size(self) -> tuple: # (columns, rows, 1) return (int(self.main.resolution[1]), int(self.main.resolution[0]), 1) - - def draw_buffer(self, device:wgpu.GPUDevice, texture) -> None: - buffer = device.create_buffer(size=self.texture_size * 4, usage=wgpu.BufferUsage.COPY_DST) + + def draw_buffer(self, device: wgpu.GPUDevice, texture) -> None: + """ + draws the buffer to the texture and updates self.last_frame + """ + buffer = device.create_buffer( + size=self.texture_size * 4, usage=wgpu.BufferUsage.COPY_DST + ) command_encoder = device.create_command_encoder() - command_encoder.copy_texture_to_buffer({ - "texture": texture, - "mip_level": 0, - "origin": (0, 0, 0), - }, { - "buffer": buffer, - "offset": 0, - "bytes_per_row": self.texture_size[0] * 4, - "rows_per_image": self.texture_size[1], - }, self.texture_size) - + # TODO: this section likely makes no sense, we need to do a .create_render_pass and also have pipeline etc available. + command_encoder.copy_texture_to_buffer( + { + "texture": texture, + "mip_level": 0, + "origin": (0, 0, 0), + }, + { + "buffer": buffer, + "offset": 0, + "bytes_per_row": self.texture_size[0] * 4, + "rows_per_image": self.texture_size[1], + }, + self.texture_size, + ) + device.queue.submit([command_encoder.finish()]) # overwrite here - when triggered via main! self.last_frame = device.queue.read_buffer(buffer) From 71d97d4f79c5d33065bdce9511aa5a1d7e94a48f Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 21 May 2024 21:59:57 +0200 Subject: [PATCH 10/59] initialize buffers with zero --- wgpu_shadertoy/inputs.py | 30 +++++++++++++++--------------- wgpu_shadertoy/shadertoy.py | 29 ++++++++++++++++------------- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index fba7f82..cf3b323 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -80,16 +80,23 @@ def channel_idx(self, idx=int): @property def channel_res(self) -> tuple: - """iChannelResolution[N] information for the uniform. Tuple of (width, height, 1, -99)""" - raise NotImplementedError("likely implemented for ChannelTexture") + return ( + self.size[1], + self.size[0], + 1, + -99, + ) # (width, height, pixel_aspect=1, padding=-99) @property - def size(self): # tuple? + def size(self) -> tuple: # tuple? return self.data.shape @property - def bytes_per_pixel(self) -> int: - return self.data.nbytes // self.data.shape[1] // self.data.shape[0] + def bytes_per_pixel( + self, + ) -> int: # usually is 4 for rgba8unorm or maybe use self.data.strides[1]? + bpp = self.data.nbytes // self.data.shape[1] // self.data.shape[0] + return bpp def create_texture(self, device) -> wgpu.GPUTexture: raise NotImplementedError( @@ -226,7 +233,9 @@ def create_texture(self, device) -> wgpu.GPUTexture: texture = device.create_texture( size=self.renderpass.texture_size, format=wgpu.TextureFormat.rgba8unorm, - usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.RENDER_ATTACHMENT, + usage=wgpu.TextureUsage.COPY_DST + | wgpu.TextureUsage.RENDER_ATTACHMENT + | wgpu.TextureUsage.TEXTURE_BINDING, # which ones do we actually need? ) return texture @@ -284,15 +293,6 @@ def __init__(self, data=None, **kwargs): vflip = True self.data = np.ascontiguousarray(self.data[::-1, :, :]) - @property - def channel_res(self) -> tuple: - return ( - self.size[1], - self.size[0], - 1, - -99, - ) # (width, height, pixel_aspect=1, padding=-99) - def create_texture(self, device) -> wgpu.GPUTexture: texture = device.create_texture( size=self.texture_size, diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index 14d913f..dc16e70 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -4,6 +4,7 @@ import re import time +import numpy as np import wgpu from wgpu.gui.auto import WgpuCanvas, run from wgpu.gui.offscreen import WgpuCanvas as OffscreenCanvas @@ -332,6 +333,8 @@ def __init__( self._shader_code = shader_code self.common = common + "\n" + self._uniform_data["resolution"] = (*resolution, 1) + self._shader_type = shader_type.lower() self.image = ImageRenderPass(main=self, code=shader_code, inputs=inputs) self.buffers = {"a": "", "b": "", "c": "", "d": ""} @@ -350,9 +353,6 @@ def __init__( else: self.buffers[k] = BufferRenderPass(buffer_idx=k, code=v, main=self) - self._uniform_data["resolution"] = (*resolution, 1) - self._shader_type = shader_type.lower() - # if no explicit offscreen option was given # inherit wgpu-py force offscreen option if offscreen is None and os.environ.get("WGPU_FORCE_OFFSCREEN") == "true": @@ -370,10 +370,11 @@ def __init__( self.title += " (incomplete)" self._prepare_canvas() + self._prepare_render( + self.image + ) # side effects here are uniform buffer - so this needs to happen first? self._bind_events() - # TODO: extend this to all renderpasses - self._prepare_render(self.image) @property def resolution(self): @@ -640,9 +641,8 @@ def _draw_frame(self): for buf in self.buffers.values(): if buf: # checks if not None? - buf.draw_buffer( - self._device, self.image.channels[buf.buffer_idx].texture - ) + pass # TODO: actually rewrtite this function + # buf.draw_buffer(self._device, ) # does this need kind of the target to write too? # TODO: most of the code below here is for the image renderpass... self.image.draw_image(self._device, self._present_context) @@ -819,11 +819,14 @@ def __init__(self, buffer_idx: str = "", **kwargs): super().__init__(**kwargs) if buffer_idx: self._buffer_idx = buffer_idx - self.last_frame = memoryview( - bytearray(256) - ).cast( - "B", shape=[8, 8, 4] - ) # TODO maybe this needs to scale with the size... but this is only for initilization + self.last_frame = np.ascontiguousarray( + np.zeros( + shape=(self.texture_size[1], self.texture_size[0], 4), dtype=np.uint8 + ) + ) + # TODO: find a generally better solution for this dimension swap between data shape and texture_size + # maybe use self.main.resolution instead but we do need ints for shape. Perhaps refactor to a method as this really only initializes the buffer with zeros? + # do we need to write this to the buffer once? @property def buffer_idx(self) -> str: From f1826992c522c944df48300baf45504f92d163eb Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 22 May 2024 00:25:09 +0200 Subject: [PATCH 11/59] move prepare_render function to passes --- wgpu_shadertoy/inputs.py | 7 +- wgpu_shadertoy/shadertoy.py | 306 ++++++++++++++++++------------------ 2 files changed, 154 insertions(+), 159 deletions(-) diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index cf3b323..168e19b 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -40,7 +40,7 @@ def infer_subclass(self, *args_, **kwargs_): ) @property - def sampler_settings(self): + def sampler_settings(self) -> dict: """ Sampler settings for this channel. Wrap currently supported. Filter not yet. """ @@ -54,9 +54,8 @@ def sampler_settings(self): return sampler_settings @property - def parent( - self, - ): # TODO: likely make a passes.py file to make typing possible -> RenderPass: + def parent(self): + # TODO: likely make a passes.py file to make typing possible -> RenderPass: """Parent of this input is a renderpass.""" if not hasattr(self, "_parent"): raise AttributeError("Parent not set.") diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index dc16e70..e3a565e 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -312,7 +312,7 @@ def __init__( "b": "", "c": "", "d": "", - }, # maybe Default dict instead? + }, resolution=(800, 450), shader_type="auto", offscreen=None, @@ -336,7 +336,9 @@ def __init__( self._uniform_data["resolution"] = (*resolution, 1) self._shader_type = shader_type.lower() - self.image = ImageRenderPass(main=self, code=shader_code, inputs=inputs) + self.image = ImageRenderPass( + main=self, code=shader_code, shader_type=shader_type, inputs=inputs + ) self.buffers = {"a": "", "b": "", "c": "", "d": ""} for k, v in buffers.items(): k = k.lower()[-1] @@ -370,11 +372,11 @@ def __init__( self.title += " (incomplete)" self._prepare_canvas() - self._prepare_render( - self.image - ) # side effects here are uniform buffer - so this needs to happen first? self._bind_events() - # TODO: extend this to all renderpasses + # TODO: could this be part of the __init__ of each renderpass? (but we need the device) + for rpass in (self.image, *self.buffers.values()): + if rpass: # skip None + rpass.prepare_render(device=self._device) @property def resolution(self): @@ -386,22 +388,13 @@ def shader_code(self) -> str: """The shader code to use.""" return self._shader_code + # TODO: remove this redundant code snippet @property def shader_type(self) -> str: - """The shader type, automatically detected from the shader code, can be "wgsl" or "glsl".""" - if self._shader_type in ("wgsl", "glsl"): - return self._shader_type - - wgsl_main_expr = re.compile(r"fn(?:\s)+shader_main") - glsl_main_expr = re.compile(r"void(?:\s)+(?:shader_main|mainImage)") - if wgsl_main_expr.search(self.shader_code): - return "wgsl" - elif glsl_main_expr.search(self.shader_code): - return "glsl" - else: - raise ValueError( - "Could not find valid entry point function in shader code. Unable to determine if it's wgsl or glsl." - ) + """ + The shader type of the main image renderpass. + """ + return self.image.shader_type @classmethod def from_json(cls, dict_or_path, **kwargs): @@ -436,137 +429,6 @@ def _prepare_canvas(self): device=self._device, format=wgpu.TextureFormat.bgra8unorm ) - def _prepare_render(self, renderpass) -> None: - shader_type = self.shader_type - if shader_type == "glsl": - vertex_shader_code = vertex_code_glsl - frag_shader_code = ( - builtin_variables_glsl - + self.common - + renderpass.shader_code - + fragment_code_glsl - ) - elif shader_type == "wgsl": - vertex_shader_code = vertex_code_wgsl - frag_shader_code = ( - builtin_variables_wgsl - + self.common - + self.shader_code - + fragment_code_wgsl - ) - - vertex_shader_program = self._device.create_shader_module( - label="triangle_vert", code=vertex_shader_code - ) - frag_shader_program = self._device.create_shader_module( - label="triangle_frag", code=frag_shader_code - ) - - self._uniform_buffer = self._device.create_buffer( - size=self._uniform_data.nbytes, - usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, - ) - - bind_groups_layout_entries = [ - { - "binding": 0, - "resource": { - "buffer": self._uniform_buffer, - "offset": 0, - "size": self._uniform_data.nbytes, - }, - }, - ] - - binding_layout = [ - { - "binding": 0, - "visibility": wgpu.ShaderStage.FRAGMENT, - "buffer": {"type": wgpu.BufferBindingType.uniform}, - }, - ] - channel_res = [] - for channel in renderpass.channels: - if channel is None: - channel_res.extend([0, 0, 1, -99]) # default values; quick hack - continue - binding_layout.extend(channel.binding_layout(offset=0)) - - texture = channel.create_texture(self._device) - - texture_view = texture.create_view() - - self._device.queue.write_texture( - { - "texture": texture, - "origin": (0, 0, 0), - "mip_level": 0, - }, - channel.data, - { - "offset": 0, - "bytes_per_row": channel.bytes_per_pixel - * channel.size[1], # must be multiple of 256? - "rows_per_image": channel.size[0], # same is done internally - }, - texture.size, - ) - - sampler = self._device.create_sampler(**channel.sampler_settings) - # TODO: explore using auto layouts (pygfx/wgpu-py#500) - bind_groups_layout_entries.extend( - channel.bind_groups_layout_entries(texture_view, sampler) - ) - channel_res.extend(channel.channel_res) # padding/tests - self._uniform_data["channel_res"] = tuple(channel_res) - bind_group_layout = self._device.create_bind_group_layout( - entries=binding_layout - ) - - self._bind_group = self._device.create_bind_group( - layout=bind_group_layout, - entries=bind_groups_layout_entries, - ) - - self._render_pipeline = self._device.create_render_pipeline( - layout=self._device.create_pipeline_layout( - bind_group_layouts=[bind_group_layout] - ), - vertex={ - "module": vertex_shader_program, - "entry_point": "main", - "buffers": [], - }, - primitive={ - "topology": wgpu.PrimitiveTopology.triangle_list, - "front_face": wgpu.FrontFace.ccw, - "cull_mode": wgpu.CullMode.none, - }, - depth_stencil=None, - multisample=None, - fragment={ - "module": frag_shader_program, - "entry_point": "main", - "targets": [ - { - "format": wgpu.TextureFormat.bgra8unorm, - "blend": { - "color": ( - wgpu.BlendFactor.one, - wgpu.BlendFactor.zero, - wgpu.BlendOperation.add, - ), - "alpha": ( - wgpu.BlendFactor.one, - wgpu.BlendFactor.zero, - wgpu.BlendOperation.add, - ), - }, - }, - ], - }, - ) - def _bind_events(self): def on_resize(event): w, h = event["width"], event["height"] @@ -632,7 +494,7 @@ def _draw_frame(self): # Update uniform buffer self._update() self._device.queue.write_buffer( - self._uniform_buffer, + self.image._uniform_buffer, 0, self._uniform_data.mem, 0, @@ -714,6 +576,9 @@ def __init__( self._shader_type = shader_type self._shader_code = code self.channels = self._attach_inputs(inputs) + self._uniform_data = ( + main._uniform_data + ) # default from main - but might be different for each renderpass def _attach_inputs(self, inputs: list) -> list[ShadertoyChannel, None]: if len(inputs) > 4: @@ -748,6 +613,137 @@ def _attach_inputs(self, inputs: list) -> list[ShadertoyChannel, None]: return channels + def prepare_render(self, device: wgpu.GPUDevice) -> None: + # Step 1: compose shader programs + shader_type = self.shader_type + if shader_type == "glsl": + vertex_shader_code = vertex_code_glsl + frag_shader_code = ( + builtin_variables_glsl + + self.main.common + + self.shader_code + + fragment_code_glsl + ) + elif shader_type == "wgsl": + vertex_shader_code = vertex_code_wgsl + frag_shader_code = ( + builtin_variables_wgsl + + self.main.common + + self.shader_code + + fragment_code_wgsl + ) + + vertex_shader_program = device.create_shader_module( + label="triangle_vert", code=vertex_shader_code + ) + frag_shader_program = device.create_shader_module( + label="triangle_frag", code=frag_shader_code + ) + + # Step 2: map uniform data to buffer + self._uniform_buffer = device.create_buffer( + size=self._uniform_data.nbytes, + usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, + ) + + bind_groups_layout_entries = [ + { + "binding": 0, + "resource": { + "buffer": self._uniform_buffer, + "offset": 0, + "size": self._uniform_data.nbytes, + }, + }, + ] + + binding_layout = [ + { + "binding": 0, + "visibility": wgpu.ShaderStage.FRAGMENT, + "buffer": {"type": wgpu.BufferBindingType.uniform}, + }, + ] + + channel_res = [] + for channel in self.channels: + if channel is None: + channel_res.extend([0, 0, 1, -99]) # default values; quick hack + continue + + binding_layout.extend(channel.binding_layout(offset=0)) + texture = channel.create_texture(device) + texture_view = texture.create_view() + # typing missing in wgpu-py for queue + device.queue.write_texture( + { + "texture": texture, + "origin": (0, 0, 0), + "mip_level": 0, + }, + channel.data, + { + "offset": 0, + "bytes_per_row": channel.bytes_per_pixel + * channel.size[1], # multiple of 256 + "rows_per_image": channel.size[0], # same is done internally + }, + texture.size, + ) + + sampler = device.create_sampler(**channel.sampler_settings) + # TODO: explore using auto layouts (pygfx/wgpu-py#500) + bind_groups_layout_entries.extend( + channel.bind_groups_layout_entries(texture_view, sampler) + ) + channel_res.extend(channel.channel_res) # padding/tests + self._uniform_data["channel_res"] = tuple(channel_res) + bind_group_layout = device.create_bind_group_layout(entries=binding_layout) + + self._bind_group = device.create_bind_group( + layout=bind_group_layout, + entries=bind_groups_layout_entries, + ) + + self._render_pipeline = device.create_render_pipeline( + layout=device.create_pipeline_layout( + bind_group_layouts=[bind_group_layout] + ), + vertex={ + "module": vertex_shader_program, + "entry_point": "main", + "buffers": [], + }, + primitive={ + "topology": wgpu.PrimitiveTopology.triangle_list, + "front_face": wgpu.FrontFace.ccw, + "cull_mode": wgpu.CullMode.none, + }, + depth_stencil=None, + multisample=None, + fragment={ + "module": frag_shader_program, + "entry_point": "main", + "targets": [ + { + "format": wgpu.TextureFormat.bgra8unorm, + "blend": { + "color": ( + wgpu.BlendFactor.one, + wgpu.BlendFactor.zero, + wgpu.BlendOperation.add, + ), + "alpha": ( + wgpu.BlendFactor.one, + wgpu.BlendFactor.zero, + wgpu.BlendOperation.add, + ), + }, + }, + ], + }, + ) + @property def shader_code(self) -> str: """The shader code to use.""" @@ -784,10 +780,10 @@ def draw_image(self, device: wgpu.GPUDevice, present_context) -> None: """ Draws the main image pass to the screen. """ - # TODO: refactor all the self.main instances to self, so attributes are attached to the pass. Perhaps even turn _prepare_render into a method of RenderPass. command_encoder = device.create_command_encoder() current_texture = present_context.get_current_texture() + # TODO: maybe use a different name in this case? render_pass = command_encoder.begin_render_pass( color_attachments=[ { @@ -800,8 +796,8 @@ def draw_image(self, device: wgpu.GPUDevice, present_context) -> None: ], ) - render_pass.set_pipeline(self.main._render_pipeline) - render_pass.set_bind_group(0, self.main._bind_group, [], 0, 99) + render_pass.set_pipeline(self._render_pipeline) + render_pass.set_bind_group(0, self._bind_group, [], 0, 99) render_pass.draw(3, 1, 0, 0) render_pass.end() From 60e8a3a8ee93f221ab08fadb9a966210713d7001 Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 22 May 2024 01:32:53 +0200 Subject: [PATCH 12/59] static buffer pass working? --- examples/shadertoy_buffer.py | 18 ++++--- wgpu_shadertoy/__init__.py | 2 +- wgpu_shadertoy/shadertoy.py | 99 +++++++++++++++++++++++++++--------- 3 files changed, 88 insertions(+), 31 deletions(-) diff --git a/examples/shadertoy_buffer.py b/examples/shadertoy_buffer.py index 39e4785..2b88d84 100644 --- a/examples/shadertoy_buffer.py +++ b/examples/shadertoy_buffer.py @@ -1,6 +1,6 @@ # run_example = false # buffer passes in development -from wgpu_shadertoy import Shadertoy +from wgpu_shadertoy import BufferRenderPass, Shadertoy from wgpu_shadertoy.inputs import ShadertoyChannelBuffer # shadertoy source: https://www.shadertoy.com/view/lljcDG by rkibria CC-BY-NC-SA-3.0 @@ -37,13 +37,19 @@ if (k <= 0.0) j = 0.0; - fragColor = vec4(k, j, 0.0, 1.0); + fragColor = vec4(0.5, j, 0.0, 1.0); } """ -buffer_a = ShadertoyChannelBuffer( - buffer="a", wrap="repeat" -) # self input for this buffer? -shader = Shadertoy(image_code, inputs=[buffer_a], buffers={"a": buffer_code}) +buffer_a_channel = ShadertoyChannelBuffer(buffer="a", wrap="repeat") +buffer_a_pass = BufferRenderPass( + buffer_idx="a", code=buffer_code, inputs=[buffer_a_channel] +) +shader = Shadertoy( + image_code, + inputs=[buffer_a_channel], + buffers={"a": buffer_a_pass}, + resolution=(512, 256), +) if __name__ == "__main__": shader.show() diff --git a/wgpu_shadertoy/__init__.py b/wgpu_shadertoy/__init__.py index 191c0a1..eb28ea3 100644 --- a/wgpu_shadertoy/__init__.py +++ b/wgpu_shadertoy/__init__.py @@ -1,5 +1,5 @@ from .inputs import ShadertoyChannel, ShadertoyChannelTexture -from .shadertoy import Shadertoy +from .shadertoy import BufferRenderPass, Shadertoy __version__ = "0.1.0" version_info = tuple(map(int, __version__.split("."))) diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index e3a565e..33c7327 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -503,8 +503,9 @@ def _draw_frame(self): for buf in self.buffers.values(): if buf: # checks if not None? - pass # TODO: actually rewrtite this function - # buf.draw_buffer(self._device, ) # does this need kind of the target to write too? + buf.draw_buffer( + self._device + ) # does this need kind of the target to write too? # TODO: most of the code below here is for the image renderpass... self.image.draw_image(self._device, self._present_context) @@ -570,15 +571,32 @@ class RenderPass: # TODO: uniform data is per pass (as it includes iChannelResolution...) def __init__( - self, main: Shadertoy, code: str, shader_type: str = "glsl", inputs=[] + self, code: str, main: Shadertoy = None, shader_type: str = "glsl", inputs=[] ) -> None: - self.main = main + self._main = main # could be None... self._shader_type = shader_type self._shader_code = code self.channels = self._attach_inputs(inputs) - self._uniform_data = ( - main._uniform_data - ) # default from main - but might be different for each renderpass + + @property + def main(self): + """ + The main Shadertoy class of which this renderpass is part of. + """ + if self._main is None: + raise ValueError("Main Shadertoy class is not set.") + return self._main + + @main.setter + def main(self, value): + self._main = value + + @property + def _uniform_data(self): + """ + each RenderPass might have some differences in terms of times, and channel res... + """ + return self.main._uniform_data def _attach_inputs(self, inputs: list) -> list[ShadertoyChannel, None]: if len(inputs) > 4: @@ -646,6 +664,7 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, ) + # Step 3: layout and bind groups bind_groups_layout_entries = [ { "binding": 0, @@ -665,6 +684,7 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: }, ] + # Step 4: add inputs as textures. channel_res = [] for channel in self.channels: if channel is None: @@ -675,6 +695,7 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: texture = channel.create_texture(device) texture_view = texture.create_view() # typing missing in wgpu-py for queue + # extract this to an update_texture method? device.queue.write_texture( { "texture": texture, @@ -813,20 +834,11 @@ class BufferRenderPass(RenderPass): def __init__(self, buffer_idx: str = "", **kwargs): super().__init__(**kwargs) - if buffer_idx: - self._buffer_idx = buffer_idx - self.last_frame = np.ascontiguousarray( - np.zeros( - shape=(self.texture_size[1], self.texture_size[0], 4), dtype=np.uint8 - ) - ) - # TODO: find a generally better solution for this dimension swap between data shape and texture_size - # maybe use self.main.resolution instead but we do need ints for shape. Perhaps refactor to a method as this really only initializes the buffer with zeros? - # do we need to write this to the buffer once? + self._buffer_idx = buffer_idx @property def buffer_idx(self) -> str: - if hasattr(self, "_buffer_idx"): + if not self._buffer_idx: # checks for empty string raise ValueError("Buffer index not set") return self._buffer_idx.lower() @@ -841,18 +853,55 @@ def texture_size(self) -> tuple: # (columns, rows, 1) return (int(self.main.resolution[1]), int(self.main.resolution[0]), 1) - def draw_buffer(self, device: wgpu.GPUDevice, texture) -> None: + @property + def last_frame(self): + if not hasattr(self, "_last_frame"): + self._last_frame = self._initial_buffer() + print(self._last_frame.shape) + return self._last_frame + + def _initial_buffer(self): + return np.ascontiguousarray( + np.zeros( + shape=(self.texture_size[1], self.texture_size[0], 4), dtype=np.uint8 + ) + ) + + def draw_buffer(self, device: wgpu.GPUDevice) -> None: """ draws the buffer to the texture and updates self.last_frame """ buffer = device.create_buffer( - size=self.texture_size * 4, usage=wgpu.BufferUsage.COPY_DST + size=(self.texture_size[0] * self.texture_size[1] * 4), + usage=wgpu.BufferUsage.COPY_SRC | wgpu.BufferUsage.COPY_DST, ) command_encoder = device.create_command_encoder() - # TODO: this section likely makes no sense, we need to do a .create_render_pass and also have pipeline etc available. + target_texture = device.create_texture( + size=self.texture_size, + format=wgpu.TextureFormat.bgra8unorm, + usage=wgpu.TextureUsage.COPY_SRC | wgpu.TextureUsage.RENDER_ATTACHMENT, + ) + + # TODO: maybe use a different name in this case? + render_pass = command_encoder.begin_render_pass( + color_attachments=[ + { + "view": target_texture.create_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._render_pipeline) + render_pass.set_bind_group(0, self._bind_group, [], 0, 99) + render_pass.draw(3, 1, 0, 0) # what is .draw_indirect? + render_pass.end() command_encoder.copy_texture_to_buffer( { - "texture": texture, + "texture": target_texture, "mip_level": 0, "origin": (0, 0, 0), }, @@ -866,8 +915,10 @@ def draw_buffer(self, device: wgpu.GPUDevice, texture) -> None: ) device.queue.submit([command_encoder.finish()]) - # overwrite here - when triggered via main! - self.last_frame = device.queue.read_buffer(buffer) + + self._last_frame = device.queue.read_buffer( + buffer + ) # correct array like object? class CubemapRenderPass(RenderPass): From e5b67feba773bec40221fa8189c2801137e32359 Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 22 May 2024 22:58:46 +0200 Subject: [PATCH 13/59] put passes into it's own file --- examples/shadertoy_buffer.py | 5 +- wgpu_shadertoy/__init__.py | 3 +- wgpu_shadertoy/api.py | 11 +- wgpu_shadertoy/inputs.py | 41 ++- wgpu_shadertoy/passes.py | 603 +++++++++++++++++++++++++++++++++++ wgpu_shadertoy/shadertoy.py | 583 +-------------------------------- 6 files changed, 656 insertions(+), 590 deletions(-) create mode 100644 wgpu_shadertoy/passes.py diff --git a/examples/shadertoy_buffer.py b/examples/shadertoy_buffer.py index 2b88d84..a4b3aeb 100644 --- a/examples/shadertoy_buffer.py +++ b/examples/shadertoy_buffer.py @@ -1,7 +1,8 @@ # run_example = false # buffer passes in development -from wgpu_shadertoy import BufferRenderPass, Shadertoy +from wgpu_shadertoy import Shadertoy from wgpu_shadertoy.inputs import ShadertoyChannelBuffer +from wgpu_shadertoy.passes import BufferRenderPass # shadertoy source: https://www.shadertoy.com/view/lljcDG by rkibria CC-BY-NC-SA-3.0 image_code = """ @@ -37,7 +38,7 @@ if (k <= 0.0) j = 0.0; - fragColor = vec4(0.5, j, 0.0, 1.0); + fragColor = vec4(k, j, 0.0, 1.0); } """ diff --git a/wgpu_shadertoy/__init__.py b/wgpu_shadertoy/__init__.py index eb28ea3..e9e6722 100644 --- a/wgpu_shadertoy/__init__.py +++ b/wgpu_shadertoy/__init__.py @@ -1,5 +1,6 @@ from .inputs import ShadertoyChannel, ShadertoyChannelTexture -from .shadertoy import BufferRenderPass, Shadertoy +from .passes import BufferRenderPass, RenderPass +from .shadertoy import Shadertoy __version__ = "0.1.0" version_info = tuple(map(int, __version__.split("."))) diff --git a/wgpu_shadertoy/api.py b/wgpu_shadertoy/api.py index 1d805f9..083e6c2 100644 --- a/wgpu_shadertoy/api.py +++ b/wgpu_shadertoy/api.py @@ -6,6 +6,7 @@ from PIL import Image from .inputs import ShadertoyChannel +from .passes import BufferRenderPass HEADERS = {"user-agent": "https://github.com/pygfx/shadertoy script"} @@ -133,6 +134,7 @@ def shader_args_from_json(dict_or_path, **kwargs) -> dict: main_image_code = "" common_code = "" inputs = [] + buffers = {} complete = True if "Shader" not in shader_data: raise ValueError( @@ -149,6 +151,12 @@ def shader_args_from_json(dict_or_path, **kwargs) -> dict: ) elif r_pass["type"] == "common": common_code = r_pass["code"] + elif r_pass["type"] == "buffer": + buffer_inputs, inputs_complete = _download_media_channels( + r_pass["inputs"], use_cache=use_cache + ) + buffer = BufferRenderPass(code=r_pass["code"], inputs=buffer_inputs) + buffers[r_pass["name"].lower()[-1]] = buffer else: complete = False complete = complete and inputs_complete @@ -158,7 +166,8 @@ def shader_args_from_json(dict_or_path, **kwargs) -> dict: "shader_code": main_image_code, "common": common_code, "shader_type": "glsl", - "inputs": inputs, + "inputs": inputs, # main_image inputs + "buffers": buffers, "title": title, "complete": complete, **kwargs, diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 168e19b..4f62a61 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -7,6 +7,7 @@ class ShadertoyChannel: ShadertoyChannel Base class. If nothing is provided, it defaults to a 8x8 black texture. Parameters: ctype (str): channeltype, can be "texture", "buffer", "video", "webcam", "music", "mic", "keyboard", "cubemap", "volume"; default assumes texture. + channel_idx (int): The channel index, can be one of (0, 1, 2, 3). Default is None. It will be set by the parent renderpass. **kwargs: Additional arguments for the sampler: wrap (str): The wrap mode, can be one of ("clamp-to-edge", "repeat", "clamp"). Default is "clamp-to-edge". """ @@ -18,8 +19,9 @@ def __init__(self, *args, ctype=None, channel_idx=None, **kwargs): if channel_idx is None: channel_idx = kwargs.pop("channel_idx", None) self._channel_idx = channel_idx - self.args = args + self.args = args # actually reduddant? self.kwargs = kwargs + self.dynamic = False def infer_subclass(self, *args_, **kwargs_): """ @@ -212,6 +214,7 @@ def __init__(self, buffer, parent=None, **kwargs): self.buffer_idx = buffer # A,B,C or D? if parent is not None: self._parent = parent + self.dynamic = True @property def renderpass(self): # -> BufferRenderPass: @@ -220,23 +223,51 @@ def renderpass(self): # -> BufferRenderPass: @property def data(self) -> memoryview: """ - previous frame rendered by this buffer. buffers render in order A, B, C, D. and before the given Image. + previous frame rendered by this buffer. buffers render in order A, B, C, D. and before the Image pass. """ return self.renderpass.last_frame def create_texture(self, device) -> wgpu.GPUTexture: """ - The output texture of the buffer (last frame?), to be sampled by specified sampler in this channel. + Creates the texture for this channel and sampler. Texture stays available to be updated later on. """ # TODO: this likely needs to be in the parent pass and simply accessed here... - texture = device.create_texture( + self.texture = device.create_texture( size=self.renderpass.texture_size, format=wgpu.TextureFormat.rgba8unorm, usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.TEXTURE_BINDING, # which ones do we actually need? ) - return texture + # texture = device.copy_buffer_to_texture( + # { + # "buffer": self.data, + # "texture": self.texture, + # "texture_size": self.renderpass.texture_size, + # "bytes_per_row": self.renderpass.bytes_per_pixel + # * self.renderpass.texture_size[1], + # } + # ) + return self.texture + + def update_texture(self, device): + """ + Updates the texture. (maybe reuse this code snippet broader?) + """ + device.queue.write_texture( + { + "texture": self.texture, + "origin": (0, 0, 0), + "mip_level": 0, + }, + self.data, + { + "offset": 0, + "bytes_per_row": self.bytes_per_pixel * self.size[1], # multiple of 256 + "rows_per_image": self.size[0], # same is done internally + }, + self.texture.size, + ) class ShadertoyChannelCubemapA(ShadertoyChannel): diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py new file mode 100644 index 0000000..c7dd35e --- /dev/null +++ b/wgpu_shadertoy/passes.py @@ -0,0 +1,603 @@ +import re + +import numpy as np +import wgpu + +from .inputs import ShadertoyChannel, ShadertoyChannelTexture + +vertex_code_glsl = """#version 450 core + +layout(location = 0) out vec2 vert_uv; + +void main(void){ + int index = int(gl_VertexID); + if (index == 0) { + gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); + vert_uv = vec2(0.0, 1.0); + } else if (index == 1) { + gl_Position = vec4(3.0, -1.0, 0.0, 1.0); + vert_uv = vec2(2.0, 1.0); + } else { + gl_Position = vec4(-1.0, 3.0, 0.0, 1.0); + vert_uv = vec2(0.0, -1.0); + } +} +""" + + +builtin_variables_glsl = """#version 450 core + +vec4 i_mouse; +vec4 i_date; +vec3 i_resolution; +float i_time; +vec3 i_channel_resolution[4]; +float i_time_delta; +int i_frame; +float i_framerate; + +layout(binding = 1) uniform texture2D i_channel0; +layout(binding = 2) uniform sampler sampler0; +layout(binding = 3) uniform texture2D i_channel1; +layout(binding = 4) uniform sampler sampler1; +layout(binding = 5) uniform texture2D i_channel2; +layout(binding = 6) uniform sampler sampler2; +layout(binding = 7) uniform texture2D i_channel3; +layout(binding = 8) uniform sampler sampler3; + +// Shadertoy compatibility, see we can use the same code copied from shadertoy website + +#define iChannel0 sampler2D(i_channel0, sampler0) +#define iChannel1 sampler2D(i_channel1, sampler1) +#define iChannel2 sampler2D(i_channel2, sampler2) +#define iChannel3 sampler2D(i_channel3, sampler3) + +#define iMouse i_mouse +#define iDate i_date +#define iResolution i_resolution +#define iTime i_time +#define iChannelResolution i_channel_resolution +#define iTimeDelta i_time_delta +#define iFrame i_frame +#define iFrameRate i_framerate + +#define mainImage shader_main +""" + + +fragment_code_glsl = """ +layout(location = 0) in vec2 vert_uv; + +struct ShadertoyInput { + vec4 si_mouse; + vec4 si_date; + vec3 si_resolution; + float si_time; + vec3 si_channel_res[4]; + float si_time_delta; + int si_frame; + float si_framerate; +}; + +layout(binding = 0) uniform ShadertoyInput input; +out vec4 FragColor; +void main(){ + + i_mouse = input.si_mouse; + i_date = input.si_date; + i_resolution = input.si_resolution; + i_time = input.si_time; + i_channel_resolution = input.si_channel_res; + i_time_delta = input.si_time_delta; + i_frame = input.si_frame; + i_framerate = input.si_framerate; + vec2 frag_uv = vec2(vert_uv.x, 1.0 - vert_uv.y); + vec2 frag_coord = frag_uv * i_resolution.xy; + + shader_main(FragColor, frag_coord); + +} + +""" + + +vertex_code_wgsl = """ + +struct Varyings { + @builtin(position) position : vec4, + @location(0) vert_uv : vec2, +}; + +@vertex +fn main(@builtin(vertex_index) index: u32) -> Varyings { + var out: Varyings; + if (index == u32(0)) { + out.position = vec4(-1.0, -1.0, 0.0, 1.0); + out.vert_uv = vec2(0.0, 1.0); + } else if (index == u32(1)) { + out.position = vec4(3.0, -1.0, 0.0, 1.0); + out.vert_uv = vec2(2.0, 1.0); + } else { + out.position = vec4(-1.0, 3.0, 0.0, 1.0); + out.vert_uv = vec2(0.0, -1.0); + } + return out; + +} +""" + + +builtin_variables_wgsl = """ + +var i_mouse: vec4; +var i_date: vec4; +var i_resolution: vec3; +var i_time: f32; +var i_channel_resolution: array,4>; +var i_time_delta: f32; +var i_frame: u32; +var i_framerate: f32; + +// TODO: more global variables +// var i_frag_coord: vec2; + +""" + + +fragment_code_wgsl = """ + +struct ShadertoyInput { + si_mouse: vec4, + si_date: vec4, + si_resolution: vec3, + si_time: f32, + si_channel_res: array,4>, + si_time_delta: f32, + si_frame: u32, + si_framerate: f32, +}; + +struct Varyings { + @builtin(position) position : vec4, + @location(0) vert_uv : vec2, +}; + +@group(0) @binding(0) +var input: ShadertoyInput; + +@group(0) @binding(1) +var i_channel0: texture_2d; +@group(0) @binding(3) +var i_channel1: texture_2d; +@group(0) @binding(5) +var i_channel2: texture_2d; +@group(0) @binding(7) +var i_channel3: texture_2d; + +@group(0) @binding(2) +var sampler0: sampler; +@group(0) @binding(4) +var sampler1: sampler; +@group(0) @binding(6) +var sampler2: sampler; +@group(0) @binding(8) +var sampler3: sampler; + +@fragment +fn main(in: Varyings) -> @location(0) vec4 { + + i_mouse = input.si_mouse; + i_date = input.si_date; + i_resolution = input.si_resolution; + i_time = input.si_time; + i_channel_resolution = input.si_channel_res; + i_time_delta = input.si_time_delta; + i_frame = input.si_frame; + i_framerate = input.si_framerate; + let frag_uv = vec2(in.vert_uv.x, 1.0 - in.vert_uv.y); + let frag_coord = frag_uv * i_resolution.xy; + + return shader_main(frag_coord); +} + +""" + + +class RenderPass: + """ + Base class for renderpass in a Shadertoy. + Parameters: + main(Shadertoy): the main Shadertoy class of which this renderpass is part of. + code (str): Shadercode for this buffer. + shader_type(str): either "wgsl" or "glsl" can also be "auto" - which then gets solved by a regular expression, we should be able to match differnt renderpasses... Defaults to glsl + inputs (list): A list of :class:`ShadertoyChannel` objects. Each pass supports up to 4 inputs/channels. If a channel is dected in the code but none provided, will be sampling a black texture. + """ + + # TODO: uniform data is per pass (as it includes iChannelResolution...) + def __init__( + self, code: str, main=None, shader_type: str = "glsl", inputs=[] + ) -> None: + self._main = main # could be None... + self._shader_type = shader_type + self._shader_code = code + self.channels = self._attach_inputs(inputs) + + @property + def main(self): # -> Shadertoy (can't type due to circular import?) + """ + The main Shadertoy class of which this renderpass is part of. + """ + if self._main is None: + raise ValueError("Main Shadertoy class is not set.") + return self._main + + @main.setter + def main(self, value): + self._main = value + + @property + def _uniform_data(self): + """ + each RenderPass might have some differences in terms of times, and channel res... + """ + return self.main._uniform_data + + @property + def shader_code(self) -> str: + """The shader code to use.""" + return self._shader_code + + @property + def shader_type(self) -> str: + """The shader type, automatically detected from the shader code, can be "wgsl" or "glsl".""" + if self._shader_type in ("wgsl", "glsl"): + return self._shader_type + + wgsl_main_expr = re.compile(r"fn(?:\s)+shader_main") + glsl_main_expr = re.compile(r"void(?:\s)+(?:shader_main|mainImage)") + if wgsl_main_expr.search(self.shader_code): + return "wgsl" + elif glsl_main_expr.search(self.shader_code): + return "glsl" + else: + raise ValueError( + "Could not find valid entry point function in shader code. Unable to determine if it's wgsl or glsl." + ) + + def _update_textures(self, device: wgpu.GPUDevice) -> None: + return # TODO: in development + for channel in self.channels: + if channel is not None and channel.dynamic: + channel.update_texture(device) + + def _attach_inputs(self, inputs: list) -> list[ShadertoyChannel, None]: + if len(inputs) > 4: + raise ValueError("Only 4 inputs supported") + + # fill up with None to always have 4 inputs. + if len(inputs) < 4: + inputs.extend([None] * (4 - len(inputs))) + + channel_pattern = re.compile(r"(?:iChannel|i_channel)(\d+)") + detected_channels = [ + int(c) for c in set(channel_pattern.findall(self.shader_code)) + ] + + channels = [] + + for inp_idx, inp in enumerate(inputs): + if inp_idx not in detected_channels: + channels.append(None) + # maybe raise a warning or some error? For unusued channel + elif type(inp) is ShadertoyChannel: + channels.append(inp.infer_subclass(parent=self, channel_idx=inp_idx)) + elif isinstance(inp, ShadertoyChannel): + inp.channel_idx = inp_idx + inp.parent = self + channels.append(inp) + elif inp is None and inp_idx in detected_channels: + # this is the base case where we sample the black texture. + channels.append(ShadertoyChannelTexture(channel_idx=inp_idx)) + else: + channels.append(None) + + return channels + + def prepare_render(self, device: wgpu.GPUDevice) -> None: + # Step 1: compose shader programs + shader_type = self.shader_type + if shader_type == "glsl": + vertex_shader_code = vertex_code_glsl + frag_shader_code = ( + builtin_variables_glsl + + self.main.common + + self.shader_code + + fragment_code_glsl + ) + elif shader_type == "wgsl": + vertex_shader_code = vertex_code_wgsl + frag_shader_code = ( + builtin_variables_wgsl + + self.main.common + + self.shader_code + + fragment_code_wgsl + ) + + vertex_shader_program = device.create_shader_module( + label="triangle_vert", code=vertex_shader_code + ) + frag_shader_program = device.create_shader_module( + label="triangle_frag", code=frag_shader_code + ) + + # Step 2: map uniform data to buffer + self._uniform_buffer = device.create_buffer( + size=self._uniform_data.nbytes, + usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, + ) + + # Step 3: layout and bind groups + bind_groups_layout_entries = [ + { + "binding": 0, + "resource": { + "buffer": self._uniform_buffer, + "offset": 0, + "size": self._uniform_data.nbytes, + }, + }, + ] + + binding_layout = [ + { + "binding": 0, + "visibility": wgpu.ShaderStage.FRAGMENT, + "buffer": {"type": wgpu.BufferBindingType.uniform}, + }, + ] + + # Step 4: add inputs as textures. + channel_res = [] + for channel in self.channels: + if channel is None: + channel_res.extend([0, 0, 1, -99]) # default values; quick hack + continue + + binding_layout.extend(channel.binding_layout(offset=0)) + texture = channel.create_texture(device) + texture_view = texture.create_view() + # typing missing in wgpu-py for queue + # extract this to an update_texture method? + device.queue.write_texture( + { + "texture": texture, + "origin": (0, 0, 0), + "mip_level": 0, + }, + channel.data, + { + "offset": 0, + "bytes_per_row": channel.bytes_per_pixel + * channel.size[1], # multiple of 256 + "rows_per_image": channel.size[0], # same is done internally + }, + texture.size, + ) + + sampler = device.create_sampler(**channel.sampler_settings) + # TODO: explore using auto layouts (pygfx/wgpu-py#500) + bind_groups_layout_entries.extend( + channel.bind_groups_layout_entries(texture_view, sampler) + ) + channel_res.extend(channel.channel_res) # padding/tests + + self._uniform_data["channel_res"] = tuple(channel_res) + bind_group_layout = device.create_bind_group_layout(entries=binding_layout) + + self._bind_group = device.create_bind_group( + layout=bind_group_layout, + entries=bind_groups_layout_entries, + ) + + self._render_pipeline = device.create_render_pipeline( + layout=device.create_pipeline_layout( + bind_group_layouts=[bind_group_layout] + ), + vertex={ + "module": vertex_shader_program, + "entry_point": "main", + "buffers": [], + }, + primitive={ + "topology": wgpu.PrimitiveTopology.triangle_list, + "front_face": wgpu.FrontFace.ccw, + "cull_mode": wgpu.CullMode.none, + }, + depth_stencil=None, + multisample=None, + fragment={ + "module": frag_shader_program, + "entry_point": "main", + "targets": [ + { + "format": wgpu.TextureFormat.bgra8unorm, + "blend": { + "color": ( + wgpu.BlendFactor.one, + wgpu.BlendFactor.zero, + wgpu.BlendOperation.add, + ), + "alpha": ( + wgpu.BlendFactor.one, + wgpu.BlendFactor.zero, + wgpu.BlendOperation.add, + ), + }, + }, + ], + }, + ) + + +class ImageRenderPass(RenderPass): + """ + The Image RenderPass of a Shadertoy. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + # TODO figure out if there is anything specific. Maybe the canvas stuff? perhaps that should stay in the main class... + + def draw_image(self, device: wgpu.GPUDevice, present_context) -> None: + """ + Draws the main image pass to the screen. + """ + # maybe have an internal self._update for the uniform buffer too? + self._update_textures(device) + command_encoder = device.create_command_encoder() + current_texture = present_context.get_current_texture() + + # TODO: maybe use a different name in this case? + render_pass = command_encoder.begin_render_pass( + color_attachments=[ + { + "view": current_texture.create_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._render_pipeline) + render_pass.set_bind_group(0, self._bind_group, [], 0, 99) + render_pass.draw(3, 1, 0, 0) + render_pass.end() + + device.queue.submit([command_encoder.finish()]) + + +class BufferRenderPass(RenderPass): + """ + The Buffer A-D RenderPass of a Shadertoy. + Parameters: + buffer_idx (str): one of "A", "B", "C" or "D". Required. + """ + + def __init__(self, buffer_idx: str = "", **kwargs): + super().__init__(**kwargs) + self._buffer_idx = buffer_idx + + @property + def buffer_idx(self) -> str: + if not self._buffer_idx: # checks for empty string + raise ValueError("Buffer index not set") + return self._buffer_idx.lower() + + @buffer_idx.setter + def buffer_idx(self, value: str): + if value.lower() not in "abcd": + raise ValueError("Buffer index must be one of 'A', 'B', 'C' or 'D'") + self._buffer_idx = value + + @property + def texture_size(self) -> tuple: + # (columns, rows, 1) + # TODO: figure out padding this to always be a multiple of 64 wide? + columns = int(self.main.resolution[0]) + rows = int(self.main.resolution[1]) + texture_size = (columns, rows, 1) + return texture_size + + @property + def last_frame(self): + if not hasattr(self, "_last_frame"): + self._last_frame = self._initial_buffer() + return self._last_frame + + def _initial_buffer(self): + zero_array = np.ascontiguousarray( + np.zeros( + shape=(self.texture_size[1], self.texture_size[0], 4), dtype=np.uint8 + ) + ) + + # buffer = self.main._device.create_buffer_with_data( + # zero_array.tobytes(), + # wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.COPY_SRC, + # ) + return zero_array + + def draw_buffer(self, device: wgpu.GPUDevice) -> None: + """ + draws the buffer to the texture and updates self.last_frame + """ + # TODO: maybe call these functions draw_buffer and have them easier to call at once? + self._update_textures(device) + buffer = device.create_buffer( + size=(self.texture_size[0] * self.texture_size[1] * 4), + usage=wgpu.BufferUsage.COPY_SRC | wgpu.BufferUsage.COPY_DST, + ) + command_encoder = device.create_command_encoder() + target_texture = device.create_texture( + size=self.texture_size, + format=wgpu.TextureFormat.bgra8unorm, + usage=wgpu.TextureUsage.COPY_SRC | wgpu.TextureUsage.RENDER_ATTACHMENT, + ) + + # TODO: maybe use a different name in this case? + render_pass = command_encoder.begin_render_pass( + color_attachments=[ + { + "view": target_texture.create_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._render_pipeline) + render_pass.set_bind_group(0, self._bind_group, [], 0, 99) + render_pass.draw(3, 1, 0, 0) # what is .draw_indirect? + render_pass.end() + command_encoder.copy_texture_to_buffer( + { + "texture": target_texture, + "mip_level": 0, + "origin": (0, 0, 0), + }, + { + "buffer": buffer, + "offset": 0, + "bytes_per_row": self.texture_size[0] * 4, + "rows_per_image": self.texture_size[1], + }, + self.texture_size, + ) + + device.queue.submit([command_encoder.finish()]) + + frame = device.queue.read_buffer(buffer) + + self._last_frame = frame + + +class CubemapRenderPass(RenderPass): + """ + The Cube A RenderPass of a Shadertoy. + this has slightly different headers see: https://shadertoyunofficial.wordpress.com/2016/07/20/special-shadertoy-features/ + """ + + pass # TODO at a later date + + +class SoundRenderPass(RenderPass): + """ + The Sound RenderPass of a Shadertoy. + sound is rendered to a buffer at the start and then played back. There is no interactivity.... + """ + + pass # TODO at a later date diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index 33c7327..cbd35c9 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -1,214 +1,14 @@ import collections import ctypes import os -import re import time -import numpy as np -import wgpu from wgpu.gui.auto import WgpuCanvas, run from wgpu.gui.offscreen import WgpuCanvas as OffscreenCanvas from wgpu.gui.offscreen import run as run_offscreen from .api import shader_args_from_json, shadertoy_from_id -from .inputs import ShadertoyChannel, ShadertoyChannelTexture - -vertex_code_glsl = """#version 450 core - -layout(location = 0) out vec2 vert_uv; - -void main(void){ - int index = int(gl_VertexID); - if (index == 0) { - gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); - vert_uv = vec2(0.0, 1.0); - } else if (index == 1) { - gl_Position = vec4(3.0, -1.0, 0.0, 1.0); - vert_uv = vec2(2.0, 1.0); - } else { - gl_Position = vec4(-1.0, 3.0, 0.0, 1.0); - vert_uv = vec2(0.0, -1.0); - } -} -""" - - -builtin_variables_glsl = """#version 450 core - -vec4 i_mouse; -vec4 i_date; -vec3 i_resolution; -float i_time; -vec3 i_channel_resolution[4]; -float i_time_delta; -int i_frame; -float i_framerate; - -layout(binding = 1) uniform texture2D i_channel0; -layout(binding = 2) uniform sampler sampler0; -layout(binding = 3) uniform texture2D i_channel1; -layout(binding = 4) uniform sampler sampler1; -layout(binding = 5) uniform texture2D i_channel2; -layout(binding = 6) uniform sampler sampler2; -layout(binding = 7) uniform texture2D i_channel3; -layout(binding = 8) uniform sampler sampler3; - -// Shadertoy compatibility, see we can use the same code copied from shadertoy website - -#define iChannel0 sampler2D(i_channel0, sampler0) -#define iChannel1 sampler2D(i_channel1, sampler1) -#define iChannel2 sampler2D(i_channel2, sampler2) -#define iChannel3 sampler2D(i_channel3, sampler3) - -#define iMouse i_mouse -#define iDate i_date -#define iResolution i_resolution -#define iTime i_time -#define iChannelResolution i_channel_resolution -#define iTimeDelta i_time_delta -#define iFrame i_frame -#define iFrameRate i_framerate - -#define mainImage shader_main -""" - - -fragment_code_glsl = """ -layout(location = 0) in vec2 vert_uv; - -struct ShadertoyInput { - vec4 si_mouse; - vec4 si_date; - vec3 si_resolution; - float si_time; - vec3 si_channel_res[4]; - float si_time_delta; - int si_frame; - float si_framerate; -}; - -layout(binding = 0) uniform ShadertoyInput input; -out vec4 FragColor; -void main(){ - - i_mouse = input.si_mouse; - i_date = input.si_date; - i_resolution = input.si_resolution; - i_time = input.si_time; - i_channel_resolution = input.si_channel_res; - i_time_delta = input.si_time_delta; - i_frame = input.si_frame; - i_framerate = input.si_framerate; - vec2 frag_uv = vec2(vert_uv.x, 1.0 - vert_uv.y); - vec2 frag_coord = frag_uv * i_resolution.xy; - - shader_main(FragColor, frag_coord); - -} - -""" - - -vertex_code_wgsl = """ - -struct Varyings { - @builtin(position) position : vec4, - @location(0) vert_uv : vec2, -}; - -@vertex -fn main(@builtin(vertex_index) index: u32) -> Varyings { - var out: Varyings; - if (index == u32(0)) { - out.position = vec4(-1.0, -1.0, 0.0, 1.0); - out.vert_uv = vec2(0.0, 1.0); - } else if (index == u32(1)) { - out.position = vec4(3.0, -1.0, 0.0, 1.0); - out.vert_uv = vec2(2.0, 1.0); - } else { - out.position = vec4(-1.0, 3.0, 0.0, 1.0); - out.vert_uv = vec2(0.0, -1.0); - } - return out; - -} -""" - - -builtin_variables_wgsl = """ - -var i_mouse: vec4; -var i_date: vec4; -var i_resolution: vec3; -var i_time: f32; -var i_channel_resolution: array,4>; -var i_time_delta: f32; -var i_frame: u32; -var i_framerate: f32; - -// TODO: more global variables -// var i_frag_coord: vec2; - -""" - - -fragment_code_wgsl = """ - -struct ShadertoyInput { - si_mouse: vec4, - si_date: vec4, - si_resolution: vec3, - si_time: f32, - si_channel_res: array,4>, - si_time_delta: f32, - si_frame: u32, - si_framerate: f32, -}; - -struct Varyings { - @builtin(position) position : vec4, - @location(0) vert_uv : vec2, -}; - -@group(0) @binding(0) -var input: ShadertoyInput; - -@group(0) @binding(1) -var i_channel0: texture_2d; -@group(0) @binding(3) -var i_channel1: texture_2d; -@group(0) @binding(5) -var i_channel2: texture_2d; -@group(0) @binding(7) -var i_channel3: texture_2d; - -@group(0) @binding(2) -var sampler0: sampler; -@group(0) @binding(4) -var sampler1: sampler; -@group(0) @binding(6) -var sampler2: sampler; -@group(0) @binding(8) -var sampler3: sampler; - -@fragment -fn main(in: Varyings) -> @location(0) vec4 { - - i_mouse = input.si_mouse; - i_date = input.si_date; - i_resolution = input.si_resolution; - i_time = input.si_time; - i_channel_resolution = input.si_channel_res; - i_time_delta = input.si_time_delta; - i_frame = input.si_frame; - i_framerate = input.si_framerate; - let frag_uv = vec2(in.vert_uv.x, 1.0 - in.vert_uv.y); - let frag_coord = frag_uv * i_resolution.xy; - - return shader_main(frag_coord); -} - -""" +from .passes import BufferRenderPass, ImageRenderPass class UniformArray: @@ -541,6 +341,7 @@ def snapshot(self, time_float: float = 0.0, mouse_pos: tuple = (0, 0, 0, 0)): return frame +# TODO: this code shouldn't be executed as a script anymore. if __name__ == "__main__": shader = Shadertoy( """ @@ -557,383 +358,3 @@ def snapshot(self, time_float: float = 0.0, mouse_pos: tuple = (0, 0, 0, 0)): """ ) shader.show() - - -class RenderPass: - """ - Base class for renderpass in a Shadertoy. - Parameters: - main(Shadertoy): the main Shadertoy class of which this renderpass is part of. - code (str): Shadercode for this buffer. - shader_type(str): either "wgsl" or "glsl" can also be "auto" - which then gets solved by a regular expression, we should be able to match differnt renderpasses... Defaults to glsl - inputs (list): A list of :class:`ShadertoyChannel` objects. Each pass supports up to 4 inputs/channels. If a channel is dected in the code but none provided, will be sampling a black texture. - """ - - # TODO: uniform data is per pass (as it includes iChannelResolution...) - def __init__( - self, code: str, main: Shadertoy = None, shader_type: str = "glsl", inputs=[] - ) -> None: - self._main = main # could be None... - self._shader_type = shader_type - self._shader_code = code - self.channels = self._attach_inputs(inputs) - - @property - def main(self): - """ - The main Shadertoy class of which this renderpass is part of. - """ - if self._main is None: - raise ValueError("Main Shadertoy class is not set.") - return self._main - - @main.setter - def main(self, value): - self._main = value - - @property - def _uniform_data(self): - """ - each RenderPass might have some differences in terms of times, and channel res... - """ - return self.main._uniform_data - - def _attach_inputs(self, inputs: list) -> list[ShadertoyChannel, None]: - if len(inputs) > 4: - raise ValueError("Only 4 inputs supported") - - # fill up with None to always have 4 inputs. - if len(inputs) < 4: - inputs.extend([None] * (4 - len(inputs))) - - channel_pattern = re.compile(r"(?:iChannel|i_channel)(\d+)") - detected_channels = [ - int(c) for c in set(channel_pattern.findall(self.shader_code)) - ] - - channels = [] - - for inp_idx, inp in enumerate(inputs): - if inp_idx not in detected_channels: - channels.append(None) - # maybe raise a warning or some error? For unusued channel - elif type(inp) is ShadertoyChannel: - channels.append(inp.infer_subclass(parent=self, channel_idx=inp_idx)) - elif isinstance(inp, ShadertoyChannel): - inp.channel_idx = inp_idx - inp.parent = self - channels.append(inp) - elif inp is None and inp_idx in detected_channels: - # this is the base case where we sample the black texture. - channels.append(ShadertoyChannelTexture(channel_idx=inp_idx)) - else: - channels.append(None) - - return channels - - def prepare_render(self, device: wgpu.GPUDevice) -> None: - # Step 1: compose shader programs - shader_type = self.shader_type - if shader_type == "glsl": - vertex_shader_code = vertex_code_glsl - frag_shader_code = ( - builtin_variables_glsl - + self.main.common - + self.shader_code - + fragment_code_glsl - ) - elif shader_type == "wgsl": - vertex_shader_code = vertex_code_wgsl - frag_shader_code = ( - builtin_variables_wgsl - + self.main.common - + self.shader_code - + fragment_code_wgsl - ) - - vertex_shader_program = device.create_shader_module( - label="triangle_vert", code=vertex_shader_code - ) - frag_shader_program = device.create_shader_module( - label="triangle_frag", code=frag_shader_code - ) - - # Step 2: map uniform data to buffer - self._uniform_buffer = device.create_buffer( - size=self._uniform_data.nbytes, - usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, - ) - - # Step 3: layout and bind groups - bind_groups_layout_entries = [ - { - "binding": 0, - "resource": { - "buffer": self._uniform_buffer, - "offset": 0, - "size": self._uniform_data.nbytes, - }, - }, - ] - - binding_layout = [ - { - "binding": 0, - "visibility": wgpu.ShaderStage.FRAGMENT, - "buffer": {"type": wgpu.BufferBindingType.uniform}, - }, - ] - - # Step 4: add inputs as textures. - channel_res = [] - for channel in self.channels: - if channel is None: - channel_res.extend([0, 0, 1, -99]) # default values; quick hack - continue - - binding_layout.extend(channel.binding_layout(offset=0)) - texture = channel.create_texture(device) - texture_view = texture.create_view() - # typing missing in wgpu-py for queue - # extract this to an update_texture method? - device.queue.write_texture( - { - "texture": texture, - "origin": (0, 0, 0), - "mip_level": 0, - }, - channel.data, - { - "offset": 0, - "bytes_per_row": channel.bytes_per_pixel - * channel.size[1], # multiple of 256 - "rows_per_image": channel.size[0], # same is done internally - }, - texture.size, - ) - - sampler = device.create_sampler(**channel.sampler_settings) - # TODO: explore using auto layouts (pygfx/wgpu-py#500) - bind_groups_layout_entries.extend( - channel.bind_groups_layout_entries(texture_view, sampler) - ) - channel_res.extend(channel.channel_res) # padding/tests - self._uniform_data["channel_res"] = tuple(channel_res) - bind_group_layout = device.create_bind_group_layout(entries=binding_layout) - - self._bind_group = device.create_bind_group( - layout=bind_group_layout, - entries=bind_groups_layout_entries, - ) - - self._render_pipeline = device.create_render_pipeline( - layout=device.create_pipeline_layout( - bind_group_layouts=[bind_group_layout] - ), - vertex={ - "module": vertex_shader_program, - "entry_point": "main", - "buffers": [], - }, - primitive={ - "topology": wgpu.PrimitiveTopology.triangle_list, - "front_face": wgpu.FrontFace.ccw, - "cull_mode": wgpu.CullMode.none, - }, - depth_stencil=None, - multisample=None, - fragment={ - "module": frag_shader_program, - "entry_point": "main", - "targets": [ - { - "format": wgpu.TextureFormat.bgra8unorm, - "blend": { - "color": ( - wgpu.BlendFactor.one, - wgpu.BlendFactor.zero, - wgpu.BlendOperation.add, - ), - "alpha": ( - wgpu.BlendFactor.one, - wgpu.BlendFactor.zero, - wgpu.BlendOperation.add, - ), - }, - }, - ], - }, - ) - - @property - def shader_code(self) -> str: - """The shader code to use.""" - return self._shader_code - - @property - def shader_type(self) -> str: - """The shader type, automatically detected from the shader code, can be "wgsl" or "glsl".""" - if self._shader_type in ("wgsl", "glsl"): - return self._shader_type - - wgsl_main_expr = re.compile(r"fn(?:\s)+shader_main") - glsl_main_expr = re.compile(r"void(?:\s)+(?:shader_main|mainImage)") - if wgsl_main_expr.search(self.shader_code): - return "wgsl" - elif glsl_main_expr.search(self.shader_code): - return "glsl" - else: - raise ValueError( - "Could not find valid entry point function in shader code. Unable to determine if it's wgsl or glsl." - ) - - -class ImageRenderPass(RenderPass): - """ - The Image RenderPass of a Shadertoy. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - # TODO figure out if there is anything specific. Maybe the canvas stuff? perhaps that should stay in the main class... - - def draw_image(self, device: wgpu.GPUDevice, present_context) -> None: - """ - Draws the main image pass to the screen. - """ - command_encoder = device.create_command_encoder() - current_texture = present_context.get_current_texture() - - # TODO: maybe use a different name in this case? - render_pass = command_encoder.begin_render_pass( - color_attachments=[ - { - "view": current_texture.create_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._render_pipeline) - render_pass.set_bind_group(0, self._bind_group, [], 0, 99) - render_pass.draw(3, 1, 0, 0) - render_pass.end() - - device.queue.submit([command_encoder.finish()]) - - -class BufferRenderPass(RenderPass): - """ - The Buffer A-D RenderPass of a Shadertoy. - Parameters: - buffer_idx (str): one of "A", "B", "C" or "D". Required. - """ - - def __init__(self, buffer_idx: str = "", **kwargs): - super().__init__(**kwargs) - self._buffer_idx = buffer_idx - - @property - def buffer_idx(self) -> str: - if not self._buffer_idx: # checks for empty string - raise ValueError("Buffer index not set") - return self._buffer_idx.lower() - - @buffer_idx.setter - def buffer_idx(self, value: str): - if value.lower() not in "abcd": - raise ValueError("Buffer index must be one of 'A', 'B', 'C' or 'D'") - self._buffer_idx = value - - @property - def texture_size(self) -> tuple: - # (columns, rows, 1) - return (int(self.main.resolution[1]), int(self.main.resolution[0]), 1) - - @property - def last_frame(self): - if not hasattr(self, "_last_frame"): - self._last_frame = self._initial_buffer() - print(self._last_frame.shape) - return self._last_frame - - def _initial_buffer(self): - return np.ascontiguousarray( - np.zeros( - shape=(self.texture_size[1], self.texture_size[0], 4), dtype=np.uint8 - ) - ) - - def draw_buffer(self, device: wgpu.GPUDevice) -> None: - """ - draws the buffer to the texture and updates self.last_frame - """ - buffer = device.create_buffer( - size=(self.texture_size[0] * self.texture_size[1] * 4), - usage=wgpu.BufferUsage.COPY_SRC | wgpu.BufferUsage.COPY_DST, - ) - command_encoder = device.create_command_encoder() - target_texture = device.create_texture( - size=self.texture_size, - format=wgpu.TextureFormat.bgra8unorm, - usage=wgpu.TextureUsage.COPY_SRC | wgpu.TextureUsage.RENDER_ATTACHMENT, - ) - - # TODO: maybe use a different name in this case? - render_pass = command_encoder.begin_render_pass( - color_attachments=[ - { - "view": target_texture.create_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._render_pipeline) - render_pass.set_bind_group(0, self._bind_group, [], 0, 99) - render_pass.draw(3, 1, 0, 0) # what is .draw_indirect? - render_pass.end() - command_encoder.copy_texture_to_buffer( - { - "texture": target_texture, - "mip_level": 0, - "origin": (0, 0, 0), - }, - { - "buffer": buffer, - "offset": 0, - "bytes_per_row": self.texture_size[0] * 4, - "rows_per_image": self.texture_size[1], - }, - self.texture_size, - ) - - device.queue.submit([command_encoder.finish()]) - - self._last_frame = device.queue.read_buffer( - buffer - ) # correct array like object? - - -class CubemapRenderPass(RenderPass): - """ - The Cube A RenderPass of a Shadertoy. - this has slightly different headers see: https://shadertoyunofficial.wordpress.com/2016/07/20/special-shadertoy-features/ - """ - - pass # TODO at a later date - - -class SoundRenderPass(RenderPass): - """ - The Sound RenderPass of a Shadertoy. - sound is rendered to a buffer at the start and then played back. There is no interactivity.... - """ - - pass # TODO at a later date From 2bcbac84f1523c39b378044154aedfda88d64cab Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 23 May 2024 00:56:35 +0200 Subject: [PATCH 14/59] naive update textures function --- examples/shadertoy_buffer.py | 2 + wgpu_shadertoy/inputs.py | 46 ++++++++++-- wgpu_shadertoy/passes.py | 131 ++++++++++++++++++++++++++--------- 3 files changed, 142 insertions(+), 37 deletions(-) diff --git a/examples/shadertoy_buffer.py b/examples/shadertoy_buffer.py index a4b3aeb..65ef094 100644 --- a/examples/shadertoy_buffer.py +++ b/examples/shadertoy_buffer.py @@ -10,6 +10,7 @@ { vec2 uv = fragCoord.xy / iResolution.xy; vec3 col = texture( iChannel0, uv ).xyz; + // col += sin(iTime); fragColor = vec4(col,1.0); } """ @@ -52,5 +53,6 @@ buffers={"a": buffer_a_pass}, resolution=(512, 256), ) + if __name__ == "__main__": shader.show() diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 4f62a61..20db906 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -1,5 +1,8 @@ +from typing import Tuple + import numpy as np import wgpu +import wgpu.structs class ShadertoyChannel: @@ -96,7 +99,9 @@ def size(self) -> tuple: # tuple? def bytes_per_pixel( self, ) -> int: # usually is 4 for rgba8unorm or maybe use self.data.strides[1]? + # print(self.data.shape, self.data.nbytes) bpp = self.data.nbytes // self.data.shape[1] // self.data.shape[0] + return bpp def create_texture(self, device) -> wgpu.GPUTexture: @@ -104,7 +109,7 @@ def create_texture(self, device) -> wgpu.GPUTexture: "This method should likely be implemented in the subclass - but maybe it's all the same? TODO: check later!" ) - def binding_layout(self, offset=0): + def _binding_layout(self, offset=0): # TODO: figure out how offset works when we have multiple passes texture_binding = (2 * self.channel_idx) + 1 sampler_binding = 2 * (self.channel_idx + 1) @@ -125,7 +130,7 @@ def binding_layout(self, offset=0): }, ] - def bind_groups_layout_entries(self, texture_view, sampler, offset=0): + def _bind_groups_layout_entries(self, texture_view, sampler, offset=0) -> list: # TODO maybe refactor this all into a prepare bindings method? texture_binding = (2 * self.channel_idx) + 1 sampler_binding = 2 * (self.channel_idx + 1) @@ -140,6 +145,36 @@ def bind_groups_layout_entries(self, texture_view, sampler, offset=0): }, ] + def bind_texture( + self, device: wgpu.GPUDevice + ) -> Tuple[wgpu.GPUBindGroupLayout, list]: + binding_layout = self._binding_layout() + texture = self.create_texture(device) + texture_view = texture.create_view() + # typing missing in wgpu-py for queue + # extract this to an update_texture method? + device.queue.write_texture( + { + "texture": texture, + "origin": (0, 0, 0), + "mip_level": 0, + }, + self.data, + { + "offset": 0, + "bytes_per_row": self.bytes_per_pixel * self.size[1], # multiple of 256 + "rows_per_image": self.size[0], # same is done internally + }, + texture.size, + ) + + sampler = device.create_sampler(**self.sampler_settings) + # TODO: explore using auto layouts (pygfx/wgpu-py#500) + bind_groups_layout_entry = self._bind_groups_layout_entries( + texture_view, sampler + ) + return binding_layout, bind_groups_layout_entry + def header_glsl(self, input_idx=0): """ GLSL code snippet added to the compatibilty header for Shadertoy inputs. @@ -221,10 +256,11 @@ def renderpass(self): # -> BufferRenderPass: return self.parent.main.buffers[self.buffer_idx] @property - def data(self) -> memoryview: + def data(self): """ previous frame rendered by this buffer. buffers render in order A, B, C, D. and before the Image pass. """ + # print(f"{self.renderpass.last_frame[0,0,2]=}") return self.renderpass.last_frame def create_texture(self, device) -> wgpu.GPUTexture: @@ -232,7 +268,7 @@ def create_texture(self, device) -> wgpu.GPUTexture: Creates the texture for this channel and sampler. Texture stays available to be updated later on. """ # TODO: this likely needs to be in the parent pass and simply accessed here... - self.texture = device.create_texture( + texture = device.create_texture( size=self.renderpass.texture_size, format=wgpu.TextureFormat.rgba8unorm, usage=wgpu.TextureUsage.COPY_DST @@ -248,7 +284,7 @@ def create_texture(self, device) -> wgpu.GPUTexture: # * self.renderpass.texture_size[1], # } # ) - return self.texture + return texture def update_texture(self, device): """ diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index c7dd35e..8adddf8 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -265,10 +265,92 @@ def shader_type(self) -> str: ) def _update_textures(self, device: wgpu.GPUDevice) -> None: - return # TODO: in development + # self._uniform_data = self.main._uniform_data # force update? + # print(f"{self._uniform_data['frame']} at start of _update_textures") + device.queue.write_buffer( + self._uniform_buffer, + 0, + self._uniform_data.mem, + 0, + self._uniform_data.nbytes, + ) + + # TODO: cleanup and avoid reuse of the same code + # this mostly reuses the later half of .prepare_render + bind_groups_layout_entries = [ + { + "binding": 0, + "resource": { + "buffer": self._uniform_buffer, + "offset": 0, + "size": self._uniform_data.nbytes, + }, + }, + ] + + binding_layout = [ + { + "binding": 0, + "visibility": wgpu.ShaderStage.FRAGMENT, + "buffer": {"type": wgpu.BufferBindingType.uniform}, + }, + ] + for channel in self.channels: - if channel is not None and channel.dynamic: - channel.update_texture(device) + if channel is None: # skip static channels (but keep their layout?) + continue + + layout, layout_entry = channel.bind_texture(device=device) + + binding_layout.extend(layout) + + bind_groups_layout_entries.extend(layout_entry) + + bind_group_layout = device.create_bind_group_layout(entries=binding_layout) + + self._bind_group = device.create_bind_group( + layout=bind_group_layout, + entries=bind_groups_layout_entries, + ) + + self._render_pipeline = device.create_render_pipeline( + layout=device.create_pipeline_layout( + bind_group_layouts=[bind_group_layout] + ), + vertex={ + "module": self._vertex_shader_program, + "entry_point": "main", + "buffers": [], + }, + primitive={ + "topology": wgpu.PrimitiveTopology.triangle_list, + "front_face": wgpu.FrontFace.ccw, + "cull_mode": wgpu.CullMode.none, + }, + depth_stencil=None, + multisample=None, + fragment={ + "module": self._frag_shader_program, + "entry_point": "main", + "targets": [ + { + "format": wgpu.TextureFormat.bgra8unorm, + "blend": { + "color": ( + wgpu.BlendFactor.one, + wgpu.BlendFactor.zero, + wgpu.BlendOperation.add, + ), + "alpha": ( + wgpu.BlendFactor.one, + wgpu.BlendFactor.zero, + wgpu.BlendOperation.add, + ), + }, + }, + ], + }, + ) def _attach_inputs(self, inputs: list) -> list[ShadertoyChannel, None]: if len(inputs) > 4: @@ -323,10 +405,10 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: + fragment_code_wgsl ) - vertex_shader_program = device.create_shader_module( + self._vertex_shader_program = device.create_shader_module( label="triangle_vert", code=vertex_shader_code ) - frag_shader_program = device.create_shader_module( + self._frag_shader_program = device.create_shader_module( label="triangle_frag", code=frag_shader_code ) @@ -363,32 +445,11 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: channel_res.extend([0, 0, 1, -99]) # default values; quick hack continue - binding_layout.extend(channel.binding_layout(offset=0)) - texture = channel.create_texture(device) - texture_view = texture.create_view() - # typing missing in wgpu-py for queue - # extract this to an update_texture method? - device.queue.write_texture( - { - "texture": texture, - "origin": (0, 0, 0), - "mip_level": 0, - }, - channel.data, - { - "offset": 0, - "bytes_per_row": channel.bytes_per_pixel - * channel.size[1], # multiple of 256 - "rows_per_image": channel.size[0], # same is done internally - }, - texture.size, - ) + layout, layout_entry = channel.bind_texture(device=device) - sampler = device.create_sampler(**channel.sampler_settings) - # TODO: explore using auto layouts (pygfx/wgpu-py#500) - bind_groups_layout_entries.extend( - channel.bind_groups_layout_entries(texture_view, sampler) - ) + binding_layout.extend(layout) + + bind_groups_layout_entries.extend(layout_entry) channel_res.extend(channel.channel_res) # padding/tests self._uniform_data["channel_res"] = tuple(channel_res) @@ -404,7 +465,7 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: bind_group_layouts=[bind_group_layout] ), vertex={ - "module": vertex_shader_program, + "module": self._vertex_shader_program, "entry_point": "main", "buffers": [], }, @@ -416,7 +477,7 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: depth_stencil=None, multisample=None, fragment={ - "module": frag_shader_program, + "module": self._frag_shader_program, "entry_point": "main", "targets": [ { @@ -582,6 +643,12 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> None: frame = device.queue.read_buffer(buffer) + frame = np.frombuffer(frame, dtype=np.uint8).reshape( + self.texture_size[1], self.texture_size[0], 4 + ) + # print(f"{self._last_frame[0,0,2]=}") + # print(f"{frame[0,0,2]=}") + # print(self._uniform_data["frame"]) self._last_frame = frame From e10aa81182b4dc52b48e988322461ac3a39879a1 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 23 May 2024 01:16:19 +0200 Subject: [PATCH 15/59] fix color and orientation --- wgpu_shadertoy/api.py | 48 +++++++++++++++++++++++----------------- wgpu_shadertoy/inputs.py | 6 +++-- wgpu_shadertoy/passes.py | 3 ++- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/wgpu_shadertoy/api.py b/wgpu_shadertoy/api.py index 083e6c2..2bdb58e 100644 --- a/wgpu_shadertoy/api.py +++ b/wgpu_shadertoy/api.py @@ -5,7 +5,7 @@ import requests from PIL import Image -from .inputs import ShadertoyChannel +from .inputs import ShadertoyChannel, ShadertoyChannelBuffer from .passes import BufferRenderPass HEADERS = {"user-agent": "https://github.com/pygfx/shadertoy script"} @@ -62,28 +62,36 @@ def _download_media_channels(inputs: list, use_cache=True): cache_dir = _get_cache_dir("media") complete = True for inp in inputs: - if inp["ctype"] != "texture": - complete = False - continue # TODO: support other media types + if inp["ctype"] == "texture": + cache_path = os.path.join(cache_dir, inp["src"].split("/")[-1]) + if use_cache and os.path.exists(cache_path): + img = Image.open(cache_path) + else: + response = requests.get( + media_url + inp["src"], headers=HEADERS, stream=True + ) + if response.status_code != 200: + raise requests.exceptions.HTTPError( + f"Failed to load media {media_url + inp['src']} with status code {response.status_code}" + ) + img = Image.open(response.raw) + if use_cache: + img.save(cache_path) + channel = ShadertoyChannel( + img, ctype=inp["ctype"], channel_idx=inp["channel"], **inp["sampler"] + ) + + elif inp["ctype"] == "buffer": + buffer_idx = "abcd"[ + int(inp["src"][-5]) + ] # hack with the preview image, otherwise you would have to look at output id... + channel = ShadertoyChannelBuffer(buffer=buffer_idx, **inp["sampler"]) - cache_path = os.path.join(cache_dir, inp["src"].split("/")[-1]) - if use_cache and os.path.exists(cache_path): - img = Image.open(cache_path) else: - response = requests.get( - media_url + inp["src"], headers=HEADERS, stream=True - ) - if response.status_code != 200: - raise requests.exceptions.HTTPError( - f"Failed to load media {media_url + inp['src']} with status code {response.status_code}" - ) - img = Image.open(response.raw) - if use_cache: - img.save(cache_path) - channel = ShadertoyChannel( - img, ctype=inp["ctype"], channel_idx=inp["channel"], **inp["sampler"] - ) + complete = False + continue # TODO: support other media types channels[inp["channel"]] = channel + return list(channels.values()), complete diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 20db906..6da5269 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -261,7 +261,9 @@ def data(self): previous frame rendered by this buffer. buffers render in order A, B, C, D. and before the Image pass. """ # print(f"{self.renderpass.last_frame[0,0,2]=}") - return self.renderpass.last_frame + # force vflip with Buffers? + data = np.ascontiguousarray(self.renderpass.last_frame[::-1, :, :]) + return data def create_texture(self, device) -> wgpu.GPUTexture: """ @@ -270,7 +272,7 @@ def create_texture(self, device) -> wgpu.GPUTexture: # TODO: this likely needs to be in the parent pass and simply accessed here... texture = device.create_texture( size=self.renderpass.texture_size, - format=wgpu.TextureFormat.rgba8unorm, + format=wgpu.TextureFormat.bgra8unorm, usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.TEXTURE_BINDING, # which ones do we actually need? diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 8adddf8..bcfa5e8 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -1,4 +1,5 @@ import re +from typing import List import numpy as np import wgpu @@ -352,7 +353,7 @@ def _update_textures(self, device: wgpu.GPUDevice) -> None: }, ) - def _attach_inputs(self, inputs: list) -> list[ShadertoyChannel, None]: + def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel, None]: if len(inputs) > 4: raise ValueError("Only 4 inputs supported") From 8529aeb7c8b6a318124db87f96f533f96fe73e30 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 23 May 2024 01:26:55 +0200 Subject: [PATCH 16/59] fix type annotations --- wgpu_shadertoy/inputs.py | 2 +- wgpu_shadertoy/passes.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 6da5269..d614e94 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -83,7 +83,7 @@ def channel_idx(self, idx=int): self._channel_idx = idx @property - def channel_res(self) -> tuple: + def channel_res(self) -> Tuple[int]: return ( self.size[1], self.size[0], diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index bcfa5e8..cff1c1b 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -353,7 +353,7 @@ def _update_textures(self, device: wgpu.GPUDevice) -> None: }, ) - def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel, None]: + def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel]: if len(inputs) > 4: raise ValueError("Only 4 inputs supported") From 8e2b577fe6f202bc830970fd8bceaee9d459041f Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 23 May 2024 14:06:58 +0200 Subject: [PATCH 17/59] only update dynamic channels --- wgpu_shadertoy/inputs.py | 24 +++++++++++--------- wgpu_shadertoy/passes.py | 48 ++++++++++++++++------------------------ 2 files changed, 32 insertions(+), 40 deletions(-) diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index d614e94..23d6ab5 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -82,6 +82,14 @@ def channel_idx(self, idx=int): raise ValueError("Channel index must be in [0,1,2,3]") self._channel_idx = idx + @property + def texture_binding(self) -> int: + return (2 * self.channel_idx) + 1 + + @property + def sampler_binding(self) -> int: + return 2 * (self.channel_idx + 1) + @property def channel_res(self) -> Tuple[int]: return ( @@ -111,12 +119,10 @@ def create_texture(self, device) -> wgpu.GPUTexture: def _binding_layout(self, offset=0): # TODO: figure out how offset works when we have multiple passes - texture_binding = (2 * self.channel_idx) + 1 - sampler_binding = 2 * (self.channel_idx + 1) return [ { - "binding": texture_binding, + "binding": self.texture_binding, "visibility": wgpu.ShaderStage.FRAGMENT, "texture": { "sample_type": wgpu.TextureSampleType.float, @@ -124,7 +130,7 @@ def _binding_layout(self, offset=0): }, }, { - "binding": sampler_binding, + "binding": self.sampler_binding, "visibility": wgpu.ShaderStage.FRAGMENT, "sampler": {"type": wgpu.SamplerBindingType.filtering}, }, @@ -132,22 +138,18 @@ def _binding_layout(self, offset=0): def _bind_groups_layout_entries(self, texture_view, sampler, offset=0) -> list: # TODO maybe refactor this all into a prepare bindings method? - texture_binding = (2 * self.channel_idx) + 1 - sampler_binding = 2 * (self.channel_idx + 1) return [ { - "binding": texture_binding, + "binding": self.texture_binding, "resource": texture_view, }, { - "binding": sampler_binding, + "binding": self.sampler_binding, "resource": sampler, }, ] - def bind_texture( - self, device: wgpu.GPUDevice - ) -> Tuple[wgpu.GPUBindGroupLayout, list]: + def bind_texture(self, device: wgpu.GPUDevice) -> Tuple[list, list]: binding_layout = self._binding_layout() texture = self.create_texture(device) texture_view = texture.create_view() diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index cff1c1b..fa1c90c 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -278,40 +278,28 @@ def _update_textures(self, device: wgpu.GPUDevice) -> None: # TODO: cleanup and avoid reuse of the same code # this mostly reuses the later half of .prepare_render - bind_groups_layout_entries = [ - { - "binding": 0, - "resource": { - "buffer": self._uniform_buffer, - "offset": 0, - "size": self._uniform_data.nbytes, - }, - }, - ] - - binding_layout = [ - { - "binding": 0, - "visibility": wgpu.ShaderStage.FRAGMENT, - "buffer": {"type": wgpu.BufferBindingType.uniform}, - }, - ] for channel in self.channels: - if channel is None: # skip static channels (but keep their layout?) + if ( + channel is None or not channel.dynamic + ): # skip static channels (but keep their layout?) continue layout, layout_entry = channel.bind_texture(device=device) - binding_layout.extend(layout) + self._binding_layout[channel.texture_binding] = layout[0] + self._binding_layout[channel.sampler_binding] = layout[1] - bind_groups_layout_entries.extend(layout_entry) + self._bind_groups_layout_entries[channel.texture_binding] = layout_entry[0] + self._bind_groups_layout_entries[channel.sampler_binding] = layout_entry[1] - bind_group_layout = device.create_bind_group_layout(entries=binding_layout) + bind_group_layout = device.create_bind_group_layout( + entries=self._binding_layout + ) self._bind_group = device.create_bind_group( layout=bind_group_layout, - entries=bind_groups_layout_entries, + entries=self._bind_groups_layout_entries, ) self._render_pipeline = device.create_render_pipeline( @@ -420,7 +408,7 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: ) # Step 3: layout and bind groups - bind_groups_layout_entries = [ + self._bind_groups_layout_entries = [ { "binding": 0, "resource": { @@ -431,7 +419,7 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: }, ] - binding_layout = [ + self._binding_layout = [ { "binding": 0, "visibility": wgpu.ShaderStage.FRAGMENT, @@ -448,17 +436,19 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: layout, layout_entry = channel.bind_texture(device=device) - binding_layout.extend(layout) + self._binding_layout.extend(layout) - bind_groups_layout_entries.extend(layout_entry) + self._bind_groups_layout_entries.extend(layout_entry) channel_res.extend(channel.channel_res) # padding/tests self._uniform_data["channel_res"] = tuple(channel_res) - bind_group_layout = device.create_bind_group_layout(entries=binding_layout) + bind_group_layout = device.create_bind_group_layout( + entries=self._binding_layout + ) self._bind_group = device.create_bind_group( layout=bind_group_layout, - entries=bind_groups_layout_entries, + entries=self._bind_groups_layout_entries, ) self._render_pipeline = device.create_render_pipeline( From cfc388faaad76a431b726afc99738eda1cd18c6c Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 23 May 2024 14:12:32 +0200 Subject: [PATCH 18/59] refactor duplicate code to method --- wgpu_shadertoy/passes.py | 123 ++++++++++++--------------------------- 1 file changed, 38 insertions(+), 85 deletions(-) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index fa1c90c..763e775 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -276,13 +276,8 @@ def _update_textures(self, device: wgpu.GPUDevice) -> None: self._uniform_data.nbytes, ) - # TODO: cleanup and avoid reuse of the same code - # this mostly reuses the later half of .prepare_render - for channel in self.channels: - if ( - channel is None or not channel.dynamic - ): # skip static channels (but keep their layout?) + if channel is None or not channel.dynamic: continue layout, layout_entry = channel.bind_texture(device=device) @@ -293,6 +288,42 @@ def _update_textures(self, device: wgpu.GPUDevice) -> None: self._bind_groups_layout_entries[channel.texture_binding] = layout_entry[0] self._bind_groups_layout_entries[channel.sampler_binding] = layout_entry[1] + self._finish_renderpass(device) + + def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel]: + if len(inputs) > 4: + raise ValueError("Only 4 inputs supported") + + # fill up with None to always have 4 inputs. + if len(inputs) < 4: + inputs.extend([None] * (4 - len(inputs))) + + channel_pattern = re.compile(r"(?:iChannel|i_channel)(\d+)") + detected_channels = [ + int(c) for c in set(channel_pattern.findall(self.shader_code)) + ] + + channels = [] + + for inp_idx, inp in enumerate(inputs): + if inp_idx not in detected_channels: + channels.append(None) + # maybe raise a warning or some error? For unusued channel + elif type(inp) is ShadertoyChannel: + channels.append(inp.infer_subclass(parent=self, channel_idx=inp_idx)) + elif isinstance(inp, ShadertoyChannel): + inp.channel_idx = inp_idx + inp.parent = self + channels.append(inp) + elif inp is None and inp_idx in detected_channels: + # this is the base case where we sample the black texture. + channels.append(ShadertoyChannelTexture(channel_idx=inp_idx)) + else: + channels.append(None) + + return channels + + def _finish_renderpass(self, device: wgpu.GPUDevice) -> None: bind_group_layout = device.create_bind_group_layout( entries=self._binding_layout ) @@ -341,39 +372,6 @@ def _update_textures(self, device: wgpu.GPUDevice) -> None: }, ) - def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel]: - if len(inputs) > 4: - raise ValueError("Only 4 inputs supported") - - # fill up with None to always have 4 inputs. - if len(inputs) < 4: - inputs.extend([None] * (4 - len(inputs))) - - channel_pattern = re.compile(r"(?:iChannel|i_channel)(\d+)") - detected_channels = [ - int(c) for c in set(channel_pattern.findall(self.shader_code)) - ] - - channels = [] - - for inp_idx, inp in enumerate(inputs): - if inp_idx not in detected_channels: - channels.append(None) - # maybe raise a warning or some error? For unusued channel - elif type(inp) is ShadertoyChannel: - channels.append(inp.infer_subclass(parent=self, channel_idx=inp_idx)) - elif isinstance(inp, ShadertoyChannel): - inp.channel_idx = inp_idx - inp.parent = self - channels.append(inp) - elif inp is None and inp_idx in detected_channels: - # this is the base case where we sample the black texture. - channels.append(ShadertoyChannelTexture(channel_idx=inp_idx)) - else: - channels.append(None) - - return channels - def prepare_render(self, device: wgpu.GPUDevice) -> None: # Step 1: compose shader programs shader_type = self.shader_type @@ -442,53 +440,8 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: channel_res.extend(channel.channel_res) # padding/tests self._uniform_data["channel_res"] = tuple(channel_res) - bind_group_layout = device.create_bind_group_layout( - entries=self._binding_layout - ) - self._bind_group = device.create_bind_group( - layout=bind_group_layout, - entries=self._bind_groups_layout_entries, - ) - - self._render_pipeline = device.create_render_pipeline( - layout=device.create_pipeline_layout( - bind_group_layouts=[bind_group_layout] - ), - vertex={ - "module": self._vertex_shader_program, - "entry_point": "main", - "buffers": [], - }, - primitive={ - "topology": wgpu.PrimitiveTopology.triangle_list, - "front_face": wgpu.FrontFace.ccw, - "cull_mode": wgpu.CullMode.none, - }, - depth_stencil=None, - multisample=None, - fragment={ - "module": self._frag_shader_program, - "entry_point": "main", - "targets": [ - { - "format": wgpu.TextureFormat.bgra8unorm, - "blend": { - "color": ( - wgpu.BlendFactor.one, - wgpu.BlendFactor.zero, - wgpu.BlendOperation.add, - ), - "alpha": ( - wgpu.BlendFactor.one, - wgpu.BlendFactor.zero, - wgpu.BlendOperation.add, - ), - }, - }, - ], - }, - ) + self._finish_renderpass(device) class ImageRenderPass(RenderPass): From 040d9e957bb48e56e77af6824536576e84313541 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 24 May 2024 01:55:02 +0200 Subject: [PATCH 19/59] add row padding, resizing still broken --- examples/shadertoy_buffer.py | 3 +-- wgpu_shadertoy/passes.py | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/shadertoy_buffer.py b/examples/shadertoy_buffer.py index 65ef094..c183833 100644 --- a/examples/shadertoy_buffer.py +++ b/examples/shadertoy_buffer.py @@ -50,8 +50,7 @@ shader = Shadertoy( image_code, inputs=[buffer_a_channel], - buffers={"a": buffer_a_pass}, - resolution=(512, 256), + buffers={"a": buffer_a_pass} ) if __name__ == "__main__": diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 763e775..0d9ce28 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -509,8 +509,10 @@ def buffer_idx(self, value: str): @property def texture_size(self) -> tuple: # (columns, rows, 1) - # TODO: figure out padding this to always be a multiple of 64 wide? + row_alignmnet = 64 # because it's 4 bytes per pixel so 265 columns = int(self.main.resolution[0]) + if columns % row_alignmnet != 0: + columns = (columns // row_alignmnet + 1) * row_alignmnet rows = int(self.main.resolution[1]) texture_size = (columns, rows, 1) return texture_size From 57df916cc2078cdc6e4e21b40d184f96b220f7ec Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 25 May 2024 21:51:04 +0200 Subject: [PATCH 20/59] fix detected channels in common --- examples/shader_ssjyWc.json | 313 +++++++++++++++++++++++++++++++++++ examples/shadertoy_buffer.py | 6 +- wgpu_shadertoy/__init__.py | 2 +- wgpu_shadertoy/api.py | 2 +- wgpu_shadertoy/inputs.py | 6 +- wgpu_shadertoy/passes.py | 107 ++++++------ 6 files changed, 374 insertions(+), 62 deletions(-) create mode 100644 examples/shader_ssjyWc.json diff --git a/examples/shader_ssjyWc.json b/examples/shader_ssjyWc.json new file mode 100644 index 0000000..e0d34e7 --- /dev/null +++ b/examples/shader_ssjyWc.json @@ -0,0 +1,313 @@ +{ + "Shader": { + "ver": "0.1", + "info": { + "id": "ssjyWc", + "date": "1644259512", + "viewed": 491691, + "name": "Lover 2", + "username": "FabriceNeyret2", + "description": "Fork of \"Lover\" by wyatt. https://shadertoy.com/view/fsjyR3 \ntrying to mimic Karthik Dondeti https://twitter.com/d0ndeti/status/1479814051366539264 series.\n\n- A:use close curve, starting as circle. k partics\n- I: basic drawing\nstill, there are crossings.", + "likes": 370, + "published": 3, + "flags": 48, + "usePreview": 0, + "tags": [ + "paper", + "reproduction", + "dondeti" + ], + "hasliked": 0 + }, + "renderpass": [ + { + "inputs": [ + { + "id": 257, + "src": "/media/previz/buffer00.png", + "ctype": "buffer", + "channel": 0, + "sampler": { + "filter": "linear", + "wrap": "clamp", + "vflip": "true", + "srgb": "false", + "internal": "byte" + }, + "published": 1 + }, + { + "id": 258, + "src": "/media/previz/buffer01.png", + "ctype": "buffer", + "channel": 1, + "sampler": { + "filter": "linear", + "wrap": "clamp", + "vflip": "true", + "srgb": "false", + "internal": "byte" + }, + "published": 1 + }, + { + "id": 259, + "src": "/media/previz/buffer02.png", + "ctype": "buffer", + "channel": 2, + "sampler": { + "filter": "linear", + "wrap": "clamp", + "vflip": "true", + "srgb": "false", + "internal": "byte" + }, + "published": 1 + }, + { + "id": 260, + "src": "/media/previz/buffer03.png", + "ctype": "buffer", + "channel": 3, + "sampler": { + "filter": "linear", + "wrap": "clamp", + "vflip": "true", + "srgb": "false", + "internal": "byte" + }, + "published": 1 + } + ], + "outputs": [ + { + "id": 37, + "channel": 0 + } + ], + "code": "// Fork of \"Lover\" by wyatt. https://shadertoy.com/view/fsjyR3\n// 2022-02-07 18:41:05\n\nMain Q = B( U ).zzzz; }\n", + "name": "Image", + "description": "", + "type": "image" + }, + { + "inputs": [], + "outputs": [], + "code": "vec2 R; int I;\n#define A(U) texture(iChannel0,(U)/R)\n#define B(U) texture(iChannel1,(U)/R)\n#define C(U) texture(iChannel2,(U)/R)\n#define D(U) texture(iChannel3,(U)/R)\n#define Main void mainImage(out vec4 Q, in vec2 U) { R = iResolution.xy; I = iFrame;\nfloat G2 (float w, float s) {\n return 0.15915494309*exp(-.5*w*w/s/s)/(s*s);\n}\nfloat G1 (float w, float s) {\n return 0.3989422804*exp(-.5*w*w/s/s)/(s);\n}\nfloat heart (vec2 u) {\n u -= vec2(.5,.4)*R;\n u.y -= 10.*sqrt(abs(u.x));\n u.y *= 1.;\n u.x *= .8;\n if (length(u)<.35*R.y) return 1.;\n else return 0.;\n}\n\nfloat _12(vec2 U) {\n\n return clamp(floor(U.x)+floor(U.y)*R.x,0.,R.x*R.y);\n\n}\n\nvec2 _21(float i) {\n\n return clamp(vec2(mod(i,R.x),floor(i/R.x))+.5,vec2(0),R);\n\n}\n\nfloat sg (vec2 p, vec2 a, vec2 b) {\n float i = clamp(dot(p-a,b-a)/dot(b-a,b-a),0.,1.);\n\tfloat l = (length(p-a-(b-a)*i));\n return l;\n}\n\nfloat hash (vec2 p)\n{\n\tvec3 p3 = fract(vec3(p.xyx) * .1031);\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\nfloat noise(vec2 p)\n{\n vec4 w = vec4(\n floor(p),\n ceil (p) );\n float \n _00 = hash(w.xy),\n _01 = hash(w.xw),\n _10 = hash(w.zy),\n _11 = hash(w.zw),\n _0 = mix(_00,_01,fract(p.y)),\n _1 = mix(_10,_11,fract(p.y));\n return mix(_0,_1,fract(p.x));\n}\nfloat fbm (vec2 p) {\n float o = 0.;\n for (float i = 0.; i < 3.; i++) {\n o += noise(.1*p)/3.;\n o += .2*exp(-2.*abs(sin(.02*p.x+.01*p.y)))/3.;\n p *= 2.;\n }\n return o;\n}\nvec2 grad (vec2 p) {\n float \n n = fbm(p+vec2(0,1)),\n e = fbm(p+vec2(1,0)),\n s = fbm(p-vec2(0,1)),\n w = fbm(p-vec2(1,0));\n return vec2(e-w,n-s);\n}\n", + "name": "Common", + "description": "", + "type": "common" + }, + { + "inputs": [ + { + "id": 33, + "src": "/presets/tex00.jpg", + "ctype": "keyboard", + "channel": 2, + "sampler": { + "filter": "linear", + "wrap": "clamp", + "vflip": "true", + "srgb": "false", + "internal": "byte" + }, + "published": 1 + }, + { + "id": 257, + "src": "/media/previz/buffer00.png", + "ctype": "buffer", + "channel": 0, + "sampler": { + "filter": "linear", + "wrap": "clamp", + "vflip": "true", + "srgb": "false", + "internal": "byte" + }, + "published": 1 + }, + { + "id": 260, + "src": "/media/previz/buffer03.png", + "ctype": "buffer", + "channel": 3, + "sampler": { + "filter": "linear", + "wrap": "clamp", + "vflip": "true", + "srgb": "false", + "internal": "byte" + }, + "published": 1 + } + ], + "outputs": [ + { + "id": 257, + "channel": 0 + } + ], + "code": "#define keyClick(a) ( texelFetch(iChannel2,ivec2(a,0),0).x > 0.)\n\n#define k ( .02 * R.x*R.y )\nMain \n float i = _12(U);\n Q = A(U);\n \n vec2 f = vec2(0);\n \n if ( i < k ) {\n for (float j = -20.; j <= 20.; j++) \n if (j!=0.) {// && j+i>=0. && j+i=0. && j+i.1) f = .1*normalize(f);\n Q.zw += f-.03*Q.zw;\n Q.xy += f+1.5*Q.zw*inversesqrt(1.+dot(Q.zw,Q.zw));\n \n vec4 m = .5*( A(_21(i-1.)) + A(_21(i+1.)) );\n Q.zw = mix(Q.zw,m.zw,0.1);\n Q.xy = mix(Q.xy,m.xy,0.01);\n if (Q.x>R.x)Q.y=.5*R.y,Q.z=-10.;\n if (Q.x<0.)Q.y=.5*R.y,Q.z=10.;\n }\n if (iFrame < 1 || keyClick(32)) {\n if ( i > k ) \n Q = vec4(R+i,0,0); \n else\n Q = vec4(.5*R + .25*R.y* cos( 6.28*i/k + vec2(0,1.57)), 0,0 );\n // Q = vec4(i-.5*R.x*R.y,.5*R.y,0,0);\n }\n \n\n}", + "name": "Buffer A", + "description": "", + "type": "buffer" + }, + { + "inputs": [ + { + "id": 257, + "src": "/media/previz/buffer00.png", + "ctype": "buffer", + "channel": 0, + "sampler": { + "filter": "linear", + "wrap": "clamp", + "vflip": "true", + "srgb": "false", + "internal": "byte" + }, + "published": 1 + }, + { + "id": 258, + "src": "/media/previz/buffer01.png", + "ctype": "buffer", + "channel": 1, + "sampler": { + "filter": "linear", + "wrap": "clamp", + "vflip": "true", + "srgb": "false", + "internal": "byte" + }, + "published": 1 + } + ], + "outputs": [ + { + "id": 258, + "channel": 0 + } + ], + "code": "void XY (vec2 U, inout vec4 Q, vec4 q) {\n if (length(U-A(_21(q.x)).xy) None: with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 23d6ab5..68a3aea 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -104,9 +104,9 @@ def size(self) -> tuple: # tuple? return self.data.shape @property - def bytes_per_pixel( - self, - ) -> int: # usually is 4 for rgba8unorm or maybe use self.data.strides[1]? + def bytes_per_pixel(self) -> int: + return 4 # shortcut for speed? + # usually is 4 for rgba8unorm or maybe use self.data.strides[1]? # print(self.data.shape, self.data.nbytes) bpp = self.data.nbytes // self.data.shape[1] // self.data.shape[0] diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 0d9ce28..35bf2a2 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -221,10 +221,11 @@ def __init__( self._main = main # could be None... self._shader_type = shader_type self._shader_code = code - self.channels = self._attach_inputs(inputs) + self._inputs = inputs # keep them here so we only attach them later? + # self.channels = self._attach_inputs(inputs) @property - def main(self): # -> Shadertoy (can't type due to circular import?) + def main(self): # -> "Shadertoy" (can't type due to circular import?) """ The main Shadertoy class of which this renderpass is part of. """ @@ -300,7 +301,8 @@ def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel]: channel_pattern = re.compile(r"(?:iChannel|i_channel)(\d+)") detected_channels = [ - int(c) for c in set(channel_pattern.findall(self.shader_code)) + int(c) + for c in set(channel_pattern.findall(self.main.common + self.shader_code)) ] channels = [] @@ -323,57 +325,9 @@ def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel]: return channels - def _finish_renderpass(self, device: wgpu.GPUDevice) -> None: - bind_group_layout = device.create_bind_group_layout( - entries=self._binding_layout - ) - - self._bind_group = device.create_bind_group( - layout=bind_group_layout, - entries=self._bind_groups_layout_entries, - ) - - self._render_pipeline = device.create_render_pipeline( - layout=device.create_pipeline_layout( - bind_group_layouts=[bind_group_layout] - ), - vertex={ - "module": self._vertex_shader_program, - "entry_point": "main", - "buffers": [], - }, - primitive={ - "topology": wgpu.PrimitiveTopology.triangle_list, - "front_face": wgpu.FrontFace.ccw, - "cull_mode": wgpu.CullMode.none, - }, - depth_stencil=None, - multisample=None, - fragment={ - "module": self._frag_shader_program, - "entry_point": "main", - "targets": [ - { - "format": wgpu.TextureFormat.bgra8unorm, - "blend": { - "color": ( - wgpu.BlendFactor.one, - wgpu.BlendFactor.zero, - wgpu.BlendOperation.add, - ), - "alpha": ( - wgpu.BlendFactor.one, - wgpu.BlendFactor.zero, - wgpu.BlendOperation.add, - ), - }, - }, - ], - }, - ) - def prepare_render(self, device: wgpu.GPUDevice) -> None: # Step 1: compose shader programs + self.channels = self._attach_inputs(self._inputs) shader_type = self.shader_type if shader_type == "glsl": vertex_shader_code = vertex_code_glsl @@ -443,6 +397,55 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: self._finish_renderpass(device) + def _finish_renderpass(self, device: wgpu.GPUDevice) -> None: + bind_group_layout = device.create_bind_group_layout( + entries=self._binding_layout + ) + + self._bind_group = device.create_bind_group( + layout=bind_group_layout, + entries=self._bind_groups_layout_entries, + ) + + self._render_pipeline = device.create_render_pipeline( + layout=device.create_pipeline_layout( + bind_group_layouts=[bind_group_layout] + ), + vertex={ + "module": self._vertex_shader_program, + "entry_point": "main", + "buffers": [], + }, + primitive={ + "topology": wgpu.PrimitiveTopology.triangle_list, + "front_face": wgpu.FrontFace.ccw, + "cull_mode": wgpu.CullMode.none, + }, + depth_stencil=None, + multisample=None, + fragment={ + "module": self._frag_shader_program, + "entry_point": "main", + "targets": [ + { + "format": wgpu.TextureFormat.bgra8unorm, + "blend": { + "color": ( + wgpu.BlendFactor.one, + wgpu.BlendFactor.zero, + wgpu.BlendOperation.add, + ), + "alpha": ( + wgpu.BlendFactor.one, + wgpu.BlendFactor.zero, + wgpu.BlendOperation.add, + ), + }, + }, + ], + }, + ) + class ImageRenderPass(RenderPass): """ From 24b523f327add6edcf5816e7d90a9cd074465f80 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 28 May 2024 01:39:20 +0200 Subject: [PATCH 21/59] add buffer resizing --- examples/shadertoy_buffer_lovers.py | 19 +++++++++++++++++++ wgpu_shadertoy/inputs.py | 7 ++++++- wgpu_shadertoy/passes.py | 25 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 examples/shadertoy_buffer_lovers.py diff --git a/examples/shadertoy_buffer_lovers.py b/examples/shadertoy_buffer_lovers.py new file mode 100644 index 0000000..e44c1ed --- /dev/null +++ b/examples/shadertoy_buffer_lovers.py @@ -0,0 +1,19 @@ +# run_example = false +# exploring some issues with this exact shader... +from pathlib import Path + +from wgpu_shadertoy import Shadertoy +from wgpu_shadertoy.api import shader_args_from_json + +# shadertoy source: https://www.shadertoy.com/view/ssjyWc by FabriceNeyret2 (CC-BY-NC-SA-3.0?) + +shader_id = "ssjyWc" +json_path = Path(Path(__file__).parent, f"shader_{shader_id}.json") + +shader_args = shader_args_from_json(json_path) +print(f"{shader_args['inputs']=}") +shader = Shadertoy.from_json(json_path, resolution=(1024, 512)) +# shader = Shadertoy(**shader_args) + +if __name__ == "__main__": + shader.show() diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 68a3aea..1ecf6b9 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -155,6 +155,7 @@ def bind_texture(self, device: wgpu.GPUDevice) -> Tuple[list, list]: texture_view = texture.create_view() # typing missing in wgpu-py for queue # extract this to an update_texture method? + # print(f"{self}, {self.data[0][0]=}") device.queue.write_texture( { "texture": texture, @@ -263,8 +264,12 @@ def data(self): previous frame rendered by this buffer. buffers render in order A, B, C, D. and before the Image pass. """ # print(f"{self.renderpass.last_frame[0,0,2]=}") + data = self.renderpass.last_frame + # overwrite the alpha channel to 255 + data[:, :, 3] = 255 + # force vflip with Buffers? - data = np.ascontiguousarray(self.renderpass.last_frame[::-1, :, :]) + data = np.ascontiguousarray(data[::-1, :, :]) return data def create_texture(self, device) -> wgpu.GPUTexture: diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 35bf2a2..15839fe 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -518,8 +518,32 @@ def texture_size(self) -> tuple: columns = (columns // row_alignmnet + 1) * row_alignmnet rows = int(self.main.resolution[1]) texture_size = (columns, rows, 1) + if ( + hasattr(self, "_last_frame") + and (rows, columns) != self._last_frame.shape[0:2] + ): + # Maybe do this with the on_resize method instead? but here we will always have the correct padding available. + self.resize(rows, columns) + return texture_size + def resize(self, new_row: int, new_col: int) -> None: + """ + resizes the buffer to a new speicified size. + Downscaling keeps the top leftmost corner, + upscaling pads the bottom and right with black. + (this becomes bottom left, after vflip). + """ + old_row, old_col, _ = self._last_frame.shape + if new_row < old_row or new_col < old_col: + self._last_frame = self._last_frame[-new_row:, :new_col, :] + else: + self._last_frame = np.pad( + self._last_frame, + ((new_row - old_row, 0), (0, new_col - old_col), (0, 0)), + mode="constant", + ) + @property def last_frame(self): if not hasattr(self, "_last_frame"): @@ -598,6 +622,7 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> None: # print(f"{self._last_frame[0,0,2]=}") # print(f"{frame[0,0,2]=}") # print(self._uniform_data["frame"]) + # print(f"{self}, {frame[100][100]=}") self._last_frame = frame From ecc8775e714401bb4b9fb86afa8fe3f54e534e3b Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 29 May 2024 01:46:06 +0200 Subject: [PATCH 22/59] fix channel order --- wgpu_shadertoy/api.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/wgpu_shadertoy/api.py b/wgpu_shadertoy/api.py index 1846a24..672a600 100644 --- a/wgpu_shadertoy/api.py +++ b/wgpu_shadertoy/api.py @@ -58,10 +58,11 @@ def _download_media_channels(inputs: list, use_cache=True): Requires internet connection (API key not required). """ media_url = "https://www.shadertoy.com" - channels = {} + channels = [None] * 4 cache_dir = _get_cache_dir("media") complete = True for inp in inputs: + # careful, the order of inputs is not guaranteed to be the same as the order of channels! if inp["ctype"] == "texture": cache_path = os.path.join(cache_dir, inp["src"].split("/")[-1]) if use_cache and os.path.exists(cache_path): @@ -85,14 +86,15 @@ def _download_media_channels(inputs: list, use_cache=True): buffer_idx = "abcd"[ int(inp["src"][-5]) ] # hack with the preview image, otherwise you would have to look at output id... - channel = ShadertoyChannelBuffer(buffer=buffer_idx, **inp["sampler"]) + channel = ShadertoyChannelBuffer( + buffer=buffer_idx, channel_idx=inp["channel"], **inp["sampler"] + ) else: complete = False continue # TODO: support other media types channels[inp["channel"]] = channel - - return list(channels.values()), complete + return channels, complete def _save_json(data: dict, path: os.PathLike) -> None: From 299e5f728b1faede3679163abda80e6302053b86 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 6 Jun 2024 01:56:28 +0200 Subject: [PATCH 23/59] reuse texture for performance --- wgpu_shadertoy/inputs.py | 12 +++ wgpu_shadertoy/passes.py | 154 +++++++++++++++++++++++++++--------- wgpu_shadertoy/shadertoy.py | 3 + 3 files changed, 132 insertions(+), 37 deletions(-) diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 1ecf6b9..dc43c4f 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -272,6 +272,18 @@ def data(self): data = np.ascontiguousarray(data[::-1, :, :]) return data + def bind_texture(self, device: wgpu.GPUDevice) -> Tuple[list, list]: + """ """ + binding_layout = self._binding_layout() + texture = self.renderpass.texture + texture_view = texture.create_view() + sampler = device.create_sampler(**self.sampler_settings) + # TODO: explore using auto layouts (pygfx/wgpu-py#500) + bind_groups_layout_entry = self._bind_groups_layout_entries( + texture_view, sampler + ) + return binding_layout, bind_groups_layout_entry + def create_texture(self, device) -> wgpu.GPUTexture: """ Creates the texture for this channel and sampler. Texture stays available to be updated later on. diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 15839fe..05e662d 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -101,7 +101,6 @@ """ - vertex_code_wgsl = """ struct Varyings { @@ -512,20 +511,15 @@ def buffer_idx(self, value: str): @property def texture_size(self) -> tuple: # (columns, rows, 1) - row_alignmnet = 64 # because it's 4 bytes per pixel so 265 - columns = int(self.main.resolution[0]) - if columns % row_alignmnet != 0: - columns = (columns // row_alignmnet + 1) * row_alignmnet - rows = int(self.main.resolution[1]) - texture_size = (columns, rows, 1) - if ( - hasattr(self, "_last_frame") - and (rows, columns) != self._last_frame.shape[0:2] - ): - # Maybe do this with the on_resize method instead? but here we will always have the correct padding available. - self.resize(rows, columns) - - return texture_size + if not hasattr(self, "_size"): + row_alignmnet = 64 # because it's 4 bytes per pixel so 265 + columns = int(self.main.resolution[0]) + if columns % row_alignmnet != 0: + columns = (columns // row_alignmnet + 1) * row_alignmnet + rows = int(self.main.resolution[1]) + self._size = (columns, rows, 1) + return self._size + def resize(self, new_row: int, new_col: int) -> None: """ @@ -535,17 +529,40 @@ def resize(self, new_row: int, new_col: int) -> None: (this becomes bottom left, after vflip). """ old_row, old_col, _ = self._last_frame.shape + print(f"Resizing from {old_row}x{old_col} to {new_row}x{new_col}") + old = self._download_texture(size=(old_col, old_row, 1)) + if new_row < old_row or new_col < old_col: - self._last_frame = self._last_frame[-new_row:, :new_col, :] + new = old[-new_row:, :new_col, :] else: - self._last_frame = np.pad( - self._last_frame, + new = np.pad( + old, ((new_row - old_row, 0), (0, new_col - old_col), (0, 0)), mode="constant", ) + self._upload_texture(new) + + @property + def texture(self) -> wgpu.GPUTexture: + """ + the texture that the buffer renders to, will also be used as a texture by the BufferChannels. + """ + if not hasattr(self, "_texture"): + # creates the initial texture + self._texture = self.main._device.create_texture( + size=self.texture_size, + format=wgpu.TextureFormat.bgra8unorm, + usage=wgpu.TextureUsage.COPY_SRC + | wgpu.TextureUsage.COPY_DST + | wgpu.TextureUsage.RENDER_ATTACHMENT + | wgpu.TextureUsage.TEXTURE_BINDING, + ) + return self._texture @property def last_frame(self): + # TODO: refactor to be a texture, not a buffer or even a numpy array. + # we likely need to have download/upload private methods to support resizing etc (unless we write shaders for that haha) if not hasattr(self, "_last_frame"): self._last_frame = self._initial_buffer() return self._last_frame @@ -565,15 +582,12 @@ def _initial_buffer(self): def draw_buffer(self, device: wgpu.GPUDevice) -> None: """ - draws the buffer to the texture and updates self.last_frame + draws the buffer to the texture and updates self._texture. """ - # TODO: maybe call these functions draw_buffer and have them easier to call at once? self._update_textures(device) - buffer = device.create_buffer( - size=(self.texture_size[0] * self.texture_size[1] * 4), - usage=wgpu.BufferUsage.COPY_SRC | wgpu.BufferUsage.COPY_DST, - ) command_encoder = device.create_command_encoder() + + # create a temporary texture as a render target target_texture = device.create_texture( size=self.texture_size, format=wgpu.TextureFormat.bgra8unorm, @@ -597,33 +611,99 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> None: render_pass.set_bind_group(0, self._bind_group, [], 0, 99) render_pass.draw(3, 1, 0, 0) # what is .draw_indirect? render_pass.end() - command_encoder.copy_texture_to_buffer( + + command_encoder.copy_texture_to_texture( { "texture": target_texture, "mip_level": 0, "origin": (0, 0, 0), }, { - "buffer": buffer, - "offset": 0, - "bytes_per_row": self.texture_size[0] * 4, - "rows_per_image": self.texture_size[1], + "texture": self._texture, + "mip_level": 0, + "origin": (0, 0, 0), }, - self.texture_size, + self.texture_size, # could this handle resizing? ) device.queue.submit([command_encoder.finish()]) - frame = device.queue.read_buffer(buffer) + def _download_texture( + self, + device: wgpu.GPUDevice = None, + size=None, + command_encoder: wgpu.GPUCommandEncoder = None, + ): + """ + downloads the texture from the GPU to the CPU by returning a numpy array. + """ + if device is None: + device = self.main._device + if command_encoder is None: + command_encoder = device.create_command_encoder() + if size is None: + size = self.texture_size - frame = np.frombuffer(frame, dtype=np.uint8).reshape( - self.texture_size[1], self.texture_size[0], 4 + buffer = device.create_buffer( + size=(size[0] * size[1] * 4), + usage=wgpu.BufferUsage.COPY_SRC | wgpu.BufferUsage.COPY_DST, + ) + command_encoder.copy_texture_to_buffer( + { + "texture": self.texture, + "mip_level": 0, + "origin": (0, 0, 0), + }, + { + "buffer": buffer, + "offset": 0, + "bytes_per_row": size[0] * 4, + "rows_per_image": size[1], + }, + size, ) - # print(f"{self._last_frame[0,0,2]=}") - # print(f"{frame[0,0,2]=}") - # print(self._uniform_data["frame"]) - # print(f"{self}, {frame[100][100]=}") + device.queue.submit([command_encoder.finish()]) + frame = device.queue.read_buffer(buffer) + frame = np.frombuffer(frame, dtype=np.uint8).reshape(size[1], size[0], 4) + # redundant copy? self._last_frame = frame + return frame + + def _upload_texture(self, data, device=None, command_encoder=None): + """ + uploads some data to self._texture. + """ + if device is None: + device = self.main._device + if command_encoder is None: + command_encoder = device.create_command_encoder() + + data = np.ascontiguousarray(data) + # create a new texture with the changed size? + new_texture = device.create_texture( + size=(data.shape[1], data.shape[0], 1), + format=wgpu.TextureFormat.bgra8unorm, + usage=wgpu.TextureUsage.COPY_SRC + | wgpu.TextureUsage.RENDER_ATTACHMENT + | wgpu.TextureUsage.COPY_DST + | wgpu.TextureUsage.TEXTURE_BINDING, + ) + + device.queue.write_texture( + { + "texture": new_texture, + "mip_level": 0, + "origin": (0, 0, 0), + }, + data, + { + "offset": 0, + "bytes_per_row": data.strides[0], + "rows_per_image": data.shape[0], + }, + (data.shape[1], data.shape[0], 1), + ) + self._texture = new_texture class CubemapRenderPass(RenderPass): diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index cbd35c9..44e61cb 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -233,6 +233,9 @@ def _bind_events(self): def on_resize(event): w, h = event["width"], event["height"] self._uniform_data["resolution"] = (w, h, 1) + # for buf in self.buffers.values(): + # if buf: + # buf.resize(int(w), int(h)) def on_mouse_move(event): if event["button"] == 1 or 1 in event["buttons"]: From bdd6d18bd1c95f0365caf6a56b56c460030f949c Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 7 Jun 2024 00:52:46 +0200 Subject: [PATCH 24/59] fix resize --- wgpu_shadertoy/passes.py | 47 +++++++++++++++++++++++-------------- wgpu_shadertoy/shadertoy.py | 6 ++--- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 05e662d..00dbef0 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -510,34 +510,45 @@ def buffer_idx(self, value: str): @property def texture_size(self) -> tuple: - # (columns, rows, 1) - if not hasattr(self, "_size"): - row_alignmnet = 64 # because it's 4 bytes per pixel so 265 - columns = int(self.main.resolution[0]) - if columns % row_alignmnet != 0: - columns = (columns // row_alignmnet + 1) * row_alignmnet + """ + (columns, rows, 1), cols aligned to 64. + (height) + """ + if not hasattr(self, "_texture_size"): + columns = self._pad_columns(int(self.main.resolution[0])) rows = int(self.main.resolution[1]) - self._size = (columns, rows, 1) - return self._size - + self._texture_size = (columns, rows, 1) + else: + self._texture_size = self._texture.size - def resize(self, new_row: int, new_col: int) -> None: + return self._texture_size + + def _pad_columns(self, cols: int, alignment=64) -> int: + if cols % alignment != 0: + cols = (cols // alignment + 1) * alignment + print(cols) + return cols + + def resize(self, new_cols: int, new_rows: int) -> None: """ resizes the buffer to a new speicified size. Downscaling keeps the top leftmost corner, upscaling pads the bottom and right with black. (this becomes bottom left, after vflip). """ - old_row, old_col, _ = self._last_frame.shape - print(f"Resizing from {old_row}x{old_col} to {new_row}x{new_col}") - old = self._download_texture(size=(old_col, old_row, 1)) - - if new_row < old_row or new_col < old_col: - new = old[-new_row:, :new_col, :] + # TODO: could this be redone as a compute shader? + + old_cols, old_rows, _ = self.texture_size + new_cols = self._pad_columns(new_cols) + if new_cols == old_cols and new_rows == old_rows: + return + old = self._download_texture() + if new_rows < old_rows or new_cols < old_cols: + new = old[-new_rows:, :new_cols, :] else: new = np.pad( old, - ((new_row - old_row, 0), (0, new_col - old_col), (0, 0)), + ((new_rows - old_rows, 0), (0, new_cols - old_cols), (0, 0)), mode="constant", ) self._upload_texture(new) @@ -698,7 +709,7 @@ def _upload_texture(self, data, device=None, command_encoder=None): data, { "offset": 0, - "bytes_per_row": data.strides[0], + "bytes_per_row": data.shape[1] * 4, "rows_per_image": data.shape[0], }, (data.shape[1], data.shape[0], 1), diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index 44e61cb..2e80972 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -233,9 +233,9 @@ def _bind_events(self): def on_resize(event): w, h = event["width"], event["height"] self._uniform_data["resolution"] = (w, h, 1) - # for buf in self.buffers.values(): - # if buf: - # buf.resize(int(w), int(h)) + for buf in self.buffers.values(): + if buf: + buf.resize(int(w), int(h)) def on_mouse_move(event): if event["button"] == 1 or 1 in event["buttons"]: From 50515a6d81ec2344a462518b0bbef014256e0fc5 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 7 Jun 2024 01:45:07 +0200 Subject: [PATCH 25/59] fix vflip buffers --- wgpu_shadertoy/inputs.py | 16 ++++++---- wgpu_shadertoy/passes.py | 68 +++++++++++++++++++++++++++++++++------- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index dc43c4f..375871a 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -178,24 +178,28 @@ def bind_texture(self, device: wgpu.GPUDevice) -> Tuple[list, list]: ) return binding_layout, bind_groups_layout_entry - def header_glsl(self, input_idx=0): + def header_glsl(self): """ GLSL code snippet added to the compatibilty header for Shadertoy inputs. """ - binding_id = (2 * input_idx) + 1 - sampler_id = 2 * (input_idx + 1) + input_idx = self.channel_idx + binding_id = self.texture_binding + sampler_id = self.sampler_binding return f""" layout(binding = {binding_id}) uniform texture2D i_channel{input_idx}; layout(binding = {sampler_id}) uniform sampler sampler{input_idx}; + #define iChannel{input_idx} sampler2D(i_channel{input_idx}, sampler{input_idx}) + """ - def header_wgsl(self, input_idx=0): + def header_wgsl(self): """ WGSL code snippet added to the compatibilty header for Shadertoy inputs. """ - binding_id = (2 * input_idx) + 1 - sampler_id = 2 * (input_idx + 1) + input_idx = self.channel_idx + binding_id = self.texture_binding + sampler_id = self.sampler_binding return f""" @group(0) @binding{binding_id} var i_channel{input_idx}: texture_2d; diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 00dbef0..cbf7b5f 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -24,6 +24,25 @@ } } """ +# TODO: avoid redundant globals, refactor to something like a headers.py file? +vertex_code_glsl_flipped = """#version 450 core + +layout(location = 0) out vec2 vert_uv; + +void main(void){ + int index = int(gl_VertexID); + if (index == 0) { + gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); + vert_uv = vec2(0.0, 0.0); // Flipped + } else if (index == 1) { + gl_Position = vec4(3.0, -1.0, 0.0, 1.0); + vert_uv = vec2(2.0, 0.0); // Flipped + } else { + gl_Position = vec4(-1.0, 3.0, 0.0, 1.0); + vert_uv = vec2(0.0, 2.0); // Flipped + } +} +""" builtin_variables_glsl = """#version 450 core @@ -126,6 +145,31 @@ } """ +vertex_code_wgsl_flipped = """ + +struct Varyings { + @builtin(position) position : vec4, + @location(0) vert_uv : vec2, +}; + +@vertex +fn main(@builtin(vertex_index) index: u32) -> Varyings { + var out: Varyings; + if (index == u32(0)) { + out.position = vec4(-1.0, -1.0, 0.0, 1.0); + out.vert_uv = vec2(0.0, 0.0); // Flipped + } else if (index == u32(1)) { + out.position = vec4(3.0, -1.0, 0.0, 1.0); + out.vert_uv = vec2(2.0, 0.0); // Flipped + } else { + out.position = vec4(-1.0, 3.0, 0.0, 1.0); + out.vert_uv = vec2(0.0, -2.0); // Flipped + } + return out; + +} +""" + builtin_variables_wgsl = """ @@ -329,7 +373,10 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: self.channels = self._attach_inputs(self._inputs) shader_type = self.shader_type if shader_type == "glsl": - vertex_shader_code = vertex_code_glsl + if type(self) is BufferRenderPass: + vertex_shader_code = vertex_code_glsl_flipped + else: + vertex_shader_code = vertex_code_glsl frag_shader_code = ( builtin_variables_glsl + self.main.common @@ -337,7 +384,10 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: + fragment_code_glsl ) elif shader_type == "wgsl": - vertex_shader_code = vertex_code_wgsl + if type(self) is BufferRenderPass: + vertex_shader_code = vertex_code_wgsl_flipped + else: + vertex_shader_code = vertex_code_wgsl frag_shader_code = ( builtin_variables_wgsl + self.main.common @@ -526,15 +576,13 @@ def texture_size(self) -> tuple: def _pad_columns(self, cols: int, alignment=64) -> int: if cols % alignment != 0: cols = (cols // alignment + 1) * alignment - print(cols) return cols def resize(self, new_cols: int, new_rows: int) -> None: """ resizes the buffer to a new speicified size. - Downscaling keeps the top leftmost corner, - upscaling pads the bottom and right with black. - (this becomes bottom left, after vflip). + Downscaling keeps the bottom left corner, + upscaling pads the top and right with black. """ # TODO: could this be redone as a compute shader? @@ -544,12 +592,10 @@ def resize(self, new_cols: int, new_rows: int) -> None: return old = self._download_texture() if new_rows < old_rows or new_cols < old_cols: - new = old[-new_rows:, :new_cols, :] + new = old[:new_rows, :new_cols] else: new = np.pad( - old, - ((new_rows - old_rows, 0), (0, new_cols - old_cols), (0, 0)), - mode="constant", + old, ((0, new_rows - old_rows), (0, new_cols - old_cols), (0, 0)) ) self._upload_texture(new) @@ -677,7 +723,7 @@ def _download_texture( frame = device.queue.read_buffer(buffer) frame = np.frombuffer(frame, dtype=np.uint8).reshape(size[1], size[0], 4) # redundant copy? - self._last_frame = frame + # self._last_frame = frame return frame def _upload_texture(self, data, device=None, command_encoder=None): From 26f7841809704609cf4fa6064fc49331ec0969fb Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 7 Jun 2024 23:52:21 +0200 Subject: [PATCH 26/59] cleanup --- wgpu_shadertoy/inputs.py | 184 ++++++++++++++------------------------- 1 file changed, 66 insertions(+), 118 deletions(-) diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 375871a..88e26ff 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -112,10 +112,12 @@ def bytes_per_pixel(self) -> int: return bpp - def create_texture(self, device) -> wgpu.GPUTexture: - raise NotImplementedError( - "This method should likely be implemented in the subclass - but maybe it's all the same? TODO: check later!" - ) + def bind_texture(self, device: wgpu.GPUDevice) -> Tuple[list, list]: + """ + returns a tuple of binding_layout and bing_groups_layout_entries. + writes textures as well as creates the sampler. + """ + raise NotImplementedError("This method should be implemented in the subclass.") def _binding_layout(self, offset=0): # TODO: figure out how offset works when we have multiple passes @@ -149,63 +151,34 @@ def _bind_groups_layout_entries(self, texture_view, sampler, offset=0) -> list: }, ] - def bind_texture(self, device: wgpu.GPUDevice) -> Tuple[list, list]: - binding_layout = self._binding_layout() - texture = self.create_texture(device) - texture_view = texture.create_view() - # typing missing in wgpu-py for queue - # extract this to an update_texture method? - # print(f"{self}, {self.data[0][0]=}") - device.queue.write_texture( - { - "texture": texture, - "origin": (0, 0, 0), - "mip_level": 0, - }, - self.data, - { - "offset": 0, - "bytes_per_row": self.bytes_per_pixel * self.size[1], # multiple of 256 - "rows_per_image": self.size[0], # same is done internally - }, - texture.size, - ) - - sampler = device.create_sampler(**self.sampler_settings) - # TODO: explore using auto layouts (pygfx/wgpu-py#500) - bind_groups_layout_entry = self._bind_groups_layout_entries( - texture_view, sampler - ) - return binding_layout, bind_groups_layout_entry - - def header_glsl(self): - """ - GLSL code snippet added to the compatibilty header for Shadertoy inputs. + def get_header(self, shader_type: str = "") -> str: """ - input_idx = self.channel_idx - binding_id = self.texture_binding - sampler_id = self.sampler_binding - return f""" - layout(binding = {binding_id}) uniform texture2D i_channel{input_idx}; - layout(binding = {sampler_id}) uniform sampler sampler{input_idx}; - - #define iChannel{input_idx} sampler2D(i_channel{input_idx}, sampler{input_idx}) - + GLSL or WGSL code snippet added to the compatibilty header for Shadertoy inputs. """ + # TODO: consider using this to dynamically add compatibility code into fragment_code_? + if not shader_type: + shader_type = self.parent.shader_type + shader_type = shader_type.lower() - def header_wgsl(self): - """ - WGSL code snippet added to the compatibilty header for Shadertoy inputs. - """ input_idx = self.channel_idx binding_id = self.texture_binding sampler_id = self.sampler_binding - return f""" - @group(0) @binding{binding_id} - var i_channel{input_idx}: texture_2d; - @group(0) @binding({sampler_id}) - var sampler{input_idx}: sampler; - """ + if shader_type == "glsl": + return f""" + layout(binding = {binding_id}) uniform texture2D i_channel{input_idx}; + layout(binding = {sampler_id}) uniform sampler sampler{input_idx}; + + #define iChannel{input_idx} sampler2D(i_channel{input_idx}, sampler{input_idx}) + """ + elif shader_type == "wgsl": + return f""" + @group(0) @binding{binding_id} + var i_channel{input_idx}: texture_2d; + @group(0) @binding({sampler_id}) + var sampler{input_idx}: sampler; + """ + else: + raise ValueError(f"Unknown shader type: {shader_type}") def __repr__(self): """ @@ -258,26 +231,21 @@ def __init__(self, buffer, parent=None, **kwargs): self._parent = parent self.dynamic = True + @property + def size(self): + # width, height, 1, ? + texture_size = self.renderpass.texture_size + return (texture_size[1], texture_size[0], 1) + @property def renderpass(self): # -> BufferRenderPass: return self.parent.main.buffers[self.buffer_idx] - @property - def data(self): + def bind_texture(self, device: wgpu.GPUDevice) -> Tuple[list, list]: """ - previous frame rendered by this buffer. buffers render in order A, B, C, D. and before the Image pass. + returns a tuple of binding_layout and bing_groups_layout_entries. + takes the texture from the buffer and creates a new sampler. """ - # print(f"{self.renderpass.last_frame[0,0,2]=}") - data = self.renderpass.last_frame - # overwrite the alpha channel to 255 - data[:, :, 3] = 255 - - # force vflip with Buffers? - data = np.ascontiguousarray(data[::-1, :, :]) - return data - - def bind_texture(self, device: wgpu.GPUDevice) -> Tuple[list, list]: - """ """ binding_layout = self._binding_layout() texture = self.renderpass.texture texture_view = texture.create_view() @@ -288,48 +256,6 @@ def bind_texture(self, device: wgpu.GPUDevice) -> Tuple[list, list]: ) return binding_layout, bind_groups_layout_entry - def create_texture(self, device) -> wgpu.GPUTexture: - """ - Creates the texture for this channel and sampler. Texture stays available to be updated later on. - """ - # TODO: this likely needs to be in the parent pass and simply accessed here... - texture = device.create_texture( - size=self.renderpass.texture_size, - format=wgpu.TextureFormat.bgra8unorm, - usage=wgpu.TextureUsage.COPY_DST - | wgpu.TextureUsage.RENDER_ATTACHMENT - | wgpu.TextureUsage.TEXTURE_BINDING, # which ones do we actually need? - ) - # texture = device.copy_buffer_to_texture( - # { - # "buffer": self.data, - # "texture": self.texture, - # "texture_size": self.renderpass.texture_size, - # "bytes_per_row": self.renderpass.bytes_per_pixel - # * self.renderpass.texture_size[1], - # } - # ) - return texture - - def update_texture(self, device): - """ - Updates the texture. (maybe reuse this code snippet broader?) - """ - device.queue.write_texture( - { - "texture": self.texture, - "origin": (0, 0, 0), - "mip_level": 0, - }, - self.data, - { - "offset": 0, - "bytes_per_row": self.bytes_per_pixel * self.size[1], # multiple of 256 - "rows_per_image": self.size[0], # same is done internally - }, - self.texture.size, - ) - class ShadertoyChannelCubemapA(ShadertoyChannel): pass @@ -373,24 +299,46 @@ def __init__(self, data=None, **kwargs): axis=2, ) - self.texture_size = ( - self.data.shape[1], - self.data.shape[0], - 1, - ) # orientation change (columns, rows, 1) + # orientation change (columns, rows, 1) + self.texture_size = (self.data.shape[1], self.data.shape[0], 1) vflip = kwargs.pop("vflip", True) if vflip in ("true", True): vflip = True self.data = np.ascontiguousarray(self.data[::-1, :, :]) - def create_texture(self, device) -> wgpu.GPUTexture: + def bind_texture(self, device: wgpu.GPUDevice) -> Tuple[list, list]: + binding_layout = self._binding_layout() texture = device.create_texture( size=self.texture_size, format=wgpu.TextureFormat.rgba8unorm, usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING, ) - return texture + texture_view = texture.create_view() + # typing missing in wgpu-py for queue + # extract this to an update_texture method? + # print(f"{self}, {self.data[0][0]=}") + device.queue.write_texture( + { + "texture": texture, + "origin": (0, 0, 0), + "mip_level": 0, + }, + self.data, + { + "offset": 0, + "bytes_per_row": self.bytes_per_pixel * self.size[1], # multiple of 256 + "rows_per_image": self.size[0], # same is done internally + }, + texture.size, + ) + + sampler = device.create_sampler(**self.sampler_settings) + # TODO: explore using auto layouts (pygfx/wgpu-py#500) + bind_groups_layout_entry = self._bind_groups_layout_entries( + texture_view, sampler + ) + return binding_layout, bind_groups_layout_entry class ShadertoyChannelCubemap(ShadertoyChannel): From 71ca0146e577795589cd0f3298b2d3da7079b9cb Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 7 Jun 2024 23:52:38 +0200 Subject: [PATCH 27/59] use rgba32float for buffers --- wgpu_shadertoy/passes.py | 33 ++++++++++++--------------------- wgpu_shadertoy/shadertoy.py | 27 +++++++++++++++++++++------ 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index cbf7b5f..43a2567 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -477,19 +477,8 @@ def _finish_renderpass(self, device: wgpu.GPUDevice) -> None: "entry_point": "main", "targets": [ { - "format": wgpu.TextureFormat.bgra8unorm, - "blend": { - "color": ( - wgpu.BlendFactor.one, - wgpu.BlendFactor.zero, - wgpu.BlendOperation.add, - ), - "alpha": ( - wgpu.BlendFactor.one, - wgpu.BlendFactor.zero, - wgpu.BlendOperation.add, - ), - }, + "format": self._format, + "blend": None, # maybe fine? }, ], }, @@ -503,6 +492,7 @@ class ImageRenderPass(RenderPass): def __init__(self, **kwargs): super().__init__(**kwargs) + self._format = wgpu.TextureFormat.bgra8unorm # TODO figure out if there is anything specific. Maybe the canvas stuff? perhaps that should stay in the main class... def draw_image(self, device: wgpu.GPUDevice, present_context) -> None: @@ -545,6 +535,7 @@ class BufferRenderPass(RenderPass): def __init__(self, buffer_idx: str = "", **kwargs): super().__init__(**kwargs) self._buffer_idx = buffer_idx + self._format = wgpu.TextureFormat.rgba32float @property def buffer_idx(self) -> str: @@ -573,7 +564,7 @@ def texture_size(self) -> tuple: return self._texture_size - def _pad_columns(self, cols: int, alignment=64) -> int: + def _pad_columns(self, cols: int, alignment=16) -> int: if cols % alignment != 0: cols = (cols // alignment + 1) * alignment return cols @@ -608,7 +599,7 @@ def texture(self) -> wgpu.GPUTexture: # creates the initial texture self._texture = self.main._device.create_texture( size=self.texture_size, - format=wgpu.TextureFormat.bgra8unorm, + format=self._format, usage=wgpu.TextureUsage.COPY_SRC | wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.RENDER_ATTACHMENT @@ -647,7 +638,7 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> None: # create a temporary texture as a render target target_texture = device.create_texture( size=self.texture_size, - format=wgpu.TextureFormat.bgra8unorm, + format=self._format, usage=wgpu.TextureUsage.COPY_SRC | wgpu.TextureUsage.RENDER_ATTACHMENT, ) @@ -702,7 +693,7 @@ def _download_texture( size = self.texture_size buffer = device.create_buffer( - size=(size[0] * size[1] * 4), + size=(size[0] * size[1] * 16), usage=wgpu.BufferUsage.COPY_SRC | wgpu.BufferUsage.COPY_DST, ) command_encoder.copy_texture_to_buffer( @@ -714,14 +705,14 @@ def _download_texture( { "buffer": buffer, "offset": 0, - "bytes_per_row": size[0] * 4, + "bytes_per_row": size[0] * 16, "rows_per_image": size[1], }, size, ) device.queue.submit([command_encoder.finish()]) frame = device.queue.read_buffer(buffer) - frame = np.frombuffer(frame, dtype=np.uint8).reshape(size[1], size[0], 4) + frame = np.frombuffer(frame, dtype=np.float32).reshape(size[1], size[0], 4) # redundant copy? # self._last_frame = frame return frame @@ -739,7 +730,7 @@ def _upload_texture(self, data, device=None, command_encoder=None): # create a new texture with the changed size? new_texture = device.create_texture( size=(data.shape[1], data.shape[0], 1), - format=wgpu.TextureFormat.bgra8unorm, + format=self._format, usage=wgpu.TextureUsage.COPY_SRC | wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.COPY_DST @@ -755,7 +746,7 @@ def _upload_texture(self, data, device=None, command_encoder=None): data, { "offset": 0, - "bytes_per_row": data.shape[1] * 4, + "bytes_per_row": data.shape[1] * 16, "rows_per_image": data.shape[0], }, (data.shape[1], data.shape[0], 1), diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index 2e80972..b65f509 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -10,6 +10,9 @@ from .api import shader_args_from_json, shadertoy_from_id from .passes import BufferRenderPass, ImageRenderPass +# TODO: hacky solution, needs to be improved +_default_device = None + class UniformArray: """Convenience class to create a uniform array. @@ -196,6 +199,23 @@ def shader_type(self) -> str: """ return self.image.shader_type + @property + def _device(self): + """ + copy and paste from wgpu.utils.device.get_default_device but with feature enabled + we need to enable float32-filterable for buffer textures. + """ + global _default_device + + if _default_device is None: + import wgpu.backends.auto + + adapter = wgpu.gpu.request_adapter(power_preference="high-performance") + _default_device = adapter.request_device( + required_features=["float32-filterable"] + ) + return _default_device + @classmethod def from_json(cls, dict_or_path, **kwargs): """Builds a `Shadertoy` instance from a JSON-like dict of Shadertoy.com shader data.""" @@ -220,8 +240,6 @@ def _prepare_canvas(self): title=self.title, size=self.resolution, max_fps=60 ) - self._device = wgpu.utils.device.get_default_device() - self._present_context = self._canvas.get_context() # We use "bgra8unorm" not "bgra8unorm-srgb" here because we want to let the shader fully control the color-space. @@ -306,10 +324,7 @@ def _draw_frame(self): for buf in self.buffers.values(): if buf: # checks if not None? - buf.draw_buffer( - self._device - ) # does this need kind of the target to write too? - # TODO: most of the code below here is for the image renderpass... + buf.draw_buffer(self._device) self.image.draw_image(self._device, self._present_context) self._canvas.request_draw() From 682398d6df18efa3ec9326427764388f901457d0 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 14 Jun 2024 00:16:05 +0200 Subject: [PATCH 28/59] add logic for device features --- examples/shadertoy_buffer.py | 2 -- examples/shadertoy_buffer_lovers.py | 7 +----- wgpu_shadertoy/passes.py | 21 ------------------ wgpu_shadertoy/shadertoy.py | 33 +++++++++++++++-------------- 4 files changed, 18 insertions(+), 45 deletions(-) diff --git a/examples/shadertoy_buffer.py b/examples/shadertoy_buffer.py index d7eba4a..e835fba 100644 --- a/examples/shadertoy_buffer.py +++ b/examples/shadertoy_buffer.py @@ -1,5 +1,3 @@ -# run_example = false -# buffer passes in development from wgpu_shadertoy import Shadertoy from wgpu_shadertoy.inputs import ShadertoyChannelBuffer from wgpu_shadertoy.passes import BufferRenderPass diff --git a/examples/shadertoy_buffer_lovers.py b/examples/shadertoy_buffer_lovers.py index e44c1ed..c13db6f 100644 --- a/examples/shadertoy_buffer_lovers.py +++ b/examples/shadertoy_buffer_lovers.py @@ -1,19 +1,14 @@ -# run_example = false -# exploring some issues with this exact shader... from pathlib import Path from wgpu_shadertoy import Shadertoy -from wgpu_shadertoy.api import shader_args_from_json # shadertoy source: https://www.shadertoy.com/view/ssjyWc by FabriceNeyret2 (CC-BY-NC-SA-3.0?) +# current "bug": the string kinda floats off to the upper right corner, without any inputs... ? Likely to be some issue with the implementation of buffers. shader_id = "ssjyWc" json_path = Path(Path(__file__).parent, f"shader_{shader_id}.json") -shader_args = shader_args_from_json(json_path) -print(f"{shader_args['inputs']=}") shader = Shadertoy.from_json(json_path, resolution=(1024, 512)) -# shader = Shadertoy(**shader_args) if __name__ == "__main__": shader.show() diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 43a2567..634c13d 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -607,27 +607,6 @@ def texture(self) -> wgpu.GPUTexture: ) return self._texture - @property - def last_frame(self): - # TODO: refactor to be a texture, not a buffer or even a numpy array. - # we likely need to have download/upload private methods to support resizing etc (unless we write shaders for that haha) - if not hasattr(self, "_last_frame"): - self._last_frame = self._initial_buffer() - return self._last_frame - - def _initial_buffer(self): - zero_array = np.ascontiguousarray( - np.zeros( - shape=(self.texture_size[1], self.texture_size[0], 4), dtype=np.uint8 - ) - ) - - # buffer = self.main._device.create_buffer_with_data( - # zero_array.tobytes(), - # wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.COPY_SRC, - # ) - return zero_array - def draw_buffer(self, device: wgpu.GPUDevice) -> None: """ draws the buffer to the texture and updates self._texture. diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index b65f509..1215876 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -3,6 +3,7 @@ import os import time +import wgpu from wgpu.gui.auto import WgpuCanvas, run from wgpu.gui.offscreen import WgpuCanvas as OffscreenCanvas from wgpu.gui.offscreen import run as run_offscreen @@ -10,9 +11,6 @@ from .api import shader_args_from_json, shadertoy_from_id from .passes import BufferRenderPass, ImageRenderPass -# TODO: hacky solution, needs to be improved -_default_device = None - class UniformArray: """Convenience class to create a uniform array. @@ -139,6 +137,11 @@ def __init__( self._uniform_data["resolution"] = (*resolution, 1) self._shader_type = shader_type.lower() + device_features = [] + if not all(value == "" for value in buffers.values()): + device_features.append(wgpu.FeatureName.float32_filterable) + self._device = self._request_device(device_features) + self.image = ImageRenderPass( main=self, code=shader_code, shader_type=shader_type, inputs=inputs ) @@ -199,22 +202,19 @@ def shader_type(self) -> str: """ return self.image.shader_type - @property - def _device(self): + def _request_device(self, features) -> wgpu.GPUDevice: """ - copy and paste from wgpu.utils.device.get_default_device but with feature enabled - we need to enable float32-filterable for buffer textures. + returns the _global_device if no features are required + otherwise requests a new device with the required features + this logic is needed to pass unit tests due to how we run examples. + Might be depricated in the future, ref: https://github.com/pygfx/wgpu-py/pull/517 """ - global _default_device + if not features: + return wgpu.utils.get_default_device() - if _default_device is None: - import wgpu.backends.auto - - adapter = wgpu.gpu.request_adapter(power_preference="high-performance") - _default_device = adapter.request_device( - required_features=["float32-filterable"] - ) - return _default_device + return wgpu.gpu.request_adapter( + power_preference="high-performance" + ).request_device(required_features=features) @classmethod def from_json(cls, dict_or_path, **kwargs): @@ -243,6 +243,7 @@ def _prepare_canvas(self): self._present_context = self._canvas.get_context() # We use "bgra8unorm" not "bgra8unorm-srgb" here because we want to let the shader fully control the color-space. + # TODO: instead use canvas preference? ref: GPUCanvasContext.get_preferred_format() self._present_context.configure( device=self._device, format=wgpu.TextureFormat.bgra8unorm ) From 74c4c7607fe2804484033559f445629d11099024 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 14 Jun 2024 00:17:04 +0200 Subject: [PATCH 29/59] increase CI verbosity --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9666f2..2441e2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,7 +80,7 @@ jobs: env: EXPECT_LAVAPIPE: true run: | - pytest -v examples + pytest -vvvs examples test-builds: name: ${{ matrix.name }} @@ -125,7 +125,7 @@ jobs: pip install -e .[dev] - name: Unit tests run: | - pytest -v tests + pytest -vvvs tests publish: name: Publish to Github and Pypi From 6f81212acad62fd1303af01b0a39393e17e9e66e Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 19 Jun 2024 00:58:12 +0200 Subject: [PATCH 30/59] dynamic input headers --- wgpu_shadertoy/inputs.py | 2 +- wgpu_shadertoy/passes.py | 42 ++++++++++------------------------------ 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 88e26ff..0c4f575 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -172,7 +172,7 @@ def get_header(self, shader_type: str = "") -> str: """ elif shader_type == "wgsl": return f""" - @group(0) @binding{binding_id} + @group(0) @binding({binding_id}) var i_channel{input_idx}: texture_2d; @group(0) @binding({sampler_id}) var sampler{input_idx}: sampler; diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 634c13d..cc8e2c5 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -56,22 +56,8 @@ int i_frame; float i_framerate; -layout(binding = 1) uniform texture2D i_channel0; -layout(binding = 2) uniform sampler sampler0; -layout(binding = 3) uniform texture2D i_channel1; -layout(binding = 4) uniform sampler sampler1; -layout(binding = 5) uniform texture2D i_channel2; -layout(binding = 6) uniform sampler sampler2; -layout(binding = 7) uniform texture2D i_channel3; -layout(binding = 8) uniform sampler sampler3; - // Shadertoy compatibility, see we can use the same code copied from shadertoy website -#define iChannel0 sampler2D(i_channel0, sampler0) -#define iChannel1 sampler2D(i_channel1, sampler1) -#define iChannel2 sampler2D(i_channel2, sampler2) -#define iChannel3 sampler2D(i_channel3, sampler3) - #define iMouse i_mouse #define iDate i_date #define iResolution i_resolution @@ -209,24 +195,6 @@ @group(0) @binding(0) var input: ShadertoyInput; -@group(0) @binding(1) -var i_channel0: texture_2d; -@group(0) @binding(3) -var i_channel1: texture_2d; -@group(0) @binding(5) -var i_channel2: texture_2d; -@group(0) @binding(7) -var i_channel3: texture_2d; - -@group(0) @binding(2) -var sampler0: sampler; -@group(0) @binding(4) -var sampler1: sampler; -@group(0) @binding(6) -var sampler2: sampler; -@group(0) @binding(8) -var sampler3: sampler; - @fragment fn main(in: Varyings) -> @location(0) vec4 { @@ -265,6 +233,7 @@ def __init__( self._shader_type = shader_type self._shader_code = code self._inputs = inputs # keep them here so we only attach them later? + self._input_headers = "" # self.channels = self._attach_inputs(inputs) @property @@ -366,6 +335,12 @@ def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel]: else: channels.append(None) + # TODO: refactor whole function to have a channel variable before appending. + if channels[-1] is not None: + self._input_headers += channels[-1].get_header( + shader_type=self.shader_type + ) + return channels def prepare_render(self, device: wgpu.GPUDevice) -> None: @@ -374,11 +349,13 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: shader_type = self.shader_type if shader_type == "glsl": if type(self) is BufferRenderPass: + # TODO: figure out a one line manipulation (via comments and #define)? vertex_shader_code = vertex_code_glsl_flipped else: vertex_shader_code = vertex_code_glsl frag_shader_code = ( builtin_variables_glsl + + self._input_headers + self.main.common + self.shader_code + fragment_code_glsl @@ -390,6 +367,7 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: vertex_shader_code = vertex_code_wgsl frag_shader_code = ( builtin_variables_wgsl + + self._input_headers + self.main.common + self.shader_code + fragment_code_wgsl From 8b7e10bed0414b5df202484a281ef57efada8b7e Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 19 Jun 2024 01:10:32 +0200 Subject: [PATCH 31/59] cleanup redundant logic --- wgpu_shadertoy/passes.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index cc8e2c5..248b6d4 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -270,13 +270,14 @@ def shader_type(self) -> str: wgsl_main_expr = re.compile(r"fn(?:\s)+shader_main") glsl_main_expr = re.compile(r"void(?:\s)+(?:shader_main|mainImage)") if wgsl_main_expr.search(self.shader_code): - return "wgsl" + self._shader_type = "wgsl" elif glsl_main_expr.search(self.shader_code): - return "glsl" + self._shader_type = "glsl" else: raise ValueError( "Could not find valid entry point function in shader code. Unable to determine if it's wgsl or glsl." ) + return self._shader_type def _update_textures(self, device: wgpu.GPUDevice) -> None: # self._uniform_data = self.main._uniform_data # force update? @@ -321,25 +322,25 @@ def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel]: for inp_idx, inp in enumerate(inputs): if inp_idx not in detected_channels: - channels.append(None) + channel = None + # print(f"Channel {inp_idx} not used in shader code.") # maybe raise a warning or some error? For unusued channel elif type(inp) is ShadertoyChannel: - channels.append(inp.infer_subclass(parent=self, channel_idx=inp_idx)) + channel = inp.infer_subclass(parent=self, channel_idx=inp_idx) elif isinstance(inp, ShadertoyChannel): inp.channel_idx = inp_idx inp.parent = self - channels.append(inp) + channel = inp elif inp is None and inp_idx in detected_channels: # this is the base case where we sample the black texture. - channels.append(ShadertoyChannelTexture(channel_idx=inp_idx)) + channel = ShadertoyChannelTexture(channel_idx=inp_idx) else: - channels.append(None) + # do we even get here? + channel = None - # TODO: refactor whole function to have a channel variable before appending. - if channels[-1] is not None: - self._input_headers += channels[-1].get_header( - shader_type=self.shader_type - ) + if channel is not None: + self._input_headers += channel.get_header(shader_type=self.shader_type) + channels.append(channel) return channels From 96ac7dd18b75bd413db6425cb52abcd4fc77a4cb Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 19 Jun 2024 17:26:37 +0200 Subject: [PATCH 32/59] add buffers test from api --- tests/test_api.py | 26 ++++++++++++++++++++++++++ wgpu_shadertoy/shadertoy.py | 1 + 2 files changed, 27 insertions(+) diff --git a/tests/test_api.py b/tests/test_api.py index cce3678..915e283 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -64,6 +64,32 @@ def test_shadertoy_from_id_without_cache(api_available): assert shader.image.channels != [] +def test_buffers_from_api(api_available): + # Import here, because it imports the wgpu.gui.auto + from wgpu_shadertoy import Shadertoy, ShadertoyChannelBuffer + + # this is a Shadertoy we don't control - so it could change. perhaps we need a fork that is stable. + # shadertoy source: https://www.shadertoy.com/view/4X33D2 by brisingre + shader = Shadertoy.from_id("4X33D2") + + assert shader.title == '"Common Code (API Test)" by brisingre' + assert "" not in shader.buffers.values() + assert len(shader.image._input_headers) > 0 + assert type(shader.buffers["a"].channels[0]) == ShadertoyChannelBuffer + assert shader.buffers["a"].channels[0].channel_idx == 0 + assert shader.buffers["a"].channels[0].buffer_idx == "a" + assert shader.buffers["a"].channels[0].renderpass == shader.buffers["a"] + assert type(shader.buffers["b"].channels[0]) == ShadertoyChannelBuffer + assert shader.buffers["b"].channels[0].buffer_idx == "b" + assert shader.buffers["b"].channels[0].renderpass == shader.buffers["b"] + assert type(shader.buffers["c"].channels[0]) == ShadertoyChannelBuffer + assert shader.buffers["c"].channels[0].buffer_idx == "c" + assert shader.buffers["c"].channels[0].renderpass == shader.buffers["c"] + assert type(shader.buffers["d"].channels[0]) == ShadertoyChannelBuffer + assert shader.buffers["d"].channels[0].buffer_idx == "d" + assert shader.buffers["d"].channels[0].renderpass == shader.buffers["d"] + + # coverage for shader_args_from_json(dict_or_path, **kwargs) def test_from_json_with_invalid_path(): with pytest.raises(FileNotFoundError): diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index 1215876..d940b72 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -101,6 +101,7 @@ class Shadertoy: the entry point function also has an alias ``mainImage``, so you can use the shader code copied from shadertoy website without making any changes. """ + # TODO: rewrite this whole docstring above. # todo: add remaining built-in variables (i_channel_time) # todo: support multiple render passes (`i_channel0`, `i_channel1`, etc.) From 4027a7f030c545f168df4684ab9c7bb2e36dc645 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 21 Jun 2024 20:52:06 +0200 Subject: [PATCH 33/59] add test for buffers --- tests/test_util_shadertoy.py | 50 +++++++++++++++++++++++++++++++++++- wgpu_shadertoy/inputs.py | 19 ++++++++++---- wgpu_shadertoy/passes.py | 6 ++--- wgpu_shadertoy/shadertoy.py | 12 +++------ 4 files changed, 70 insertions(+), 17 deletions(-) diff --git a/tests/test_util_shadertoy.py b/tests/test_util_shadertoy.py index 815e0d7..3cd1cab 100644 --- a/tests/test_util_shadertoy.py +++ b/tests/test_util_shadertoy.py @@ -146,7 +146,7 @@ def test_shadertoy_offscreen(): } """ - + # kinda redundant, tests are run with force_offscreen anyway shader = Shadertoy(shader_code, resolution=(800, 450), offscreen=True) assert shader.resolution == (800, 450) assert shader.shader_code == shader_code @@ -215,3 +215,51 @@ def test_shadertoy_snapshot(): assert shader._offscreen is True assert frame1a == frame1b assert frame2a == frame2b + + +def test_shadertoy_with_buffers(): + # Import here, because it imports the wgpu.gui.auto + from wgpu_shadertoy import BufferRenderPass, Shadertoy, ShadertoyChannelBuffer + + image_code = """ + void mainImage( out vec4 fragColor, in vec2 fragCoord ) + { + vec2 uv = fragCoord/iResolution.xy; + + vec4 c0 = texture(iChannel0, uv); + vec4 c1 = texture(iChannel1, uv); + + fragColor = vec4(c0.r, c0.g, c1.b, c1.a); + } + """ + + buffer_code_a = """ + void mainImage( out vec4 fragColor, in vec2 fragCoord ) + { + fragColor = vec4(fragCoord.x/iResolution.x); + } + """ + buffer_code_b = """ + void mainImage( out vec4 fragColor, in vec2 fragCoord ) + { + fragColor = vec4(fragCoord.y/iResolution.y); + } + """ + + buffer_pass_a = BufferRenderPass(buffer_idx="a", code=buffer_code_a) + buffer_pass_b = BufferRenderPass(buffer_idx="b", code=buffer_code_b) + channel_a = ShadertoyChannelBuffer(buffer=buffer_pass_a) + channel_b = ShadertoyChannelBuffer(buffer="b", wrap="repeat") + shader = Shadertoy( + shader_code=image_code, + resolution=(800, 450), + inputs=[channel_a, channel_b], + buffers={"a": buffer_pass_a, "b": buffer_pass_b}, + ) + + assert shader.resolution == (800, 450) + assert shader.buffers["a"].shader_code == buffer_code_a + assert shader.buffers["b"].shader_code == buffer_code_b + assert shader.image.channels[0].renderpass.buffer_idx == "a" + assert shader.image.channels[1].renderpass.buffer_idx == "b" + assert shader.image.channels[1].sampler_settings["address_mode_u"] == "repeat" diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 0c4f575..4d9c9a4 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -153,7 +153,7 @@ def _bind_groups_layout_entries(self, texture_view, sampler, offset=0) -> list: def get_header(self, shader_type: str = "") -> str: """ - GLSL or WGSL code snippet added to the compatibilty header for Shadertoy inputs. + GLSL or WGSL code snippet added to the compatibility header for Shadertoy inputs. """ # TODO: consider using this to dynamically add compatibility code into fragment_code_? if not shader_type: @@ -219,14 +219,21 @@ class ShadertoyChannelBuffer(ShadertoyChannel): """ Shadertoy buffer texture input. The relevant code and renderpass resides in the main Shadertoy class. Parameters: - buffer_or_pass (str|BufferRenderPass): The buffer index, can be one oif ("A", "B", "C", "D"), or the buffer itself. - parent (RenderPass): The main renderpass this buffer is attached to. (optional in the init, but should be set later) + buffer (str|`BufferRenderPass`): The buffer index, can be one of ("A", "B", "C", "D"), or the `BufferRenderPass` itself. + parent (`RenderPass`): The main renderpass this buffer-texture is attached to. (optional in the init, but should be set later). **kwargs for sampler settings. """ def __init__(self, buffer, parent=None, **kwargs): super().__init__(**kwargs) - self.buffer_idx = buffer # A,B,C or D? + if isinstance(buffer, str): + self.buffer_idx = buffer.lower() # A,B,C or D? + self._renderpass = None + else: + # TODO can we check for instance of BufferRenderPass? + self._renderpass = buffer + self.buffer_idx = buffer.buffer_idx + if parent is not None: self._parent = parent self.dynamic = True @@ -239,7 +246,9 @@ def size(self): @property def renderpass(self): # -> BufferRenderPass: - return self.parent.main.buffers[self.buffer_idx] + if self._renderpass is None: + self._renderpass = self.parent.main.buffers[self.buffer_idx] + return self._renderpass def bind_texture(self, device: wgpu.GPUDevice) -> Tuple[list, list]: """ diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 248b6d4..b854100 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -221,7 +221,7 @@ class RenderPass: Parameters: main(Shadertoy): the main Shadertoy class of which this renderpass is part of. code (str): Shadercode for this buffer. - shader_type(str): either "wgsl" or "glsl" can also be "auto" - which then gets solved by a regular expression, we should be able to match differnt renderpasses... Defaults to glsl + shader_type(str): either "wgsl" or "glsl" can also be "auto" - which then gets solved by a regular expression. Defaults to "glsl" inputs (list): A list of :class:`ShadertoyChannel` objects. Each pass supports up to 4 inputs/channels. If a channel is dected in the code but none provided, will be sampling a black texture. """ @@ -324,7 +324,7 @@ def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel]: if inp_idx not in detected_channels: channel = None # print(f"Channel {inp_idx} not used in shader code.") - # maybe raise a warning or some error? For unusued channel + # maybe raise a warning or some error? For unused channel elif type(inp) is ShadertoyChannel: channel = inp.infer_subclass(parent=self, channel_idx=inp_idx) elif isinstance(inp, ShadertoyChannel): @@ -550,7 +550,7 @@ def _pad_columns(self, cols: int, alignment=16) -> int: def resize(self, new_cols: int, new_rows: int) -> None: """ - resizes the buffer to a new speicified size. + resizes the buffer to a new specified size. Downscaling keeps the bottom left corner, upscaling pads the top and right with black. """ diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index d940b72..236c04a 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -69,9 +69,9 @@ class Shadertoy: It helps you research and quickly build or test shaders using `WGSL` or `GLSL` via WGPU. Parameters: - shader_code (str|ImageRenderPass): The shader code to use for the Image renderpass. + shader_code (str): The shader code to use for the Image renderpass. common (str): The common shaderpass code gets executed before all other shaderpasses (buffers/image/sound). Defaults to empty string. - buffers (dict(str|BufferRenderPass)): Codes for buffers A through D. Still requires to set buffer as channel input. Defaults to empty strings. + buffers (dict of str: `BufferRenderPass`): Codes for buffers A through D. Still requires to set buffer as channel input. Defaults to empty strings. resolution (tuple): The resolution of the shadertoy in (width, height). Defaults to (800, 450). shader_type (str): Can be "wgsl" or "glsl". On any other value, it will be automatically detected from shader_code. Default is "auto". offscreen (bool): Whether to render offscreen. Default is False. @@ -150,7 +150,7 @@ def __init__( for k, v in buffers.items(): k = k.lower()[-1] if k not in "abcd": - raise ValueError(f"Invalid buffer key: {k}") + raise ValueError(f"Invalid buffer key: {k=}") if v == "": continue elif type(v) is BufferRenderPass: @@ -168,10 +168,6 @@ def __init__( offscreen = True self._offscreen = offscreen - if len(inputs) < 4: - inputs.extend([None] * (4 - len(inputs))) - # likely a better solution. But theoretically, someone could set one or more inputs but still mention a channel beyond that. - self.title = title self.complete = complete @@ -208,7 +204,7 @@ def _request_device(self, features) -> wgpu.GPUDevice: returns the _global_device if no features are required otherwise requests a new device with the required features this logic is needed to pass unit tests due to how we run examples. - Might be depricated in the future, ref: https://github.com/pygfx/wgpu-py/pull/517 + Might be deprecated in the future, ref: https://github.com/pygfx/wgpu-py/pull/517 """ if not features: return wgpu.utils.get_default_device() From 7bacf7842b1cdfa3b985bfedad999e5ecd49ecdb Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 28 Jun 2024 23:22:46 +0200 Subject: [PATCH 34/59] fix empty buffer case --- tests/test_util_shadertoy.py | 46 ++++++++++++++++++++++++++++++++++++ wgpu_shadertoy/inputs.py | 4 +--- wgpu_shadertoy/passes.py | 6 ++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/tests/test_util_shadertoy.py b/tests/test_util_shadertoy.py index 3cd1cab..ba71fe6 100644 --- a/tests/test_util_shadertoy.py +++ b/tests/test_util_shadertoy.py @@ -263,3 +263,49 @@ def test_shadertoy_with_buffers(): assert shader.image.channels[0].renderpass.buffer_idx == "a" assert shader.image.channels[1].renderpass.buffer_idx == "b" assert shader.image.channels[1].sampler_settings["address_mode_u"] == "repeat" + + +def test_shadertoy_with_buffer_missing(): + # Import here, because it imports the wgpu.gui.auto + from wgpu_shadertoy import ( + BufferRenderPass, + Shadertoy, + ShadertoyChannelBuffer, + ShadertoyChannelTexture, + ) + + image_code = """ + void mainImage( out vec4 fragColor, in vec2 fragCoord ) + { + vec2 uv = fragCoord/iResolution.xy; + + vec4 c0 = texture(iChannel0, uv); + vec4 c1 = texture(iChannel1, uv); + + fragColor = vec4(c0.r, c0.g, c1.b, c1.a); + } + """ + + buffer_code = """ + void mainImage( out vec4 fragColor, in vec2 fragCoord ) + { + fragColor = vec4(fragCoord.x/iResolution.x); + } + """ + + buffer_pass_a = BufferRenderPass(buffer_idx="a", code=buffer_code) + channel_a = ShadertoyChannelBuffer(buffer=buffer_pass_a) + # this references the buffer "b" we don't have attched. We use the default 8x8 pixels of black instead. + channel_b = ShadertoyChannelBuffer(buffer="b", wrap="repeat") + shader = Shadertoy( + shader_code=image_code, + resolution=(800, 450), + inputs=[channel_a, channel_b], + buffers={"a": buffer_pass_a}, + ) + + assert shader.resolution == (800, 450) + assert shader.buffers["a"].shader_code == buffer_code + assert shader.image.channels[0].renderpass.buffer_idx == "a" + assert type(shader.image.channels[1]) == ShadertoyChannelTexture + assert not shader.image.channels[1].data[0:2].any() diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 4d9c9a4..d3cf840 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -120,8 +120,6 @@ def bind_texture(self, device: wgpu.GPUDevice) -> Tuple[list, list]: raise NotImplementedError("This method should be implemented in the subclass.") def _binding_layout(self, offset=0): - # TODO: figure out how offset works when we have multiple passes - return [ { "binding": self.texture_binding, @@ -155,7 +153,6 @@ def get_header(self, shader_type: str = "") -> str: """ GLSL or WGSL code snippet added to the compatibility header for Shadertoy inputs. """ - # TODO: consider using this to dynamically add compatibility code into fragment_code_? if not shader_type: shader_type = self.parent.shader_type shader_type = shader_type.lower() @@ -195,6 +192,7 @@ def __repr__(self): data_repr = None class_repr = {k: v for k, v in self.__dict__.items() if k != "data"} class_repr["data"] = data_repr + class_repr["class"] = self.__class__ return repr(class_repr) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index b854100..4f4b281 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -4,7 +4,7 @@ import numpy as np import wgpu -from .inputs import ShadertoyChannel, ShadertoyChannelTexture +from .inputs import ShadertoyChannel, ShadertoyChannelBuffer, ShadertoyChannelTexture vertex_code_glsl = """#version 450 core @@ -337,6 +337,9 @@ def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel]: else: # do we even get here? channel = None + # additional base case where a referenced Buffer isn't attached + if type(channel) == ShadertoyChannelBuffer and channel.renderpass == "": + channel = ShadertoyChannelTexture(channel_idx=inp_idx) if channel is not None: self._input_headers += channel.get_header(shader_type=self.shader_type) @@ -544,6 +547,7 @@ def texture_size(self) -> tuple: return self._texture_size def _pad_columns(self, cols: int, alignment=16) -> int: + # TODO: avoid magic numbers and base it on self._format somehow. if cols % alignment != 0: cols = (cols // alignment + 1) * alignment return cols From 964e40ddebc16fb2ff1bb2eac7833c04c2edad93 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 29 Jun 2024 00:25:24 +0200 Subject: [PATCH 35/59] omit test due to caching issue --- tests/test_util_shadertoy.py | 47 ------------------------------------ 1 file changed, 47 deletions(-) diff --git a/tests/test_util_shadertoy.py b/tests/test_util_shadertoy.py index ba71fe6..e542cd9 100644 --- a/tests/test_util_shadertoy.py +++ b/tests/test_util_shadertoy.py @@ -4,7 +4,6 @@ if not can_use_wgpu_lib: skip("Skipping tests that need the wgpu lib", allow_module_level=True) - def test_shadertoy_wgsl(): # Import here, because it imports the wgpu.gui.auto from wgpu_shadertoy import Shadertoy @@ -263,49 +262,3 @@ def test_shadertoy_with_buffers(): assert shader.image.channels[0].renderpass.buffer_idx == "a" assert shader.image.channels[1].renderpass.buffer_idx == "b" assert shader.image.channels[1].sampler_settings["address_mode_u"] == "repeat" - - -def test_shadertoy_with_buffer_missing(): - # Import here, because it imports the wgpu.gui.auto - from wgpu_shadertoy import ( - BufferRenderPass, - Shadertoy, - ShadertoyChannelBuffer, - ShadertoyChannelTexture, - ) - - image_code = """ - void mainImage( out vec4 fragColor, in vec2 fragCoord ) - { - vec2 uv = fragCoord/iResolution.xy; - - vec4 c0 = texture(iChannel0, uv); - vec4 c1 = texture(iChannel1, uv); - - fragColor = vec4(c0.r, c0.g, c1.b, c1.a); - } - """ - - buffer_code = """ - void mainImage( out vec4 fragColor, in vec2 fragCoord ) - { - fragColor = vec4(fragCoord.x/iResolution.x); - } - """ - - buffer_pass_a = BufferRenderPass(buffer_idx="a", code=buffer_code) - channel_a = ShadertoyChannelBuffer(buffer=buffer_pass_a) - # this references the buffer "b" we don't have attched. We use the default 8x8 pixels of black instead. - channel_b = ShadertoyChannelBuffer(buffer="b", wrap="repeat") - shader = Shadertoy( - shader_code=image_code, - resolution=(800, 450), - inputs=[channel_a, channel_b], - buffers={"a": buffer_pass_a}, - ) - - assert shader.resolution == (800, 450) - assert shader.buffers["a"].shader_code == buffer_code - assert shader.image.channels[0].renderpass.buffer_idx == "a" - assert type(shader.image.channels[1]) == ShadertoyChannelTexture - assert not shader.image.channels[1].data[0:2].any() From 1aa5ce07e16a3085f80cd8320b8e4f1f91802265 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 29 Jun 2024 00:29:32 +0200 Subject: [PATCH 36/59] fix lint --- tests/test_util_shadertoy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_util_shadertoy.py b/tests/test_util_shadertoy.py index e542cd9..3cd1cab 100644 --- a/tests/test_util_shadertoy.py +++ b/tests/test_util_shadertoy.py @@ -4,6 +4,7 @@ if not can_use_wgpu_lib: skip("Skipping tests that need the wgpu lib", allow_module_level=True) + def test_shadertoy_wgsl(): # Import here, because it imports the wgpu.gui.auto from wgpu_shadertoy import Shadertoy From 6957cc3c1ec5e63d0f560259c458fd08a087ed8a Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 29 Jun 2024 00:34:15 +0200 Subject: [PATCH 37/59] update ruff --- tests/test_api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 915e283..6513305 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -75,17 +75,17 @@ def test_buffers_from_api(api_available): assert shader.title == '"Common Code (API Test)" by brisingre' assert "" not in shader.buffers.values() assert len(shader.image._input_headers) > 0 - assert type(shader.buffers["a"].channels[0]) == ShadertoyChannelBuffer + assert isinstance(shader.buffers["a"].channels[0], ShadertoyChannelBuffer) assert shader.buffers["a"].channels[0].channel_idx == 0 assert shader.buffers["a"].channels[0].buffer_idx == "a" assert shader.buffers["a"].channels[0].renderpass == shader.buffers["a"] - assert type(shader.buffers["b"].channels[0]) == ShadertoyChannelBuffer + assert isinstance(shader.buffers["b"].channels[0], ShadertoyChannelBuffer) assert shader.buffers["b"].channels[0].buffer_idx == "b" assert shader.buffers["b"].channels[0].renderpass == shader.buffers["b"] - assert type(shader.buffers["c"].channels[0]) == ShadertoyChannelBuffer + assert isinstance(shader.buffers["c"].channels[0], ShadertoyChannelBuffer) assert shader.buffers["c"].channels[0].buffer_idx == "c" assert shader.buffers["c"].channels[0].renderpass == shader.buffers["c"] - assert type(shader.buffers["d"].channels[0]) == ShadertoyChannelBuffer + assert isinstance(shader.buffers["d"].channels[0], ShadertoyChannelBuffer) assert shader.buffers["d"].channels[0].buffer_idx == "d" assert shader.buffers["d"].channels[0].renderpass == shader.buffers["d"] From 00c0ee22ef41c78f28ab67adba161212f22727a5 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 29 Jun 2024 01:49:32 +0200 Subject: [PATCH 38/59] initialize inputs_complete --- wgpu_shadertoy/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wgpu_shadertoy/api.py b/wgpu_shadertoy/api.py index 672a600..d4ad75a 100644 --- a/wgpu_shadertoy/api.py +++ b/wgpu_shadertoy/api.py @@ -145,7 +145,7 @@ def shader_args_from_json(dict_or_path, **kwargs) -> dict: common_code = "" inputs = [] buffers = {} - complete = True + complete = inputs_complete = True if "Shader" not in shader_data: raise ValueError( "shader_data must have a 'Shader' key, following Shadertoy export format." From 253c5e162dac85fea9e7933ad64370d73ddf2820 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 26 Jul 2024 01:00:17 +0200 Subject: [PATCH 39/59] fix lint --- wgpu_shadertoy/passes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 4f4b281..d79cf5a 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -338,7 +338,7 @@ def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel]: # do we even get here? channel = None # additional base case where a referenced Buffer isn't attached - if type(channel) == ShadertoyChannelBuffer and channel.renderpass == "": + if isinstance(channel, ShadertoyChannelBuffer) and channel.renderpass == "": channel = ShadertoyChannelTexture(channel_idx=inp_idx) if channel is not None: From 46833c7864d8eb061c9b5fe83140b83ddd959312 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 26 Jul 2024 01:28:03 +0200 Subject: [PATCH 40/59] fix wgsl buffer vertex code --- examples/shadertoy_buffer.py | 46 +++++++++++++++++++++++++++++++++--- wgpu_shadertoy/passes.py | 2 +- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/examples/shadertoy_buffer.py b/examples/shadertoy_buffer.py index e835fba..faad67c 100644 --- a/examples/shadertoy_buffer.py +++ b/examples/shadertoy_buffer.py @@ -41,11 +41,51 @@ } """ +# theoretically we can use a buffer pass in wgsl as well, and mix them. Just set shader_type = "wgsl"! +buffer_code_wgsl = """ +fn shader_main(frag_coord: vec2) -> vec4{ + let uv = frag_coord / i_resolution.xy; + let col = textureSample(i_channel0, sampler0, uv).xyz; + + var k = col.x; + var j = col.y; + + let inc = ((uv.x + uv.y) / 100.0) * 0.99 + 0.01; + + if (j == 0.0) { + k += inc; + } + else { + k -= inc; + } + + if (k >= 1.0){ + j = 1.0; + } + + if (k <= 0.0){ + j = 0.0; + + } + return vec4(k, j, 0.0, 1.0); +} + +""" + + buffer_a_channel = ShadertoyChannelBuffer(buffer="a", wrap="repeat") -buffer_a_pass = BufferRenderPass( - buffer_idx="a", code=buffer_code, inputs=[buffer_a_channel] +# buffer_a_pass = BufferRenderPass( +# buffer_idx="a", code=buffer_code, inputs=[buffer_a_channel] +# ) +# shader = Shadertoy(image_code, inputs=[buffer_a_channel], buffers={"a": buffer_a_pass}) + +# using the wgsl buffer pass instead +buffer_a_pass_wgsl = BufferRenderPass( + buffer_idx="a", code=buffer_code_wgsl, inputs=[buffer_a_channel], shader_type="wgsl" +) +shader = Shadertoy( + image_code, inputs=[buffer_a_channel], buffers={"a": buffer_a_pass_wgsl} ) -shader = Shadertoy(image_code, inputs=[buffer_a_channel], buffers={"a": buffer_a_pass}) if __name__ == "__main__": shader.show() diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index d79cf5a..24eb965 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -149,7 +149,7 @@ out.vert_uv = vec2(2.0, 0.0); // Flipped } else { out.position = vec4(-1.0, 3.0, 0.0, 1.0); - out.vert_uv = vec2(0.0, -2.0); // Flipped + out.vert_uv = vec2(0.0, 2.0); // Flipped } return out; From 589ce4ef431dd73806ce40c0b98a4e2324af44a4 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 26 Jul 2024 02:37:36 +0200 Subject: [PATCH 41/59] avoid duplicated glsl vertex code --- wgpu_shadertoy/passes.py | 38 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 24eb965..770a37a 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -7,44 +7,31 @@ from .inputs import ShadertoyChannel, ShadertoyChannelBuffer, ShadertoyChannelTexture vertex_code_glsl = """#version 450 core - +//#define YFLIP layout(location = 0) out vec2 vert_uv; -void main(void){ - int index = int(gl_VertexID); - if (index == 0) { - gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); - vert_uv = vec2(0.0, 1.0); - } else if (index == 1) { - gl_Position = vec4(3.0, -1.0, 0.0, 1.0); - vert_uv = vec2(2.0, 1.0); - } else { - gl_Position = vec4(-1.0, 3.0, 0.0, 1.0); - vert_uv = vec2(0.0, -1.0); - } -} -""" -# TODO: avoid redundant globals, refactor to something like a headers.py file? -vertex_code_glsl_flipped = """#version 450 core - -layout(location = 0) out vec2 vert_uv; +// if YFLIP is defined, the vertex is flipped. This is used for the buffer passes. +#ifdef YFLIP +float flip = -1.0; +#else +float flip = 0.0; +#endif void main(void){ int index = int(gl_VertexID); if (index == 0) { gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); - vert_uv = vec2(0.0, 0.0); // Flipped + vert_uv = vec2(0.0, 1.0 + flip); } else if (index == 1) { gl_Position = vec4(3.0, -1.0, 0.0, 1.0); - vert_uv = vec2(2.0, 0.0); // Flipped + vert_uv = vec2(2.0, 1.0 + flip); } else { gl_Position = vec4(-1.0, 3.0, 0.0, 1.0); - vert_uv = vec2(0.0, 2.0); // Flipped + vert_uv = vec2(0.0, -1.0 - (3*flip)); } } """ - builtin_variables_glsl = """#version 450 core vec4 i_mouse; @@ -131,6 +118,7 @@ } """ +# TODO: can this be done without repeating yourself in wgsl too? vertex_code_wgsl_flipped = """ struct Varyings { @@ -353,8 +341,8 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: shader_type = self.shader_type if shader_type == "glsl": if type(self) is BufferRenderPass: - # TODO: figure out a one line manipulation (via comments and #define)? - vertex_shader_code = vertex_code_glsl_flipped + # skip the // to uncomment out the YFLIP define. + vertex_shader_code = vertex_code_glsl[:18] + vertex_code_glsl[20:] else: vertex_shader_code = vertex_code_glsl frag_shader_code = ( From 8e2089a512d7be8385d5d66247dc0bb439e0e598 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 15 Aug 2024 19:23:28 +0200 Subject: [PATCH 42/59] address comments --- .github/workflows/ci.yml | 4 ++-- README.md | 1 + examples/shadertoy_buffer.py | 11 ++--------- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2441e2e..b9666f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,7 +80,7 @@ jobs: env: EXPECT_LAVAPIPE: true run: | - pytest -vvvs examples + pytest -v examples test-builds: name: ${{ matrix.name }} @@ -125,7 +125,7 @@ jobs: pip install -e .[dev] - name: Unit tests run: | - pytest -vvvs tests + pytest -v tests publish: name: Publish to Github and Pypi diff --git a/README.md b/README.md index aea4e45..8b4d9f6 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ When passing `offscreen=True` the `.snapshot()` method allows you to render spec shader = Shadertoy(shader_code, resolution=(800, 450), offscreen=True) frame0_data = shader.snapshot() frame10_data = shader.snapshot(10.0) +# reorder the the channels from bgra to rgba frame0_img = Image.fromarray(np.asarray(frame0_data)[..., [2, 1, 0, 3]]).convert('RGB') frame0_img.save("frame0.png") ``` diff --git a/examples/shadertoy_buffer.py b/examples/shadertoy_buffer.py index faad67c..e02a925 100644 --- a/examples/shadertoy_buffer.py +++ b/examples/shadertoy_buffer.py @@ -1,6 +1,4 @@ -from wgpu_shadertoy import Shadertoy -from wgpu_shadertoy.inputs import ShadertoyChannelBuffer -from wgpu_shadertoy.passes import BufferRenderPass +from wgpu_shadertoy import BufferRenderPass, Shadertoy, ShadertoyChannelBuffer # shadertoy source: https://www.shadertoy.com/view/lljcDG by rkibria CC-BY-NC-SA-3.0 image_code = """ @@ -74,12 +72,7 @@ buffer_a_channel = ShadertoyChannelBuffer(buffer="a", wrap="repeat") -# buffer_a_pass = BufferRenderPass( -# buffer_idx="a", code=buffer_code, inputs=[buffer_a_channel] -# ) -# shader = Shadertoy(image_code, inputs=[buffer_a_channel], buffers={"a": buffer_a_pass}) - -# using the wgsl buffer pass instead +# using the wgsl translated code for the buffer pass buffer_a_pass_wgsl = BufferRenderPass( buffer_idx="a", code=buffer_code_wgsl, inputs=[buffer_a_channel], shader_type="wgsl" ) From 26295e7a776d162d3ce9a72904bee1476fb9df69 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 1 Sep 2024 21:20:26 +0200 Subject: [PATCH 43/59] expand buffers test --- CHANGELOG.md | 4 ++++ tests/test_util_shadertoy.py | 40 +++++++++++++++++++++++++++++++----- wgpu_shadertoy/inputs.py | 19 ++++++++--------- wgpu_shadertoy/passes.py | 2 +- 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e56bcf2..1aeca9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ Possible sections in each release: Added: * Run shaders from the website API https://github.com/pygfx/shadertoy/pull/25 +* Support for Buffer channels and Buffer passes https://github.com/pygfx/shadertoy/pull/30 + +Changed: +* `ShadertoyChannel` is now more specific `ShadertoyChannelTexture` ### [v0.1.0] - 2024-01-21 diff --git a/tests/test_util_shadertoy.py b/tests/test_util_shadertoy.py index 3cd1cab..16a3b38 100644 --- a/tests/test_util_shadertoy.py +++ b/tests/test_util_shadertoy.py @@ -228,8 +228,10 @@ def test_shadertoy_with_buffers(): vec4 c0 = texture(iChannel0, uv); vec4 c1 = texture(iChannel1, uv); + vec4 c2 = texture(iChannel2, uv); + vec4 c3 = texture(iChannel3, uv); - fragColor = vec4(c0.r, c0.g, c1.b, c1.a); + fragColor = vec4(c0.r, c1.g, c2.b, c3.a); } """ @@ -245,21 +247,49 @@ def test_shadertoy_with_buffers(): fragColor = vec4(fragCoord.y/iResolution.y); } """ + buffer_code_c = """ + void mainImage( out vec4 fragColor, in vec2 fragCoord ) + { + fragColor = vec4(fragCoord.x/iResolution.y); + } + """ + buffer_code_d = """ + void mainImage( out vec4 fragColor, in vec2 fragCoord ) + { + fragColor = vec4(fragCoord.y/iResolution.x); + } + """ + # this tests a combination of creating explicit and implicit BufferRenderPass classes for the always explicit ChannelBuffer instances. buffer_pass_a = BufferRenderPass(buffer_idx="a", code=buffer_code_a) buffer_pass_b = BufferRenderPass(buffer_idx="b", code=buffer_code_b) - channel_a = ShadertoyChannelBuffer(buffer=buffer_pass_a) - channel_b = ShadertoyChannelBuffer(buffer="b", wrap="repeat") + channel_0 = ShadertoyChannelBuffer(buffer=buffer_pass_a) + channel_1 = ShadertoyChannelBuffer(buffer="b", wrap="repeat") + channel_2 = ShadertoyChannelBuffer(buffer="c") + channel_3 = ShadertoyChannelBuffer(buffer="d", wrap="clamp") + shader = Shadertoy( shader_code=image_code, resolution=(800, 450), - inputs=[channel_a, channel_b], - buffers={"a": buffer_pass_a, "b": buffer_pass_b}, + inputs=[channel_0, channel_1, channel_2, channel_3], + buffers={ + "a": buffer_pass_a, + "b": buffer_pass_b, + "c": buffer_code_c, + "d": buffer_code_d, + }, ) assert shader.resolution == (800, 450) assert shader.buffers["a"].shader_code == buffer_code_a assert shader.buffers["b"].shader_code == buffer_code_b + assert shader.buffers["c"].shader_code == buffer_code_c + assert shader.buffers["d"].shader_code == buffer_code_d assert shader.image.channels[0].renderpass.buffer_idx == "a" assert shader.image.channels[1].renderpass.buffer_idx == "b" + assert shader.image.channels[2].renderpass.buffer_idx == "c" + assert shader.image.channels[3].renderpass.buffer_idx == "d" assert shader.image.channels[1].sampler_settings["address_mode_u"] == "repeat" + assert ( + shader.image.channels[3].sampler_settings["address_mode_u"] == "clamp-to-edge" + ) diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index d3cf840..66613c2 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -60,7 +60,6 @@ def sampler_settings(self) -> dict: @property def parent(self): - # TODO: likely make a passes.py file to make typing possible -> RenderPass: """Parent of this input is a renderpass.""" if not hasattr(self, "_parent"): raise AttributeError("Parent not set.") @@ -91,13 +90,11 @@ def sampler_binding(self) -> int: return 2 * (self.channel_idx + 1) @property - def channel_res(self) -> Tuple[int]: - return ( - self.size[1], - self.size[0], - 1, - -99, - ) # (width, height, pixel_aspect=1, padding=-99) + def channel_res(self) -> Tuple[int, int, int, int]: + """ + Tuple of (width, height, pixel_aspect=1, padding=-99) + """ + return (self.size[1], self.size[0], 1, -99) @property def size(self) -> tuple: # tuple? @@ -105,7 +102,9 @@ def size(self) -> tuple: # tuple? @property def bytes_per_pixel(self) -> int: - return 4 # shortcut for speed? + # TODO: parse and set from the format for this data. + # like bgra8unorm should indicate: 4 channels, 1 byte => 8 bytes per pixel + return 4 # usually is 4 for rgba8unorm or maybe use self.data.strides[1]? # print(self.data.shape, self.data.nbytes) bpp = self.data.nbytes // self.data.shape[1] // self.data.shape[0] @@ -237,7 +236,7 @@ def __init__(self, buffer, parent=None, **kwargs): self.dynamic = True @property - def size(self): + def size(self) -> Tuple[int, int, int]: # width, height, 1, ? texture_size = self.renderpass.texture_size return (texture_size[1], texture_size[0], 1) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 770a37a..f9f238f 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -617,7 +617,7 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> None: "origin": (0, 0, 0), }, { - "texture": self._texture, + "texture": self.texture, "mip_level": 0, "origin": (0, 0, 0), }, From 4db5990c949f1b88d87e58cf23c767e2ff6429d8 Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 4 Sep 2024 18:42:39 +0200 Subject: [PATCH 44/59] use texture view --- wgpu_shadertoy/inputs.py | 6 +++--- wgpu_shadertoy/passes.py | 42 ++++++++++++++++++------------------- wgpu_shadertoy/shadertoy.py | 6 ++++++ 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 66613c2..53a5e98 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -253,12 +253,12 @@ def bind_texture(self, device: wgpu.GPUDevice) -> Tuple[list, list]: takes the texture from the buffer and creates a new sampler. """ binding_layout = self._binding_layout() - texture = self.renderpass.texture - texture_view = texture.create_view() + # texture = self.renderpass.texture + # texture_view = texture.create_view() sampler = device.create_sampler(**self.sampler_settings) # TODO: explore using auto layouts (pygfx/wgpu-py#500) bind_groups_layout_entry = self._bind_groups_layout_entries( - texture_view, sampler + self.renderpass.texture_view, sampler ) return binding_layout, bind_groups_layout_entry diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index f9f238f..0e9656e 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -267,9 +267,10 @@ def shader_type(self) -> str: ) return self._shader_type - def _update_textures(self, device: wgpu.GPUDevice) -> None: - # self._uniform_data = self.main._uniform_data # force update? - # print(f"{self._uniform_data['frame']} at start of _update_textures") + def _update_uniforms(self, device: wgpu.GPUDevice) -> None: + """ + Updates the uniform buffer for this specific renderpass. Currently it's mirroring the data from the main Image pass. + """ device.queue.write_buffer( self._uniform_buffer, 0, @@ -278,20 +279,6 @@ def _update_textures(self, device: wgpu.GPUDevice) -> None: self._uniform_data.nbytes, ) - for channel in self.channels: - if channel is None or not channel.dynamic: - continue - - layout, layout_entry = channel.bind_texture(device=device) - - self._binding_layout[channel.texture_binding] = layout[0] - self._binding_layout[channel.sampler_binding] = layout[1] - - self._bind_groups_layout_entries[channel.texture_binding] = layout_entry[0] - self._bind_groups_layout_entries[channel.sampler_binding] = layout_entry[1] - - self._finish_renderpass(device) - def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel]: if len(inputs) > 4: raise ValueError("Only 4 inputs supported") @@ -470,7 +457,6 @@ def draw_image(self, device: wgpu.GPUDevice, present_context) -> None: Draws the main image pass to the screen. """ # maybe have an internal self._update for the uniform buffer too? - self._update_textures(device) command_encoder = device.create_command_encoder() current_texture = present_context.get_current_texture() @@ -530,6 +516,7 @@ def texture_size(self) -> tuple: rows = int(self.main.resolution[1]) self._texture_size = (columns, rows, 1) else: + # this is redundant, and can be refactored to be simpler logic. self._texture_size = self._texture.size return self._texture_size @@ -564,7 +551,7 @@ def resize(self, new_cols: int, new_rows: int) -> None: @property def texture(self) -> wgpu.GPUTexture: """ - the texture that the buffer renders to, will also be used as a texture by the BufferChannels. + Texture holds the previous frame of this renderpass. It will be updated by draw_buffer. """ if not hasattr(self, "_texture"): # creates the initial texture @@ -573,19 +560,27 @@ def texture(self) -> wgpu.GPUTexture: format=self._format, usage=wgpu.TextureUsage.COPY_SRC | wgpu.TextureUsage.COPY_DST - | wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.TEXTURE_BINDING, ) return self._texture + @property + def texture_view(self) -> wgpu.GPUTextureView: + """ + Texture view to be reused by multiple ShadertoyChannelBuffers + """ + if not hasattr(self, "_texture_view"): + self._texture_view = self.texture.create_view() + return self._texture_view + def draw_buffer(self, device: wgpu.GPUDevice) -> None: """ draws the buffer to the texture and updates self._texture. """ - self._update_textures(device) + self._update_uniforms(device) command_encoder = device.create_command_encoder() - # create a temporary texture as a render target + # create a temporary texture as a render target, as writing to a texture we also sample from won't work. target_texture = device.create_texture( size=self.texture_size, format=self._format, @@ -610,6 +605,7 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> None: render_pass.draw(3, 1, 0, 0) # what is .draw_indirect? render_pass.end() + # overwrite the existing texture with the newly rendered one. command_encoder.copy_texture_to_texture( { "texture": target_texture, @@ -702,6 +698,8 @@ def _upload_texture(self, data, device=None, command_encoder=None): (data.shape[1], data.shape[0], 1), ) self._texture = new_texture + # TODO: we need to update the texture view and all the bindings too? + # del self._texture_view class CubemapRenderPass(RenderPass): diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index 236c04a..c0e112e 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -275,10 +275,14 @@ def on_mouse_up(event): self._canvas.add_event_handler(on_mouse_up, "pointer_up") def _update(self): + """ + Updates the uniform information (time, date, frame, etc.) for the next frame. + """ now = time.perf_counter() if not hasattr(self, "_last_time"): self._last_time = now + # consider using timestamp queryset to get the actual rendertime somehow? if not hasattr(self, "_time_history"): self._time_history = collections.deque(maxlen=256) @@ -320,9 +324,11 @@ def _draw_frame(self): self._uniform_data.nbytes, ) + # Buffers are rendered first, order A-D, then finally the Image. for buf in self.buffers.values(): if buf: # checks if not None? buf.draw_buffer(self._device) + self.image.draw_image(self._device, self._present_context) self._canvas.request_draw() From c4d3140794d42d1bac3c38c43e8afe02a4743a07 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 12 Sep 2024 01:33:51 +0200 Subject: [PATCH 45/59] Add more type hints --- wgpu_shadertoy/passes.py | 44 +++++++++++++++++++++++++------------ wgpu_shadertoy/shadertoy.py | 9 ++++---- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 0e9656e..57e031f 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -223,9 +223,13 @@ def __init__( self._inputs = inputs # keep them here so we only attach them later? self._input_headers = "" # self.channels = self._attach_inputs(inputs) + self._format: wgpu.TextureFormat = ( + wgpu.TextureFormat.bgra8unorm + ) # assume default? @property - def main(self): # -> "Shadertoy" (can't type due to circular import?) + def main(self,): + # -> 'Shadertoy': # TODO figure out a solution to forward refernce this correctly. """ The main Shadertoy class of which this renderpass is part of. """ @@ -328,7 +332,7 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: shader_type = self.shader_type if shader_type == "glsl": if type(self) is BufferRenderPass: - # skip the // to uncomment out the YFLIP define. + # skip the // to uncomment out the YFLIP define. (why even have define?) vertex_shader_code = vertex_code_glsl[:18] + vertex_code_glsl[20:] else: vertex_shader_code = vertex_code_glsl @@ -352,6 +356,7 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: + fragment_code_wgsl ) + # why are the labels triangle? they should be something more approriate. self._vertex_shader_program = device.create_shader_module( label="triangle_vert", code=vertex_shader_code ) @@ -365,7 +370,7 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, ) - # Step 3: layout and bind groups + # Step 3: layout and bind groups, initially with the uniform buffer. self._bind_groups_layout_entries = [ { "binding": 0, @@ -449,19 +454,20 @@ class ImageRenderPass(RenderPass): def __init__(self, **kwargs): super().__init__(**kwargs) - self._format = wgpu.TextureFormat.bgra8unorm - # TODO figure out if there is anything specific. Maybe the canvas stuff? perhaps that should stay in the main class... + # TODO can self._format be set by the canvas preference? - def draw_image(self, device: wgpu.GPUDevice, present_context) -> None: + def draw_image( + self, device: wgpu.GPUDevice, present_context: wgpu.GPUCanvasContext + ) -> None: """ Draws the main image pass to the screen. """ # maybe have an internal self._update for the uniform buffer too? - command_encoder = device.create_command_encoder() - current_texture = present_context.get_current_texture() + command_encoder: wgpu.GPUCommandEncoder = device.create_command_encoder() + current_texture: wgpu.GPUTexture = present_context.get_current_texture() # TODO: maybe use a different name in this case? - render_pass = command_encoder.begin_render_pass( + render_pass: wgpu.GPURenderPassEncoder = command_encoder.begin_render_pass( color_attachments=[ { "view": current_texture.create_view(), @@ -491,7 +497,7 @@ class BufferRenderPass(RenderPass): def __init__(self, buffer_idx: str = "", **kwargs): super().__init__(**kwargs) self._buffer_idx = buffer_idx - self._format = wgpu.TextureFormat.rgba32float + self._format: wgpu.TextureFormat = wgpu.TextureFormat.rgba32float @property def buffer_idx(self) -> str: @@ -547,6 +553,11 @@ def resize(self, new_cols: int, new_rows: int) -> None: old, ((0, new_rows - old_rows), (0, new_cols - old_cols), (0, 0)) ) self._upload_texture(new) + # print(new.size) + # reset the view to force a new one to be created? + if hasattr(self, "_texture_view"): + self.__delattr__("_texture_view") + # TODO: refresh all passes (but in what order) with at least pass.prepare_render() @property def texture(self) -> wgpu.GPUTexture: @@ -578,17 +589,17 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> None: draws the buffer to the texture and updates self._texture. """ self._update_uniforms(device) - command_encoder = device.create_command_encoder() + command_encoder: wgpu.GPUCommandEncoder = device.create_command_encoder() # create a temporary texture as a render target, as writing to a texture we also sample from won't work. - target_texture = device.create_texture( + target_texture: wgpu.GPUTexture = device.create_texture( size=self.texture_size, format=self._format, usage=wgpu.TextureUsage.COPY_SRC | wgpu.TextureUsage.RENDER_ATTACHMENT, ) # TODO: maybe use a different name in this case? - render_pass = command_encoder.begin_render_pass( + render_pass: wgpu.GPURenderPassEncoder = command_encoder.begin_render_pass( color_attachments=[ { "view": target_texture.create_view(), @@ -663,7 +674,12 @@ def _download_texture( # self._last_frame = frame return frame - def _upload_texture(self, data, device=None, command_encoder=None): + def _upload_texture( + self, + data, + device: wgpu.GPUDevice = None, + command_encoder: wgpu.GPUCommandEncoder = None, + ): """ uploads some data to self._texture. """ diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index c0e112e..c7cb51a 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -109,7 +109,7 @@ def __init__( self, shader_code: str, common: str = "", - buffers: dict = { + buffers: dict[str, BufferRenderPass] = { "a": "", "b": "", "c": "", @@ -146,13 +146,13 @@ def __init__( self.image = ImageRenderPass( main=self, code=shader_code, shader_type=shader_type, inputs=inputs ) - self.buffers = {"a": "", "b": "", "c": "", "d": ""} + self.buffers: dict[str, BufferRenderPass] = {} # or empty string? for k, v in buffers.items(): k = k.lower()[-1] if k not in "abcd": raise ValueError(f"Invalid buffer key: {k=}") if v == "": - continue + self.buffers[k] = "" elif type(v) is BufferRenderPass: v.main = self v.buffer_idx = k @@ -237,7 +237,7 @@ def _prepare_canvas(self): title=self.title, size=self.resolution, max_fps=60 ) - self._present_context = self._canvas.get_context() + self._present_context: wgpu.GPUCanvasContext = self._canvas.get_context() # We use "bgra8unorm" not "bgra8unorm-srgb" here because we want to let the shader fully control the color-space. # TODO: instead use canvas preference? ref: GPUCanvasContext.get_preferred_format() @@ -252,6 +252,7 @@ def on_resize(event): for buf in self.buffers.values(): if buf: buf.resize(int(w), int(h)) + # TODO: loop again and refresh all channels that use buffer textures? def on_mouse_move(event): if event["button"] == 1 or 1 in event["buttons"]: From 4606609d72de83167c72a044b83dd6fd1494c2e4 Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 23 Sep 2024 22:15:08 +0200 Subject: [PATCH 46/59] readd buffer resizing --- wgpu_shadertoy/passes.py | 20 +++++++++++++++----- wgpu_shadertoy/shadertoy.py | 11 ++++++++++- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 57e031f..bb99f83 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -228,7 +228,7 @@ def __init__( ) # assume default? @property - def main(self,): + def main(self): # -> 'Shadertoy': # TODO figure out a solution to forward refernce this correctly. """ The main Shadertoy class of which this renderpass is part of. @@ -327,6 +327,11 @@ def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel]: return channels def prepare_render(self, device: wgpu.GPUDevice) -> None: + """ + This function is run once per renderpass. + It composes the shader code, then compiles the shader modules and finally maps the uniform buffer. + """ + # Step 1: compose shader programs self.channels = self._attach_inputs(self._inputs) shader_type = self.shader_type @@ -345,6 +350,7 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: ) elif shader_type == "wgsl": if type(self) is BufferRenderPass: + # TODO: find a better solution than duplicated vertex code for YFLIP. vertex_shader_code = vertex_code_wgsl_flipped else: vertex_shader_code = vertex_code_wgsl @@ -390,7 +396,14 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: }, ] - # Step 4: add inputs as textures. + # the remaning steps of binding channels and creating the render pipeline are done in a separe function for reuse. + self._finish_renderpass(device=device) + + def _finish_renderpass(self, device: wgpu.GPUDevice) -> None: + """ + This function sets up the channels (inputs) bindings to the renderpass. + Then creates the render pipeline. + """ channel_res = [] for channel in self.channels: if channel is None: @@ -406,9 +419,6 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: self._uniform_data["channel_res"] = tuple(channel_res) - self._finish_renderpass(device) - - def _finish_renderpass(self, device: wgpu.GPUDevice) -> None: bind_group_layout = device.create_bind_group_layout( entries=self._binding_layout ) diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index c7cb51a..3368341 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -252,7 +252,15 @@ def on_resize(event): for buf in self.buffers.values(): if buf: buf.resize(int(w), int(h)) - # TODO: loop again and refresh all channels that use buffer textures? + # Refresh all channels that use buffer textures, by redoing all the channels pretty much. + for rpass in [*self.buffers.values(), self.image]: + if rpass: + # clear out the previous binding layout, first entry is the uniform buffer, which stays. + rpass._binding_layout = rpass._binding_layout[:1] + rpass._bind_groups_layout_entries = ( + rpass._bind_groups_layout_entries[:1] + ) + rpass._finish_renderpass(self._device) def on_mouse_move(event): if event["button"] == 1 or 1 in event["buttons"]: @@ -316,6 +324,7 @@ def _update(self): def _draw_frame(self): # Update uniform buffer + # TODO:look into push constants https://github.com/pygfx/wgpu-py/pull/574 self._update() self._device.queue.write_buffer( self.image._uniform_buffer, From fde4501eec0cb7d984410ee15963201f390116a3 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 29 Sep 2024 01:35:56 +0200 Subject: [PATCH 47/59] typo in link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8b4d9f6..e5de09e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ pip install wgpu-shadertoy ``` To install the latest development version, use: ```bash -pip install git+https://gihub.com/pygfx/shadertoy.git@main +pip install git+https://github.com/pygfx/shadertoy.git@main ``` To use the Shadertoy.com API, please setup an environment variable with the key `SHADERTOY_KEY`. See [How To](https://www.shadertoy.com/howto#q2) for instructions. From b2ee401f7e1a2db9121639de2791c17ab0254d05 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 29 Sep 2024 01:36:34 +0200 Subject: [PATCH 48/59] update deps --- pyproject.toml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ae39b94..9cddf88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,14 +9,14 @@ build-backend = "setuptools.build_meta" name = "wgpu-shadertoy" dynamic = ["version", "readme"] dependencies = [ - "wgpu>=0.16.0,<0.17.0", + "wgpu>=0.16.0,<0.19.0", "requests", "numpy", "Pillow", ] description = "Shadertoy implementation based on wgpu-py" license = {file = "LICENSE"} -requires-python = ">=3.8.0" +requires-python = ">=3.9.0" authors = [ {name = "Jan Kels", email = "Jan.Kels@hhu.de"}, ] @@ -29,7 +29,6 @@ Repository = "https://github.com/pygfx/shadertoy" [project.optional-dependencies] dev = [ - "numpy", "pytest", "ruff", "imageio", From 634b7e2aa7ceea297c142b23cc8770c5c834a0f3 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 29 Sep 2024 01:36:48 +0200 Subject: [PATCH 49/59] use perferred format --- wgpu_shadertoy/passes.py | 2 +- wgpu_shadertoy/shadertoy.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index bb99f83..f5135ba 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -224,7 +224,7 @@ def __init__( self._input_headers = "" # self.channels = self._attach_inputs(inputs) self._format: wgpu.TextureFormat = ( - wgpu.TextureFormat.bgra8unorm + wgpu.TextureFormat.bgra8unorm_srgb, ) # assume default? @property diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index 3368341..6a17fca 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -175,6 +175,7 @@ def __init__( self.title += " (incomplete)" self._prepare_canvas() + self.image._format = self._format self._bind_events() # TODO: could this be part of the __init__ of each renderpass? (but we need the device) for rpass in (self.image, *self.buffers.values()): @@ -240,9 +241,12 @@ def _prepare_canvas(self): self._present_context: wgpu.GPUCanvasContext = self._canvas.get_context() # We use "bgra8unorm" not "bgra8unorm-srgb" here because we want to let the shader fully control the color-space. + # broken in newer versions of wgpu-py it seems... due to the minimal Vulkan capabilities... # TODO: instead use canvas preference? ref: GPUCanvasContext.get_preferred_format() + self._format = self._present_context.get_preferred_format(adapter=self._device.adapter) + self._present_context.configure( - device=self._device, format=wgpu.TextureFormat.bgra8unorm + device=self._device, format=self._format ) def _bind_events(self): From 039ece89fdb75a63e3a2f306b7b9d8bee6e3e80d Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 21 Nov 2024 00:50:28 +0100 Subject: [PATCH 50/59] fix missing buffer pass --- examples/tests/test_examples.py | 8 +++++--- wgpu_shadertoy/passes.py | 7 ++++++- wgpu_shadertoy/shadertoy.py | 8 ++++---- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/examples/tests/test_examples.py b/examples/tests/test_examples.py index 5f71476..049cec2 100644 --- a/examples/tests/test_examples.py +++ b/examples/tests/test_examples.py @@ -48,9 +48,11 @@ def test_examples_run(module, force_offscreen): def mock_time(): """Some examples use time to animate. Fix the return value for repeatable output.""" - with patch("time.time") as time_mock, patch( - "time.perf_counter" - ) as perf_counter_mock, patch("time.localtime") as localtime_mock: + with ( + patch("time.time") as time_mock, + patch("time.perf_counter") as perf_counter_mock, + patch("time.localtime") as localtime_mock, + ): time_mock.return_value = 1704449357.71442 perf_counter_mock.return_value = 6036.9424436 localtime_mock.return_value = time.struct_time((2024, 1, 5, 11, 9, 25, 4, 5, 0)) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index f5135ba..0930351 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -316,7 +316,12 @@ def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel]: else: # do we even get here? channel = None - # additional base case where a referenced Buffer isn't attached + # additional base case where a referenced Buffer isn't attached (first one should do it) + if ( + isinstance(channel, ShadertoyChannelBuffer) + and channel.buffer_idx not in self.main.buffers.keys() + ): + channel = ShadertoyChannelTexture(channel_idx=inp_idx) if isinstance(channel, ShadertoyChannelBuffer) and channel.renderpass == "": channel = ShadertoyChannelTexture(channel_idx=inp_idx) diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index 6a17fca..109c7f5 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -243,12 +243,12 @@ def _prepare_canvas(self): # We use "bgra8unorm" not "bgra8unorm-srgb" here because we want to let the shader fully control the color-space. # broken in newer versions of wgpu-py it seems... due to the minimal Vulkan capabilities... # TODO: instead use canvas preference? ref: GPUCanvasContext.get_preferred_format() - self._format = self._present_context.get_preferred_format(adapter=self._device.adapter) - - self._present_context.configure( - device=self._device, format=self._format + self._format = self._present_context.get_preferred_format( + adapter=self._device.adapter ) + self._present_context.configure(device=self._device, format=self._format) + def _bind_events(self): def on_resize(event): w, h = event["width"], event["height"] From 2c360680143756cca8ee4b12987f26541ec19129 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 21 Nov 2024 01:05:44 +0100 Subject: [PATCH 51/59] fix example --- examples/shadertoy_buffer_lovers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/shadertoy_buffer_lovers.py b/examples/shadertoy_buffer_lovers.py index c13db6f..fc4ab97 100644 --- a/examples/shadertoy_buffer_lovers.py +++ b/examples/shadertoy_buffer_lovers.py @@ -4,11 +4,10 @@ # shadertoy source: https://www.shadertoy.com/view/ssjyWc by FabriceNeyret2 (CC-BY-NC-SA-3.0?) -# current "bug": the string kinda floats off to the upper right corner, without any inputs... ? Likely to be some issue with the implementation of buffers. shader_id = "ssjyWc" json_path = Path(Path(__file__).parent, f"shader_{shader_id}.json") -shader = Shadertoy.from_json(json_path, resolution=(1024, 512)) +shader = Shadertoy.from_json(json_path, resolution=(800, 450)) if __name__ == "__main__": shader.show() From a16ee5f33a9840b48d10a298e4c9baefaa3c8931 Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 25 Nov 2024 20:38:45 +0100 Subject: [PATCH 52/59] fix gamma --- wgpu_shadertoy/shadertoy.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index 109c7f5..e6f1e91 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -240,12 +240,12 @@ def _prepare_canvas(self): self._present_context: wgpu.GPUCanvasContext = self._canvas.get_context() - # We use "bgra8unorm" not "bgra8unorm-srgb" here because we want to let the shader fully control the color-space. - # broken in newer versions of wgpu-py it seems... due to the minimal Vulkan capabilities... - # TODO: instead use canvas preference? ref: GPUCanvasContext.get_preferred_format() + # We use non srgb variants, because we want to let the shader fully control the color-space. + # Defaults usually return the srgb variant, but a non srgb option is usually available + # comparable: https://docs.rs/wgpu/latest/wgpu/enum.TextureFormat.html#method.remove_srgb_suffix self._format = self._present_context.get_preferred_format( adapter=self._device.adapter - ) + ).removesuffix("-srgb") self._present_context.configure(device=self._device, format=self._format) From 452013f2ef2f1b1fbd8966cb32b443aebd141e60 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 10 Dec 2024 00:28:53 +0100 Subject: [PATCH 53/59] submit the command buffers once --- wgpu_shadertoy/passes.py | 10 ++++++---- wgpu_shadertoy/shadertoy.py | 10 ++++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 0930351..7a861e4 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -473,7 +473,7 @@ def __init__(self, **kwargs): def draw_image( self, device: wgpu.GPUDevice, present_context: wgpu.GPUCanvasContext - ) -> None: + ) -> wgpu.GPUCommandBuffer: """ Draws the main image pass to the screen. """ @@ -499,7 +499,8 @@ def draw_image( render_pass.draw(3, 1, 0, 0) render_pass.end() - device.queue.submit([command_encoder.finish()]) + return command_encoder.finish() + # device.queue.submit([command_encoder.finish()]) class BufferRenderPass(RenderPass): @@ -599,7 +600,7 @@ def texture_view(self) -> wgpu.GPUTextureView: self._texture_view = self.texture.create_view() return self._texture_view - def draw_buffer(self, device: wgpu.GPUDevice) -> None: + def draw_buffer(self, device: wgpu.GPUDevice) -> wgpu.GPUCommandBuffer: """ draws the buffer to the texture and updates self._texture. """ @@ -646,7 +647,8 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> None: self.texture_size, # could this handle resizing? ) - device.queue.submit([command_encoder.finish()]) + return command_encoder.finish() + # device.queue.submit([command_encoder.finish()]) def _download_texture( self, diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index e6f1e91..2c10b00 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -338,13 +338,19 @@ def _draw_frame(self): self._uniform_data.nbytes, ) + render_encoders = [] + # Buffers are rendered first, order A-D, then finally the Image. for buf in self.buffers.values(): if buf: # checks if not None? - buf.draw_buffer(self._device) + render_encoders.append(buf.draw_buffer(self._device)) - self.image.draw_image(self._device, self._present_context) + render_encoders.append( + self.image.draw_image(self._device, self._present_context) + ) + # Submit all render encoders + self._device.queue.submit(render_encoders) self._canvas.request_draw() def show(self): From 5b5befd8f10b14f8a2efb49882e97360781e2f1d Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 10 Dec 2024 23:36:36 +0100 Subject: [PATCH 54/59] add simple profiling --- examples/shadertoy_buffer.py | 5 +++- examples/shadertoy_buffer_lovers.py | 2 +- wgpu_shadertoy/cli.py | 9 ++++++- wgpu_shadertoy/passes.py | 30 ++++++++++++++++++++++ wgpu_shadertoy/shadertoy.py | 39 +++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 3 deletions(-) diff --git a/examples/shadertoy_buffer.py b/examples/shadertoy_buffer.py index e02a925..7377a59 100644 --- a/examples/shadertoy_buffer.py +++ b/examples/shadertoy_buffer.py @@ -77,7 +77,10 @@ buffer_idx="a", code=buffer_code_wgsl, inputs=[buffer_a_channel], shader_type="wgsl" ) shader = Shadertoy( - image_code, inputs=[buffer_a_channel], buffers={"a": buffer_a_pass_wgsl} + image_code, + inputs=[buffer_a_channel], + buffers={"a": buffer_a_pass_wgsl}, + profile=True, ) if __name__ == "__main__": diff --git a/examples/shadertoy_buffer_lovers.py b/examples/shadertoy_buffer_lovers.py index fc4ab97..f88edfb 100644 --- a/examples/shadertoy_buffer_lovers.py +++ b/examples/shadertoy_buffer_lovers.py @@ -7,7 +7,7 @@ shader_id = "ssjyWc" json_path = Path(Path(__file__).parent, f"shader_{shader_id}.json") -shader = Shadertoy.from_json(json_path, resolution=(800, 450)) +shader = Shadertoy.from_json(json_path, resolution=(800, 450), profile=True) if __name__ == "__main__": shader.show() diff --git a/wgpu_shadertoy/cli.py b/wgpu_shadertoy/cli.py index fed471d..c311899 100644 --- a/wgpu_shadertoy/cli.py +++ b/wgpu_shadertoy/cli.py @@ -17,12 +17,19 @@ default=(800, 450), ) +argument_parser.add_argument( + "--profile", + action="store_true", + default=False, + help="Enable profiling for the shader", +) + def main_cli(): args = argument_parser.parse_args() shader_id = args.shader_id resolution = args.resolution - shader = Shadertoy.from_id(shader_id, resolution=resolution) + shader = Shadertoy.from_id(shader_id, resolution=resolution, profile=args.profile) shader.show() diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 7a861e4..4bcb674 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -492,6 +492,11 @@ def draw_image( "store_op": wgpu.StoreOp.store, } ], + timestamp_writes={ + "query_set": self.main._query_set, + "beginning_of_pass_write_index": 8, + "end_of_pass_write_index": 9, + }, ) render_pass.set_pipeline(self._render_pipeline) @@ -499,6 +504,14 @@ def draw_image( render_pass.draw(3, 1, 0, 0) render_pass.end() + command_encoder.resolve_query_set( + query_set=self.main._query_set, + first_query=8, + query_count=2, + destination=self.main._query_buffer, + destination_offset=256 * 4, + ) + return command_encoder.finish() # device.queue.submit([command_encoder.finish()]) @@ -614,6 +627,9 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> wgpu.GPUCommandBuffer: usage=wgpu.TextureUsage.COPY_SRC | wgpu.TextureUsage.RENDER_ATTACHMENT, ) + # for the timestamp buffer + buffer_address = "abcd".index(self.buffer_idx) * 2 + # TODO: maybe use a different name in this case? render_pass: wgpu.GPURenderPassEncoder = command_encoder.begin_render_pass( color_attachments=[ @@ -625,6 +641,12 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> wgpu.GPUCommandBuffer: "store_op": wgpu.StoreOp.store, } ], + # TODO: make only if we are in profiling mode? + timestamp_writes={ + "query_set": self.main._query_set, + "beginning_of_pass_write_index": buffer_address, + "end_of_pass_write_index": buffer_address + 1, + }, ) render_pass.set_pipeline(self._render_pipeline) @@ -632,6 +654,14 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> wgpu.GPUCommandBuffer: render_pass.draw(3, 1, 0, 0) # what is .draw_indirect? render_pass.end() + command_encoder.resolve_query_set( + query_set=self.main._query_set, + first_query=buffer_address, + query_count=2, + destination=self.main._query_buffer, + destination_offset=buffer_address * 128, + ) + # overwrite the existing texture with the newly rendered one. command_encoder.copy_texture_to_texture( { diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index 2c10b00..f7ddda3 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -78,6 +78,7 @@ class Shadertoy: inputs (list): A list of :class:`ShadertoyChannel` objects. Supports up to 4 inputs. Defaults to sampling a black texture. title (str): The title of the window. Defaults to "Shadertoy". complete (bool): Whether the shader is complete. Unsupported renderpasses or inputs will set this to False. Default is True. + profile (bool): Whether to enable profiling will spew runtimes for all passes. Default is False. The shader code must contain a entry point function: @@ -121,6 +122,7 @@ def __init__( inputs=[None] * 4, title: str = "Shadertoy", complete: bool = True, + profile: bool = False, ) -> None: self._uniform_data = UniformArray( ("mouse", "f", 4), @@ -141,6 +143,8 @@ def __init__( device_features = [] if not all(value == "" for value in buffers.values()): device_features.append(wgpu.FeatureName.float32_filterable) + if profile: + device_features.append(wgpu.FeatureName.timestamp_query) self._device = self._request_device(device_features) self.image = ImageRenderPass( @@ -177,6 +181,18 @@ def __init__( self._prepare_canvas() self.image._format = self._format self._bind_events() + + if profile: + # start and end for all buffers and image is 10 for now + self._query_set = self._device.create_query_set( + type=wgpu.QueryType.timestamp, count=10 + ) + self._query_buffer = self._device.create_buffer( + size=8 * self._query_set.count * 16, + usage=wgpu.BufferUsage.QUERY_RESOLVE | wgpu.BufferUsage.COPY_SRC, + ) + print(f"frame, A-buf, wait1, B-buf, wait2, C-buf, wait3, D-buf, wait4, Image,cpu(sum),gpu(sum)") + # TODO: could this be part of the __init__ of each renderpass? (but we need the device) for rpass in (self.image, *self.buffers.values()): if rpass: # skip None @@ -353,6 +369,29 @@ def _draw_frame(self): self._device.queue.submit(render_encoders) self._canvas.request_draw() + if hasattr(self, "_query_set"): + # values in nanosecond timestamps + timestamps = ( + self._device.queue.read_buffer(self._query_buffer).cast("Q").tolist() + ) + print(f"{self._frame:5d}", end=",") + total_dur, total_wait = 0, 0 + for n, rpass in enumerate("ABCDI"): + start = timestamps[n*32] + if n == 0: + # TODO: first wait is between frames maybe? + wait = 0.0 + else: + # wait time between passes, takes the end from the previous pass. + wait = start-end + print(f"{wait/1000:>6.2f}",end=",") + end = timestamps[(n*32)+1] + duration = end - start + print(f"{duration/1000:>6.2f}",end=",",) + total_dur += duration + total_wait += wait + print(f"{total_wait/1000:>8.2f},{total_dur/1000:>8.2f}") + def show(self): self._canvas.request_draw(self._draw_frame) if self._offscreen: From 1ce550049dd7e3d0ba7b9e22d1f5200a1a08d18d Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 11 Dec 2024 00:24:55 +0100 Subject: [PATCH 55/59] make profiling optional --- examples/shadertoy_buffer.py | 2 +- wgpu_shadertoy/cli.py | 4 +-- wgpu_shadertoy/passes.py | 62 +++++++++++++++++++++--------------- wgpu_shadertoy/shadertoy.py | 5 ++- 4 files changed, 43 insertions(+), 30 deletions(-) diff --git a/examples/shadertoy_buffer.py b/examples/shadertoy_buffer.py index 7377a59..d8edb03 100644 --- a/examples/shadertoy_buffer.py +++ b/examples/shadertoy_buffer.py @@ -80,7 +80,7 @@ image_code, inputs=[buffer_a_channel], buffers={"a": buffer_a_pass_wgsl}, - profile=True, + profile=False, ) if __name__ == "__main__": diff --git a/wgpu_shadertoy/cli.py b/wgpu_shadertoy/cli.py index c311899..cef28e9 100644 --- a/wgpu_shadertoy/cli.py +++ b/wgpu_shadertoy/cli.py @@ -18,10 +18,10 @@ ) argument_parser.add_argument( - "--profile", + "-P", "--profile", action="store_true", default=False, - help="Enable profiling for the shader", + help="Outputs rendertimes for all renderpasses, can be piped into a .csv for analysis", ) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 4bcb674..464425f 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -481,6 +481,15 @@ def draw_image( command_encoder: wgpu.GPUCommandEncoder = device.create_command_encoder() current_texture: wgpu.GPUTexture = present_context.get_current_texture() + if hasattr(self.main, "_query_set"): + timestamp_query = { + "query_set": self.main._query_set, + "beginning_of_pass_write_index": 8, + "end_of_pass_write_index": 9, + } + else: + timestamp_query = None + # TODO: maybe use a different name in this case? render_pass: wgpu.GPURenderPassEncoder = command_encoder.begin_render_pass( color_attachments=[ @@ -492,11 +501,7 @@ def draw_image( "store_op": wgpu.StoreOp.store, } ], - timestamp_writes={ - "query_set": self.main._query_set, - "beginning_of_pass_write_index": 8, - "end_of_pass_write_index": 9, - }, + timestamp_writes=timestamp_query, ) render_pass.set_pipeline(self._render_pipeline) @@ -504,13 +509,14 @@ def draw_image( render_pass.draw(3, 1, 0, 0) render_pass.end() - command_encoder.resolve_query_set( - query_set=self.main._query_set, - first_query=8, - query_count=2, - destination=self.main._query_buffer, - destination_offset=256 * 4, - ) + if hasattr(self.main, "_query_set"): + command_encoder.resolve_query_set( + query_set=self.main._query_set, + first_query=8, + query_count=2, + destination=self.main._query_buffer, + destination_offset=256 * 4, + ) return command_encoder.finish() # device.queue.submit([command_encoder.finish()]) @@ -628,7 +634,15 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> wgpu.GPUCommandBuffer: ) # for the timestamp buffer - buffer_address = "abcd".index(self.buffer_idx) * 2 + if hasattr(self.main, "_query_set"): + buffer_address = "abcd".index(self.buffer_idx) * 2 + timestamp_query = { + "query_set": self.main._query_set, + "beginning_of_pass_write_index": buffer_address, + "end_of_pass_write_index": buffer_address + 1, + } + else: + timestamp_query = None # TODO: maybe use a different name in this case? render_pass: wgpu.GPURenderPassEncoder = command_encoder.begin_render_pass( @@ -641,12 +655,7 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> wgpu.GPUCommandBuffer: "store_op": wgpu.StoreOp.store, } ], - # TODO: make only if we are in profiling mode? - timestamp_writes={ - "query_set": self.main._query_set, - "beginning_of_pass_write_index": buffer_address, - "end_of_pass_write_index": buffer_address + 1, - }, + timestamp_writes=timestamp_query, ) render_pass.set_pipeline(self._render_pipeline) @@ -654,13 +663,14 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> wgpu.GPUCommandBuffer: render_pass.draw(3, 1, 0, 0) # what is .draw_indirect? render_pass.end() - command_encoder.resolve_query_set( - query_set=self.main._query_set, - first_query=buffer_address, - query_count=2, - destination=self.main._query_buffer, - destination_offset=buffer_address * 128, - ) + if hasattr(self.main, "_query_set"): + command_encoder.resolve_query_set( + query_set=self.main._query_set, + first_query=buffer_address, + query_count=2, + destination=self.main._query_buffer, + destination_offset=buffer_address * 128, + ) # overwrite the existing texture with the newly rendered one. command_encoder.copy_texture_to_texture( diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index f7ddda3..83a5224 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -187,11 +187,13 @@ def __init__( self._query_set = self._device.create_query_set( type=wgpu.QueryType.timestamp, count=10 ) + # TODO: can the passive amount of padding be avoided? we need 80 bytes for 10 values, not 1280. self._query_buffer = self._device.create_buffer( size=8 * self._query_set.count * 16, usage=wgpu.BufferUsage.QUERY_RESOLVE | wgpu.BufferUsage.COPY_SRC, ) - print(f"frame, A-buf, wait1, B-buf, wait2, C-buf, wait3, D-buf, wait4, Image,cpu(sum),gpu(sum)") + # TODO: dynamic header by used passes, also sleep? + print("frame, A-buf, wait1, B-buf, wait2, C-buf, wait3, D-buf, wait4, Image,cpu(sum),gpu(sum)") # TODO: could this be part of the __init__ of each renderpass? (but we need the device) for rpass in (self.image, *self.buffers.values()): @@ -380,6 +382,7 @@ def _draw_frame(self): start = timestamps[n*32] if n == 0: # TODO: first wait is between frames maybe? + # TODO: proper solution for fewer than 4 buffers wait = 0.0 else: # wait time between passes, takes the end from the previous pass. From 47e561c8cb3df0db655104d893ebc78acca65bce Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 18 Dec 2024 00:07:47 +0100 Subject: [PATCH 56/59] fix profiling for fewer passes --- examples/shadertoy_buffer.py | 1 - examples/shadertoy_buffer_lovers.py | 2 +- wgpu_shadertoy/cli.py | 3 +- wgpu_shadertoy/passes.py | 24 ++-------------- wgpu_shadertoy/shadertoy.py | 44 ++++++++++++++++++++--------- 5 files changed, 36 insertions(+), 38 deletions(-) diff --git a/examples/shadertoy_buffer.py b/examples/shadertoy_buffer.py index d8edb03..26e5c6e 100644 --- a/examples/shadertoy_buffer.py +++ b/examples/shadertoy_buffer.py @@ -80,7 +80,6 @@ image_code, inputs=[buffer_a_channel], buffers={"a": buffer_a_pass_wgsl}, - profile=False, ) if __name__ == "__main__": diff --git a/examples/shadertoy_buffer_lovers.py b/examples/shadertoy_buffer_lovers.py index f88edfb..fc4ab97 100644 --- a/examples/shadertoy_buffer_lovers.py +++ b/examples/shadertoy_buffer_lovers.py @@ -7,7 +7,7 @@ shader_id = "ssjyWc" json_path = Path(Path(__file__).parent, f"shader_{shader_id}.json") -shader = Shadertoy.from_json(json_path, resolution=(800, 450), profile=True) +shader = Shadertoy.from_json(json_path, resolution=(800, 450)) if __name__ == "__main__": shader.show() diff --git a/wgpu_shadertoy/cli.py b/wgpu_shadertoy/cli.py index cef28e9..cea812c 100644 --- a/wgpu_shadertoy/cli.py +++ b/wgpu_shadertoy/cli.py @@ -18,7 +18,8 @@ ) argument_parser.add_argument( - "-P", "--profile", + "-P", + "--profile", action="store_true", default=False, help="Outputs rendertimes for all renderpasses, can be piped into a .csv for analysis", diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 464425f..f95d4a6 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -484,8 +484,8 @@ def draw_image( if hasattr(self.main, "_query_set"): timestamp_query = { "query_set": self.main._query_set, - "beginning_of_pass_write_index": 8, - "end_of_pass_write_index": 9, + "beginning_of_pass_write_index": self.main._query_set.count - 2, + "end_of_pass_write_index": self.main._query_set.count - 1, } else: timestamp_query = None @@ -498,7 +498,7 @@ def draw_image( "resolve_target": None, "clear_value": (0, 0, 0, 1), "load_op": wgpu.LoadOp.clear, - "store_op": wgpu.StoreOp.store, + "store_op": wgpu.StoreOp.store, # .discard might be faster } ], timestamp_writes=timestamp_query, @@ -509,15 +509,6 @@ def draw_image( render_pass.draw(3, 1, 0, 0) render_pass.end() - if hasattr(self.main, "_query_set"): - command_encoder.resolve_query_set( - query_set=self.main._query_set, - first_query=8, - query_count=2, - destination=self.main._query_buffer, - destination_offset=256 * 4, - ) - return command_encoder.finish() # device.queue.submit([command_encoder.finish()]) @@ -663,15 +654,6 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> wgpu.GPUCommandBuffer: render_pass.draw(3, 1, 0, 0) # what is .draw_indirect? render_pass.end() - if hasattr(self.main, "_query_set"): - command_encoder.resolve_query_set( - query_set=self.main._query_set, - first_query=buffer_address, - query_count=2, - destination=self.main._query_buffer, - destination_offset=buffer_address * 128, - ) - # overwrite the existing texture with the newly rendered one. command_encoder.copy_texture_to_texture( { diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index 83a5224..f09637d 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -156,7 +156,8 @@ def __init__( if k not in "abcd": raise ValueError(f"Invalid buffer key: {k=}") if v == "": - self.buffers[k] = "" + # self.buffers[k] = "" + continue # skip this whole buffer it's empty! elif type(v) is BufferRenderPass: v.main = self v.buffer_idx = k @@ -183,17 +184,19 @@ def __init__( self._bind_events() if profile: - # start and end for all buffers and image is 10 for now + # start and end for all buffers and image + count = 2 * (len(self.buffers) + 1) self._query_set = self._device.create_query_set( - type=wgpu.QueryType.timestamp, count=10 + type=wgpu.QueryType.timestamp, count=count ) - # TODO: can the passive amount of padding be avoided? we need 80 bytes for 10 values, not 1280. + # INT64 means 8 bytes per query. self._query_buffer = self._device.create_buffer( - size=8 * self._query_set.count * 16, + size=8 * self._query_set.count, usage=wgpu.BufferUsage.QUERY_RESOLVE | wgpu.BufferUsage.COPY_SRC, ) - # TODO: dynamic header by used passes, also sleep? - print("frame, A-buf, wait1, B-buf, wait2, C-buf, wait3, D-buf, wait4, Image,cpu(sum),gpu(sum)") + print( + f"frame, {', '.join([f'{c}-buf, wait{n}' for n,c in enumerate(self.buffers.keys())] + ['Image,cpu(sum),gpu(sum)'])}" + ) # TODO: could this be part of the __init__ of each renderpass? (but we need the device) for rpass in (self.image, *self.buffers.values()): @@ -367,6 +370,17 @@ def _draw_frame(self): self.image.draw_image(self._device, self._present_context) ) + if hasattr(self, "_query_set"): + command_encoder = self._device.create_command_encoder() + command_encoder.resolve_query_set( + query_set=self._query_set, + first_query=0, + query_count=self._query_set.count, + destination=self._query_buffer, + destination_offset=0, + ) + render_encoders.append(command_encoder.finish()) + # Submit all render encoders self._device.queue.submit(render_encoders) self._canvas.request_draw() @@ -378,19 +392,21 @@ def _draw_frame(self): ) print(f"{self._frame:5d}", end=",") total_dur, total_wait = 0, 0 - for n, rpass in enumerate("ABCDI"): - start = timestamps[n*32] + for n in range(self._query_set.count // 2): + start = timestamps[n * 2] if n == 0: # TODO: first wait is between frames maybe? - # TODO: proper solution for fewer than 4 buffers wait = 0.0 else: # wait time between passes, takes the end from the previous pass. - wait = start-end - print(f"{wait/1000:>6.2f}",end=",") - end = timestamps[(n*32)+1] + wait = start - timestamps[(n * 2) - 1] + print(f"{wait/1000:>6.2f}", end=",") + end = timestamps[(n * 2) + 1] duration = end - start - print(f"{duration/1000:>6.2f}",end=",",) + print( + f"{duration/1000:>6.2f}", + end=",", + ) total_dur += duration total_wait += wait print(f"{total_wait/1000:>8.2f},{total_dur/1000:>8.2f}") From f67a247640c72cd5d8b18e78527e57c980d00b71 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 19 Dec 2024 00:50:42 +0100 Subject: [PATCH 57/59] refactor glsl vertex --- wgpu_shadertoy/passes.py | 167 ++++++++++++++++++++------------------- 1 file changed, 86 insertions(+), 81 deletions(-) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index f95d4a6..58be2c9 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -6,32 +6,6 @@ from .inputs import ShadertoyChannel, ShadertoyChannelBuffer, ShadertoyChannelTexture -vertex_code_glsl = """#version 450 core -//#define YFLIP -layout(location = 0) out vec2 vert_uv; - -// if YFLIP is defined, the vertex is flipped. This is used for the buffer passes. -#ifdef YFLIP -float flip = -1.0; -#else -float flip = 0.0; -#endif - -void main(void){ - int index = int(gl_VertexID); - if (index == 0) { - gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); - vert_uv = vec2(0.0, 1.0 + flip); - } else if (index == 1) { - gl_Position = vec4(3.0, -1.0, 0.0, 1.0); - vert_uv = vec2(2.0, 1.0 + flip); - } else { - gl_Position = vec4(-1.0, 3.0, 0.0, 1.0); - vert_uv = vec2(0.0, -1.0 - (3*flip)); - } -} -""" - builtin_variables_glsl = """#version 450 core vec4 i_mouse; @@ -57,42 +31,6 @@ #define mainImage shader_main """ - -fragment_code_glsl = """ -layout(location = 0) in vec2 vert_uv; - -struct ShadertoyInput { - vec4 si_mouse; - vec4 si_date; - vec3 si_resolution; - float si_time; - vec3 si_channel_res[4]; - float si_time_delta; - int si_frame; - float si_framerate; -}; - -layout(binding = 0) uniform ShadertoyInput input; -out vec4 FragColor; -void main(){ - - i_mouse = input.si_mouse; - i_date = input.si_date; - i_resolution = input.si_resolution; - i_time = input.si_time; - i_channel_resolution = input.si_channel_res; - i_time_delta = input.si_time_delta; - i_frame = input.si_frame; - i_framerate = input.si_framerate; - vec2 frag_uv = vec2(vert_uv.x, 1.0 - vert_uv.y); - vec2 frag_coord = frag_uv * i_resolution.xy; - - shader_main(FragColor, frag_coord); - -} - -""" - vertex_code_wgsl = """ struct Varyings { @@ -331,21 +269,54 @@ def _attach_inputs(self, inputs: list) -> List[ShadertoyChannel]: return channels - def prepare_render(self, device: wgpu.GPUDevice) -> None: + def _construct_code(self) -> tuple[str, str]: """ - This function is run once per renderpass. - It composes the shader code, then compiles the shader modules and finally maps the uniform buffer. + assembles the code templates for the vertext and fragment stages. """ - - # Step 1: compose shader programs - self.channels = self._attach_inputs(self._inputs) - shader_type = self.shader_type - if shader_type == "glsl": - if type(self) is BufferRenderPass: - # skip the // to uncomment out the YFLIP define. (why even have define?) - vertex_shader_code = vertex_code_glsl[:18] + vertex_code_glsl[20:] - else: - vertex_shader_code = vertex_code_glsl + if self.shader_type == "glsl": + vertex_shader_code = """ + #version 450 core + vec2 pos[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + void main() { + int index = int(gl_VertexID); + gl_Position = vec4(pos[index], 0.0, 1.0); + } + """ + # the image pass needs to be yflipped, buffers not. However dFdy is still violated. + fragment_code_glsl = f""" + {'' if isinstance(self, ImageRenderPass) else '//'} #define YFLIP + + struct ShadertoyInput {{ + vec4 si_mouse; + vec4 si_date; + vec3 si_resolution; + float si_time; + vec3 si_channel_res[4]; + float si_time_delta; + int si_frame; + float si_framerate; + }}; + + layout(binding = 0) uniform ShadertoyInput input; + out vec4 FragColor; + void main(){{ + i_mouse = input.si_mouse; + i_date = input.si_date; + i_resolution = input.si_resolution; + i_time = input.si_time; + i_channel_resolution = input.si_channel_res; + i_time_delta = input.si_time_delta; + i_frame = input.si_frame; + i_framerate = input.si_framerate; + + // handle the YFLIP part for just the Image pass? + vec2 fragcoord=gl_FragCoord.xy; + #ifdef YFLIP + fragcoord.y=i_resolution.y-fragcoord.y; + #endif + shader_main(FragColor, fragcoord); + }} + """ frag_shader_code = ( builtin_variables_glsl + self._input_headers @@ -353,12 +324,8 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: + self.shader_code + fragment_code_glsl ) - elif shader_type == "wgsl": - if type(self) is BufferRenderPass: - # TODO: find a better solution than duplicated vertex code for YFLIP. - vertex_shader_code = vertex_code_wgsl_flipped - else: - vertex_shader_code = vertex_code_wgsl + elif self.shader_type == "wgsl": + vertex_shader_code = vertex_code_wgsl frag_shader_code = ( builtin_variables_wgsl + self._input_headers @@ -366,6 +333,44 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: + self.shader_code + fragment_code_wgsl ) + return vertex_shader_code, frag_shader_code + + def prepare_render(self, device: wgpu.GPUDevice) -> None: + """ + This function is run once per renderpass. + It composes the shader code, then compiles the shader modules and finally maps the uniform buffer. + """ + + # Step 1: compose shader programs + self.channels = self._attach_inputs(self._inputs) + # shader_type = self.shader_type + # if shader_type == "glsl": + # if type(self) is BufferRenderPass: + # # skip the // to uncomment out the YFLIP define. (why even have define?) + # vertex_shader_code = vertex_code_glsl[:18] + vertex_code_glsl[20:] + # else: + # vertex_shader_code = vertex_code_glsl + # frag_shader_code = ( + # builtin_variables_glsl + # + self._input_headers + # + self.main.common + # + self.shader_code + # + fragment_code_glsl + # ) + # elif shader_type == "wgsl": + # if type(self) is BufferRenderPass: + # # TODO: find a better solution than duplicated vertex code for YFLIP. + # vertex_shader_code = vertex_code_wgsl_flipped + # else: + # vertex_shader_code = vertex_code_wgsl + # frag_shader_code = ( + # builtin_variables_wgsl + # + self._input_headers + # + self.main.common + # + self.shader_code + # + fragment_code_wgsl + # ) + vertex_shader_code, frag_shader_code = self._construct_code() # why are the labels triangle? they should be something more approriate. self._vertex_shader_program = device.create_shader_module( From 7544126def41dc62c8446de2324b4a703c1e607e Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 20 Dec 2024 01:17:45 +0100 Subject: [PATCH 58/59] refactor wgsl vertex and fragment --- wgpu_shadertoy/passes.py | 178 +++++++++++---------------------------- 1 file changed, 51 insertions(+), 127 deletions(-) diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 58be2c9..61209ee 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -6,6 +6,7 @@ from .inputs import ShadertoyChannel, ShadertoyChannelBuffer, ShadertoyChannelTexture +# TODO: drop the double aliases in GLSL, just go with the website syntax (also change the docstring of the Shadertoy class!) builtin_variables_glsl = """#version 450 core vec4 i_mouse; @@ -31,58 +32,6 @@ #define mainImage shader_main """ -vertex_code_wgsl = """ - -struct Varyings { - @builtin(position) position : vec4, - @location(0) vert_uv : vec2, -}; - -@vertex -fn main(@builtin(vertex_index) index: u32) -> Varyings { - var out: Varyings; - if (index == u32(0)) { - out.position = vec4(-1.0, -1.0, 0.0, 1.0); - out.vert_uv = vec2(0.0, 1.0); - } else if (index == u32(1)) { - out.position = vec4(3.0, -1.0, 0.0, 1.0); - out.vert_uv = vec2(2.0, 1.0); - } else { - out.position = vec4(-1.0, 3.0, 0.0, 1.0); - out.vert_uv = vec2(0.0, -1.0); - } - return out; - -} -""" - -# TODO: can this be done without repeating yourself in wgsl too? -vertex_code_wgsl_flipped = """ - -struct Varyings { - @builtin(position) position : vec4, - @location(0) vert_uv : vec2, -}; - -@vertex -fn main(@builtin(vertex_index) index: u32) -> Varyings { - var out: Varyings; - if (index == u32(0)) { - out.position = vec4(-1.0, -1.0, 0.0, 1.0); - out.vert_uv = vec2(0.0, 0.0); // Flipped - } else if (index == u32(1)) { - out.position = vec4(3.0, -1.0, 0.0, 1.0); - out.vert_uv = vec2(2.0, 0.0); // Flipped - } else { - out.position = vec4(-1.0, 3.0, 0.0, 1.0); - out.vert_uv = vec2(0.0, 2.0); // Flipped - } - return out; - -} -""" - - builtin_variables_wgsl = """ var i_mouse: vec4; @@ -100,47 +49,6 @@ """ -fragment_code_wgsl = """ - -struct ShadertoyInput { - si_mouse: vec4, - si_date: vec4, - si_resolution: vec3, - si_time: f32, - si_channel_res: array,4>, - si_time_delta: f32, - si_frame: u32, - si_framerate: f32, -}; - -struct Varyings { - @builtin(position) position : vec4, - @location(0) vert_uv : vec2, -}; - -@group(0) @binding(0) -var input: ShadertoyInput; - -@fragment -fn main(in: Varyings) -> @location(0) vec4 { - - i_mouse = input.si_mouse; - i_date = input.si_date; - i_resolution = input.si_resolution; - i_time = input.si_time; - i_channel_resolution = input.si_channel_res; - i_time_delta = input.si_time_delta; - i_frame = input.si_frame; - i_framerate = input.si_framerate; - let frag_uv = vec2(in.vert_uv.x, 1.0 - in.vert_uv.y); - let frag_coord = frag_uv * i_resolution.xy; - - return shader_main(frag_coord); -} - -""" - - class RenderPass: """ Base class for renderpass in a Shadertoy. @@ -284,8 +192,6 @@ def _construct_code(self) -> tuple[str, str]: """ # the image pass needs to be yflipped, buffers not. However dFdy is still violated. fragment_code_glsl = f""" - {'' if isinstance(self, ImageRenderPass) else '//'} #define YFLIP - struct ShadertoyInput {{ vec4 si_mouse; vec4 si_date; @@ -310,10 +216,7 @@ def _construct_code(self) -> tuple[str, str]: i_framerate = input.si_framerate; // handle the YFLIP part for just the Image pass? - vec2 fragcoord=gl_FragCoord.xy; - #ifdef YFLIP - fragcoord.y=i_resolution.y-fragcoord.y; - #endif + vec2 fragcoord=vec2(gl_FragCoord.x, {'i_resolution.y-' if isinstance(self, ImageRenderPass) else ''}gl_FragCoord.y); shader_main(FragColor, fragcoord); }} """ @@ -325,7 +228,55 @@ def _construct_code(self) -> tuple[str, str]: + fragment_code_glsl ) elif self.shader_type == "wgsl": - vertex_shader_code = vertex_code_wgsl + vertex_shader_code = """ + struct VertexOut { + @builtin(position) position : vec4 + } + + @vertex + fn main(@builtin(vertex_index) vertIndex: u32) -> VertexOut { + var pos = array( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0) + ); + var out: VertexOut; + out.position = vec4(pos[vertIndex], 0.0, 1.0); + return out; + } + """ + fragment_code_wgsl = f""" + struct ShadertoyInput {{ + si_mouse: vec4, + si_date: vec4, + si_resolution: vec3, + si_time: f32, + si_channel_res: array,4>, + si_time_delta: f32, + si_frame: u32, + si_framerate: f32, + }}; + struct VertexOut {{ + @builtin(position) position : vec4, + }}; + @group(0) @binding(0) + var input: ShadertoyInput; + @fragment + fn main(in: VertexOut) -> @location(0) vec4 {{ + i_mouse = input.si_mouse; + i_date = input.si_date; + i_resolution = input.si_resolution; + i_time = input.si_time; + i_channel_resolution = input.si_channel_res; + i_time_delta = input.si_time_delta; + i_frame = input.si_frame; + i_framerate = input.si_framerate; + + // Yflip for the image pass (not the correct solution) + let frag_coord = vec2f(in.position.x, {'i_resolution.y-' if isinstance(self, ImageRenderPass) else ''}in.position.y); + return shader_main(frag_coord); + }} + """ frag_shader_code = ( builtin_variables_wgsl + self._input_headers @@ -343,33 +294,6 @@ def prepare_render(self, device: wgpu.GPUDevice) -> None: # Step 1: compose shader programs self.channels = self._attach_inputs(self._inputs) - # shader_type = self.shader_type - # if shader_type == "glsl": - # if type(self) is BufferRenderPass: - # # skip the // to uncomment out the YFLIP define. (why even have define?) - # vertex_shader_code = vertex_code_glsl[:18] + vertex_code_glsl[20:] - # else: - # vertex_shader_code = vertex_code_glsl - # frag_shader_code = ( - # builtin_variables_glsl - # + self._input_headers - # + self.main.common - # + self.shader_code - # + fragment_code_glsl - # ) - # elif shader_type == "wgsl": - # if type(self) is BufferRenderPass: - # # TODO: find a better solution than duplicated vertex code for YFLIP. - # vertex_shader_code = vertex_code_wgsl_flipped - # else: - # vertex_shader_code = vertex_code_wgsl - # frag_shader_code = ( - # builtin_variables_wgsl - # + self._input_headers - # + self.main.common - # + self.shader_code - # + fragment_code_wgsl - # ) vertex_shader_code, frag_shader_code = self._construct_code() # why are the labels triangle? they should be something more approriate. From 91d364ef2ac843ee5d244544dfbf85036145dd1a Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 31 Dec 2024 00:46:38 +0100 Subject: [PATCH 59/59] simplify GLSL uniforms --- README.md | 19 ++++++++++++ tests/test_textures.py | 2 +- tests/test_util_shadertoy.py | 27 ++++++++-------- wgpu_shadertoy/inputs.py | 1 + wgpu_shadertoy/passes.py | 60 ++++++++++++++++-------------------- wgpu_shadertoy/shadertoy.py | 32 ++++--------------- 6 files changed, 68 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index e5de09e..dcac89b 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,25 @@ To display a shader from the website, simply provide its ID or url. > wgpu-shadertoy tsXBzS --resolution 1024 640 ``` +### Uniforms +The Shadertoy uniform format is directly supported for GLSL. However for WGSL the syntax is a bit different. + +| Shadertoy.com | GLSL | WGSL | +|--- | --- | --- | +| `vec4 iMouse` | `iMouse` | `i_mouse` | +| `vec4 iDate` | `iDate` | `i_date` | +| `vec3 iResolution` | `iResolution` | `i_resolution` | +| `float iTime` | `iTime` | `i_time` | +| `vec3 iChannelResolution[4]` | `iChannelResolution` | `i_channel_resolution` | +| `float iTimeDelta` | `iTimeDelta` | `i_time_delta` | +| `int iFrame` | `iFrame` | `i_frame` | +| `float iFrameRate` | `iFrameRate` | `i_frame_rate` | +| `sampler2D iChannel0..3` | `iChannel0..3` | `i_channel0..3` | +| `sampler3D iChannel0..3` | not yet supported | not yet supported | +| `samplerCube iChannel0..3` | not yet supported | not yet supported | +| `float iChannelTime[4]` | not yet supported | not yet supported | +| `float iSampleRate` | not yet supported | not yet supported | + ## Status This project is still in development. Some functionality from the Shadertoy [website is missing](https://github.com/pygfx/shadertoy/issues/4) and [new features](https://github.com/pygfx/shadertoy/issues/8) are being added. See the issues to follow the development or [contribute yourself](./CONTRIBUTING.md)! For progress see the [changelog](./CHANGELOG.md). diff --git a/tests/test_textures.py b/tests/test_textures.py index 5be6988..3333a32 100644 --- a/tests/test_textures.py +++ b/tests/test_textures.py @@ -61,7 +61,7 @@ def test_textures_glsl(): vec2 uv = fragCoord/iResolution.xy; vec4 c0 = texture(iChannel0, 2.0*uv); vec4 c1 = texture(iChannel1, 3.0*uv); - fragColor = mix(c0,c1,abs(sin(i_time))); + fragColor = mix(c0,c1,abs(sin(iTime))); } """ diff --git a/tests/test_util_shadertoy.py b/tests/test_util_shadertoy.py index 16a3b38..160a4e0 100644 --- a/tests/test_util_shadertoy.py +++ b/tests/test_util_shadertoy.py @@ -60,18 +60,19 @@ def test_shadertoy_glsl(): from wgpu_shadertoy import Shadertoy shader_code = """ - void shader_main(out vec4 fragColor, vec2 frag_coord) { - vec2 uv = frag_coord / i_resolution.xy; + void mainImage(out vec4 fragColor, vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; - if ( length(frag_coord - i_mouse.xy) < 20.0 ) { + if ( length(fragCoord - iMouse.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); + fragColor = vec4( 0.5 + 0.5 * sin(iTime * vec3(uv, 1.0) ), 1.0); } } """ + # tests the shader_type detection base case we will most likely see. shader = Shadertoy(shader_code, resolution=(800, 450)) assert shader.resolution == (800, 450) assert shader.shader_code == shader_code @@ -97,6 +98,7 @@ def test_shadertoy_glsl2(): } """ + # this tests if setting the shader_type to glsl works as expected shader = Shadertoy(shader_code, shader_type="glsl", resolution=(800, 450)) assert shader.resolution == (800, 450) assert shader.shader_code == shader_code @@ -122,6 +124,7 @@ def test_shadertoy_glsl3(): } """ + # this tests glsl detection against the regular expression when using more than one whitespace between void and mainImage. shader = Shadertoy(shader_code, resolution=(800, 450)) assert shader.resolution == (800, 450) assert shader.shader_code == shader_code @@ -135,13 +138,13 @@ def test_shadertoy_offscreen(): from wgpu_shadertoy import Shadertoy shader_code = """ - void shader_main(out vec4 fragColor, vec2 frag_coord) { - vec2 uv = frag_coord / i_resolution.xy; + void mainImage(out vec4 fragColor, vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; - if ( length(frag_coord - i_mouse.xy) < 20.0 ) { + if ( length(fragCoord - iMouse.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); + fragColor = vec4( 0.5 + 0.5 * sin(iTime * vec3(uv, 1.0) ), 1.0); } } @@ -159,13 +162,13 @@ def test_shadertoy_snapshot(): from wgpu_shadertoy import Shadertoy shader_code = """ - void shader_main(out vec4 fragColor, vec2 frag_coord) { - vec2 uv = frag_coord / i_resolution.xy; + void mainImage(out vec4 fragColor, vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; - if ( length(frag_coord - i_mouse.xy) < 20.0 ) { + if ( length(fragCoord - iMouse.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); + fragColor = vec4( 0.5 + 0.5 * sin(iTime * vec3(uv, 1.0) ), 1.0); } } diff --git a/wgpu_shadertoy/inputs.py b/wgpu_shadertoy/inputs.py index 53a5e98..d72b9a5 100644 --- a/wgpu_shadertoy/inputs.py +++ b/wgpu_shadertoy/inputs.py @@ -55,6 +55,7 @@ def sampler_settings(self) -> dict: wrap = "clamp-to-edge" sampler_settings["address_mode_u"] = wrap sampler_settings["address_mode_v"] = wrap + # we don't do 3D textures yet, but I guess ssetting this too is fine. sampler_settings["address_mode_w"] = wrap return sampler_settings diff --git a/wgpu_shadertoy/passes.py b/wgpu_shadertoy/passes.py index 61209ee..c65e476 100644 --- a/wgpu_shadertoy/passes.py +++ b/wgpu_shadertoy/passes.py @@ -6,30 +6,16 @@ from .inputs import ShadertoyChannel, ShadertoyChannelBuffer, ShadertoyChannelTexture -# TODO: drop the double aliases in GLSL, just go with the website syntax (also change the docstring of the Shadertoy class!) builtin_variables_glsl = """#version 450 core -vec4 i_mouse; -vec4 i_date; -vec3 i_resolution; -float i_time; -vec3 i_channel_resolution[4]; -float i_time_delta; -int i_frame; -float i_framerate; - -// Shadertoy compatibility, see we can use the same code copied from shadertoy website - -#define iMouse i_mouse -#define iDate i_date -#define iResolution i_resolution -#define iTime i_time -#define iChannelResolution i_channel_resolution -#define iTimeDelta i_time_delta -#define iFrame i_frame -#define iFrameRate i_framerate - -#define mainImage shader_main +vec4 iMouse; +vec4 iDate; +vec3 iResolution; +float iTime; +vec3 iChannelResolution[4]; +float iTimeDelta; +int iFrame; +float iFrameRate; """ builtin_variables_wgsl = """ @@ -106,7 +92,7 @@ def shader_type(self) -> str: return self._shader_type wgsl_main_expr = re.compile(r"fn(?:\s)+shader_main") - glsl_main_expr = re.compile(r"void(?:\s)+(?:shader_main|mainImage)") + glsl_main_expr = re.compile(r"void(?:\s)+mainImage") if wgsl_main_expr.search(self.shader_code): self._shader_type = "wgsl" elif glsl_main_expr.search(self.shader_code): @@ -192,7 +178,7 @@ def _construct_code(self) -> tuple[str, str]: """ # the image pass needs to be yflipped, buffers not. However dFdy is still violated. fragment_code_glsl = f""" - struct ShadertoyInput {{ + uniform struct ShadertoyInput {{ vec4 si_mouse; vec4 si_date; vec3 si_resolution; @@ -206,18 +192,18 @@ def _construct_code(self) -> tuple[str, str]: layout(binding = 0) uniform ShadertoyInput input; out vec4 FragColor; void main(){{ - i_mouse = input.si_mouse; - i_date = input.si_date; - i_resolution = input.si_resolution; - i_time = input.si_time; - i_channel_resolution = input.si_channel_res; - i_time_delta = input.si_time_delta; - i_frame = input.si_frame; - i_framerate = input.si_framerate; + iMouse = input.si_mouse; + iDate = input.si_date; + iResolution = input.si_resolution; + iTime = input.si_time; + iChannelResolution = input.si_channel_res; + iTimeDelta = input.si_time_delta; + iFrame = input.si_frame; + iFrameRate = input.si_framerate; // handle the YFLIP part for just the Image pass? - vec2 fragcoord=vec2(gl_FragCoord.x, {'i_resolution.y-' if isinstance(self, ImageRenderPass) else ''}gl_FragCoord.y); - shader_main(FragColor, fragcoord); + vec2 fragcoord=vec2(gl_FragCoord.x, {'iResolution.y-' if isinstance(self, ImageRenderPass) else ''}gl_FragCoord.y); + mainImage(FragColor, fragcoord); }} """ frag_shader_code = ( @@ -514,6 +500,7 @@ def resize(self, new_cols: int, new_rows: int) -> None: self.__delattr__("_texture_view") # TODO: refresh all passes (but in what order) with at least pass.prepare_render() + # TODO: sort the properties towards the top. (or can we avoid them in the first place?) @property def texture(self) -> wgpu.GPUTexture: """ @@ -546,6 +533,11 @@ def draw_buffer(self, device: wgpu.GPUDevice) -> wgpu.GPUCommandBuffer: self._update_uniforms(device) command_encoder: wgpu.GPUCommandEncoder = device.create_command_encoder() + # TODO: improve performance by avoiding temporary textures and copying: + # implementing a back buffer swap chain seems unlikely untill we get usages for the views: https://github.com/gfx-rs/wgpu/pull/6755 + # will also require us to redo all the bind groups, might be able to use replace by looking for the channel that uses this buffer. + # alternatively explore the StorageTexture and reformulate the main function for buffer passes? + # create a temporary texture as a render target, as writing to a texture we also sample from won't work. target_texture: wgpu.GPUTexture = device.create_texture( size=self.texture_size, diff --git a/wgpu_shadertoy/shadertoy.py b/wgpu_shadertoy/shadertoy.py index f09637d..cb82551 100644 --- a/wgpu_shadertoy/shadertoy.py +++ b/wgpu_shadertoy/shadertoy.py @@ -15,7 +15,6 @@ class UniformArray: """Convenience class to create a uniform array. - Maybe we can make it a public util at some point. Ensure that the order matches structs in the shader code. See https://www.w3.org/TR/WGSL/#alignment-and-size for reference on alignment. """ @@ -82,13 +81,13 @@ class Shadertoy: The shader code must contain a entry point function: - WGSL: ``fn shader_main(frag_coord: vec2) -> vec4{}`` - GLSL: ``void shader_main(out vec4 frag_color, in vec2 frag_coord){}`` + WGSL: ``fn shader_main(frag_coord: vec2) -> vec4{}``
+ GLSL: ``void mainImage(out vec4 fragColor, in vec2 fragCoord){}`` It has a parameter ``frag_coord`` which is the current pixel coordinate (in range 0..resolution, origin is bottom-left), - and it must return a vec4 color (for GLSL, it's the ``out vec4 frag_color`` parameter), which is the color of the pixel at that coordinate. + and it must return a vec4 color (for GLSL, it's the ``out vec4 fragColor`` parameter), which is the color of the pixel at that coordinate. - some built-in variables are available in the shader: + some built-in uniforms are available in the shader: * ``i_mouse``: the mouse position in pixels * ``i_date``: the current date and time as a vec4 (year, month, day, seconds) @@ -98,8 +97,8 @@ class Shadertoy: * ``i_frame``: the frame number * ``i_framerate``: the number of frames rendered in the last second. - For GLSL, you can also use the aliases ``iTime``, ``iTimeDelta``, ``iFrame``, ``iResolution``, ``iMouse``, ``iDate`` and ``iFrameRate`` of these built-in variables, - the entry point function also has an alias ``mainImage``, so you can use the shader code copied from shadertoy website without making any changes. + For GLSL, these uniforms are ``iTime``, ``iTimeDelta``, ``iFrame``, ``iResolution``, ``iMouse``, ``iDate`` and ``iFrameRate``, + the entry point function is ``mainImage``, so you can use the shader code copied from shadertoy website without making any changes. """ # TODO: rewrite this whole docstring above. @@ -439,22 +438,3 @@ def snapshot(self, time_float: float = 0.0, mouse_pos: tuple = (0, 0, 0, 0)): self._canvas.request_draw(self._draw_frame) frame = self._canvas.draw() return frame - - -# TODO: this code shouldn't be executed as a script anymore. -if __name__ == "__main__": - shader = Shadertoy( - """ - fn shader_main(frag_coord: vec2) -> vec4 { - let uv = frag_coord / i_resolution.xy; - - if ( length(frag_coord - i_mouse.xy) < 20.0 ) { - return vec4(textureSample(i_channel0, sampler0, uv)); - }else{ - return vec4( 0.5 + 0.5 * sin(i_time * vec3(uv, 1.0) ), 1.0); - } - - } - """ - ) - shader.show()