singularity-rs/docs/superpowers/plans/2026-07-15-volumetric-disk.md
xfy 3ab432417a docs(plan): volumetric accretion disk implementation plan
9-task plan for the volumetric disk spec. Tasks are ordered for
independent compilability (a temporary disk_color shim in Task 5 keeps
the build green until Task 7 restructures the main loop):
  1. DiskQuality enum + 8 params + defaults (params.rs)
  2. 9 uniform fields, Rust + WGSL in lockstep (material.rs, shader)
  3. mirror_params copies (plugin.rs)
  4. ridged_fbm noise (shader, inert)
  5. shared helpers + DiskSample + disk_color_flat (shader refactor)
  6. disk_color_volumetric: ridged + density + spiral arms (shader, inert)
  7. main-loop restructure: in-slab sampling + edge-capture + tier dispatch
  8. Disk Turbulence egui panel (ui.rs)
  9. full verification: cargo test, desktop + web visual + perf check
2026-07-15 11:31:33 +08:00

28 KiB
Raw Permalink Blame History

Volumetric Accretion Disk Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the smooth zero-thickness disk with a Gargantua-style volumetric gas disk — finite-thickness luminous slab, ridged bright filaments, density clumping, logarithmic-spiral arm modulation — all panel-tunable with a quality tier (Off/Low/Medium/High).

Architecture: Volumetric integration reuses the existing RK45 adaptive loop (approach A from the spec): each accepted step that lies inside the disk thickness slab samples the new disk_color_volumetric and accumulates emission × arc length. A ridged multifractal noise field drives bright filaments; a separate smoothstep-gated FBM drives density; a logarithmic-spiral term riding the Keplerian shear drives large-scale arms. physics.rs is untouched (volumetric integration is render-sampling layer only).

Tech Stack: Bevy 0.19, WGSL, egui. Natural units (Rs = 1).

Spec: docs/superpowers/specs/2026-07-15-volumetric-disk-design.md


File Structure

File Responsibility Change
src/params.rs DiskQuality enum + tunable params + defaults Add enum, 8 f32 fields, tier field, web/desktop defaults
src/render/material.rs GPU uniform struct (BlackHoleUniforms) Add 9 fields (8 f32 + 1 u32) to struct + Default
assets/shaders/black_hole.wgsl The renderer Add uniform fields, ridged_fbm, DiskSample, disk_color_volumetric, disk_color_flat, shared helpers; restructure main-loop disk handling
src/render/plugin.rs Per-frame param→uniform mirror Copy 9 new fields in mirror_params
src/ui.rs egui Controls panel Add "Disk turbulence" collapsible section

Not touched: src/physics.rs, src/lib.rs, bloom/brightpass/blur/composite shaders, src/camera.rs, src/scene/planets.rs, src/web.rs.


Task 1: Add DiskQuality enum + params fields

Extend BlackHoleParams with the new tunables and a DiskQuality tier enum. This is the data layer — nothing reads it yet, so it compiles standalone.

Files:

  • Modify: src/params.rs

  • Step 1: Add the DiskQuality enum

Insert after the BloomQuality impl block (after src/params.rs:22, the closing } of impl BloomQuality):

/// Disk volumetric rendering quality. Gates noise octave counts.
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub enum DiskQuality {
    Off,     // flat zero-thickness fallback (current appearance)
    Low,     // 3/2/2 octaves — web default
    Medium,  // 4/3/3 octaves
    #[default]
    High,    // 5/4/3 octaves — desktop default
}

impl DiskQuality {
    /// Returns (filament_octaves, density_octaves, warp_octaves).
    /// Off returns zeros; the shader dispatches to the flat path instead.
    pub fn octaves(self) -> (u32, u32, u32) {
        match self {
            DiskQuality::Off => (0, 0, 0),
            DiskQuality::Low => (3, 2, 2),
            DiskQuality::Medium => (4, 3, 3),
            DiskQuality::High => (5, 4, 3),
        }
    }
}
  • Step 2: Add the 8 f32 fields + tier field to BlackHoleParams

