Compare commits

...

24 Commits

Author SHA1 Message Date
xfy
b4f14b2f35 tune(disk): align defaults to Gargantua visuals
Defaults retuned to match the Interstellar look now that the density
model drives appearance:

- disk_outer 15 → 25: Gargantua-like extent.
- disk_half_thickness semantics → H/R ratio, default 0.15 (was an
  absolute 0.3/0.2 world-space band). Standard thin-disk scale height.
- disk_color_mode default → Blackbody (was Gradient): the Novikov-Thorne
  radial temperature gradient × Kerr Doppler gives the smooth white-hot
  inner → deep-orange outer look the new density model is built around.
- disk_temp 10000 → 6500 K: tuned so the NT profile yields warm-white
  inner fading to deep orange at the edge.
- density_strength 1.0 → 1.2: the new model has no floor and decays to 0
  at the edges, so a slightly higher multiplier keeps the bulk opaque
  after per-step integration.

UI: "Half thickness" → "Thickness (H/R)" with a 0.02..=0.3 range
(thin-disk regime); "Outer radius" range widened to 6..=50.

BlackHoleUniforms::Default (material.rs) mirrors the same values so the
GPU default matches params.rs before the first mirror_params tick. Field
order/types/layout unchanged; no uniform-struct migration needed.
2026-07-15 17:32:14 +08:00
xfy
7130d0c6b2 feat(disk): rewrite density model for solid Gargantua-style accretion disk
The volumetric disk rendered translucent and jelly-like. Three causes,
all fixed:

1. Density floor 0.55 (disk_color_volumetric): noise valleys stayed
   semi-transparent no matter the integration → jelly. Replaced with a
   physical density field: radial smoothstep falloff × Gaussian vertical
   decay × weak ±25% low-frequency turbulence. Density now reaches 0 at
   the edges and peak at the midplane, so per-step integration builds a
   solid disk instead of a uniform glow.

2. Single midpoint sample (main loop): the old design clipped the ray
   segment to the slab and sampled density once at the midpoint, then
   weighted by segment length. Under-integration → see-through. Switched
   to per-step accumulation: every accepted step whose endpoint is inside
   the slab samples density once × geometric step length — the standard
   front-to-back volumetric composite, matching the others/ reference
   (fragment.glsl.ts sample_accretion_disk).

