Compare commits

...

23 Commits

Author SHA1 Message Date
xfy
4c9097f82f fix(planets): bind orbit radius to disk_outer so planets sit outside the disk
Planets were landing on/in the accretion disk. Root cause: orbit radius was
r = radius_factor · kerr_isco(χ), keyed off the disk's INNER edge. The disk
extends from ISCO out to disk_outer (default 25), so for every spin the
planets' radii fell inside the disk's radial span:
  χ=0: planets r ∈ [4.5, 17.25], disk [3, 25]   — all inside
  χ=1: planets r ∈ [0.75, 2.88], disk [0.5, 25]  — all inside, near hole

Re-keyed to the disk OUTER edge: r = radius_factor · disk_outer. With the
factor now meaning "multiple of disk_outer" (default 1.3, slider 1.1..=2.0,
per-planet jitter ±0.25 floored at 1.05), planets stay at r ≈ 32.5 —
reliably outside the disk regardless of spin, while still close enough for
lensing to curve their images.

Changes:
- orbit_position: new disk_outer param, r = radius_factor · disk_outer
- orbit_system: passes params.disk_outer
- spawn_planet_system: jitter range tightened to ±0.25 around the factor,
  floor 1.05 (was ±0.75 around an ISCO-keyed k, floor 1.0)
- params default planet_radius_factor 2.5 → 1.3 (new semantics)
- UI: slider 1.5..=5.0 → 1.1..=2.0, label "Radius (× disk outer)",
  readout shows orbit r and disk_outer instead of ISCO
- tests updated for the new signature and r = factor · disk_outer invariant
2026-07-17 09:45:56 +08:00
xfy
40ff101b6d fix(planets): wire planet_radius_factor UI slider into orbit radii
The "Radius factor k" UI slider wrote params.planet_radius_factor and
triggered respawn via the dirty flag, but spawn_planet_system ignored it —
every planet's radius_factor came from rng.gen_range(2.0..4.0), so the
slider was a silent no-op (it changed the ISCO readout label and respawned,
but the actual orbit radii never reflected its value).

Now samples radius_factor as planet_radius_factor ± 0.75, so the slider
controls the orbital scale while preserving per-planet radius diversity
(no fully-identical radii). Clamped to >=1.0 so jitter near the slider's
low end can't drop a planet inside the ISCO.

Caught by the Phase 3.4 final code review.
2026-07-16 18:38:42 +08:00
xfy
b787a4c54c fix(web): enable getrandom js feature for wasm32 target
rand 0.8 depends on getrandom 0.2, which refuses to compile on
wasm32-unknown-unknown without its "js" feature (it needs to call into the
Web Crypto API via wasm-bindgen). Bevy's own rand usage sits on getrandom
0.3+, which enables this by default, so the web build only broke once we
added a direct rand 0.8 dep in 76be513.

Force-enabling getrandom/js in the wasm32 target deps section unblocks
`trunk build`. Desktop is unaffected (the cfg gates it to wasm only).
Caught by the Task 8 wasm cargo-check gate.
2026-07-16 18:29:11 +08:00
xfy
dd4f8fb202 feat(ui): Planets control panel + dirty-flag respawn on seed/count/k 2026-07-16 18:27:09 +08:00
xfy
ec69698530 feat(planets): spawn_planet_system with deterministic ChaCha8Rng seeding 2026-07-16 18:07:53 +08:00
xfy
78a222e685 feat(params): planet system params + orbit_system wiring 2026-07-16 17:57:42 +08:00
xfy
474dfbe723 feat(planets): OrbitParams component + orbit_position geometry 2026-07-16 17:55:42 +08:00
xfy
6ff5a05e74 docs(spec): correct Ω_θ cross term (a√M/r^1.5, not a·Ω_φ/r)
The vertical epicyclic frequency's cross term is a√M/r^1.5 (radius to the
−1.5), following Okazaki 1987. The spec previously wrote a·Ω_φ/r, which makes
Ω_θ too large and breaks monotonic growth of precession with spin. Caught
during Task 1 implementation by the grows_with_spin test; syncing the spec
to the corrected formula that shipped in 891eadf.
2026-07-16 17:53:12 +08:00
xfy
891eadf28b feat(physics): Kerr nodal precession Ω_LT (strong-field Lense-Thirring)
Adds kerr_nodal_precession(r, chi) = Ω_φ − Ω_θ using the Okazaki (1987)
vertical epicyclic frequency:
  Ω_θ² = Ω_φ² · (1 − 4a√M/r^1.5 + 3a²/r²)

χ=0 → a=0 → Ω_θ = Ω_φ → zero precession (exact Schwarzschild degeneracy).

Note on the cross term: it is a√M/r^1.5 (radius to the −1.5), NOT a·Ω_φ/r.
The latter makes Ω_θ too large and breaks the "precession grows monotonically
with spin" property — caught by the grows_with_spin test.

Three tests: vanishes at zero spin, grows monotonically with spin at fixed r,
and exceeds the weak-field 2aM/r³ approximation in the strong-field (r<6)
region. Full suite green (14 tests).
2026-07-16 17:52:51 +08:00
xfy
be4ff2cfcc feat(physics): Kerr equatorial orbital frequency Ω_φ (Bardeen 1972) 2026-07-16 17:43:58 +08:00
xfy
76be513bb5 deps: add rand + rand_chacha for deterministic planet seeding
ChaCha8Rng needs both crates: rand (the core traits Rng/SeedableRng) and
rand_chacha (the ChaCha8Rng type, split out of rand since 0.9). rand was
already a transitive dep via bevy_math but not declared directly.

Pinned to 0.8/0.3 (LTS, API-stable with the Bevy 0.19 ecosystem) rather
than the newer 0.10 line, which would force a wider dep upgrade.
2026-07-16 17:41:56 +08:00
xfy
27e5e8b1f6 docs(plan): Kerr orbiting planets implementation plan (Phase 3.4)
9-task TDD plan implementing the Phase 3.4 spec. Path A: all orbital physics
on CPU, shader untouched.

Task ordering enforces a buildable chain:
- Task 0: add rand + rand_chacha (currently only transitive via bevy)
- Task 1: physics.rs Ω_φ + Ω_LT with 5 failing-first tests (χ=0 degeneracy,
  strong-field > weak-field, monotonic-in-spin)
- Task 2: OrbitParams + orbit_position pure fn with 3 geometry invariants
  (radius preserved, equatorial χ=0 stays in plane, motion over time)
- Task 3-4: orbit_system + BlackHoleParams fields (coupled, committed together)
- Task 5: spawn_planet_system (ChaCha8Rng, replaces spawn_default_planet) +
  PlanetSystemDirty resource + plugin registration
- Task 6-7: UI header + dirty-flag wiring on seed/count/k change
- Task 8: integration test + visual tuning

Each task is bite-sized (2-5 min steps), exact file paths/line numbers,
complete code blocks, exact test commands. Self-reviewed against spec: all
sections covered, no placeholders, type names consistent across tasks.
2026-07-16 16:50:18 +08:00
xfy
95968c0575 docs(spec): Kerr orbiting planets design (Phase 3.4)
Spec for 5-8 planets on Kerr circular orbits with strong-field Lense-Thirring
nodal precession. Path A: CPU computes position each frame, uploads to the
existing storage buffer; shader unchanged.

Physics: closed-form Ω_φ (Bardeen 1972 eqn 2.16) and Ω_θ (Caltech Ph236
epicyclic vertical frequency), giving exact nodal precession Ω_φ - Ω_θ.
χ=0 degenerates exactly to Schwarzschild (Ω_LT = 0, Newtonian Ω_φ).

Explicitly rejects Mino-time analytic geodesics (Fujita-Hikida): circular
orbits collapse the three-frequency decomposition to Ω_φ alone, elliptic
functions have no WGSL/std backing, and it would break physics.rs's
single-testable-mirror design. Closed-form Kerr frequencies are the right
tool for circular orbits.

Orbit radius bound to k·kerr_isco(χ), so planets track the disk inner edge
as spin changes and span the strong-field region (k=2.5 → r ∈ [1.25, 7.5]).
Random initial attitudes (ChaCha8Rng + UI seed) so the three orbital planes
precess at different Ω_LT rates — the visible Kerr signature.

Decomposes Planet into OrbitParams (immutable elements) + Planet (derived
center), keeps SphereData GPU layout untouched. Adds time_scale UI knob
because Ω_LT at r=8 needs ~25 min per revolution otherwise.
2026-07-16 16:46:10 +08:00
xfy
dd1facae39 docs(agents): sync AGENTS.md with bloom pipeline, quality tiers, web gotchas, and Phase 3 docs 2026-07-16 16:24:51 +08:00
xfy
573e68e96d fix(jets): weight emission on geometric step length to kill base-of-jet bands
The concentric banding persisted on the jets (near the base) after the
disk moiré was fixed. Root cause: sample_jets used a fixed per-step
weight (0.5) instead of a per-unit-length one, so the jet's
per-unit-length contribution was ∝ 1/step_len. RK45 packs its accepted
steps tightly near the hole (high curvature → small dt), so the jet
brightened in step-dense rings exactly where the user saw bands. This
was the inverse of the earlier `* dt` attempt (which banded because dt
varies 16×); both forms couple brightness to the integrator's step
choice rather than to the physics.

Fix: pass the step's world-space length |new_pos − prev| in from the
caller (it already has prev and new_pos) and weight the per-step
emission and alpha on it — the same step-size-decoupling technique the
disk path already uses (integrate_disk_segment's analytic seg_len). The
per-unit-length coefficient (0.1) is calibrated to keep the default
scene's jet brightness close to the old fixed-0.5 look.

cargo build --release: clean. cargo test: passing.
2026-07-16 16:14:41 +08:00
xfy
65a7611868 fix(disk): kill concentric moiré + smooth lensed-image rings via AA
Two distinct sampling artifacts on the accretion disk, found with a
CPU pixel-mirror of the integrator:

1. Moiré on the disk. integrate_disk_segment placed its N sub-samples
   on the deterministic (i+0.5)/N grid. That lattice aliases against
   the 2-D pixel grid and the per-ray RK45 step lattice → a concentric
   banding. Fix: jitter the sub-sample position within stratum i using
   a hash of the pixel coordinate and the sample index. The jitter is
   pixel-seeded only (no time), so the noise is spatially stable (no
   per-frame flicker) and stays inside the stratum, preserving the
   quadrature order. Verified by CPU mirror: banding transitions drop
   sharply and the noise is unstructured.

2. Higher-order lensed-image rings read as an artifact. Rays near the
   critical impact parameter wrap around the hole; each wrap crosses
   the disk plane once, projecting to a ring (a physical Einstein-ring
   structure, the same one Gargantua shows). The integrator renders
   each wrap as a sharp discrete band, so at high `steps` the rings
   look like a bug. Fix: per-pixel stochastic supersampling — fire
   several jittered sub-rays per pixel and average them, which
   antialiases the band edges into a smooth gradient while keeping the
   underlying lensing physics.

The two fixes share a `pixel_seed` (var<private>, set from
@builtin(position)) so the jitter is deterministic per pixel.

Shader refactor: the single-ray march is extracted into march(dir);
fragment() now loops it MAX_AA_SAMPLES times (compile-time cap for
WGSL's static-loop-bound requirement, runtime count from a new
aa_samples uniform via early break — the fbm3/MAX_OCTAVES pattern).

Uniform layout: added aa_samples: u32 + two f32 pads to round the
struct to 16-byte alignment, mirrored byte-for-byte across the WGSL
struct and BlackHoleUniforms (ShaderType).

UI/params: new AaQuality enum (Off/Low/High = 1/2/4 samples) in the
Quality panel. Defaults are tiered per AGENTS.md: Off on web, Low (2×)
on desktop — the RK45 march is already ~10× Phase 1's cost, so desktop
stays at 2× with High (4×) selectable for a fully smooth look.

cargo test: 20 passed. cargo build --release: clean.
2026-07-16 16:02:41 +08:00
xfy
934468f7a0 ui(jets): gray out Strength + hint when spin is below the render gate
sample_jets suppresses jets for χ < 0.05 (previous commit), but the egui
panel's checkbox still flipped freely and Strength stayed editable at low
spin, so enabling jets there did nothing with no explanation — the toggle
looked broken.

Mirror the shader's gate in the UI: keep the Enabled checkbox interactive
(it expresses user intent), gray out Strength when jets won't render
(matching the existing pattern used for disk quality, Doppler, color
model, and bloom), and show a one-line hint ("Spin (χ) too low — jets
need χ ≥ 0.05.") so the no-op is self-documenting.
2026-07-16 14:11:02 +08:00
xfy
38d4bd00f2 fix(disk/jets): suppress spin-0 jets, cap beaming, decouple from RK45 dt
Three issues in sample_jets produced the reported "jet column + bright
white ring over the hole's pole" on the default scene (spin = 0,
jets_enabled = true):

1. Jets rendered at χ = 0. Relativistic jets are spin-powered
   (Blandford-Znajek taps the ergosphere), which doesn't exist for a
   non-rotating hole. jets_enabled was a free toggle decoupled from
   spin, so the default scene showed polar columns with no physical
   cause. Gate sample_jets on uniforms.spin >= 0.05.

2. Beaming saturates to white. With β=0.92 the approaching jet's
   δ^3.5 reaches ~258×, blowing the blue base color past the bloom
   threshold so High-level bloom (3-pyramid) collapses it to white.
   Cap beaming at 8× — the approaching side is still visibly brighter,
   and the base color (0.4, 0.7, 1.0) survives the HDR + bloom path.

3. dt-modulated accumulation painted ring structures. `* dt` weighted
   the per-step contribution by the RK45 adaptive step size, which
   varies across a 16× range (dt_min..dt_max) and between neighboring
   pixels — the same class of bug the disk path already documented and
   fixed in integrate_disk_segment (radial spokes). The jet has no
   analytic slab to clip to, so use a fixed normalized weight (0.5);
   brightness then depends only on geometry and beaming, never on dt.
   Drops the now-unused dt parameter.
2026-07-16 13:48:04 +08:00
xfy
614bd1d88c perf(build): add fat LTO + single codegen-unit to desktop release profile
The renderer is GPU-bound, but Bevy's CPU path (resource management,
system scheduling, egui, wgpu command submission) still benefits from
cross-crate inlining. Cargo's default release profile skips LTO and
splits codegen across 16 units, leaving inter-crate trampolines through
Bevy/wgpu in the hot path.

- lto = "fat": whole-program cross-crate inlining.
- codegen-units = 1: best optimization (slower compiles, accepted).
- panic = "abort": smaller binary, no unwind tables.
- strip = "symbols": drop debug symbols from the shipped binary.

wasm-release inherits release then overrides opt-level to "z", so web
builds keep their size focus. Release build verified; cargo test green
(14 passed).
2026-07-16 13:23:01 +08:00
xfy
2d764f5f19 chore(skills): pin rust-advanced-performance skill 2026-07-16 11:44:52 +08:00
xfy
5c0b1db01b fix(web): use textureSampleLevel for skybox to satisfy Tint's uniform-flow rule
The web build black-screened on the first frame: the black-hole fragment
shader failed to compile in Chrome's Tint (BrowserWebGpu backend) with
"'textureSample' must only be called from uniform control flow", which
invalidated the opaque_mesh2d_pipeline and made Bevy quit after frame 1.
Desktop (naga) was unaffected because naga does not enforce this WGSL rule,
which is why the regression was web-only.

skybox_color() called textureSample on the cubemap from inside the main RK45
integration loop. That loop's control flow is non-uniform — per-pixel early
exit via `if (accum_alpha > 0.99) { break; }` and per-iteration accept/reject
— and textureSample needs screen-space derivatives (uniform across the quad)
for mip selection, so the spec forbids it there.

Switch to textureSampleLevel with an explicit LOD of 0. textureSampleLevel
does no derivative computation and is permitted in non-uniform control flow.
LOD 0 is correct for the skybox: it is sampled at full resolution with no
mip minification, so the visual result is identical to the implicit-lod path.

