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 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-01-24 20:28:53 -05:00
commit 41d551e6e1
4 changed files with 331 additions and 6 deletions

View file

@ -8,6 +8,7 @@
#include "McRFPy_API.h"
#include "PythonObjectCache.h"
#include "PyAlignment.h"
#include <iostream> // #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<unsigned int>(size.x),
static_cast<unsigned int>(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<sf::Shader>();
// 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),

View file

@ -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<sf::Shader> 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);