Skip to content

Commit 3233b70

Browse files
committed
v0.9.12: Visualization system overhaul — correctness, timing, realism
Critical fixes: - ferrofluidsim: WGSL Particle was 48B vs 32B host buffer; 1/3 of particles were phantom static splats. Repacked to two vec4s. - video: real viewport dims in VideoParams (was hardcoded 1080p); repack planes when decoder stride violates 256B row alignment (panic risk) - racer: light pools now match lamppost spacing (76u); car march exit budget covers the full bounding box diagonal Framerate independence: - play_time accumulates smoothed real dt (x2.88 preserves 144fps tuning) - frame_dt + history_cam_z added to AudioUniforms; all compute sims (fire, firesim, biolum, ferrofluid) use real dt with pow-corrected damping - synthwave cam_z uses history-locked uniform (scroll sync at any fps) Dead code removal: - GPU FFT pipeline/buffers deleted (FFT is CPU-side; shader was a naive DFT) - vis_3dtest.wgsl removed; gpu_spectrum upload gated to consumers; waveform history upload skipped when unchanged Robustness: - smoothstep_r() helper; ~80 spec-indeterminate reversed-edge smoothsteps migrated across all shaders - Fixed: oscilloscope double-amber tint, 3d-osc div-by-zero, HUD full-scale peak invisibility, solar flare-dist ordering + ejecta sawtooth, spectrum startup line + phosphor decay calibration, spatial glyph index, matrix mod-by-zero, firesim LFE guard, resynth EQ soft-knee Performance: - vumeters AABB pre-test, ferrofluid sky early-out + adaptive Lipschitz step, neon smoke bypass when silent + 100->80 steps, firesim black-pixel early-out, loop-invariant hoisting in 3d oscilloscope Realism pass: - Fire Simulation: upward anisotropic erosion w/ body texture, tongue seeding, flame bending, blue combustion fringe, renderer-side rising ember sparks, merged fuel line, smoke scatter direction fixed, taller tongues (buoyancy/cooling), no more blown white cores or purple fringing - Retro VU Meters: DENON removed (generic AUDIO marker); finite rounded chassis with rolled bevel edge, rack screws, pivot cap, dial grain, signal-reactive lamp, red-zone threshold fix, real aspect uniform - Matrix Rain: authentic half-width katakana + digits glyph set (55 glyphs) - Synthwave Racer: tapered fuselage, rounded SDF primitives, smin-blended panels, smooth wheel wells, side skirts + front valance Includes VIS_REVIEW.md with the full audit and remaining backlog.
1 parent d70fc7c commit 3233b70

32 files changed

Lines changed: 935 additions & 586 deletions

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rusttracker"
3-
version = "0.9.11"
3+
version = "0.9.12"
44
edition = "2024"
55
default-run = "rusttracker"
66
description = "High-Performance Vulkan Tracker Module Visualizer"

VIS_REVIEW.md

Lines changed: 312 additions & 0 deletions
Large diffs are not rendered by default.

src/engine.rs

Lines changed: 72 additions & 121 deletions
Large diffs are not rendered by default.

src/shaders/_common.wgsl

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ struct AudioUniforms {
4444
step_fraction: f32,
4545
steps_to_fill: u32,
4646
aspect_ratio: f32,
47-
pad2: u32,
48-
pad3: u32,
47+
// Real frame delta in seconds (clamped) — for framerate-independent sims
48+
frame_dt: f32,
49+
// (push_count + step_fraction) * 0.5 — world Z locked to history rows
50+
history_cam_z: f32,
4951
};
5052

5153
// Narkowicz ACES fitted tonemapping curve.
@@ -62,6 +64,12 @@ fn hash21_crt(p: vec2<f32>) -> f32 {
6264
return fract((p3.x + p3.y) * p3.z);
6365
}
6466