Diagnosis required building a JS-side probe that fetches the WGSL, mirrors
naga_oil preprocessing (resolve #{MATERIAL_BIND_GROUP}, inline VertexOutput),
then calls device.createShaderModule + getCompilationInfo to surface Tint's
structured messages — Bevy's wgpu path swallows the raw Tint error on web and
only ever surfaces the downstream "Invalid ShaderModule due to a previous
error". The probe confirmed the error site and rule; it is not committed.
2026-07-16 11:31:15 +08:00
xfy
00aa27175e fix(web): silence spurious console errors (meta + AudioContext)
Two independent sources of noise in the web console, both from the
default plugin set:

1. 'Failed to deserialize meta for asset shaders/*.wgsl'
   DefaultPlugins adds AssetPlugin with meta_check = Always, so bevy
   fetches <path>.meta for every shader. The shaders ship raw (no .meta
   files); on the trunk dev server those requests don't return a clean
   404 that bevy maps to NotFound, so bevy receives bytes and tries to
   RON-deserialize them as AssetMetaMinimal, logging a SpannedError per
   shader. Set meta_check = Never: it skips the meta lookup and uses the
   loader's default meta — correct for processor-free raw assets.

2. 'AudioContext was not allowed to start'
   The default AudioPlugin opens a WebAudio sink at startup; browsers
   block that until a user gesture and log it as a JS error. This app
   has no audio, so disable AudioPlugin entirely — removes the noise and
   shrinks the wasm binary.
2026-07-16 10:07:19 +08:00
xfy
30786eb253 fix(web): ship WGSL shaders to dist via trunk copy-dir
Bevy's HttpWasmAssetReader fetches asset paths over HTTP at runtime,
requesting 'assets/shaders/*.wgsl' (matching AssetPlugin's default
file_path of "assets"). Trunk only emits what index.html references,
so without an explicit copy-dir link the shaders were absent from dist/
and every material hit a 404 → grey screen.

Add the copy-dir link so trunk mirrors assets/ into dist/.
2026-07-16 10:07:07 +08:00
16 changed files with 2263 additions and 72 deletions

View File

@ -0,0 +1,299 @@
---
name: rust-advanced-performance
description: |
当局部代码技巧已不够、需从系统级/架构级/编译器级压榨 Rust 性能时主动应用。
触发关键词LTO、codegen-units、mimalloc、jemalloc、SIMD、向量化、cache locality、
false sharing、伪共享、repr(C)、FxHash、AHash、SipHash、lock-free、无锁、AtomicU64、
crossbeam、dashmap、zero-copy、零拷贝、Deserialize、serde 借用,以及"压榨极致性能"、
"高并发吞吐"、"Flamegraph 找热点"、"Criterion 基准"、改 Cargo.toml profile、或线上
服务需整体提速而非单点优化时。
allowed-tools:
- Read
- Edit
- Grep
- Glob
metadata:
trigger: Rust 系统级性能优化 / 编译期配置 / 内存布局与缓存 / 无锁并发 / 零拷贝架构
related: 基础 skill optimizing-rust-performance 处理局部技巧;本 skill 处理架构级手段
---
# Rust 高级性能优化(系统级 / 架构级 / 编译器级)
当局部代码技巧(见基础 skill `optimizing-rust-performance`)不足以达到目标时,
从**四个维度**寻找更大的收益:编译期配置、内存布局与缓存、并发、零拷贝架构。
**核心原则(与基础 skill 一致)**:按固定优先级评估、判断到触发条件即应用、量化收益、
**一切以 profiling 数据为准**。这些手段比局部技巧侵入性更大、成本更高,**更**需要先确认
它是真热点,别为用而用。
## 优化心智模型(系统级版,必须按此顺序判断)
```
局部技巧打不动了,再按此顺序评估系统级手段:
1. 先 profiling — 不测量就优化是浪费。Criterion 微基准 + Flamegraph 找真热点。
没数据,下面三条都别动。
2. 编译期 / 配置 — 改 Cargo.toml、换分配器。不动业务代码收益大、风险小。优先。
3. 内存布局 / 缓存 — cache locality、对齐、避免伪共享。CPU 密集型热点的核心。
4. 并发 — 降锁竞争、原子操作、无锁结构。仅当瓶颈在多线程同步时。
5. 零拷贝 — I/O 密集型的解析/传输路径。
记住:系统级手段成本高(改全局配置 / 改数据布局 / 引入新依赖 / 改公开 API
局部能解决的,别上架构级。
```
## 铁律:先 profiling再动手判断到触发条件就应用
> **没有火焰图/基准数据,不要改 Cargo.toml、不要换分配器、不要上 SIMD。**
>
> 这些是"暴击"也是"重武器":改 profile.release 影响所有 release 构建;换分配器影响
> 全局内存行为SIMD/`#[repr(C)]` 改的是数据布局。**先确认目标函数/路径真的是热点**
> 再判断下面的触发条件是否命中,命中才应用,并说明改了什么、为什么快、风险在哪。
## 一、编译期与配置黑魔法(无需改业务代码的暴击)
最高性价比:不改一行业务代码,只动 `Cargo.toml` 或入口配置。
### 模式 1开启 LTOLink-Time Optimization
**触发**release 构建追求极致体积/速度;跨 crate 调用频繁、希望编译器跨边界内联。
**动作**:在 `Cargo.toml` 开启 LTO 并限制 codegen-units。
```toml
[profile.release]
lto = true # 跨 crate 内联 + 死代码删除
codegen-units = 1 # 单编译单元,允许更激进的全局优化(代价:编译变慢)
```
**收益**:通常 **10% ~ 20%** 运行速度提升 + 二进制体积下降。
**代价**link 阶段显著变慢、内存占用升高。CI/release 才开,日常 dev 不开。
### 模式 2更换内存分配器jemalloc / mimalloc
**触发**:多线程高频 heap 分配场景如高并发服务、每个请求大量小对象profiling 显示
分配/锁竞争占比高。
**动作**:用 `mimalloc``jemalloc` 替换系统默认分配器Linux 上是 glibc malloc
```rust
// Cargo.toml: mimalloc = "0.1"
use mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
```
**收益**thread-local cache 降低多线程分配锁竞争,分配吞吐显著提升。
**代价**:新增依赖、稍大体积;不同工作负载提升差异大,**必须 benchmark 验证**。
## 二、内存布局与缓存友好优化Cache Locality
CPU 密集型热点的核心:让数据紧凑、让缓存行命中。
### 模式 3SIMD / 自动向量化
**触发**:对超大数组做相同数学运算(矩阵、图像处理、加密、批量数值计算)。
**动作**
- **优先靠编译器自动向量化**——写对齐友好的循环(连续切片迭代、避免循环内分支/调用)。
- 需要显式控制时用 `std::simd`nightly`wide`/`pulp` cratestable
```rust
// 倾向:写编译器能自动向量化的朴素循环,而不是手撸 intrinsics
pub fn sum(xs: &[f32]) -> f32 {
xs.iter().copied().sum::<f32>() // 连续、无分支,编译器易自动向量化
}
```
**收益**:一条指令处理多个数据,吞吐数倍提升。
**代价/注意**:手写 intrinsics/`unsafe` 易错、难维护;先确认自动向量化没命中再上显式 SIMD。
### 模式 4调整结构体对齐与字段顺序`#[repr(C)]` / Packing / 避免伪共享)
**触发**
- cache locality 差:结构体字段零散、热循环里只摸其中一两个字段却把整个大结构体带进缓存。
- **伪共享false sharing**:多线程高频写两个变量,它们恰好落在同一缓存行(通常 64 字节),
导致该缓存行在核心间反复失效。
**动作**
- 字段按类型大小降序排列提升紧凑度Rust 默认会重排,但 `#[repr(C)]` 后顺序固定、
需自己负责)。
- 把多线程高频写的计数器/状态用对齐填充隔开,避免同缓存行。
```rust
// ❌ 两个热变量挨着,大概率同缓存行 → false sharing
struct Counters {
a: u64,
b: u64,
}
// ✅ 用对齐填充强制分到不同缓存行64 字节)
#[repr(C)]
struct Counter {
value: u64,
_pad: [u8; 56], // 填到 64 字节,下一个字段落到新缓存行
}
```
**收益**:减少 cache miss消除伪共享后多线程写吞吐大幅提升。
**代价**体积增大padding`#[repr(C)]` 改变布局,与 FFI/序列化耦合时要小心。
## 三、高级数据结构与无锁并发
仅当 profiling 显示瓶颈在哈希表或线程同步时才上。
### 模式 5用 FxHash / AHash 替代默认哈希
**触发**`HashMap` 读写是热点,且 key 来自可信输入(内部计算、无恶意外部输入)。
**动作**:把默认 `SipHash 1-3` 换成 `fxhash` / `ahash`
```rust
use fxhash::FxHashMap; // = HashMap<K, V, FxBuildHasher>
let mut m: FxHashMap<&str, u32> = FxHashMap::default();
```
**收益**:哈希速度快数倍,`HashMap` 读写性能暴涨。
**代价/注意**`fxhash` 不抗 HashDoS。**面向不可信网络输入的路径绝不能换**,否则被恶意
构造 key 打成 O(n²) 拒绝服务。
### 模式 6无锁Lock-Free并发
**触发**:高并发下 `Mutex` 成为瓶颈profiling 显示锁等待 / 上下文切换占比高)。
**动作**
- 简单计数器/状态位 → `std::sync::atomic``AtomicU64` / `AtomicBool`),硬件指令保证
原子性,开销远低于锁。
- 跨线程通道 → `crossbeam-channel`底层大量无锁CAS设计吞吐远超 `std::sync::mpsc`
```rust
use std::sync::atomic::{AtomicU64, Ordering};
static HITS: AtomicU64 = AtomicU64::new(0);
HITS.fetch_add(1, Ordering::Relaxed); // 无锁计数
```
**收益**:消除线程挂起/上下文切换,高争用场景吞吐提升明显。
**代价/注意**:无锁代码(尤其自写 CAS 循环极难写对ABA、memory ordering 误用)。
优先用成熟库(`crossbeam``dashmap`),别手撸无锁结构。
## 四、零拷贝架构Zero-Copy
I/O 密集型(网络/文件 → 解析)路径的杀手锏:让数据尽量不搬家。
### 模式 7零拷贝解析serde 借用 / `Deserialize<'a>`
**触发**:解析 JSON/二进制时为每个字符串字段分配新 `String`profiling 显示分配是热点。
**动作**:用 serde 的借用反序列化,让字段直接 `&'a str` 指向原始缓冲区,不分配。
```rust
use serde::Deserialize;
// ❌ 每个字段 clone 出新 String堆分配
#[derive(Deserialize)]
struct User {
name: String,
email: String,
}
// ✅ 借用:字段指向输入缓冲区,零分配(输入生命周期必须 >= 结构体)
#[derive(Deserialize)]
struct UserRef<'a> {
name: &'a str,
email: &'a str,
}
// let user: UserRef = serde_json::from_slice(buffer)?;
```
**收益**:读路径零分配;解析大文档时分配数从 O(n 字段) 降到 0。
**代价/注意**:借用结构体被生命周期 `'a` 绑定,输入缓冲区必须存活且不可变——
这是 API 传染(同基础 skill 的 Cow 警告),确认波及面配得上收益再改公开签名。
## 回应规范(应用系统级优化时必须做到)
1. **先给 profiling 证据**:引用火焰图/基准说明"为什么是这里"。
2. **按优先级挑手段**:先看配置(风险小),再看布局,再看并发,最后零拷贝。
3. **量化理论收益**:复杂度 / 分配 / cache 行 / 锁竞争层面的"为什么快"。
4. **说明代价与风险**:编译变慢?体积变大?不抗 HashDoS生命周期传染memory ordering 风险?
5. **提示验证**:改完用 Criterion 微基准对比 before/after别盲信理论。
**量化收益参考**(写到改动说明里):
| 改动 | 收益 / 代价 |
| -------------------------------------- | ----------------------------------------- |
| `lto=true` + `codegen-units=1` | +10%~20% 速度 / 体积下降link 慢、内存高 |
| 系统 allocator → mimalloc/jemalloc | 高并发分配吞吐提升;+依赖,需 benchmark |
| 朴素循环 →(自动/显式SIMD | 数值批量运算吞吐数倍;显式版难维护 |
| 字段重排 / 消除 false sharing | cache miss↓多线程写吞吐↑体积可能↑ |
| SipHash → FxHash/AHash | 哈希快数倍;**不抗 HashDoS仅可信输入** |
| `Mutex` → 原子 / crossbeam / dashmap | 消除锁等待/上下文切换;自写无锁极易错 |
| `String` 字段 → `&'a str` (serde 借用) | 读路径零分配;生命周期传染 API |
## 标准优化路径(核对清单)
面对一个要压榨极致性能的 Rust 项目,按这个顺序走:
```
1. 基础分析 ──> Criterion 写基准 + Flamegraph 抓 CPU 热点函数
│ (没数据就别往下走)
2. 配置先行 ──> lto=true + codegen-units=1热点是分配就换 mimalloc/jemalloc
│ (不动业务代码,先拿这部分收益)
3. 代码微调 ──> 先用基础 skillswap_remove / retain / mem::take / SmallVec / Cow
│ (局部技巧成本最低,优先于架构级)
4. 架构重构 ──> 内存布局/缓存SIMD、字段重排、去伪共享
│ 并发FxHash、原子、crossbeam/dashmap
│ 零拷贝serde 借用)
```
**黄金法则:不要凭空猜测,一切以 profiling 数据为准。** 动手前先用火焰图抓出
最慢的函数,往往事半功倍。
## 自检清单(应用系统级优化前逐条过)
- [ ] 是否**先 profiling**Criterion / Flamegraph确认目标是真热点再动手
- [ ] 能否先用基础 skill 的局部技巧解决?能就别上架构级。
- [ ] LTO / codegen-units 是否只在 release profile 开dev 保持快编译?
- [ ] 换分配器前是否 benchmark 过?不同工作负载差异大。
- [ ] SIMD 是否先试自动向量化、再考虑显式 intrinsics后者难维护
- [ ] 调字段顺序/加 padding 是否评估了体积增大和 FFI/序列化耦合?
- [ ] 换 FxHash/AHash 的路径是否**只处理可信输入**,绝不被恶意 key 攻击?
- [ ] 无锁并发是否优先用成熟库crossbeam/dashmap而非手写 CAS
- [ ] serde 借用改公开返回类型前,是否确认生命周期传染波及面配得上收益?
- [ ] 每条改动是否说明了收益**和代价/风险**,并提示用基准验证?
## 注意(应用前必读 —— 系统级手段更需克制)
这些手段比局部技巧重得多,应用前权衡,**别因为"听起来高级"就上**
- **先测量再优化**:没有火焰图/基准数据,改 `Cargo.toml`、换分配器、上 SIMD 都是赌博。
profiling 数据为准是第一原则,不是口号。
- **优先局部,其次架构**:基础 skill 的局部技巧swap_remove / retain / Cow 等)成本最低、
波及面最小。局部能解决就别动架构。
- **配置类副作用全局化**`lto`/`codegen-units`/分配器影响整个 release 构建。先在分支验证,
别直接动主干 profile。
- **安全换哈希有前提**FxHash/AHash 不抗 HashDoS。任何面向不可信输入HTTP body、
外部 RPC的 map **必须保留 SipHash**,否则引入拒绝服务漏洞。
- **无锁代码是雷区**memory ordering / ABA 错误极难复现。用成熟库,别手写。
- **API 传染**`&'a str` / 借用结构体的生命周期会传染所有调用方(同基础 skill 的 Cow 警告)。
改公开签名前确认波及面配得上收益;内部 `fn` 无所谓。
**决策速查**
| 情况 | 推荐 |
| ------------------------- | ------------------------------------------ |
| 还没 profiling | 先 Criterion + Flamegraph别动手 |
| 局部技巧能解决 | 用基础 skill别上架构级 |
| release 追求极致、CI 可慢 | `lto=true` + `codegen-units=1` |
| 多线程高频分配是热点 | benchmark 后换 mimalloc/jemalloc |
| CPU 密集 + 批量数值运算 | 先试自动向量化,不够再显式 SIMD |
| 多线程高频写相邻变量 | 对齐填充消除 false sharing |
| 可信输入 + HashMap 是热点 | FxHash/AHash**不可信输入保留 SipHash** |
| 高争用锁是瓶颈 | 原子计数;通道用 crossbeammap 用 dashmap |
| I/O 解析分配是热点 | serde 借用 `&'a str`(注意生命周期传染) |

View File

