From 28b6d0940497d38bc1b1829f4fb7266a54636ee6 Mon Sep 17 00:00:00 2001 From: skal Date: Tue, 10 Feb 2026 18:24:34 +0100 Subject: refactor: Improve convert_shadertoy.py to generate compile-ready code Major improvements to reduce manual code changes after conversion: **Scene vs Post-Process Detection:** - Added --post-process flag (default: scene effect) - Scene effects: Simple pattern like HeptagonEffect (no texture input) - Post-process effects: Uses PostProcessEffect base class **Generated Code Now Compiles As-Is:** - Scene: Uses gpu_create_render_pass() helper - Post-process: Uses create_post_process_pipeline() helper - No manual Effect base class rewrites needed - Correct shader bindings for each type **Improved WGSL Conversion:** - Better mainImage extraction and conversion - Proper fragCoord -> p.xy mapping - Handles iResolution/iTime -> uniforms automatically - Fixed return statements (fragColor = ... -> return ...) - Preserves helper functions from original shader **Better Instructions:** - Shows exact asset.txt format with SHADER_ prefix - Includes shader declaration/definition steps - Indicates correct test list (scene_effects vs post_process_effects) **Example:** ```bash ./tools/shadertoy/convert_shadertoy.py shader.txt MyEffect # Generates compile-ready scene effect ./tools/shadertoy/convert_shadertoy.py blur.txt Blur --post-process # Generates compile-ready post-process effect ``` Co-Authored-By: Claude Sonnet 4.5 --- tools/shadertoy/convert_shadertoy.py | 380 ++++++++++++++++------------------- 1 file changed, 177 insertions(+), 203 deletions(-) diff --git a/tools/shadertoy/convert_shadertoy.py b/tools/shadertoy/convert_shadertoy.py index 29eca1d..48201bc 100755 --- a/tools/shadertoy/convert_shadertoy.py +++ b/tools/shadertoy/convert_shadertoy.py @@ -47,10 +47,28 @@ def to_camel_case(name): def convert_shadertoy_to_wgsl(shader_code): """Basic ShaderToy to WGSL conversion.""" - # Replace common ShaderToy uniforms + # Extract mainImage first + main_match = re.search(r'void\s+mainImage\s*\([^)]+\)\s*\{(.*)\}', shader_code, re.DOTALL) + if main_match: + main_body = main_match.group(1).strip() + helpers = shader_code[:main_match.start()] + else: + main_body = "" + helpers = shader_code + + # Replace common ShaderToy defines conversions = [ - (r'\biResolution\b', 'uniforms.resolution'), + (r'#define\s+TIME\s+iTime', ''), + (r'#define\s+RESOLUTION\s+iResolution', ''), + (r'#define\s+PI\s+[\d.]+', 'const PI: f32 = 3.141592654;'), + (r'#define\s+TAU\s+\([^)]+\)', 'const TAU: f32 = 6.283185307;'), + (r'#define\s+ROT\(a\)\s+mat2\([^)]+\)', ''), # Will be converted to function + + # Common ShaderToy uniforms + (r'\bTIME\b', 'uniforms.time'), (r'\biTime\b', 'uniforms.time'), + (r'\bRESOLUTION\b', 'uniforms.resolution'), + (r'\biResolution\b', 'uniforms.resolution'), (r'\bfragCoord\b', 'p.xy'), # Type conversions @@ -62,36 +80,49 @@ def convert_shadertoy_to_wgsl(shader_code): (r'\bmat3\b', 'mat3x3'), (r'\bmat4\b', 'mat4x4'), - # Function declarations - (r'\bfloat\s+(\w+)\s*\(', r'fn \1('), - (r'\bvec2\s+(\w+)\s*\(', r'fn \1('), - (r'\bvec3\s+(\w+)\s*\(', r'fn \1('), - (r'\bvec4\s+(\w+)\s*\(', r'fn \1('), + # Function declarations (preserve return type context) + (r'\bf32\s+(\w+)\s*\(', r'fn \1('), + (r'\bvec2\s+(\w+)\s*\(', r'fn \1('), + (r'\bvec3\s+(\w+)\s*\(', r'fn \1('), + (r'\bvec4\s+(\w+)\s*\(', r'fn \1('), + (r'\bvoid\s+(\w+)\s*\(', r'fn \1('), + + # Const declarations + (r'\bconst\s+f32\s+(\w+)\s*=', r'const \1: f32 ='), + (r'\bconst\s+vec2\s+(\w+)\s*=', r'const \1 ='), + (r'\bconst\s+vec3\s+(\w+)\s*=', r'const \1 ='), + (r'\bconst\s+vec4\s+(\w+)\s*=', r'const \1 ='), + + # Function calls that need fixing + (r'\bfract\s*\(', 'fract('), + (r'\bmod\s*\(([^,]+),\s*([^)]+)\)', r'(\1 % \2)'), + ] - # Function parameters (simple cases) - (r'\(\s*vec2\s+(\w+)\s*\)', r'(\1: vec2)'), - (r'\(\s*vec3\s+(\w+)\s*\)', r'(\1: vec3)'), - (r'\(\s*float\s+(\w+)\s*\)', r'(\1: f32)'), + converted_helpers = helpers + for pattern, replacement in conversions: + converted_helpers = re.sub(pattern, replacement, converted_helpers) - # Return types - (r'\)\s*{', r') -> f32 {'), # Assume f32 return, may need manual fix + # Convert mainImage body + converted_main = main_body + for pattern, replacement in conversions: + converted_main = re.sub(pattern, replacement, converted_main) - # Texture sampling - (r'\btexture\s*\(\s*iChannel0\s*,', 'textureSample(txt, smplr,'), - (r'\btexture\s*\(\s*iChannel1\s*,', 'textureSample(txt, smplr,'), - ] + # Fix fragColor assignments -> returns + converted_main = re.sub(r'\bfragColor\s*=\s*([^;]+);', r'return \1;', converted_main) - converted = shader_code - for pattern, replacement in conversions: - converted = re.sub(pattern, replacement, converted) + # Indent main body + indented_main = '\n'.join(' ' + line if line.strip() else '' for line in converted_main.split('\n')) - # Convert variable declarations - converted = re.sub(r'\bfloat\s+(\w+)\s*=', r'var \1: f32 =', converted) - converted = re.sub(r'\bvec2\s+(\w+)\s*=', r'var \1: vec2 =', converted) - converted = re.sub(r'\bvec3\s+(\w+)\s*=', r'var \1: vec3 =', converted) - converted = re.sub(r'\bvec4\s+(\w+)\s*=', r'var \1: vec4 =', converted) + # Build fragment function + fragment = f"""@fragment fn fs_main(@builtin(position) p: vec4) -> @location(0) vec4 {{ + let q = p.xy / uniforms.resolution; + var coord = -1.0 + 2.0 * q; + coord.x *= uniforms.resolution.x / uniforms.resolution.y; - return converted +{indented_main} +}}""" + + return converted_helpers + '\n\n' + fragment def extract_main_image(shader_code): """Extract mainImage function body from ShaderToy code.""" @@ -103,14 +134,14 @@ def extract_main_image(shader_code): # If no mainImage found, return whole shader return shader_code -def generate_header(effect_name): +def generate_header(effect_name, is_post_process=False): """Generate .h file content.""" class_name = f"{effect_name}Effect" - snake_name = to_snake_case(effect_name) upper_name = to_upper_snake_case(effect_name) - return f"""// This file is part of the 64k demo project. -// {effect_name} effect - ShaderToy conversion + if is_post_process: + return f"""// This file is part of the 64k demo project. +// {effect_name} effect - ShaderToy conversion (post-process) // Generated by convert_shadertoy.py #ifndef {upper_name}_EFFECT_H_ @@ -118,193 +149,136 @@ def generate_header(effect_name): #include "gpu/effect.h" #include "gpu/effects/post_process_helper.h" -#include "gpu/uniform_helper.h" -class {class_name} : public Effect {{ +class {class_name} : public PostProcessEffect {{ public: {class_name}(const GpuContext& ctx); - ~{class_name}() override; + void render(WGPURenderPassEncoder pass, float time, float beat, + float intensity, float aspect_ratio) override; + void update_bind_group(WGPUTextureView input_view) override; +}}; - void init(MainSequence* demo) override; +#endif /* {upper_name}_EFFECT_H_ */ +""" + else: + # Scene effect (simpler, like HeptagonEffect) + return f"""// This file is part of the 64k demo project. +// {effect_name} effect - ShaderToy conversion (scene) +// Generated by convert_shadertoy.py + +#ifndef {upper_name}_EFFECT_H_ +#define {upper_name}_EFFECT_H_ + +#include "gpu/effect.h" + +class {class_name} : public Effect {{ + public: + {class_name}(const GpuContext& ctx); void render(WGPURenderPassEncoder pass, float time, float beat, float intensity, float aspect_ratio) override; private: - // Effect-specific parameters - adjust as needed - struct {effect_name}Params {{ - float param1; - float param2; - float _pad[2]; - }}; - static_assert(sizeof({effect_name}Params) == 16, - "{effect_name}Params must be 16 bytes for WGSL alignment"); - - MainSequence* demo_ = nullptr; - WGPURenderPipeline pipeline_ = nullptr; - WGPUBindGroup bind_group_ = nullptr; - WGPUSampler sampler_ = nullptr; - UniformBuffer<{effect_name}Params> params_; + RenderPass pass_; }}; #endif /* {upper_name}_EFFECT_H_ */ """ -def generate_implementation(effect_name): +def generate_implementation(effect_name, is_post_process=False): """Generate .cc file content.""" class_name = f"{effect_name}Effect" snake_name = to_snake_case(effect_name) - upper_name = to_upper_snake_case(effect_name) - return f"""// This file is part of the 64k demo project. -// {effect_name} effect implementation - ShaderToy conversion + if is_post_process: + return f"""// This file is part of the 64k demo project. +// {effect_name} effect - ShaderToy conversion (post-process) // Generated by convert_shadertoy.py -#include "gpu/effects/{snake_name}_effect.h" -#include "gpu/effects/shader_composer.h" -#include "generated/assets.h" - -{class_name}::{class_name}(const GpuContext& ctx) : Effect(ctx) {{ -}} - -{class_name}::~{class_name}() {{ - if (sampler_) - wgpuSamplerRelease(sampler_); - if (bind_group_) - wgpuBindGroupRelease(bind_group_); - if (pipeline_) - wgpuRenderPipelineRelease(pipeline_); -}} - -void {class_name}::init(MainSequence* demo) {{ - demo_ = demo; - params_.init(ctx_.device); - - WGPUSamplerDescriptor sampler_desc = {{}}; - sampler_desc.addressModeU = WGPUAddressMode_ClampToEdge; - sampler_desc.addressModeV = WGPUAddressMode_ClampToEdge; - sampler_desc.magFilter = WGPUFilterMode_Linear; - sampler_desc.minFilter = WGPUFilterMode_Linear; - sampler_desc.mipmapFilter = WGPUMipmapFilterMode_Linear; - sampler_desc.maxAnisotropy = 1; - sampler_ = wgpuDeviceCreateSampler(ctx_.device, &sampler_desc); - - size_t shader_size; - const char* shader_code = (const char*)GetAsset( - AssetId::ASSET_{upper_name}_SHADER, &shader_size); - - std::string composed = ShaderComposer::Get().Compose({{}}, shader_code); - - WGPUShaderSourceWGSL wgsl = {{}}; - wgsl.chain.sType = WGPUSType_ShaderSourceWGSL; - wgsl.code = str_view(composed.c_str()); - - WGPUShaderModuleDescriptor desc = {{}}; - desc.nextInChain = &wgsl.chain; - WGPUShaderModule module = wgpuDeviceCreateShaderModule(ctx_.device, &desc); - - const WGPUColorTargetState target = {{ - .format = ctx_.format, - .writeMask = WGPUColorWriteMask_All, - }}; - WGPUFragmentState frag = {{}}; - frag.module = module; - frag.entryPoint = str_view("fs_main"); - frag.targetCount = 1; - frag.targets = ⌖ - - const WGPUDepthStencilState depth_stencil = {{ - .format = WGPUTextureFormat_Depth24Plus, - .depthWriteEnabled = WGPUOptionalBool_False, - .depthCompare = WGPUCompareFunction_Always, - }}; +#include "gpu/demo_effects.h" +#include "gpu/effects/post_process_helper.h" +#include "gpu/gpu.h" - WGPURenderPipelineDescriptor pipeline_desc = {{}}; - pipeline_desc.label = label_view("{class_name}"); - pipeline_desc.vertex.module = module; - pipeline_desc.vertex.entryPoint = str_view("vs_main"); - pipeline_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList; - pipeline_desc.primitive.cullMode = WGPUCullMode_None; - pipeline_desc.depthStencil = &depth_stencil; - pipeline_desc.multisample.count = 1; - pipeline_desc.multisample.mask = 0xFFFFFFFF; - pipeline_desc.fragment = &frag; - - pipeline_ = wgpuDeviceCreateRenderPipeline(ctx_.device, &pipeline_desc); - wgpuShaderModuleRelease(module); - - WGPUTextureView prev_view = demo_->get_prev_texture_view(); - const WGPUBindGroupEntry entries[] = {{ - {{.binding = 0, .sampler = sampler_}}, - {{.binding = 1, .textureView = prev_view}}, - {{.binding = 2, - .buffer = uniforms_.get().buffer, - .size = sizeof(CommonPostProcessUniforms)}}, - {{.binding = 3, - .buffer = params_.get().buffer, - .size = sizeof({effect_name}Params)}}, - }}; - const WGPUBindGroupDescriptor bg_desc = {{ - .layout = wgpuRenderPipelineGetBindGroupLayout(pipeline_, 0), - .entryCount = 4, - .entries = entries, - }}; - bind_group_ = wgpuDeviceCreateBindGroup(ctx_.device, &bg_desc); +{class_name}::{class_name}(const GpuContext& ctx) : PostProcessEffect(ctx) {{ + pipeline_ = create_post_process_pipeline(ctx_.device, ctx_.format, {snake_name}_shader_wgsl); }} -void {class_name}::render(WGPURenderPassEncoder pass, float time, - float beat, float intensity, float aspect_ratio) {{ - const CommonPostProcessUniforms uniforms = {{ - .resolution = {{static_cast(width_), static_cast(height_)}}, +void {class_name}::render(WGPURenderPassEncoder pass, float time, float beat, + float intensity, float aspect_ratio) {{ + const CommonPostProcessUniforms u = {{ + .resolution = {{(float)width_, (float)height_}}, + ._pad = {{0.0f, 0.0f}}, .aspect_ratio = aspect_ratio, .time = time, .beat = beat, .audio_intensity = intensity, }}; - uniforms_.update(ctx_.queue, uniforms); - - const {effect_name}Params params = {{ - .param1 = 1.0f, - .param2 = beat, - }}; - params_.update(ctx_.queue, params); + uniforms_.update(ctx_.queue, u); wgpuRenderPassEncoderSetPipeline(pass, pipeline_); wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 0, nullptr); wgpuRenderPassEncoderDraw(pass, 3, 1, 0, 0); }} + +void {class_name}::update_bind_group(WGPUTextureView input_view) {{ + pp_update_bind_group(ctx_.device, pipeline_, &bind_group_, input_view, uniforms_.get()); +}} """ + else: + # Scene effect (simpler pattern like HeptagonEffect) + return f"""// This file is part of the 64k demo project. +// {effect_name} effect - ShaderToy conversion (scene) +// Generated by convert_shadertoy.py -def generate_shader(effect_name, shadertoy_code): - """Generate .wgsl file content.""" - # Extract mainImage body - main_body = extract_main_image(shadertoy_code) +#include "gpu/demo_effects.h" +#include "gpu/gpu.h" - # Convert to WGSL - converted = convert_shadertoy_to_wgsl(main_body) +{class_name}::{class_name}(const GpuContext& ctx) : Effect(ctx) {{ + ResourceBinding bindings[] = {{{{uniforms_.get(), WGPUBufferBindingType_Uniform}}}}; + pass_ = gpu_create_render_pass(ctx_.device, ctx_.format, {snake_name}_shader_wgsl, + bindings, 1); + pass_.vertex_count = 3; +}} - # Indent the converted code - indented = '\n'.join(' ' + line if line.strip() else '' - for line in converted.split('\n')) +void {class_name}::render(WGPURenderPassEncoder pass, float t, float b, + float i, float a) {{ + CommonPostProcessUniforms u = {{ + .resolution = {{(float)width_, (float)height_}}, + ._pad = {{0.0f, 0.0f}}, + .aspect_ratio = a, + .time = t, + .beat = b, + .audio_intensity = i, + }}; + uniforms_.update(ctx_.queue, u); + wgpuRenderPassEncoderSetPipeline(pass, pass_.pipeline); + wgpuRenderPassEncoderSetBindGroup(pass, 0, pass_.bind_group, 0, nullptr); + wgpuRenderPassEncoderDraw(pass, pass_.vertex_count, 1, 0, 0); +}} +""" - return f"""// {effect_name} effect shader - ShaderToy conversion -// Generated by convert_shadertoy.py -// TODO: Review and fix conversion issues +def generate_shader(effect_name, shadertoy_code, is_post_process=False): + """Generate .wgsl file content.""" + # Convert to WGSL (full shader, not just mainImage) + converted = convert_shadertoy_to_wgsl(shadertoy_code) -@group(0) @binding(0) var smplr: sampler; + if is_post_process: + bindings = """@group(0) @binding(0) var smplr: sampler; @group(0) @binding(1) var txt: texture_2d; #include "common_uniforms" -@group(0) @binding(2) var uniforms: CommonUniforms; +@group(0) @binding(2) var uniforms: CommonUniforms;""" + else: + # Scene effect - only uniforms, no texture input + bindings = """#include "common_uniforms" -struct {effect_name}Params {{ - param1: f32, - param2: f32, - _pad0: f32, - _pad1: f32, -}} +@group(0) @binding(0) var uniforms: CommonUniforms;""" + + return f"""// {effect_name} effect shader - ShaderToy conversion +// Generated by convert_shadertoy.py +// NOTE: Manual review recommended - conversion is basic -@group(0) @binding(3) var params: {effect_name}Params; +{bindings} @vertex fn vs_main(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 {{ var pos = array, 3>( @@ -315,29 +289,20 @@ struct {effect_name}Params {{ return vec4(pos[i], 0.0, 1.0); }} -// ============================================================================ -// CONVERTED SHADERTOY CODE (may need manual fixes) -// ============================================================================ -// Original helper functions (if any) appear above mainImage conversion - -@fragment fn fs_main(@builtin(position) p: vec4) -> @location(0) vec4 {{ - // Converted from mainImage() -{indented} - - // TODO: Fix the return statement if needed - // ShaderToy used: fragColor = ... - // WGSL needs: return vec4(...); -}} +{converted} """ def main(): if len(sys.argv) < 3: - print("Usage: convert_shadertoy.py ") + print("Usage: convert_shadertoy.py [--post-process]") print() print("Examples:") print(" ./tools/shadertoy/convert_shadertoy.py tunnel.txt Tunnel") - print(" ./tools/shadertoy/convert_shadertoy.py plasma.txt Plasma") - print(" ./tools/shadertoy/convert_shadertoy.py tools/shadertoy/example.txt Rainbow") + print(" ./tools/shadertoy/convert_shadertoy.py blur.txt Blur --post-process") + print() + print("Options:") + print(" --post-process Generate post-process effect (operates on previous frame)") + print(" Default: scene effect (renders geometry)") print() print("This will generate:") print(" src/gpu/effects/_effect.h") @@ -347,6 +312,7 @@ def main(): shader_file = sys.argv[1] effect_name = sys.argv[2] + is_post_process = '--post-process' in sys.argv # Ensure effect name is CamelCase if '_' in effect_name: @@ -362,6 +328,7 @@ def main(): # Generate file names snake_name = to_snake_case(effect_name) + upper_name = to_upper_snake_case(effect_name) # Script is in tools/shadertoy/, so go up two levels to repo root repo_root = Path(__file__).parent.parent.parent @@ -377,36 +344,43 @@ def main(): print() # Write files - header_path.write_text(generate_header(effect_name)) - impl_path.write_text(generate_implementation(effect_name)) - shader_path.write_text(generate_shader(effect_name, shadertoy_code)) + header_path.write_text(generate_header(effect_name, is_post_process)) + impl_path.write_text(generate_implementation(effect_name, is_post_process)) + shader_path.write_text(generate_shader(effect_name, shadertoy_code, is_post_process)) - print("✓ Files generated") + effect_type = "post-process" if is_post_process else "scene" + print(f"✓ Files generated ({effect_type} effect)") print() print("Next steps (see doc/EFFECT_WORKFLOW.md for details):") print() print("1. Add shader to workspaces/main/assets.txt:") - print(f" shaders/{snake_name}.wgsl") + print(f" SHADER_{upper_name}, NONE, shaders/{snake_name}.wgsl, \"{effect_name} effect\"") + print() + print() + print("2. Add shader declaration to src/gpu/effects/shaders.h:") + print(f" extern const char* {snake_name}_shader_wgsl;") + print() + print("3. Add shader definition to src/gpu/effects/shaders.cc:") + print(f" const char* {snake_name}_shader_wgsl = SafeGetAsset(AssetId::ASSET_SHADER_{upper_name});") print() - print("2. Include header in src/gpu/demo_effects.h:") + print("4. Include header in src/gpu/demo_effects.h:") print(f' #include "gpu/effects/{snake_name}_effect.h"') print() - print("3. Add to timeline in workspaces/main/timeline.seq:") + print("5. Add to timeline in workspaces/main/timeline.seq:") print(f" EFFECT + {effect_name}Effect 0.0 10.0") print() - print("4. Add to CMakeLists.txt GPU_SOURCES (both headless and normal mode):") + print("6. Add to CMakeLists.txt GPU_SOURCES (both headless and normal mode):") print(f" src/gpu/effects/{snake_name}_effect.cc") print() - print("5. Update src/tests/gpu/test_demo_effects.cc:") - print(f' - Add "{{{effect_name}Effect", std::make_shared<{effect_name}Effect>(fixture.ctx())}}" to appropriate list') - print(" - Use post_process_effects (lines 80-93) for post-process effects") - print(" - Use scene_effects (lines 125-137) for scene effects") + print("7. Update src/tests/gpu/test_demo_effects.cc:") + test_list = "post_process_effects" if is_post_process else "scene_effects" + print(f' - Add "{{{effect_name}Effect", std::make_shared<{effect_name}Effect>(fixture.ctx())}}" to {test_list} list') print() - print("6. Build and test:") + print("8. Build and test:") print(" cmake --build build -j4") print(" ./build/demo64k") print() - print("Note: WGSL conversion is basic - review and fix shader manually!") + print("Note: Review generated shader for const expression issues (normalize, etc)") if __name__ == '__main__': main() -- cgit v1.2.3