Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions examples/example_imgui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from wgpu_shadertoy import Shadertoy

# shadertoy source: https://www.shadertoy.com/view/Wf3SWn by Xor CC-BY-NC-SA 3.0
# modified in Line73 to disassemble the for loop due to: https://github.com/gfx-rs/wgpu/issues/6208

shader_code = """//glsl
/*
"Sunset" by @XorDev

Expanded and clarified version of my Sunset shader:
https://www.shadertoy.com/view/wXjSRt

Based on my tweet shader:
https://x.com/XorDev/status/1918764164153049480
*/

//Output image brightness
#define BRIGHTNESS 1.0

//Base brightness (higher = brighter, less saturated)
#define COLOR_BASE 1.5
//Color cycle speed (radians per second)
#define COLOR_SPEED 0.5
//RGB color phase shift (in radians)
#define RGB vec3(0.0, 1.0, 2.0)
//Color translucency strength
#define COLOR_WAVE 14.0
//Color direction and (magnitude = frequency)
#define COLOR_DOT vec3(1,-1,0)

//Wave iterations (higher = slower)
#define WAVE_STEPS 8.0
//Starting frequency
#define WAVE_FREQ 5.0
//Wave amplitude
#define WAVE_AMP 0.6
//Scaling exponent factor
#define WAVE_EXP 1.8
//Movement direction
#define WAVE_VELOCITY vec3(0.2)


//Cloud thickness (lower = denser)
#define PASSTHROUGH 0.2

//Cloud softness
#define SOFTNESS 0.005
//Raymarch step
#define STEPS 100.0
//Sky brightness factor (finicky)
#define SKY 10.0
//Camera fov ratio (tan(fov_y/2))
#define FOV 1.0

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
//Raymarch depth
float z = 0.0;

//Step distance
float d = 0.0;
//Signed distance
float s = 0.0;

//Ray direction
vec3 dir = normalize( vec3(2.0*fragCoord - iResolution.xy, - FOV * iResolution.y));

//Output color
vec3 col = vec3(0);

//Clear fragcolor and raymarch with 100 iterations
for(float i = 0.0; i<STEPS; i++)
{
//Compute raymarch sample point
vec3 p = z * dir;

//Turbulence loop
//https://www.shadertoy.com/view/3XXSWS
float j, f = WAVE_FREQ;
for(j = 0.0; j<WAVE_STEPS; j++) {
p += WAVE_AMP*sin(p*f - WAVE_VELOCITY*iTime).yzx / f;
f *= WAVE_EXP;
}
//Compute distance to top and bottom planes
s = 0.3 - abs(p.y);
//Soften and scale inside the clouds
d = SOFTNESS + max(s, -s*PASSTHROUGH) / 4.0;
//Step forward
z += d;
//Coloring with signed distance, position and cycle time
float phase = COLOR_WAVE * s + dot(p,COLOR_DOT) + COLOR_SPEED*iTime;
//Apply RGB phase shifts, add base brightness and correct for sky
col += (cos(phase - RGB) + COLOR_BASE) * exp(s*SKY) / d;
}
//Tanh tonemapping
//https://www.shadertoy.com/view/ms3BD7
col *= SOFTNESS / STEPS * BRIGHTNESS;
fragColor = vec4(tanh(col * col), 1.0);
}
"""

shader = Shadertoy(shader_code, resolution=(800, 450), imgui=True)