@ -4,64 +4,84 @@ A real-time Kerr (spinning) black-hole renderer in Bevy 0.19. One binary, two ta
## Build & run ## Build & run
- **Desktop:** `cargo run --release` (debug build is too slow to ray-trace; always use `--release` for visual checks). - **Desktop:** `cargo run --release` (debug is too slow to ray-trace; always use `--release` for visual checks).
- **Web** (WebGPU only): `trunk serve` → http://127.0.0.1:8080. First-time setup: `cargo install --locked trunk` and `rustup target add wasm32-unknown-unknown`. Release web build: `trunk build --release`. - **Web** (WebGPU only): `trunk serve` → http://127.0.0.1:8080. First-time setup: `cargo install --locked trunk` and `rustup target add wasm32-unknown-unknown`. Release web build: `trunk build --release`.
- `.cargo/config.toml` sets `--cfg web_sys_unstable_apis` for the `wasm32` target only — required for `web-sys`'s WebGPU bindings. It is a no-op on desktop; do not remove it. - `.cargo/config.toml` sets `--cfg web_sys_unstable_apis` for the `wasm32` target only — required for `web-sys`'s WebGPU bindings. It is a no-op on desktop; do not remove it.
- `edition = "2024"`. No pinned toolchain file; tested on stable 1.96. - `edition = "2024"`. No pinned toolchain file; tested on stable 1.96.
- `target/` and `dist/` are gitignored. The `dist/` folder may contain a ~200 MB wasm build locally — never commit it. - Release profiles (`Cargo.toml`): desktop `[profile.release]` uses `lto = "fat"`, `codegen-units = 1`, `panic = "abort"`, `strip = "symbols"`. Web `[profile.wasm-release]` inherits release and overrides `opt-level = "z"` for binary size. `trunk build --release` picks up `wasm-release`.
- `target/` and `dist/` are gitignored. `dist/` may hold a ~200 MB wasm build locally — never commit it.
## Test ## Test
- `cargo test`. There is exactly one testable surface: `src/physics.rs` (inline `#[cfg(test)]`) + `tests/physics_test.rs` (integration test via the `singularity_rs::physics` lib export). `src/lib.rs` exists solely to expose `physics` for these tests. - `cargo test`. There is exactly one testable surface: `src/physics.rs` (inline `#[cfg(test)]`) + `tests/physics_test.rs` (integration test via the `singularity_rs::physics` lib export). `src/lib.rs` (`pub mod physics;`) exists solely to expose `physics` for these tests.
- The GPU shader is not unit-tested. The whole point of `physics.rs` is to be a CPU mirror that *is* testable. - The GPU shader is not unit-tested. The whole point of `physics.rs` is to be a CPU mirror that _is_ testable.
- Run a single test: `cargo test rk45_capture_radius_shrinks_with_spin` (substring match works).
## Architecture: the CPU ↔ shader mirror ## Architecture: the CPU ↔ shader mirror
The real renderer is a single full-screen quad running `assets/shaders/black_hole.wgsl` (Kerr geodesic integration via an adaptive Dormand-Prince RK45 loop + disk/planets/grid/star compositing), rendered into a sub-resolution offscreen `Image` and upscaled to the window by a second camera (`render_scale`). `src/physics.rs` is a hand-maintained **CPU mirror** of that integrator, kept so the capture-vs-escape boundary is unit-testable on the CPU. The renderer is a **multi-stage HDR pipeline** of full-screen quads, each a `Material2d` writing into an offscreen `Rgba16Float` `Image`:
**Changing physics in one place means updating the other.** `bending_accel` / `kerr_bending_accel` / `rk45_step` / `is_captured` / `is_captured_rk45` in `physics.rs` must stay in lockstep with the shader's `deriv` / `rk45_step` / integration loop, or the tests will pass on code that the shader contradicts. The mirror covers: the single-step Kerr derivative (`kerr_bending_accel``deriv`), the adaptive step (`rk45_step` ↔ shader `rk45_step`), and the full loop (`is_captured_rk45` ↔ the `loop` at `black_hole.wgsl:320-390`, including the budget = accepted-steps-only rule and the `dt_min` forced-accept floor). 1. **Black-hole quad** (`assets/shaders/black_hole.wgsl`) — Kerr geodesic integration via an adaptive Dormand-Prince RK45 loop + disk/planets/grid/star/jets compositing. Renders at sub-resolution (`render_scale`) into the offscreen target.
2. **Bright-pass** (`brightpass.wgsl`) → **blur pyramid** (`blur.wgsl`, 2 down + 2 up) → **composite** (`composite.wgsl`): bloom extraction, pyramid blur, then ACES tone-map of scene + bloom to the window's LDR surface.
Seven `Camera2d` entities are spawned at startup (offscreen + brightpass + 4 blur + composite), ordered by `Camera.order` from -20 (offscreen) up to the composite/window camera. Bloom is gated by `BloomQuality` (Off on web default, High on desktop); when Off the composite samples a 1×1 black texture (scene-only ACES). `rebuild_bloom` despawns/respawns the whole bloom sub-graph when the quality tier changes.
`src/physics.rs` is a hand-maintained **CPU mirror** of stage 1's integrator, kept so the capture-vs-escape boundary is unit-testable on the CPU.
**Changing physics in one place means updating the other.** `bending_accel` / `kerr_bending_accel` / `rk45_step` / `is_captured` / `is_captured_rk45` in `physics.rs` must stay in lockstep with the shader's `deriv` / `rk45_step` / integration `loop`, or tests pass on code the shader contradicts. The mirror covers: the single-step Kerr derivative (`kerr_bending_accel``deriv`), the adaptive step (`rk45_step` ↔ shader `rk45_step`), and the full loop (`is_captured_rk45` ↔ the capture `loop`). The loop-level invariants that must match: budget = accepted-steps-only, the `dt_min` forced-accept floor, and `r₊(χ)` as the capture radius. (Shader line numbers drift as the file grows — locate these by function name: `deriv`, `rk45_step`, and the main `loop`.)
Module wiring (entrypoints): Module wiring (entrypoints):
- `main.rs` — app entry, web fallback gate, plugin wiring (`render::BlackHolePlugin`).
- `render/plugin.rs` — the `BlackHolePlugin`: spawns the fullscreen quad + `Camera2d`, mirrors params to the GPU uniform each frame. - `main.rs` — app entry, web fallback gate, plugin wiring (`render::BlackHolePlugin`). See web gotchas below for the two non-obvious `DefaultPlugins` overrides.
- `render/material.rs``BlackHoleMaterial` (`Material2d`) + `BlackHoleUniforms` / `SphereData` structs. - `render/plugin.rs``BlackHolePlugin`: spawns the offscreen quad + bloom pipeline + composite camera, `mirror_params` (params → GPU uniform each frame), `resize_offscreen`, `nudge_camera`, `rebuild_bloom`.
- `render/material.rs` — the four `Material2d`s: `BlackHoleMaterial` (+ `BlackHoleUniforms` / `SphereData`), `BrightPassMaterial`, `BlurMaterial`, `CompositeMaterial`.
- `camera.rs` — orbit controller (yaw/pitch/zoom) + `WantsPointer` (disables orbit over the UI panel). - `camera.rs` — orbit controller (yaw/pitch/zoom) + `WantsPointer` (disables orbit over the UI panel).
- `params.rs``BlackHoleParams` resource, edited live by the egui panel, mirrored into the material each frame. - `params.rs``BlackHoleParams` resource + quality-tier enums (`BloomQuality`, `DiskQuality`, `DiskColorMode`, `AaQuality`), edited live by the egui panel, mirrored into the material each frame.
- `scene/planets.rs``Planet` component + storage-buffer upload. - `scene/planets.rs``Planet` component + storage-buffer upload.
- `ui.rs` — egui Controls panel. - `ui.rs` — egui Controls panel.
- `web.rs` — wasm-only: WebGPU detection + fallback message. - `web.rs` — wasm-only: WebGPU detection + fallback message.
## Bevy 0.19 gotchas (cause of the recurring "grey screen") ## Bevy 0.19 gotchas (cause of the recurring "grey screen" / crash)
Three things that silently produce a grey/frozen canvas if broken — recent commits on this branch exist precisely to fix these: Silent failure modes — recent commits exist solely to fix these. Check them before the shader when debugging a blank/grey/crashing canvas:
1. **`nudge_camera` (render/plugin.rs)** works around Bevy 0.19 issue #24448: a static `Camera2d` stops rendering after the first frame. It oscillates the camera by a sub-pixel amount each frame. Do not remove it expecting a cleanup. 1. **`nudge_camera` (render/plugin.rs)** works around Bevy 0.19 issue #24448: a static `Camera2d` stops rendering after the first frame. It oscillates every `Nudgable` camera by a sub-pixel amount each frame. Do not remove it expecting a cleanup — the offscreen camera freezing makes the composite re-sample a stale texture (frozen view).
2. **bevy_egui 0.41** requires UI systems to run in `EguiPrimaryContextPass`, **not** `Update`. Placing `ui_system` in `Update` panics. 2. **bevy_egui 0.41 requires UI systems to run in `EguiPrimaryContextPass`, not `Update`.** Placing `ui_system` in `Update` panics.
3. **The planets storage buffer must be a real `ShaderBuffer` asset**, not `Handle::default()`. A default handle makes `AsBindGroup` return `RetryNextUpdate` every frame, silently skipping the quad's draw — the screen shows only the camera clear color. The quad is pre-filled with a `MAX_PLANETS`-sized zeroed buffer at startup; `upload_planets` updates it. 3. **`PrimaryEguiContext` must be explicitly pinned to the composite (window) camera**, with `disable_egui_auto_context` turning off bevy_egui's auto-assignment in `PreStartup`. Auto-assignment gives `PrimaryEguiContext` to the _first spawned_ camera — the offscreen one, whose `Rgba16Float` render target mismatches egui's `Rgba8UnormSrgb` pipeline → **format-mismatch crash**. This is load-bearing now that the bloom pipeline spawns 7 cameras.
4. **The planets storage buffer must be a real `ShaderBuffer` asset**, not `Handle::default()`. A default handle makes `AsBindGroup` return `RetryNextUpdate` every frame, silently skipping the quad's draw — the screen shows only the camera clear color. The quad is pre-filled with a `MAX_PLANETS`-sized zeroed buffer at startup; `upload_planets` updates it.
5. **The skybox texture binding must declare `dimension = "cube"`** (`render/material.rs`). The `AsBindGroup` derive defaults to D2; the shader declares `texture_cube<f32>`, so a D2 layout makes the pipeline fail to specialize and the quad silently draws nothing. When no cubemap is set, Bevy binds its 1×1 cube fallback (gated out by `skybox_intensity > 0` anyway).
When debugging a blank/grey screen, check these three before the shader. ## Web gotchas
- `main.rs` sets `AssetPlugin { meta_check: AssetMetaCheck::Never }`: shaders ship as raw `.wgsl` with no `.meta` companions. The default `Always` fetches `<path>.meta` per asset; the trunk dev server doesn't 404 cleanly, so bevy tries to RON-deserialize the returned bytes and logs a deserialization error per shader.
- `main.rs` disables `bevy::audio::AudioPlugin`: the app has no audio, and the default plugin opens a WebAudio sink that browsers block until a user gesture, logging a noisy "AudioContext was not allowed to start" error.
- The skybox must be sampled with `textureSampleLevel` (not `textureSample`) in the shader — Tint (the WGSL→-SPIR-V compiler used on web) enforces uniform-control-flow rules that `textureSample` violates, failing the web build. See commit `5c0b1db`.
- `Trunk.toml` copies `assets/` (shaders) into `dist/` via `copy-dir`. If you add a new shader, it is picked up automatically because the whole `assets/` tree is copied.
## Conventions ## Conventions
- **Natural units: `Rs = 1`** throughout (Rust + WGSL). `BCRIT = 3√3/2·Rs ≈ 2.598` is a literal in `physics.rs` because `f32::sqrt` isn't `const`; the integration test guards the literal. - **Natural units: `Rs = 1`** throughout (Rust + WGSL). `BCRIT = 3√3/2·Rs ≈ 2.598` is a literal in `physics.rs` because `f32::sqrt` isn't `const`; the integration test guards the literal.
- **`spin` and `render_scale` are both wired (Phase 2).** `spin` (dimensionless χ = a/M ∈ [0,1]) drives the Kerr frame-dragging term in the shader's `deriv` and a spin-dependent capture radius (`r₊`); the disk inner edge is derived from `kerr_isco(spin)` in `mirror_params`. `render_scale` renders the black-hole quad into an offscreen `Image` at sub-resolution and a second camera upscales it (see `render/plugin.rs`: `OffscreenTarget` / `OffscreenCamera` / `UpscaleCamera`). The `#[allow(dead_code)]` on `BlackHoleParams` is now only for `spin`'s historical reservation — both fields are live. - **`disk_inner` is spin-derived, not read from its param field.** `mirror_params` overwrites `uniforms.disk_inner` with `kerr_isco(params.spin)` every frame; `params.disk_inner` exists only for the default/UI readout and is ignored at runtime. The disk inner edge tracks the Kerr ISCO (6M = 3 Rs at χ = 0, shrinking to M = Rs/2 at extremal spin).
- **Both `steps` and `render_scale` are performance levers.** `steps` caps *accepted* RK45 steps per ray; `render_scale` lowers the offscreen resolution. The RK45 integrator is ~an order of magnitude costlier than Phase 1's fixed-step RK4, so the default `render_scale` dropped to 0.75 (desktop) / 0.5 (web). - **`spin` (χ ∈ [0,1])** drives the Kerr frame-dragging term in the shader's `deriv` and a spin-dependent capture radius (`r₊`); at χ = 0 the frame-dragging term vanishes and the integrator is exactly Schwarzschild.
- **Web defaults differ** via `cfg!(target_arch = "wasm32")`: `steps` 200 (web) vs 300 (desktop), `render_scale` 0.5 vs 0.75. - **`steps` and `render_scale` are performance levers.** `steps` caps _accepted_ RK45 steps per ray; `render_scale` lowers the offscreen resolution. The RK45 integrator is ~an order of magnitude costlier than fixed-step RK4, so the default `render_scale` is 0.75 (desktop) / 0.5 (web).
- **Quality tiers** (`params.rs`) gate per-pixel work and default differently per target via `cfg!(target_arch = "wasm32")`: `steps` 200 (web) vs 300 (desktop); `render_scale` 0.5 vs 0.75; `bloom_quality` Low vs High; `disk_quality` Low vs High; `aa_quality` Off vs Low.
- **Mirroring a new param end-to-end** touches four places in lockstep: `BlackHoleParams` (`params.rs`) → `BlackHoleUniforms` (`render/material.rs`, mind WGSL `vec3`-alignment padding) → `mirror_params` assignment (`render/plugin.rs`) → the shader struct + its use. Quality-tier enums also need a UI entry in `ui.rs`.
## Git workflow ## Git workflow
- After finishing a change, **decide for yourself whether it should be committed** — don't stop and ask. Commit when the work forms a coherent, complete unit (a fix builds, tests pass, code compiles); hold off only if it's mid-flight or known-broken. - After finishing a change, **decide for yourself whether it should be committed** — don't stop and ask. Commit when the work forms a coherent, complete unit (builds, tests pass, code compiles); hold off only if it's mid-flight or known-broken.
- **Commits are granular and detailed.** Split by concern: one logical change per commit, not one giant dump. The message explains *what* and *why* (the gotcha it fixes, the invariant it restores), not just a restatement of the diff. - **Commits are granular and detailed.** Split by concern: one logical change per commit, not one giant dump. The message explains _what_ and _why_ (the gotcha it fixes, the invariant it restores), not just a restatement of the diff.
- **Never push.** Local commits only; leave pushing to the human. - **Never push.** Local commits only; leave pushing to the human.
## Reference docs ## Reference docs
- `README.md` — controls, how-it-works, project layout, status. - `README.md` — controls, how-it-works, project layout, status.
- `docs/superpowers/specs/2026-07-09-interstellar-blackhole-design.md` — Phase 1 (Schwarzschild) design spec. - `docs/superpowers/specs/` + `docs/superpowers/plans/` — phased design specs and implementation plans:
- `docs/superpowers/plans/2026-07-09-interstellar-blackhole-phase1.md` — Phase 1 implementation plan. - Phase 1 (Schwarzschild): `2026-07-09-interstellar-blackhole-*.md`
- `docs/superpowers/specs/2026-07-13-interstellar-blackhole-phase2-kerr-design.md` — Phase 2 (Kerr) design spec. - Phase 2 (Kerr): `2026-07-13-interstellar-blackhole-phase2-kerr-*.md`
- `docs/superpowers/plans/2026-07-13-interstellar-blackhole-phase2-kerr.md` — Phase 2 implementation plan (Tasks 17 done; Task 8 is the human visual/perf validation). - Phase 3 (cinematic: HDR/bloom/tone-map): `2026-07-14-blackhole-cinematic-rendering-*.md`
- Phase 3.1 (volumetric disk): `2026-07-15-volumetric-disk-*.md`
## Skills ## Skills
This repo has Matt Pocock's engineering skills vendored under `.agents/skills/` and pinned in `skills-lock.json` (e.g. `tdd`, `code-review`, `diagnosing-bugs`, `codebase-design`). They are workspace-scoped and load automatically. Matt Pocock's engineering skills are vendored under `.agents/skills/` and pinned in `skills-lock.json` (e.g. `tdd`, `code-review`, `diagnosing-bugs`, `codebase-design`). They are workspace-scoped and load automatically.

69
Cargo.lock generated
View File

@ -1189,7 +1189,7 @@ dependencies = [
"glam", "glam",
"itertools", "itertools",
"libm", "libm",
"rand", "rand 0.10.2",
"rand_distr", "rand_distr",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
@ -2825,6 +2825,19 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.4" version = "0.3.4"
@ -2904,7 +2917,7 @@ dependencies = [
"bytemuck", "bytemuck",
"encase", "encase",
"libm", "libm",
"rand", "rand 0.10.2",
"serde_core", "serde_core",
] ]
@ -4482,6 +4495,15 @@ dependencies = [
"unicode-xid", "unicode-xid",
] ]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "presser" name = "presser"
version = "0.3.1" version = "0.3.1"
@ -4560,6 +4582,17 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "019b4b213425016d7d84a153c4c73afb0946fbb4840e4eece7ba8848b9d6da22" checksum = "019b4b213425016d7d84a153c4c73afb0946fbb4840e4eece7ba8848b9d6da22"
[[package]]
name = "rand"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
"rand_chacha",
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.10.2" version = "0.10.2"
@ -4567,7 +4600,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"rand_core", "rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
] ]
[[package]] [[package]]
@ -4583,7 +4635,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
dependencies = [ dependencies = [
"num-traits", "num-traits",
"rand", "rand 0.10.2",
] ]
[[package]] [[package]]
@ -4910,7 +4962,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"bevy", "bevy",
"bevy_egui", "bevy_egui",
"getrandom 0.2.17",
"js-sys", "js-sys",
"rand 0.8.7",
"rand_chacha",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
] ]
@ -5526,6 +5581,12 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]] [[package]]
name = "wasip2" name = "wasip2"
version = "1.0.4+wasi-0.2.12" version = "1.0.4+wasi-0.2.12"

View File

@ -10,12 +10,39 @@ path = "src/lib.rs"
[dependencies] [dependencies]
bevy = "0.19" bevy = "0.19"
bevy_egui = "0.41" bevy_egui = "0.41"
# Deterministic PRNG for planet seeding (ChaCha8Rng). `rand` is a transitive
# dep via bevy_math but not declared directly; `rand_chacha` ships ChaCha8Rng.
# Pinned to 0.8/0.3 (LTS, API-compatible with the Bevy 0.19 ecosystem).
rand = "0.8"
rand_chacha = "0.3"
# Web-only deps for WebGPU detection + fallback message. # Web-only deps for WebGPU detection + fallback message.
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Gpu", "Navigator", "Window", "Document", "HtmlElement", "Element"] } web-sys = { version = "0.3", features = ["Gpu", "Navigator", "Window", "Document", "HtmlElement", "Element"] }
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
js-sys = "0.3" js-sys = "0.3"
# rand 0.8 pulls getrandom 0.2, which on wasm32-unknown-unknown refuses to
# compile unless its "js" feature is explicitly enabled (it calls into the
# Web Crypto API via wasm-bindgen). Bevy's own rand usage is on getrandom
# 0.3+, which enables this by default; our direct rand 0.8 dep does not.
# Force-enabling it here unblocks the web build. See getrandom docs:
# https://docs.rs/getrandom/#webassembly-support
getrandom = { version = "0.2", features = ["js"] }
# Desktop release tuning. This renderer is GPU-bound, but Bevy's CPU path
# (resource management, system scheduling, egui, wgpu command submission)
# still benefits measurably from cross-crate LTO and a single codegen unit:
# - lto = "fat": cross-crate inlining, removes trampolines through Bevy/wgpu.
# - codegen-units = 1: best optimization, at the cost of slower compiles.
# - panic = "abort": smaller binary, no unwind tables in the hot path.
# - strip: drop debug symbols from the shipped binary.
# `wasm-release` inherits release, so web picks these up too (then overrides
# opt-level to "z" for binary size).
[profile.release]
lto = "fat"
codegen-units = 1
panic = "abort"
strip = "symbols"
# Smaller wasm binary in release web builds (per Bevy examples README). # Smaller wasm binary in release web builds (per Bevy examples README).
[profile.wasm-release] [profile.wasm-release]

View File

