feat: Add dirty flag and RenderTexture caching for Grid layers (closes #148)

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 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-11-28 21:44:33 -05:00
parent 4b05a95efe
commit abb3316ac1
3 changed files with 389 additions and 36 deletions

View File

@ -10,9 +10,50 @@
GridLayer::GridLayer(GridLayerType type, int z_index, int grid_x, int grid_y, UIGrid* parent) 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), : 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 // ColorLayer implementation
// ============================================================================= // =============================================================================
@ -32,6 +73,7 @@ const sf::Color& ColorLayer::at(int x, int y) const {
void ColorLayer::fill(const sf::Color& color) { void ColorLayer::fill(const sf::Color& color) {
std::fill(colors.begin(), colors.end(), 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) { 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); colors = std::move(new_colors);
grid_x = new_grid_x; grid_x = new_grid_x;
grid_y = new_grid_y; 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, void ColorLayer::render(sf::RenderTarget& target,
@ -57,27 +130,61 @@ void ColorLayer::render(sf::RenderTarget& target,
float zoom, int cell_width, int cell_height) { float zoom, int cell_width, int cell_height) {
if (!visible) return; if (!visible) return;
sf::RectangleShape rect; // #148 - Use cached texture rendering
rect.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom)); // Re-render to texture only if dirty
rect.setOutlineThickness(0); if (dirty || !texture_initialized) {
renderToTexture(cell_width, cell_height);
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);
}
} }
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) { void TileLayer::fill(int tile_index) {
std::fill(tiles.begin(), tiles.end(), 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) { 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); tiles = std::move(new_tiles);
grid_x = new_grid_x; grid_x = new_grid_x;
grid_y = new_grid_y; 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, void TileLayer::render(sf::RenderTarget& target,
@ -126,22 +261,56 @@ void TileLayer::render(sf::RenderTarget& target,
float zoom, int cell_width, int cell_height) { float zoom, int cell_width, int cell_height) {
if (!visible || !texture) return; if (!visible || !texture) return;
for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0); x < x_limit; ++x) { // #148 - Use cached texture rendering
for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0); y < y_limit; ++y) { // Re-render to texture only if dirty
if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) continue; if (dirty || !texture_initialized) {
renderToTexture(cell_width, cell_height);
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);
}
} }
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); Py_DECREF(color_type);
self->data->at(x, y) = color; self->data->at(x, y) = color;
self->data->markDirty(); // #148 - Mark for re-render
Py_RETURN_NONE; Py_RETURN_NONE;
} }
@ -491,6 +661,7 @@ PyObject* PyGridLayerAPI::TileLayer_set(PyTileLayerObject* self, PyObject* args)
} }
self->data->at(x, y) = index; self->data->at(x, y) = index;
self->data->markDirty(); // #148 - Mark for re-render
Py_RETURN_NONE; Py_RETURN_NONE;
} }
@ -578,6 +749,7 @@ int PyGridLayerAPI::TileLayer_set_texture(PyTileLayerObject* self, PyObject* val
if (value == Py_None) { if (value == Py_None) {
self->data->texture.reset(); self->data->texture.reset();
self->data->markDirty(); // #148 - Mark for re-render
return 0; return 0;
} }
@ -596,6 +768,7 @@ int PyGridLayerAPI::TileLayer_set_texture(PyTileLayerObject* self, PyObject* val
Py_DECREF(texture_type); Py_DECREF(texture_type);
self->data->texture = ((PyTextureObject*)value)->data; self->data->texture = ((PyTextureObject*)value)->data;
self->data->markDirty(); // #148 - Mark for re-render
return 0; return 0;
} }

View File

@ -28,10 +28,27 @@ public:
UIGrid* parent_grid; // Parent grid reference UIGrid* parent_grid; // Parent grid reference
bool visible; // Visibility flag 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); GridLayer(GridLayerType type, int z_index, int grid_x, int grid_y, UIGrid* parent);
virtual ~GridLayer() = default; 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 // 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, virtual void render(sf::RenderTarget& target,
float left_spritepixels, float top_spritepixels, float left_spritepixels, float top_spritepixels,
int left_edge, int top_edge, int x_limit, int y_limit, int left_edge, int top_edge, int x_limit, int y_limit,
@ -55,6 +72,9 @@ public:
// Fill entire layer with a color // Fill entire layer with a color
void fill(const sf::Color& 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, void render(sf::RenderTarget& target,
float left_spritepixels, float top_spritepixels, float left_spritepixels, float top_spritepixels,
int left_edge, int top_edge, int x_limit, int y_limit, int left_edge, int top_edge, int x_limit, int y_limit,
@ -79,6 +99,9 @@ public:
// Fill entire layer with a tile index // Fill entire layer with a tile index
void fill(int 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, void render(sf::RenderTarget& target,
float left_spritepixels, float top_spritepixels, float left_spritepixels, float top_spritepixels,
int left_edge, int top_edge, int x_limit, int y_limit, int left_edge, int top_edge, int x_limit, int y_limit,

View File

@ -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)