3. Absolute slab thickness 0.3: vanishingly thin at large radius → even
   less integration. disk_half_thickness is now an H/R RATIO; the slab
   scales as H = r·disk_half_thickness (a thin disk's geometry), so a ray
   through large r traverses a proportionally thicker region.

The ridged-filament brightness driver and log-spiral arm modulation are
removed — they produced the noisy, striated look. The radial temperature
gradient inside disk_emission (Novikov-Thorne × Kerr Doppler) now
dominates, giving a smooth disk like DNEG's Gargantua render. Noise
functions (fbm3/ridged_fbm/value_noise3) are retained: jets and the flat
(Off-tier) path still use them.

disk_color_flat (Off tier) drops its fixed 0.85 alpha for the same radial
smoothstep, so edges feather consistently across all quality tiers.
2026-07-15 17:32:00 +08:00
xfy
6801f196c3 style: clear remaining clippy warnings
- params.rs: collapse `if cfg!(wasm) { false } else { true }` for
  star_aa into `!cfg!(wasm)` (clippy::needless_bool).
- render/plugin.rs: `resize_offscreen` is a Bevy system whose argument
  count is fixed by its SystemParam set; allow clippy::too_many_arguments
  alongside the existing type_complexity allow on the same function.
2026-07-15 17:01:40 +08:00
xfy
a5ef427cb8 feat(disk): tune volumetric disk noise for opaque body with visible streaks 2026-07-15 16:59:49 +08:00
xfy
abdd50576b fix(ui): make Controls panel vertically scrollable
The control panel grew past one screen after the disk/jet additions
(Color model, Temperature, Jets section). egui::Window without a height
constraint just clips overflowing content. Give it a default width/height,
enable resizing, and wrap the body in ScrollArea::vertical so all sections
remain reachable.
2026-07-15 16:17:24 +08:00
xfy
6f1f2ac75b feat(disk): port blackbody color, Kerr 4-velocity Doppler, relativistic jets
Bring three accretion-disk strengths from others/blackhole-simulation into
the WGSL renderer, behind opt-in controls. The physics.rs CPU mirror is
untouched — these are all color/shading functions, outside the deriv/rk45/
integration-loop lockstep surface.

Color model (DiskColorMode uniform, default Gradient to preserve the prior
look):
- Gradient (mode 0): existing white-hot → orange ramp + Newtonian
  apply_doppler, unchanged.
- Blackbody (mode 1): Novikov-Thorne radial temperature profile
  (isco_r^0.75 · (1−√isco_r)^0.25) shifted by the Kerr equatorial
  4-velocity Doppler factor δ, fed through a Tanner-Helland blackbody
  color function, times δ^3.5 beaming. The approaching side therefore
  turns hotter/bluer and the receding side cooler/redder — the physical
  signature the gradient mode lacks. kerr_doppler() solves g_μν u^μ u^ν=-1
  for circular orbits exactly; the max(0.01,…) floor guards pow-of-negative
  NaN. A unified disk_emission() now assembles color for both the flat and
  volumetric paths, replacing two duplicated tcol/falloff/doppler chains.

Relativistic jets (jets_enabled, default on): bipolar cones along the spin
axis with 0.92c outflow beaming (δ^3.5, no floor needed — β fixed keeps the
denominator positive), Gaussian radial falloff, exponential length decay,
and outward-flowing value-noise turbulence. Front-to-back composited into
the same accumulators as the disk. Ported to ptr<function,…> accumulators
(WGSL has no inout) and the existing value_noise3.

UI: Color model dropdown + temperature slider under Accretion Disk; new
Jets section with enable + strength. Four uniform fields added as one
16-byte row, Rust/WGSL order kept identical.

Verified: cargo build --release, cargo test (17 tests, mirror untouched),
naga WGSL validation.
2026-07-15 16:12:12 +08:00
xfy
f7eda338ab Merge branch 'feat/volumetric-disk'
Volumetric disk rendering: ridged multifractal noise + density/slab
integration in the RK45 loop, with a Disk Turbulence UI panel. 13 commits.
2026-07-15 15:15:55 +08:00
xfy
ef631ab848 feat(camera): default to yaw -1.065 / pitch -0.335 framing
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.
2026-07-15 15:09:53 +08:00
xfy
e00c37a3ae fix(disk): unify slab integration to eliminate radial spokes
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.
2026-07-15 14:57:21 +08:00
xfy
f236e47f83 chore(skills): pin optimizing-rust-performance skill 2026-07-15 14:23:57 +08:00
xfy
376abbc87a fix(disk): sample noise in polar coords to kill radial spokes
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.
2026-07-15 14:17:09 +08:00
xfy
19905ed86a 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.
2026-07-15 13:57:18 +08:00
xfy
7621ff12bb 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.
2026-07-15 13:44:46 +08:00
xfy
e2e2469971 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.
2026-07-15 13:40:35 +08:00
xfy
107fbd0a74 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.
2026-07-15 13:37:52 +08:00
xfy
de4ceee8ff 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.
2026-07-15 13:29:48 +08:00
xfy
c9cc2eec99 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.
2026-07-15 13:24:16 +08:00
xfy
827edd35a2 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.
2026-07-15 13:22:15 +08:00
xfy
35ae3ce5cc 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.
2026-07-15 13:18:02 +08:00
xfy
2a0c3bbe69 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.
2026-07-15 13:16:50 +08:00
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
xfy
d1ea789274 docs(spec): volumetric accretion disk design
Design for replacing the smooth zero-thickness disk with a Gargantua-style
volumetric gas disk. Supersedes the Phase 3 non-goal "disk stays a zero-
thickness plane" - user feedback identified zero thickness as a root cause
of the disk looking too smooth.

Key decisions captured:
- Approach A volumetric integration: in-slab sampling reuses the existing
  RK45 adaptive step (dense where light bends, sparse where straight),
  no separate ray-march. Midplane edge-capture retained for straddling steps.
- Ridged multifractal noise (ridge^2) for sharp bright filaments, replacing
  the smooth FBM blobs that produce the current mushy texture.
- Separate density field (smoothstep-gated FBM) for gas clumping, plus a
  logarithmic-spiral arm modulation riding the Keplerian shear.
- DiskQuality tier enum (Off/Low/Medium/High) gates octave counts; Off is
  a full revert to the flat disk as perf escape hatch + visual A/B.
- physics.rs untouched: volumetric integration is render-sampling layer,
  never enters the geodesic solver; existing tests stay green.
2026-07-15 11:17:12 +08:00
xfy
52cf4e4a29 fix(stars): balance AA so it only softens edges, not size or count
After the round-star fix, toggling "Anti-aliased stars" made stars look
bigger and more numerous instead of just smoother. The AA path differed
from the hard-edge path in three ways that each inflated the footprint:

  - radius: AA used 0.25 + b*0.4 (up to 0.65); hard path used fixed 0.5
  - gain:   AA used 4.0; hard path used 3.0  (+33% peak brightness)
  - falloff: AA Gaussian decayed slowly (36.8% at one radius, never 0);
             hard path smoothstep hit 0 at the radius

The slow Gaussian tail was the main offender: it kept dim stars lit far
past the hard path's cutoff, lifting many sub-threshold stars above the
visibility floor — the apparent "more stars".

Balance the two paths so the toggle is purely an edge-softness control:
  - both use radius = 0.25 + b*0.4  (same size, brightness-driven)
  - both use gain 3.0               (same peak brightness)
  - AA steepens the Gaussian to exp(-4.6*d²/r²) so it converges to ~1%
    near the same radius where smoothstep cuts to 0 — matched footprint,
    soft vs hard edge.

Peak brightness is now identical across the toggle; effective lit area
differs by <30% (the Gaussian is a cone, smoothstep a flatter dome) and
the visibility radius matches, so dim stars no longer pop in and out as
AA is toggled.

Validated: naga type-checks; app runs 20s on Metal (M4) with no shader
errors; cargo test (14 physics tests) passes.
2026-07-15 10:23:29 +08:00
xfy
f4e937f35b fix(stars): round anti-aliased stars via 3x3x3 neighborhood scan
Background stars looked square even with anti-aliasing on, and toggling
AA just made them bigger rather than round. Two compounding causes, both
in star_color():

1. Square shape: the fast path measured cell distance with
   max(f.x, max(f.y, f.z)) — Chebyshev distance, which draws an
   axis-aligned cube, not a sphere. The visible star was literally a
   faded cube face.

2. AA "just makes it bigger": the Gaussian AA path used Euclidean
   distance, but only inside ONE cell. hash13(cell) returns a different
   value across a cell boundary, so a star's radial falloff could never
   reach zero — it was hard-clipped at the cell edge. The star's outline
   was therefore the cell's bounding box regardless of the radius
   parameter; bumping the gain/radius just filled more of that box.

Fix: scan the 3x3x3 neighborhood of cells so a star whose center lies in
an adjacent cell still contributes across the whole cell it lives in,
and take the brightest contributor. Distance is Euclidean in both paths,
so both are now genuinely round. star_aa selects soft Gaussian glow (on)
vs. a tight smoothstep disk (off) — a real shape choice instead of
square-vs-clipped-square.

Validated: naga type-checks the preprocessed shader; app runs 20s on
Metal (Apple M4) with no shader/compile/validation errors; cargo test
(14 physics tests) passes.
2026-07-15 10:00:57 +08:00
11 changed files with 1751 additions and 62 deletions

View File

@ -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> 在循环中 / 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<T>` 取值时用了 `.clone()`,或想"取出旧值替换为默认"。
**动作**
- `Option<T>` 取值 → `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<PromoCode> {
self.active_promo.clone()
}
// ✅ take取出所有权字段变 None无 clone
fn clear_promo(&mut self) -> Option<PromoCode> {
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<String> {
vec![format!("tag-{id}-a"), format!("tag-{id}-b")]
}
// ✅ SmallVec2-3 个元素常驻栈,无堆分配
fn tags_for(&self, id: u32) -> SmallVec<[String; 4]> {
smallvec![format!("tag-{id}-a"), format!("tag-{id}-b")]
}
```
**收益**:栈分配 vs 堆分配——省掉 malloc/free 和可能的 cache miss。
### 模式 5Copy-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<char>``collect::<String>()`(省双重分配 + 双重拷贝)。
- **借用切片匹配后再拥有化**:先在借用的 `&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<str>` | 读路径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]` / 迭代器 / 现有类型替代 |

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/target
/dist
.zcode/
others/

View File

@ -39,6 +39,20 @@ 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,
// Disk color mode + blackbody temp (Phase 3.2), relativistic jets.
disk_color_mode: u32, // 0=gradient, 1=blackbody
disk_temp: f32, // blackbody base temperature (Kelvin)
jets_enabled: u32, // 0=off, 1=on
jets_strength: f32, // jet brightness multiplier
};
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> uniforms: BlackHoleUniforms;
@ -85,31 +99,49 @@ fn hash13(p: vec3<f32>) -> f32 {
}
fn star_color(dir: vec3<f32>, intensity: f32) -> vec3<f32> {
// The unit-sphere direction is hashed into a 3D cell grid. A pixel's ray
// usually sits near a cell boundary, so evaluating only the home cell clips
// any star whose center lies in a neighbor that clip is what made stars
// look square even with the Gaussian AA path: the hash changes at the cell
// edge, so the falloff could never reach zero and the visible shape was
// just the cell's bounding box. Scan the 3×3×3 neighborhood so each star's
// radial falloff extends across the whole cell it lives in.
let scale = 80.0;
let p = dir * scale;
let cell = floor(p);
let h = hash13(cell);
let base = floor(p);
let threshold = 0.985;
if (h > threshold) {
// Brightest contributor wins: `best` = (brightness, r, g, b).
var best = vec4<f32>(0.0);
for (var dz: i32 = -1; dz <= 1; dz = dz + 1) {
for (var dy: i32 = -1; dy <= 1; dy = dy + 1) {
for (var dx: i32 = -1; dx <= 1; dx = dx + 1) {
let cell = base + vec3<f32>(f32(dx), f32(dy), f32(dz));
let h = hash13(cell);
if (h <= threshold) { continue; }
let b = (h - threshold) / (1.0 - threshold);
let col = mix(vec3<f32>(0.6, 0.7, 1.0), vec3<f32>(1.0, 0.9, 0.7), b);
if (uniforms.star_aa != 0u) {
// Gaussian speck: distance to cell center, soft radial falloff.
// Produces a round 2-3 pixel anti-aliased disk instead of a square.
let center = cell + vec3<f32>(0.5);
let dist = length(p - center);
let dist = length(p - center); // Euclidean round, never square
let col = mix(vec3<f32>(0.6, 0.7, 1.0), vec3<f32>(1.0, 0.9, 0.7), b);
// Both paths share the same brightness-driven radius and the
// same gain, so toggling AA only softens the edge it does not
// change peak brightness or visible count. Before, the AA path
// used a larger radius (up to 0.65 vs 0.5), a heavier gain
// (4.0 vs 3.0), and a slow-decaying Gaussian whose long tail
// lifted many dim stars above the visibility floor that's
// what made AA look like "bigger and more" stars.
let radius = 0.25 + b * 0.4;
let falloff = exp(-dist * dist / (radius * radius));
return col * b * falloff * 4.0 * intensity;
} else {
// Original fast path: square-cell smoothstep (blocky but cheap).
let f = abs(p - cell);
let d = max(f.x, max(f.y, f.z));
let falloff = smoothstep(0.5, 0.0, d);
return col * b * falloff * 3.0 * intensity;
let falloff = select(
smoothstep(radius, 0.0, dist), // hard edge
exp(-4.6 * dist * dist / (radius * radius)), // soft edge
uniforms.star_aa != 0u);
let bright = b * falloff;
if (bright > best.x) {
best = vec4<f32>(bright, col.r, col.g, col.b);
}
}
return vec3<f32>(0.0);
}
}
return best.yzw * best.x * 3.0 * intensity;
}
// --- skybox ---
@ -188,10 +220,15 @@ fn value_noise3(p: vec3<f32>) -> f32 {
}
fn fbm3(p: vec3<f32>, 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;
@ -199,41 +236,266 @@ fn fbm3(p: vec3<f32>, 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<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;
}
fn disk_noise(pos: vec3<f32>, t: f32) -> f32 {
let warp = fbm3(pos * 0.8 + vec3<f32>(0.0, 0.0, t * 0.1), 3u);
let n = fbm3(pos * 2.0 + warp * 1.5 + vec3<f32>(0.0, 0.0, t * 0.3), 4u);
return n;
}
fn disk_color(pos: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
let r = length(vec2<f32>(pos.x, pos.z));
// 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,
}
// 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));
}
// Analytic blackbody color (Tanner-Helland / Mitchell Charity approximation).
// Input temp in Kelvin; output linear RGB. Ported from the others/ GLSL shader.
fn blackbody(temp: f32) -> vec3f {
// Clamp to prevent log(0) at the event horizon (infinite redshift).
let t = max(temp, 1.0) / 100.0;
var r: f32;
var g: f32;
var b: f32;
if (t <= 66.0) {
r = 255.0;
g = 99.4708025861 * log(t) - 161.1195681661;
if (t <= 19.0) {
b = 0.0;
} else {
b = 138.5177312231 * log(t - 10.0) - 305.0447927307;
}
} else {
r = 329.698727446 * pow(t - 60.0, -0.1332047592);
g = 288.1221695283 * pow(t - 60.0, -0.0755148492);
b = 255.0;
}
// Formula produces sRGB; convert to linear for the HDR pipeline.
let srgb = vec3f(r, g, b) / 255.0;
return pow(max(srgb, vec3f(0.0)), vec3f(2.2));
}
// 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>) -> f32 {
return length(vec2<f32>(pos.x, pos.z));
}
// 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 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
// faster than outer correct differential rotation.
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 = mix(vec3<f32>(1.0, 0.95, 0.85), vec3<f32>(1.0, 0.45, 0.12), clamp(t, 0.0, 1.0));
let falloff = 1.0 / pow(r / uniforms.disk_inner, 2.0);
var col = tcol * (0.6 + 0.4 * noise) * falloff;
let v_orbital = sqrt(uniforms.rs / (2.0 * r));
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));
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;
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;
}
col *= doppler;
return col * uniforms.disk_brightness;
// Kerr equatorial 4-velocity Doppler factor δ = E_obs/E_em (Page & Thorne 1974).
// Exact: solves g_μν u^μ u^ν = -1 for circular equatorial orbits, then folds in
// the conserved photon angular momentum. Returns vec2(delta, beaming=δ^3.5).
// Used by the blackbody disk color mode; the floor guards against NaN from
// pow of a negative base.
fn kerr_doppler(pos: vec3f, dir: vec3f) -> vec2f {
let r = r_of(pos);
let m = 0.5;
let a = uniforms.spin * m;
let sqrt_m = sqrt(m);
let sign_spin = sign(uniforms.spin + 1.0e-8);
// 1. Keplerian angular velocity Ω = /dt.
let omega = (sign_spin * sqrt_m) / (r * sqrt(r) + a * sqrt_m);
// 2. Equatorial metric components (θ = π/2).
let g_tt = -(1.0 - 2.0 * m / r);
let g_tphi = -2.0 * m * a / r;
let g_phiphi = r * r + a * a + 2.0 * m * a * a / r;
// 3. Time component of the circular-orbit 4-velocity.
let u_t_sq = -(g_tt + 2.0 * omega * g_tphi + omega * omega * g_phiphi);
let u_t = 1.0 / sqrt(max(1.0e-6, u_t_sq));
// 4. Conserved photon angular momentum (impact-parameter mapping).
let l_photon = pos.z * dir.x - pos.x * dir.z;
// 5. δ = E_obs/E_em = 1 / (u_t · (1 Ω · L_photon)).
let delta = 1.0 / max(0.01, u_t * (1.0 - omega * l_photon));
let beaming = max(0.01, pow(delta, 3.5));
return vec2f(delta, beaming);
}
// Unifies the color assembly for both disk paths. Mode 0 = existing gradient +
// Newtonian apply_doppler (preserves the pre-blackbody appearance). Mode 1 =
// Novikov-Thorne radial temperature gradient × Kerr δ blackbody color × δ^3.5
// beaming, so the approaching side shifts hotter/bluer and the receding side
// cooler/redder the physical signature absent from the gradient mode.
fn disk_emission(r: f32, pos: vec3f, dir: vec3f, brightness: f32) -> vec3f {
let inner = uniforms.disk_inner;
let t = (r - inner) / (uniforms.disk_outer - inner);
let falloff = radial_falloff(r, inner);
if (uniforms.disk_color_mode == 0u) {
var col = temperature_color(t) * brightness * falloff * uniforms.disk_brightness;
return apply_doppler(col, pos, dir);
}
// Blackbody mode: NT radial temperature profile × Kerr Doppler shift.
let isco_r = clamp(inner / r, 0.0, 1.0);
let nt_factor = max(0.0, 1.0 - sqrt(isco_r));
let radial_temp = pow(isco_r, 0.75) * pow(nt_factor, 0.25);
let d = kerr_doppler(pos, dir);
let temperature = uniforms.disk_temp * radial_temp * d.x;
return blackbody(temperature) * d.y * brightness * falloff * uniforms.disk_brightness;
}
// Off-tier fallback: zero-thickness disk, single midplane sample. Density is
// driven by the same radial smoothstep falloff as the volumetric path (no flat
// 0.85 floor) so the edges feather out naturally. 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);
// Domain-warped FBM for a feathered gas texture. The Keplerian shear
// (rot 1/r^1.5) is folded into the noise flow term so inner radii flow
// faster than outer correct differential rotation. Amplitude is kept mild
// so the radial temperature gradient (inside disk_emission) dominates.
let noise = disk_noise(vec3<f32>(pos.x * 0.3, pos.z * 0.3, rot), uniforms.time);
let col = disk_emission(r, pos, dir, 0.8 + 0.4 * noise * noise);
// Radial smoothstep: solid through the bulk, feathered at both edges.
let radial = smoothstep(uniforms.disk_outer, uniforms.disk_inner, r);
return DiskSample(vec3<f32>(col), radial * 0.9);
}
// Volumetric disk color. Models the accretion disk as a physically-structured
// density field (no flat floor): radial smoothstep falloff × Gaussian vertical
// decay × a WEAK low-frequency turbulence modulation. The radial temperature
// gradient inside disk_emission drives the dominant appearance (Gargantua's
// smooth white-hot inner deep-orange outer look); turbulence is kept to a
// subtle ±25% texture accent so the disk reads as solid gas rather than the
// jelly-like translucent slab the old 0.55-floor + ridged-filament model gave.
//
// Noise is sampled in POLAR coordinates (r_norm, phi·freq + Keplerian flow,
// height) so what little texture there is flows tangentially correct for a
// rotating fluid. The Keplerian flow term (`+ rot`) advects the turbulence.
fn disk_color_volumetric(pos: vec3<f32>, dir: vec3<f32>) -> 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);
// 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.
// NOTE: h here is normalized by disk_half_thickness, but disk_half_thickness
// is now an H/R RATIO (semantics changed from absolute world units), so the
// caller scales H = r * disk_half_thickness before sampling see the
// per-step accumulation site in the main loop.
let r_norm = r / uniforms.disk_inner;
let h = pos.y / max(r * uniforms.disk_half_thickness, 1e-3);
let sp = vec3<f32>(r_norm, phi * 2.5 + rot, h);
// Weak low-frequency turbulence. Two octaves only (was 45): the goal is a
// Gargantua-style smooth disk, so turbulence is a texture accent (±25%
// density modulation), NOT the dominant carrier. ridged filaments and
// logarithmic-spiral arms were removed they produced the noisy,
// jelly-like, striated look this model replaces.
let turbulence = fbm3(sp * uniforms.density_freq, 2u);
let turb_mod = mix(1.0, 0.75 + 0.5 * turbulence, 0.5); // 0.751.25
// Gaussian vertical decay: densest at the midplane, ~0 at the slab edge.
// h is normalized to the slab half-height, so exp(-(h·h)·4) 0.018 at the
// rim. This replaces the old uniform-in-slab density (the cause of the
// jelly: the whole thickness glowed at a constant floor alpha).
let h_falloff = exp(-(h * h) * 4.0);
// Radial smoothstep falloff (outer 0, inner 1): smooth inner+outer
// edges instead of a hard annulus, matching the others/ reference disk.ts.
let radial = smoothstep(uniforms.disk_outer, uniforms.disk_inner, r);
let total_density = radial * h_falloff * turb_mod * uniforms.density_strength;
// Brightness: turbulence is a mild accent so the radial temperature gradient
// (inside disk_emission) dominates the visible luminosity. This keeps the
// disk smooth and gradient-driven rather than streaked by ridged filaments.
let brightness = mix(0.8, 1.2, turbulence);
let col = disk_emission(r, pos, dir, brightness);
return DiskSample(vec3<f32>(col), total_density);
}
// Relativistic jets along the spin axis (Y). Bipolar cones above/below the
// disk with 0.92c outflow beaming. Ported from others/' sample_relativistic_jets:
// Gaussian radial falloff, exponential length decay, outward-flowing noise,
// δ^3.5 beaming (no floor needed β=0.92 keeps the denominator positive).
// Front-to-back composited into the same accumulators as the disk.
fn sample_jets(pos: vec3f, dir: vec3f, r_plus: f32, dt: f32,
accum_color: ptr<function, vec3f>, accum_alpha: ptr<function, f32>) {
let jet_v = abs(pos.y);
let jet_max_h = 80.0;
if (jet_v <= r_plus * 1.8 || jet_v >= jet_max_h) {
return;
}
let jet_r = length(vec2f(pos.x, pos.z));
let jet_width = 1.0 + jet_v * 0.15;
if (jet_r >= jet_width * 2.0) {
return;
}
let radial_falloff = exp(-(jet_r * jet_r) / (jet_width * 0.5));
let length_falloff = exp(-jet_v * 0.05);
// Outward-flowing turbulence: the y term reverses sign of the time flow so
// the lower jet streams downward and the upper jet upward.
let flow = pos.y * 2.0 - uniforms.time * 8.0;
let uv_jet = vec3f(pos.x, flow, pos.z);
let noise_val = value_noise3(uv_jet * 0.5) * 0.6 + value_noise3(uv_jet * 1.5) * 0.4;
let jet_density = radial_falloff * length_falloff * max(0.0, noise_val - 0.2);
if (jet_density <= 0.001) {
return;
}
// 0.92c outflow beaming.
let jet_vel = 0.92 * sign(pos.y);
let jet_vel_vec = vec3f(0.0, jet_vel, 0.0);
let cos_theta = dot(normalize(jet_vel_vec), -dir);
let beta = abs(jet_vel);
let gamma = 1.0 / sqrt(1.0 - beta * beta);
let delta = 1.0 / (gamma * (1.0 - beta * cos_theta));
let beaming = pow(delta, 3.5);
let base_color = vec3f(0.4, 0.7, 1.0);
let emission = base_color * jet_density * 0.05 * beaming * uniforms.jets_strength * dt;
*accum_color += emission * (1.0 - *accum_alpha);
*accum_alpha += jet_density * 0.05 * uniforms.jets_strength * dt;
}
// --- planets ---
@ -426,13 +688,49 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
break;
}
// --- 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 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;
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. Per-step accumulation: at every accepted step
// whose endpoint lies inside the disk slab, sample density once and
// accumulate × the geometric step length. This integrates the
// emission/absorption along the ray through the slab the standard
// volumetric front-to-back composite and is what makes the disk
// read as solid and opaque rather than translucent.
//
// Replaces the old single-midpoint-in-clipped-slab design, which
// under-sampled (one density value per ray-slab traversal
// see-through, jelly-like disk). Per-step integration matches the
// others/ reference (fragment.glsl.ts sample_accretion_disk, called
// every step of the raymarch loop).
//
// Thickness is now an H/R RATIO: H = r · disk_half_thickness, so the
// slab grows with radius (a thin disk's geometry), not a constant
// world-space band that is vanishingly thin at large r.
let r_here = r_of(new_pos);
let H = r_here * uniforms.disk_half_thickness;
if (abs(new_pos.y) <= H &&
r_here >= uniforms.disk_inner && r_here <= uniforms.disk_outer) {
let step_len = length(new_pos - prev);
let s = disk_color_volumetric(new_pos, new_dir);
accum_color += (1.0 - accum_alpha) * s.color * s.density * step_len;
accum_alpha += (1.0 - accum_alpha) * s.density * step_len;
}
if (accum_alpha > 0.99) { break; }
}
// --- relativistic jets (along the spin axis) ---
if (uniforms.jets_enabled != 0u) {
sample_jets(new_pos, new_dir, r_plus, dt, &accum_color, &accum_alpha);
if (accum_alpha > 0.99) { break; }
}

View File

@ -0,0 +1,714 @@
# 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`):
```rust
/// 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`):
```rust
// 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`):
```rust
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**
```bash
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):
```rust
// 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):
```rust
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):
```wgsl
bloom_threshold: f32,
bloom_strength: f32,
exposure: f32,
_pad5: f32,
};
```
with:
```wgsl
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**
```bash
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` (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):
```rust
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**
```bash
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`):
```wgsl
// 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**
```bash
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):
```wgsl
// 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`):
```wgsl
// 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:
```wgsl
// 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`:
```wgsl
// 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**
```bash
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:
```wgsl
// 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**
```bash
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:
```wgsl
// 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):
```wgsl
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:
```wgsl
// --- 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**
```bash
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):
```rust
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**
```bash
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:
```bash
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.

View File

@ -0,0 +1,277 @@
# Volumetric Accretion Disk — Design Spec
**Date:** 2026-07-15
**Phase:** 3.1 (disk visual fidelity), builds on Phase 3 (cinematic)
**Status:** approved design, pending implementation plan
## Goal
Replace the current smooth, zero-thickness disk with a Gargantua-style volumetric gas disk: a finite-thickness luminous layer with sharp bright filaments embedded in darker gas, large-scale spiral-arm modulation, and Keplerian-shear-driven flow. All new parameters configurable via the egui panel, with tiered web/desktop defaults.
This is the direct follow-up to the user-observed problem: the current disk looks "very smooth." The root cause is not a bug but five stacked design choices (zero thickness, fixed alpha, single surface sample, low-frequency low-contrast FBM, bloom smoothing) — this spec replaces the first four with volumetric physics and ridged turbulence.
## Non-goals
- **No Kerr-physics changes.** The geodesic integrator (`deriv` / `rk45_step` / the main integration loop's step-accept logic) is untouched. Volumetric integration happens in the render-sampling layer, layered *on top of* accepted RK45 steps, and never enters the geodesic solver.
- **Therefore `physics.rs` is unchanged**, and the existing `is_captured_rk45` / integration tests stay green. The CPU mirror covers capture-vs-escape, not disk appearance.
- **No real MHD.** Turbulence is procedurally faked with noise for visual effect, not magnetohydrodynamic simulation.
- **No changes to the post-processing pipeline** (bloom/brightpass/blur/composite). The disk feeds stage [1] HDR output as before; bloom then operates on the new higher-detail disk.
## Explicit supersession of a Phase 3 non-goal
Phase 3 spec (`2026-07-14-blackhole-cinematic-rendering-design.md`, line 20) states:
> **Volumetric disk thickness** — disk stays a zero-thickness plane. Volume ray-marching is a separate future project.
**This design supersedes that line.** Rationale: user feedback identified the disk as too smooth, and zero thickness is a primary root cause — without intra-volume density variation, no amount of brightness modulation produces the rolling/turbulent structure of the reference image. The volumetric integration approach chosen here (§2, approach A) is specifically the one that reuses the existing RK45 adaptive step rather than a separate ray-march, keeping the cost bounded and the architecture coherent. All other Phase 3 decisions (HDR pipeline, tone mapping, bloom, AA) remain in force.
## Architecture: volumetric integration in the RK45 loop
### Approach A — in-step volumetric sampling (chosen)
The main loop (`black_hole.wgsl:404-474`) currently calls `disk_hit(prev, new_pos)` once per accepted step to test `y=0` plane crossing. The new logic splits disk handling into two cooperating parts:
**A. In-disk step sampling (new).** On every accepted step, at `new_pos`, test whether the point lies within the disk thickness slab:
```wgsl
let r = length(vec2<f32>(new_pos.x, new_pos.z));
if (abs(new_pos.y) < disk_half_thickness && r >= disk_inner && r <= disk_outer) {
let s = disk_color_volumetric(new_pos, d);
let step_len = length(new_pos - prev);
accum_color += (1.0 - accum_alpha) * s.color * step_len;
accum_alpha += (1.0 - accum_alpha) * s.density * step_len;
}
```
Multiplying by `step_len` makes emission integrate over arc length. This is the key to approach A: the RK45 adaptive step already shrinks where light bends sharply (exactly where fine detail should appear) and grows where it travels straight. Volumetric contribution is therefore dense where the disk's light path curves and sparse where it doesn't — the adaptivity is reused, not duplicated. No extra geometry intersection, no separate march.
**B. Midplane edge capture (retained, repurposed).** The existing `disk_hit(prev, new_pos)` test is kept but its meaning changes from "the only sample" to "edge catch": when a single step straddles the `y=0` midplane and the step is large enough to skip over the thin slab, one precise at-plane sample is added:
```wgsl
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, d);
let thickness_proj = disk_half_thickness / max(abs(d.y), 1e-3);
accum_color += (1.0 - accum_alpha) * s.color * thickness_proj;
accum_alpha += (1.0 - accum_alpha) * s.density * thickness_proj;
}
```
`thickness_proj` is the slab depth projected along the ray, compensating for the midplane crossing a step skipped. The two parts do not conflict: A handles thick-slab traversal (many in-slab steps), B handles a straddling step that jumps the midplane.
**Parameters:** `disk_half_thickness` (default 0.3 desktop / 0.2 web → total thickness 0.6 / 0.4 Rs; with `disk_inner = 3.0` this is a ~20% / ~13% thickness-to-radius ratio, close to a real thin disk).
**Termination:** the existing `if (accum_alpha > 0.99) { break; }` is retained; opacity saturates naturally over a few in-disk steps.
### Why not B (fixed-step march) or C (analytic falloff)
- **B** opens a separate fixed-step mini-loop per plane crossing. It divorces sampling from the RK45 adaptivity, re-marches on every secondary-image crossing, and charges every ray uniformly regardless of viewing angle. Strictly more expensive for no visual gain over A.
- **C** (thickness only scales a single sample by `1/cos(angle)`) is precisely the current smooth disk plus a slope falloff — it cannot produce intra-volume structure, so it fails the entire goal. A false economy.
## Turbulence texture: ridged filaments + spiral arms
`disk_color_volumetric` produces three signals: **color, density, and brightness**. The noise is layered.
### Layer 1 — ridged bright filaments (replaces current FBM blobs)
The current `disk_noise` (`black_hole.wgsl:220`) is domain-warped FBM outputting [0,1] smooth blobs. It is replaced by ridged multifractal noise:
```wgsl
fn ridged_fbm(p: vec3<f32>, octaves: u32) -> f32 {
var sum = 0.0; var amp = 0.5; var freq = 1.0;
for (var i: u32 = 0u; i < octaves; i = i + 1u) {
let n = value_noise3(p * freq);
let ridge = 1.0 - abs(2.0 * n - 1.0); // peak at n=0.5, valleys at 0 and 1
sum += amp * pow(ridge, filament_sharpness);
freq *= 2.0; amp *= 0.5;
}
return sum;
}
```
`1 - |2n1|` inverts the smooth value-noise gradient into a sharp ridge — where the noise used to transition smoothly it now peaks, falling off to zero on both sides. Raising to `filament_sharpness` (default **2.0**, `ridge²`) thins and sharpens the ridges into filaments.
The default of 2.0 is chosen to match Gargantua: its bright structures are feathered smoke streaks — clearly defined bright lines that retain a soft, gaseous quality. Higher powers (4+) turn filaments into needle-thin hard lines and lose the gas feel; lower or no power widens them back toward the current blobs. 2.0 is panel-tunable up (sharper) or down (softer).
Domain warping (one layer of plain FBM distorting the sample coordinate) is retained so filaments curve and turbulence-bend rather than running straight.
### Layer 2 — density (decides where gas is, where it is void)
Density is no longer the fixed 0.85. A separate low-frequency FBM generates a volume density field:
```wgsl
let density_noise = fbm3(pos * density_freq + warp, density_octaves);
let density = smoothstep(0.3, 0.7, density_noise) * density_strength;
```
`smoothstep` produces a definite boundary: density noise above 0.7 is solid gas, below 0.3 is near-vacuum, between is a feathered edge. This gives the disk clumping — dense bright knots and thin dark gaps — instead of a uniform sheet.
### Layer 3 — spiral-arm modulation (large-scale winding)
A density modulation evolving with angle and radius, layered on top of the turbulence:
```wgsl
let phi = atan2(pos.z, pos.x);
let arm_phase = phi * arm_count + log(r) * arm_tightness - rot;
let arm = 0.5 + 0.5 * cos(arm_phase); // [0,1]; cos peaks = arms
let arm_mod = mix(1.0, pow(arm, 2.0), arm_strength);
```
`phi * arm_count` sets the number of arms; `log(r) * arm_tightness` winds them with radius (a logarithmic spiral, the physically-motivated shape for differentially-rotating disks); the existing Keplerian `rot` term (`time * disk_rotation_speed / r^1.5`, already computed in `disk_color`) advects the arms at the disk's own differential rotation so inner radii wind faster — no separate arm-speed parameter, the arms simply ride the flow. `arm_strength` interpolates between no arms (`mix(…, …, 0) = 1.0`, modulation absent) and full arms. The modulation multiplies density: arms are dense, inter-arm is tenuous.
### Composition
```wgsl
let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5); // Keplerian shear (already in disk_color)
let flow = vec3<f32>(0.0, 0.0, rot); // advect noise along the Keplerian flow
let warp = fbm3(pos * 0.8 + flow * 0.1, warp_octaves);
let filament = ridged_fbm(pos * filament_freq + warp * 1.5 + flow * 0.3, filament_octaves);
let density_noise = fbm3(pos * density_freq + warp, density_octaves);
let base_density = smoothstep(0.3, 0.7, density_noise) * density_strength;
let arm_mod = spiral_arm_modulation(pos, r, rot);
let brightness = filament; // filaments drive luminance
let total_density = base_density * arm_mod; // gas × spiral arms
let col = temperature_color(r) * brightness * radial_falloff(r) * disk_brightness;
col = apply_doppler(col, pos, dir); // Doppler asymmetry preserved
return DiskSample { color: col, density: total_density };
```
Physically motivated separation: density decides *where matter is*, filaments decide *how bright that matter is*, spiral arms decide *how the large-scale structure is distributed*. The three multiply, never add.
**Retained unchanged:** radial temperature color `tcol`, radial falloff `falloff`, Doppler beaming, Keplerian shear `rot`. These physical terms are extracted into shared helpers (`temperature_color`, `radial_falloff`, `apply_doppler`) used by both the volumetric path and the flat fallback, ensuring the two modes share identical physics.
## Disk quality tiers and performance budget
A new `DiskQuality` enum (mirroring `BloomQuality`) gates octave counts and provides a full escape hatch:
| Tier | Filament octaves | Density octaves | Warp octaves | Half-thickness default |
|------|------------------|-----------------|--------------|------------------------|
| `Off` | — (flat fallback) | — | — | — |
| `Low` (web default) | 3 | 2 | 2 | 0.2 |
| `Medium` | 4 | 3 | 3 | 0.3 |
| `High` (desktop default) | 5 | 4 | 3 | 0.3 |
`Off` reverts to the current zero-thickness disk: single midplane `disk_color_flat` sample, fixed alpha 0.85, identical to today's appearance. It is both a performance escape hatch and a visual A/B reference.
**Per-fragment cost.** Each in-disk RK45 step pays one `disk_color_volumetric` call:
- domain warp FBM (warp_octaves × ~7 ALU)
- ridged filament (filament_octaves × ~11 ALU)
- density FBM (density_octaves × ~7 ALU)
- spiral-arm trig + pow ≈ 15 ALU
At High (5/4/3): ≈ 21 + 55 + 28 + 15 ≈ **120 ALU per in-disk step**. A ray crosses the slab in roughly 520 accepted steps (dense where bent, sparse where straight), so **≈ 6002400 ALU per ray that hits the disk**. Disk-missing rays pay zero. The midplane edge capture (part B) runs at most twice per ray (primary + secondary image), ≈ 240 ALU/ray, constant.
This is ~10× the current disk cost, but only on disk-intersecting rays and only at High tier. Low tier (3/2/2) drops to ≈ **60 ALU per in-disk step**.
**Levers untouched:** `steps` (200 web / 300 desktop) and `render_scale` (0.5 web / 0.75 desktop) keep their Phase 3 defaults — they are tuned for the black-hole silhouette and secondary-image quality. Disk cost is absorbed by octave tiering, not resolution cuts.
### WGSL octave-loop safety
WGSL permits dynamic `for` bounds, but older WebGPU drivers have bugs with non-constant loop bounds. Octave loops use a fixed upper bound with early break:
```wgsl
const MAX_OCTAVES = 6u;
for (var i: u32 = 0u; i < MAX_OCTAVES; i = i + 1u) {
if (i >= actual_octaves) { break; }
...
}
```
`MAX_OCTAVES = 6` covers the highest tier (5) with headroom.
## Data flow and interface changes
### Uniform extension (`BlackHoleUniforms`, `src/render/material.rs`)
Nine new fields appended to the existing `#[derive(ShaderType)]` struct:
```wgsl
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, // 0=Off, 1=Low, 2=Medium, 3=High
```
`disk_quality` is a single `u32` encoding the tier; the shader selects the octave triplet from it rather than receiving three separate octave integers (fewer fields, panel is one dropdown not three sliders). WGSL-side `if/else` selects the octave set. Total uniform struct stays far under the 16384-byte limit.
### Rust params (`BlackHoleParams`, `src/params.rs`)
Eight new `pub f32` fields plus a `DiskQuality` enum. `Default`:
- desktop: `DiskQuality::High`, `disk_half_thickness` 0.3
- web: `DiskQuality::Low`, `disk_half_thickness` 0.2
- the other seven noise params share web/desktop defaults (table in §3)
```rust
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub enum DiskQuality {
Off,
Low,
Medium,
#[default]
High,
}
impl DiskQuality {
pub fn octaves(self) -> (u32, u32, u32) { // (filament, density, warp)
match self {
DiskQuality::Off => (0, 0, 0),
DiskQuality::Low => (3, 2, 2),
DiskQuality::Medium => (4, 3, 3),
DiskQuality::High => (5, 4, 3),
}
}
}
```
### Mirror (`mirror_params`, `src/render/plugin.rs:579`)
The existing per-frame copy loop appends nine assignments mirroring the new params into `u`. `disk_quality` is copied as `params.disk_quality as u32`.
### egui panel (`src/ui.rs`)
A new collapsible "Disk turbulence" section after the existing disk params block, holding eight sliders plus a `DiskQuality` `ComboBox` (pattern identical to the existing bloom-quality combo at `ui.rs:58-68`). Sliders are disabled when `DiskQuality::Off`.
### Shader function changes (`assets/shaders/black_hole.wgsl`)
- New `struct DiskSample { color: vec3<f32>, density: f32 }`.
- New `fn disk_color_volumetric(pos, dir) -> DiskSample` (ridged + density + spiral-arm path).
- Renamed `disk_color``fn disk_color_flat(pos, dir) -> DiskSample` returning `{ color: <existing calc>, density: 0.85 }` (the `Off` fallback).
- Extracted shared helpers `temperature_color(r)`, `radial_falloff(r)`, `apply_doppler(col, pos, dir)` used by both paths.
- New `fn ridged_fbm(p, octaves) -> f32`.
- Main loop `:447-455` restructured per §2, dispatching on `disk_quality`: `Off` → single `disk_color_flat` midplane sample (current behavior); non-`Off` → in-disk step sampling (part A) + edge capture (part B).
## Files touched
| File | Change |
|------|--------|
| `assets/shaders/black_hole.wgsl` | `ridged_fbm`, `DiskSample`, `disk_color_volumetric`, `disk_color_flat`, shared helpers, main-loop §2 restructure |
| `src/render/material.rs` | `BlackHoleUniforms`: +9 fields, `Default` |
| `src/params.rs` | `DiskQuality` enum + octaves; `BlackHoleParams`: +8 f32 + tier field; web/desktop `Default` |
| `src/ui.rs` | "Disk turbulence" collapsible: 8 sliders + quality combo |
| `src/render/plugin.rs` | `mirror_params`: +9 field copies |
**Not touched:** `src/physics.rs`, `src/lib.rs`, `assets/shaders/brightpass.wgsl` / `blur.wgsl` / `composite.wgsl`, `src/camera.rs`, `src/scene/planets.rs`, `src/web.rs`.
## Risks and mitigation
1. **Web frame rate (top risk).** Volumetric integration atop the already-heavy RK45 may drop web below playable.
- Mitigation: `Off` escape hatch; web defaults `Low` + thin slab (0.2); octaves cut to 3/2/2. After implementation, web frame rate must be measured at Low; if it still drops, consider further lowering the web default `filament_freq`. This is part of the existing Phase 3 human visual/perf validation gate.
2. **`DiskSample` struct return overhead.** WGSL returns structs by value; the compiler expands to registers. Negligible; acceptable.
3. **Octave count must be runtime-known.** Addressed by the fixed-`MAX_OCTAVES`-with-break pattern above (conservative WebGPU form).
4. **Secondary-image disk texture.** Rays that loop around the hole and re-cross the disk re-trigger volumetric integration — desirable (secondary image should also show texture), and naturally bounded by `accum_alpha > 0.99`. Cost is at most doubled on those rays; no special handling needed.
5. **`accum_alpha` now carries units.** Today alpha is the dimensionless constant 0.85. Under volumetric integration it becomes `density × step_len` (length units). Numerically: density ∈ [0,1], step_len ∈ [0.1, 1.0] typically, so a single in-disk step contributes ≈ 0.01.0 to alpha, and a few steps saturate to the 0.99 break. Magnitudes are reasonable, but `density_strength` default may need tuning after first visual check to avoid an over-transparent or over-opaque disk.
## Validation (human, per Phase 3 Task 8 process)
- **Desktop** `cargo run --release`: compare against the Gargantua reference — check that bright filaments, spiral-arm winding, slab thickness edge-feather, and Doppler left/right asymmetry all read correctly at High tier.
- **Web** `trunk serve`: confirm Low-tier frame rate is acceptable and that `Off` reverts exactly to the current disk appearance.
- **`cargo test`**: confirm `physics.rs` tests are unaffected (expected all green — `physics.rs` is not modified by this spec).