@ -53,10 +53,20 @@ struct BlackHoleUniforms {
disk_temp: f32, // blackbody base temperature (Kelvin) disk_temp: f32, // blackbody base temperature (Kelvin)
jets_enabled: u32, // 0=off, 1=on jets_enabled: u32, // 0=off, 1=on
jets_strength: f32, // jet brightness multiplier jets_strength: f32, // jet brightness multiplier
aa_samples: u32, // per-pixel supersample count (1/2/4); >1 antialiases the higher-order lensed-image rings
_pad6: f32, // explicit round-up to 16-byte uniform alignment
_pad7: f32, // (matches BlackHoleUniforms in material.rs byte-for-byte)
}; };
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> uniforms: BlackHoleUniforms; @group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> uniforms: BlackHoleUniforms;
// Per-pixel fragment coordinate, set at the top of `fragment` from
// `in.position.xy` (the @builtin(position) frag coord). Used as a deterministic
// seed for the slab sub-sample jitter and the supersample jitter so the noise
// is spatially stable (no per-frame flicker). `var<private>` is the same form
// `blur.wgsl` uses for its runtime-indexed kernel tables.
var<private> pixel_seed: vec2<f32>;
// ---------- planets storage (binding 3) ---------- // ---------- planets storage (binding 3) ----------
struct SphereData { struct SphereData {
center: vec4<f32>, // xyz = center (world space), w = radius center: vec4<f32>, // xyz = center (world space), w = radius
@ -145,8 +155,17 @@ fn star_color(dir: vec3<f32>, intensity: f32) -> vec3<f32> {
} }
// --- skybox --- // --- skybox ---
// textureSampleLevel (not textureSample) is REQUIRED here: this is called from
// inside the main RK45 integration loop, whose control flow is non-uniform
// (`if (accum_alpha > 0.99) { break; }`, per-pixel early exit). The WGSL spec
// forbids textureSample outside uniform control flow because it needs
// screen-space derivatives for mip selection; Chrome's Tint enforces this and
// rejects the shader, while naga (desktop) does not which is why this only
// crashed the web build. textureSampleLevel takes an explicit LOD (0 here: the
// skybox cubemap is sampled at full resolution, no mip minification) and is
// permitted in non-uniform flow. The visual result is identical.
fn skybox_color(dir: vec3<f32>) -> vec3<f32> { fn skybox_color(dir: vec3<f32>) -> vec3<f32> {
return textureSample(skybox, skybox_sampler, dir).rgb; return textureSampleLevel(skybox, skybox_sampler, dir, 0.0).rgb;
} }
// --- geodesic --- // --- geodesic ---
@ -498,14 +517,26 @@ fn integrate_disk_segment(prev: vec3f, new_pos: vec3f, dir: vec3f,
// Analytic in-slab length the key: depends on geometry, NOT on RK45 dt. // Analytic in-slab length the key: depends on geometry, NOT on RK45 dt.
let seg_len = (t1 - t0) * length(new_pos - prev); let seg_len = (t1 - t0) * length(new_pos - prev);
// N uniform samples along the clipped segment, front-to-back composite. // N samples along the clipped segment, front-to-back composite. Each sample
// Each sample carries density × (seg_len / N) so the N samples sum to the // carries density × (seg_len / N) so the N samples sum to the full segment
// full segment integral when density is roughly constant; the Gaussian // integral when density is roughly constant; the Gaussian vertical decay is
// vertical decay is captured by sampling at differing heights within the // captured by sampling at differing heights within the slab. N is a
// slab. N is a compile-time constant (WGSL requires static loop bounds). // compile-time constant (WGSL requires static loop bounds).
//
// The sub-sample position is STOCHASTIC, not on the deterministic
// (i+0.5)/N grid. A deterministic grid aliases against the 2-D pixel grid
// and against the per-ray RK45 step lattice a concentric moiré on the
// disk. Folding the pixel coordinate and the sample index into a hash and
// using it to jitter the position within stratum i turns the moiré into
// unstructured noise (which the HDR + bloom pipeline tolerates far better
// than coherent bands). The jitter stays inside stratum i (amplitude 1/N),
// so the quadrature order is preserved and the integrated density is
// unbiased. The seed is pixel-only (no time) the noise is spatially
// stable, no per-frame flicker.
const N = 4u; const N = 4u;
for (var i: u32 = 0u; i < N; i = i + 1u) { for (var i: u32 = 0u; i < N; i = i + 1u) {
let t = t0 + (t1 - t0) * (f32(i) + 0.5) / f32(N); let stratum = f32(i) + hash13(vec3<f32>(pixel_seed, f32(i)));
let t = t0 + (t1 - t0) * stratum / f32(N);
let p = mix(prev, new_pos, vec3f(t)); let p = mix(prev, new_pos, vec3f(t));
let rp = r_of(p); let rp = r_of(p);
if (rp < uniforms.disk_inner || rp > uniforms.disk_outer) { if (rp < uniforms.disk_inner || rp > uniforms.disk_outer) {
@ -523,8 +554,25 @@ fn integrate_disk_segment(prev: vec3f, new_pos: vec3f, dir: vec3f,
// Gaussian radial falloff, exponential length decay, outward-flowing noise, // Gaussian radial falloff, exponential length decay, outward-flowing noise,
// δ^3.5 beaming (no floor needed β=0.92 keeps the denominator positive). // δ^3.5 beaming (no floor needed β=0.92 keeps the denominator positive).
// Front-to-back composited into the same accumulators as the disk. // Front-to-back composited into the same accumulators as the disk.
fn sample_jets(pos: vec3f, dir: vec3f, r_plus: f32, dt: f32, //
// `step_len` is the world-space length of THIS RK45 step (|new_pos prev|),
// passed in by the caller. The per-step emission is weighted by it so the
// integrated jet brightness is step-size-independent the same technique the
// disk path uses (integrate_disk_segment weights on the analytic in-slab
// length). Two prior attempts both produced banding: `* dt` varied with the
// adaptive step size (radial bands), and a fixed `0.5` made the per-UNIT-length
// contribution 1/step_len, so it brightened wherever steps packed together
// (near the hole) the concentric base-of-jet bands. Weighting on the actual
// geometric step length makes the contribution per unit length constant.
fn sample_jets(pos: vec3f, dir: vec3f, r_plus: f32, step_len: f32,
accum_color: ptr<function, vec3f>, accum_alpha: ptr<function, f32>) { accum_color: ptr<function, vec3f>, accum_alpha: ptr<function, f32>) {
// Relativistic jets are spin-powered (Blandford-Znajek): the mechanism taps
// the ergosphere, which only exists for a rotating hole. At χ 0 there is
// nothing to drive an outflow, so suppress the jets regardless of the
// jets_enabled toggle the toggle expresses user intent, spin expresses
// physics. Without this gate the default scene (spin = 0, jets_enabled =
// true) shows blue/white columns over the poles that have no physical cause.
if (uniforms.spin < 0.05) { return; }
let jet_v = abs(pos.y); let jet_v = abs(pos.y);
let jet_max_h = 80.0; let jet_max_h = 80.0;
if (jet_v <= r_plus * 1.8 || jet_v >= jet_max_h) { if (jet_v <= r_plus * 1.8 || jet_v >= jet_max_h) {
@ -555,12 +603,21 @@ fn sample_jets(pos: vec3f, dir: vec3f, r_plus: f32, dt: f32,
let beta = abs(jet_vel); let beta = abs(jet_vel);
let gamma = 1.0 / sqrt(1.0 - beta * beta); let gamma = 1.0 / sqrt(1.0 - beta * beta);
let delta = 1.0 / (gamma * (1.0 - beta * cos_theta)); let delta = 1.0 / (gamma * (1.0 - beta * cos_theta));
let beaming = pow(delta, 3.5); // Cap the beaming: with β=0.92 the approaching jet's δ^3.5 reaches ~258×,
// which blows the blue jet past the bloom threshold and saturates it to
// white. Clamp to 8× still visibly brighter on the approaching side, but
// the base color (0.4, 0.7, 1.0) survives the HDR + bloom pipeline.
let beaming = min(pow(delta, 3.5), 8.0);
let base_color = vec3f(0.4, 0.7, 1.0); let base_color = vec3f(0.4, 0.7, 1.0);
let emission = base_color * jet_density * 0.05 * beaming * uniforms.jets_strength * dt; // Weight the per-step emission by the geometric step length so the
// integrated brightness is proportional to the path length through the jet,
// not to how many RK45 steps happened to fall there. The 0.1 coefficient is
// a per-unit-length strength, calibrated against the default scene to match
// the old fixed-0.5 brightness at the typical accepted-step length (~0.5).
let emission = base_color * jet_density * 0.05 * beaming * uniforms.jets_strength * step_len * 0.1;
*accum_color += emission * (1.0 - *accum_alpha); *accum_color += emission * (1.0 - *accum_alpha);
*accum_alpha += jet_density * 0.05 * uniforms.jets_strength * dt; *accum_alpha += jet_density * 0.05 * uniforms.jets_strength * step_len * 0.1;
} }
// --- planets --- // --- planets ---
@ -678,16 +735,13 @@ fn rk45_step(pos: vec3<f32>, dir: vec3<f32>, dt: f32) -> RkStep {
} }
// ====================== main ====================== // ====================== main ======================
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let aspect = uniforms.resolution.x / uniforms.resolution.y;
var uv = (in.uv * 2.0 - 1.0);
uv.x *= aspect;
let dir = ray_direction(uv);
// Work in disk-local space: rotate eye + dir by -disk_tilt around X so the
// disk lies on y=0. (disk_hit/disk_color assume disk-local coords.)
// One full ray march (camera capture/escape/budget) for a single ray
// direction, returning the accumulated HDR color. Factored out of `fragment`
// so the supersampling loop can fire several jittered rays per pixel and
// average them. `dir` is the camera-space ray direction; the disk-local
// rotation and all per-ray state live in here so each sample is independent.
fn march(dir: vec3<f32>) -> vec3<f32> {
// Total path length to integrate: enough to go from the camera, past the // Total path length to integrate: enough to go from the camera, past the
// hole, and far enough beyond to count as escaped. // hole, and far enough beyond to count as escaped.
let eye_dist = length(uniforms.eye.xyz); let eye_dist = length(uniforms.eye.xyz);
@ -701,6 +755,8 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let tol = 1e-3; let tol = 1e-3;
let r_plus = 0.5 + sqrt(max(0.25 - (uniforms.spin * 0.5) * (uniforms.spin * 0.5), 0.0)); let r_plus = 0.5 + sqrt(max(0.25 - (uniforms.spin * 0.5) * (uniforms.spin * 0.5), 0.0));
// Work in disk-local space: rotate eye + dir by -disk_tilt around X so the
// disk lies on y=0. (disk_hit/disk_color assume disk-local coords.)
var pos = rot_x(uniforms.eye.xyz, -uniforms.disk_tilt); var pos = rot_x(uniforms.eye.xyz, -uniforms.disk_tilt);
var d = normalize(rot_x(dir, -uniforms.disk_tilt)); var d = normalize(rot_x(dir, -uniforms.disk_tilt));
var dt = dt_init; var dt = dt_init;
@ -776,7 +832,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// --- relativistic jets (along the spin axis) --- // --- relativistic jets (along the spin axis) ---
if (uniforms.jets_enabled != 0u) { if (uniforms.jets_enabled != 0u) {
sample_jets(new_pos, new_dir, r_plus, dt, &accum_color, &accum_alpha); sample_jets(new_pos, new_dir, r_plus, length(new_pos - prev), &accum_color, &accum_alpha);
if (accum_alpha > 0.99) { break; } if (accum_alpha > 0.99) { break; }
} }
@ -799,5 +855,47 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
d = new_dir; d = new_dir;
} }
return vec4<f32>(accum_color, 1.0); return accum_color;
}
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// Seed the per-pixel hash used by the slab sub-sample jitter (Fix B) and
// the supersample jitter (Fix A). @builtin(position) is the fragment's
// framebuffer coordinate; using it makes the noise spatially stable.
pixel_seed = in.position.xy;
let aspect = uniforms.resolution.x / uniforms.resolution.y;
let base_uv = vec2<f32>((in.uv * 2.0 - 1.0).x * aspect, (in.uv * 2.0 - 1.0).y);
// Per-pixel stochastic supersampling (Fix A). The concentric rings that
// appear on the disk are higher-order gravitationally-lensed disk images:
// rays near the critical impact parameter wrap around the hole, and each
// wrap crosses the disk plane once, projecting to a ring. The integrator
// renders each wrap as a sharp discrete band rather than a smooth image, so
// the rings read as an artifact. Firing several jittered sub-rays per pixel
// and averaging antialiases those sharp band edges into a smooth gradient
// (the physical Einstein-ring structure is preserved; only its staircase
// discretization is removed).
//
// MAX_AA_SAMPLES is a compile-time cap (WGSL needs static loop bounds);
// the runtime count comes from uniforms.aa_samples with an early break
// the same MAX-with-break form fbm3/ridged_fbm use for dynamic octaves.
const MAX_AA_SAMPLES = 4u;
var color_sum = vec3<f32>(0.0);
var n_done = 0u;
for (var s: u32 = 0u; s < MAX_AA_SAMPLES; s = s + 1u) {
if (s >= uniforms.aa_samples) { break; }
var uv = base_uv;
if (uniforms.aa_samples > 1u) {
// ~half-pixel jitter in NDC, seeded by pixel + sample index. The
// seed is pixel-only (no time) so the pattern is temporally stable.
let j = hash33(vec3<f32>(pixel_seed, f32(s))) - 0.5;
uv = uv + j.xy / uniforms.resolution;
}
color_sum = color_sum + march(ray_direction(uv));
n_done = n_done + 1u;
}
return vec4<f32>(color_sum / f32(n_done), 1.0);
} }

View File

@ -0,0 +1,839 @@
# Kerr 轨道行星系统 实现计划 (Phase 3.4)
> **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:** 让 58 颗行星沿 Kerr 圆轨道绕黑洞旋转,轨道面因 Lense-Thirring 进动绕自旋轴转动,位置每帧 CPU 闭式计算并上传既有 storage buffer。
**Architecture:** 路径 A — 物理/轨道全在 CPU,shader 零改动。新增 `OrbitParams`(不可变根数) + 复用 `Planet`(每帧派生 center)。闭式公式 `Ω_φ`(Bardeen 1972) 与 `Ω_θ`(垂直 epicyclic 频率) 给出精确强场节点进动 `Ω_LT = Ω_φ - Ω_θ`,χ=0 精确退化为牛顿。
**Tech Stack:** Bevy 0.19, Rust edition 2024, `rand` + `rand_chacha`(确定性 PRNG), egui 控制面板。
**Spec:** `docs/superpowers/specs/2026-07-16-kerr-orbiting-planets-design.md`
---
## 文件结构
| 文件 | 责任 | 改动 |
|---|---|---|
| `Cargo.toml` | 声明 `rand` + `rand_chacha` 直接依赖 | 新增 2 行 |
| `src/physics.rs` | `kerr_orbital_frequency` + `kerr_nodal_precession` + 测试 | 新增 ~80 行 |
| `src/scene/planets.rs` | `OrbitParams` 组件 + 轨道几何 + `orbit_system` + `spawn_planet_system` | 大改,删除 `spawn_default_planet` |
| `src/params.rs` | 5 个新 `BlackHoleParams` 字段 + Default | 新增 ~15 行 |
| `src/render/plugin.rs` | 系统注册 + 资源初始化 | 改 ~10 行 |
| `src/ui.rs` | Planets collapsing header | 新增 ~15 行 |
**不改动:** `src/render/material.rs`(`SphereData`/`BlackHoleUniforms` 不动), `assets/shaders/black_hole.wgsl`(shader 零改动)。
---
## Task 0: 添加 rand 依赖
**Files:**
- Modify: `Cargo.toml:10-12`
`rand` 当前只是 bevy 的传递依赖,未在 `Cargo.toml` 直接声明;`rand_chacha` 完全缺失。需要显式声明以便 `ChaCha8Rng` 稳定可用。
- [ ] **Step 1: 添加依赖**
修改 `Cargo.toml``[dependencies]` 段:
```toml
[dependencies]
bevy = "0.19"
bevy_egui = "0.41"
rand = "0.8"
rand_chacha = "0.3"
```
注:用 `rand 0.8` + `rand_chacha 0.3`(稳定 LTS,API 与 Bevy 0.19 生态兼容)。`seed_from_u64` + `gen_range` API 在 0.8 稳定。
- [ ] **Step 2: 验证编译**
Run: `cargo check`
Expected: 编译通过(可能下载新 crate)。若版本冲突,用 `cargo update -p rand` 或锁到与 bevy 兼容的版本。
- [ ] **Step 3: Commit**
```bash
git add Cargo.toml Cargo.lock
git commit -m "deps: add rand + rand_chacha for deterministic planet seeding"
```
---
## Task 1: Kerr 轨道频率 + 进动率 (physics.rs) — TDD
核心物理公式,先写测试。这部分是整个方案物理正确性的基石。
**Files:**
- Modify: `src/physics.rs`(在文件末尾,`_phantom` 函数之前)
- Test: `src/physics.rs` 内联 `#[cfg(test)]` 模块
### 1a: `kerr_orbital_frequency`
- [ ] **Step 1: 写失败测试**
`src/physics.rs``#[cfg(test)]` mod 里(`fn _phantom` 之后,或现有测试 mod 内)加:
```rust
#[test]
fn orbital_frequency_reduces_to_newton_at_zero_spin() {
// χ=0: Ω = 1/r^1.5 (牛顿开普勒, Rs=1)
for r in [4.0_f32, 6.0, 10.0, 20.0] {
let newton = 1.0 / r.powf(1.5);
let kerr = kerr_orbital_frequency(r, 0.0);
assert!(
(kerr - newton).abs() < 1e-6,
"χ=0 at r={}: expected {} (newton), got {}",
r, newton, kerr
);
}
}
#[test]
fn orbital_frequency_decreases_with_spin_at_fixed_r() {
// prograde 轨道 (a>0): Ω_φ 随 χ 减小 (分母 r^1.5+a 增大)
let r = 8.0;
let omega_0 = kerr_orbital_frequency(r, 0.0);
let omega_1 = kerr_orbital_frequency(r, 1.0);
assert!(omega_1 < omega_0, "prograde Ω should decrease with spin");
}
```
- [ ] **Step 2: 运行测试,确认失败**
Run: `cargo test orbital_frequency`
Expected: FAIL,编译错误 `cannot find function kerr_orbital_frequency`
- [ ] **Step 3: 实现 `kerr_orbital_frequency`**
`src/physics.rs``kerr_horizon` 函数之后(`kerr_bending_accel` 之前)加:
```rust
/// Kerr 赤道 prograde 圆轨角速度 (Rs=1, M=0.5). Bardeen 1972 eqn 2.16.
/// `Ω_φ = 1 / (r^1.5 + a)`, a = χM = 0.5χ. χ=0 退化为牛顿 1/r^1.5.
pub fn kerr_orbital_frequency(r: f32, chi: f32) -> f32 {
let m = 0.5;
let a = chi * m;
1.0 / (r.powf(1.5) + a)
}
```
- [ ] **Step 4: 运行测试,确认通过**
Run: `cargo test orbital_frequency`
Expected: PASS,2 个测试通过。
- [ ] **Step 5: Commit**
```bash
git add src/physics.rs
git commit -m "feat(physics): Kerr equatorial orbital frequency Ω_φ (Bardeen 1972)"
```
### 1b: `kerr_nodal_precession`
- [ ] **Step 1: 写失败测试**
`src/physics.rs` 的测试 mod 加:
```rust
#[test]
fn nodal_precession_vanishes_at_zero_spin() {
// χ=0: 球对称 (Schwarzschild), 无节点进动
for r in [4.0_f32, 6.0, 10.0, 20.0] {
let prec = kerr_nodal_precession(r, 0.0);
assert!(
prec.abs() < 1e-6,
"χ=0 at r={} should have zero precession, got {}",
r, prec
);
}
}
#[test]
fn nodal_precession_grows_with_spin() {
// 固定 r, prograde 节点进动率随 χ 单调增
let r = 6.0;
let p_low = kerr_nodal_precession(r, 0.3);
let p_high = kerr_nodal_precession(r, 0.9);
assert!(p_high > p_low, "precession should grow with spin");
assert!(p_low > 0.0, "prograde precession should be positive");
}
#[test]
fn nodal_precession_strong_field_exceeds_weak_field() {
// r<6 强场区: 精确 Ω_LT > 弱场近似 2Ma/r³ = χ/r³ (M=0.5)
let r = 4.0;
let chi = 0.9;
let weak = chi / r.powi(3); // 2Ma/r³ = (2·0.5·χ)/r³ = χ/r³
let strong = kerr_nodal_precession(r, chi);
assert!(
strong > weak,
"strong-field precession at r={} should exceed weak approx {} , got {}",
r, weak, strong
);
}
```
- [ ] **Step 2: 运行测试,确认失败**
Run: `cargo test nodal_precession`
Expected: FAIL,`cannot find function kerr_nodal_precession`
- [ ] **Step 3: 实现 `kerr_nodal_precession`**
`kerr_orbital_frequency` 之后加:
```rust
/// Kerr 赤道圆轨节点进动率 (Lense-Thirring, 强场精确). χ=0 返回 0.
///
/// `Ω_LT = Ω_φ - Ω_θ`, 其中 Ω_θ 是垂直 epicyclic 频率:
/// `Ω_θ² = Ω_φ² · (1 4a·Ω_φ/r + 3a²/r²)` (Caltech Ph236 lec27).
/// χ=0 时 a=0, 括号=1, 故 Ω_θ=Ω_φ, 进动为零 (Schwarzschild 球对称).
pub fn kerr_nodal_precession(r: f32, chi: f32) -> f32 {
let m = 0.5;
let a = chi * m;
let omega_phi = kerr_orbital_frequency(r, chi);
// 垂直 epicyclic 频率比 (>=0, 极端 r/a 组合下数值精度可能略负, 钳位)
let ratio = (1.0 - 4.0 * a * omega_phi / r + 3.0 * a * a / (r * r)).max(0.0);
let omega_theta = omega_phi * ratio.sqrt();
omega_phi - omega_theta
}
```
- [ ] **Step 4: 运行测试,确认通过**
Run: `cargo test nodal_precession`
Expected: PASS,3 个测试通过。
- [ ] **Step 5: 运行全部测试确认无回归**
Run: `cargo test`
Expected: 全部通过(原有 capture/escape 测试 + 5 个新测试)。
- [ ] **Step 6: Commit**
```bash
git add src/physics.rs
git commit -m "feat(physics): Kerr nodal precession Ω_LT (strong-field Lense-Thirring)"
```
---
## Task 2: OrbitParams 组件 + 轨道几何纯函数
先把不可变根数和"根数 + 时间 → 位置"的纯函数定下来。纯函数可独立测试,不依赖 Bevy 系统。
**Files:**
- Modify: `src/scene/planets.rs`(文件顶部,`Planet` struct 之后)
- Test: `src/scene/planets.rs` 内联 `#[cfg(test)]` mod
- [ ] **Step 1: 加 `OrbitParams` 组件 + 轨道几何函数(含测试 mod)**
`src/scene/planets.rs``Planet` struct 定义之后(当前 `:8-13`)加:
```rust
use std::f32::consts::{PI, TAU};
/// 轨道根数 (不变量, 启动时随机生成, 运行时不变除非 UI 改种子重生).
#[derive(Component, Clone, Copy)]
pub struct OrbitParams {
/// k, 乘到 kerr_isco(χ) 上得实际轨道半径.
pub radius_factor: f32,
/// 轨道面法向与 Y 轴(自旋轴)的夹角 (rad).
pub inclination: f32,
/// 升交点经度 (rad), 决定轨道面在方位上的初始取向.
pub longitude_of_node: f32,
/// 轨道内初始相位 (rad).
pub phase: f32,
}
/// 由轨道根数 + 当前 (模拟)时间 + 自旋, 计算行星世界空间位置.
/// 纯函数: 无 Bevy 依赖, 可独立测试.
///
/// 物理:
/// - r = k · kerr_isco(χ)
/// - Ω_φ = kerr_orbital_frequency(r, χ) (轨道角速度)
/// - Ω_LT = kerr_nodal_precession(r, χ) (轨道面绕 Y 轴的进动率)
/// 轨道面基 (u, v) 由 inclination + longitude_of_node 构造, 然后绕 Y 轴
/// 整体旋转 Ω_LT·t (Lense-Thirring 进动).
pub fn orbit_position(orbit: &OrbitParams, t: f32, chi: f32) -> Vec3 {
let r = orbit.radius_factor * crate::physics::kerr_isco(chi);
let omega_phi = crate::physics::kerr_orbital_frequency(r, chi);
let omega_lt = crate::physics::kerr_nodal_precession(r, chi);
// 1. 轨道面法向 (Y 轴为极轴的球坐标)
let inc = orbit.inclination;
let lon = orbit.longitude_of_node;
let sin_inc = inc.sin();
let n = Vec3::new(
sin_inc * lon.cos(),
inc.cos(),
sin_inc * lon.sin(),
);
// 2. 轨道面内正交基: u 沿升节点方向, v = n × u
// u 在 XZ 平面 (垂直于 Y 轴), 指向升节点
let u = Vec3::new(-lon.sin(), 0.0, lon.cos());
let v = n.cross(u);
// 3. 进动: (u, v) 绕 Y 轴整体旋转 Ω_LT·t
let pa = omega_lt * t;
let cp = pa.cos();
let sp = pa.sin();
let u_p = Vec3::new(u.x * cp + u.z * sp, u.y, -u.x * sp + u.z * cp);
let v_p = Vec3::new(v.x * cp + v.z * sp, v.y, -v.x * sp + v.z * cp);
// 4. 行星在进动后的轨道面内的位置
let theta = orbit.phase + omega_phi * t;
r * (theta.cos() * u_p + theta.sin() * v_p)
}
```
- [ ] **Step 2: 写几何不变量测试**
`src/scene/planets.rs` 文件末尾加测试 mod:
```rust
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::TAU;
#[test]
fn orbit_position_radius_is_preserved() {
// 不管时间/相位, 行星到原点距离应恒等于 r = k·isco(χ)
let orbit = OrbitParams {
radius_factor: 2.5,
inclination: 0.7,
longitude_of_node: 1.3,
phase: 0.5,
};
let chi = 0.8;
let expected_r = 2.5 * crate::physics::kerr_isco(chi);
for t in [0.0_f32, 1.0, 5.5, 100.0] {
let pos = orbit_position(&orbit, t, chi);
let dist = pos.length();
assert!(
(dist - expected_r).abs() < 1e-4,
"t={}: dist {} != r {}",
t, dist, expected_r
);
}
}
#[test]
fn orbit_position_zero_spin_keeps_plane_fixed() {
// χ=0: 无进动, 倾角 0 (赤道面) 的行星应严格在 y=0 平面
let orbit = OrbitParams {
radius_factor: 3.0,
inclination: 0.0, // 赤道面
longitude_of_node: 0.0,
phase: 0.0,
};
for t in [0.0_f32, 1.0, 10.0] {
let pos = orbit_position(&orbit, t, 0.0);
assert!(pos.y.abs() < 1e-5, "χ=0 equatorial orbit should stay in y=0 plane at t={}", t);
}
}
#[test]
fn orbit_position_advance_with_time() {
// 不同时间应给不同位置 (除非极端巧合)
let orbit = OrbitParams {
radius_factor: 3.0,
inclination: 0.5,
longitude_of_node: 0.0,
phase: 0.0,
};
let p0 = orbit_position(&orbit, 0.0, 0.5);
let p1 = orbit_position(&orbit, 1.0, 0.5);
assert!((p0 - p1).length() > 0.01, "planet should move over time");
}
}
```
- [ ] **Step 3: 运行测试**
Run: `cargo test --lib scene::planets`
Expected: PASS,3 个几何测试通过。
注:若 `--lib` 选择器不工作,用 `cargo test orbit_position`
- [ ] **Step 4: Commit**
```bash
git add src/scene/planets.rs
git commit -m "feat(planets): OrbitParams component + orbit_position geometry"
```
---
## Task 3: orbit_system (每帧更新 Planet.center)
把纯函数接进 Bevy 调度,每帧写 `Planet.center`
**Files:**
- Modify: `src/scene/planets.rs`
- [ ] **Step 1: 加 `orbit_system`**
`orbit_position` 函数之后加。注:`Time` 已在 `bevy::prelude::*` 里(`planets.rs:1` 已 import prelude,参考 `plugin.rs:582``time: Res<Time>` 用法),无需额外 import。
```rust
/// 每帧读 OrbitParams + time + spin, 用闭式公式写 Planet.center.
/// 必须在 upload_planets 之前运行 (plugin.rs 用 .before() 保证).
pub fn orbit_system(
time: Res<Time>,
params: Res<crate::params::BlackHoleParams>,
mut query: Query<(&OrbitParams, &mut Planet)>,
) {
if !params.planets_enabled {
return;
}
// time_scale 放大模拟时间, 让慢进动在合理时间内可见 (Ω_LT 在 r=8 转一圈 ~25 min)
let t = time.elapsed_secs() * params.planet_time_scale;
for (orbit, mut planet) in &mut query {
planet.center = orbit_position(orbit, t, params.spin);
}
}
```
- [ ] **Step 2: 验证编译**
Run: `cargo check`
Expected: 编译通过。会报 `planets_enabled` / `planet_time_scale` 字段不存在——这是 Task 4 要加的。**若如此,先做 Task 4 再回来验证。**
- [ ] **Step 3: Commit(字段未加前不 commit;待 Task 4 完成后一起验证再 commit)**
暂不 commit。
---
## Task 4: BlackHoleParams 新字段
**Files:**
- Modify: `src/params.rs:107-153`(struct 定义) + `:156-211`(Default impl)
- [ ] **Step 1: 加字段到 struct**
`src/params.rs``BlackHoleParams` struct 里,`aa_quality: AaQuality,`(`:153`)之后加:
```rust
// Planets (Phase 3.4: Kerr orbiting planets)
pub planets_enabled: bool,
pub planet_count_target: u32, // 0..=8
pub planet_radius_factor: f32, // k, 乘到 kerr_isco(χ) 上
pub planet_seed: u32, // ChaCha8Rng 种子, 改了触发系统重生
pub planet_time_scale: f32, // 模拟时间放大 (进动很慢, 需放大才可见)
```
- [ ] **Step 2: 加 Default 值**
`impl Default for BlackHoleParams``aa_quality: ...`(`:208`)之后加:
```rust
// Planets: 6 颗, k=2.5 (r ∈ [1.25, 7.5], 横跨强场区),
// 种子 42, time_scale 50× (Ω_LT 在 r=8 转一圈 ~25 min, 放大才可见).
planets_enabled: true,
planet_count_target: 6,
planet_radius_factor: 2.5,
planet_seed: 42,
planet_time_scale: 50.0,
```
- [ ] **Step 3: 验证编译 (回 Task 3 的待验证项)**
Run: `cargo check`
Expected: 编译通过,`orbit_system` 现在能找到 `planets_enabled` / `planet_time_scale`
- [ ] **Step 4: Commit**
```bash
git add src/params.rs src/scene/planets.rs
git commit -m "feat(params): planet system params + orbit_system wiring"
```
---
## Task 5: spawn_planet_system (随机生成 + despawn 旧的)
取代 `spawn_default_planet`。用确定性 PRNG,改种子时整个系统重生。
**Files:**
- Modify: `src/scene/planets.rs`
- Modify: `src/render/plugin.rs:113`(注册) + 资源初始化
- [ ] **Step 1: 加 dirty-flag 资源 + spawn_planet_system**
`src/scene/planets.rs` 顶部 `use` 区加(`bevy::prelude::*` 已含 `Commands`/`Query`/`Entity`/`Resource`/`With`/`Vec3`):
```rust
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand::Rng;
```
`OrbitParams` struct 之后加 dirty flag 资源:
```rust
/// UI 改了种子/count/k 时置位, spawn_planet_system 检测到就重生整个行星系统.
#[derive(Resource, Default)]
pub struct PlanetSystemDirty(pub bool);
```
用新函数取代 `spawn_default_planet`(删除 `:66-73` 的整个 `spawn_default_planet`):
```rust
/// (重)生成行星系统. 检测 PlanetSystemDirty: 若置位, 先 despawn 所有现有
/// (Planet, OrbitParams), 再用 ChaCha8Rng + params.planet_seed 重新随机生成.
/// 确定性种子 → 同种子给同布局, 方便调试/截图/测试.
pub fn spawn_planet_system(
mut commands: Commands,
params: Res<crate::params::BlackHoleParams>,
mut dirty: ResMut<PlanetSystemDirty>,
existing: Query<Entity, With<Planet>>,
) {
// 只在 dirty 时重生 (避免每帧重建). 首帧 dirty 默认 false → 需要初始 spawn.
// 用 Resource Default 给的 false + 一个 startup 标记, 或始终在 Startup 调一次.
// 简化: 此系统同时在 Startup 和 Update 注册; Update 路径靠 dirty 门控,
// Startup 路径靠 "现有为零" 门控.
if !dirty.0 && !existing.is_empty() {
return;
}
// despawn 现有行星
for entity in &existing {
commands.entity(entity).despawn();
}
dirty.0 = false;
if !params.planets_enabled {
return;
}
let mut rng = ChaCha8Rng::seed_from_u64(params.planet_seed as u64);
for _ in 0..params.planet_count_target.min(crate::render::material::MAX_PLANETS as u32) {
let inclination = rng.gen_range(0.0..PI);
let longitude = rng.gen_range(0.0..TAU);
let phase = rng.gen_range(0.0..TAU);
let radius_factor = rng.gen_range(2.0..4.0);
// 颜色: 暖色行星 (橙/红/黄系), 避开蓝色 (易与背景星混淆)
let hue = rng.gen_range(0.02..0.13); // 橙红色相
let color = hsv_to_rgb(hue, rng.gen_range(0.5..0.9), rng.gen_range(0.7..1.0));
commands.spawn((
OrbitParams {
radius_factor,
inclination,
longitude_of_node: longitude,
phase,
},
Planet {
center: Vec3::ZERO, // 首帧由 orbit_system 填
radius: rng.gen_range(0.8..1.6),
color,
emissive: false,
},
));
}
}
/// HSV → RGB (h,s,v ∈ [0,1]). 行星颜色用.
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> Vec3 {
let i = (h * 6.0).floor() as i32 % 6;
let f = h * 6.0 - (h * 6.0).floor();
let p = v * (1.0 - s);
let q = v * (1.0 - f * s);
let t = v * (1.0 - (1.0 - f) * s);
match i {
0 => Vec3::new(v, t, p),
1 => Vec3::new(q, v, p),
2 => Vec3::new(p, v, t),
3 => Vec3::new(p, q, v),
4 => Vec3::new(t, p, v),
_ => Vec3::new(v, p, q),
}
}
```
注:`hsv_to_rgb``i32 % 6` 处理 h=1.0 边界;`hue 0.02..0.13` 给橙红色相,避免与背景星(蓝/白)混淆。
- [ ] **Step 2: 改 plugin.rs 注册**
`src/render/plugin.rs:96-98``init_resource` 链,在 `WantsPointer` 之后加 `PlanetSystemDirty`:
把:
```rust
app.init_resource::<crate::camera::OrbitCamera>()
.init_resource::<crate::camera::WantsPointer>()
.init_resource::<crate::params::BlackHoleParams>()
```
改为(在 `BlackHoleParams` 之后加一行,注意 `init_resource::<BlackHoleParams>()` 后原本没有 `.` 链式调用——检查上下文,它可能用 `;` 结束。读 `plugin.rs:96-100` 确认):
```rust
app.init_resource::<crate::camera::OrbitCamera>()
.init_resource::<crate::camera::WantsPointer>()
.init_resource::<crate::params::BlackHoleParams>()
.init_resource::<crate::scene::planets::PlanetSystemDirty>();
```
`init_resource::<BlackHoleParams>()` 后是 `;` 而非 `.`(即链已断),把新行单独写:
```rust
app.init_resource::<crate::scene::planets::PlanetSystemDirty>();
```
放在 `BlackHoleParams` init 之后。
- [ ] **Step 3: 改系统注册**
`src/render/plugin.rs:113`:
```rust
.add_systems(Startup, crate::scene::planets::spawn_default_planet)
```
改为:
```rust
.add_systems(Startup, crate::scene::planets::spawn_planet_system)
```
`:123`:
```rust
.add_systems(Update, crate::scene::planets::upload_planets)
```
改为(加 orbit_system 在 upload_planets 前, 加 spawn_planet_system 在 Update):
```rust
.add_systems(Update, crate::scene::planets::spawn_planet_system)
.add_systems(
Update,
crate::scene::planets::orbit_system
.before(crate::scene::planets::upload_planets),
)
.add_systems(Update, crate::scene::planets::upload_planets)
```
注:`spawn_planet_system` 放 Update 是为了检测 dirty flag 重生。它内部靠 dirty + `existing.is_empty()` 门控,不会每帧重建。
- [ ] **Step 4: 验证编译**
Run: `cargo check`
Expected: 编译通过。`spawn_default_planet` 已删除,无悬空引用。
- [ ] **Step 5: 验证启动**
Run: `cargo run --release`
Expected: 应用启动,看到 ~6 颗行星在轨道上。可能位置/速度还需调(time_scale 50× 下进动应可见)。
- [ ] **Step 6: Commit**
```bash
git add src/scene/planets.rs src/render/plugin.rs
git commit -m "feat(planets): spawn_planet_system with deterministic ChaCha8Rng seeding"
```
---
## Task 6: UI 控制面板
**Files:**
- Modify: `src/ui.rs`(在 Accretion Disk header 之后,约 `:34` 段之后)
- [ ] **Step 1: 定位插入点**
`src/ui.rs`,找 "Accretion Disk" collapsing header 结束的位置(下一个 `egui::CollapsingHeader::new` 之前)。
- [ ] **Step 2: 加 Planets header**
在 Accretion Disk header 之后加(具体缩进对齐现有代码):
```rust
egui::CollapsingHeader::new("Planets")
.default_open(false)
.show(ui, |ui| {
let was_enabled = params.planets_enabled;
ui.checkbox(&mut params.planets_enabled, "Enable");
ui.add(egui::Slider::new(&mut params.planet_count_target, 0..=8).text("Count"));
ui.add(egui::Slider::new(&mut params.planet_radius_factor, 1.5..=5.0).text("Radius factor k"));
let isco = crate::physics::kerr_isco(params.spin);
ui.label(format!("ISCO: {:.3} → r = {:.3}", isco, params.planet_radius_factor * isco));
ui.add(egui::Slider::new(&mut params.planet_seed, 0..=1000).text("Seed"));
ui.add(egui::Slider::new(&mut params.planet_time_scale, 1.0..=200.0).text("Time scale"));
});
```
- [ ] **Step 3: 验证编译**
Run: `cargo check`
Expected: 编译通过。
- [ ] **Step 4: Commit**
```bash
git add src/ui.rs
git commit -m "feat(ui): Planets control panel header"
```
---
## Task 7: dirty flag 联动 (UI 改种子/count/k 时重生)
当前 UI 改 `planet_seed` / `planet_count_target` / `planets_enabled` 不会触发 `spawn_planet_system` 重生。需要在 `ui_system` 里检测变化并置 `PlanetSystemDirty`
**Files:**
- Modify: `src/ui.rs`(`ui_system` 签名 + Planets header)
- [ ] **Step 1: 改 ui_system 签名加 PlanetSystemDirty**
`src/ui.rs:4-9`,当前签名:
```rust
pub fn ui_system(
mut contexts: bevy_egui::EguiContexts,
mut params: ResMut<crate::params::BlackHoleParams>,
mut camera: ResMut<crate::camera::OrbitCamera>,
mut wants: ResMut<crate::camera::WantsPointer>,
) {
```
`PlanetSystemDirty`:
```rust
pub fn ui_system(
mut contexts: bevy_egui::EguiContexts,
mut params: ResMut<crate::params::BlackHoleParams>,
mut camera: ResMut<crate::camera::OrbitCamera>,
mut wants: ResMut<crate::camera::WantsPointer>,
mut planet_dirty: ResMut<crate::scene::planets::PlanetSystemDirty>,
) {
```
- [ ] **Step 2: 在 Planets header 里检测变化置 dirty**
把 Task 6 加的 Planets header 改为(记录改前值,改后对比):
```rust
egui::CollapsingHeader::new("Planets")
.default_open(false)
.show(ui, |ui| {
let prev = (
params.planets_enabled,
params.planet_count_target,
params.planet_radius_factor,
params.planet_seed,
);
ui.checkbox(&mut params.planets_enabled, "Enable");
ui.add(egui::Slider::new(&mut params.planet_count_target, 0..=8).text("Count"));
ui.add(egui::Slider::new(&mut params.planet_radius_factor, 1.5..=5.0).text("Radius factor k"));
let isco = crate::physics::kerr_isco(params.spin);
ui.label(format!("ISCO: {:.3} → r = {:.3}", isco, params.planet_radius_factor * isco));
ui.add(egui::Slider::new(&mut params.planet_seed, 0..=1000).text("Seed"));
ui.add(egui::Slider::new(&mut params.planet_time_scale, 1.0..=200.0).text("Time scale"));
let curr = (
params.planets_enabled,
params.planet_count_target,
params.planet_radius_factor,
params.planet_seed,
);
if curr != prev {
planet_dirty.0 = true;
}
});
```
注:`planet_time_scale` 不触发重生(它只影响 orbit_system 的时间放大,不需重建实体)。
- [ ] **Step 3: spin 变化也触发重生**
Spin 改变 ISCO → 轨道半径变。读 `src/ui.rs` 的 "Black Hole" header(`:27-33`),在 spin slider 后加 dirty 标记。或者更简单:在 `ui_system` 末尾统一检测 spin 变化。
最简方案:在 `ui_system` 开头记录 `params.spin` 旧值,结尾对比。但这会污染整个函数。**推荐:** 在 "Black Hole" header 的 spin slider 后直接加:
`src/ui.rs:30` 附近:
```rust
ui.add(egui::Slider::new(&mut params.spin, 0.0..=1.0).text("Spin (χ)"));
```
改为(加 dirty 联动)——但这里需要 prev/curr。由于 spin 改变只影响半径(连续),不必重生实体(orbit_system 每帧读 spin)。**决定:spin 不触发重生**——orbit_system 已每帧读 `params.spin`,半径会平滑变化。只有种子/count/k 这些"根数"改变才需重生。
保持 Task 7 Step 2 的实现即可,spin 不加联动。
- [ ] **Step 4: 验证编译**
Run: `cargo check`
Expected: 编译通过。
- [ ] **Step 5: 验证 dirty 重生**
Run: `cargo run --release`
手动测试:在 UI 里拖动 Seed 滑条 → 行星布局应立即改变。拖动 Count → 行星数变化。拖动 k → 半径变化(可能需重生才体现新 k 的随机分布)。
- [ ] **Step 6: Commit**
```bash
git add src/ui.rs
git commit -m "feat(ui): planet dirty flag on seed/count/k change triggers respawn"
```
---
## Task 8: 集成验证 + 视觉调参
**Files:** 无代码改动(纯验证 + 可能微调默认值)
- [ ] **Step 1: 运行全部测试**
Run: `cargo test`
Expected: 所有测试通过:
- 原有 physics capture/escape 测试
- `orbital_frequency_*` (2)
- `nodal_precession_*` (3)
- `orbit_position_*` (3)
共 ~10+ 测试全绿。
- [ ] **Step 2: 桌面端视觉检查**
Run: `cargo run --release`
检查项:
- [ ] 启动后看到 ~6 颗行星
- [ ] 行星在轨道上运动(角速度可见)
- [ ] 拖动 Time Scale → 进动速率变化(高 time_scale 下轨道面绕 Y 轴转动可见)
- [ ] 拖动 Spin (χ) → 从 0 到 1:χ=0 时轨道面固定,χ>0 时进动出现
- [ ] 拖动 Seed → 行星布局重生
- [ ] 行星被引力透镜扭曲(爱因斯坦环/弧)——这是 shader 既有功能,验证位置上传正确
- [ ] 行星不会被吸积盘完全淹没(随机倾角应让多数行星偏离盘面)
- [ ] **Step 3: Web 端编译检查**
Run: `cargo check --target wasm32-unknown-unknown`
Expected: 编译通过。`ChaCha8Rng` 在 wasm 可用(纯计算,无平台依赖)。
- [ ] **Step 4: 微调默认值(若需要)**
若视觉不佳,调 `src/params.rs` 的 Default:
- 行星太小/大 → 调 `spawn_planet_system``radius: rng.gen_range(0.8..1.6)` 的范围
- 进动太慢/快 → 调 `planet_time_scale: 50.0`
- 行星太暗 → 调 `hsv_to_rgb` 的 value 范围,或让部分行星 `emissive: true`
- 颜色不好看 → 调 hue 范围 `0.02..0.13`
- [ ] **Step 5: 最终 commit(若有调参)**
```bash
git add -A
git commit -m "tune(planets): default visual parameters after visual check"
```
---
## Self-Review 结论
**1. Spec 覆盖:**
- ✅ 物理模型(Ω_φ, Ω_LT) → Task 1
- ✅ OrbitParams 组件 → Task 2
- ✅ 轨道几何 → Task 2 (`orbit_position`)
- ✅ orbit_system → Task 3
- ✅ BlackHoleParams 字段 → Task 4
- ✅ spawn_planet_system + ChaCha8Rng → Task 5
- ✅ UI 控制面板 → Task 6
- ✅ dirty flag 重生 → Task 7
- ✅ SphereData/shader 不动 → 全程未涉及
- ✅ 测试 → Task 1 (5 个) + Task 2 (3 个)
- ✅ χ=0 退化 → Task 1 测试覆盖
**2. 占位符扫描:** 无 TBD/TODO。所有代码块完整。
**3. 类型一致性:** `OrbitParams` 字段名(radius_factor, inclination, longitude_of_node, phase)在 Task 2/3/5 一致。`PlanetSystemDirty(pub bool)` 在 Task 5/7 一致。`planets_enabled` / `planet_time_scale` 在 Task 4/5/6/7 一致。
**4. 调度顺序:** orbit_system `.before(upload_planets)` (Task 5 Step 3) 保证上传最新位置。spawn_planet_system 在 Update 靠 dirty 门控。

View File

@ -0,0 +1,406 @@
# Kerr 轨道行星系统Phase 3.4
**日期:** 2026-07-16
**状态:** 设计稿,待实现
**前置:** Phase 2Kerr 光线积分、Phase 3.1(体积盘)
## 目标
让 58 颗行星沿 Kerr 时空的**圆轨道**绕黑洞旋转,轨道面因 LenseThirring 效应绕黑洞自旋轴进动。行星位置每帧由 CPU 用闭式解析公式计算,写入既有 storage buffer渲染端 shader **不改动**
这是 AGENTS.md 里档次 3 的方案Kerr 圆轨 + 强场节点进动。路径 ACPU 算位置 + 现有上传机制shader 零改动。
## 非目标
- **不做完整类时测地线**偏心、倾轨、plunging。理由圆轨道下径向频率 `Ω_r = 0`Mino 时间的三频率分解退化为纯 `Ω_φ`;上 Mino 时间需要椭圆函数WGSL 与 std 都没有),破坏 `physics.rs` 单一可测试镜像的设计原则。这不是"测地线可视化"项目。
- **不改渲染端**`planet_hit``SphereData`、storage buffer 布局全部不动。进动和轨道运动只在 CPU 端,最终位置照常上传。
- **不做行星-行星引力相互作用**。测试粒子近似。
## 物理模型
全部闭式公式,无数值积分。自然单位 `Rs = 1`,故 `M = 0.5``a = χM = 0.5χ`
### 轨道角速度 `Ω_φ`
赤道 prograde 圆轨,[Bardeen 1972, eqn 2.16](https://physics.stackexchange.com/questions/502796/how-to-derive-the-angular-velocity-of-circular-orbits-in-kerr-geometry)
```
Ω_φ(r, χ) = 1 / (r^1.5 + a) // a = 0.5χ
```
`χ = 0``a = 0`,退化为牛顿开普勒 `Ω = 1/r^1.5`
### 节点进动率 `Ω_LT`
轨道面绕黑洞自旋轴Y 轴)的进动率。精确强场形式:
```
Ω_LT(r, χ) = Ω_φ(r, χ) Ω_θ(r, χ)
```
其中 `Ω_θ` 是 Kerr 赤道圆轨的垂直 epicyclic 频率([Okazaki 1987](https://articles.adsabs.harvard.edu/pdf/1985PASJ...37..807O)Kato/Fukue/Mineshige "Black-Hole Accretion Disks"
```
Ω_θ² = Ω_φ² · (1 4a√M/r^1.5 + 3a²/r²) // a = 0.5χ, M = 0.5
Ω_θ = Ω_φ · sqrt(1 4a√M/r^1.5 + 3a²/r²)
```
**关键:交叉项是 `a√M/r^1.5`(半径 1.5 次幂),不是 `a·Ω_φ/r`。** 后者会让 `Ω_θ` 偏大、进动偏小,并破坏"进动随 χ 单调增"的物理性质(实现时这个错误被 `nodal_precession_grows_with_spin` 测试当场抓住)。
**退化验证:** `χ = 0``a = 0`,括号内 = 1`Ω_θ = Ω_φ``Ω_LT = 0`——精确退化为"轨道面固定"Schwarzschild 球对称),满足 AGENTS.md 的核心不变量。
**弱场极限交叉验证:** 大 `r` 展开,`Ω_LT → 2aM/r³ = 0.5χ/r³`M=0.5a=0.5χ)。此弱场极限用作测试断言,不用于渲染。
**实现:** `kerr_nodal_precession(r, chi)` 封装上述两式,返回 `Ω_φ - Ω_θ`。注意括号内可能因数值精度略负(极端 r/a 组合),`sqrt` 前用 `.max(0.0)` 钳位。
### 轨道半径
动态绑定 Kerr ISCO`physics.rs:65` 已实现的 `kerr_isco`
```
r = k · kerr_isco(χ)
```
`k` 是 UI 可调的倍数(默认 2.5。ISCO 从 `χ=0` 的 3 缩到 `χ=1` 的 0.5,故默认 `k=2.5``r ∈ [1.25, 7.5]`**横跨强场区**——这是选强场 `Ω_LT` 而非弱场近似的物理理由。
### χ=0 退化验证表
| 量 | χ = 0 | 含义 |
|---|---|---|
| `Ω_φ` | `1/r^1.5` | 牛顿开普勒 |
| `Ω_LT` | `0` | 无进动,轨道面固定 |
| `r` | `k · 3` | Schwarzschild ISCO = 6M = 3 Rs |
## 组件与数据结构
### `OrbitParams`(轨道根数,启动时随机生成)
不可变,除非 UI 改种子重生整个系统。
```rust
#[derive(Component, Clone, Copy)]
pub struct OrbitParams {
pub radius_factor: f32, // k, 乘到 kerr_isco(χ) 上得实际半径
pub inclination: f32, // 轨道面法向与 Y 轴夹角 (rad)
pub longitude_of_node: f32, // 升交点经度 (rad), 决定初始进动相位
pub phase: f32, // 轨道内初始相位 (rad)
}
```
### `Planet`(渲染状态,每帧重算 center
字段不变,但 `center` 从"静态坐标"变成"每帧由 orbit_system 派生"。
```rust
#[derive(Component, Clone, Copy)]
pub struct Planet {
pub center: Vec3, // ← 每帧重算
pub radius: f32,
pub color: Vec3,
pub emissive: bool,
}
```
**为什么拆两个组件:** `OrbitParams` 是不可变根数,`Planet.center` 是派生量。分开后 `orbit_system` 只写 `Planet.center`、读 `OrbitParams`——职责清晰,且轨道力学逻辑可独立测试,不依赖渲染数据结构。符合 AGENTS.md 的 deep-module 原则。
### `SphereData`GPU 布局)— 不变
`render/material.rs:122``SphereData { center, radius, color, emissive, _pad0..2 }` **不改**。进动和轨道运动全在 CPU 算完,只把最终 `center` 写进 buffer。这是路径 A 的核心好处。
## 轨道几何
给定 `OrbitParams` 和当前时间 `t`,计算行星世界空间位置。
### 1. 轨道面法向与基向量
从倾角 `i` 和升交点 `Ω`(用 `longitude_of_node`)解析构造轨道面内两个正交单位基:
```rust
// 轨道面法向 (Y 轴为极轴的球坐标)
let n = Vec3::new(
i.sin() * Omega.cos(),
i.cos(),
i.sin() * Omega.sin(),
);
// 轨道面内基: u 沿升节点方向, v = n × u
let u = Vec3::new(-Omega.sin(), 0.0, Omega.cos());
let v = n.cross(u); // 已单位化 (u, n 均单位且正交)
```
### 2. 进动
整个 `(u, v)` 基绕 Y 轴旋转 `Ω_LT · t`。用旋转矩阵作用:
```rust
let prec_angle = Omega_LT * t;
let cos_p = prec_angle.cos();
let sin_p = prec_angle.sin();
// 绕 Y 轴: x' = x cos + z sin, z' = -x sin + z cos
let u_prec = Vec3::new(
u.x * cos_p + u.z * sin_p,
u.y,
-u.x * sin_p + u.z * cos_p,
);
let v_prec = Vec3::new(
v.x * cos_p + v.z * sin_p,
v.y,
-v.x * sin_p + v.z * cos_p,
);
```
### 3. 行星位置
```rust
let theta = phase + Omega_phi * t;
let r = radius_factor * kerr_isco(chi);
let center = r * (theta.cos() * u_prec + theta.sin() * v_prec);
```
**disk_tilt 的处理:** shader 的 `planet_hit``black_hole.wgsl:632`)会把世界空间球心用 `rot_x(center, -disk_tilt)` 转进盘局部空间。所以轨道平面定义在**世界空间**,倾斜交给 shader——轨道系统不读 `disk_tilt`
## 随机生成
58 颗,全随机散布。用确定性 PRNG`ChaCha8Rng` + UI 种子),改种子时整个系统可复现地重生。
```rust
pub fn spawn_planet_system(
mut commands: Commands,
params: Res<BlackHoleParams>,
seed: Res<PlanetSeed>,
) {
// 先 despawn 现有 (Planet, OrbitParams) — 由系统签名 query 完成
let mut rng = ChaCha8Rng::seed_from_u64(seed.0);
for _ in 0..params.planet_count_target {
let inclination = rng.gen_range(0.0..PI);
let longitude = rng.gen_range(0.0..TAU);
let phase = rng.gen_range(0.0..TAU);
let radius_factor = rng.gen_range(2.0..4.0);
commands.spawn((
OrbitParams { radius_factor, inclination, longitude_of_node: longitude, phase },
Planet {
center: Vec3::ZERO, // 首帧由 orbit_system 填
radius: rng.gen_range(0.8..1.6),
color: random_planet_color(&mut rng),
emissive: false,
},
));
}
}
```
`ChaCha8Rng`(不是 `thread_rng()`)的理由:
1. **可复现**——UI 改种子时整个系统重生,同样种子给同样布局,方便调试和截图对比。
2. **可测试**——CPU 测试能 seed 固定值断言生成的根数。
3. **跨平台一致**——web 与 desktop 同种子给同布局。
依赖:`rand`(通常已是 Bevy 间接依赖)+ `rand_chacha`。若 `rand` 未在 `Cargo.toml` 直接声明,需加上。
## 系统调度
### 新增系统
```rust
// Update: 读 OrbitParams + time + spin, 写 Planet.center
fn orbit_system(
time: Res<Time>,
params: Res<BlackHoleParams>,
mut query: Query<(&OrbitParams, &mut Planet)>,
)
```
逻辑:对每个 `(orbit, planet)`,用上面的轨道几何公式算 `center`,写入 `planet.center`
**调度顺序:** `orbit_system` 必须在 `upload_planets` 之前跑(否则上传的是上一帧位置)。用 Bevy 的 `.before(upload_planets)` 或同一个 `SystemSet` 排序。
### 改动的系统
- **`spawn_default_planet``planets.rs:66`)→ 删除**。由 `spawn_planet_system` 取代。
- **`upload_planets``planets.rs:30`)→ 不变**。它已经每帧全量重写 buffer天然支持动态位置。
- **`mirror_params``plugin.rs:579`)→ 加几行**,把新参数镜像进 uniform见下
### 插件注册(`plugin.rs`
```rust
.add_systems(Startup, spawn_planet_system) // 取代 spawn_default_planet
.add_systems(
Update,
orbit_system.before(crate::scene::planets::upload_planets),
)
```
## 参数镜像end-to-end4 处锁步)
按 AGENTS.md 的"Mirroring a new param"约定,每个新参数要改 4 处。这次新增的参数只影响 CPU 端的轨道计算,**不需要进 GPU uniform**——`Omega_φ``Omega_LT`、轨道几何全在 CPU 算shader 只拿最终 `center`
所以实际的锁步是 **3 处**(不是 4
1. **`BlackHoleParams``params.rs`** — 加字段:
```rust
pub planets_enabled: bool, // 行星系统开关
pub planet_count_target: u32, // 58
pub planet_radius_factor: f32, // k, 默认 2.5
pub planet_seed: u32, // 随机种子
pub planet_time_scale: f32, // 时间加速 (进动很慢, 需放大才可见)
```
2. **`mirror_params``plugin.rs`** — 把 `planet_count` 改为反映实际活动行星数(已有逻辑,保持),其余新字段不进 uniform。
3. **`ui.rs`** — 新增 "Planets" collapsing header见下
**没有第 4 处 shader 改动**,因为 `SphereData``BlackHoleUniforms` 都不改。
### `PlanetSeed` 资源
```rust
#[derive(Resource, Clone, Copy)]
pub struct PlanetSeed(pub u32);
```
种子变更需要触发行星系统重生。两种做法:
- **方案 A简单** UI 里把种子滑动条改成"改了就标记 dirty",下一帧 `spawn_planet_system` 检测到 dirty 就 despawn + respawn。需要一个 `PlanetSystemDirty` 资源。
- **方案 B事件** UI 发 `RespawnPlanets` 事件,专门系统消费。
推荐 **方案 A**——dirty flag 足够,事件系统对这个规模过度设计。
## UI`ui.rs`
新增 collapsing header放在 "Accretion Disk" 之后:
```rust
egui::CollapsingHeader::new("Planets")
.default_open(false)
.show(ui, |ui| {
ui.checkbox(&mut params.planets_enabled, "Enable");
ui.add(egui::Slider::new(&mut params.planet_count_target, 0..=8).text("Count"));
ui.add(egui::Slider::new(&mut params.planet_radius_factor, 1.5..=5.0).text("Radius factor k"));
ui.label(format!("ISCO: {:.3} → r = {:.3}",
crate::physics::kerr_isco(params.spin),
params.planet_radius_factor * crate::physics::kerr_isco(params.spin)));
ui.add(egui::Slider::new(&mut params.planet_seed, 0..=1000).text("Seed"));
ui.add(egui::Slider::new(&mut params.planet_time_scale, 1.0..=200.0).text("Time scale"));
});
```
**Time scale 的必要性:** `Ω_LT` 在 r=8 处约 `0.004 rad/s`,转一圈要 ~25 分钟。不放大根本看不见进动。`time_scale` 是让进动在合理时间内可见的必要旋钮,不是物理作弊——它等价于"模拟时间流速"。
## 测试策略
按 AGENTS.md测试面是 `physics.rs` 的 CPU 镜像。新增的轨道公式应该进 `physics.rs` 并加测试。
### 进 `physics.rs` 的函数
```rust
/// Kerr 赤道 prograde 圆轨角速度 (Rs=1, M=0.5). Bardeen 1972 eqn 2.16.
/// χ=0 退化为牛顿 1/r^1.5.
pub fn kerr_orbital_frequency(r: f32, chi: f32) -> f32 {
let a = 0.5 * chi;
1.0 / (r.powf(1.5) + a)
}
/// Kerr 圆轨节点进动率 (Lense-Thirring, 强场精确). χ=0 返回 0.
pub fn kerr_nodal_precession(r: f32, chi: f32) -> f32 {
let m = 0.5;
let a = chi * m;
let omega_phi = kerr_orbital_frequency(r, chi);
// 垂直 epicyclic 频率 (Caltech Ph236 lec27):
// Ω_θ² = Ω_φ² · (1 4a·Ω_φ/r + 3a²/r²)
let ratio = (1.0 - 4.0 * a * omega_phi / r + 3.0 * a * a / (r * r)).max(0.0);
let omega_theta = omega_phi * ratio.sqrt();
omega_phi - omega_theta
}
```
### 测试用例(`#[cfg(test)]` in `physics.rs`
```rust
#[test]
fn orbital_frequency_reduces_to_newton_at_zero_spin() {
// χ=0: Ω = 1/r^1.5
let r = 8.0;
let newton = 1.0 / r.powf(1.5);
assert!((kerr_orbital_frequency(r, 0.0) - newton).abs() < 1e-6);
}
#[test]
fn nodal_precession_vanishes_at_zero_spin() {
// χ=0: 球对称, 无进动
for r in [4.0, 6.0, 10.0, 20.0] {
assert!(kerr_nodal_precession(r, 0.0).abs() < 1e-6,
"χ=0 at r={} should have zero precession", r);
}
}
#[test]
fn nodal_precession_grows_with_spin() {
// 固定 r, 进动率随 χ 单调增
let r = 6.0;
let p_low = kerr_nodal_precession(r, 0.3);
let p_high = kerr_nodal_precession(r, 0.9);
assert!(p_high > p_low, "precession should grow with spin");
}
#[test]
fn nodal_preception_strong_field_exceeds_weak_field() {
// r<6 强场区: 精确 Ω_LT > 弱场近似 2Ma/r³
let r = 4.0;
let chi = 0.9;
let weak = chi / r.powi(3); // 2Ma/r³ = χ/r³ (M=0.5)
let strong = kerr_nodal_precession(r, chi);
assert!(strong > weak, "strong-field precession should exceed weak at r={}", r);
}
#[test]
fn orbital_radius_tracks_isco() {
// r = k · isco(χ), 随 χ 收缩
let k = 2.5;
let r0 = k * kerr_isco(0.0); // 7.5
let r1 = k * kerr_isco(1.0); // 1.25
assert!(r1 < r0);
}
```
### 轨道几何测试
`u, v, n` 的正交性、绕 Y 轴旋转保持手性、`χ=0` 时位置退化为赤道圆——这些可以用 `ChaCha8Rng` 固定种子生成 `OrbitParams`,断言几何不变量。
## 性能分析
### GPU 开销:零
`planet_hit``black_hole.wgsl:626`)不变。`planet_count` 从 1 涨到 ~8`planet_hit` 内循环(`:630`)从 1 轮涨到 8 轮——每步每像素多 7 次射线-球求交。对 300 steps × ~1.16M 像素desktop这是 ~24 亿次额外求交/帧,但每次求交是 ~10 次 FLOPGPU 上微秒级。
实测验证项:`planet_count=8` vs `planet_count=0` 的帧时间差应 < 1ms
### CPU 开销:可忽略
每帧每颗行星1 次 `kerr_isco` + 1 次 `kerr_orbital_frequency` + 1 次 `kerr_nodal_precession` + 1 次 `sin` + 1 次 `cos` + ~20 次乘加。8 颗 = ~200 次 FLOP/帧,亚微秒。
### 上传开销:与现状相同
`upload_planets` 每帧全量重写 32 颗 `SphereData`1.5KB)的逻辑不变——加轨道运动不增加它。
## 风险与缓解
1. **`Ω_θ` 闭式公式找错** → 用弱场极限 `2Ma/r³` 作交叉验证(测试用例已覆盖)。实现前先在 Python/KerrGeoPy 里算几个参考值对照。
2. **进动太慢看不见**`time_scale` 旋钮(默认 ~50×。这是必要的 UI 权宜,非物理错误。
3. **行星半径撞进吸积盘** → 默认 `k=2.5` 让 r ≥ ISCO×2.5,盘内边缘外。且随机倾角让多数行星不在盘面内。
4. **种子改动不触发重生**`PlanetSystemDirty` flag + `spawn_planet_system` 检测。
5. **`orbit_system``upload_planets` 竞态** → `.before(upload_planets)` 强制顺序。
## 实现顺序(供 writing-plans 参考)
1. `physics.rs`:加 `kerr_orbital_frequency` + `kerr_nodal_precession` + 测试。先确保物理公式正确。
2. `scene/planets.rs`:加 `OrbitParams` 组件 + 轨道几何函数(纯函数,可测)+ `orbit_system`
3. `scene/planets.rs`:加 `spawn_planet_system`(带 despawn 旧行星 + ChaCha8Rng+ 删除 `spawn_default_planet`
4. `params.rs`:加 5 个新字段 + Default。
5. `plugin.rs`:注册新系统 + `.before(upload_planets)` + `PlanetSeed` / `PlanetSystemDirty` 资源。
6. `ui.rs`Planets header。
7. 视觉调参:`cargo run --release`,调 `k` / `time_scale` / 颜色直到好看。
## 参考资料
- [Bardeen, Press, Teukolsky 1972 — Kerr 圆轨角速度 eqn 2.16](https://physics.stackexchange.com/questions/502796/how-to-derive-the-angular-velocity-of-circular-orbits-in-kerr-geometry)
- [Chakraborty 2014 — Kerr 强场 Lense-Thirring 进动](https://arxiv.org/pdf/1304.6936)
- [Fujita & Hikida 2009 — Mino 时间解析解](https://arxiv.org/abs/0906.1420)(本文档解释为何**不用**它)
- [Costa & Natário 2021 — frame-dragging 三种含义](https://www.mdpi.com/2218-1997/7/10/388)
- [KerrGeoPy 文档](https://kerrgeopy.readthedocs.io/)(圆轨特例用同一组 Ω_φ
- [Black Hole Perturbation Toolkit](http://bhptoolkit.org/toolkit.html)

View File

@ -70,7 +70,7 @@
"optimizing-rust-performance": { "optimizing-rust-performance": {
"source": "DefectingCat/optimizing-rust-performance", "source": "DefectingCat/optimizing-rust-performance",
"sourceType": "github", "sourceType": "github",
"skillPath": "SKILL.md", "skillPath": "optimizing-rust-performance/SKILL.md",
"computedHash": "96bf0221d116e617055f5dc038bb33d92dd61b7808d804f92da78d32c01e6934" "computedHash": "96bf0221d116e617055f5dc038bb33d92dd61b7808d804f92da78d32c01e6934"
}, },
"prototype": { "prototype": {
@ -85,6 +85,12 @@
"skillPath": "skills/engineering/research/SKILL.md", "skillPath": "skills/engineering/research/SKILL.md",
"computedHash": "87d17f5103899fbe179b552a85485d50f2316ca5b3128f5716af7d88817533e1" "computedHash": "87d17f5103899fbe179b552a85485d50f2316ca5b3128f5716af7d88817533e1"
}, },
"rust-advanced-performance": {
"source": "DefectingCat/optimizing-rust-performance",
"sourceType": "github",
"skillPath": "rust-advanced-performance/SKILL.md",
"computedHash": "dc4863bc85f63bb2810e61c3001d06730e2df520ca08a4bd800f662cdfb5d12c"
},
"setup-matt-pocock-skills": { "setup-matt-pocock-skills": {
"source": "mattpocock/skills", "source": "mattpocock/skills",
"sourceType": "github", "sourceType": "github",

View File

@ -21,7 +21,9 @@ fn main() {
} }
App::new() App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin { .add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window { primary_window: Some(Window {
title: "singularity-rs".into(), title: "singularity-rs".into(),
// On web, make the canvas track the browser window size. // On web, make the canvas track the browser window size.
@ -29,7 +31,25 @@ fn main() {
..default() ..default()
}), }),
..default() ..default()
})) })
// The shaders ship as raw `.wgsl` files with no companion
// `.meta` files. The default `AssetMetaCheck::Always` makes bevy
// fetch `<path>.meta` for every asset; on the web dev server
// those requests don't return a clean 404, so bevy receives
// bytes it tries to RON-deserialize as `AssetMetaMinimal` and
// logs a deserialization error per shader. `Never` skips the
// meta lookup entirely and uses the loader's default meta —
// exactly right for processor-free assets.
.set(AssetPlugin {
meta_check: bevy::asset::AssetMetaCheck::Never,
..default()
})
// The app has no audio. The default AudioPlugin opens a WebAudio
// sink at startup, which browsers block until a user gesture and
// log as a noisy "AudioContext was not allowed to start" error.
// Dropping it removes that noise and shrinks the wasm binary.
.disable::<bevy::audio::AudioPlugin>(),
)
.add_plugins(render::BlackHolePlugin) .add_plugins(render::BlackHolePlugin)
.run(); .run();
} }

View File

@ -76,6 +76,30 @@ impl DiskColorMode {
} }
} }
/// Per-pixel supersampling for the higher-order lensed-image rings. The rings
/// are physical (lensed disk images), but the integrator renders each wrap as a
/// sharp discrete band; firing several jittered sub-rays per pixel and
/// averaging antialiases those edges into a smooth gradient. Off = 1 ray/pixel
/// (cheapest, rings visible); Low = 2; High = 4 (smoothest, ~4× the march cost).
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub enum AaQuality {
Off,
Low,
#[default]
High,
}
impl AaQuality {
/// Sub-rays per pixel, consumed by the shader's supersampling loop.
pub fn samples(self) -> u32 {
match self {
AaQuality::Off => 1,
AaQuality::Low => 2,
AaQuality::High => 4,
}
}
}
/// All tunable black-hole parameters. Edited by the egui panel (Task 17), /// All tunable black-hole parameters. Edited by the egui panel (Task 17),
/// mirrored into BlackHoleUniforms each frame (Task 7). /// mirrored into BlackHoleUniforms each frame (Task 7).
#[derive(Resource, Clone)] #[derive(Resource, Clone)]
@ -125,6 +149,14 @@ pub struct BlackHoleParams {
pub disk_temp: f32, pub disk_temp: f32,
pub jets_enabled: bool, pub jets_enabled: bool,
pub jets_strength: f32, pub jets_strength: f32,
// Anti-aliasing (Phase 3.3): supersample count for the lensed-image rings.
pub aa_quality: AaQuality,
// Planets (Phase 3.4: Kerr orbiting planets)
pub planets_enabled: bool,
pub planet_count_target: u32, // 0..=8
pub planet_radius_factor: f32, // k, 乘到 kerr_isco(χ) 上
pub planet_seed: u32, // ChaCha8Rng 种子, 改了触发系统重生
pub planet_time_scale: f32, // 模拟时间放大 (进动很慢, 需放大才可见)
} }
impl Default for BlackHoleParams { impl Default for BlackHoleParams {
@ -175,6 +207,19 @@ impl Default for BlackHoleParams {
disk_temp: 6500.0, disk_temp: 6500.0,
jets_enabled: true, jets_enabled: true,
jets_strength: 1.0, jets_strength: 1.0,
// Supersampling off on web (WebGPU budget) and Low (2×) on desktop.
// The RK45 march is already ~10× Phase 1's cost, so desktop stays
// at 2× by default — enough to soften the rings — with High (4×)
// available in the UI for a fully smooth Gargantua look.
aa_quality: if cfg!(target_arch = "wasm32") { AaQuality::Off } else { AaQuality::Low },
// Planets: 6 颗. 半径基准是 disk_outer (默认 25), factor 1.3 →
// r ≈ 32.5, 稳定在盘外 (盘范围 [isco, 25]). 早期用 ISCO 作基准会让
// 行星落进盘的径向范围. 种子 42, time_scale 50× (进动慢, 需放大).
planets_enabled: true,
planet_count_target: 6,
planet_radius_factor: 1.3,
planet_seed: 42,
planet_time_scale: 50.0,
} }
} }
} }

