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
28 KiB
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
DiskQualityenum
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
BlackHoleUniformsstruct
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
Defaultimpl
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 BlackHoleParams → BlackHoleUniforms 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(themirror_paramscopy 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 existingfbm3function, 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
DiskSamplestruct
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_colorwithdisk_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_colorshim 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 afterdisk_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_colorshim (remove it) -
Step 1: Remove the temporary
disk_colorshim
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 editparams.rsdefaultdisk_qualitytoDiskQuality::Offto 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 newCollapsingHeaderafter 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
Offdisables the 8 sliders and the disk reverts to smooth. -
Selecting
Highre-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.
-
Offtier: 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.
Offreverts 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.