View File

@ -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",

View File

@ -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
}

View File

@ -21,6 +21,61 @@ 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),
}
}
/// 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,
}
}
}
/// Accretion disk color model. Gradient = the hand-tuned white-hot → orange
/// ramp with Newtonian Doppler; Blackbody = Tanner-Helland color keyed to a
/// Novikov-Thorne temperature profile shifted by the Kerr 4-velocity Doppler
/// factor, so the approaching side turns hotter/bluer.
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub enum DiskColorMode {
#[default]
Gradient, // mode 0: existing appearance
Blackbody, // mode 1: analytic blackbody + Kerr δ
}
impl DiskColorMode {
pub fn as_u32(self) -> u32 {
match self {
DiskColorMode::Gradient => 0,
DiskColorMode::Blackbody => 1,
}
}
}
/// All tunable black-hole parameters. Edited by the egui panel (Task 17),
/// mirrored into BlackHoleUniforms each frame (Task 7).
#[derive(Resource, Clone)]
@ -54,6 +109,22 @@ 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, // H/R ratio (scale height / radius); NOT absolute
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,
// Disk color model + blackbody temp (Phase 3.2), relativistic jets.
pub disk_color_mode: DiskColorMode,
pub disk_temp: f32,
pub jets_enabled: bool,
pub jets_strength: f32,
}
impl Default for BlackHoleParams {
@ -61,10 +132,10 @@ impl Default for BlackHoleParams {
Self {
rs: 1.0,
disk_inner: 3.0,
disk_outer: 15.0,
disk_outer: 25.0, // Gargantua-like extent (was 15.0)
disk_tilt: 0.45, // ~25.8 deg
disk_brightness: 1.0,
disk_rotation_speed: 0.5,
disk_rotation_speed: 1.2,
doppler_enabled: true,
doppler_strength: 1.0,
steps: if cfg!(target_arch = "wasm32") { 200 } else { 300 },
@ -75,11 +146,35 @@ impl Default for BlackHoleParams {
skybox_intensity: 0.0, // procedural stars only by default
planet_count: 0,
spin: 0.0,
star_aa: if cfg!(target_arch = "wasm32") { false } else { true },
star_aa: !cfg!(target_arch = "wasm32"),
bloom_threshold: 1.0,
bloom_strength: 0.8,
exposure: 1.0,
bloom_quality: if cfg!(target_arch = "wasm32") { BloomQuality::Low } else { BloomQuality::High },
// H/R ratio (was an absolute world-space half-height). 0.15 = standard
// thin-disk scale height; the slab now scales with radius, so a ray
// through large r traverses a proportionally thicker disk.
disk_half_thickness: 0.15,
filament_freq: 1.0,
filament_sharpness: 2.0,
density_freq: 0.8,
// Raised from 1.0: the new density model has no 0.55 floor and decays
// to 0 at the edges, so a slightly higher multiplier keeps the bulk
// opaque after per-step integration.
density_strength: 1.2,
arm_count: 2.0,
arm_tightness: 2.0,
arm_strength: 0.5,
disk_quality: if cfg!(target_arch = "wasm32") { DiskQuality::Low } else { DiskQuality::High },
// Blackbody: the Novikov-Thorne radial temperature gradient × Kerr
// Doppler gives the smooth white-hot inner → deep-orange outer look
// (closer to Gargantua than the hand-tuned Gradient mode).
disk_color_mode: DiskColorMode::Blackbody,
// 6500 K (was 10000): tuned so the NT profile yields a warm-white
// inner disk fading to deep orange at the outer edge.
disk_temp: 6500.0,
jets_enabled: true,
jets_strength: 1.0,
}
}
}

View File

@ -43,6 +43,21 @@ 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,
// Disk color mode + blackbody temp, relativistic jets.
pub disk_color_mode: u32, // 0=gradient, 1=blackbody
pub disk_temp: f32, // blackbody base temperature (Kelvin)
pub jets_enabled: u32,
pub jets_strength: f32,
}
impl Default for BlackHoleUniforms {
@ -61,7 +76,7 @@ impl Default for BlackHoleUniforms {
_pad3: 0.0,
rs: 1.0,
disk_inner: 3.0,
disk_outer: 15.0,
disk_outer: 25.0,
disk_tilt: 0.45,
disk_brightness: 1.0,
disk_rotation_speed: 0.5,
@ -79,6 +94,19 @@ impl Default for BlackHoleUniforms {
bloom_strength: 0.8,
exposure: 1.0,
_pad5: 0.0,
disk_half_thickness: 0.15,
filament_freq: 1.0,
filament_sharpness: 2.0,
density_freq: 0.8,
density_strength: 1.2,
arm_count: 2.0,
arm_tightness: 2.0,
arm_strength: 0.5,
disk_quality: 3, // High
disk_color_mode: 1, // Blackbody (Novikov-Thorne + Kerr Doppler)
disk_temp: 6500.0,
jets_enabled: 1,
jets_strength: 1.0,
}
}
}