View File

@ -79,6 +79,34 @@ pub fn kerr_horizon(chi: f32) -> f32 {
m + (m * m - a * a).max(0.0).sqrt() m + (m * m - a * a).max(0.0).sqrt()
} }
/// Kerr 赤道 prograde 圆轨角速度 (Rs=1, M=0.5). Bardeen 1972 eqn 2.16.
/// `Ω_φ = 1 / (r^1.5 + a)`, a = χM = 0.5χ. χ=0 退化为牛顿 1/r^1.5.
pub fn kerr_orbital_frequency(r: f32, chi: f32) -> f32 {
let m = 0.5;
let a = chi * m;
1.0 / (r.powf(1.5) + a)
}
/// Kerr 赤道圆轨节点进动率 (Lense-Thirring, 强场精确). χ=0 返回 0.
///
/// `Ω_LT = Ω_φ - Ω_θ`, 其中 Ω_θ 是垂直 epicyclic 频率 (Okazaki 1987;
/// Kato/Fukue/Mineshige "Black-Hole Accretion Disks"):
/// `Ω_θ² = Ω_φ² · (1 4a√M/r^1.5 + 3a²/r²)`.
/// χ=0 时 a=0, 括号=1, 故 Ω_θ=Ω_φ, 进动为零 (Schwarzschild 球对称).
///
/// 注: 交叉项是 `a√M/r^1.5` (半径 -1.5 次幂), 不是 `a·Ω_φ/r`. 后者会让
/// Ω_θ 偏大、进动偏小, 且破坏"进动随 χ 单调增"的物理性质.
pub fn kerr_nodal_precession(r: f32, chi: f32) -> f32 {
let m = 0.5;
let a = chi * m;
let omega_phi = kerr_orbital_frequency(r, chi);
// 垂直 epicyclic 频率比 (>=0; 极端 r/a 组合下数值精度可能略负, 钳位)
let sqrt_m = m.sqrt();
let ratio = (1.0 - 4.0 * a * sqrt_m / r.powf(1.5) + 3.0 * a * a / (r * r)).max(0.0);
let omega_theta = omega_phi * ratio.sqrt();
omega_phi - omega_theta
}
/// Kerr bending acceleration (CPU mirror of the shader `deriv` accel). /// Kerr bending acceleration (CPU mirror of the shader `deriv` accel).
/// `chi = a/M ∈ [0,1]`. At chi=0 this equals `bending_accel`. /// `chi = a/M ∈ [0,1]`. At chi=0 this equals `bending_accel`.
pub fn kerr_bending_accel(pos: Vec3, dir: Vec3, chi: f32) -> Vec3 { pub fn kerr_bending_accel(pos: Vec3, dir: Vec3, chi: f32) -> Vec3 {
@ -279,4 +307,65 @@ mod tests {
assert!(b > BCRIT); assert!(b > BCRIT);
assert!(!is_captured(eye, dir, 2000, 0.1), "ray above bcrit should escape"); assert!(!is_captured(eye, dir, 2000, 0.1), "ray above bcrit should escape");
} }
#[test]
fn orbital_frequency_reduces_to_newton_at_zero_spin() {
// χ=0: Ω = 1/r^1.5 (牛顿开普勒, Rs=1)
for r in [4.0_f32, 6.0, 10.0, 20.0] {
let newton = 1.0 / r.powf(1.5);
let kerr = kerr_orbital_frequency(r, 0.0);
assert!(
(kerr - newton).abs() < 1e-6,
"χ=0 at r={}: expected {} (newton), got {}",
r, newton, kerr
);
}
}
#[test]
fn orbital_frequency_decreases_with_spin_at_fixed_r() {
// prograde 轨道 (a>0): Ω_φ 随 χ 减小 (分母 r^1.5+a 增大)
let r = 8.0;
let omega_0 = kerr_orbital_frequency(r, 0.0);
let omega_1 = kerr_orbital_frequency(r, 1.0);
assert!(omega_1 < omega_0, "prograde Ω should decrease with spin");
}
#[test]
fn nodal_precession_vanishes_at_zero_spin() {
// χ=0: 球对称 (Schwarzschild), 无节点进动
for r in [4.0_f32, 6.0, 10.0, 20.0] {
let prec = kerr_nodal_precession(r, 0.0);
assert!(
prec.abs() < 1e-6,
"χ=0 at r={} should have zero precession, got {}",
r, prec
);
}
}
#[test]
fn nodal_precession_grows_with_spin() {
// 固定 r, prograde 节点进动率随 χ 单调增
let r = 6.0;
let p_low = kerr_nodal_precession(r, 0.3);
let p_high = kerr_nodal_precession(r, 0.9);
assert!(p_high > p_low, "precession should grow with spin");
assert!(p_low > 0.0, "prograde precession should be positive");
}
#[test]
fn nodal_precession_strong_field_exceeds_weak_field() {
// r<6 强场区: 精确 Ω_LT > 弱场近似 2aM/r³.
// a = χM = 0.5χ, M = 0.5 → 2aM/r³ = 2·(0.5χ)·0.5/r³ = 0.5χ/r³.
let r = 4.0_f32;
let chi = 0.9;
let weak = 0.5 * chi / r.powi(3);
let strong = kerr_nodal_precession(r, chi);
assert!(
strong > weak,
"strong-field precession at r={} should exceed weak approx {} , got {}",
r, weak, strong
);
}
} }