Add these fields to the BlackHoleParams struct, after bloom_quality: BloomQuality, (after src/params.rs:56):

    // Disk turbulence (Phase 3.1: volumetric disk)
    pub disk_half_thickness: f32,
    pub filament_freq: f32,
    pub filament_sharpness: f32,
    pub density_freq: f32,
    pub density_strength: f32,
    pub arm_count: f32,
    pub arm_tightness: f32,
    pub arm_strength: f32,
    pub disk_quality: DiskQuality,
  • Step 3: Add the defaults to impl Default

Add these to the Self { ... } literal in Default::default (after bloom_quality: ... at src/params.rs:82):

            disk_half_thickness: if cfg!(target_arch = "wasm32") { 0.2 } else { 0.3 },
            filament_freq: 1.0,
            filament_sharpness: 2.0,
            density_freq: 0.8,
            density_strength: 1.0,
            arm_count: 2.0,
            arm_tightness: 2.0,
            arm_strength: 0.5,
            disk_quality: if cfg!(target_arch = "wasm32") { DiskQuality::Low } else { DiskQuality::High },
  • Step 4: Verify it compiles

Run: cargo build Expected: compiles with no errors (the new fields are unused yet, but #[allow(dead_code)] on the struct suppresses warnings).

  • Step 5: Commit
git add src/params.rs
git commit -m "feat(params): add DiskQuality enum + volumetric disk params

Data layer for the volumetric disk. DiskQuality gates octave counts
(Off/Low/Medium/High); Off is a full revert to the flat disk. Eight new
tunable f32 fields for thickness, filament, density, and spiral-arm
control. Web defaults to Low tier + 0.2 half-thickness; desktop to
High + 0.3. Nothing reads these yet."

Task 2: Extend the GPU uniform struct

Add the 9 fields (8 f32 + 1 u32 tier) to both the Rust BlackHoleUniforms and the WGSL BlackHoleUniforms, keeping them in lockstep. The order and types must match exactly between Rust and WGSL.

Files:

  • Modify: src/render/material.rs

  • Modify: assets/shaders/black_hole.wgsl:14-42 (the WGSL uniform struct)

  • Step 1: Add fields to the Rust BlackHoleUniforms struct

In src/render/material.rs, add these fields to pub struct BlackHoleUniforms after pub _pad5: f32, (after line 45):

    // Disk volumetric (Phase 3.1)
    pub disk_half_thickness: f32,
    pub filament_freq: f32,
    pub filament_sharpness: f32,
    pub density_freq: f32,
    pub density_strength: f32,
    pub arm_count: f32,
    pub arm_tightness: f32,
    pub arm_strength: f32,
    pub disk_quality: u32,
  • Step 2: Add defaults to the Rust Default impl

In impl Default for BlackHoleUniforms (src/render/material.rs:48-84), add after _pad5: 0.0, (after line 81):

            disk_half_thickness: 0.3,
            filament_freq: 1.0,
            filament_sharpness: 2.0,
            density_freq: 0.8,
            density_strength: 1.0,
            arm_count: 2.0,
            arm_tightness: 2.0,
            arm_strength: 0.5,
            disk_quality: 3, // High
  • Step 3: Add fields to the WGSL uniform struct

In assets/shaders/black_hole.wgsl, replace the struct tail (lines 38-42):

    bloom_threshold: f32,
    bloom_strength: f32,
    exposure: f32,
    _pad5: f32,
};

with:

    bloom_threshold: f32,
    bloom_strength: f32,
    exposure: f32,
    _pad5: f32,
    disk_half_thickness: f32,
    filament_freq: f32,
    filament_sharpness: f32,
    density_freq: f32,
    density_strength: f32,
    arm_count: f32,
    arm_tightness: f32,
    arm_strength: f32,
    disk_quality: u32,
};
  • Step 4: Verify it compiles + shader reflects

Run: cargo build Expected: compiles. ShaderType derive generates the uniform layout from the Rust struct; as long as the WGSL field names/types match, bind group reflection will align. (A mismatch surfaces at runtime as a validation error / grey screen, per the AGENTS.md gotchas — so this match is critical.)

  • Step 5: Commit