View File

@ -419,7 +419,7 @@ fn spawn_bloom_pipeline(
/// Recreate the offscreen Image and rescale both quads on window resize,
/// honoring the live `render_scale` param.
#[allow(clippy::type_complexity)]
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn resize_offscreen(
mut images: ResMut<Assets<Image>>,
params: Res<crate::params::BlackHoleParams>,
@ -620,6 +620,19 @@ 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();
u.disk_color_mode = params.disk_color_mode.as_u32();
u.disk_temp = params.disk_temp;
u.jets_enabled = params.jets_enabled as u32;
u.jets_strength = params.jets_strength;
}
// Update brightpass threshold (live-tunable).
for (_, mat) in brightpass_materials.iter_mut() {

View File

@ -10,8 +10,12 @@ pub fn ui_system(
if let Ok(ctx) = contexts.ctx_mut() {
egui::Window::new("Controls")
.collapsible(true)
.resizable(true)
.default_pos([16.0, 16.0])
.default_width(300.0)
.default_height(560.0)
.show(ctx, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
egui::CollapsingHeader::new("Camera")
.default_open(true)
.show(ui, |ui| {
@ -31,15 +35,56 @@ pub fn ui_system(
.default_open(true)
.show(ui, |ui| {
// disk_inner removed — now spin-derived (see Black Hole section).
ui.add(egui::Slider::new(&mut params.disk_outer, 6.0..=40.0).text("Outer radius"));
ui.add(egui::Slider::new(&mut params.disk_outer, 6.0..=50.0).text("Outer radius"));
ui.add(egui::Slider::new(&mut params.disk_tilt, 0.0..=std::f32::consts::PI).text("Tilt"));
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"));
use crate::params::DiskColorMode;
let mut cm = params.disk_color_mode;
egui::ComboBox::from_label("Color model")
.selected_text(format!("{:?}", cm))
.show_ui(ui, |ui| {
ui.selectable_value(&mut cm, DiskColorMode::Gradient, "Gradient");
ui.selectable_value(&mut cm, DiskColorMode::Blackbody, "Blackbody");
});
params.disk_color_mode = cm;
ui.add_enabled(
cm == DiskColorMode::Blackbody,
egui::Slider::new(&mut params.disk_temp, 1000.0..=50000.0).text("Temperature (K)"),
);
});
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.02..=0.3).text("Thickness (H/R)"));
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"));
});
egui::CollapsingHeader::new("Jets").show(ui, |ui| {
ui.checkbox(&mut params.jets_enabled, "Enabled");
ui.add_enabled(params.jets_enabled, egui::Slider::new(&mut params.jets_strength, 0.0..=3.0).text("Strength"));
});
egui::CollapsingHeader::new("Renderer").show(ui, |ui| {
ui.add(egui::Slider::new(&mut params.steps, 50..=600).text("Steps"));
ui.add(egui::Slider::new(&mut params.render_scale, 0.25..=1.0).text("Render scale"));
@ -74,6 +119,7 @@ pub fn ui_system(
ui.label("MSAA is decorative on a fullscreen shader (no geometry edges to sample).");
});
});
});
// egui captures pointer when the cursor is over a window or being interacted with.
wants.0 = ctx.egui_wants_pointer_input();
} else {