Tests for cached rendering performance

This commit is contained in:
John McCardle 2025-11-28 23:28:13 -05:00
parent 42fcd3417e
commit 0545dd4861
20 changed files with 1740562 additions and 0 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,51 @@
{
"timestamp": "2025-11-28T19:22:01.900442",
"mode": "headless",
"results": {
"many_frames": {
"avg_work_ms": 0.5644053203661328,
"max_work_ms": 1.78,
"frame_count": 3496
},
"many_sprites": {
"avg_work_ms": 0.14705301494330555,
"max_work_ms": 11.814,
"frame_count": 13317
},
"many_captions": {
"avg_work_ms": 0.49336296106557376,
"max_work_ms": 2.202,
"frame_count": 3904
},
"deep_nesting": {
"avg_work_ms": 0.3517734925606891,
"max_work_ms": 145.75,
"frame_count": 10216
},
"deep_nesting_cached": {
"avg_work_ms": 0.0942947468905298,
"max_work_ms": 100.242,
"frame_count": 35617
},
"large_grid": {
"avg_work_ms": 2.2851537544696066,
"max_work_ms": 11.534,
"frame_count": 839
},
"animation_stress": {
"avg_work_ms": 0.0924456547145996,
"max_work_ms": 11.933,
"frame_count": 21391
},
"static_scene": {
"avg_work_ms": 2.022726128016789,
"max_work_ms": 17.275,
"frame_count": 953
},
"static_scene_cached": {
"avg_work_ms": 2.694431129476584,
"max_work_ms": 22.059,
"frame_count": 726
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,41 @@
{
"timestamp": "2025-11-28T16:53:30.850948",
"mode": "windowed",
"results": {
"many_frames": {
"avg_work_ms": 1.5756444444444444,
"max_work_ms": 3.257,
"frame_count": 90
},
"many_sprites": {
"avg_work_ms": 0.6889555555555555,
"max_work_ms": 1.533,
"frame_count": 90
},
"many_captions": {
"avg_work_ms": 1.2975777777777777,
"max_work_ms": 3.386,
"frame_count": 90
},
"deep_nesting": {
"avg_work_ms": 0.6173444444444445,
"max_work_ms": 1.4,
"frame_count": 90
},
"large_grid": {
"avg_work_ms": 3.6094,
"max_work_ms": 6.631,
"frame_count": 90
},
"animation_stress": {
"avg_work_ms": 0.5419333333333334,
"max_work_ms": 1.081,
"frame_count": 90
},
"static_scene": {
"avg_work_ms": 3.321588888888889,
"max_work_ms": 11.905,
"frame_count": 90
}
}
}

View File

@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""
Stress Test Suite for McRogueFace Performance Analysis
Establishes baseline performance data before implementing texture caching (#144).
Uses a single repeating timer pattern to avoid callback chain issues.
Usage:
./mcrogueface --headless --exec tests/benchmarks/stress_test_suite.py
"""
import mcrfpy
import sys
import os
import json
from datetime import datetime
# Configuration
TEST_DURATION_MS = 2000
TIMER_INTERVAL_MS = 50
OUTPUT_DIR = "../tests/benchmarks/baseline"
IS_HEADLESS = True # Assume headless for automated testing
class StressTestRunner:
def __init__(self):
self.tests = []
self.current_test = -1
self.results = {}
self.frames_counted = 0
self.mode = "headless" if IS_HEADLESS else "windowed"
def add_test(self, name, setup_fn, description=""):
self.tests.append({'name': name, 'setup': setup_fn, 'description': description})
def tick(self, runtime):
"""Single timer callback that manages all test flow"""
self.frames_counted += 1
# Check if current test should end
if self.current_test >= 0 and self.frames_counted * TIMER_INTERVAL_MS >= TEST_DURATION_MS:
self.end_current_test()
self.start_next_test()
elif self.current_test < 0:
self.start_next_test()
def start_next_test(self):
self.current_test += 1
if self.current_test >= len(self.tests):
self.finish_suite()
return
test = self.tests[self.current_test]
print(f"\n[{self.current_test + 1}/{len(self.tests)}] {test['name']}")
print(f" {test['description']}")
# Setup scene
scene_name = f"stress_{self.current_test}"
mcrfpy.createScene(scene_name)
# Start benchmark
mcrfpy.start_benchmark()
mcrfpy.log_benchmark(f"TEST: {test['name']}")
# Run setup
try:
test['setup'](scene_name)
except Exception as e:
print(f" SETUP ERROR: {e}")
mcrfpy.setScene(scene_name)
self.frames_counted = 0
def end_current_test(self):
if self.current_test < 0:
return
test = self.tests[self.current_test]
try:
filename = mcrfpy.end_benchmark()
with open(filename, 'r') as f:
data = json.load(f)
frames = data['frames'][30:] # Skip warmup
if frames:
avg_work = sum(f['work_time_ms'] for f in frames) / len(frames)
avg_frame = sum(f['frame_time_ms'] for f in frames) / len(frames)
max_work = max(f['work_time_ms'] for f in frames)
self.results[test['name']] = {
'avg_work_ms': avg_work,
'max_work_ms': max_work,
'frame_count': len(frames),
}
print(f" Work: {avg_work:.2f}ms avg, {max_work:.2f}ms max ({len(frames)} frames)")
os.makedirs(OUTPUT_DIR, exist_ok=True)
new_name = f"{OUTPUT_DIR}/{self.mode}_{test['name']}.json"
os.rename(filename, new_name)
except Exception as e:
print(f" ERROR: {e}")
self.results[test['name']] = {'error': str(e)}
def finish_suite(self):
mcrfpy.delTimer("tick")
print("\n" + "="*50)
print("STRESS TEST COMPLETE")
print("="*50)
for name, r in self.results.items():
if 'error' in r:
print(f" {name}: ERROR")
else:
print(f" {name}: {r['avg_work_ms']:.2f}ms avg")
# Save summary
os.makedirs(OUTPUT_DIR, exist_ok=True)
with open(f"{OUTPUT_DIR}/{self.mode}_summary.json", 'w') as f:
json.dump({
'timestamp': datetime.now().isoformat(),
'mode': self.mode,
'results': self.results
}, f, indent=2)
print(f"\nResults saved to {OUTPUT_DIR}/")
sys.exit(0)
def start(self):
print("="*50)
print("McRogueFace Stress Test Suite")
print("="*50)
print(f"Tests: {len(self.tests)}, Duration: {TEST_DURATION_MS}ms each")
mcrfpy.createScene("init")
ui = mcrfpy.sceneUI("init")
ui.append(mcrfpy.Frame(pos=(0,0), size=(10,10))) # Required for timer to fire
mcrfpy.setScene("init")
mcrfpy.setTimer("tick", self.tick, TIMER_INTERVAL_MS)
# =============================================================================
# TEST SETUP FUNCTIONS
# =============================================================================
def test_many_frames(scene_name):
"""1000 Frame elements"""
ui = mcrfpy.sceneUI(scene_name)
for i in range(1000):
frame = mcrfpy.Frame(
pos=((i % 32) * 32, (i // 32) * 24),
size=(30, 22),
fill_color=mcrfpy.Color((i*7)%256, (i*13)%256, (i*17)%256)
)
ui.append(frame)
mcrfpy.log_benchmark("1000 frames created")
def test_many_sprites(scene_name):
"""500 Sprite elements"""
ui = mcrfpy.sceneUI(scene_name)
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
for i in range(500):
sprite = mcrfpy.Sprite(
pos=((i % 20) * 48 + 10, (i // 20) * 28 + 10),
texture=texture,
sprite_index=i % 128
)
sprite.scale_x = 2.0
sprite.scale_y = 2.0
ui.append(sprite)
mcrfpy.log_benchmark("500 sprites created")
def test_many_captions(scene_name):
"""500 Caption elements"""
ui = mcrfpy.sceneUI(scene_name)
for i in range(500):
caption = mcrfpy.Caption(
text=f"Text #{i}",
pos=((i % 20) * 50 + 5, (i // 20) * 28 + 5)
)
ui.append(caption)
mcrfpy.log_benchmark("500 captions created")
def test_deep_nesting(scene_name):
"""15-level nested frames"""
ui = mcrfpy.sceneUI(scene_name)
current = ui
for level in range(15):
frame = mcrfpy.Frame(
pos=(20, 20),
size=(1024 - level * 60, 768 - level * 45),
fill_color=mcrfpy.Color((level * 17) % 256, 100, (255 - level * 17) % 256, 200)
)
current.append(frame)
# Add children at each level
for j in range(3):
child = mcrfpy.Frame(pos=(50 + j * 80, 50), size=(60, 30))
frame.children.append(child)
current = frame.children
mcrfpy.log_benchmark("15-level nesting created")
def test_large_grid(scene_name):
"""100x100 grid with 500 entities"""
ui = mcrfpy.sceneUI(scene_name)
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
grid = mcrfpy.Grid(pos=(50, 50), size=(900, 650), grid_size=(100, 100), texture=texture)
ui.append(grid)
for y in range(100):
for x in range(100):
cell = grid.at(x, y)
cell.tilesprite = (x + y) % 64
for i in range(500):
entity = mcrfpy.Entity(
grid_pos=((i * 7) % 100, (i * 11) % 100),
texture=texture,
sprite_index=(i * 3) % 128,
grid=grid
)
mcrfpy.log_benchmark("100x100 grid with 500 entities created")
def test_animation_stress(scene_name):
"""100 frames with 200 animations"""
ui = mcrfpy.sceneUI(scene_name)
for i in range(100):
frame = mcrfpy.Frame(
pos=((i % 10) * 100 + 10, (i // 10) * 70 + 10),
size=(80, 50),
fill_color=mcrfpy.Color(100, 150, 200)
)
ui.append(frame)
# Two animations per frame
anim_x = mcrfpy.Animation("x", float((i % 10) * 100 + 50), 1.5, "easeInOut")
anim_x.start(frame)
anim_o = mcrfpy.Animation("fill_color.a", 128 + (i % 128), 2.0, "linear")
anim_o.start(frame)
mcrfpy.log_benchmark("100 frames with 200 animations")
def test_static_scene(scene_name):
"""Static game scene (ideal for caching)"""
ui = mcrfpy.sceneUI(scene_name)
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
# Background
bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(30, 30, 50))
ui.append(bg)
# UI panel
panel = mcrfpy.Frame(pos=(10, 10), size=(200, 300), fill_color=mcrfpy.Color(50, 50, 70))
ui.append(panel)
for i in range(10):
caption = mcrfpy.Caption(text=f"Status {i}", pos=(10, 10 + i * 25))
panel.children.append(caption)
# Grid
grid = mcrfpy.Grid(pos=(220, 10), size=(790, 600), grid_size=(40, 30), texture=texture)
ui.append(grid)
for y in range(30):
for x in range(40):
grid.at(x, y).tilesprite = ((x + y) % 4) + 1
for i in range(20):
entity = mcrfpy.Entity(grid_pos=((i*2) % 40, (i*3) % 30),
texture=texture, sprite_index=64 + i % 16, grid=grid)
mcrfpy.log_benchmark("Static game scene created")
def test_static_scene_cached(scene_name):
"""Static game scene with cache_subtree enabled (#144)"""
ui = mcrfpy.sceneUI(scene_name)
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
# Background with caching enabled
bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(30, 30, 50), cache_subtree=True)
ui.append(bg)
# UI panel with caching enabled
panel = mcrfpy.Frame(pos=(10, 10), size=(200, 300), fill_color=mcrfpy.Color(50, 50, 70), cache_subtree=True)
ui.append(panel)
for i in range(10):
caption = mcrfpy.Caption(text=f"Status {i}", pos=(10, 10 + i * 25))
panel.children.append(caption)
# Grid (not cached - grids handle their own optimization)
grid = mcrfpy.Grid(pos=(220, 10), size=(790, 600), grid_size=(40, 30), texture=texture)
ui.append(grid)
for y in range(30):
for x in range(40):
grid.at(x, y).tilesprite = ((x + y) % 4) + 1
for i in range(20):
entity = mcrfpy.Entity(grid_pos=((i*2) % 40, (i*3) % 30),
texture=texture, sprite_index=64 + i % 16, grid=grid)
mcrfpy.log_benchmark("Static game scene with cache_subtree created")
def test_deep_nesting_cached(scene_name):
"""15-level nested frames with cache_subtree on outer frame (#144)"""
ui = mcrfpy.sceneUI(scene_name)
# Outer frame with caching - entire subtree cached
outer = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768),
fill_color=mcrfpy.Color(0, 100, 255, 200),
cache_subtree=True # Cache entire nested hierarchy
)
ui.append(outer)
current = outer.children
for level in range(15):
frame = mcrfpy.Frame(
pos=(20, 20),
size=(1024 - level * 60, 768 - level * 45),
fill_color=mcrfpy.Color((level * 17) % 256, 100, (255 - level * 17) % 256, 200)
)
current.append(frame)
# Add children at each level
for j in range(3):
child = mcrfpy.Frame(pos=(50 + j * 80, 50), size=(60, 30))
frame.children.append(child)
current = frame.children
mcrfpy.log_benchmark("15-level nesting with cache_subtree created")
# =============================================================================
# MAIN
# =============================================================================
runner = StressTestRunner()
runner.add_test("many_frames", test_many_frames, "1000 Frame elements")
runner.add_test("many_sprites", test_many_sprites, "500 Sprite elements")
runner.add_test("many_captions", test_many_captions, "500 Caption elements")
runner.add_test("deep_nesting", test_deep_nesting, "15-level nested hierarchy")
runner.add_test("deep_nesting_cached", test_deep_nesting_cached, "15-level nested (cache_subtree)")
runner.add_test("large_grid", test_large_grid, "100x100 grid, 500 entities")
runner.add_test("animation_stress", test_animation_stress, "100 frames, 200 animations")
runner.add_test("static_scene", test_static_scene, "Static game scene (no caching)")
runner.add_test("static_scene_cached", test_static_scene_cached, "Static game scene (cache_subtree)")
runner.start()

View File

@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
TCOD Scaling Benchmark - Test pathfinding/FOV on large grids
Tests whether TCOD operations scale acceptably on 1000x1000 grids,
to determine if TCOD data needs chunking or can stay as single logical grid.
"""
import mcrfpy
import sys
import time
# Grid sizes to test
SIZES = [(100, 100), (250, 250), (500, 500), (1000, 1000)]
ITERATIONS = 10
def benchmark_grid_size(grid_x, grid_y):
"""Benchmark TCOD operations for a given grid size"""
results = {}
# Create scene and grid
scene_name = f"bench_{grid_x}x{grid_y}"
mcrfpy.createScene(scene_name)
ui = mcrfpy.sceneUI(scene_name)
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
# Time grid creation
t0 = time.perf_counter()
grid = mcrfpy.Grid(
pos=(0, 0),
size=(800, 600),
grid_size=(grid_x, grid_y),
texture=texture
)
ui.append(grid)
results['create_ms'] = (time.perf_counter() - t0) * 1000
# Set up some walkability (maze-like pattern)
t0 = time.perf_counter()
for y in range(grid_y):
for x in range(grid_x):
cell = grid.at(x, y)
# Create a simple maze: every 3rd cell is a wall
cell.walkable = not ((x % 3 == 0) and (y % 3 == 0))
cell.transparent = cell.walkable
results['setup_walkability_ms'] = (time.perf_counter() - t0) * 1000
# Add an entity for FOV perspective
entity = mcrfpy.Entity(
grid_pos=(grid_x // 2, grid_y // 2),
texture=texture,
sprite_index=64,
grid=grid
)
# Benchmark FOV computation
fov_times = []
for i in range(ITERATIONS):
# Move entity to different positions
ex, ey = (i * 7) % (grid_x - 20) + 10, (i * 11) % (grid_y - 20) + 10
t0 = time.perf_counter()
grid.compute_fov(ex, ey, radius=15)
fov_times.append((time.perf_counter() - t0) * 1000)
results['fov_avg_ms'] = sum(fov_times) / len(fov_times)
results['fov_max_ms'] = max(fov_times)
# Benchmark A* pathfinding (corner to corner)
path_times = []
for i in range(ITERATIONS):
# Path from near origin to near opposite corner
x1, y1 = 1, 1
x2, y2 = grid_x - 2, grid_y - 2
t0 = time.perf_counter()
path = grid.compute_astar_path(x1, y1, x2, y2)
path_times.append((time.perf_counter() - t0) * 1000)
results['astar_avg_ms'] = sum(path_times) / len(path_times)
results['astar_max_ms'] = max(path_times)
results['astar_path_len'] = len(path) if path else 0
# Benchmark Dijkstra (full map distance calculation)
dijkstra_times = []
for i in range(ITERATIONS):
cx, cy = grid_x // 2, grid_y // 2
t0 = time.perf_counter()
grid.compute_dijkstra(cx, cy)
dijkstra_times.append((time.perf_counter() - t0) * 1000)
results['dijkstra_avg_ms'] = sum(dijkstra_times) / len(dijkstra_times)
results['dijkstra_max_ms'] = max(dijkstra_times)
return results
def main():
print("=" * 60)
print("TCOD Scaling Benchmark")
print("=" * 60)
print(f"Testing grid sizes: {SIZES}")
print(f"Iterations per test: {ITERATIONS}")
print()
all_results = {}
for grid_x, grid_y in SIZES:
print(f"\n--- Grid {grid_x}x{grid_y} ({grid_x * grid_y:,} cells) ---")
try:
results = benchmark_grid_size(grid_x, grid_y)
all_results[f"{grid_x}x{grid_y}"] = results
print(f" Creation: {results['create_ms']:.2f}ms")
print(f" Walkability: {results['setup_walkability_ms']:.2f}ms")
print(f" FOV (r=15): {results['fov_avg_ms']:.3f}ms avg, {results['fov_max_ms']:.3f}ms max")
print(f" A* path: {results['astar_avg_ms']:.2f}ms avg, {results['astar_max_ms']:.2f}ms max (len={results['astar_path_len']})")
print(f" Dijkstra: {results['dijkstra_avg_ms']:.2f}ms avg, {results['dijkstra_max_ms']:.2f}ms max")
except Exception as e:
print(f" ERROR: {e}")
all_results[f"{grid_x}x{grid_y}"] = {'error': str(e)}
print("\n" + "=" * 60)
print("SUMMARY - Per-frame budget analysis (targeting 16ms for 60fps)")
print("=" * 60)
for size, results in all_results.items():
if 'error' in results:
print(f" {size}: ERROR")
else:
total_logic = results['fov_avg_ms'] + results['astar_avg_ms']
print(f" {size}: FOV+A* = {total_logic:.2f}ms ({total_logic/16*100:.0f}% of frame budget)")
print("\nDone.")
sys.exit(0)
# Run immediately (no timer needed for this test)
mcrfpy.createScene("init")
mcrfpy.setScene("init")
# Use a timer to let the engine initialize
def run_benchmark(runtime):
main()
mcrfpy.setTimer("bench", run_benchmark, 100)