git add src/render/material.rs assets/shaders/black_hole.wgsl
git commit -m "feat(uniform): add 9 volumetric-disk fields to BlackHoleUniforms

Rust and WGSL structs kept in lockstep: 8 f32 tunables + disk_quality
u32 tier. Appended after _pad5; scalar fields pack contiguously so no
explicit padding needed. Defaults mirror BlackHoleParams::default."

Task 3: Mirror the new params into the uniform each frame

Wire BlackHoleParamsBlackHoleUniforms in the existing per-frame mirror_params system. After this task, the GPU receives the new values (though nothing in the shader uses them yet).

Files:

  • Modify: src/render/plugin.rs:579-623 (the mirror_params copy loop)

  • Step 1: Add the field copies

In src/render/plugin.rs, inside the for (_, mat) in materials.iter_mut() loop in mirror_params, add after u.exposure = params.exposure; (after line 622):

        u.disk_half_thickness = params.disk_half_thickness;
        u.filament_freq = params.filament_freq;
        u.filament_sharpness = params.filament_sharpness;
        u.density_freq = params.density_freq;
        u.density_strength = params.density_strength;
        u.arm_count = params.arm_count;
        u.arm_tightness = params.arm_tightness;
        u.arm_strength = params.arm_strength;
        u.disk_quality = params.disk_quality as u32;
  • Step 2: Verify it compiles

Run: cargo build Expected: compiles cleanly.

  • Step 3: Commit
git add src/render/plugin.rs
git commit -m "feat(mirror): copy volumetric-disk params into uniform each frame

Extends mirror_params with the 9 new field assignments. DiskQuality enum
is cast to u32 for the WGSL tier selector. GPU now receives live values;
shader does not consume them yet."

Task 4: Add ridged_fbm noise function to the shader

Add the ridged multifractal noise that produces sharp bright filaments. This is a standalone helper — no caller yet, so it compiles but is inert. Uses the fixed-MAX_OCTAVES-with-break pattern for WebGPU driver safety.

Files:

  • Modify: assets/shaders/black_hole.wgsl (add after the existing fbm3 function, which ends at line 218)

  • Step 1: Add ridged_fbm

Insert immediately after the closing } of fn fbm3 (assets/shaders/black_hole.wgsl:218):

// Ridged multifractal noise: 1 - |2n-1| turns value-noise gradients into
// sharp ridges (peak where n=0.5, zero at n=0 and n=1). Raising to
// `sharpness` thins the ridges into filaments. MAX_OCTAVES-with-break is
// the conservative WebGPU form for a runtime-chosen octave count.
fn ridged_fbm(p: vec3<f32>, octaves: u32, sharpness: f32) -> f32 {
    const MAX_OCTAVES = 6u;
    var sum = 0.0;
    var amp = 0.5;
    var freq = 1.0;
    for (var i: u32 = 0u; i < MAX_OCTAVES; i = i + 1u) {
        if (i >= octaves) { break; }
        let n = value_noise3(p * freq);
        let ridge = 1.0 - abs(2.0 * n - 1.0);
        sum = sum + amp * pow(ridge, sharpness);
        freq = freq * 2.0;
        amp = amp * 0.5;
    }
    return sum;
}
  • Step 2: Verify the shader still compiles

Run: cargo build Expected: compiles. (ridged_fbm is unused, but WGSL does not warn on unused functions the way Rust does; value_noise3 is already defined at line 187.)

  • Step 3: Commit
git add assets/shaders/black_hole.wgsl
git commit -m "feat(shader): add ridged_fbm multifractal noise

Ridged noise (1 - |2n-1|, raised to sharpness) produces the sharp bright
filaments the spec calls for, replacing the smooth FBM blobs. Fixed
MAX_OCTAVES=6 with early break — conservative form for runtime-chosen
octave counts on older WebGPU drivers. Inert; no caller yet."

Task 5: Extract shared physics helpers + add DiskSample + disk_color_flat

