Compare commits
23 Commits
359d2f876f
...
4c9097f82f
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c9097f82f | |||
| 40ff101b6d | |||
| b787a4c54c | |||
| dd4f8fb202 | |||
| ec69698530 | |||
| 78a222e685 | |||
| 474dfbe723 | |||
| 6ff5a05e74 | |||
| 891eadf28b | |||
| be4ff2cfcc | |||
| 76be513bb5 | |||
| 27e5e8b1f6 | |||
| 95968c0575 | |||
| dd1facae39 | |||
| 573e68e96d | |||
| 65a7611868 | |||
| 934468f7a0 | |||
| 38d4bd00f2 | |||
| 614bd1d88c | |||
| 2d764f5f19 | |||
| 5c0b1db01b | |||
| 00aa27175e | |||
| 30786eb253 |
299
.agents/skills/rust-advanced-performance/SKILL.md
Normal file
299
.agents/skills/rust-advanced-performance/SKILL.md
Normal 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:开启 LTO(Link-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 密集型热点的核心:让数据紧凑、让缓存行命中。
|
||||||
|
|
||||||
|
### 模式 3:SIMD / 自动向量化
|
||||||
|
|
||||||
|
**触发**:对超大数组做相同数学运算(矩阵、图像处理、加密、批量数值计算)。
|
||||||
|
|
||||||
|
**动作**:
|
||||||
|
|
||||||
|
- **优先靠编译器自动向量化**——写对齐友好的循环(连续切片迭代、避免循环内分支/调用)。
|
||||||
|
- 需要显式控制时用 `std::simd`(nightly)或 `wide`/`pulp` crate(stable)。
|
||||||
|
|
||||||
|
```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. 代码微调 ──> 先用基础 skill:swap_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** |
|
||||||
|
| 高争用锁是瓶颈 | 原子计数;通道用 crossbeam;map 用 dashmap |
|
||||||
|
| I/O 解析分配是热点 | serde 借用 `&'a str`(注意生命周期传染) |
|
||||||
72
AGENTS.md
72
AGENTS.md
@ -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 1–7 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
69
Cargo.lock
generated
@ -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"
|
||||||
|
|||||||
27
Cargo.toml
27
Cargo.toml
@ -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]
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
839
docs/superpowers/plans/2026-07-16-kerr-orbiting-planets.md
Normal file
839
docs/superpowers/plans/2026-07-16-kerr-orbiting-planets.md
Normal 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:** 让 5–8 颗行星沿 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 门控。
|
||||||
@ -0,0 +1,406 @@
|
|||||||
|
# Kerr 轨道行星系统(Phase 3.4)
|
||||||
|
|
||||||
|
**日期:** 2026-07-16
|
||||||
|
**状态:** 设计稿,待实现
|
||||||
|
**前置:** Phase 2(Kerr 光线积分)、Phase 3.1(体积盘)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让 5–8 颗行星沿 Kerr 时空的**圆轨道**绕黑洞旋转,轨道面因 Lense–Thirring 效应绕黑洞自旋轴进动。行星位置每帧由 CPU 用闭式解析公式计算,写入既有 storage buffer;渲染端 shader **不改动**。
|
||||||
|
|
||||||
|
这是 AGENTS.md 里档次 3 的方案:Kerr 圆轨 + 强场节点进动。路径 A(CPU 算位置 + 现有上传机制),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.5,a=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`。
|
||||||
|
|
||||||
|
## 随机生成
|
||||||
|
|
||||||
|
5–8 颗,全随机散布。用确定性 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-end,4 处锁步)
|
||||||
|
|
||||||
|
按 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, // 5–8
|
||||||
|
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 次 FLOP,GPU 上微秒级。
|
||||||
|
|
||||||
|
实测验证项:`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)
|
||||||
@ -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",
|
||||||
|
|||||||
24
src/main.rs
24
src/main.rs
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/ui.rs
59
src/ui.rs
@ -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).");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user