From e00c37a3aec58c381761771eac5475561d963db1 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 14:57:21 +0800 Subject: [PATCH] fix(disk): unify slab integration to eliminate radial spokes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the radial spokes (the "zebra stripe" artifact): the volumetric integration used TWO overlapping sampling paths per RK45 step with inconsistent angle-dependent weights: (A) in-slab step: accum += density * step_len where step_len = full RK45 step (may extend far outside the slab) (B) edge-capture: accum += density * thickness_proj where thickness_proj = half_thickness / |dir.y| At a straddling step BOTH fired. step_len (RK45 adaptive) and thickness_proj vary with the ray's bending geometry differently, so their sum modulated brightness by angle → radial spokes whose count/position shifted with the camera angle. This was confirmed decisively: setting the texture to a completely uniform constant (no noise, no arms) STILL produced spokes, proving the texture was innocent and the integration was the source. Fix: replace the two-path design with a single unified path. Clip each RK45 step's segment to the slab |y|<=half_thickness (parametric clip in t-space), sample once at the clipped segment midpoint, accumulate × the in-slab segment length only. This gives consistent, geometry-correct weights with no double-counting. Verified: uniform-constant texture now renders a clean smooth ring (no spokes); full texture restores turbulent filaments without spokes. Also reverts the temporary uniform-constant debug texture to the full polar-sampled ridged+density+arms function. --- assets/shaders/black_hole.wgsl | 67 +++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/assets/shaders/black_hole.wgsl b/assets/shaders/black_hole.wgsl index ad4ca6a..37b7be8 100644 --- a/assets/shaders/black_hole.wgsl +++ b/assets/shaders/black_hole.wgsl @@ -316,13 +316,10 @@ fn disk_color_flat(pos: vec3, dir: vec3) -> DiskSample { return DiskSample(vec3(col), 0.85); } -// Volumetric disk color. Noise is sampled in POLAR coordinates, not the -// raw Cartesian pos, because sampling Cartesian (x,y,z) on a disk produces -// radial spokes: r changes move x/z a lot (high-frequency stripes) while a -// full angular sweep revisits correlated lattice points (weak variation), -// so stripes elongate along radius into spokes. Polar sampling (r_norm, -// phi·freq, height) decouples the two axes and lets turbulence flow -// tangentially — the physically correct pattern for a rotating fluid. +// Volumetric disk color. Noise is sampled in POLAR coordinates (r_norm, +// phi·freq, height) so turbulence flows tangentially — the correct pattern +// for a rotating fluid. Three layers multiply: ridged filaments (brightness), +// soft density clumping, logarithmic-spiral arms advected by Keplerian shear. fn disk_color_volumetric(pos: vec3, dir: vec3) -> DiskSample { let r = r_of(pos); let phi = atan2(pos.z, pos.x); @@ -349,8 +346,7 @@ fn disk_color_volumetric(pos: vec3, dir: vec3) -> DiskSample { let filament = ridged_fbm(sp * uniforms.filament_freq + warp * 1.5, filament_octaves, uniforms.filament_sharpness); - // Layer 2: density clumping (soft, no hard smoothstep cut → avoids - // a patchy/over-transparent slab). + // Layer 2: density clumping (soft ramp, no hard cut → avoids patchiness). let density_noise = fbm3(sp * uniforms.density_freq + warp, density_octaves); let base_density = (0.35 + 0.65 * density_noise) * uniforms.density_strength; @@ -574,28 +570,39 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { if (accum_alpha > 0.99) { break; } } } else { - // Volumetric tier. - // (A) In-slab per-step sampling: if this step ends inside the - // thickness slab, accumulate emission × arc length. Reuses the - // RK45 adaptive step — dense where light bends, sparse where straight. - let slab_r = r_of(new_pos); - if (abs(new_pos.y) < uniforms.disk_half_thickness - && slab_r >= uniforms.disk_inner - && slab_r <= uniforms.disk_outer) { - let s = disk_color_volumetric(new_pos, new_dir); - let step_len = length(new_pos - prev); - accum_color += (1.0 - accum_alpha) * s.color * s.density * step_len; - accum_alpha += (1.0 - accum_alpha) * s.density * step_len; + // Volumetric tier. Single unified path: clip THIS step's segment + // to the disk slab |y|<=half_thickness, sample once at the clipped + // segment's midpoint, accumulate × the in-slab segment length. + // The previous design used two overlapping paths (in-slab step + + // at-plane edge-capture) with inconsistent angle-dependent weights + // (step_len vs thickness_proj) that produced radial spokes. + let H = uniforms.disk_half_thickness; + let dy = new_pos.y - prev.y; + var seg_t0 = 0.0; + var seg_t1 = 1.0; + var has_slab_seg = false; + if (abs(dy) < 1e-6) { + // Step is parallel to the slab plane: in-slab iff prev is in-slab. + has_slab_seg = abs(prev.y) <= H; + } else { + // Clip the parametric line prev+t*(new_pos-prev) to |y|<=H. + let ta = (H - prev.y) / dy; + let tb = (-H - prev.y) / dy; + seg_t0 = clamp(min(ta, tb), 0.0, 1.0); + seg_t1 = clamp(max(ta, tb), 0.0, 1.0); + has_slab_seg = seg_t1 > seg_t0; } - // (B) Midplane edge-capture: if a step straddles y=0, add one - // precise at-plane sample weighted by the slab depth along the ray. - if (disk_hit(prev, new_pos)) { - let ty = prev.y / (prev.y - new_pos.y); - let hit = mix(prev, new_pos, vec3(ty)); - let s = disk_color_volumetric(hit, new_dir); - let thickness_proj = uniforms.disk_half_thickness / max(abs(new_dir.y), 1e-3); - accum_color += (1.0 - accum_alpha) * s.color * s.density * thickness_proj; - accum_alpha += (1.0 - accum_alpha) * s.density * thickness_proj; + + if (has_slab_seg) { + let mid_t = (seg_t0 + seg_t1) * 0.5; + let mid = mix(prev, new_pos, vec3(mid_t)); + let mid_r = r_of(mid); + if (mid_r >= uniforms.disk_inner && mid_r <= uniforms.disk_outer) { + let s = disk_color_volumetric(mid, new_dir); + let seg_len = (seg_t1 - seg_t0) * length(new_pos - prev); + accum_color += (1.0 - accum_alpha) * s.color * s.density * seg_len; + accum_alpha += (1.0 - accum_alpha) * s.density * seg_len; + } } if (accum_alpha > 0.99) { break; } }