View File

@ -58,6 +58,10 @@ pub struct BlackHoleUniforms {
pub disk_temp: f32, // blackbody base temperature (Kelvin) pub disk_temp: f32, // blackbody base temperature (Kelvin)
pub jets_enabled: u32, pub jets_enabled: u32,
pub jets_strength: f32, pub jets_strength: f32,
// Anti-aliasing (Phase 3.3): per-pixel supersample count for the lensed-image rings.
pub aa_samples: u32,
pub _pad6: f32,
pub _pad7: f32,
} }
impl Default for BlackHoleUniforms { impl Default for BlackHoleUniforms {
@ -107,6 +111,9 @@ impl Default for BlackHoleUniforms {
disk_temp: 6500.0, disk_temp: 6500.0,
jets_enabled: 1, jets_enabled: 1,
jets_strength: 1.0, jets_strength: 1.0,
aa_samples: 1, // overridden by params.aa_quality each frame
_pad6: 0.0,
_pad7: 0.0,
} }
} }
} }

View File

@ -96,6 +96,7 @@ impl Plugin for BlackHolePlugin {
app.init_resource::<crate::camera::OrbitCamera>() app.init_resource::<crate::camera::OrbitCamera>()
.init_resource::<crate::camera::WantsPointer>() .init_resource::<crate::camera::WantsPointer>()
.init_resource::<crate::params::BlackHoleParams>() .init_resource::<crate::params::BlackHoleParams>()
.init_resource::<crate::scene::planets::PlanetSystemDirty>()
.add_plugins(Material2dPlugin::<BlackHoleMaterial>::default()) .add_plugins(Material2dPlugin::<BlackHoleMaterial>::default())
.add_plugins(Material2dPlugin::<crate::render::material::CompositeMaterial>::default()) .add_plugins(Material2dPlugin::<crate::render::material::CompositeMaterial>::default())
.add_plugins(Material2dPlugin::<crate::render::material::BrightPassMaterial>::default()) .add_plugins(Material2dPlugin::<crate::render::material::BrightPassMaterial>::default())
@ -110,7 +111,7 @@ impl Plugin for BlackHolePlugin {
// Runs in PreStartup, before setup_primary_egui_context_system. // Runs in PreStartup, before setup_primary_egui_context_system.
.add_systems(bevy::prelude::PreStartup, disable_egui_auto_context) .add_systems(bevy::prelude::PreStartup, disable_egui_auto_context)
.add_systems(Startup, spawn_fullscreen_quad) .add_systems(Startup, spawn_fullscreen_quad)
.add_systems(Startup, crate::scene::planets::spawn_default_planet) .add_systems(Startup, crate::scene::planets::spawn_planet_system)
.add_systems( .add_systems(
Update, Update,
( (
@ -120,6 +121,12 @@ impl Plugin for BlackHolePlugin {
nudge_camera, nudge_camera,
), ),
) )
.add_systems(Update, crate::scene::planets::spawn_planet_system)
.add_systems(
Update,
crate::scene::planets::orbit_system
.before(crate::scene::planets::upload_planets),
)
.add_systems(Update, crate::scene::planets::upload_planets) .add_systems(Update, crate::scene::planets::upload_planets)
.add_systems(Update, rebuild_bloom) .add_systems(Update, rebuild_bloom)
// bevy_egui 0.41 requires UI systems to run inside the egui context // bevy_egui 0.41 requires UI systems to run inside the egui context
@ -633,6 +640,7 @@ fn mirror_params(
u.disk_temp = params.disk_temp; u.disk_temp = params.disk_temp;
u.jets_enabled = params.jets_enabled as u32; u.jets_enabled = params.jets_enabled as u32;
u.jets_strength = params.jets_strength; u.jets_strength = params.jets_strength;
u.aa_samples = params.aa_quality.samples();
} }
// Update brightpass threshold (live-tunable). // Update brightpass threshold (live-tunable).
for (_, mat) in brightpass_materials.iter_mut() { for (_, mat) in brightpass_materials.iter_mut() {

View File

@ -1,5 +1,9 @@
use std::f32::consts::{PI, TAU};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::render::storage::ShaderBuffer; use bevy::render::storage::ShaderBuffer;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
use crate::render::material::{SphereData, MAX_PLANETS}; use crate::render::material::{SphereData, MAX_PLANETS};
@ -12,6 +16,84 @@ pub struct Planet {
pub emissive: bool, pub emissive: bool,
} }
/// 轨道根数 (不变量, 启动时随机生成, 运行时不变除非 UI 改种子重生).
#[derive(Component, Clone, Copy)]
pub struct OrbitParams {
/// k, 乘到 kerr_isco(χ) 上得实际轨道半径.
pub radius_factor: f32,
/// 轨道面法向与 Y 轴(自旋轴)的夹角 (rad).
pub inclination: f32,
/// 升交点经度 (rad), 决定轨道面在方位上的初始取向.
pub longitude_of_node: f32,
/// 轨道内初始相位 (rad).
pub phase: f32,
}
/// UI 改了种子/count/k 时置位, spawn_planet_system 检测到就重生整个行星系统.
#[derive(Resource, Default)]
pub struct PlanetSystemDirty(pub bool);
/// 由轨道根数 + 当前 (模拟)时间 + 自旋 + 盘外半径, 计算行星世界空间位置.
/// 纯函数: 无 Bevy 依赖, 可独立测试.
///
/// 物理:
/// - r = radius_factor · disk_outer (绑定盘外缘, 保证行星在盘外)
/// - Ω_φ = kerr_orbital_frequency(r, χ) (轨道角速度)
/// - Ω_LT = kerr_nodal_precession(r, χ) (轨道面绕 Y 轴的进动率)
/// 轨道面基 (u, v) 由 inclination + longitude_of_node 构造, 然后绕 Y 轴
/// 整体旋转 Ω_LT·t (Lense-Thirring 进动).
///
/// 注: 半径基准是 disk_outer 而非 kerr_isco(χ). 早期版本用 ISCO 作基准,
/// 但盘从 ISCO 向外延伸到 disk_outer (默认 25), 用 ISCO 会让行星落在
/// 盘的径向范围内 (与盘重叠/被盘淹没). disk_outer 保证行星永远在盘外.
pub fn orbit_position(orbit: &OrbitParams, t: f32, chi: f32, disk_outer: f32) -> Vec3 {
let r = orbit.radius_factor * disk_outer;
let omega_phi = crate::physics::kerr_orbital_frequency(r, chi);
let omega_lt = crate::physics::kerr_nodal_precession(r, chi);
// 1. 轨道面法向 (Y 轴为极轴的球坐标)
let inc = orbit.inclination;
let lon = orbit.longitude_of_node;
let sin_inc = inc.sin();
let n = Vec3::new(
sin_inc * lon.cos(),
inc.cos(),
sin_inc * lon.sin(),
);
// 2. 轨道面内正交基: u 沿升节点方向, v = n × u
// u 在 XZ 平面 (垂直于 Y 轴), 指向升节点
let u = Vec3::new(-lon.sin(), 0.0, lon.cos());
let v = n.cross(u);
// 3. 进动: (u, v) 绕 Y 轴整体旋转 Ω_LT·t
let pa = omega_lt * t;
let cp = pa.cos();
let sp = pa.sin();
let u_p = Vec3::new(u.x * cp + u.z * sp, u.y, -u.x * sp + u.z * cp);
let v_p = Vec3::new(v.x * cp + v.z * sp, v.y, -v.x * sp + v.z * cp);
// 4. 行星在进动后的轨道面内的位置
let theta = orbit.phase + omega_phi * t;
r * (theta.cos() * u_p + theta.sin() * v_p)
}
/// 每帧读 OrbitParams + time + spin, 用闭式公式写 Planet.center.
/// 必须在 upload_planets 之前运行 (plugin.rs 用 .before() 保证).
pub fn orbit_system(
time: Res<Time>,
params: Res<crate::params::BlackHoleParams>,
mut query: Query<(&OrbitParams, &mut Planet)>,
) {
if !params.planets_enabled {
return;
}
// time_scale 放大模拟时间, 让慢进动在合理时间内可见 (Ω_LT 在 r=8 转一圈 ~25 min)
let t = time.elapsed_secs() * params.planet_time_scale;
for (orbit, mut planet) in &mut query {
planet.center = orbit_position(orbit, t, params.spin, params.disk_outer);
}
}
/// Collects all Planet components, writes them into the shared MAX_PLANETS-sized /// Collects all Planet components, writes them into the shared MAX_PLANETS-sized
/// `ShaderBuffer` that the material already binds, and updates `planet_count`. /// `ShaderBuffer` that the material already binds, and updates `planet_count`.
/// ///
@ -62,12 +144,133 @@ pub fn upload_planets(
} }
} }
/// Spawns a default test planet behind/above the hole so lensing is visible. /// (重)生成行星系统. 检测 PlanetSystemDirty: 若置位, 先 despawn 所有现有
pub fn spawn_default_planet(mut commands: Commands) { /// (Planet, OrbitParams), 再用 ChaCha8Rng + params.planet_seed 重新随机生成.
commands.spawn(Planet { /// 确定性种子 → 同种子给同布局, 方便调试/截图/测试.
center: Vec3::new(0.0, 2.0, -25.0), ///
radius: 2.0, /// 同时注册在 Startup 和 Update: Startup 首帧靠 "existing 为空" 门控首次生成;
color: Vec3::new(0.3, 0.5, 1.0), /// Update 路径靠 dirty flag 门控重生. 不会每帧重建.
pub fn spawn_planet_system(
mut commands: Commands,
params: Res<crate::params::BlackHoleParams>,
mut dirty: ResMut<PlanetSystemDirty>,
existing: Query<Entity, With<Planet>>,
) {
// 只在 dirty, 或现有行星为零 (首帧/Startup) 时重生.
if !dirty.0 && !existing.is_empty() {
return;
}
// despawn 现有行星
for entity in &existing {
commands.entity(entity).despawn();
}
dirty.0 = false;
if !params.planets_enabled {
return;
}
let mut rng = ChaCha8Rng::seed_from_u64(params.planet_seed as u64);
let max = crate::render::material::MAX_PLANETS as u32;
for _ in 0..params.planet_count_target.min(max) {
let inclination = rng.gen_range(0.0..PI);
let longitude = rng.gen_range(0.0..TAU);
let phase = rng.gen_range(0.0..TAU);
// 半径因子 = planet_radius_factor ± 0.25 (per-planet 散布).
// orbit_position 用 r = radius_factor · disk_outer, 所以这个因子是
// "盘外缘的倍数": 1.0 = 紧贴盘外, 1.5 = 盘外 50%. 默认滑条 1.3 让行星
// 稳定在盘外, 散布保留 per-planet 半径多样性. 钳到 1.05 以上保证始终盘外.
let radius_factor = (params.planet_radius_factor + rng.gen_range(-0.25..0.25)).max(1.05);
// 颜色: 暖色行星 (橙/红/黄系), 避开蓝色 (易与背景星混淆)
let hue = rng.gen_range(0.02..0.13);
let color = hsv_to_rgb(hue, rng.gen_range(0.5..0.9), rng.gen_range(0.7..1.0));
commands.spawn((
OrbitParams {
radius_factor,
inclination,
longitude_of_node: longitude,
phase,
},
Planet {
center: Vec3::ZERO, // 首帧由 orbit_system 填
radius: rng.gen_range(0.8..1.6),
color,
emissive: false, emissive: false,
}); },
));
}
}
/// HSV → RGB (h,s,v ∈ [0,1]). 行星颜色用.
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> Vec3 {
let i = (h * 6.0).floor() as i32 % 6;
let f = h * 6.0 - (h * 6.0).floor();
let p = v * (1.0 - s);
let q = v * (1.0 - f * s);
let t = v * (1.0 - (1.0 - f) * s);
match i {
0 => Vec3::new(v, t, p),
1 => Vec3::new(q, v, p),
2 => Vec3::new(p, v, t),
3 => Vec3::new(p, q, v),
4 => Vec3::new(t, p, v),
_ => Vec3::new(v, p, q),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn orbit_position_radius_is_preserved() {
// 不管时间/相位, 行星到原点距离应恒等于 r = radius_factor · disk_outer
let orbit = OrbitParams {
radius_factor: 1.3,
inclination: 0.7,
longitude_of_node: 1.3,
phase: 0.5,
};
let chi = 0.8;
let disk_outer = 25.0;
let expected_r = 1.3 * disk_outer;
for t in [0.0_f32, 1.0, 5.5, 100.0] {
let pos = orbit_position(&orbit, t, chi, disk_outer);
let dist = pos.length();
assert!(
(dist - expected_r).abs() < 1e-4,
"t={}: dist {} != r {}",
t, dist, expected_r
);
}
}
#[test]
fn orbit_position_zero_spin_keeps_equatorial_plane() {
// χ=0: 无进动, 倾角 0 (赤道面) 的行星应严格在 y=0 平面
let orbit = OrbitParams {
radius_factor: 1.3,
inclination: 0.0,
longitude_of_node: 0.0,
phase: 0.0,
};
for t in [0.0_f32, 1.0, 10.0] {
let pos = orbit_position(&orbit, t, 0.0, 25.0);
assert!(pos.y.abs() < 1e-5, "χ=0 equatorial orbit should stay in y=0 plane at t={}", t);
}
}
#[test]
fn orbit_position_advances_with_time() {
// 不同时间应给不同位置 (除非极端巧合)
let orbit = OrbitParams {
radius_factor: 1.3,
inclination: 0.5,
longitude_of_node: 0.0,
phase: 0.0,
};
let p0 = orbit_position(&orbit, 0.0, 0.5, 25.0);
let p1 = orbit_position(&orbit, 1.0, 0.5, 25.0);
assert!((p0 - p1).length() > 0.01, "planet should move over time");
}
} }

View File

@ -6,6 +6,7 @@ pub fn ui_system(
mut params: ResMut<crate::params::BlackHoleParams>, mut params: ResMut<crate::params::BlackHoleParams>,
mut camera: ResMut<crate::camera::OrbitCamera>, mut camera: ResMut<crate::camera::OrbitCamera>,
mut wants: ResMut<crate::camera::WantsPointer>, mut wants: ResMut<crate::camera::WantsPointer>,
mut planet_dirty: ResMut<crate::scene::planets::PlanetSystemDirty>,
) { ) {
if let Ok(ctx) = contexts.ctx_mut() { if let Ok(ctx) = contexts.ctx_mut() {
egui::Window::new("Controls") egui::Window::new("Controls")
@ -53,6 +54,35 @@ pub fn ui_system(
egui::Slider::new(&mut params.disk_temp, 1000.0..=50000.0).text("Temperature (K)"), egui::Slider::new(&mut params.disk_temp, 1000.0..=50000.0).text("Temperature (K)"),
); );
}); });
egui::CollapsingHeader::new("Planets")
.default_open(false)
.show(ui, |ui| {
// Record state before edits; if seed/count/k/enabled change,
// flag dirty so spawn_planet_system regenerates next frame.
// (time_scale excluded: it only scales orbit_system's time,
// no respawn needed.)
let prev = (
params.planets_enabled,
params.planet_count_target,
params.planet_radius_factor,
params.planet_seed,
);
ui.checkbox(&mut params.planets_enabled, "Enable");
ui.add(egui::Slider::new(&mut params.planet_count_target, 0..=8).text("Count"));
ui.add(egui::Slider::new(&mut params.planet_radius_factor, 1.1..=2.0).text("Radius (× disk outer)"));
ui.label(format!("Orbit r = {:.2} (disk outer: {:.1})", params.planet_radius_factor * params.disk_outer, params.disk_outer));
ui.add(egui::Slider::new(&mut params.planet_seed, 0..=1000).text("Seed"));
ui.add(egui::Slider::new(&mut params.planet_time_scale, 1.0..=200.0).text("Time scale"));
let curr = (
params.planets_enabled,
params.planet_count_target,
params.planet_radius_factor,
params.planet_seed,
);
if curr != prev {
planet_dirty.0 = true;
}
});
egui::CollapsingHeader::new("Disk Turbulence") egui::CollapsingHeader::new("Disk Turbulence")
.default_open(true) .default_open(true)
.show(ui, |ui| { .show(ui, |ui| {
@ -83,7 +113,18 @@ pub fn ui_system(
}); });
egui::CollapsingHeader::new("Jets").show(ui, |ui| { egui::CollapsingHeader::new("Jets").show(ui, |ui| {
ui.checkbox(&mut params.jets_enabled, "Enabled"); 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")); // Mirror the shader's spin gate (sample_jets in black_hole.wgsl):
// jets are a spin-powered (Blandford-Znajek) outflow and render
// only for χ ≥ 0.05. When the user enables them at low spin,
// explain the no-op so the checkbox doesn't look broken.
let jets_renderable = params.spin >= 0.05;
if params.jets_enabled && !jets_renderable {
ui.label("Spin (χ) too low — jets need χ ≥ 0.05.");
}
ui.add_enabled(
params.jets_enabled && jets_renderable,
egui::Slider::new(&mut params.jets_strength, 0.0..=3.0).text("Strength"),
);
}); });
egui::CollapsingHeader::new("Renderer").show(ui, |ui| { 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.steps, 50..=600).text("Steps"));
@ -116,6 +157,22 @@ pub fn ui_system(
ui.add(egui::Slider::new(&mut params.exposure, 0.5..=3.0).text("Exposure")); ui.add(egui::Slider::new(&mut params.exposure, 0.5..=3.0).text("Exposure"));
ui.add(egui::Slider::new(&mut params.render_scale, 0.25..=1.0).text("Resolution scale")); ui.add(egui::Slider::new(&mut params.render_scale, 0.25..=1.0).text("Resolution scale"));
ui.checkbox(&mut params.star_aa, "Anti-aliased stars"); ui.checkbox(&mut params.star_aa, "Anti-aliased stars");
{
// Per-pixel supersampling: antialiases the higher-order
// lensed-image rings on the disk into a smooth gradient.
// Cost scales linearly with sample count (each sub-ray
// runs the full RK45 march).
use crate::params::AaQuality;
let mut a = params.aa_quality;
egui::ComboBox::from_label("Ring anti-alias")
.selected_text(format!("{:?} ({}×)", a, a.samples()))
.show_ui(ui, |ui| {
ui.selectable_value(&mut a, AaQuality::Off, "Off (1×)");
ui.selectable_value(&mut a, AaQuality::Low, "Low (2×)");
ui.selectable_value(&mut a, AaQuality::High, "High (4×)");
});
params.aa_quality = a;
}
ui.label("MSAA is decorative on a fullscreen shader (no geometry edges to sample)."); ui.label("MSAA is decorative on a fullscreen shader (no geometry edges to sample).");
}); });
}); });

View File

@ -17,5 +17,11 @@
data-cargo-features="bevy/webgpu" data-cargo-features="bevy/webgpu"
data-wasm-opt="z" data-wasm-opt="z"
/> />
<!-- Ship the WGSL shaders so Bevy's WasmAssetReader can fetch them at
runtime (it requests `assets/shaders/*.wgsl` over HTTP, matching the
default AssetPlugin file_path of "assets"). Without this the shaders
are absent from dist/ and every material loads a 404 fallback → grey
screen. -->
<link data-trunk rel="copy-dir" href="../assets" />
</body> </body>
</html> </html>