67+
// Reversed-edge smoothstep: equivalent to smoothstep(hi, lo, x) with hi > lo,
68+
// which is indeterminate per the WGSL spec. Use this instead.
69+
fn smoothstep_r(hi: f32, lo: f32, x: f32) -> f32 {
70+
return 1.0 - smoothstep(lo, hi, x);
71+
}
72+
6573
fn crt_distort_uv(uv: vec2<f32>, distortion: f32) -> vec2<f32> {
6674
let crt_uv = uv * 2.0 - 1.0;
6775
let r2 = dot(crt_uv, crt_uv);
@@ -98,7 +106,7 @@ fn apply_crt_effects(color: vec3<f32>, uv: vec2<f32>, clip_pos: vec2<f32>, time:
98106
// Vignette
99107
let crt_uv = uv * 2.0 - 1.0;
100108
let r = length(crt_uv);
101-
let bezel = smoothstep(settings.vignette_scale, settings.vignette_softness, r);
109+
let bezel = smoothstep_r(settings.vignette_scale, settings.vignette_softness, r);
102110
final_color *= bezel;
103111

104112
// Flicker

src/shaders/biolum_compute.wgsl

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
132132
if (idx >= 65536u) { return; }
133133

134134
var p = particles[idx];
135-
let dt = 0.016;
135+
// Framerate-independent timestep (real frame dt, clamped)
136+
let dt = clamp(audio.frame_dt, 0.001, 0.033);
136137
let time = audio.smooth_time;
137138
let idx_f = f32(idx);
138139

@@ -166,7 +167,8 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
166167
let target_vel = wave_vel + drift_vel;
167168

168169
// High drag keeps particles flowing smoothly with currents rather than zipping around
169-
vel = mix(vel, target_vel, 0.15);
170+
// (0.15 per 16ms step, converted to the real dt)
171+
vel = mix(vel, target_vel, 1.0 - pow(0.85, dt / 0.016));
170172

171173
// Audio reactivity - Bass agitation triggers glow directly
172174
let bass = max(audio.spectrum[0].x, audio.spectrum[1].x) * 0.02;
@@ -184,8 +186,9 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
184186
pos.z += vel.z * dt;
185187

186188
// Snap vertical position to the surface wave height
187-
let new_disp = get_wave_displacement(pos.xz, time);
188-
pos.y = new_disp.y;
189+
// (reuse disp from above — the field is smooth and the horizontal
190+
// integration step is tiny, so a second evaluation is wasted work)
191+
pos.y = wave_h;
189192
vel.y = 0.0;
190193

191194
// Wave breaks and foam structure simulation (active at crests)

src/shaders/ferrofluidsim_compute.wgsl

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
@group(0) @binding(0)
44
var<uniform> audio: AudioUniforms;
55

6+
// Packed to exactly 32 bytes to match the host allocation (100_000 * 32B).
7+
// pos.w = mass, vel.w = EMA-smoothed spectrum value (filters transients for standing waves)
68
struct Particle {
7-
pos: vec3<f32>,
8-
vel: vec3<f32>,
9-
mass: f32,
10-
smooth_spec: f32, // EMA-smoothed spectrum value — filters transients for standing waves
9+
pos: vec4<f32>,
10+
vel: vec4<f32>,
1111
}
1212

1313
@group(0) @binding(1)
@@ -30,42 +30,42 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
3030
if (idx >= 100000u) { return; }
3131

3232
var p = particles[idx];
33-
33+
3434
// Initialize if pos is exactly 0
35-
if (length(p.pos) < 0.001 && length(p.vel) < 0.001) {
35+
if (length(p.pos.xyz) < 0.001 && length(p.vel.xyz) < 0.001) {
3636
let rng1 = fract(sin(f32(idx) * 12.9898) * 43758.5453);
3737
let rng2 = fract(sin(f32(idx) * 78.233) * 43758.5453);
3838
let angle = rng1 * 6.28318;
3939
let r = sqrt(rng2) * 5.0;
40-
p.pos = vec3<f32>(cos(angle) * r, 0.1, sin(angle) * r);
41-
p.vel = vec3<f32>(0.0);
42-
p.mass = 1.0;
43-
p.smooth_spec = 0.0;
40+
p.pos = vec4<f32>(cos(angle) * r, 0.1, sin(angle) * r, 1.0);
41+
p.vel = vec4<f32>(0.0);
4442
}
4543

46-
// Physics
47-
let dt = 0.016;
44+
// Physics — framerate-independent timestep (real frame dt, clamped)
45+
let dt = clamp(audio.frame_dt, 0.001, 0.033);
4846
var force = vec3<f32>(0.0, -9.8, 0.0); // Gravity
4947

5048
// Frequency-driven magnetic field
5149
let angle = atan2(p.pos.z, p.pos.x);
5250
let abs_angle = abs(angle);
5351
let spec_idx = min(u32((abs_angle / 3.14159) * 127.0), 127u);
5452
let raw_spec = clamp(audio.spectrum[spec_idx / 4u][spec_idx % 4u], 0.0, 1.0);
55-
53+
5654
// Temporal EMA smoothing — standing wave filter
5755
// Slow attack (0.03): transient hits are ignored, only sustained frequencies build peaks
5856
// Moderate decay (0.08): fluid relaxes gracefully when a frequency stops
59-
let rate = select(0.08, 0.03, raw_spec > p.smooth_spec);
60-
p.smooth_spec = mix(p.smooth_spec, raw_spec, rate);
61-
let spec_val = p.smooth_spec;
57+
// Rates are per-16ms-step; converted to the real dt so behavior is fps-independent
58+
let dt_scale = dt / 0.016;
59+
let rate = 1.0 - pow(1.0 - select(0.08, 0.03, raw_spec > p.vel.w), dt_scale);
60+
p.vel.w = mix(p.vel.w, raw_spec, rate);
61+
let spec_val = p.vel.w;
6262

6363
// Dynamic magnet position based on smoothed frequency
6464
let mag_r = 0.5 + spec_val * 4.0;
6565
let mag_h = -0.5 + spec_val * 3.0;
6666
let mag_pos = vec3<f32>(cos(angle) * mag_r, mag_h, sin(angle) * mag_r);
6767

68-
let dir = mag_pos - p.pos;
68+
let dir = mag_pos - p.pos.xyz;
6969
let dist_sq = dot(dir, dir);
7070
let mag_force = (spec_val * 200.0 + 20.0) / (dist_sq + 0.5);
7171
force += normalize(dir) * mag_force;
@@ -80,7 +80,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
8080
force += turbulence * (3.0 + spec_val * 8.0);
8181

8282
// Central electromagnet to keep the fluid pooled together
83-
let center_dir = vec3<f32>(0.0, -1.0, 0.0) - p.pos;
83+
let center_dir = vec3<f32>(0.0, -1.0, 0.0) - p.pos.xyz;
8484
force += normalize(center_dir) * (60.0 / (dot(center_dir, center_dir) + 1.0));
8585

8686
// Floor collision
@@ -91,12 +91,12 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
9191
}
9292

9393
// Spring back to origin slightly to keep them in bounds
94-
force -= p.pos * 5.0;
94+
force -= p.pos.xyz * 5.0;
9595

96-
// Integration with viscous damping (0.91 = thick fluid, resists rapid motion)
97-
p.vel += force * dt;
98-
p.vel *= 0.91;
99-
p.pos += p.vel * dt;
96+
// Integration with viscous damping (0.91 per 16ms step = thick fluid),
97+
// converted to the real dt so damping strength is fps-independent
98+
p.vel = vec4<f32>((p.vel.xyz + force * dt) * pow(0.91, dt_scale), p.vel.w);
99+
p.pos = vec4<f32>(p.pos.xyz + p.vel.xyz * dt, p.pos.w);
100100

101101
particles[idx] = p;
102102

src/shaders/fire_compute.wgsl

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ struct FireParams {
1010
num_channels: u32,
1111
lfe_idx: u32,
1212
fft_channels: u32,
13-
_pad1: u32,
13+
dt: f32,
1414
display_order: array<vec4<u32>, 4>,
1515
channels: array<vec4<f32>, 8>,
1616
};
@@ -55,8 +55,9 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
5555
let bottom = params.height - 1u;
5656
let idx = y * w + x;
5757

58-
// Seed RNG from position + time
59-
let seed = pcg_hash(x + y * 1337u + bitcast<u32>(params.time * 100.0));
58+
// Rates below were tuned per-frame at 144fps; scale by real dt so the
59+
// simulation evolves identically at any refresh rate.
60+
let dt_scale = params.dt * 144.0;
6061

6162
// === Bottom row: update coal bed with thermal inertia ===
6263
if (y == bottom) {
@@ -67,7 +68,7 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
6768
if lfe_idx < n_ch {
6869
n_spatial_ch = n_ch - 1u;
6970
}
70-
let channel_width = 1024.0 / f32(max(n_spatial_ch, 1u));
71+
let channel_width = f32(params.width) / f32(max(n_spatial_ch, 1u));
7172
var sigma_scale = 0.18;
7273
if n_spatial_ch <= 2u {
7374
sigma_scale = 0.4;
@@ -93,7 +94,7 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
9394
spatial_idx += 1u;
9495
}
9596
if lfe_idx < n_ch {
96-
let lfe_dist = f32(x) - 512.0;
97+
let lfe_dist = f32(x) - f32(params.width) * 0.5;
9798
let lfe_influence = exp(-(lfe_dist * lfe_dist) / (2.0 * 90.0 * 90.0));
9899
var lfe_disp_idx = 999u;
99100
for (var i = 0u; i < n_ch; i = i + 1u) {
@@ -119,9 +120,9 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
119120
let coal_target = 1.0 - exp(-(params.bass * 0.5 + activity * 3.0));
120121
let current = coal_bed[x];
121122
if (coal_target > current) {
122-
coal_bed[x] = current + (coal_target - current) * 0.18;
123+
coal_bed[x] = current + (coal_target - current) * min(0.18 * dt_scale, 1.0);
123124
} else {
124-
coal_bed[x] = current + (coal_target - current) * 0.008;
125+
coal_bed[x] = current + (coal_target - current) * min(0.008 * dt_scale, 1.0);
125126
}
126127

127128
// Use lower frequency noise to create wide, organic hotspots rather than single-pixel static
@@ -173,10 +174,11 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
173174
let cool_noise = perlin_noise(p_cool + vec2<f32>(0.0, params.time * 6.0));
174175

175176
// Higher variance in cooling forces the flame to tear into distinct licks
176-
let base_cooling = 0.0015 + (cool_noise + 1.0) * 0.0015;
177-
177+
let base_cooling = (0.0015 + (cool_noise + 1.0) * 0.0015) * dt_scale;
178+
178179
// Occasional sparks (holes in the flame) for realism, clumped by noise
179-
let spark = select(0.0, 0.06, cool_noise > 0.8 && y > 100u);
180+
// (threshold 0.55: this perlin gradient noise peaks around +/-0.7, so 0.8 was unreachable)
181+
let spark = select(0.0, 0.06 * dt_scale, cool_noise > 0.55 && y > 100u);
180182

181183
// Normalize the cooling factor so heavily compressed tracks don't turn off cooling entirely
182184
let dynamic_cooling = mix(0.6, 1.0, params.cooling_factor);

src/shaders/firesim_compute.wgsl

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ struct FireParams {
1010
num_channels: u32,
1111
lfe_idx: u32,
1212
fft_channels: u32,
13-
_pad1: u32,
13+
dt: f32,
1414
display_order: array<vec4<u32>, 4>,
1515
channels: array<vec4<f32>, 8>,
1616
};
@@ -85,8 +85,9 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
8585
let bottom = params.height - 1u;
8686
let idx = y * w + x;
8787

88-
// Seed RNG from position + time
89-
let seed = pcg_hash(x + y * 1337u + bitcast<u32>(params.time * 100.0));
88+
// Rates below were tuned per-frame at 144fps; scale by real dt so the
89+
// simulation evolves identically at any refresh rate.
90+
let dt_scale = params.dt * 144.0;
9091

9192
// === Bottom row: update coal bed with thermal inertia ===
9293
if (y == bottom) {
@@ -97,10 +98,11 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
9798
if lfe_idx < n_ch {
9899
n_spatial_ch = n_ch - 1u;
99100
}
100-
let channel_width = 1024.0 / f32(max(n_spatial_ch, 1u));
101-
// Sharp fuel sources for distinct columns
102-
var sigma_scale = 0.08;
103-
if n_spatial_ch <= 2u { sigma_scale = 0.2; }
101+
let channel_width = f32(params.width) / f32(max(n_spatial_ch, 1u));
102+
// Fuel gaussians: wide enough that adjacent channel fires merge into a
103+
// continuous fire line (narrow ones read as separated pillars with gaps)
104+
var sigma_scale = 0.14;
105+
if n_spatial_ch <= 2u { sigma_scale = 0.25; }
104106

105107
var spatial_idx = 0u;
106108
for (var i = 0u; i < n_ch; i = i + 1u) {
@@ -137,7 +139,12 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
137139
}
138140
if lfe_idx < n_ch {
139141
var lfe_bass = 0.0;
140-
let offset = lfe_idx * 1024u;
142+
// Guard the FFT channel index the same way spatial channels are guarded above
143+
var lfe_ch = lfe_idx;
144+
if (params.fft_channels < params.num_channels) {
145+
lfe_ch = lfe_ch % max(params.fft_channels, 1u);
146+
}
147+
let offset = lfe_ch * 1024u;
141148
for (var b = 0u; b < 200u; b = b + 5u) {
142149
let c = multi_spectrum[offset + b];
143150
lfe_bass += clamp(length(c) * 100.0, 0.0, 100.0);
@@ -152,9 +159,9 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
152159
let coal_target = min(params.bass * 0.05 + activity * 1.2, 1.0);
153160
let current = coal_bed[x];
154161
if (coal_target > current) {
155-
coal_bed[x] = current + (coal_target - current) * 0.18;
162+
coal_bed[x] = current + (coal_target - current) * min(0.18 * dt_scale, 1.0);
156163
} else {
157-
coal_bed[x] = current + (coal_target - current) * 0.008;
164+
coal_bed[x] = current + (coal_target - current) * min(0.008 * dt_scale, 1.0);
158165
}
159166

160167
output_grid[idx] = min(coal_bed[x], 1.0);
@@ -167,14 +174,17 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
167174

168175
var out_val = coal_heat;
169176

170-
// Embers are directly spawned by extreme high-frequency transients (snares, hi-hats)
177+
// Embers are spawned by high-frequency transients (snares, hi-hats) and
178+
// mid-band bursts (kicks, toms) so they rise through the flame regularly
171179
let spark_bin = 800u + (x % 200u);
172180
var spark_noise = 0.0;
181+
var mid_noise = 0.0;
173182
for (var i = 0u; i < params.num_channels; i = i + 1u) {
174183
spark_noise += length(multi_spectrum[i * 1024u + spark_bin]);
184+
mid_noise += length(multi_spectrum[i * 1024u + 300u + (x % 150u)]);
175185
}
176-
177-
if (coal_heat > 0.4 && spark_noise * 150.0 > 1.0) {
186+
187+
if (coal_heat > 0.3 && (spark_noise * 150.0 > 0.6 || mid_noise * 90.0 > 1.6)) {
178188
out_val = 6.0; // Crisp ember packet
179189
}
180190

@@ -193,7 +203,7 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
193203
let smoke = max(-current_val, 0.0);
194204

195205
// 1. Thermal Buoyancy: Hot air and warm smoke rise.
196-
var buoyancy = 0.5; // Stronger base updraft
206+
var buoyancy = 0.7; // Stronger base updraft
197207
buoyancy += heat * 6.5; // Roaring vertical speed
198208
buoyancy += smoke * 1.5;
199209
let buoyancy_vel = vec2<f32>(0.0, -1.0) * buoyancy;
@@ -223,29 +233,33 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
223233
let jitter_seed = u32(x + y * params.width) + bitcast<u32>(params.time * 100.0);
224234
let jitter = (f32(pcg_hash(jitter_seed) % 100u) / 100.0) - 0.5; // -0.5 to 0.5
225235

226-
let dt = 1.0;
236+
let dt = dt_scale;
227237
let src_p = p1 - vel * dt + vec2<f32>(0.0, jitter * 2.0);
228-
238+
229239
var new_val = sample_grid(src_p.x, src_p.y);
230-
240+
231241
if (new_val > 0.0) {
232-
// Fire cooling (Constant to prevent spatial standing-wave interference)
233-
let base_cooling = 0.02;
234-
242+
// Fire cooling (Constant to prevent spatial standing-wave interference).
243+
// 0.016 (down from 0.02) lets tongues stretch taller before dying.
244+
let base_cooling = 0.016;
245+
235246
var cooling_rate = base_cooling * params.cooling_factor;
236247
// Embers (val > 1.0) cool logarithmically so they survive the journey upwards
237-
if (new_val > 1.0) { cooling_rate = new_val * 0.02; }
238-
248+
// (slower than fire cooling: real embers stay lit while they rise)
249+
if (new_val > 1.0) { cooling_rate = new_val * 0.012; }
250+
cooling_rate *= dt_scale;
251+
239252
new_val -= cooling_rate;
240253

241254
// COMBUSTION: When fire runs out of heat (crosses 0), it becomes smoke.
242255
if (new_val <= 0.0) {
243-
// A gentle puff of smoke when the flame dies (prevents screen-wide smoke explosion)
244-
new_val = -0.15;
256+
// A puff of smoke when the flame dies (enough to read as sooty
257+
// smoke above the flame tops, not a screen-wide haze)
258+
new_val = -0.28;
245259
}
246260
} else if (new_val < 0.0) {
247261
// Smoke dissipating slowly as it mixes with cold air
248-
new_val += 0.005;
262+
new_val += 0.005 * dt_scale;
249263
if (new_val > 0.0) { new_val = 0.0; }
250264
}
251265

0 commit comments

Comments
 (0)