From 2a0c3bbe69d7407c7e1d29f958e0cf56b974fc3b Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 11:55:17 +0800 Subject: [PATCH 01/13] 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. --- src/params.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/params.rs b/src/params.rs index 337063f..1906219 100644 --- a/src/params.rs +++ b/src/params.rs @@ -21,6 +21,31 @@ impl BloomQuality { } } +/// Disk volumetric rendering quality. Gates noise octave counts. +#[allow(dead_code)] // consumed starting Task 7 (tier dispatch) + Task 8 (egui) +#[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. + #[allow(dead_code)] // read in Task 7's tier dispatch + 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), + } + } +} + /// All tunable black-hole parameters. Edited by the egui panel (Task 17), /// mirrored into BlackHoleUniforms each frame (Task 7). #[derive(Resource, Clone)] @@ -54,6 +79,16 @@ pub struct BlackHoleParams { pub bloom_strength: f32, pub exposure: f32, pub bloom_quality: BloomQuality, + // 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, } impl Default for BlackHoleParams { @@ -80,6 +115,15 @@ impl Default for BlackHoleParams { bloom_strength: 0.8, exposure: 1.0, bloom_quality: if cfg!(target_arch = "wasm32") { BloomQuality::Low } else { BloomQuality::High }, + 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 }, } } } From 35ae3ce5cc30d677388e6c57bd0af76680e3d7ae Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 13:18:02 +0800 Subject: [PATCH 02/13] 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. --- assets/shaders/black_hole.wgsl | 9 +++++++++ src/render/material.rs | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/assets/shaders/black_hole.wgsl b/assets/shaders/black_hole.wgsl index 0433c96..8f569b2 100644 --- a/assets/shaders/black_hole.wgsl +++ b/assets/shaders/black_hole.wgsl @@ -39,6 +39,15 @@ struct BlackHoleUniforms { 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, }; @group(#{MATERIAL_BIND_GROUP}) @binding(0) var uniforms: BlackHoleUniforms; diff --git a/src/render/material.rs b/src/render/material.rs index 2a65bef..12a74b8 100644 --- a/src/render/material.rs +++ b/src/render/material.rs @@ -43,6 +43,16 @@ pub struct BlackHoleUniforms { pub bloom_strength: f32, pub exposure: f32, pub _pad5: f32, + // 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, } impl Default for BlackHoleUniforms { @@ -79,6 +89,15 @@ impl Default for BlackHoleUniforms { bloom_strength: 0.8, exposure: 1.0, _pad5: 0.0, + 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 } } } From 827edd35a224797f53c418ebf2661ed9e1f987ea Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 13:22:15 +0800 Subject: [PATCH 03/13] feat(mirror): copy volumetric-disk params into uniform each frame Extends mirror_params with the 9 new field assignments. DiskQuality enum gains an as_u32() accessor for the WGSL tier selector. GPU now receives live values; shader does not consume them yet. --- src/params.rs | 10 ++++++++++ src/render/plugin.rs | 9 +++++++++ 2 files changed, 19 insertions(+) diff --git a/src/params.rs b/src/params.rs index 1906219..812c913 100644 --- a/src/params.rs +++ b/src/params.rs @@ -44,6 +44,16 @@ impl DiskQuality { DiskQuality::High => (5, 4, 3), } } + + /// Tier as a u32 for the WGSL uniform selector. + pub fn as_u32(self) -> u32 { + match self { + DiskQuality::Off => 0, + DiskQuality::Low => 1, + DiskQuality::Medium => 2, + DiskQuality::High => 3, + } + } } /// All tunable black-hole parameters. Edited by the egui panel (Task 17), diff --git a/src/render/plugin.rs b/src/render/plugin.rs index f83284f..7c91d6f 100644 --- a/src/render/plugin.rs +++ b/src/render/plugin.rs @@ -620,6 +620,15 @@ fn mirror_params( u.bloom_threshold = params.bloom_threshold; u.bloom_strength = params.bloom_strength; u.exposure = params.exposure; + 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(); } // Update brightpass threshold (live-tunable). for (_, mat) in brightpass_materials.iter_mut() { From c9cc2eec990abb88207c62e4d5ef6066d3da9457 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 13:24:16 +0800 Subject: [PATCH 04/13] feat(shader): add ridged_fbm multifractal noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- assets/shaders/black_hole.wgsl | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/assets/shaders/black_hole.wgsl b/assets/shaders/black_hole.wgsl index 8f569b2..b450b20 100644 --- a/assets/shaders/black_hole.wgsl +++ b/assets/shaders/black_hole.wgsl @@ -226,6 +226,26 @@ fn fbm3(p: vec3, octaves: u32) -> f32 { return sum; } +// 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, 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; +} + fn disk_noise(pos: vec3, t: f32) -> f32 { let warp = fbm3(pos * 0.8 + vec3(0.0, 0.0, t * 0.1), 3u); let n = fbm3(pos * 2.0 + warp * 1.5 + vec3(0.0, 0.0, t * 0.3), 4u); From de4ceee8fff8bdf735a895408257ae6bebda097d Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 13:29:48 +0800 Subject: [PATCH 05/13] 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. --- assets/shaders/black_hole.wgsl | 69 +++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/assets/shaders/black_hole.wgsl b/assets/shaders/black_hole.wgsl index b450b20..855e232 100644 --- a/assets/shaders/black_hole.wgsl +++ b/assets/shaders/black_hole.wgsl @@ -252,10 +252,49 @@ fn disk_noise(pos: vec3, t: f32) -> f32 { return n; } -fn disk_color(pos: vec3, dir: vec3) -> vec3 { - let r = length(vec2(pos.x, pos.z)); - let phi = atan2(pos.z, pos.x); +// 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, + density: f32, +} +// Radial temperature gradient: white-hot inner → deep-orange outer. +fn temperature_color(t: f32) -> vec3 { + return mix(vec3(1.0, 0.95, 0.85), vec3(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); +} + +// Cylindrical radius in the disk plane. +fn r_of(pos: vec3) -> f32 { + return length(vec2(pos.x, pos.z)); +} + +// Relativistic Doppler beaming. `dir` is the ray direction (disk-local). +fn apply_doppler(col: vec3, pos: vec3, dir: vec3) -> vec3 { + let phi = atan2(pos.z, pos.x); + let v_orbital = sqrt(uniforms.rs / (2.0 * r_of(pos))); + let tangent = normalize(vec3(-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; +} + +// 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, dir: vec3) -> DiskSample { + let r = r_of(pos); let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5); // Domain-warped FBM for feathered/smoky gas texture. The Keplerian shear // (rot ∝ 1/r^1.5) is folded into the noise flow term so inner radii flow @@ -263,24 +302,18 @@ fn disk_color(pos: vec3, dir: vec3) -> vec3 { let noise = disk_noise(vec3(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 = mix(vec3(1.0, 0.95, 0.85), vec3(1.0, 0.45, 0.12), clamp(t, 0.0, 1.0)); + let tcol = temperature_color(t); + let falloff = radial_falloff(r, uniforms.disk_inner); - let falloff = 1.0 / pow(r / uniforms.disk_inner, 2.0); + var col = tcol * (0.6 + 0.4 * noise) * falloff * uniforms.disk_brightness; + col = apply_doppler(col, pos, dir); - var col = tcol * (0.6 + 0.4 * noise) * falloff; + return DiskSample(vec3(col), 0.85); +} - let v_orbital = sqrt(uniforms.rs / (2.0 * r)); - let tangent = normalize(vec3(-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)); - var doppler = 1.0; - if (uniforms.doppler_enabled != 0u) { - let delta = 1.0 / (gamma * (1.0 - vdotn)); - doppler = pow(delta, 3.0) * uniforms.doppler_strength; - } - col *= doppler; - - return col * uniforms.disk_brightness; +// TEMPORARY shim — removed in Task 7 when the main loop is restructured. +fn disk_color(pos: vec3, dir: vec3) -> vec3 { + return disk_color_flat(pos, dir).color; } // --- planets --- From 107fbd0a74a2fff36f2c9bccbeecffa3d1488a64 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 13:37:52 +0800 Subject: [PATCH 06/13] 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. --- assets/shaders/black_hole.wgsl | 47 ++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/assets/shaders/black_hole.wgsl b/assets/shaders/black_hole.wgsl index 855e232..661671e 100644 --- a/assets/shaders/black_hole.wgsl +++ b/assets/shaders/black_hole.wgsl @@ -316,6 +316,53 @@ fn disk_color(pos: vec3, dir: vec3) -> vec3 { return disk_color_flat(pos, dir).color; } +// 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, dir: vec3) -> DiskSample { + let r = r_of(pos); + let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5); + let flow = vec3(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(col), total_density); +} + // --- planets --- // `prev`/`cur` are in DISK-LOCAL space; planet centers are world space, so we // rotate each center into disk-local space here. From e2e246997123b6948592ee54056c4ad18d174f9b Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 13:40:35 +0800 Subject: [PATCH 07/13] feat(render): volumetric disk integration in the RK45 loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- assets/shaders/black_hole.wgsl | 47 +++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/assets/shaders/black_hole.wgsl b/assets/shaders/black_hole.wgsl index 661671e..b2d75ba 100644 --- a/assets/shaders/black_hole.wgsl +++ b/assets/shaders/black_hole.wgsl @@ -311,11 +311,6 @@ fn disk_color_flat(pos: vec3, dir: vec3) -> DiskSample { return DiskSample(vec3(col), 0.85); } -// TEMPORARY shim — removed in Task 7 when the main loop is restructured. -fn disk_color(pos: vec3, dir: vec3) -> vec3 { - return disk_color_flat(pos, dir).color; -} - // 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 @@ -553,13 +548,41 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { break; } - if (disk_hit(prev, new_pos)) { - let ty = prev.y / (prev.y - new_pos.y); - let hit = mix(prev, new_pos, vec3(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; + // --- 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(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(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; } } From 7621ff12bbafba428fb7f35906dc71134001a489 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 13:44:46 +0800 Subject: [PATCH 08/13] 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. --- src/ui.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/ui.rs b/src/ui.rs index 522a20d..1ef1ba9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -36,6 +36,30 @@ pub fn ui_system( ui.add(egui::Slider::new(&mut params.disk_brightness, 0.0..=3.0).text("Brightness")); ui.add(egui::Slider::new(&mut params.disk_rotation_speed, 0.0..=3.0).text("Rotation speed")); }); + 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")); + }); egui::CollapsingHeader::new("Doppler").show(ui, |ui| { ui.checkbox(&mut params.doppler_enabled, "Enabled"); ui.add_enabled(params.doppler_enabled, egui::Slider::new(&mut params.doppler_strength, 0.0..=3.0).text("Strength")); From 19905ed86a8662f9f54e6a8c590706e73e99745c Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 13:57:18 +0800 Subject: [PATCH 09/13] fix(shader): port MAX_OCTAVES-with-break into fbm3 This branch is the first to call fbm3 with runtime-variable octave counts (previously only compile-time constants 3u/4u). The spec's WebGPU safety mitigation (fixed upper bound + early break) was applied to ridged_fbm but missed on fbm3. Ports the same pattern so older WebGPU drivers don't miscompile the dynamic loop bound. --- assets/shaders/black_hole.wgsl | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/assets/shaders/black_hole.wgsl b/assets/shaders/black_hole.wgsl index b2d75ba..8609204 100644 --- a/assets/shaders/black_hole.wgsl +++ b/assets/shaders/black_hole.wgsl @@ -215,10 +215,15 @@ fn value_noise3(p: vec3) -> f32 { } fn fbm3(p: vec3, octaves: u32) -> f32 { + // MAX_OCTAVES-with-break: the conservative WebGPU form for a runtime- + // chosen octave count. Older drivers can miscompile non-constant loop + // bounds; this branch is the first to pass dynamic octaves here. + const MAX_OCTAVES = 6u; var sum = 0.0; var amp = 0.5; var freq = 1.0; - for (var i: u32 = 0u; i < octaves; i = i + 1u) { + for (var i: u32 = 0u; i < MAX_OCTAVES; i = i + 1u) { + if (i >= octaves) { break; } sum = sum + amp * value_noise3(p * freq); freq = freq * 2.0; amp = amp * 0.5; From 376abbc87adcd8f421c8cb7ebe117518b4885812 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 14:17:09 +0800 Subject: [PATCH 10/13] fix(disk): sample noise in polar coords to kill radial spokes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the "zebra stripe" / radial spoke artifact: disk_color_volumetric sampled noise using the raw Cartesian pos (x,y,z). On a disk, changing the radius r moves x/z a lot (high-frequency oscillation → stripes), while a full angular sweep revisits correlated lattice points (weak variation), so the stripes elongate along radius into spokes. Verified numerically: the radial sample line oscillated filament in [0.05, 0.77] while the angular line was symmetric (phi=0 and phi=pi identical at 0.2475). Fix: sample noise in polar space sp = (r/inner, phi*2.5 + rot, h/half_thickness). This decouples radial and angular axes so turbulence flows tangentially — the physically correct pattern for a rotating fluid. Post-fix radial/angular variance ratio is 0.50 (radial smoother than angular), eliminating the spoke bias. Two secondary fixes in the same function: - Density: replaced the hard smoothstep(0.3,0.7) cut (which made the slab patchy and over-transparent) with a soft 0.35+0.65*noise ramp. - Brightness: raised the floor to 0.5+filament (was filament alone) so the disk reads as luminous plasma instead of dim brown. - Guarded log(r) with max(r,0.1) for robustness near the inner edge. --- assets/shaders/black_hole.wgsl | 41 +++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/assets/shaders/black_hole.wgsl b/assets/shaders/black_hole.wgsl index 8609204..ad4ca6a 100644 --- a/assets/shaders/black_hole.wgsl +++ b/assets/shaders/black_hole.wgsl @@ -316,15 +316,17 @@ fn disk_color_flat(pos: vec3, dir: vec3) -> DiskSample { return DiskSample(vec3(col), 0.85); } -// 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. +// 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. fn disk_color_volumetric(pos: vec3, dir: vec3) -> DiskSample { let r = r_of(pos); + let phi = atan2(pos.z, pos.x); let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5); - let flow = vec3(0.0, 0.0, rot); // Octave triplet from the quality tier. let q = uniforms.disk_quality; @@ -333,25 +335,32 @@ fn disk_color_volumetric(pos: vec3, dir: vec3) -> DiskSample { 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); + // Polar sample coordinate: (r normalized, angle × freq + Keplerian flow, + // height within slab). The flow term advects the noise so inner radii + // (faster rotation) drift ahead of outer radii — differential rotation. + let r_norm = r / uniforms.disk_inner; + let h = pos.y / max(uniforms.disk_half_thickness, 1e-3); + let sp = vec3(r_norm, phi * 2.5 + rot, h); - // Layer 1: ridged bright filaments. - let filament = ridged_fbm(pos * uniforms.filament_freq + warp * 1.5 + flow * 0.3, + // Domain warp in polar space: distorts sample coords so filaments bend. + let warp = fbm3(sp * 0.8, warp_octaves); + + // Layer 1: ridged bright filaments (polar-sampled → tangential streaks). + let filament = ridged_fbm(sp * uniforms.filament_freq + warp * 1.5, 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 2: density clumping (soft, no hard smoothstep cut → avoids + // a patchy/over-transparent slab). + let density_noise = fbm3(sp * uniforms.density_freq + warp, density_octaves); + let base_density = (0.35 + 0.65 * 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_phase = phi * uniforms.arm_count + log(max(r, 0.1)) * 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 brightness = 0.5 + filament; let t = (r - uniforms.disk_inner) / (uniforms.disk_outer - uniforms.disk_inner); let tcol = temperature_color(t); From f236e47f83b99cd3900c9f28219dd7bbeaf3e71f Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 14:23:57 +0800 Subject: [PATCH 11/13] chore(skills): pin optimizing-rust-performance skill --- .../optimizing-rust-performance/SKILL.md | 210 ++++++++++++++++++ skills-lock.json | 6 + 2 files changed, 216 insertions(+) create mode 100644 .agents/skills/optimizing-rust-performance/SKILL.md diff --git a/.agents/skills/optimizing-rust-performance/SKILL.md b/.agents/skills/optimizing-rust-performance/SKILL.md new file mode 100644 index 0000000..5baf775 --- /dev/null +++ b/.agents/skills/optimizing-rust-performance/SKILL.md @@ -0,0 +1,210 @@ +--- +name: optimizing-rust-performance +description: | + 审查、重构或编写 Rust 代码时主动识别性能瓶颈并应用优化模式。触发关键词: + "optimize"、"perf"、"性能优化"、"加速"、"hot path"、"热点路径"、 + "make it faster"、"reduce allocation"、"zero-copy"、"零拷贝"、 + 以及任何对 .rs 文件的 review/refactor 请求、或代码中出现 Vec::remove / + clone / Vec 在循环中 / String::from / filter 后 collect 等可疑模式时。 +allowed-tools: + - Read + - Edit + - Grep + - Glob +metadata: + trigger: Rust 代码性能审查 / 重构 / 热点路径优化 / 减少 heap 分配 + source: 基于 Rust 性能优化通用最佳实践 + 真实 baseline 测试提炼 +--- + +# Rust 性能优化(Performance Optimization) + +编写或审查 Rust 代码时,**主动**识别性能瓶颈并应用优化模式。核心原则: +**按固定优先级排序**,**判断触发条件即应用**,**量化收益**,**用 profiling 验证**。 + +## 优化心智模型(必须按此顺序判断) + +``` +优化不是"想到什么改什么"。永远按此优先级评估: + +1. 算法与复杂度 — O(n)→O(1)、O(n²)→O(n log n)。最大收益,优先看。 +2. 内存分配 — 减少 heap 分配,栈优先,能零拷贝就零拷贝。 +3. 数据布局 — cache locality、字段顺序、对齐、SoA vs AoS。 +4. 并发 — 减少锁竞争,考虑 lock-free / 无锁结构。 + +低层级优化在高层级问题存在时收益微乎其微。先看复杂度,再看分配。 +``` + +## 铁律:判断到触发条件就应用,不要只"提及" + +> **发现反模式 → 直接改。不要说"可以考虑用 X"然后不改。** +> +> 如果触发条件命中,你必须(a)在改进后的代码里实际应用该模式, +> (b)说明改了什么、为什么快。把"应该用 Cow"挂在嘴边却不写进代码, +> 等于没优化。 + +## 核心模式(触发条件 → 动作) + +### 模式 1:集合删除(顺序无关时) + +**触发**:代码用 `.remove(index)` 删除 `Vec` 元素,且调用方不关心顺序。 + +**动作**:改用 `.swap_remove(index)` —— O(1),把末尾元素换到被删位置,无内存搬移。 + +```rust +// ❌ O(n):删中间元素要把后面所有元素左移 +self.items.remove(index); + +// ✅ O(1):末尾元素换位,不保证顺序 +self.items.swap_remove(index); +``` + +**收益**:O(n) → O(1) 的内存搬移。 + +### 模式 2:集合过滤 + +**触发**:代码用 `for` 循环 + 条件 `push` 到新 `Vec`,或 `filter()` 后 `collect()` 再赋值。 + +**动作**:用 `.retain()` / `.retain_mut()` 原地 O(n) 过滤,避免第二个 `Vec` 分配和逐元素 `clone`。 + +```rust +// ❌ 分配第二个 Vec + 每个元素 clone +let mut kept = Vec::new(); +for item in &self.items { + if item.in_stock { kept.push(item.clone()); } +} +self.items = kept; + +// ✅ 原地过滤,零额外分配,零 clone +self.items.retain(|item| item.in_stock); +``` + +**收益**:省一次堆分配 + N 次 clone。 + +### 模式 3:所有权转移,避免 clone + +**触发**:从 `&mut T` / `Option` 取值时用了 `.clone()`,或想"取出旧值替换为默认"。 + +**动作**: + +- `Option` 取值 → `option.take()`(取出并留 `None`,无 clone) +- `T: Default` 取出旧值 → `std::mem::take(dest)`(旧值返回,`dest` 变默认) +- 交换值 → `std::mem::replace(dest, src)`(无深拷贝) + +```rust +// ❌ clone 后原值仍在,字段没被清空(还可能是 bug) +fn clear_promo(&mut self) -> Option { + self.active_promo.clone() +} + +// ✅ take:取出所有权,字段变 None,无 clone +fn clear_promo(&mut self) -> Option { + self.active_promo.take() +} +``` + +**收益**:消除一次堆分配(clone 的 `String`/`Vec` 等)。 + +### 模式 4:栈优先(短生命周期小集合) + +**触发**:循环内频繁分配小而短命的 `Vec`/`String`(如"通常 2-3 个元素"的辅助返回值)。 + +**动作**:用 `SmallVec`/`TinyVec` 把小负载(<4 或 <8 元素)放栈上,溢出才上堆。 + +```rust +// ❌ 每次 tags_for 都堆分配一个 Vec(通常只有 2-3 个 tag) +fn tags_for(&self, id: u32) -> Vec { + vec![format!("tag-{id}-a"), format!("tag-{id}-b")] +} + +// ✅ SmallVec:2-3 个元素常驻栈,无堆分配 +fn tags_for(&self, id: u32) -> SmallVec<[String; 4]> { + smallvec![format!("tag-{id}-a"), format!("tag-{id}-b")] +} +``` + +**收益**:栈分配 vs 堆分配——省掉 malloc/free 和可能的 cache miss。 + +### 模式 5:Copy-on-Write 延迟分配 + +**触发**:字符串/切片处理大多只读,偶尔才需要修改或拥有所有权;返回类型是 `String` 但其实常无需分配。 + +**动作**:用 `std::borrow::Cow` 封装——只读时零分配借用,真正写时才 clone。 + +```rust +use std::borrow::Cow; + +// ❌ 永远堆分配,即使输入已经全是小写无需改动 +fn normalize(name: &str) -> String { + name.trim().to_lowercase() +} + +// ✅ 只在确实需要改写(trim 砍掉字符 / 含大写)时才分配; +// 纯小写无空白的输入零分配,原样借用返回 +fn normalize<'a>(name: &'a str) -> Cow<'a, str> { + let trimmed = name.trim(); + let needs_lower = trimmed.chars().any(|c| c.is_ascii_uppercase()); + let needs_trim = trimmed.len() != name.len(); + if !needs_lower && !needs_trim { + Cow::Borrowed(trimmed) + } else { + Cow::Owned(trimmed.to_lowercase()) + } +} +``` + +**收益**:读路径零分配;分配延迟到真正写时才发生。 + +## 热点路径额外模式 + +热点路径(每秒百万次调用的解析器/词法器/序列化)适用更激进的优化: + +- **切片代替逐字符 collect**:从原 `&str`/`&[u8]` 用范围切片 `&input[start..pos]` 取词素,而不是把 `char` push 进 `Vec` 再 `collect::()`(省双重分配 + 双重拷贝)。 +- **借用切片匹配后再拥有化**:先在借用的 `&str` 上 match 关键字(零分配),仅非关键字才 `.to_string()`。 +- **ASCII 谓词代替 Unicode 谓词**:`is_ascii_digit()` / `is_ascii_alphabetic()` 代替 `is_digit(10)` / `is_alphanumeric()`(省 Unicode 表查找)——前提是你确实只需 ASCII。 + +## 回应规范(应用优化时必须做到) + +1. **先分析瓶颈**:说明当前问题("这会导致 O(n) 内存搬移" / "这触发一次堆分配")。 +2. **给出优化代码**:干净、生产可用的 Rust,实际应用模式(不只是提及)。 +3. **量化理论收益**:说明*为什么*更快(堆 vs 栈、O(n) vs O(1)、cache locality)。 +4. **提示 profiling**:提醒用 `Criterion`(微基准)或 `Flamegraph` 验证,不要盲信理论。 + +**量化收益示例**(写到改动说明里): + +| 改动 | 复杂度 / 分配变化 | +| -------------------------- | --------------------------- | +| `remove` → `swap_remove` | O(n) → O(1) 内存搬移 | +| 循环+clone → `retain` | 省 1 次堆分配 + N 次 clone | +| `clone` → `take` | 省 1 次堆分配 | +| `Vec` → `SmallVec<[T; 4]>` | 堆分配 → 栈分配(小负载时) | +| `String` → `Cow` | 读路径:1 次分配 → 0 次 | + +## 自检清单(提交 Rust 改动前逐条过) + +- [ ] 任何 `Vec::remove` 在顺序无关处是否已换 `swap_remove`? +- [ ] 过滤操作是否用了 `retain` / `retain_mut` 而非 collect? +- [ ] 取值是否用了 `take` / `mem::take` / `mem::replace` 而非 `clone`? +- [ ] 循环内小而短命的 `Vec`/`String` 是否考虑过 `SmallVec`? +- [ ] 大多只读、偶尔写的字符串/切片是否考虑过 `Cow`? +- [ ] 热点路径是否切片取词素而非逐字符 collect? +- [ ] 每条改动是否说明了复杂度/分配收益,并提示 profiling 验证? +- [ ] 引入新依赖(`smallvec` 等)或改动公开返回类型前,确认收益配得上成本。 + +## 注意(应用前必读 —— 防止过度优化) + +优化有成本。应用下列模式前先权衡,**别为了用模式而用**: + +- **先正确再优化**:`#[inline]`、手动 SIMD、`unsafe` 等通常不是首选;先确认算法复杂度和分配已最优。 +- **新增依赖有代价**:`SmallVec`/`TinyVec` 要加 crate 依赖、增加编译时间。只为"通常 2-3 个元素"就在非热点代码里引入依赖,多半不划算——优先考虑能否直接用数组 `[T; N]` / slice / 返回迭代器。 +- **Cow/SmallVec 改返回类型是 API 传染**:把 `-> String` 改成 `-> Cow<'_, str>` 会把生命周期参数传染给所有调用方;把 `Token` 改成 `Token<'src>` 是全 crate 的 API 变更。改公开签名前确认收益配得上波及面;内部 `fn` 则无所谓。 +- **量入为出**:Cow/SmallVec 引入复杂度,确认该路径真的是热点或高频才上。非热点的小函数直接 `Vec`/`String` 更清晰。 +- **profile 验证**:理论收益不等于实测收益。用 `Criterion` 做微基准,`Flamegraph` 找真热点,别盲改。 + +**决策速查**: + +| 情况 | 推荐 | +| -------------------- | ------------------------------------------- | +| 非热点、简单工具函数 | 直接 `Vec`/`String`/`clone`,别上模式 | +| 公开 API 返回类型 | 慎用 `Cow<'_, str>`/生命周期——会传染调用方 | +| 真热点 + 内部函数 | 放心上 Cow/SmallVec/借用切片 | +| 新增依赖 | 先看能否用 `[T; N]` / 迭代器 / 现有类型替代 | diff --git a/skills-lock.json b/skills-lock.json index 6903533..54754f2 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -67,6 +67,12 @@ "skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md", "computedHash": "8bf292143ca93b00276a0de0fc5f84f2381f4690c5b0d32e99fa283a072f392f" }, + "optimizing-rust-performance": { + "source": "DefectingCat/optimizing-rust-performance", + "sourceType": "github", + "skillPath": "SKILL.md", + "computedHash": "96bf0221d116e617055f5dc038bb33d92dd61b7808d804f92da78d32c01e6934" + }, "prototype": { "source": "mattpocock/skills", "sourceType": "github", From e00c37a3aec58c381761771eac5475561d963db1 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 14:57:21 +0800 Subject: [PATCH 12/13] 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; } } From ef631ab848f41da05d32642d27fffcd1b036c58f Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 15:09:53 +0800 Subject: [PATCH 13/13] feat(camera): default to yaw -1.065 / pitch -0.335 framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set the OrbitCamera default to the framing the UI screenshot shows: yaw -1.065, pitch -0.335, distance 30, fov 1.0 (the latter two were already the defaults). The old default (yaw 0, pitch +0.7) was a 3/4 view from above the disk plane. The new pitch sits the eye just below the disk plane, so the gravitationally lensed far side of the disk arcs over the top of the shadow — the iconic Interstellar framing. yaw -1.065 (~-61°) picks a viewing azimuth that keeps the lensed arc and photon ring balanced in frame. --- src/camera.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/camera.rs b/src/camera.rs index 34cf86a..3b0a780 100644 --- a/src/camera.rs +++ b/src/camera.rs @@ -16,11 +16,12 @@ pub struct OrbitCamera { impl Default for OrbitCamera { fn default() -> Self { Self { - yaw: 0.0, - // ~0.7 rad (40°) is a comfortable 3/4 view of the disk. The basis() - // no longer has a gimbal pole, so any pitch is safe; this is just a - // nice default angle, not a pole-avoidance choice. - pitch: 0.7, + yaw: -1.065, + // ~0.335 rad (19°) below the disk plane. The lensed far side of the + // disk then arcs over the top of the shadow — the iconic framing. The + // basis() has no gimbal pole, so any pitch is safe; this is just a nice + // default angle, not a pole-avoidance choice. + pitch: -0.335, distance: 30.0, fov: 1.0, // radians }