From 41d551e6e19b8e0e2b1c37c1250e3c3b05216678 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sat, 24 Jan 2026 20:28:53 -0500 Subject: [PATCH] Shader POC: Add shader_enabled property to UIFrame (#106) Proof of concept for shader support on UIFrame: - Add shader and shader_enabled members to UIFrame - Add initializeTestShader() with hardcoded wave/glow fragment shader - Add shader_enabled Python property for toggling - Apply shader when drawing RenderTexture sprite - Auto-update time uniform for animated effects Also fixes position corruption when toggling RenderTexture usage: - Standard rendering path now uses `position` as source of truth - Prevents box position from staying at (0,0) after texture render Test files: - tests/shader_poc_test.py: Visual test of 6 render variants - tests/shader_toggle_test.py: Regression test for position bug Co-Authored-By: Claude Opus 4.5 --- src/UIFrame.cpp | 108 +++++++++++++++++++++++++++-- src/UIFrame.h | 7 ++ tests/shader_poc_test.py | 132 ++++++++++++++++++++++++++++++++++++ tests/shader_toggle_test.py | 90 ++++++++++++++++++++++++ 4 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 tests/shader_poc_test.py create mode 100644 tests/shader_toggle_test.py diff --git a/src/UIFrame.cpp b/src/UIFrame.cpp index c624cd0..4779efc 100644 --- a/src/UIFrame.cpp +++ b/src/UIFrame.cpp @@ -8,6 +8,7 @@ #include "McRFPy_API.h" #include "PythonObjectCache.h" #include "PyAlignment.h" +#include // #106: for shader error output // UIDrawable methods now in UIBase.h UIDrawable* UIFrame::click_at(sf::Vector2f point) @@ -109,10 +110,11 @@ void UIFrame::render(sf::Vector2f offset, sf::RenderTarget& target) // TODO: Apply opacity when SFML supports it on shapes - // #144: Use RenderTexture for clipping OR texture caching + // #144: Use RenderTexture for clipping OR texture caching OR shaders // clip_children: requires texture for clipping effect (only when has children) // cache_subtree: uses texture for performance (always, even without children) - bool use_texture = (clip_children && !children->empty()) || cache_subtree; + // shader_enabled: requires texture for shader post-processing + bool use_texture = (clip_children && !children->empty()) || cache_subtree || shader_enabled; if (use_texture) { // Enable RenderTexture if not already enabled @@ -167,13 +169,24 @@ void UIFrame::render(sf::Vector2f offset, sf::RenderTarget& target) if (use_render_texture) { // Use `position` instead of box.getPosition() - box was set to (0,0) for texture rendering render_sprite.setPosition(offset + position); - target.draw(render_sprite); + + // #106 POC: Apply shader if enabled + if (shader_enabled && shader) { + // Update time uniform for animated effects + static sf::Clock shader_clock; + shader->setUniform("time", shader_clock.getElapsedTime().asSeconds()); + shader->setUniform("texture", sf::Shader::CurrentTexture); + target.draw(render_sprite, shader.get()); + } else { + target.draw(render_sprite); + } } } else { // Standard rendering without caching - box.move(offset); + // Restore box position from `position` - may have been set to (0,0) by previous texture render + box.setPosition(offset + position); target.draw(box); - box.move(-offset); + box.setPosition(position); // Restore to canonical position // Sort children by z_index if needed if (children_need_sort && !children->empty()) { @@ -185,7 +198,7 @@ void UIFrame::render(sf::Vector2f offset, sf::RenderTarget& target) } for (auto drawable : *children) { - drawable->render(offset + box.getPosition(), target); + drawable->render(offset + position, target); // Use `position` as source of truth } } } @@ -448,6 +461,88 @@ int UIFrame::set_cache_subtree(PyUIFrameObject* self, PyObject* value, void* clo return 0; } +// #106 - Shader POC: shader_enabled property +PyObject* UIFrame::get_shader_enabled(PyUIFrameObject* self, void* closure) +{ + return PyBool_FromLong(self->data->shader_enabled); +} + +int UIFrame::set_shader_enabled(PyUIFrameObject* self, PyObject* value, void* closure) +{ + if (!PyBool_Check(value)) { + PyErr_SetString(PyExc_TypeError, "shader_enabled must be a boolean"); + return -1; + } + + bool new_shader = PyObject_IsTrue(value); + if (new_shader != self->data->shader_enabled) { + self->data->shader_enabled = new_shader; + + if (new_shader) { + // Initialize the test shader if not already done + if (!self->data->shader) { + self->data->initializeTestShader(); + } + // Shader requires RenderTexture - enable it + auto size = self->data->box.getSize(); + if (size.x > 0 && size.y > 0) { + self->data->enableRenderTexture(static_cast(size.x), + static_cast(size.y)); + } + } + // Note: we don't disable RenderTexture when shader disabled - + // clip_children or cache_subtree may still need it + + self->data->markDirty(); + } + + return 0; +} + +// #106 - Initialize test shader (hardcoded glow/brightness effect) +void UIFrame::initializeTestShader() +{ + // Check if shaders are available + if (!sf::Shader::isAvailable()) { + std::cerr << "Shaders are not available on this system!" << std::endl; + return; + } + + shader = std::make_unique(); + + // Simple color inversion + wave distortion shader for POC + // This makes it obvious the shader is working + const std::string fragmentShader = R"( + uniform sampler2D texture; + uniform float time; + + void main() { + vec2 uv = gl_TexCoord[0].xy; + + // Subtle wave distortion based on time + uv.x += sin(uv.y * 10.0 + time * 2.0) * 0.01; + uv.y += cos(uv.x * 10.0 + time * 2.0) * 0.01; + + vec4 color = texture2D(texture, uv); + + // Glow effect: boost brightness and add slight color shift + float glow = 0.2 + 0.1 * sin(time * 3.0); + color.rgb = color.rgb * (1.0 + glow); + + // Slight hue shift for visual interest + color.r += 0.1 * sin(time); + color.b += 0.1 * cos(time); + + gl_FragColor = color; + } + )"; + + if (!shader->loadFromMemory(fragmentShader, sf::Shader::Fragment)) { + std::cerr << "Failed to load test shader!" << std::endl; + shader.reset(); + } +} + // Define the PyObjectType alias for the macros typedef PyUIFrameObject PyObjectType; @@ -486,6 +581,7 @@ PyGetSetDef UIFrame::getsetters[] = { {"grid_size", (getter)UIDrawable::get_grid_size, (setter)UIDrawable::set_grid_size, "Size in grid tile coordinates (only when parent is Grid)", (void*)PyObjectsEnum::UIFRAME}, {"clip_children", (getter)UIFrame::get_clip_children, (setter)UIFrame::set_clip_children, "Whether to clip children to frame bounds", NULL}, {"cache_subtree", (getter)UIFrame::get_cache_subtree, (setter)UIFrame::set_cache_subtree, "#144: Cache subtree rendering to texture for performance", NULL}, + {"shader_enabled", (getter)UIFrame::get_shader_enabled, (setter)UIFrame::set_shader_enabled, "#106 POC: Enable test shader effect", NULL}, UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIFRAME), UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UIFRAME), diff --git a/src/UIFrame.h b/src/UIFrame.h index 772a22a..28b72fb 100644 --- a/src/UIFrame.h +++ b/src/UIFrame.h @@ -32,6 +32,11 @@ public: bool children_need_sort = true; // Dirty flag for z_index sorting optimization bool clip_children = false; // Whether to clip children to frame bounds bool cache_subtree = false; // #144: Whether to cache subtree rendering to texture + + // Shader POC (#106) + std::unique_ptr shader; + bool shader_enabled = false; + void initializeTestShader(); // Load hardcoded test shader void render(sf::Vector2f, sf::RenderTarget&) override final; void move(sf::Vector2f); PyObjectsEnum derived_type() override final; @@ -55,6 +60,8 @@ public: static int set_clip_children(PyUIFrameObject* self, PyObject* value, void* closure); static PyObject* get_cache_subtree(PyUIFrameObject* self, void* closure); static int set_cache_subtree(PyUIFrameObject* self, PyObject* value, void* closure); + static PyObject* get_shader_enabled(PyUIFrameObject* self, void* closure); + static int set_shader_enabled(PyUIFrameObject* self, PyObject* value, void* closure); static PyGetSetDef getsetters[]; static PyObject* repr(PyUIFrameObject* self); static int init(PyUIFrameObject* self, PyObject* args, PyObject* kwds); diff --git a/tests/shader_poc_test.py b/tests/shader_poc_test.py new file mode 100644 index 0000000..4d869ab --- /dev/null +++ b/tests/shader_poc_test.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +""" +Shader POC Test - Issue #106 + +Tests 6 render variants: +1. Basic frame (no special flags) +2. clip_children=True +3. cache_subtree=True +4. Basic + shader_enabled +5. clip_children + shader_enabled +6. cache_subtree + shader_enabled + +The shader applies a wave distortion + glow effect. +Shader-enabled frames should show visible animation. +""" +import mcrfpy +import sys + +# Create test scene +scene = mcrfpy.Scene("shader_test") +mcrfpy.current_scene = scene +ui = scene.children + +# Create a background +bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=(30, 30, 40, 255)) +ui.append(bg) + +# Helper to create a test frame with children +def create_test_frame(x, y, label, clip=False, cache=False, shader=False): + """Create a frame with some child content for testing.""" + frame = mcrfpy.Frame( + pos=(x, y), + size=(200, 200), + fill_color=(60, 80, 120, 255), + outline_color=(200, 200, 255, 255), + outline=2.0, + clip_children=clip, + cache_subtree=cache + ) + + # Add some child content + title = mcrfpy.Caption(text=label, pos=(5, 5), font_size=12) + title.fill_color = (255, 255, 200, 255) + frame.children.append(title) + + # Add a sprite or shape inside + inner = mcrfpy.Frame( + pos=(20, 35), + size=(110, 60), + fill_color=(100, 150, 200, 255), + outline_color=(255, 255, 255, 200), + outline=1.0 + ) + frame.children.append(inner) + + # Add text inside inner frame + inner_text = mcrfpy.Caption(text="Content", pos=(25, 20), font_size=14) + inner_text.fill_color = (255, 255, 255, 255) + inner.children.append(inner_text) + + # Enable shader if requested + if shader: + frame.shader_enabled = True + + return frame + +# Row 1: Without shader +y1 = 50 +title1 = mcrfpy.Caption(text="Without Shader:", pos=(20, y1 - 30), font_size=16) +title1.fill_color = (255, 255, 100, 255) +ui.append(title1) + +# 1. Basic (no flags) +basic = create_test_frame(50, y1, "Basic", clip=False, cache=False, shader=False) +ui.append(basic) + +# 2. clip_children +clipped = create_test_frame(300, y1, "clip_children", clip=True, cache=False, shader=False) +ui.append(clipped) + +# 3. cache_subtree +cached = create_test_frame(550, y1, "cache_subtree", clip=False, cache=True, shader=False) +ui.append(cached) + +# Row 2: With shader +y2 = 300 +title2 = mcrfpy.Caption(text="With Shader (should animate):", pos=(20, y2 - 30), font_size=16) +title2.fill_color = (255, 255, 100, 255) +ui.append(title2) + +# 4. Basic + shader +basic_shader = create_test_frame(50, y2, "Basic+Shader", clip=False, cache=False, shader=True) +ui.append(basic_shader) + +# 5. clip_children + shader +clipped_shader = create_test_frame(300, y2, "clip+Shader", clip=True, cache=False, shader=True) +ui.append(clipped_shader) + +# 6. cache_subtree + shader +cached_shader = create_test_frame(550, y2, "cache+Shader", clip=False, cache=True, shader=True) +ui.append(cached_shader) + +# Add instructions +#instructions = mcrfpy.Caption( +# text="Press Q or Escape to quit. Bottom row should show animated wave/glow effects.", +# pos=(20, 400), +# font_size=14 +#) +#instructions.fill_color = (180, 180, 180, 255) +#ui.append(instructions) + +# Debug info +#debug_info = mcrfpy.Caption( +# text=f"Frames created: 6 variants (3 without shader, 3 with shader)", +# pos=(20, 430), +# font_size=12 +#) +#debug_info.fill_color = (120, 120, 120, 255) +#ui.append(debug_info) + +# Keyboard handler +def on_key(key, state): + if state == "start" and key in ("Q", "Escape"): + print("PASS: Shader POC test complete - exiting") + sys.exit(0) + +scene.on_key = on_key + +print("Shader POC Test running...") +print("- Top row: No shader (static)") +print("- Bottom row: Shader enabled (should animate with wave/glow)") +print("Press Q or Escape to quit") diff --git a/tests/shader_toggle_test.py b/tests/shader_toggle_test.py new file mode 100644 index 0000000..e87a51f --- /dev/null +++ b/tests/shader_toggle_test.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +Shader Toggle Test - Regression test for position corruption bug + +Tests that toggling shader_enabled on and off does not corrupt frame position. +This was a bug similar to #223 where box.setPosition(0,0) during texture +rendering was never restored when switching back to standard rendering. +""" +import mcrfpy +import sys + +scene = mcrfpy.Scene("toggle_test") +mcrfpy.current_scene = scene +ui = scene.children + +# Background +bg = mcrfpy.Frame(pos=(0, 0), size=(800, 600), fill_color=(20, 20, 30, 255)) +ui.append(bg) + +# Create test frame at a specific position +test_frame = mcrfpy.Frame( + pos=(200, 200), + size=(150, 100), + fill_color=(80, 120, 180, 255), + outline_color=(255, 255, 255, 255), + outline=3.0 +) +label = mcrfpy.Caption(text="Test Frame", pos=(10, 10), font_size=14) +label.fill_color = (255, 255, 255, 255) +test_frame.children.append(label) +ui.append(test_frame) + +# Status display +status = mcrfpy.Caption(text="Status: Initial", pos=(20, 20), font_size=16) +status.fill_color = (255, 255, 100, 255) +ui.append(status) + +pos_display = mcrfpy.Caption(text="", pos=(20, 50), font_size=14) +pos_display.fill_color = (200, 200, 200, 255) +ui.append(pos_display) + +instructions = mcrfpy.Caption( + text="Press 1: Enable shader | 2: Disable shader | Q: Quit", + pos=(20, 550), font_size=14 +) +instructions.fill_color = (150, 150, 150, 255) +ui.append(instructions) + +def update_display(): + pos_display.text = f"Position: ({test_frame.x}, {test_frame.y}) | Shader: {test_frame.shader_enabled}" + +update_display() + +test_count = 0 + +def on_key(key, state): + global test_count + if state != "start": + return + + if key == "Num1" or key == "1": + test_frame.shader_enabled = True + status.text = "Status: Shader ENABLED" + update_display() + test_count += 1 + elif key == "Num2" or key == "2": + test_frame.shader_enabled = False + status.text = "Status: Shader DISABLED" + update_display() + test_count += 1 + + # Check if position is still correct + if test_frame.x != 200 or test_frame.y != 200: + status.text = f"BUG! Position corrupted to ({test_frame.x}, {test_frame.y})" + status.fill_color = (255, 100, 100, 255) + else: + status.text = "Status: Shader DISABLED - Position OK!" + status.fill_color = (100, 255, 100, 255) + elif key in ("Q", "Escape"): + if test_frame.x == 200 and test_frame.y == 200: + print(f"PASS: Position remained correct after {test_count} toggles") + else: + print(f"FAIL: Position corrupted to ({test_frame.x}, {test_frame.y})") + sys.exit(0) + +scene.on_key = on_key + +print("Shader Toggle Test") +print("Press 1 to enable shader, 2 to disable, Q to quit") +print("Frame should stay at (200, 200) regardless of shader state")