fix(disk): eliminate radial spokes by decoupling density weight from RK45 step size
The previous per-step accumulator weighted density by `step_len` — the RK45 step length. That step size is spatially adaptive (small near the hole where curvature is high, large in flat regions) and varies between neighbouring pixels, so `density * step_len` injected a per-pixel brightness modulation along the radial direction: radial spokes, densest at the inner disk where steps are smallest. Fix: a new integrate_disk_segment clips each step's segment to the disk slab analytically and weights samples by `seg_len` — the world-space length of the ray-slab intersection, which depends only on the ray's incidence angle and the local slab height H, never on the RK45 step size. Slab height uses the step-midpoint r (H/R ratio; r varies <5% across one step). Within the clipped segment, N=4 uniform samples replace the single midpoint. A single sample under-integrates the Gaussian vertical decay and leaves the disk translucent; four samples resolve the density profile across the slab thickness while keeping the cost bounded (4 × 2-octave fbm per in-slab step). This recovers the pre-regression slab-clipping geometry but with segment-multi-sampling instead of one midpoint — the slab clip removes the step-size coupling (spokes), the multi-sample restores the integration depth (opacity). Off-tier (disk_quality == 0, flat path) is untouched: it samples a zero-thickness midplane with no slab, so step-size weighting never applied there.
This commit is contained in:
parent
b4f14b2f35
commit
359d2f876f
@ -453,6 +453,71 @@ fn disk_color_volumetric(pos: vec3<f32>, dir: vec3<f32>) -> DiskSample {
|
||||
return DiskSample(vec3<f32>(col), total_density);
|
||||
}
|
||||
|
||||
// Per-step disk integration that is DECOUPLED from the RK45 step size.
|
||||
//
|
||||
// Why this exists: the previous "sample at new_pos, weight by step_len" form
|
||||
// produced radial spokes. RK45's step size is spatially adaptive (small near
|
||||
// the hole where curvature is high, large in flat regions) AND varies between
|
||||
// neighbouring pixels, so weighting density by `step_len` injected a per-pixel
|
||||
// brightness modulation along the radial direction → spokes, densest at the
|
||||
// inner disk where steps are smallest.
|
||||
//
|
||||
// Fix: clip THIS step's segment to the disk slab analytically and weight each
|
||||
// sample by `seg_len` — the world-space length of the ray-slab intersection,
|
||||
// which depends only on the ray's incidence angle and the local slab height,
|
||||
// never on the RK45 step size. Then sample N points uniformly along the
|
||||
// clipped segment so the integral is well-resolved (a single midpoint sample
|
||||
// under-integrates the Gaussian vertical decay and leaves the disk translucent).
|
||||
//
|
||||
// `prev`/`new_pos` are disk-local (caller rotates by -disk_tilt).
|
||||
fn integrate_disk_segment(prev: vec3f, new_pos: vec3f, dir: vec3f,
|
||||
accum_color: ptr<function, vec3f>,
|
||||
accum_alpha: ptr<function, f32>) {
|
||||
// Slab height H is radius-dependent (H/R ratio). Use the step's midpoint r
|
||||
// to define H for this segment — the change in r across one RK45 step is
|
||||
// small, so this local-constant approximation is accurate enough.
|
||||
let mid = (prev + new_pos) * 0.5;
|
||||
let r_mid = r_of(mid);
|
||||
let H = r_mid * uniforms.disk_half_thickness;
|
||||
|
||||
// Clip the parametric segment prev + t·(new_pos − prev), t∈[0,1], to |y|≤H.
|
||||
let dy = new_pos.y - prev.y;
|
||||
var t0 = 0.0;
|
||||
var t1 = 1.0;
|
||||
if (abs(dy) < 1e-6) {
|
||||
// Step is parallel to the disk plane: in-slab iff prev is in-slab.
|
||||
if (abs(prev.y) > H) { return; }
|
||||
} else {
|
||||
let ta = ( H - prev.y) / dy;
|
||||
let tb = (-H - prev.y) / dy;
|
||||
t0 = clamp(min(ta, tb), 0.0, 1.0);
|
||||
t1 = clamp(max(ta, tb), 0.0, 1.0);
|
||||
if (t1 <= t0) { return; } // segment does not cross the slab
|
||||
}
|
||||
|
||||
// Analytic in-slab length — the key: depends on geometry, NOT on RK45 dt.
|
||||
let seg_len = (t1 - t0) * length(new_pos - prev);
|
||||
|
||||
// N uniform samples along the clipped segment, front-to-back composite.
|
||||
// Each sample carries density × (seg_len / N) so the N samples sum to the
|
||||
// full segment integral when density is roughly constant; the Gaussian
|
||||
// vertical decay is captured by sampling at differing heights within the
|
||||
// slab. N is a compile-time constant (WGSL requires static loop bounds).
|
||||
const N = 4u;
|
||||
for (var i: u32 = 0u; i < N; i = i + 1u) {
|
||||
let t = t0 + (t1 - t0) * (f32(i) + 0.5) / f32(N);
|
||||
let p = mix(prev, new_pos, vec3f(t));
|
||||
let rp = r_of(p);
|
||||
if (rp < uniforms.disk_inner || rp > uniforms.disk_outer) {
|
||||
continue; // radial clipping: outside the annulus
|
||||
}
|
||||
let s = disk_color_volumetric(p, dir);
|
||||
let ds = s.density * seg_len / f32(N);
|
||||
*accum_color += (1.0 - *accum_alpha) * s.color * ds;
|
||||
*accum_alpha += (1.0 - *accum_alpha) * ds;
|
||||
}
|
||||
}
|
||||
|
||||
// Relativistic jets along the spin axis (Y). Bipolar cones above/below the
|
||||
// disk with 0.92c outflow beaming. Ported from others/' sample_relativistic_jets:
|
||||
// Gaussian radial falloff, exponential length decay, outward-flowing noise,
|
||||
@ -700,31 +765,12 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
if (accum_alpha > 0.99) { break; }
|
||||
}
|
||||
} else {
|
||||
// Volumetric tier. Per-step accumulation: at every accepted step
|
||||
// whose endpoint lies inside the disk slab, sample density once and
|
||||
// accumulate × the geometric step length. This integrates the
|
||||
// emission/absorption along the ray through the slab — the standard
|
||||
// volumetric front-to-back composite — and is what makes the disk
|
||||
// read as solid and opaque rather than translucent.
|
||||
//
|
||||
// Replaces the old single-midpoint-in-clipped-slab design, which
|
||||
// under-sampled (one density value per ray-slab traversal →
|
||||
// see-through, jelly-like disk). Per-step integration matches the
|
||||
// others/ reference (fragment.glsl.ts sample_accretion_disk, called
|
||||
// every step of the raymarch loop).
|
||||
//
|
||||
// Thickness is now an H/R RATIO: H = r · disk_half_thickness, so the
|
||||
// slab grows with radius (a thin disk's geometry), not a constant
|
||||
// world-space band that is vanishingly thin at large r.
|
||||
let r_here = r_of(new_pos);
|
||||
let H = r_here * uniforms.disk_half_thickness;
|
||||
if (abs(new_pos.y) <= H &&
|
||||
r_here >= uniforms.disk_inner && r_here <= uniforms.disk_outer) {
|
||||
let step_len = length(new_pos - prev);
|
||||
let s = disk_color_volumetric(new_pos, new_dir);
|
||||
accum_color += (1.0 - accum_alpha) * s.color * s.density * step_len;
|
||||
accum_alpha += (1.0 - accum_alpha) * s.density * step_len;
|
||||
}
|
||||
// Volumetric tier: clip this step to the disk slab and integrate N
|
||||
// samples along the clipped segment, weighted by the analytic
|
||||
// in-slab length (NOT the RK45 step size). See integrate_disk_segment
|
||||
// for why the step-size-decoupled weight is what kills the radial
|
||||
// spokes that the previous `* step_len` form produced.
|
||||
integrate_disk_segment(prev, new_pos, new_dir, &accum_color, &accum_alpha);
|
||||
if (accum_alpha > 0.99) { break; }
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user