Refactor the existing disk_color (assets/shaders/black_hole.wgsl:226-255) into shared helpers plus a flat fallback that returns a DiskSample. This preserves the exact current appearance behind the Off tier and sets up the struct that the volumetric path (Task 6) will also return.

Files:

  • Modify: assets/shaders/black_hole.wgsl:226-255

  • Step 1: Add the DiskSample struct

Insert immediately before fn disk_color (before line 226):

// Result of a disk color query: emitted radiance + opacity contribution.
// Both the volumetric and flat paths return this struct so the main loop
// can treat them uniformly.
struct DiskSample {
    color: vec3<f32>,
    density: f32,
}
  • Step 2: Add shared helpers

Insert immediately after the DiskSample struct (before fn disk_color):

// Radial temperature gradient: white-hot inner → deep-orange outer.
fn temperature_color(t: f32) -> vec3<f32> {
    return mix(vec3<f32>(1.0, 0.95, 0.85), vec3<f32>(1.0, 0.45, 0.12), clamp(t, 0.0, 1.0));
}

// Radial brightness falloff (∝ 1/r² from the inner edge).
fn radial_falloff(r: f32, inner: f32) -> f32 {
    return 1.0 / pow(r / inner, 2.0);
}

// Relativistic Doppler beaming. `dir` is the ray direction (disk-local).
fn apply_doppler(col: vec3<f32>, pos: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
    let phi = atan2(pos.z, pos.x);
    let v_orbital = sqrt(uniforms.rs / (2.0 * r_of(pos)));
    let tangent = normalize(vec3<f32>(-sin(phi), 0.0, cos(phi)));
    let vdotn = dot(tangent * v_orbital, -dir);
    let gamma = 1.0 / sqrt(max(1.0 - v_orbital * v_orbital, 1e-4));
    if (uniforms.doppler_enabled == 0u) {
        return col;
    }
    let delta = 1.0 / (gamma * (1.0 - vdotn));
    let doppler = pow(delta, 3.0) * uniforms.doppler_strength;
    return col * doppler;
}

// Cylindrical radius in the disk plane.
fn r_of(pos: vec3<f32>) -> f32 {
    return length(vec2<f32>(pos.x, pos.z));
}
  • Step 3: Replace disk_color with disk_color_flat

Replace the entire fn disk_color(pos: vec3<f32>, dir: vec3<f32>) -> vec3<f32> (lines 226-255) with:

// Off-tier fallback: zero-thickness disk, single sample, fixed alpha.
// Preserves the exact pre-volumetric appearance. Returns DiskSample so the
// main loop dispatches both paths uniformly.
fn disk_color_flat(pos: vec3<f32>, dir: vec3<f32>) -> DiskSample {
    let r = r_of(pos);
    let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5);
    let noise = disk_noise(vec3<f32>(pos.x * 0.3, pos.z * 0.3, rot), uniforms.time);

    let t = (r - uniforms.disk_inner) / (uniforms.disk_outer - uniforms.disk_inner);
    let tcol = temperature_color(t);
    let falloff = radial_falloff(r, uniforms.disk_inner);

    var col = tcol * (0.6 + 0.4 * noise) * falloff * uniforms.disk_brightness;
    col = apply_doppler(col, pos, dir);

    return DiskSample(vec3<f32>(col), 0.85);
}
  • Step 4: Add a temporary disk_color shim to keep the build green

disk_color is renamed to disk_color_flat, but the main loop (line 450) still calls disk_color. To keep every task independently compilable, add a one-line shim right after disk_color_flat. (Removed in Task 7 when the main loop is restructured to call the new functions directly.)

Insert immediately after disk_color_flat:

// TEMPORARY shim — removed in Task 7 when the main loop is restructured.
fn disk_color(pos: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
    return disk_color_flat(pos, dir).color;
}
  • Step 5: Verify it compiles + app runs identically

Run: cargo build Expected: compiles.

Run: cargo run --release Expected: the disk looks identical to before (the shim routes through disk_color_flat, which reproduces the old math).

  • Step 6: Commit
