diff --git a/assets/shaders/black_hole.wgsl b/assets/shaders/black_hole.wgsl index e91b296..23798e0 100644 --- a/assets/shaders/black_hole.wgsl +++ b/assets/shaders/black_hole.wgsl @@ -532,8 +532,15 @@ fn integrate_disk_segment(prev: vec3f, new_pos: vec3f, dir: vec3f, // Gaussian radial falloff, exponential length decay, outward-flowing noise, // δ^3.5 beaming (no floor needed — β=0.92 keeps the denominator positive). // Front-to-back composited into the same accumulators as the disk. -fn sample_jets(pos: vec3f, dir: vec3f, r_plus: f32, dt: f32, +fn sample_jets(pos: vec3f, dir: vec3f, r_plus: f32, accum_color: ptr, accum_alpha: ptr) { + // Relativistic jets are spin-powered (Blandford-Znajek): the mechanism taps + // the ergosphere, which only exists for a rotating hole. At χ ≈ 0 there is + // nothing to drive an outflow, so suppress the jets regardless of the + // jets_enabled toggle — the toggle expresses user intent, spin expresses + // physics. Without this gate the default scene (spin = 0, jets_enabled = + // true) shows blue/white columns over the poles that have no physical cause. + if (uniforms.spin < 0.05) { return; } let jet_v = abs(pos.y); let jet_max_h = 80.0; if (jet_v <= r_plus * 1.8 || jet_v >= jet_max_h) { @@ -564,12 +571,26 @@ fn sample_jets(pos: vec3f, dir: vec3f, r_plus: f32, dt: f32, let beta = abs(jet_vel); let gamma = 1.0 / sqrt(1.0 - beta * beta); let delta = 1.0 / (gamma * (1.0 - beta * cos_theta)); - let beaming = pow(delta, 3.5); + // Cap the beaming: with β=0.92 the approaching jet's δ^3.5 reaches ~258×, + // which blows the blue jet past the bloom threshold and saturates it to + // white. Clamp to 8× — still visibly brighter on the approaching side, but + // the base color (0.4, 0.7, 1.0) survives the HDR + bloom pipeline. + let beaming = min(pow(delta, 3.5), 8.0); let base_color = vec3f(0.4, 0.7, 1.0); - let emission = base_color * jet_density * 0.05 * beaming * uniforms.jets_strength * dt; + // Decouple accumulation from the RK45 step size. Multiplying by `dt` (the + // adaptive step the caller passes in) injects a per-pixel brightness + // modulation along the jet axis: dt varies across a 16× range (dt_min.. + // dt_max) and between neighbouring pixels, so `* dt` paints bright/dim + // bands and ring structures whose only cause is the integrator's step + // choice, not the physics. The disk path hit the identical bug (radial + // spokes) and fixed it by weighting on the analytic in-slab length instead + // of dt (see integrate_disk_segment). The jet has no comparable analytic + // segment to clip to, so use a fixed normalized weight (0.5) — brightness + // then depends only on geometry and beaming, never on dt. + let emission = base_color * jet_density * 0.05 * beaming * uniforms.jets_strength * 0.5; *accum_color += emission * (1.0 - *accum_alpha); - *accum_alpha += jet_density * 0.05 * uniforms.jets_strength * dt; + *accum_alpha += jet_density * 0.05 * uniforms.jets_strength * 0.5; } // --- planets --- @@ -785,7 +806,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { // --- relativistic jets (along the spin axis) --- if (uniforms.jets_enabled != 0u) { - sample_jets(new_pos, new_dir, r_plus, dt, &accum_color, &accum_alpha); + sample_jets(new_pos, new_dir, r_plus, &accum_color, &accum_alpha); if (accum_alpha > 0.99) { break; } }