Skip to content

Commit 0bd6975

Browse files
committed
refactor: optimize Gaussian blur in ferrofluid sim, expand Lissajous visualization to multiple instances, and add runtime benchmarking support.
1 parent e0e81a0 commit 0bd6975

10 files changed

Lines changed: 662 additions & 185 deletions

run_regression_tests.py

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
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()

src/engine.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4075,7 +4075,13 @@ impl<'a> VulkanEngine<'a> {
40754075
}
40764076
}
40774077

4078-
let do_capture = std::env::var("CAPTURE_FRAME").is_ok() && self.frame_count >= 180;
4078+
let capture_frame = if let Ok(bf_str) = std::env::var("BENCH_FRAMES") {
4079+
bf_str.parse::<u32>().unwrap_or(180).saturating_sub(1)
4080+
} else {
4081+
180
4082+
};
4083+
let do_capture = std::env::var("CAPTURE_FRAME").is_ok() && self.frame_count == capture_frame;
4084+
40794085
let mut readback_buffer = None;
40804086
if do_capture {
40814087
let bpr = (self.config.width * 4 + 255) & !255;
@@ -4130,9 +4136,13 @@ impl<'a> VulkanEngine<'a> {
41304136
img.put_pixel(x, y, image::Rgba([r, g, b, 255])); // Ignore A to force fully opaque screenshot
41314137
}
41324138
}
4133-
img.save("screenshot.png").unwrap();
4139+
let capture_path = std::env::var("CAPTURE_PATH").unwrap_or_else(|_| "screenshot.png".to_string());
4140+
img.save(&capture_path).unwrap();
4141+
println!("Screenshot saved to {}", capture_path);
4142+
}
4143+
if std::env::var("BENCH_FRAMES").is_err() {
4144+
std::process::exit(0);
41344145
}
4135-
std::process::exit(0);
41364146
}
41374147

41384148
surface_texture.present();

src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,13 @@ async fn run_gui(app_state: Arc<Mutex<AppState>>, mut active_stream: Option<audi
205205
let mut state = app_state.lock().unwrap();
206206
state.gpu_fft = true;
207207
}
208+
if let Some(bf) = bench {
209+
unsafe {
210+
std::env::set_var("BENCH_FRAMES", bf.to_string());
211+
}
212+
}
213+
214+
208215

209216
let event_loop = EventLoop::new().unwrap();
210217

src/shaders/vis_ferrofluid.wgsl

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ fn map_dist(p: vec3<f32>) -> f32 {
111111
// Normalized xz for angle alignment
112112
let p_xz_norm = p.xz / max(dist_xz, 0.0001);
113113

114-
for (var i = 0u; i < num_ch; i++) {
114+
for (var i = 0u; i < 12u; i++) {
115+
if i >= num_ch { break; }
115116
let vu = clamp(get_vu(i), 0.0, 1.0);
116117

117118
var alignment = 1.0;
@@ -147,11 +148,16 @@ fn map_dist(p: vec3<f32>) -> f32 {
147148
let ripple = sin(dist_xz * 12.0 - audio.time * 8.0) * 0.015 * bass * smoothstep(PUDDLE_RADIUS, 0.0, dist_xz);
148149

149150
// Organic surface perturbation (smooth magnetic domain noise)
150-
let noise_p = p * 4.0 + vec3<f32>(audio.time * 0.5, 0.0, audio.time * 0.3);
151-
let surface_noise = (hash3_smooth(noise_p) - 0.5) * 0.05;
151+
var surface_noise = 0.0;
152+
let d_base = p.y + 0.5 - (total_displacement + ripple);
153+
if abs(d_base) < 0.2 {
154+
let noise_p = p * 4.0 + vec3<f32>(audio.time * 0.5, 0.0, audio.time * 0.3);
155+
surface_noise = (hash3_smooth(noise_p) - 0.5) * 0.05;
156+
}
152157

153158
fluid_h += total_displacement + ripple + surface_noise;
154159

160+
155161
let d = p.y + 0.5 - fluid_h;
156162

157163
// Lipschitz-corrected step size
@@ -179,7 +185,8 @@ fn map(p: vec3<f32>) -> MapData {
179185
let p_xz_norm = p.xz / max(dist_xz, 0.0001);
180186
var glow = vec3<f32>(0.0);
181187

182-
for (var i = 0u; i < num_ch; i++) {
188+
for (var i = 0u; i < 12u; i++) {
189+
if i >= num_ch { break; }
183190
let vu = clamp(get_vu(i), 0.0, 1.0);
184191

185192
var alignment = 1.0;
@@ -220,8 +227,12 @@ fn map(p: vec3<f32>) -> MapData {
220227
let ripple = sin(dist_xz * 12.0 - audio.time * 8.0) * 0.015 * bass * smoothstep(PUDDLE_RADIUS, 0.0, dist_xz);
221228

222229
// Organic surface perturbation (smooth magnetic domain noise)
223-
let noise_p = p * 4.0 + vec3<f32>(audio.time * 0.5, 0.0, audio.time * 0.3);
224-
let surface_noise = (hash3_smooth(noise_p) - 0.5) * 0.05;
230+
var surface_noise = 0.0;
231+
let d_base = p.y + 0.5 - (total_displacement + ripple);
232+
if abs(d_base) < 0.2 {
233+
let noise_p = p * 4.0 + vec3<f32>(audio.time * 0.5, 0.0, audio.time * 0.3);
234+
surface_noise = (hash3_smooth(noise_p) - 0.5) * 0.05;
235+
}
225236

226237
fluid_h += total_displacement + ripple + surface_noise;
227238

0 commit comments

Comments
 (0)