From abb3316ac195fe6c1b608a53cb07a62a67adf6a2 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 28 Nov 2025 21:44:33 -0500 Subject: [PATCH] feat: Add dirty flag and RenderTexture caching for Grid layers (closes #148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement per-layer dirty tracking and RenderTexture caching for ColorLayer and TileLayer. Each layer now maintains its own cached texture and only re-renders when content changes. Key changes: - Add dirty flag, cached_texture, and cached_sprite to GridLayer base - Implement renderToTexture() for both ColorLayer and TileLayer - Mark layers dirty on: set(), fill(), resize(), texture change - Viewport changes (center/zoom) just blit cached texture portion - Fallback to direct rendering if texture creation fails - Add regression test with performance benchmarks Expected performance improvement: Static layers render once, then viewport panning/zooming only requires texture blitting instead of re-rendering all cells. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/GridLayers.cpp | 245 +++++++++++++++--- src/GridLayers.h | 23 ++ .../regression/issue_148_layer_dirty_flags.py | 157 +++++++++++ 3 files changed, 389 insertions(+), 36 deletions(-) create mode 100644 tests/regression/issue_148_layer_dirty_flags.py diff --git a/src/GridLayers.cpp b/src/GridLayers.cpp index be67cad..33fe945 100644 --- a/src/GridLayers.cpp +++ b/src/GridLayers.cpp @@ -10,9 +10,50 @@ GridLayer::GridLayer(GridLayerType type, int z_index, int grid_x, int grid_y, UIGrid* parent) : type(type), z_index(z_index), grid_x(grid_x), grid_y(grid_y), - parent_grid(parent), visible(true) + parent_grid(parent), visible(true), + dirty(true), texture_initialized(false), + cached_cell_width(0), cached_cell_height(0) {} +void GridLayer::markDirty() { + dirty = true; +} + +void GridLayer::ensureTextureSize(int cell_width, int cell_height) { + // Check if we need to resize/create the texture + unsigned int required_width = grid_x * cell_width; + unsigned int required_height = grid_y * cell_height; + + // Maximum texture size limit (prevent excessive memory usage) + const unsigned int MAX_TEXTURE_SIZE = 4096; + if (required_width > MAX_TEXTURE_SIZE) required_width = MAX_TEXTURE_SIZE; + if (required_height > MAX_TEXTURE_SIZE) required_height = MAX_TEXTURE_SIZE; + + // Skip if already properly sized + if (texture_initialized && + cached_texture.getSize().x == required_width && + cached_texture.getSize().y == required_height && + cached_cell_width == cell_width && + cached_cell_height == cell_height) { + return; + } + + // Create or resize the texture (SFML uses .create() not .resize()) + if (!cached_texture.create(required_width, required_height)) { + // Creation failed - texture will remain uninitialized + texture_initialized = false; + return; + } + + cached_cell_width = cell_width; + cached_cell_height = cell_height; + texture_initialized = true; + dirty = true; // Force re-render after resize + + // Setup the sprite to use the texture + cached_sprite.setTexture(cached_texture.getTexture()); +} + // ============================================================================= // ColorLayer implementation // ============================================================================= @@ -32,6 +73,7 @@ const sf::Color& ColorLayer::at(int x, int y) const { void ColorLayer::fill(const sf::Color& color) { std::fill(colors.begin(), colors.end(), color); + markDirty(); // #148 - Mark for re-render } void ColorLayer::resize(int new_grid_x, int new_grid_y) { @@ -49,6 +91,37 @@ void ColorLayer::resize(int new_grid_x, int new_grid_y) { colors = std::move(new_colors); grid_x = new_grid_x; grid_y = new_grid_y; + + // #148 - Invalidate cached texture (will be resized on next render) + texture_initialized = false; + markDirty(); +} + +// #148 - Render all cells to cached texture (called when dirty) +void ColorLayer::renderToTexture(int cell_width, int cell_height) { + ensureTextureSize(cell_width, cell_height); + if (!texture_initialized) return; + + cached_texture.clear(sf::Color::Transparent); + + sf::RectangleShape rect; + rect.setSize(sf::Vector2f(cell_width, cell_height)); + rect.setOutlineThickness(0); + + // Render all cells to cached texture (no zoom - 1:1 pixel mapping) + for (int x = 0; x < grid_x; ++x) { + for (int y = 0; y < grid_y; ++y) { + const sf::Color& color = at(x, y); + if (color.a == 0) continue; // Skip fully transparent + + rect.setPosition(sf::Vector2f(x * cell_width, y * cell_height)); + rect.setFillColor(color); + cached_texture.draw(rect); + } + } + + cached_texture.display(); + dirty = false; } void ColorLayer::render(sf::RenderTarget& target, @@ -57,27 +130,61 @@ void ColorLayer::render(sf::RenderTarget& target, float zoom, int cell_width, int cell_height) { if (!visible) return; - sf::RectangleShape rect; - rect.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom)); - rect.setOutlineThickness(0); - - for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0); x < x_limit; ++x) { - for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0); y < y_limit; ++y) { - if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) continue; - - const sf::Color& color = at(x, y); - if (color.a == 0) continue; // Skip fully transparent - - auto pixel_pos = sf::Vector2f( - (x * cell_width - left_spritepixels) * zoom, - (y * cell_height - top_spritepixels) * zoom - ); - - rect.setPosition(pixel_pos); - rect.setFillColor(color); - target.draw(rect); - } + // #148 - Use cached texture rendering + // Re-render to texture only if dirty + if (dirty || !texture_initialized) { + renderToTexture(cell_width, cell_height); } + + if (!texture_initialized) { + // Fallback to direct rendering if texture creation failed + sf::RectangleShape rect; + rect.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom)); + rect.setOutlineThickness(0); + + for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0); x < x_limit; ++x) { + for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0); y < y_limit; ++y) { + if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) continue; + + const sf::Color& color = at(x, y); + if (color.a == 0) continue; + + auto pixel_pos = sf::Vector2f( + (x * cell_width - left_spritepixels) * zoom, + (y * cell_height - top_spritepixels) * zoom + ); + + rect.setPosition(pixel_pos); + rect.setFillColor(color); + target.draw(rect); + } + } + return; + } + + // Blit visible portion of cached texture with zoom applied + // Calculate source rectangle (unzoomed pixel coordinates in cached texture) + int src_left = std::max(0, (int)left_spritepixels); + int src_top = std::max(0, (int)top_spritepixels); + int src_width = std::min((int)cached_texture.getSize().x - src_left, + (int)((x_limit - left_edge + 2) * cell_width)); + int src_height = std::min((int)cached_texture.getSize().y - src_top, + (int)((y_limit - top_edge + 2) * cell_height)); + + if (src_width <= 0 || src_height <= 0) return; + + // Set texture rect for visible portion + cached_sprite.setTextureRect(sf::IntRect({src_left, src_top}, {src_width, src_height})); + + // Position in target (offset for partial cell visibility) + float dest_x = (src_left - left_spritepixels) * zoom; + float dest_y = (src_top - top_spritepixels) * zoom; + cached_sprite.setPosition(sf::Vector2f(dest_x, dest_y)); + + // Apply zoom via scale + cached_sprite.setScale(sf::Vector2f(zoom, zoom)); + + target.draw(cached_sprite); } // ============================================================================= @@ -101,6 +208,7 @@ int TileLayer::at(int x, int y) const { void TileLayer::fill(int tile_index) { std::fill(tiles.begin(), tiles.end(), tile_index); + markDirty(); // #148 - Mark for re-render } void TileLayer::resize(int new_grid_x, int new_grid_y) { @@ -118,6 +226,33 @@ void TileLayer::resize(int new_grid_x, int new_grid_y) { tiles = std::move(new_tiles); grid_x = new_grid_x; grid_y = new_grid_y; + + // #148 - Invalidate cached texture (will be resized on next render) + texture_initialized = false; + markDirty(); +} + +// #148 - Render all cells to cached texture (called when dirty) +void TileLayer::renderToTexture(int cell_width, int cell_height) { + ensureTextureSize(cell_width, cell_height); + if (!texture_initialized || !texture) return; + + cached_texture.clear(sf::Color::Transparent); + + // Render all tiles to cached texture (no zoom - 1:1 pixel mapping) + for (int x = 0; x < grid_x; ++x) { + for (int y = 0; y < grid_y; ++y) { + int tile_index = at(x, y); + if (tile_index < 0) continue; // No tile + + auto pixel_pos = sf::Vector2f(x * cell_width, y * cell_height); + sf::Sprite sprite = texture->sprite(tile_index, pixel_pos, sf::Vector2f(1.0f, 1.0f)); + cached_texture.draw(sprite); + } + } + + cached_texture.display(); + dirty = false; } void TileLayer::render(sf::RenderTarget& target, @@ -126,22 +261,56 @@ void TileLayer::render(sf::RenderTarget& target, float zoom, int cell_width, int cell_height) { if (!visible || !texture) return; - for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0); x < x_limit; ++x) { - for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0); y < y_limit; ++y) { - if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) continue; - - int tile_index = at(x, y); - if (tile_index < 0) continue; // No tile - - auto pixel_pos = sf::Vector2f( - (x * cell_width - left_spritepixels) * zoom, - (y * cell_height - top_spritepixels) * zoom - ); - - sf::Sprite sprite = texture->sprite(tile_index, pixel_pos, sf::Vector2f(zoom, zoom)); - target.draw(sprite); - } + // #148 - Use cached texture rendering + // Re-render to texture only if dirty + if (dirty || !texture_initialized) { + renderToTexture(cell_width, cell_height); } + + if (!texture_initialized) { + // Fallback to direct rendering if texture creation failed + for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0); x < x_limit; ++x) { + for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0); y < y_limit; ++y) { + if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) continue; + + int tile_index = at(x, y); + if (tile_index < 0) continue; + + auto pixel_pos = sf::Vector2f( + (x * cell_width - left_spritepixels) * zoom, + (y * cell_height - top_spritepixels) * zoom + ); + + sf::Sprite sprite = texture->sprite(tile_index, pixel_pos, sf::Vector2f(zoom, zoom)); + target.draw(sprite); + } + } + return; + } + + // Blit visible portion of cached texture with zoom applied + // Calculate source rectangle (unzoomed pixel coordinates in cached texture) + int src_left = std::max(0, (int)left_spritepixels); + int src_top = std::max(0, (int)top_spritepixels); + int src_width = std::min((int)cached_texture.getSize().x - src_left, + (int)((x_limit - left_edge + 2) * cell_width)); + int src_height = std::min((int)cached_texture.getSize().y - src_top, + (int)((y_limit - top_edge + 2) * cell_height)); + + if (src_width <= 0 || src_height <= 0) return; + + // Set texture rect for visible portion + cached_sprite.setTextureRect(sf::IntRect({src_left, src_top}, {src_width, src_height})); + + // Position in target (offset for partial cell visibility) + float dest_x = (src_left - left_spritepixels) * zoom; + float dest_y = (src_top - top_spritepixels) * zoom; + cached_sprite.setPosition(sf::Vector2f(dest_x, dest_y)); + + // Apply zoom via scale + cached_sprite.setScale(sf::Vector2f(zoom, zoom)); + + target.draw(cached_sprite); } // ============================================================================= @@ -273,6 +442,7 @@ PyObject* PyGridLayerAPI::ColorLayer_set(PyColorLayerObject* self, PyObject* arg Py_DECREF(color_type); self->data->at(x, y) = color; + self->data->markDirty(); // #148 - Mark for re-render Py_RETURN_NONE; } @@ -491,6 +661,7 @@ PyObject* PyGridLayerAPI::TileLayer_set(PyTileLayerObject* self, PyObject* args) } self->data->at(x, y) = index; + self->data->markDirty(); // #148 - Mark for re-render Py_RETURN_NONE; } @@ -578,6 +749,7 @@ int PyGridLayerAPI::TileLayer_set_texture(PyTileLayerObject* self, PyObject* val if (value == Py_None) { self->data->texture.reset(); + self->data->markDirty(); // #148 - Mark for re-render return 0; } @@ -596,6 +768,7 @@ int PyGridLayerAPI::TileLayer_set_texture(PyTileLayerObject* self, PyObject* val Py_DECREF(texture_type); self->data->texture = ((PyTextureObject*)value)->data; + self->data->markDirty(); // #148 - Mark for re-render return 0; } diff --git a/src/GridLayers.h b/src/GridLayers.h index 7aa5ef3..44531ae 100644 --- a/src/GridLayers.h +++ b/src/GridLayers.h @@ -28,10 +28,27 @@ public: UIGrid* parent_grid; // Parent grid reference bool visible; // Visibility flag + // #148 - Dirty flag and RenderTexture caching + bool dirty; // True if layer needs re-render + sf::RenderTexture cached_texture; // Cached layer content + sf::Sprite cached_sprite; // Sprite for blitting cached texture + bool texture_initialized; // True if RenderTexture has been created + int cached_cell_width, cached_cell_height; // Cell size used for cached texture + GridLayer(GridLayerType type, int z_index, int grid_x, int grid_y, UIGrid* parent); virtual ~GridLayer() = default; + // Mark layer as needing re-render + void markDirty(); + + // Ensure cached texture is properly sized for current grid dimensions + void ensureTextureSize(int cell_width, int cell_height); + + // Render the layer content to the cached texture (called when dirty) + virtual void renderToTexture(int cell_width, int cell_height) = 0; + // Render the layer to a RenderTarget with the given transformation parameters + // Uses cached texture if available, only re-renders when dirty virtual void render(sf::RenderTarget& target, float left_spritepixels, float top_spritepixels, int left_edge, int top_edge, int x_limit, int y_limit, @@ -55,6 +72,9 @@ public: // Fill entire layer with a color void fill(const sf::Color& color); + // #148 - Render all content to cached texture + void renderToTexture(int cell_width, int cell_height) override; + void render(sf::RenderTarget& target, float left_spritepixels, float top_spritepixels, int left_edge, int top_edge, int x_limit, int y_limit, @@ -79,6 +99,9 @@ public: // Fill entire layer with a tile index void fill(int tile_index); + // #148 - Render all content to cached texture + void renderToTexture(int cell_width, int cell_height) override; + void render(sf::RenderTarget& target, float left_spritepixels, float top_spritepixels, int left_edge, int top_edge, int x_limit, int y_limit, diff --git a/tests/regression/issue_148_layer_dirty_flags.py b/tests/regression/issue_148_layer_dirty_flags.py new file mode 100644 index 0000000..cc338da --- /dev/null +++ b/tests/regression/issue_148_layer_dirty_flags.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +""" +Regression test for issue #148: Grid Layer Dirty Flags and RenderTexture Caching + +Tests: +1. Dirty flag is initially set (layers start dirty) +2. Setting cell values marks layer dirty +3. Fill operation marks layer dirty +4. Texture change marks TileLayer dirty +5. Viewport changes (center/zoom) don't trigger re-render (static benchmark) +6. Performance improvement for static layers +""" +import mcrfpy +import sys +import time + +def run_test(runtime): + print("=" * 60) + print("Issue #148 Regression Test: Layer Dirty Flags and Caching") + print("=" * 60) + + # Create test scene + mcrfpy.createScene("test") + ui = mcrfpy.sceneUI("test") + texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16) + + # Create grid with larger size for performance testing + grid = mcrfpy.Grid(pos=(50, 50), size=(500, 400), grid_size=(50, 40), texture=texture) + ui.append(grid) + mcrfpy.setScene("test") + + print("\n--- Test 1: Layer creation (starts dirty) ---") + color_layer = grid.add_layer("color", z_index=-1) + # The layer should be dirty initially + # We can't directly check dirty flag from Python, but we verify the system works + print(" ColorLayer created successfully") + + tile_layer = grid.add_layer("tile", z_index=-2, texture=texture) + print(" TileLayer created successfully") + print(" PASS: Layers created") + + print("\n--- Test 2: Fill operations work ---") + # Fill with some data + color_layer.fill(mcrfpy.Color(128, 0, 128, 64)) + print(" ColorLayer filled with purple overlay") + + tile_layer.fill(5) # Fill with tile index 5 + print(" TileLayer filled with tile index 5") + print(" PASS: Fill operations completed") + + print("\n--- Test 3: Cell set operations work ---") + # Set individual cells + color_layer.set(10, 10, mcrfpy.Color(255, 255, 0, 128)) + color_layer.set(11, 10, mcrfpy.Color(255, 255, 0, 128)) + color_layer.set(10, 11, mcrfpy.Color(255, 255, 0, 128)) + color_layer.set(11, 11, mcrfpy.Color(255, 255, 0, 128)) + print(" Set 4 cells in ColorLayer to yellow") + + tile_layer.set(15, 15, 10) + tile_layer.set(16, 15, 11) + tile_layer.set(15, 16, 10) + tile_layer.set(16, 16, 11) + print(" Set 4 cells in TileLayer to different tiles") + print(" PASS: Cell set operations completed") + + print("\n--- Test 4: Texture change on TileLayer ---") + # Create a second texture and assign it + texture2 = mcrfpy.Texture("assets/kenney_ice.png", 16, 16) + tile_layer.texture = texture2 + print(" Changed TileLayer texture") + + # Set back to original + tile_layer.texture = texture + print(" Restored original texture") + print(" PASS: Texture changes work") + + print("\n--- Test 5: Viewport changes (should use cached texture) ---") + # Pan around - these should NOT cause layer re-renders (just blit different region) + original_center = grid.center + print(f" Original center: {original_center}") + + # Perform multiple viewport changes + for i in range(10): + grid.center = (100 + i * 20, 80 + i * 10) + print(" Performed 10 center changes") + + # Zoom changes + original_zoom = grid.zoom + for z in [1.0, 0.8, 1.2, 0.5, 1.5, 1.0]: + grid.zoom = z + print(" Performed 6 zoom changes") + + # Restore + grid.center = original_center + grid.zoom = original_zoom + print(" PASS: Viewport changes completed without crashing") + + print("\n--- Test 6: Performance benchmark ---") + # Create a large layer for performance testing + perf_grid = mcrfpy.Grid(pos=(50, 50), size=(600, 500), grid_size=(100, 80), texture=texture) + ui.append(perf_grid) + perf_layer = perf_grid.add_layer("tile", z_index=-1, texture=texture) + + # Fill with data + perf_layer.fill(1) + + # First render will be slow (cache miss) + start = time.time() + mcrfpy.setScene("test") # Force render + first_render = time.time() - start + print(f" First render (cache build): {first_render*1000:.2f}ms") + + # Subsequent viewport changes should be fast (cache hit) + # We simulate by changing center multiple times + start = time.time() + for i in range(5): + perf_grid.center = (200 + i * 10, 160 + i * 8) + viewport_changes = time.time() - start + print(f" 5 viewport changes: {viewport_changes*1000:.2f}ms") + + print(" PASS: Performance benchmark completed") + + print("\n--- Test 7: Layer visibility toggle ---") + color_layer.visible = False + print(" ColorLayer hidden") + color_layer.visible = True + print(" ColorLayer shown") + print(" PASS: Visibility toggle works") + + print("\n--- Test 8: Large grid stress test ---") + # Test with maximum size grid to ensure texture caching works + stress_grid = mcrfpy.Grid(pos=(10, 10), size=(200, 150), grid_size=(200, 150), texture=texture) + ui.append(stress_grid) + stress_layer = stress_grid.add_layer("color", z_index=-1) + + # This would be 30,000 cells - should handle via caching + stress_layer.fill(mcrfpy.Color(0, 100, 200, 100)) + + # Set a few specific cells + for x in range(10): + for y in range(10): + stress_layer.set(x, y, mcrfpy.Color(255, 0, 0, 200)) + + print(" Created 200x150 grid with 30,000 cells") + print(" PASS: Large grid handled successfully") + + print("\n" + "=" * 60) + print("All tests PASSED") + print("=" * 60) + print("\nNote: Dirty flag behavior is internal - tests verify API works") + print("Actual caching benefits are measured by render performance.") + sys.exit(0) + +# Initialize and run +mcrfpy.createScene("init") +mcrfpy.setScene("init") +mcrfpy.setTimer("test", run_test, 100)