git add assets/shaders/black_hole.wgsl
git commit -m "refactor(shader): extract disk helpers, add DiskSample + flat fallback

Splits disk_color into shared helpers (temperature_color, radial_falloff,
apply_doppler, r_of) reused by both the flat and volumetric paths.
disk_color_flat returns a DiskSample with the old fixed 0.85 alpha,
preserving the exact pre-volumetric appearance behind the Off tier. A
temporary disk_color shim keeps the main-loop call site compiling until
Task 7 restructures it."

Task 6: Add disk_color_volumetric

The volumetric color function: ridged filaments for brightness, smoothstep-gated FBM for density, logarithmic-spiral arm modulation. Returns the same DiskSample struct.

Files:

  • Modify: assets/shaders/black_hole.wgsl (add after disk_color_flat + its shim)

  • Step 1: Add disk_color_volumetric

Insert immediately after the temporary disk_color shim from Task 5:

// Volumetric disk color: ridged filaments drive brightness, a smoothstep-
// gated FBM drives density clumping, and a logarithmic-spiral term (riding
// the Keplerian shear `rot`) drives large-scale arm structure. The three
// signals multiply — density says where matter is, filaments say how bright,
// arms say how it's distributed.
fn disk_color_volumetric(pos: vec3<f32>, dir: vec3<f32>) -> DiskSample {
    let r = r_of(pos);
    let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5);
    let flow = vec3<f32>(0.0, 0.0, rot);

    // Octave triplet from the quality tier.
    let q = uniforms.disk_quality;
    var filament_octaves = 5u; var density_octaves = 4u; var warp_octaves = 3u;
    if (q == 1u) { filament_octaves = 3u; density_octaves = 2u; warp_octaves = 2u; }
    else if (q == 2u) { filament_octaves = 4u; density_octaves = 3u; warp_octaves = 3u; }
    // q == 3u keeps the High defaults above; q == 0u is never passed here.

    // Domain warp: distorts sample coords so filaments curve and bend.
    let warp = fbm3(pos * 0.8 + flow * 0.1, warp_octaves);

    // Layer 1: ridged bright filaments.
    let filament = ridged_fbm(pos * uniforms.filament_freq + warp * 1.5 + flow * 0.3,
                              filament_octaves, uniforms.filament_sharpness);

    // Layer 2: density clumping (smoothstep makes a definite gas/void boundary).
    let density_noise = fbm3(pos * uniforms.density_freq + warp, density_octaves);
    let base_density = smoothstep(0.3, 0.7, density_noise) * uniforms.density_strength;

    // Layer 3: logarithmic-spiral arm modulation, advected by Keplerian shear.
    let phi = atan2(pos.z, pos.x);
    let arm_phase = phi * uniforms.arm_count + log(r) * uniforms.arm_tightness - rot;
    let arm = 0.5 + 0.5 * cos(arm_phase);
    let arm_mod = mix(1.0, pow(arm, 2.0), uniforms.arm_strength);

    let total_density = base_density * arm_mod;
    let brightness = filament;

    let t = (r - uniforms.disk_inner) / (uniforms.disk_outer - uniforms.disk_inner);
    let tcol = temperature_color(t);
    let falloff = radial_falloff(r, uniforms.disk_inner);

    var col = tcol * brightness * falloff * uniforms.disk_brightness;
    col = apply_doppler(col, pos, dir);

    return DiskSample(vec3<f32>(col), total_density);
}
  • Step 2: Verify it compiles

Run: cargo build Expected: compiles. (Function is unused; ridged_fbm, fbm3, value_noise3 all defined.)

  • Step 3: Commit
git add assets/shaders/black_hole.wgsl
git commit -m "feat(shader): add disk_color_volumetric (ridged + density + arms)

Three multiplying signal layers: ridged_fbm filaments for brightness,
smoothstep-gated FBM for density clumping, logarithmic-spiral arm
modulation riding the Keplerian shear. Octave triplet selected from
disk_quality tier. Returns DiskSample; inert until Task 7 wires it in."

