|
| 1 | +#!/usr/bin/env python3 |
| 2 | +import os |
| 3 | +import sys |
| 4 | +import subprocess |
| 5 | +import json |
| 6 | +import argparse |
| 7 | +from PIL import Image |
| 8 | + |
| 9 | +VISUALIZERS = [ |
| 10 | + "spectrum", "oscilloscope", "3doscilloscope", "3doscilloscope_freq", |
| 11 | + "flame", "firesim", "solar", "spatial", "ferrofluid", "ferrofluidsim", |
| 12 | + "neon", "lissajous", "synthwave", "starfield", "rain", "3drain", |
| 13 | + "synthwaveracer", "cuboids" |
| 14 | +] |
| 15 | + |
| 16 | +import math |
| 17 | +import struct |
| 18 | +import wave |
| 19 | + |
| 20 | +def generate_multichannel_sweep(filename="test_multichannel_sweep.wav", duration=5.0, sample_rate=44100): |
| 21 | + if os.path.exists(filename): |
| 22 | + return |
| 23 | + print(f"Generating {filename} (5.1 surround sweep for testing)...") |
| 24 | + num_channels = 6 |
| 25 | + num_samples = int(duration * sample_rate) |
| 26 | + |
| 27 | + with wave.open(filename, 'wb') as wav: |
| 28 | + wav.setnchannels(num_channels) |
| 29 | + wav.setsampwidth(2) # 16-bit |
| 30 | + wav.setframerate(sample_rate) |
| 31 | + |
| 32 | + frames = [] |
| 33 | + for n in range(num_samples): |
| 34 | + t = n / sample_rate |
| 35 | + |
| 36 | + # Left (0): 100Hz to 1000Hz sweep |
| 37 | + f_l = 100.0 + (900.0 * (t / duration)) |
| 38 | + val_l = math.sin(2.0 * math.pi * f_l * t) |
| 39 | + |
| 40 | + # Right (1): 1000Hz to 100Hz sweep |
| 41 | + f_r = 1000.0 - (900.0 * (t / duration)) |
| 42 | + val_r = math.sin(2.0 * math.pi * f_r * t) |
| 43 | + |
| 44 | + # Center (2): 200Hz to 2000Hz sweep |
| 45 | + f_c = 200.0 + (1800.0 * (t / duration)) |
| 46 | + val_c = math.sin(2.0 * math.pi * f_c * t) |
| 47 | + |
| 48 | + # LFE (3): 20Hz to 120Hz sweep (bass) |
| 49 | + f_lfe = 20.0 + (100.0 * (t / duration)) |
| 50 | + val_lfe = math.sin(2.0 * math.pi * f_lfe * t) |
| 51 | + |
| 52 | + # Ls (4): 500Hz to 5000Hz sweep |
| 53 | + f_ls = 500.0 + (4500.0 * (t / duration)) |
| 54 | + val_ls = math.sin(2.0 * math.pi * f_ls * t) |
| 55 | + |
| 56 | + # Rs (5): 5000Hz to 500Hz sweep |
| 57 | + f_rs = 5000.0 - (4500.0 * (t / duration)) |
| 58 | + val_rs = math.sin(2.0 * math.pi * f_rs * t) |
| 59 | + |
| 60 | + l_int = int(val_l * 32767.0 * 0.5) |
| 61 | + r_int = int(val_r * 32767.0 * 0.5) |
| 62 | + c_int = int(val_c * 32767.0 * 0.5) |
| 63 | + lfe_int = int(val_lfe * 32767.0 * 0.8) |
| 64 | + ls_int = int(val_ls * 32767.0 * 0.5) |
| 65 | + rs_int = int(val_rs * 32767.0 * 0.5) |
| 66 | + |
| 67 | + frame = struct.pack('<hhhhhh', l_int, r_int, c_int, lfe_int, ls_int, rs_int) |
| 68 | + frames.append(frame) |
| 69 | + |
| 70 | + wav.writeframes(b''.join(frames)) |
| 71 | + print(f"Successfully generated {filename}") |
| 72 | + |
| 73 | + |
| 74 | +def run_benchmark(vis_name, mode, frames, use_release): |
| 75 | + print(f"Running benchmark for '{vis_name}' ({mode} mode)...") |
| 76 | + |
| 77 | + # Configure directories and paths |
| 78 | + os.makedirs("test_baselines", exist_ok=True) |
| 79 | + capture_path = os.path.abspath(f"test_baselines/{vis_name}_{mode}.png") |
| 80 | + |
| 81 | + # Set env vars |
| 82 | + env = os.environ.copy() |
| 83 | + env["CAPTURE_FRAME"] = "1" |
| 84 | + env["CAPTURE_PATH"] = capture_path |
| 85 | + |
| 86 | + # Build cargo run command |
| 87 | + cargo_cmd = ["cargo", "run"] |
| 88 | + if use_release: |
| 89 | + cargo_cmd.append("--release") |
| 90 | + cargo_cmd.extend(["--", "--vis", vis_name, "--bench", str(frames)]) |
| 91 | + |
| 92 | + # Use test_multichannel_sweep.wav as reference input if available, else test_sine.wav |
| 93 | + if os.path.exists("test_multichannel_sweep.wav"): |
| 94 | + cargo_cmd.append("test_multichannel_sweep.wav") |
| 95 | + elif os.path.exists("test_sine.wav"): |
| 96 | + cargo_cmd.append("test_sine.wav") |
| 97 | + |
| 98 | + |
| 99 | + |
| 100 | + try: |
| 101 | + # Run process and capture stdout |
| 102 | + result = subprocess.run( |
| 103 | + cargo_cmd, |
| 104 | + env=env, |
| 105 | + stdout=subprocess.PIPE, |
| 106 | + stderr=subprocess.PIPE, |
| 107 | + text=True, |
| 108 | + check=True |
| 109 | + ) |
| 110 | + |
| 111 | + # Parse output metrics |
| 112 | + fps = 0.0 |
| 113 | + shader_us = 0.0 |
| 114 | + render_us = 0.0 |
| 115 | + |
| 116 | + for line in result.stdout.splitlines(): |
| 117 | + if line.startswith("BENCHMARK_RESULT_FPS:"): |
| 118 | + fps = float(line.split(":")[1].strip()) |
| 119 | + elif line.startswith("BENCHMARK_RESULT_SHADER_US:"): |
| 120 | + shader_us = float(line.split(":")[1].strip()) |
| 121 | + elif line.startswith("BENCHMARK_RESULT_RENDER_US:"): |
| 122 | + render_us = float(line.split(":")[1].strip()) |
| 123 | + |
| 124 | + metrics = { |
| 125 | + "fps": fps, |
| 126 | + "shader_us": shader_us, |
| 127 | + "render_us": render_us, |
| 128 | + "screenshot": capture_path |
| 129 | + } |
| 130 | + |
| 131 | + print(f" FPS: {fps:.2f} | Shader Time: {shader_us:.2f} µs | Render Time: {render_us:.2f} µs") |
| 132 | + return metrics |
| 133 | + except subprocess.CalledProcessError as e: |
| 134 | + print(f"Error running benchmark for {vis_name}:") |
| 135 | + print(e.stderr) |
| 136 | + return None |
| 137 | + |
| 138 | +def compare_screenshots(base_path, test_path): |
| 139 | + if not os.path.exists(base_path) or not os.path.exists(test_path): |
| 140 | + return 1.0, "Missing one or both screenshots for comparison" |
| 141 | + |
| 142 | + try: |
| 143 | + img_base = Image.open(base_path).convert("RGB") |
| 144 | + img_test = Image.open(test_path).convert("RGB") |
| 145 | + |
| 146 | + if img_base.size != img_test.size: |
| 147 | + return 1.0, f"Dimensions mismatch: {img_base.size} vs {img_test.size}" |
| 148 | + |
| 149 | + pixels_base = list(img_base.getdata()) |
| 150 | + pixels_test = list(img_test.getdata()) |
| 151 | + total_pixels = len(pixels_base) |
| 152 | + |
| 153 | + mse = 0.0 |
| 154 | + for p1, p2 in zip(pixels_base, pixels_test): |
| 155 | + mse += (p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2 + (p1[2] - p2[2]) ** 2 |
| 156 | + |
| 157 | + mse /= (total_pixels * 3) |
| 158 | + normalized_diff = mse / (255.0 ** 2) |
| 159 | + |
| 160 | + return normalized_diff, None |
| 161 | + except Exception as e: |
| 162 | + return 1.0, f"Error comparing images: {str(e)}" |
| 163 | + |
| 164 | +def main(): |
| 165 | + generate_multichannel_sweep() |
| 166 | + parser = argparse.ArgumentParser(description="RustTracker Visual and Performance Regression Test Harness") |
| 167 | + parser.add_argument("--mode", choices=["baseline", "test", "compare"], required=True, |
| 168 | + help="Mode: 'baseline' to record references, 'test' to record current build, 'compare' to analyze differences.") |
| 169 | + parser.add_argument("--frames", type=int, default=300, help="Number of frames to run (default: 300 = 5 seconds @ 60fps)") |
| 170 | + parser.add_argument("--debug", action="store_true", help="Run in debug mode (default is release mode)") |
| 171 | + parser.add_argument("--vis", help="Run only for a specific visualization (comma-separated list, or single name)") |
| 172 | + |
| 173 | + args = parser.parse_args() |
| 174 | + use_release = not args.debug |
| 175 | + |
| 176 | + selected_vis = VISUALIZERS |
| 177 | + if args.vis: |
| 178 | + selected_vis = [v.strip() for v in args.vis.split(",") if v.strip() in VISUALIZERS] |
| 179 | + if not selected_vis: |
| 180 | + print(f"Error: None of the specified visualizers match the available set.") |
| 181 | + sys.exit(1) |
| 182 | + |
| 183 | + metrics_file = f"test_baselines/{args.mode}_metrics.json" |
| 184 | + |
| 185 | + if args.mode in ["baseline", "test"]: |
| 186 | + results = {} |
| 187 | + for vis in selected_vis: |
| 188 | + metrics = run_benchmark(vis, args.mode, args.frames, use_release) |
| 189 | + if metrics: |
| 190 | + results[vis] = metrics |
| 191 | + |
| 192 | + with open(metrics_file, "w") as f: |
| 193 | + json.dump(results, f, indent=4) |
| 194 | + print(f"\n{args.mode.capitalize()} run complete. Metrics written to {metrics_file}") |
| 195 | + |
| 196 | + elif args.mode == "compare": |
| 197 | + base_file = "test_baselines/baseline_metrics.json" |
| 198 | + test_file = "test_baselines/test_metrics.json" |
| 199 | + |
| 200 | + if not os.path.exists(base_file) or not os.path.exists(test_file): |
| 201 | + print(f"Error: Both '{base_file}' and '{test_file}' must exist to run a comparison.") |
| 202 | + print("Please run with --mode baseline and --mode test first.") |
| 203 | + sys.exit(1) |
| 204 | + |
| 205 | + with open(base_file, "r") as f: |
| 206 | + base_data = json.load(f) |
| 207 | + with open(test_file, "r") as f: |
| 208 | + test_data = json.load(f) |
| 209 | + |
| 210 | + print("\n=======================================================") |
| 211 | + print(" REGRESSION COMPARISON RESULTS ") |
| 212 | + print("=======================================================") |
| 213 | + |
| 214 | + markdown_lines = [ |
| 215 | + "# Regression Comparison Report", |
| 216 | + "", |
| 217 | + "This report compares the performance and visuals of the updated shaders against the baseline.", |
| 218 | + "", |
| 219 | + "| Visualizer | Baseline FPS | Test FPS | FPS Change | Baseline Shader (µs) | Test Shader (µs) | Shader Change | Visual Mismatch (MSE) |", |
| 220 | + "| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |" |
| 221 | + ] |
| 222 | + |
| 223 | + any_perf_degraded = False |
| 224 | + any_visual_mismatch = False |
| 225 | + |
| 226 | + for vis in selected_vis: |
| 227 | + if vis not in base_data or vis not in test_data: |
| 228 | + print(f"Skipping {vis}: missing data in baseline or test.") |
| 229 | + continue |
| 230 | + |
| 231 | + base_m = base_data[vis] |
| 232 | + test_m = test_data[vis] |
| 233 | + |
| 234 | + fps_pct = ((test_m["fps"] - base_m["fps"]) / base_m["fps"]) * 100.0 if base_m["fps"] > 0 else 0.0 |
| 235 | + shader_pct = ((test_m["shader_us"] - base_m["shader_us"]) / base_m["shader_us"]) * 100.0 if base_m["shader_us"] > 0 else 0.0 |
| 236 | + |
| 237 | + diff_val, err = compare_screenshots(base_m["screenshot"], test_m["screenshot"]) |
| 238 | + |
| 239 | + # Format differences |
| 240 | + fps_change_str = f"{fps_pct:+.1f}%" |
| 241 | + shader_change_str = f"{shader_pct:+.1f}%" |
| 242 | + |
| 243 | + # Color/alert highlights for markdown |
| 244 | + if fps_pct < -5.0: |
| 245 | + fps_change_str = f"**{fps_change_str}** ⚠️" |
| 246 | + any_perf_degraded = True |
| 247 | + if shader_pct > 10.0: |
| 248 | + shader_change_str = f"**{shader_change_str}** ⚠️" |
| 249 | + any_perf_degraded = True |
| 250 | + |
| 251 | + visual_status = "0.0% (Identical)" |
| 252 | + if err: |
| 253 | + visual_status = f"Err: {err}" |
| 254 | + else: |
| 255 | + mismatch_pct = diff_val * 100.0 |
| 256 | + # Note: noise/particles cause slight changes. High threshold > 1.5% might suggest mismatch |
| 257 | + if mismatch_pct > 1.5: |
| 258 | + visual_status = f"**{mismatch_pct:.3f}%** ⚠️" |
| 259 | + any_visual_mismatch = True |
| 260 | + else: |
| 261 | + visual_status = f"{mismatch_pct:.3f}%" |
| 262 | + |
| 263 | + markdown_lines.append( |
| 264 | + f"| `{vis}` | {base_m['fps']:.1f} | {test_m['fps']:.1f} | {fps_change_str} | {base_m['shader_us']:.1f} | {test_m['shader_us']:.1f} | {shader_change_str} | {visual_status} |" |
| 265 | + ) |
| 266 | + |
| 267 | + # Print to stdout |
| 268 | + print(f"\nVisualizer: {vis}") |
| 269 | + print(f" FPS: {base_m['fps']:.1f} -> {test_m['fps']:.1f} ({fps_pct:+.1f}%)") |
| 270 | + print(f" Shader: {base_m['shader_us']:.1f} µs -> {test_m['shader_us']:.1f} µs ({shader_pct:+.1f}%)") |
| 271 | + if err: |
| 272 | + print(f" Visuals: Comparison error - {err}") |
| 273 | + else: |
| 274 | + print(f" Visual Mismatch: {diff_val*100.0:.3f}%") |
| 275 | + |
| 276 | + # Append warnings to report |
| 277 | + markdown_lines.append("") |
| 278 | + if any_perf_degraded: |
| 279 | + markdown_lines.append("> [!WARNING]") |
| 280 | + markdown_lines.append("> Performance regressions of >5% FPS drop or >10% shader time increase were detected (marked with ⚠️). Please check if shader loops or allocations can be optimized.") |
| 281 | + markdown_lines.append("") |
| 282 | + if any_visual_mismatch: |
| 283 | + markdown_lines.append("> [!NOTE]") |
| 284 | + markdown_lines.append("> Visual mismatches of >1.5% (marked with ⚠️) may indicate changes in rendering paths or layout. Note that minor mismatches are normal for shaders using time-dependent or random/procedural elements.") |
| 285 | + markdown_lines.append("") |
| 286 | + |
| 287 | + report_path = "test_baselines/regression_report.md" |
| 288 | + with open(report_path, "w") as f: |
| 289 | + f.write("\n".join(markdown_lines)) |
| 290 | + |
| 291 | + print(f"\nComparison report written to {report_path}") |
| 292 | + |
| 293 | +if __name__ == "__main__": |
| 294 | + main() |
0 commit comments