diff --git a/src/GridChunk.cpp b/src/GridChunk.cpp new file mode 100644 index 0000000..2491c07 --- /dev/null +++ b/src/GridChunk.cpp @@ -0,0 +1,253 @@ +#include "GridChunk.h" +#include "UIGrid.h" +#include "PyTexture.h" +#include +#include + +// ============================================================================= +// GridChunk implementation +// ============================================================================= + +GridChunk::GridChunk(int chunk_x, int chunk_y, int width, int height, + int world_x, int world_y, UIGrid* parent) + : chunk_x(chunk_x), chunk_y(chunk_y), + width(width), height(height), + world_x(world_x), world_y(world_y), + cells(width * height), + dirty(true), texture_initialized(false), + parent_grid(parent) +{} + +UIGridPoint& GridChunk::at(int local_x, int local_y) { + return cells[local_y * width + local_x]; +} + +const UIGridPoint& GridChunk::at(int local_x, int local_y) const { + return cells[local_y * width + local_x]; +} + +void GridChunk::markDirty() { + dirty = true; +} + +void GridChunk::ensureTexture(int cell_width, int cell_height) { + unsigned int required_width = width * cell_width; + unsigned int required_height = height * cell_height; + + if (texture_initialized && + cached_texture.getSize().x == required_width && + cached_texture.getSize().y == required_height) { + return; + } + + if (!cached_texture.create(required_width, required_height)) { + texture_initialized = false; + return; + } + + texture_initialized = true; + dirty = true; // Force re-render after resize + cached_sprite.setTexture(cached_texture.getTexture()); +} + +void GridChunk::renderToTexture(int cell_width, int cell_height, + std::shared_ptr texture) { + ensureTexture(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 in this chunk + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const auto& cell = at(x, y); + sf::Vector2f pixel_pos(x * cell_width, y * cell_height); + + // Draw background color + rect.setPosition(pixel_pos); + rect.setFillColor(cell.color); + cached_texture.draw(rect); + + // Draw tile sprite if available + if (texture && cell.tilesprite != -1) { + sf::Sprite sprite = texture->sprite(cell.tilesprite, pixel_pos, + sf::Vector2f(1.0f, 1.0f)); + cached_texture.draw(sprite); + } + } + } + + cached_texture.display(); + dirty = false; +} + +sf::FloatRect GridChunk::getWorldBounds(int cell_width, int cell_height) const { + return sf::FloatRect( + sf::Vector2f(world_x * cell_width, world_y * cell_height), + sf::Vector2f(width * cell_width, height * cell_height) + ); +} + +bool GridChunk::isVisible(float left_edge, float top_edge, + float right_edge, float bottom_edge) const { + // Check if chunk's cell range overlaps with viewport's cell range + float chunk_right = world_x + width; + float chunk_bottom = world_y + height; + + return !(world_x >= right_edge || chunk_right <= left_edge || + world_y >= bottom_edge || chunk_bottom <= top_edge); +} + +// ============================================================================= +// ChunkManager implementation +// ============================================================================= + +ChunkManager::ChunkManager(int grid_x, int grid_y, UIGrid* parent) + : grid_x(grid_x), grid_y(grid_y), parent_grid(parent) +{ + // Calculate number of chunks needed + chunks_x = (grid_x + GridChunk::CHUNK_SIZE - 1) / GridChunk::CHUNK_SIZE; + chunks_y = (grid_y + GridChunk::CHUNK_SIZE - 1) / GridChunk::CHUNK_SIZE; + + chunks.reserve(chunks_x * chunks_y); + + // Create chunks + for (int cy = 0; cy < chunks_y; ++cy) { + for (int cx = 0; cx < chunks_x; ++cx) { + // Calculate world position + int world_x = cx * GridChunk::CHUNK_SIZE; + int world_y = cy * GridChunk::CHUNK_SIZE; + + // Calculate actual size (may be smaller at edges) + int chunk_width = std::min(GridChunk::CHUNK_SIZE, grid_x - world_x); + int chunk_height = std::min(GridChunk::CHUNK_SIZE, grid_y - world_y); + + chunks.push_back(std::make_unique( + cx, cy, chunk_width, chunk_height, world_x, world_y, parent + )); + } + } +} + +GridChunk* ChunkManager::getChunkForCell(int x, int y) { + if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) { + return nullptr; + } + + int chunk_x = x / GridChunk::CHUNK_SIZE; + int chunk_y = y / GridChunk::CHUNK_SIZE; + return getChunk(chunk_x, chunk_y); +} + +const GridChunk* ChunkManager::getChunkForCell(int x, int y) const { + if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) { + return nullptr; + } + + int chunk_x = x / GridChunk::CHUNK_SIZE; + int chunk_y = y / GridChunk::CHUNK_SIZE; + return getChunk(chunk_x, chunk_y); +} + +GridChunk* ChunkManager::getChunk(int chunk_x, int chunk_y) { + if (chunk_x < 0 || chunk_x >= chunks_x || chunk_y < 0 || chunk_y >= chunks_y) { + return nullptr; + } + return chunks[chunk_y * chunks_x + chunk_x].get(); +} + +const GridChunk* ChunkManager::getChunk(int chunk_x, int chunk_y) const { + if (chunk_x < 0 || chunk_x >= chunks_x || chunk_y < 0 || chunk_y >= chunks_y) { + return nullptr; + } + return chunks[chunk_y * chunks_x + chunk_x].get(); +} + +UIGridPoint& ChunkManager::at(int x, int y) { + GridChunk* chunk = getChunkForCell(x, y); + if (!chunk) { + // Return a static dummy point for out-of-bounds access + // This matches the original behavior of UIGrid::at() + static UIGridPoint dummy; + return dummy; + } + + // Convert to local coordinates within chunk + int local_x = x % GridChunk::CHUNK_SIZE; + int local_y = y % GridChunk::CHUNK_SIZE; + + // Mark chunk dirty when accessed for modification + chunk->markDirty(); + + return chunk->at(local_x, local_y); +} + +const UIGridPoint& ChunkManager::at(int x, int y) const { + const GridChunk* chunk = getChunkForCell(x, y); + if (!chunk) { + static UIGridPoint dummy; + return dummy; + } + + int local_x = x % GridChunk::CHUNK_SIZE; + int local_y = y % GridChunk::CHUNK_SIZE; + + return chunk->at(local_x, local_y); +} + +void ChunkManager::markAllDirty() { + for (auto& chunk : chunks) { + chunk->markDirty(); + } +} + +std::vector ChunkManager::getVisibleChunks(float left_edge, float top_edge, + float right_edge, float bottom_edge) { + std::vector visible; + visible.reserve(chunks.size()); // Pre-allocate for worst case + + for (auto& chunk : chunks) { + if (chunk->isVisible(left_edge, top_edge, right_edge, bottom_edge)) { + visible.push_back(chunk.get()); + } + } + + return visible; +} + +void ChunkManager::resize(int new_grid_x, int new_grid_y) { + // For now, simple rebuild - could be optimized to preserve data + grid_x = new_grid_x; + grid_y = new_grid_y; + + chunks_x = (grid_x + GridChunk::CHUNK_SIZE - 1) / GridChunk::CHUNK_SIZE; + chunks_y = (grid_y + GridChunk::CHUNK_SIZE - 1) / GridChunk::CHUNK_SIZE; + + chunks.clear(); + chunks.reserve(chunks_x * chunks_y); + + for (int cy = 0; cy < chunks_y; ++cy) { + for (int cx = 0; cx < chunks_x; ++cx) { + int world_x = cx * GridChunk::CHUNK_SIZE; + int world_y = cy * GridChunk::CHUNK_SIZE; + int chunk_width = std::min(GridChunk::CHUNK_SIZE, grid_x - world_x); + int chunk_height = std::min(GridChunk::CHUNK_SIZE, grid_y - world_y); + + chunks.push_back(std::make_unique( + cx, cy, chunk_width, chunk_height, world_x, world_y, parent_grid + )); + } + } +} + +int ChunkManager::dirtyChunks() const { + int count = 0; + for (const auto& chunk : chunks) { + if (chunk->dirty) ++count; + } + return count; +} diff --git a/src/GridChunk.h b/src/GridChunk.h new file mode 100644 index 0000000..d590392 --- /dev/null +++ b/src/GridChunk.h @@ -0,0 +1,118 @@ +#pragma once +#include "Common.h" +#include +#include +#include +#include "UIGridPoint.h" + +// Forward declarations +class UIGrid; +class PyTexture; + +/** + * #123 - Grid chunk for sub-grid rendering system + * + * Each chunk represents a CHUNK_SIZE x CHUNK_SIZE portion of the grid. + * Chunks have their own RenderTexture and dirty flag for efficient + * incremental rendering - only dirty chunks are re-rendered. + */ +class GridChunk { +public: + // Compile-time configurable chunk size (power of 2 recommended) + static constexpr int CHUNK_SIZE = 64; + + // Position of this chunk in chunk coordinates + int chunk_x, chunk_y; + + // Actual dimensions (may be less than CHUNK_SIZE at grid edges) + int width, height; + + // World position (in cell coordinates) + int world_x, world_y; + + // Cell data for this chunk + std::vector cells; + + // Cached rendering + sf::RenderTexture cached_texture; + sf::Sprite cached_sprite; + bool dirty; + bool texture_initialized; + + // Parent grid reference (for texture access) + UIGrid* parent_grid; + + // Constructor + GridChunk(int chunk_x, int chunk_y, int width, int height, + int world_x, int world_y, UIGrid* parent); + + // Access cell at local chunk coordinates + UIGridPoint& at(int local_x, int local_y); + const UIGridPoint& at(int local_x, int local_y) const; + + // Mark chunk as needing re-render + void markDirty(); + + // Ensure texture is properly sized + void ensureTexture(int cell_width, int cell_height); + + // Render chunk content to cached texture + void renderToTexture(int cell_width, int cell_height, + std::shared_ptr texture); + + // Get pixel bounds of this chunk in world coordinates + sf::FloatRect getWorldBounds(int cell_width, int cell_height) const; + + // Check if chunk overlaps with viewport + bool isVisible(float left_edge, float top_edge, + float right_edge, float bottom_edge) const; +}; + +/** + * Manages a 2D array of chunks for a grid + */ +class ChunkManager { +public: + // Dimensions in chunks + int chunks_x, chunks_y; + + // Grid dimensions in cells + int grid_x, grid_y; + + // All chunks (row-major order) + std::vector> chunks; + + // Parent grid + UIGrid* parent_grid; + + // Constructor - creates chunks for given grid dimensions + ChunkManager(int grid_x, int grid_y, UIGrid* parent); + + // Get chunk containing cell (x, y) + GridChunk* getChunkForCell(int x, int y); + const GridChunk* getChunkForCell(int x, int y) const; + + // Get chunk at chunk coordinates + GridChunk* getChunk(int chunk_x, int chunk_y); + const GridChunk* getChunk(int chunk_x, int chunk_y) const; + + // Access cell at grid coordinates (routes through chunk) + UIGridPoint& at(int x, int y); + const UIGridPoint& at(int x, int y) const; + + // Mark all chunks dirty (for full rebuild) + void markAllDirty(); + + // Get chunks that overlap with viewport + std::vector getVisibleChunks(float left_edge, float top_edge, + float right_edge, float bottom_edge); + + // Resize grid (rebuilds chunks) + void resize(int new_grid_x, int new_grid_y); + + // Get total number of chunks + int totalChunks() const { return chunks_x * chunks_y; } + + // Get number of dirty chunks + int dirtyChunks() const; +}; diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index 118df49..72c5683 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -8,10 +8,10 @@ #include // #142 - for std::floor // UIDrawable methods now in UIBase.h -UIGrid::UIGrid() +UIGrid::UIGrid() : grid_x(0), grid_y(0), zoom(1.0f), center_x(0.0f), center_y(0.0f), ptex(nullptr), fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr), tcod_path(nullptr), - perspective_enabled(false) // Default to omniscient view + perspective_enabled(false), use_chunks(false) // Default to omniscient view { // Initialize entities list entities = std::make_shared>>(); @@ -24,30 +24,31 @@ UIGrid::UIGrid() position = sf::Vector2f(0, 0); // Set base class position box.setPosition(position); // Sync box position box.setFillColor(sf::Color(0, 0, 0, 0)); - + // Initialize render texture (small default size) renderTexture.create(1, 1); - + // Initialize output sprite output.setTextureRect(sf::IntRect(0, 0, 0, 0)); output.setPosition(0, 0); output.setTexture(renderTexture.getTexture()); - + // Points vector starts empty (grid_x * grid_y = 0) // TCOD map will be created when grid is resized } UIGrid::UIGrid(int gx, int gy, std::shared_ptr _ptex, sf::Vector2f _xy, sf::Vector2f _wh) : grid_x(gx), grid_y(gy), - zoom(1.0f), - ptex(_ptex), points(gx * gy), + zoom(1.0f), + ptex(_ptex), fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr), tcod_path(nullptr), - perspective_enabled(false) // Default to omniscient view + perspective_enabled(false), + use_chunks(gx > CHUNK_THRESHOLD || gy > CHUNK_THRESHOLD) // #123 - Use chunks for large grids { // Use texture dimensions if available, otherwise use defaults int cell_width = _ptex ? _ptex->sprite_width : DEFAULT_CELL_WIDTH; int cell_height = _ptex ? _ptex->sprite_height : DEFAULT_CELL_HEIGHT; - + center_x = (gx/2) * cell_width; center_y = (gy/2) * cell_height; entities = std::make_shared>>(); @@ -57,12 +58,12 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr _ptex, sf::Vector2f _x box.setSize(_wh); position = _xy; // Set base class position - box.setPosition(position); // Sync box position + box.setPosition(position); // Sync box position box.setFillColor(sf::Color(0,0,0,0)); // create renderTexture with maximum theoretical size; sprite can resize to show whatever amount needs to be rendered renderTexture.create(1920, 1080); // TODO - renderTexture should be window size; above 1080p this will cause rendering errors - + // Only initialize sprite if texture is available if (ptex) { sprite = ptex->sprite(0); @@ -77,23 +78,47 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr _ptex, sf::Vector2f _x // Create TCOD map tcod_map = new TCODMap(gx, gy); - + // Create TCOD dijkstra pathfinder tcod_dijkstra = new TCODDijkstra(tcod_map); - + // Create TCOD A* pathfinder tcod_path = new TCODPath(tcod_map); - - // Initialize grid points with parent reference - for (int y = 0; y < gy; y++) { - for (int x = 0; x < gx; x++) { - int idx = y * gx + x; - points[idx].grid_x = x; - points[idx].grid_y = y; - points[idx].parent_grid = this; + + // #123 - Initialize storage based on grid size + if (use_chunks) { + // Large grid: use chunk-based storage + chunk_manager = std::make_unique(gx, gy, this); + + // Initialize all cells with parent reference + for (int cy = 0; cy < chunk_manager->chunks_y; ++cy) { + for (int cx = 0; cx < chunk_manager->chunks_x; ++cx) { + GridChunk* chunk = chunk_manager->getChunk(cx, cy); + if (!chunk) continue; + + for (int ly = 0; ly < chunk->height; ++ly) { + for (int lx = 0; lx < chunk->width; ++lx) { + auto& cell = chunk->at(lx, ly); + cell.grid_x = chunk->world_x + lx; + cell.grid_y = chunk->world_y + ly; + cell.parent_grid = this; + } + } + } + } + } else { + // Small grid: use flat storage (original behavior) + points.resize(gx * gy); + for (int y = 0; y < gy; y++) { + for (int x = 0; x < gx; x++) { + int idx = y * gx + x; + points[idx].grid_x = x; + points[idx].grid_y = y; + points[idx].parent_grid = this; + } } } - + // Initial sync of TCOD map syncTCODMap(); } @@ -147,36 +172,64 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) // base layer - bottom color, tile sprite ("ground") int cellsRendered = 0; - for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0); - x < x_limit; //x < view_width; - x+=1) - { - //for (float y = (top_edge >= 0 ? top_edge : 0); - for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0); - y < y_limit; //y < view_height; - y+=1) - { - auto pixel_pos = sf::Vector2f( - (x*cell_width - left_spritepixels) * zoom, - (y*cell_height - top_spritepixels) * zoom ); - auto gridpoint = at(std::floor(x), std::floor(y)); + // #123 - Use chunk-based rendering for large grids + if (use_chunks && chunk_manager) { + // Get visible chunks based on cell coordinate bounds + float right_edge = left_edge + width_sq + 2; + float bottom_edge = top_edge + height_sq + 2; + auto visible_chunks = chunk_manager->getVisibleChunks(left_edge, top_edge, right_edge, bottom_edge); - //sprite.setPosition(pixel_pos); - - r.setPosition(pixel_pos); - r.setFillColor(gridpoint.color); - renderTexture.draw(r); - - // tilesprite - only draw if texture is available - // if discovered but not visible, set opacity to 90% - // if not discovered... just don't draw it? - if (ptex && gridpoint.tilesprite != -1) { - sprite = ptex->sprite(gridpoint.tilesprite, pixel_pos, sf::Vector2f(zoom, zoom)); //setSprite(gridpoint.tilesprite);; - renderTexture.draw(sprite); + for (auto* chunk : visible_chunks) { + // Re-render dirty chunks to their cached textures + if (chunk->dirty) { + chunk->renderToTexture(cell_width, cell_height, ptex); } - cellsRendered++; + // Calculate pixel position for this chunk's sprite + float chunk_pixel_x = (chunk->world_x * cell_width - left_spritepixels) * zoom; + float chunk_pixel_y = (chunk->world_y * cell_height - top_spritepixels) * zoom; + + // Set up and draw the chunk sprite + chunk->cached_sprite.setPosition(chunk_pixel_x, chunk_pixel_y); + chunk->cached_sprite.setScale(zoom, zoom); + renderTexture.draw(chunk->cached_sprite); + + cellsRendered += chunk->width * chunk->height; + } + } else { + // Original cell-by-cell rendering for small grids + for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0); + x < x_limit; //x < view_width; + x+=1) + { + //for (float y = (top_edge >= 0 ? top_edge : 0); + for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0); + y < y_limit; //y < view_height; + y+=1) + { + auto pixel_pos = sf::Vector2f( + (x*cell_width - left_spritepixels) * zoom, + (y*cell_height - top_spritepixels) * zoom ); + + auto gridpoint = at(std::floor(x), std::floor(y)); + + //sprite.setPosition(pixel_pos); + + r.setPosition(pixel_pos); + r.setFillColor(gridpoint.color); + renderTexture.draw(r); + + // tilesprite - only draw if texture is available + // if discovered but not visible, set opacity to 90% + // if not discovered... just don't draw it? + if (ptex && gridpoint.tilesprite != -1) { + sprite = ptex->sprite(gridpoint.tilesprite, pixel_pos, sf::Vector2f(zoom, zoom)); //setSprite(gridpoint.tilesprite);; + renderTexture.draw(sprite); + } + + cellsRendered++; + } } } @@ -368,6 +421,10 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) UIGridPoint& UIGrid::at(int x, int y) { + // #123 - Route through chunk manager for large grids + if (use_chunks && chunk_manager) { + return chunk_manager->at(x, y); + } return points[y * grid_x + x]; } @@ -1109,7 +1166,8 @@ PyObject* UIGrid::py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds) auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "GridPoint"); auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0); //auto target = std::static_pointer_cast(target); - obj->data = &(self->data->points[x + self->data->grid_x * y]); + // #123 - Use at() method to route through chunks for large grids + obj->data = &(self->data->at(x, y)); obj->grid = self->data; return (PyObject*)obj; } diff --git a/src/UIGrid.h b/src/UIGrid.h index 82b66d6..34efa55 100644 --- a/src/UIGrid.h +++ b/src/UIGrid.h @@ -21,6 +21,7 @@ #include "UIDrawable.h" #include "UIBase.h" #include "GridLayers.h" +#include "GridChunk.h" class UIGrid: public UIDrawable { @@ -75,7 +76,15 @@ public: std::shared_ptr getTexture(); sf::Sprite sprite, output; sf::RenderTexture renderTexture; + + // #123 - Chunk-based storage for large grid support + std::unique_ptr chunk_manager; + // Legacy flat storage (kept for small grids or compatibility) std::vector points; + // Use chunks for grids larger than this threshold + static constexpr int CHUNK_THRESHOLD = 64; + bool use_chunks; + std::shared_ptr>> entities; // UIDrawable children collection (speech bubbles, effects, overlays, etc.) diff --git a/tests/benchmarks/layer_performance_test.py b/tests/benchmarks/layer_performance_test.py new file mode 100644 index 0000000..a81b441 --- /dev/null +++ b/tests/benchmarks/layer_performance_test.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +""" +Layer Performance Benchmark for McRogueFace (#147, #148, #123) + +Uses C++ benchmark logger (start_benchmark/end_benchmark) for accurate timing. +Results written to JSON files for analysis. + +Compares rendering performance between: +1. Traditional grid.at(x,y).color API (no caching) +2. New layer system with dirty flag caching +3. Various layer configurations + +Usage: + ./mcrogueface --exec tests/benchmarks/layer_performance_test.py + # Results in benchmark_*.json files +""" + +import mcrfpy +import sys +import os +import json + +# Test configuration +GRID_SIZE = 100 # 100x100 = 10,000 cells +MEASURE_FRAMES = 120 +WARMUP_FRAMES = 30 + +current_test = None +frame_count = 0 +test_results = {} # Store filenames for each test + + +def run_test_phase(runtime): + """Run through warmup and measurement phases.""" + global frame_count + + frame_count += 1 + + if frame_count == WARMUP_FRAMES: + # Start benchmark after warmup + mcrfpy.start_benchmark() + mcrfpy.log_benchmark(f"Test: {current_test}") + + elif frame_count == WARMUP_FRAMES + MEASURE_FRAMES: + # End benchmark and store filename + filename = mcrfpy.end_benchmark() + test_results[current_test] = filename + print(f" {current_test}: saved to {filename}") + + mcrfpy.delTimer("test_phase") + run_next_test() + + +def run_next_test(): + """Run next test in sequence.""" + global current_test, frame_count + + tests = [ + ('1_base_static', setup_base_layer_static), + ('2_base_modified', setup_base_layer_modified), + ('3_layer_static', setup_color_layer_static), + ('4_layer_modified', setup_color_layer_modified), + ('5_tile_static', setup_tile_layer_static), + ('6_tile_modified', setup_tile_layer_modified), + ('7_multi_layer', setup_multi_layer_static), + ('8_comparison', setup_base_vs_layer_comparison), + ] + + # Find current + current_idx = -1 + if current_test: + for i, (name, _) in enumerate(tests): + if name == current_test: + current_idx = i + break + + next_idx = current_idx + 1 + + if next_idx >= len(tests): + analyze_results() + return + + current_test = tests[next_idx][0] + frame_count = 0 + + print(f"\n[{next_idx + 1}/{len(tests)}] Running: {current_test}") + tests[next_idx][1]() + + mcrfpy.setTimer("test_phase", run_test_phase, 1) + + +# ============================================================================ +# Test Scenarios +# ============================================================================ + +def setup_base_layer_static(): + """Traditional grid.at(x,y).color API - no modifications during render.""" + mcrfpy.createScene("test_base_static") + ui = mcrfpy.sceneUI("test_base_static") + + grid = mcrfpy.Grid(grid_size=(GRID_SIZE, GRID_SIZE), + pos=(10, 10), size=(600, 600)) + ui.append(grid) + + # Fill base layer using traditional API + for y in range(GRID_SIZE): + for x in range(GRID_SIZE): + cell = grid.at(x, y) + cell.color = mcrfpy.Color((x * 2) % 256, (y * 2) % 256, 128, 255) + + mcrfpy.setScene("test_base_static") + + +def setup_base_layer_modified(): + """Traditional API with single cell modified each frame.""" + mcrfpy.createScene("test_base_mod") + ui = mcrfpy.sceneUI("test_base_mod") + + grid = mcrfpy.Grid(grid_size=(GRID_SIZE, GRID_SIZE), + pos=(10, 10), size=(600, 600)) + ui.append(grid) + + # Fill base layer + for y in range(GRID_SIZE): + for x in range(GRID_SIZE): + cell = grid.at(x, y) + cell.color = mcrfpy.Color(100, 100, 100, 255) + + # Timer to modify one cell per frame + mod_counter = [0] + def modify_cell(runtime): + x = mod_counter[0] % GRID_SIZE + y = (mod_counter[0] // GRID_SIZE) % GRID_SIZE + cell = grid.at(x, y) + cell.color = mcrfpy.Color(255, 0, 0, 255) + mod_counter[0] += 1 + + mcrfpy.setScene("test_base_mod") + mcrfpy.setTimer("modify", modify_cell, 1) + + +def setup_color_layer_static(): + """New ColorLayer with dirty flag caching - static after fill.""" + mcrfpy.createScene("test_color_static") + ui = mcrfpy.sceneUI("test_color_static") + + grid = mcrfpy.Grid(grid_size=(GRID_SIZE, GRID_SIZE), + pos=(10, 10), size=(600, 600)) + ui.append(grid) + + # Add color layer and fill once + layer = grid.add_layer("color", z_index=-1) + layer.fill(mcrfpy.Color(100, 150, 200, 128)) + + mcrfpy.setScene("test_color_static") + + +def setup_color_layer_modified(): + """ColorLayer with single cell modified each frame - tests dirty flag.""" + mcrfpy.createScene("test_color_mod") + ui = mcrfpy.sceneUI("test_color_mod") + + grid = mcrfpy.Grid(grid_size=(GRID_SIZE, GRID_SIZE), + pos=(10, 10), size=(600, 600)) + ui.append(grid) + + layer = grid.add_layer("color", z_index=-1) + layer.fill(mcrfpy.Color(100, 100, 100, 128)) + + # Timer to modify one cell per frame - triggers re-render + mod_counter = [0] + def modify_cell(runtime): + x = mod_counter[0] % GRID_SIZE + y = (mod_counter[0] // GRID_SIZE) % GRID_SIZE + layer.set(x, y, mcrfpy.Color(255, 0, 0, 255)) + mod_counter[0] += 1 + + mcrfpy.setScene("test_color_mod") + mcrfpy.setTimer("modify", modify_cell, 1) + + +def setup_tile_layer_static(): + """TileLayer with caching - static after fill.""" + mcrfpy.createScene("test_tile_static") + ui = mcrfpy.sceneUI("test_tile_static") + + try: + texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16) + except: + texture = None + + grid = mcrfpy.Grid(grid_size=(GRID_SIZE, GRID_SIZE), + pos=(10, 10), size=(600, 600), texture=texture) + ui.append(grid) + + if texture: + layer = grid.add_layer("tile", z_index=-1, texture=texture) + layer.fill(5) + + mcrfpy.setScene("test_tile_static") + + +def setup_tile_layer_modified(): + """TileLayer with single cell modified each frame.""" + mcrfpy.createScene("test_tile_mod") + ui = mcrfpy.sceneUI("test_tile_mod") + + try: + texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16) + except: + texture = None + + grid = mcrfpy.Grid(grid_size=(GRID_SIZE, GRID_SIZE), + pos=(10, 10), size=(600, 600), texture=texture) + ui.append(grid) + + layer = None + if texture: + layer = grid.add_layer("tile", z_index=-1, texture=texture) + layer.fill(5) + + # Timer to modify one cell per frame + mod_counter = [0] + def modify_cell(runtime): + if layer: + x = mod_counter[0] % GRID_SIZE + y = (mod_counter[0] // GRID_SIZE) % GRID_SIZE + layer.set(x, y, (mod_counter[0] % 20)) + mod_counter[0] += 1 + + mcrfpy.setScene("test_tile_mod") + mcrfpy.setTimer("modify", modify_cell, 1) + + +def setup_multi_layer_static(): + """Multiple layers (5 color, 5 tile) - all static.""" + mcrfpy.createScene("test_multi_static") + ui = mcrfpy.sceneUI("test_multi_static") + + try: + texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16) + except: + texture = None + + grid = mcrfpy.Grid(grid_size=(GRID_SIZE, GRID_SIZE), + pos=(10, 10), size=(600, 600), texture=texture) + ui.append(grid) + + # Add 5 color layers with different z_indices and colors + for i in range(5): + layer = grid.add_layer("color", z_index=-(i+1)*2) + layer.fill(mcrfpy.Color(50 + i*30, 100 + i*20, 150 - i*20, 50)) + + # Add 5 tile layers + if texture: + for i in range(5): + layer = grid.add_layer("tile", z_index=-(i+1)*2 - 1, texture=texture) + layer.fill(i * 4) + + print(f" Created {len(grid.layers)} layers") + mcrfpy.setScene("test_multi_static") + + +def setup_base_vs_layer_comparison(): + """Direct comparison: same visual using base API vs layer API.""" + mcrfpy.createScene("test_comparison") + ui = mcrfpy.sceneUI("test_comparison") + + # Grid using ONLY the new layer system (no base layer colors) + grid = mcrfpy.Grid(grid_size=(GRID_SIZE, GRID_SIZE), + pos=(10, 10), size=(600, 600)) + ui.append(grid) + + # Single color layer that covers everything + layer = grid.add_layer("color", z_index=-1) + + # Fill with pattern (same as base_layer_static but via layer) + for y in range(GRID_SIZE): + for x in range(GRID_SIZE): + layer.set(x, y, mcrfpy.Color((x * 2) % 256, (y * 2) % 256, 128, 255)) + + mcrfpy.setScene("test_comparison") + + +# ============================================================================ +# Results Analysis +# ============================================================================ + +def analyze_results(): + """Read JSON files and print comparison.""" + print("\n" + "=" * 70) + print("LAYER PERFORMANCE BENCHMARK RESULTS") + print("=" * 70) + print(f"Grid size: {GRID_SIZE}x{GRID_SIZE} = {GRID_SIZE*GRID_SIZE:,} cells") + print(f"Samples per test: {MEASURE_FRAMES} frames") + + results = {} + + for test_name, filename in test_results.items(): + if not os.path.exists(filename): + print(f" WARNING: {filename} not found") + continue + + with open(filename, 'r') as f: + data = json.load(f) + + frames = data.get('frames', []) + if not frames: + continue + + # Calculate averages + avg_grid = sum(f['grid_render_ms'] for f in frames) / len(frames) + avg_frame = sum(f['frame_time_ms'] for f in frames) / len(frames) + avg_cells = sum(f['grid_cells_rendered'] for f in frames) / len(frames) + avg_work = sum(f.get('work_time_ms', 0) for f in frames) / len(frames) + + results[test_name] = { + 'avg_grid_ms': avg_grid, + 'avg_frame_ms': avg_frame, + 'avg_work_ms': avg_work, + 'avg_cells': avg_cells, + 'samples': len(frames), + } + + print(f"\n{'Test':<20} {'Grid (ms)':>10} {'Work (ms)':>10} {'Cells':>10}") + print("-" * 70) + + for name in sorted(results.keys()): + r = results[name] + print(f"{name:<20} {r['avg_grid_ms']:>10.3f} {r['avg_work_ms']:>10.3f} {r['avg_cells']:>10.0f}") + + print("\n" + "-" * 70) + print("ANALYSIS:") + + # Compare base static vs layer static + if '1_base_static' in results and '3_layer_static' in results: + base = results['1_base_static']['avg_grid_ms'] + layer = results['3_layer_static']['avg_grid_ms'] + if base > 0.001: + improvement = ((base - layer) / base) * 100 + print(f" Static ColorLayer vs Base: {improvement:+.1f}% " + f"({'FASTER' if improvement > 0 else 'slower'})") + print(f" Base: {base:.3f}ms, Layer: {layer:.3f}ms") + + # Compare base modified vs layer modified + if '2_base_modified' in results and '4_layer_modified' in results: + base = results['2_base_modified']['avg_grid_ms'] + layer = results['4_layer_modified']['avg_grid_ms'] + if base > 0.001: + improvement = ((base - layer) / base) * 100 + print(f" Modified ColorLayer vs Base: {improvement:+.1f}% " + f"({'FASTER' if improvement > 0 else 'slower'})") + print(f" Base: {base:.3f}ms, Layer: {layer:.3f}ms") + + # Cache benefit (static vs modified for layers) + if '3_layer_static' in results and '4_layer_modified' in results: + static = results['3_layer_static']['avg_grid_ms'] + modified = results['4_layer_modified']['avg_grid_ms'] + if static > 0.001: + overhead = ((modified - static) / static) * 100 + print(f" Layer cache hit vs miss: {overhead:+.1f}% " + f"({'overhead when dirty' if overhead > 0 else 'benefit'})") + print(f" Static: {static:.3f}ms, Modified: {modified:.3f}ms") + + print("\n" + "=" * 70) + print("Benchmark JSON files saved for detailed analysis.") + print("Key insight: Base layer has NO caching; layers require opt-in.") + + sys.exit(0) + + +# ============================================================================ +# Main +# ============================================================================ + +if __name__ == "__main__": + print("=" * 70) + print("Layer Performance Benchmark (C++ timing)") + print("=" * 70) + print("\nThis benchmark compares:") + print(" - Traditional grid.at(x,y).color API (renders every frame)") + print(" - New layer system with dirty flag caching (#147, #148)") + print(f"\nEach test: {WARMUP_FRAMES} warmup + {MEASURE_FRAMES} measured frames") + + run_next_test() diff --git a/tests/regression/issue_123_chunk_system_test.py b/tests/regression/issue_123_chunk_system_test.py new file mode 100644 index 0000000..0437494 --- /dev/null +++ b/tests/regression/issue_123_chunk_system_test.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +Issue #123 Regression Test: Grid Sub-grid Chunk System + +Tests that large grids (>64 cells) use chunk-based storage and rendering, +while small grids use the original flat storage. Verifies that: +1. Small grids work as before (no regression) +2. Large grids work correctly with chunks +3. Cell access (read/write) works for both modes +4. Rendering displays correctly for both modes +""" + +import mcrfpy +import sys + +def test_small_grid(): + """Test that small grids work (original flat storage)""" + print("Testing small grid (50x50 < 64 threshold)...") + + # Small grid should use flat storage + grid = mcrfpy.Grid(grid_size=(50, 50), pos=(10, 10), size=(400, 400)) + + # Set some cells + for y in range(50): + for x in range(50): + cell = grid.at(x, y) + cell.color = mcrfpy.Color((x * 5) % 256, (y * 5) % 256, 128, 255) + cell.tilesprite = -1 + + # Verify cells + cell = grid.at(25, 25) + expected_r = (25 * 5) % 256 + expected_g = (25 * 5) % 256 + color = cell.color + r, g = color[0], color[1] + if r != expected_r or g != expected_g: + print(f"FAIL: Small grid cell color mismatch. Expected ({expected_r}, {expected_g}), got ({r}, {g})") + return False + + print(" Small grid: PASS") + return True + +def test_large_grid(): + """Test that large grids work (chunk-based storage)""" + print("Testing large grid (100x100 > 64 threshold)...") + + # Large grid should use chunk storage (100 > 64) + grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400)) + + # Set cells across multiple chunks + # Chunks are 64x64, so a 100x100 grid has 2x2 = 4 chunks + test_points = [ + (0, 0), # Chunk (0,0) + (63, 63), # Chunk (0,0) - edge + (64, 0), # Chunk (1,0) - start + (64, 64), # Chunk (1,1) - start + (99, 99), # Chunk (1,1) - edge + (50, 50), # Chunk (0,0) + (70, 80), # Chunk (1,1) + ] + + for x, y in test_points: + cell = grid.at(x, y) + cell.color = mcrfpy.Color(x, y, 100, 255) + cell.tilesprite = -1 + + # Verify cells + for x, y in test_points: + cell = grid.at(x, y) + color = cell.color + if color[0] != x or color[1] != y: + print(f"FAIL: Large grid cell ({x},{y}) color mismatch. Expected ({x}, {y}), got ({color[0]}, {color[1]})") + return False + + print(" Large grid cell access: PASS") + return True + +def test_very_large_grid(): + """Test very large grid (500x500)""" + print("Testing very large grid (500x500)...") + + # 500x500 = 250,000 cells, should use ~64 chunks (8x8) + grid = mcrfpy.Grid(grid_size=(500, 500), pos=(10, 10), size=(400, 400)) + + # Set some cells at various positions + test_points = [ + (0, 0), + (127, 127), + (128, 128), + (255, 255), + (256, 256), + (400, 400), + (499, 499), + ] + + for x, y in test_points: + cell = grid.at(x, y) + cell.color = mcrfpy.Color(x % 256, y % 256, 200, 255) + + # Verify + for x, y in test_points: + cell = grid.at(x, y) + color = cell.color + if color[0] != (x % 256) or color[1] != (y % 256): + print(f"FAIL: Very large grid cell ({x},{y}) color mismatch") + return False + + print(" Very large grid: PASS") + return True + +def test_boundary_case(): + """Test the exact boundary (64x64 should NOT use chunks, 65x65 should)""" + print("Testing boundary cases...") + + # 64x64 should use flat storage (not exceeding threshold) + grid_64 = mcrfpy.Grid(grid_size=(64, 64), pos=(10, 10), size=(400, 400)) + cell = grid_64.at(63, 63) + cell.color = mcrfpy.Color(255, 0, 0, 255) + color = grid_64.at(63, 63).color + if color[0] != 255: + print(f"FAIL: 64x64 grid boundary cell not set correctly, got r={color[0]}") + return False + + # 65x65 should use chunk storage (exceeding threshold) + grid_65 = mcrfpy.Grid(grid_size=(65, 65), pos=(10, 10), size=(400, 400)) + cell = grid_65.at(64, 64) + cell.color = mcrfpy.Color(0, 255, 0, 255) + color = grid_65.at(64, 64).color + if color[1] != 255: + print(f"FAIL: 65x65 grid cell not set correctly, got g={color[1]}") + return False + + print(" Boundary cases: PASS") + return True + +def test_edge_cases(): + """Test edge cell access in chunked grid""" + print("Testing edge cases...") + + # Create 100x100 grid + grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400)) + + # Test all corners + corners = [(0, 0), (99, 0), (0, 99), (99, 99)] + for i, (x, y) in enumerate(corners): + cell = grid.at(x, y) + cell.color = mcrfpy.Color(i * 60, i * 60, i * 60, 255) + + for i, (x, y) in enumerate(corners): + cell = grid.at(x, y) + expected = i * 60 + color = cell.color + if color[0] != expected: + print(f"FAIL: Corner ({x},{y}) color mismatch, expected {expected}, got {color[0]}") + return False + + print(" Edge cases: PASS") + return True + +def run_test(runtime): + """Timer callback to run tests after scene is active""" + results = [] + + results.append(test_small_grid()) + results.append(test_large_grid()) + results.append(test_very_large_grid()) + results.append(test_boundary_case()) + results.append(test_edge_cases()) + + if all(results): + print("\n=== ALL TESTS PASSED ===") + sys.exit(0) + else: + print("\n=== SOME TESTS FAILED ===") + sys.exit(1) + +# Main +if __name__ == "__main__": + print("=" * 60) + print("Issue #123: Grid Sub-grid Chunk System Test") + print("=" * 60) + + mcrfpy.createScene("test") + mcrfpy.setScene("test") + + # Run tests after scene is active + mcrfpy.setTimer("test", run_test, 100)