Task 7: Restructure the main loop for volumetric integration

This is the core integration change. Replace the single disk_hit sample with: (A) in-slab per-step sampling, (B) midplane edge-capture, dispatching between flat and volumetric paths by tier. Remove the Task-5 shim.

Files:

  • Modify: assets/shaders/black_hole.wgsl:447-455 (the disk block in the main loop)

  • Modify: the temporary disk_color shim (remove it)

  • Step 1: Remove the temporary disk_color shim

Delete the shim added in Task 5 Step 4:

// TEMPORARY shim — removed in Task 7 when the main loop is restructured.
fn disk_color(pos: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
    return disk_color_flat(pos, dir).color;
}
  • Step 2: Replace the disk-handling block in the main loop

In assets/shaders/black_hole.wgsl, replace the current disk block (lines 447-455):

        if (disk_hit(prev, new_pos)) {
            let ty = prev.y / (prev.y - new_pos.y);
            let hit = mix(prev, new_pos, vec3<f32>(ty));
            let dc = disk_color(hit, new_dir);
            let a = 0.85;
            accum_color += (1.0 - accum_alpha) * dc * a;
            accum_alpha += (1.0 - accum_alpha) * a;
            if (accum_alpha > 0.99) { break; }
        }

with:

        // --- volumetric disk ---
        if (uniforms.disk_quality == 0u) {
            // Off tier: zero-thickness single midplane sample, fixed alpha.
            if (disk_hit(prev, new_pos)) {
                let ty = prev.y / (prev.y - new_pos.y);
                let hit = mix(prev, new_pos, vec3<f32>(ty));
                let s = disk_color_flat(hit, new_dir);
                accum_color += (1.0 - accum_alpha) * s.color * s.density;
                accum_alpha += (1.0 - accum_alpha) * s.density;
                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;
            }
            // (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<f32>(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 (accum_alpha > 0.99) { break; }
        }
  • Step 3: Verify it compiles

Run: cargo build Expected: compiles with no errors. (Both disk_color_flat and disk_color_volumetric return DiskSample; the main loop reads .color and .density.)

  • Step 4: Verify visually at all tiers

Run: cargo run --release Expected:

  • With default params (High tier), the disk shows bright filaments, density clumping, and spiral-arm structure — visibly different from the smooth original.

  • Switching the panel to Off (once Task 8 adds the panel) reverts to the smooth disk. (If running this step before Task 8, temporarily edit params.rs default disk_quality to DiskQuality::Off to confirm the flat path works, then revert.)

  • Step 5: Verify physics tests are unaffected

Run: cargo test Expected: all tests pass (the mirror in physics.rs is not modified by this plan; this confirms no accidental regression).

  • Step 6: Commit
git add assets/shaders/black_hole.wgsl
git commit -m "feat(render): volumetric disk integration in the RK45 loop

Restructures the disk block in the main loop:
- Off tier: flat disk_color_flat single midplane sample (old behavior).
- Volumetric tiers: (A) in-slab per-step sampling accumulates emission ×
  arc length, reusing the RK45 adaptive step density; (B) midplane edge-
  capture weights one at-plane sample by slab depth along the ray.
Removes the Task-5 disk_color shim. physics.rs untouched; cargo test green."

Task 8: Add the "Disk turbulence" egui panel section

Wire all 8 sliders + the quality dropdown into the Controls window so the user can tune live. Sliders disable when quality is Off.

Files:

  • Modify: src/ui.rs (insert a new CollapsingHeader after the existing "Accretion Disk" section, which ends at line 38)

  • Step 1: Add the panel section

