feat: Implement chunk-based Grid rendering for large grids (closes #123)

Adds a sub-grid system where grids larger than 64x64 cells are automatically
divided into 64x64 chunks, each with its own RenderTexture for incremental
rendering. This significantly improves performance for large grids by:

- Only re-rendering dirty chunks when cells are modified
- Caching rendered chunk textures between frames
- Viewport culling at the chunk level (skip invisible chunks entirely)

Implementation details:
- GridChunk class manages individual 64x64 cell regions with dirty tracking
- ChunkManager organizes chunks and routes cell access appropriately
- UIGrid::at() method transparently routes through chunks for large grids
- UIGrid::render() uses chunk-based blitting for large grids
- Compile-time CHUNK_SIZE (64) and CHUNK_THRESHOLD (64) constants
- Small grids (<= 64x64) continue to use flat storage (no regression)

Benchmark results show ~2x improvement in base layer render time for 100x100
grids (0.45ms -> 0.22ms) due to chunk caching.

Note: Dynamic layers (#147) still use full-grid textures; extending chunk
system to layers is tracked separately as #150.

🤖 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 22:33:16 -05:00
commit 9469c04b01
6 changed files with 1059 additions and 49 deletions

View file

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

View file

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