if __name__ == "__main__":
shader.show()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
name = "wgpu-shadertoy"
dynamic = ["version", "readme"]
dependencies = [
"wgpu>=0.21.1",
"wgpu[imgui]>=0.21.1",
"rendercanvas",
"requests",
"numpy",
Expand Down
2 changes: 1 addition & 1 deletion tests/testutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def get_default_adapter_summary():
def find_examples(query=None, negative_query=None, return_stems=False):
result = []
for example_path in examples_dir.glob("*.py"):
example_code = example_path.read_text()
example_code = example_path.read_text(encoding="utf-8")
query_match = query is None or query in example_code
negative_query_match = (
negative_query is None or negative_query not in example_code
Expand Down
10 changes: 9 additions & 1 deletion wgpu_shadertoy/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,20 @@
default=(800, 450),
)

argument_parser.add_argument(
"--imgui",
help="automatically turn constants into imgui sliders",
action="store_true",
)


def main_cli():
args = argument_parser.parse_args()
shader_id = args.shader_id
resolution = args.resolution
shader = Shadertoy.from_id(shader_id, resolution=resolution)
imgui = args.imgui
# TODO maybe **args?
shader = Shadertoy.from_id(shader_id, resolution=resolution, imgui=imgui)
shader.show()


Expand Down
227 changes: 227 additions & 0 deletions wgpu_shadertoy/imgui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import re
from imgui_bundle import imgui as ig

from .utils import UniformArray
from wgpu.utils.imgui import ImguiWgpuBackend
# from wgpu_shadertoy.passes import RenderPass #circular import-.-
from dataclasses import dataclass
from math import log

Check failure on line 8 in wgpu_shadertoy/imgui.py

View workflow job for this annotation

GitHub Actions / Test Linting

Ruff (F401)

wgpu_shadertoy/imgui.py:8:18: F401 `math.log` imported but unused

Check failure on line 8 in wgpu_shadertoy/imgui.py

View workflow job for this annotation

GitHub Actions / Test Linting

Ruff (I001)

wgpu_shadertoy/imgui.py:1:1: I001 Import block is un-sorted or un-formatted


# could imgui become just another RenderPass after Image? I got to understand backend vs renderer first.
# make become part of .passes??
# todo: raise error if imgui isn't installed (only if this module is required?)


@dataclass
class ShaderConstant:
# renderpass_pass: str #maybe this is a RenderPass pointer? likely redundant
line_number: int
original_line: str
name: str
value: int | float
shader_dtype: str # float, int, vec2, vec3, bool etc.

def c_type_format(self) -> str:
# based on these for the memoryview cast:
# https://docs.python.org/3/library/struct.html#format-characters
if self.shader_dtype == "float":
return "f"
elif self.shader_dtype == "int":
return "i"
elif self.shader_dtype == "uint":
return "I"
# add more types as needed
return "?"

def parse_constants(code:str) -> list[ShaderConstant]:
# todo:
# WGSL variants??
# re/tree-sitter/loops and functions?
# parse and collect constants from shadercode (including common pass?)
# get information about the line, the type and it's initial value
# make up a range (maybe just the order of magnitude + 1 as max and 0 as min (what about negative values?))
# what is the return type? (line(int), type(str), value(float/tuple/int?)) maybe proper dataclasss for once

# for multipass shader this might need to be per pass (rpass.value) ?
# mataches the macro: #define NAME VALUE
# TODO there can be characters in numerical literals, such as x and o for hex and octal representation or e for scientific notation
# technically the macros can also be an expression that is evaluated to be a number... such as # define DOF 10..0/30.0 - so how do we deal with that?
define_pattern = re.compile(r"#\s*define\s+(\w+)\s+(-?[\d.]+)") #for numerical literals right now.
if_def_template = r"#(el)?if\s+" #preprocessor ifdef blocks can't become uniforms. replacing these dynamically will be difficult.

constants = []
for li, line in enumerate(code.splitlines()):
match = define_pattern.match(line.strip())
if match:
name, value = match.groups()
if_def_pattern = re.compile(if_def_template + name)
if if_def_pattern.findall(code):
#.findall over .match because because not only the beginning matters here
print(f"skipping constant {name}, it needs to stay a macro")
continue

if "." in value: #value.isdecimal?
# TODO: wgsl needs to be more specific (f32 for example?) - but there is no preprocessor anyways...
dtype = "float" #default float (32bit)
value = float(value)
elif value.isdecimal(): # value.isnumeric?
dtype = "int" # "big I (32bit)"
value = int(value)
else:
# TODO complexer types?
print(f"can't parse type for constant {name} with value {value}, skipping")
continue

constant = ShaderConstant(
# renderpass_pass="image", # TODO: shouldn't be names.
line_number=li,
original_line=line.strip(),
name=name,
value=value,
shader_dtype=dtype
)
# todo: remove lines here? (comment out better)
constants.append(constant)
print(f"In line {li} found constant: {name} with value: {value} of dtype {dtype}") # maybe name renderpass too?

# maybe just named tuple instead of dataclass?
return constants

def make_uniform(constants) -> UniformArray:
arr_data = []
for constant in constants:
arr_data.append(tuple([constant.name, constant.c_type_format(), 1]))
data = UniformArray(*arr_data)

# init data
for constant in constants:
data[constant.name] = constant.value

# TODO:
# is there issues with padding? (maybe solve in the class)
# figure out order due to padding/alignment: https://www.w3.org/TR/WGSL/#alignment-and-size
# return a UniformArray object too (cycling import?) also needs device handed down.
# (does this need to be a class to update the values?)
return data

# TODO mark private?
def construct_imports(constants: list[ShaderConstant], constant_binding_idx: int) -> str:
# codegen the import block for this uniform (including binding? - which number?)
# could be part of the UniformArray class maybe?
# to be pasted near the top of the fragment shader code.
# alternatively: insert these in the ShadertoyInputs uniform?
# better yet: use push constants
# TODO: can you even import a uniform struct and then have these available as global?
# maybe I got to add them back in as #define name = constant.name or something

if not constants:
return ""

var_init_lines = []
var_mapping_lines = []
for const in constants:
var_init_lines.append(f"{const.shader_dtype} {const.name};")
var_mapping_lines.append(f"# define {const.name} const_input{constant_binding_idx}.{const.name}")

new_line = "\n" # pytest was complaining about having blackslash in an f-string
code_construct = f"""
uniform struct ConstantInput{constant_binding_idx} {{
{new_line.join(var_init_lines)}
}};
layout(binding = {constant_binding_idx}) uniform ConstantInput{constant_binding_idx} const_input{constant_binding_idx};
{new_line.join(var_mapping_lines)}
"""
# the identifier name includes the digit so common doesn't cause redefinition!
# TODO messed up indentation... textwrap.dedent?
return code_construct

def replace_constants(code: str, constants: list[ShaderConstant], constant_binding_idx: int) -> str:
"""
comment out existing constants and redefine them with uniform struct
"""
code_lines = code.splitlines()
for const in constants:
# comment out existing constants
code_lines[const.line_number] = f"// {code_lines[const.line_number]}"

constant_headers = construct_imports(constants, constant_binding_idx)
code_lines.insert(0, constant_headers)

return "\n".join(code_lines)


# imgui stuff
def update_gui():
# todo: look at exmaples nad largely copy nad paste, will be called in the draw_frame function I think.

pass


def gui(renderpasses: list["RenderPass"]):

Check failure on line 161 in wgpu_shadertoy/imgui.py

View workflow job for this annotation

GitHub Actions / Test Linting

Ruff (F821)

wgpu_shadertoy/imgui.py:161:29: F821 Undefined name `RenderPass`
ig.new_frame()
ig.set_next_window_pos((0, 0), ig.Cond_.appearing)
ig.set_next_window_size((0, 0), ig.Cond_.appearing) # auto size not wide enough with text :/
ig.begin("Shader constants", None)
ig.text('in-dev imgui overlay\n')

if ig.is_item_hovered():
ig.set_tooltip("TODO")

# maybe we should have a global main or utils.get_main()?
main = renderpasses[0].main

# TODO: avoid duplication, maybe common should be a renderpass instance (at least a little bit) - or we iterate through constants lists
if main._common_constants:
if ig.collapsing_header("Common Constants", flags=ig.TreeNodeFlags_.default_open):
for const in main._common_constants:
if const.shader_dtype == "float":
_, main._common_constants_data[const.name] = ig.drag_float(f"{const.name}", main._common_constants_data[const.name], v_speed=abs(const.value)*0.01)
elif const.shader_dtype == "int":
_, main._common_constants_data[const.name] = ig.drag_int(f"{const.name}", main._common_constants_data[const.name], v_speed=abs(const.value)*0.01)
if ig.is_item_hovered() and ig.is_mouse_clicked(ig.MouseButton_.right):
main._common_constants_data[const.name] = const.value

for rp in renderpasses: # TODO: most likely add common here?
constants = rp._constants
constants_data = rp._constants_data
if ig.collapsing_header(f"{rp} Constants", flags=ig.TreeNodeFlags_.default_open):
if hasattr(rp, "texture_front"): # isinstance(rp, BufferRenderPass)
# make this another toggle? or a whole 2nd UI?
front_view = rp.texture_front.create_view()
front_ref = rp.main._imgui_backend.register_texture(front_view)
Comment on lines +191 to +192

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, came cross here from pygfx/wgpu-py#729 (comment).
It’s generally better not to create and register new texture views every frame in the render loop. Instead, a global texture view should be registered and used during initialization, or a imgui.TextureRef can be registered and created only at the first time the GUI is rendered.
If it’s absolutely necessary to create and register new texture views each frame in the render loop, you must manually call the newly added unregister method in pygfx/wgpu-py#749 to release the old texture view resources.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cheers for the heads up,
the render target for the buffer passes is switching back and forth between two textures(maybe I should also keep both views...) so I will likely have to register both of them during initialization and figure out a way to select the correct one in the gui function or swap them around after the draw function too. Sorta is a little against the "intermediate mode" concept.

I will have a think since this whole branch needs a better pattern anyway, why too many if self._imgui

scale = 0.25 # TODO dynamic zoom via width?
# TODO: can we force a background? do we need to request additional view formats? -> ig.image_with_bg?
buf_img = ig.image(front_ref, (front_view.size[0]*scale, front_view.size[1]*scale), uv0=(0,1), uv1=(1,0))

Check failure on line 195 in wgpu_shadertoy/imgui.py

View workflow job for this annotation

GitHub Actions / Test Linting

Ruff (F841)

wgpu_shadertoy/imgui.py:195:17: F841 Local variable `buf_img` is assigned to but never used

# create the sliders? -> drag widget!
for const in constants:
if const.shader_dtype == "float":
_, constants_data[const.name] = ig.drag_float(f"{const.name}", constants_data[const.name], v_speed=abs(const.value)*0.01)
elif const.shader_dtype == "int":
_, constants_data[const.name] = ig.drag_int(f"{const.name}", constants_data[const.name], v_speed=abs(const.value)*0.01)
if ig.is_item_hovered() and ig.is_mouse_clicked(ig.MouseButton_.right):
constants_data[const.name] = const.value

ig.text("Right sliders/drag to reset")

# TODO: control the size of these headers to make the window as small as possible after they are collapsed!
ig.end()
ig.end_frame()
ig.render()
return ig.get_draw_data()

def get_backend(device, canvas, render_texture_format):
"""
copied from backend example, held here to avoid clutter in the main class
"""

# init imgui backend
ig.create_context()
imgui_backend = ImguiWgpuBackend(device, render_texture_format)
imgui_backend.io.display_size = canvas.get_logical_size()
imgui_backend.io.display_framebuffer_scale = (
canvas.get_pixel_ratio(),
canvas.get_pixel_ratio(),
)
return imgui_backend
Loading
Loading