In src/ui.rs, insert this new CollapsingHeader immediately after the "Accretion Disk" block's closing }); (after line 38, before the "Doppler" header at line 39):

                egui::CollapsingHeader::new("Disk Turbulence")
                    .default_open(true)
                    .show(ui, |ui| {
                        use crate::params::DiskQuality;
                        let mut q = params.disk_quality;
                        egui::ComboBox::from_label("Disk quality")
                            .selected_text(format!("{:?}", q))
                            .show_ui(ui, |ui| {
                                ui.selectable_value(&mut q, DiskQuality::Off, "Off");
                                ui.selectable_value(&mut q, DiskQuality::Low, "Low");
                                ui.selectable_value(&mut q, DiskQuality::Medium, "Medium");
                                ui.selectable_value(&mut q, DiskQuality::High, "High");
                            });
                        params.disk_quality = q;
                        let on = q != DiskQuality::Off;
                        ui.add_enabled(on, egui::Slider::new(&mut params.disk_half_thickness, 0.05..=1.0).text("Half thickness"));
                        ui.add_enabled(on, egui::Slider::new(&mut params.filament_freq, 0.2..=4.0).text("Filament frequency"));
                        ui.add_enabled(on, egui::Slider::new(&mut params.filament_sharpness, 1.0..=6.0).text("Filament sharpness"));
                        ui.add_enabled(on, egui::Slider::new(&mut params.density_freq, 0.2..=3.0).text("Density frequency"));
                        ui.add_enabled(on, egui::Slider::new(&mut params.density_strength, 0.0..=2.0).text("Density strength"));
                        ui.add_enabled(on, egui::Slider::new(&mut params.arm_count, 0.0..=6.0).text("Arm count"));
                        ui.add_enabled(on, egui::Slider::new(&mut params.arm_tightness, 0.0..=6.0).text("Arm tightness"));
                        ui.add_enabled(on, egui::Slider::new(&mut params.arm_strength, 0.0..=1.0).text("Arm strength"));
                    });
  • Step 2: Verify it compiles

Run: cargo build Expected: compiles.

  • Step 3: Verify the panel works live

Run: cargo run --release Expected:

  • A "Disk Turbulence" collapsible appears in the Controls window between "Accretion Disk" and "Doppler".

  • Selecting Off disables the 8 sliders and the disk reverts to smooth.

  • Selecting High re-enables them; dragging each slider visibly changes the disk in real time.

  • The quality combo and slider-enable pattern mirror the existing Bloom quality block.

  • Step 4: Commit

git add src/ui.rs
git commit -m "feat(ui): Disk Turbulence panel with quality tier + 8 sliders

Live-tunable volumetric disk params. Quality ComboBox (Off/Low/Medium/
High) mirrors the Bloom quality pattern; the 8 sliders disable when Off.
Off reverts to the flat disk for perf escape + visual A/B."

Task 9: Final verification + web check

Confirm the full system works end-to-end on both targets and that no regression slipped in.

Files: none modified.

  • Step 1: Full test suite

Run: cargo test Expected: all tests pass. (physics.rs is untouched by this entire plan; this is a regression guard.)

  • Step 2: Desktop release build + visual check

Run: cargo run --release Expected:

  • Default High tier: disk shows ridged bright filaments, density clumping (bright knots + dark gaps), and logarithmic-spiral arm structure winding with differential rotation. Doppler left/right asymmetry preserved. Slab thickness produces feathered edges at the disk limb.

  • Compare against the Gargantua reference for the intended aesthetic.

  • Off tier: disk reverts to the smooth pre-volumetric appearance.

  • Tune the 8 sliders; each produces a visible, sensible response.

  • Step 3: Web build + Low-tier frame-rate check

Run: trunk serve Expected:

  • App loads at http://127.0.0.1:8080 with WebGPU.
  • Low tier (web default) renders the volumetric disk at an acceptable frame rate.
  • Off reverts to flat and is fastest.

If Low tier frame rate is poor on web, the spec's mitigation applies: lower the web-default filament_freq in src/params.rs. Document the finding.

  • Step 4: Density-strength tuning check

Per spec risk #5, inspect whether the disk is over-transparent (background bleeds through too much) or over-opaque (a solid ring). Adjust density_strength default in src/params.rs if needed and re-check. Commit any default change separately:

git add src/params.rs
git commit -m "tune(params): adjust density_strength default after visual check"
  • Step 5: Final commit (only if any tuning changes were made in Step 4)

No commit needed if defaults held. This step exists only to capture tuning.