Merge branch 'feat/volumetric-disk'
Volumetric disk rendering: ridged multifractal noise + density/slab integration in the RK45 loop, with a Disk Turbulence UI panel. 13 commits.
This commit is contained in:
commit
f7eda338ab
210
.agents/skills/optimizing-rust-performance/SKILL.md
Normal file
210
.agents/skills/optimizing-rust-performance/SKILL.md
Normal file
@ -0,0 +1,210 @@
|
||||
---
|
||||
name: optimizing-rust-performance
|
||||
description: |
|
||||
审查、重构或编写 Rust 代码时主动识别性能瓶颈并应用优化模式。触发关键词:
|
||||
"optimize"、"perf"、"性能优化"、"加速"、"hot path"、"热点路径"、
|
||||
"make it faster"、"reduce allocation"、"zero-copy"、"零拷贝"、
|
||||
以及任何对 .rs 文件的 review/refactor 请求、或代码中出现 Vec::remove /
|
||||
clone / Vec<String> 在循环中 / String::from / filter 后 collect 等可疑模式时。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Edit
|
||||
- Grep
|
||||
- Glob
|
||||
metadata:
|
||||
trigger: Rust 代码性能审查 / 重构 / 热点路径优化 / 减少 heap 分配
|
||||
source: 基于 Rust 性能优化通用最佳实践 + 真实 baseline 测试提炼
|
||||
---
|
||||
|
||||
# Rust 性能优化(Performance Optimization)
|
||||
|
||||
编写或审查 Rust 代码时,**主动**识别性能瓶颈并应用优化模式。核心原则:
|
||||
**按固定优先级排序**,**判断触发条件即应用**,**量化收益**,**用 profiling 验证**。
|
||||
|
||||
## 优化心智模型(必须按此顺序判断)
|
||||
|
||||
```
|
||||
优化不是"想到什么改什么"。永远按此优先级评估:
|
||||
|
||||
1. 算法与复杂度 — O(n)→O(1)、O(n²)→O(n log n)。最大收益,优先看。
|
||||
2. 内存分配 — 减少 heap 分配,栈优先,能零拷贝就零拷贝。
|
||||
3. 数据布局 — cache locality、字段顺序、对齐、SoA vs AoS。
|
||||
4. 并发 — 减少锁竞争,考虑 lock-free / 无锁结构。
|
||||
|
||||
低层级优化在高层级问题存在时收益微乎其微。先看复杂度,再看分配。
|
||||
```
|
||||
|
||||
## 铁律:判断到触发条件就应用,不要只"提及"
|
||||
|
||||
> **发现反模式 → 直接改。不要说"可以考虑用 X"然后不改。**
|
||||
>
|
||||
> 如果触发条件命中,你必须(a)在改进后的代码里实际应用该模式,
|
||||
> (b)说明改了什么、为什么快。把"应该用 Cow"挂在嘴边却不写进代码,
|
||||
> 等于没优化。
|
||||
|
||||
## 核心模式(触发条件 → 动作)
|
||||
|
||||
### 模式 1:集合删除(顺序无关时)
|
||||
|
||||
**触发**:代码用 `.remove(index)` 删除 `Vec` 元素,且调用方不关心顺序。
|
||||
|
||||
**动作**:改用 `.swap_remove(index)` —— O(1),把末尾元素换到被删位置,无内存搬移。
|
||||
|
||||
```rust
|
||||
// ❌ O(n):删中间元素要把后面所有元素左移
|
||||
self.items.remove(index);
|
||||
|
||||
// ✅ O(1):末尾元素换位,不保证顺序
|
||||
self.items.swap_remove(index);
|
||||
```
|
||||
|
||||
**收益**:O(n) → O(1) 的内存搬移。
|
||||
|
||||
### 模式 2:集合过滤
|
||||
|
||||
**触发**:代码用 `for` 循环 + 条件 `push` 到新 `Vec`,或 `filter()` 后 `collect()` 再赋值。
|
||||
|
||||
**动作**:用 `.retain()` / `.retain_mut()` 原地 O(n) 过滤,避免第二个 `Vec` 分配和逐元素 `clone`。
|
||||
|
||||
```rust
|
||||
// ❌ 分配第二个 Vec + 每个元素 clone
|
||||
let mut kept = Vec::new();
|
||||
for item in &self.items {
|
||||
if item.in_stock { kept.push(item.clone()); }
|
||||
}
|
||||
self.items = kept;
|
||||
|
||||
// ✅ 原地过滤,零额外分配,零 clone
|
||||
self.items.retain(|item| item.in_stock);
|
||||
```
|
||||
|
||||
**收益**:省一次堆分配 + N 次 clone。
|
||||
|
||||
### 模式 3:所有权转移,避免 clone
|
||||
|
||||
**触发**:从 `&mut T` / `Option<T>` 取值时用了 `.clone()`,或想"取出旧值替换为默认"。
|
||||
|
||||
**动作**:
|
||||
|
||||
- `Option<T>` 取值 → `option.take()`(取出并留 `None`,无 clone)
|
||||
- `T: Default` 取出旧值 → `std::mem::take(dest)`(旧值返回,`dest` 变默认)
|
||||
- 交换值 → `std::mem::replace(dest, src)`(无深拷贝)
|
||||
|
||||
```rust
|
||||
// ❌ clone 后原值仍在,字段没被清空(还可能是 bug)
|
||||
fn clear_promo(&mut self) -> Option<PromoCode> {
|
||||
self.active_promo.clone()
|
||||
}
|
||||
|
||||
// ✅ take:取出所有权,字段变 None,无 clone
|
||||
fn clear_promo(&mut self) -> Option<PromoCode> {
|
||||
self.active_promo.take()
|
||||
}
|
||||
```
|
||||
|
||||
**收益**:消除一次堆分配(clone 的 `String`/`Vec` 等)。
|
||||
|
||||
### 模式 4:栈优先(短生命周期小集合)
|
||||
|
||||
**触发**:循环内频繁分配小而短命的 `Vec`/`String`(如"通常 2-3 个元素"的辅助返回值)。
|
||||
|
||||
**动作**:用 `SmallVec`/`TinyVec` 把小负载(<4 或 <8 元素)放栈上,溢出才上堆。
|
||||
|
||||
```rust
|
||||
// ❌ 每次 tags_for 都堆分配一个 Vec(通常只有 2-3 个 tag)
|
||||
fn tags_for(&self, id: u32) -> Vec<String> {
|
||||
vec![format!("tag-{id}-a"), format!("tag-{id}-b")]
|
||||
}
|
||||
|
||||
// ✅ SmallVec:2-3 个元素常驻栈,无堆分配
|
||||
fn tags_for(&self, id: u32) -> SmallVec<[String; 4]> {
|
||||
smallvec![format!("tag-{id}-a"), format!("tag-{id}-b")]
|
||||
}
|
||||
```
|
||||
|
||||
**收益**:栈分配 vs 堆分配——省掉 malloc/free 和可能的 cache miss。
|
||||
|
||||
### 模式 5:Copy-on-Write 延迟分配
|
||||
|
||||
**触发**:字符串/切片处理大多只读,偶尔才需要修改或拥有所有权;返回类型是 `String` 但其实常无需分配。
|
||||
|
||||
**动作**:用 `std::borrow::Cow` 封装——只读时零分配借用,真正写时才 clone。
|
||||
|
||||
```rust
|
||||
use std::borrow::Cow;
|
||||
|
||||
// ❌ 永远堆分配,即使输入已经全是小写无需改动
|
||||
fn normalize(name: &str) -> String {
|
||||
name.trim().to_lowercase()
|
||||
}
|
||||
|
||||
// ✅ 只在确实需要改写(trim 砍掉字符 / 含大写)时才分配;
|
||||
// 纯小写无空白的输入零分配,原样借用返回
|
||||
fn normalize<'a>(name: &'a str) -> Cow<'a, str> {
|
||||
let trimmed = name.trim();
|
||||
let needs_lower = trimmed.chars().any(|c| c.is_ascii_uppercase());
|
||||
let needs_trim = trimmed.len() != name.len();
|
||||
if !needs_lower && !needs_trim {
|
||||
Cow::Borrowed(trimmed)
|
||||
} else {
|
||||
Cow::Owned(trimmed.to_lowercase())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**收益**:读路径零分配;分配延迟到真正写时才发生。
|
||||
|
||||
## 热点路径额外模式
|
||||
|
||||
热点路径(每秒百万次调用的解析器/词法器/序列化)适用更激进的优化:
|
||||
|
||||
- **切片代替逐字符 collect**:从原 `&str`/`&[u8]` 用范围切片 `&input[start..pos]` 取词素,而不是把 `char` push 进 `Vec<char>` 再 `collect::<String>()`(省双重分配 + 双重拷贝)。
|
||||
- **借用切片匹配后再拥有化**:先在借用的 `&str` 上 match 关键字(零分配),仅非关键字才 `.to_string()`。
|
||||
- **ASCII 谓词代替 Unicode 谓词**:`is_ascii_digit()` / `is_ascii_alphabetic()` 代替 `is_digit(10)` / `is_alphanumeric()`(省 Unicode 表查找)——前提是你确实只需 ASCII。
|
||||
|
||||
## 回应规范(应用优化时必须做到)
|
||||
|
||||
1. **先分析瓶颈**:说明当前问题("这会导致 O(n) 内存搬移" / "这触发一次堆分配")。
|
||||
2. **给出优化代码**:干净、生产可用的 Rust,实际应用模式(不只是提及)。
|
||||
3. **量化理论收益**:说明*为什么*更快(堆 vs 栈、O(n) vs O(1)、cache locality)。
|
||||
4. **提示 profiling**:提醒用 `Criterion`(微基准)或 `Flamegraph` 验证,不要盲信理论。
|
||||
|
||||
**量化收益示例**(写到改动说明里):
|
||||
|
||||
| 改动 | 复杂度 / 分配变化 |
|
||||
| -------------------------- | --------------------------- |
|
||||
| `remove` → `swap_remove` | O(n) → O(1) 内存搬移 |
|
||||
| 循环+clone → `retain` | 省 1 次堆分配 + N 次 clone |
|
||||
| `clone` → `take` | 省 1 次堆分配 |
|
||||
| `Vec` → `SmallVec<[T; 4]>` | 堆分配 → 栈分配(小负载时) |
|
||||
| `String` → `Cow<str>` | 读路径:1 次分配 → 0 次 |
|
||||
|
||||
## 自检清单(提交 Rust 改动前逐条过)
|
||||
|
||||
- [ ] 任何 `Vec::remove` 在顺序无关处是否已换 `swap_remove`?
|
||||
- [ ] 过滤操作是否用了 `retain` / `retain_mut` 而非 collect?
|
||||
- [ ] 取值是否用了 `take` / `mem::take` / `mem::replace` 而非 `clone`?
|
||||
- [ ] 循环内小而短命的 `Vec`/`String` 是否考虑过 `SmallVec`?
|
||||
- [ ] 大多只读、偶尔写的字符串/切片是否考虑过 `Cow`?
|
||||
- [ ] 热点路径是否切片取词素而非逐字符 collect?
|
||||
- [ ] 每条改动是否说明了复杂度/分配收益,并提示 profiling 验证?
|
||||
- [ ] 引入新依赖(`smallvec` 等)或改动公开返回类型前,确认收益配得上成本。
|
||||
|
||||
## 注意(应用前必读 —— 防止过度优化)
|
||||
|
||||
优化有成本。应用下列模式前先权衡,**别为了用模式而用**:
|
||||
|
||||
- **先正确再优化**:`#[inline]`、手动 SIMD、`unsafe` 等通常不是首选;先确认算法复杂度和分配已最优。
|
||||
- **新增依赖有代价**:`SmallVec`/`TinyVec` 要加 crate 依赖、增加编译时间。只为"通常 2-3 个元素"就在非热点代码里引入依赖,多半不划算——优先考虑能否直接用数组 `[T; N]` / slice / 返回迭代器。
|
||||
- **Cow/SmallVec 改返回类型是 API 传染**:把 `-> String` 改成 `-> Cow<'_, str>` 会把生命周期参数传染给所有调用方;把 `Token` 改成 `Token<'src>` 是全 crate 的 API 变更。改公开签名前确认收益配得上波及面;内部 `fn` 则无所谓。
|
||||
- **量入为出**:Cow/SmallVec 引入复杂度,确认该路径真的是热点或高频才上。非热点的小函数直接 `Vec`/`String` 更清晰。
|
||||
- **profile 验证**:理论收益不等于实测收益。用 `Criterion` 做微基准,`Flamegraph` 找真热点,别盲改。
|
||||
|
||||
**决策速查**:
|
||||
|
||||
| 情况 | 推荐 |
|
||||
| -------------------- | ------------------------------------------- |
|
||||
| 非热点、简单工具函数 | 直接 `Vec`/`String`/`clone`,别上模式 |
|
||||
| 公开 API 返回类型 | 慎用 `Cow<'_, str>`/生命周期——会传染调用方 |
|
||||
| 真热点 + 内部函数 | 放心上 Cow/SmallVec/借用切片 |
|
||||
| 新增依赖 | 先看能否用 `[T; N]` / 迭代器 / 现有类型替代 |
|
||||
@ -39,6 +39,15 @@ struct BlackHoleUniforms {
|
||||
bloom_strength: f32,
|
||||
exposure: f32,
|
||||
_pad5: f32,
|
||||
disk_half_thickness: f32,
|
||||
filament_freq: f32,
|
||||
filament_sharpness: f32,
|
||||
density_freq: f32,
|
||||
density_strength: f32,
|
||||
arm_count: f32,
|
||||
arm_tightness: f32,
|
||||
arm_strength: f32,
|
||||
disk_quality: u32,
|
||||
};
|
||||
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> uniforms: BlackHoleUniforms;
|
||||
@ -206,10 +215,15 @@ fn value_noise3(p: vec3<f32>) -> f32 {
|
||||
}
|
||||
|
||||
fn fbm3(p: vec3<f32>, octaves: u32) -> f32 {
|
||||
// MAX_OCTAVES-with-break: the conservative WebGPU form for a runtime-
|
||||
// chosen octave count. Older drivers can miscompile non-constant loop
|
||||
// bounds; this branch is the first to pass dynamic octaves here.
|
||||
const MAX_OCTAVES = 6u;
|
||||
var sum = 0.0;
|
||||
var amp = 0.5;
|
||||
var freq = 1.0;
|
||||
for (var i: u32 = 0u; i < octaves; i = i + 1u) {
|
||||
for (var i: u32 = 0u; i < MAX_OCTAVES; i = i + 1u) {
|
||||
if (i >= octaves) { break; }
|
||||
sum = sum + amp * value_noise3(p * freq);
|
||||
freq = freq * 2.0;
|
||||
amp = amp * 0.5;
|
||||
@ -217,16 +231,75 @@ fn fbm3(p: vec3<f32>, octaves: u32) -> f32 {
|
||||
return sum;
|
||||
}
|
||||
|
||||
// Ridged multifractal noise: 1 - |2n-1| turns value-noise gradients into
|
||||
// sharp ridges (peak where n=0.5, zero at n=0 and n=1). Raising to
|
||||
// `sharpness` thins the ridges into filaments. MAX_OCTAVES-with-break is
|
||||
// the conservative WebGPU form for a runtime-chosen octave count.
|
||||
fn ridged_fbm(p: vec3<f32>, octaves: u32, sharpness: f32) -> f32 {
|
||||
const MAX_OCTAVES = 6u;
|
||||
var sum = 0.0;
|
||||
var amp = 0.5;
|
||||
var freq = 1.0;
|
||||
for (var i: u32 = 0u; i < MAX_OCTAVES; i = i + 1u) {
|
||||
if (i >= octaves) { break; }
|
||||
let n = value_noise3(p * freq);
|
||||
let ridge = 1.0 - abs(2.0 * n - 1.0);
|
||||
sum = sum + amp * pow(ridge, sharpness);
|
||||
freq = freq * 2.0;
|
||||
amp = amp * 0.5;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
fn disk_noise(pos: vec3<f32>, t: f32) -> f32 {
|
||||
let warp = fbm3(pos * 0.8 + vec3<f32>(0.0, 0.0, t * 0.1), 3u);
|
||||
let n = fbm3(pos * 2.0 + warp * 1.5 + vec3<f32>(0.0, 0.0, t * 0.3), 4u);
|
||||
return n;
|
||||
}
|
||||
|
||||
fn disk_color(pos: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
|
||||
let r = length(vec2<f32>(pos.x, pos.z));
|
||||
let phi = atan2(pos.z, pos.x);
|
||||
// Result of a disk color query: emitted radiance + opacity contribution.
|
||||
// Both the volumetric and flat paths return this struct so the main loop
|
||||
// can treat them uniformly.
|
||||
struct DiskSample {
|
||||
color: vec3<f32>,
|
||||
density: f32,
|
||||
}
|
||||
|
||||
// Radial temperature gradient: white-hot inner → deep-orange outer.
|
||||
fn temperature_color(t: f32) -> vec3<f32> {
|
||||
return mix(vec3<f32>(1.0, 0.95, 0.85), vec3<f32>(1.0, 0.45, 0.12), clamp(t, 0.0, 1.0));
|
||||
}
|
||||
|
||||
// Radial brightness falloff (∝ 1/r² from the inner edge).
|
||||
fn radial_falloff(r: f32, inner: f32) -> f32 {
|
||||
return 1.0 / pow(r / inner, 2.0);
|
||||
}
|
||||
|
||||
// Cylindrical radius in the disk plane.
|
||||
fn r_of(pos: vec3<f32>) -> f32 {
|
||||
return length(vec2<f32>(pos.x, pos.z));
|
||||
}
|
||||
|
||||
// Relativistic Doppler beaming. `dir` is the ray direction (disk-local).
|
||||
fn apply_doppler(col: vec3<f32>, pos: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
|
||||
let phi = atan2(pos.z, pos.x);
|
||||
let v_orbital = sqrt(uniforms.rs / (2.0 * r_of(pos)));
|
||||
let tangent = normalize(vec3<f32>(-sin(phi), 0.0, cos(phi)));
|
||||
let vdotn = dot(tangent * v_orbital, -dir);
|
||||
let gamma = 1.0 / sqrt(max(1.0 - v_orbital * v_orbital, 1e-4));
|
||||
if (uniforms.doppler_enabled == 0u) {
|
||||
return col;
|
||||
}
|
||||
let delta = 1.0 / (gamma * (1.0 - vdotn));
|
||||
let doppler = pow(delta, 3.0) * uniforms.doppler_strength;
|
||||
return col * doppler;
|
||||
}
|
||||
|
||||
// Off-tier fallback: zero-thickness disk, single sample, fixed alpha.
|
||||
// Preserves the exact pre-volumetric appearance. Returns DiskSample so the
|
||||
// main loop dispatches both paths uniformly.
|
||||
fn disk_color_flat(pos: vec3<f32>, dir: vec3<f32>) -> DiskSample {
|
||||
let r = r_of(pos);
|
||||
let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5);
|
||||
// Domain-warped FBM for feathered/smoky gas texture. The Keplerian shear
|
||||
// (rot ∝ 1/r^1.5) is folded into the noise flow term so inner radii flow
|
||||
@ -234,24 +307,65 @@ fn disk_color(pos: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
|
||||
let noise = disk_noise(vec3<f32>(pos.x * 0.3, pos.z * 0.3, rot), uniforms.time);
|
||||
|
||||
let t = (r - uniforms.disk_inner) / (uniforms.disk_outer - uniforms.disk_inner);
|
||||
let tcol = mix(vec3<f32>(1.0, 0.95, 0.85), vec3<f32>(1.0, 0.45, 0.12), clamp(t, 0.0, 1.0));
|
||||
let tcol = temperature_color(t);
|
||||
let falloff = radial_falloff(r, uniforms.disk_inner);
|
||||
|
||||
let falloff = 1.0 / pow(r / uniforms.disk_inner, 2.0);
|
||||
var col = tcol * (0.6 + 0.4 * noise) * falloff * uniforms.disk_brightness;
|
||||
col = apply_doppler(col, pos, dir);
|
||||
|
||||
var col = tcol * (0.6 + 0.4 * noise) * falloff;
|
||||
|
||||
let v_orbital = sqrt(uniforms.rs / (2.0 * r));
|
||||
let tangent = normalize(vec3<f32>(-sin(phi), 0.0, cos(phi)));
|
||||
let vdotn = dot(tangent * v_orbital, -dir);
|
||||
let gamma = 1.0 / sqrt(max(1.0 - v_orbital * v_orbital, 1e-4));
|
||||
var doppler = 1.0;
|
||||
if (uniforms.doppler_enabled != 0u) {
|
||||
let delta = 1.0 / (gamma * (1.0 - vdotn));
|
||||
doppler = pow(delta, 3.0) * uniforms.doppler_strength;
|
||||
return DiskSample(vec3<f32>(col), 0.85);
|
||||
}
|
||||
col *= doppler;
|
||||
|
||||
return col * uniforms.disk_brightness;
|
||||
// Volumetric disk color. Noise is sampled in POLAR coordinates (r_norm,
|
||||
// phi·freq, height) so turbulence flows tangentially — the correct pattern
|
||||
// for a rotating fluid. Three layers multiply: ridged filaments (brightness),
|
||||
// soft density clumping, logarithmic-spiral arms advected by Keplerian shear.
|
||||
fn disk_color_volumetric(pos: vec3<f32>, dir: vec3<f32>) -> DiskSample {
|
||||
let r = r_of(pos);
|
||||
let phi = atan2(pos.z, pos.x);
|
||||
let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5);
|
||||
|
||||
// Octave triplet from the quality tier.
|
||||
let q = uniforms.disk_quality;
|
||||
var filament_octaves = 5u; var density_octaves = 4u; var warp_octaves = 3u;
|
||||
if (q == 1u) { filament_octaves = 3u; density_octaves = 2u; warp_octaves = 2u; }
|
||||
else if (q == 2u) { filament_octaves = 4u; density_octaves = 3u; warp_octaves = 3u; }
|
||||
// q == 3u keeps the High defaults above; q == 0u is never passed here.
|
||||
|
||||
// Polar sample coordinate: (r normalized, angle × freq + Keplerian flow,
|
||||
// height within slab). The flow term advects the noise so inner radii
|
||||
// (faster rotation) drift ahead of outer radii — differential rotation.
|
||||
let r_norm = r / uniforms.disk_inner;
|
||||
let h = pos.y / max(uniforms.disk_half_thickness, 1e-3);
|
||||
let sp = vec3<f32>(r_norm, phi * 2.5 + rot, h);
|
||||
|
||||
// Domain warp in polar space: distorts sample coords so filaments bend.
|
||||
let warp = fbm3(sp * 0.8, warp_octaves);
|
||||
|
||||
// Layer 1: ridged bright filaments (polar-sampled → tangential streaks).
|
||||
let filament = ridged_fbm(sp * uniforms.filament_freq + warp * 1.5,
|
||||
filament_octaves, uniforms.filament_sharpness);
|
||||
|
||||
// Layer 2: density clumping (soft ramp, no hard cut → avoids patchiness).
|
||||
let density_noise = fbm3(sp * uniforms.density_freq + warp, density_octaves);
|
||||
let base_density = (0.35 + 0.65 * density_noise) * uniforms.density_strength;
|
||||
|
||||
// Layer 3: logarithmic-spiral arm modulation, advected by Keplerian shear.
|
||||
let arm_phase = phi * uniforms.arm_count + log(max(r, 0.1)) * uniforms.arm_tightness - rot;
|
||||
let arm = 0.5 + 0.5 * cos(arm_phase);
|
||||
let arm_mod = mix(1.0, pow(arm, 2.0), uniforms.arm_strength);
|
||||
|
||||
let total_density = base_density * arm_mod;
|
||||
let brightness = 0.5 + filament;
|
||||
|
||||
let t = (r - uniforms.disk_inner) / (uniforms.disk_outer - uniforms.disk_inner);
|
||||
let tcol = temperature_color(t);
|
||||
let falloff = radial_falloff(r, uniforms.disk_inner);
|
||||
|
||||
var col = tcol * brightness * falloff * uniforms.disk_brightness;
|
||||
col = apply_doppler(col, pos, dir);
|
||||
|
||||
return DiskSample(vec3<f32>(col), total_density);
|
||||
}
|
||||
|
||||
// --- planets ---
|
||||
@ -444,13 +558,52 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
break;
|
||||
}
|
||||
|
||||
// --- volumetric disk ---
|
||||
if (uniforms.disk_quality == 0u) {
|
||||
// Off tier: zero-thickness single midplane sample, fixed alpha.
|
||||
if (disk_hit(prev, new_pos)) {
|
||||
let ty = prev.y / (prev.y - new_pos.y);
|
||||
let hit = mix(prev, new_pos, vec3<f32>(ty));
|
||||
let dc = disk_color(hit, new_dir);
|
||||
let a = 0.85;
|
||||
accum_color += (1.0 - accum_alpha) * dc * a;
|
||||
accum_alpha += (1.0 - accum_alpha) * a;
|
||||
let s = disk_color_flat(hit, new_dir);
|
||||
accum_color += (1.0 - accum_alpha) * s.color * s.density;
|
||||
accum_alpha += (1.0 - accum_alpha) * s.density;
|
||||
if (accum_alpha > 0.99) { break; }
|
||||
}
|
||||
} else {
|
||||
// Volumetric tier. Single unified path: clip THIS step's segment
|
||||
// to the disk slab |y|<=half_thickness, sample once at the clipped
|
||||
// segment's midpoint, accumulate × the in-slab segment length.
|
||||
// The previous design used two overlapping paths (in-slab step +
|
||||
// at-plane edge-capture) with inconsistent angle-dependent weights
|
||||
// (step_len vs thickness_proj) that produced radial spokes.
|
||||
let H = uniforms.disk_half_thickness;
|
||||
let dy = new_pos.y - prev.y;
|
||||
var seg_t0 = 0.0;
|
||||
var seg_t1 = 1.0;
|
||||
var has_slab_seg = false;
|
||||
if (abs(dy) < 1e-6) {
|
||||
// Step is parallel to the slab plane: in-slab iff prev is in-slab.
|
||||
has_slab_seg = abs(prev.y) <= H;
|
||||
} else {
|
||||
// Clip the parametric line prev+t*(new_pos-prev) to |y|<=H.
|
||||
let ta = (H - prev.y) / dy;
|
||||
let tb = (-H - prev.y) / dy;
|
||||
seg_t0 = clamp(min(ta, tb), 0.0, 1.0);
|
||||
seg_t1 = clamp(max(ta, tb), 0.0, 1.0);
|
||||
has_slab_seg = seg_t1 > seg_t0;
|
||||
}
|
||||
|
||||
if (has_slab_seg) {
|
||||
let mid_t = (seg_t0 + seg_t1) * 0.5;
|
||||
let mid = mix(prev, new_pos, vec3<f32>(mid_t));
|
||||
let mid_r = r_of(mid);
|
||||
if (mid_r >= uniforms.disk_inner && mid_r <= uniforms.disk_outer) {
|
||||
let s = disk_color_volumetric(mid, new_dir);
|
||||
let seg_len = (seg_t1 - seg_t0) * length(new_pos - prev);
|
||||
accum_color += (1.0 - accum_alpha) * s.color * s.density * seg_len;
|
||||
accum_alpha += (1.0 - accum_alpha) * s.density * seg_len;
|
||||
}
|
||||
}
|
||||
if (accum_alpha > 0.99) { break; }
|
||||
}
|
||||
|
||||
|
||||
@ -67,6 +67,12 @@
|
||||
"skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md",
|
||||
"computedHash": "8bf292143ca93b00276a0de0fc5f84f2381f4690c5b0d32e99fa283a072f392f"
|
||||
},
|
||||
"optimizing-rust-performance": {
|
||||
"source": "DefectingCat/optimizing-rust-performance",
|
||||
"sourceType": "github",
|
||||
"skillPath": "SKILL.md",
|
||||
"computedHash": "96bf0221d116e617055f5dc038bb33d92dd61b7808d804f92da78d32c01e6934"
|
||||
},
|
||||
"prototype": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
|
||||
@ -16,11 +16,12 @@ pub struct OrbitCamera {
|
||||
impl Default for OrbitCamera {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
yaw: 0.0,
|
||||
// ~0.7 rad (40°) is a comfortable 3/4 view of the disk. The basis()
|
||||
// no longer has a gimbal pole, so any pitch is safe; this is just a
|
||||
// nice default angle, not a pole-avoidance choice.
|
||||
pitch: 0.7,
|
||||
yaw: -1.065,
|
||||
// ~0.335 rad (19°) below the disk plane. The lensed far side of the
|
||||
// disk then arcs over the top of the shadow — the iconic framing. The
|
||||
// basis() has no gimbal pole, so any pitch is safe; this is just a nice
|
||||
// default angle, not a pole-avoidance choice.
|
||||
pitch: -0.335,
|
||||
distance: 30.0,
|
||||
fov: 1.0, // radians
|
||||
}
|
||||
|
||||
@ -21,6 +21,41 @@ impl BloomQuality {
|
||||
}
|
||||
}
|
||||
|
||||
/// Disk volumetric rendering quality. Gates noise octave counts.
|
||||
#[allow(dead_code)] // consumed starting Task 7 (tier dispatch) + Task 8 (egui)
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||
pub enum DiskQuality {
|
||||
Off, // flat zero-thickness fallback (current appearance)
|
||||
Low, // 3/2/2 octaves — web default
|
||||
Medium, // 4/3/3 octaves
|
||||
#[default]
|
||||
High, // 5/4/3 octaves — desktop default
|
||||
}
|
||||
|
||||
impl DiskQuality {
|
||||
/// Returns (filament_octaves, density_octaves, warp_octaves).
|
||||
/// Off returns zeros; the shader dispatches to the flat path instead.
|
||||
#[allow(dead_code)] // read in Task 7's tier dispatch
|
||||
pub fn octaves(self) -> (u32, u32, u32) {
|
||||
match self {
|
||||
DiskQuality::Off => (0, 0, 0),
|
||||
DiskQuality::Low => (3, 2, 2),
|
||||
DiskQuality::Medium => (4, 3, 3),
|
||||
DiskQuality::High => (5, 4, 3),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tier as a u32 for the WGSL uniform selector.
|
||||
pub fn as_u32(self) -> u32 {
|
||||
match self {
|
||||
DiskQuality::Off => 0,
|
||||
DiskQuality::Low => 1,
|
||||
DiskQuality::Medium => 2,
|
||||
DiskQuality::High => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All tunable black-hole parameters. Edited by the egui panel (Task 17),
|
||||
/// mirrored into BlackHoleUniforms each frame (Task 7).
|
||||
#[derive(Resource, Clone)]
|
||||
@ -54,6 +89,16 @@ pub struct BlackHoleParams {
|
||||
pub bloom_strength: f32,
|
||||
pub exposure: f32,
|
||||
pub bloom_quality: BloomQuality,
|
||||
// Disk turbulence (Phase 3.1: volumetric disk)
|
||||
pub disk_half_thickness: f32,
|
||||
pub filament_freq: f32,
|
||||
pub filament_sharpness: f32,
|
||||
pub density_freq: f32,
|
||||
pub density_strength: f32,
|
||||
pub arm_count: f32,
|
||||
pub arm_tightness: f32,
|
||||
pub arm_strength: f32,
|
||||
pub disk_quality: DiskQuality,
|
||||
}
|
||||
|
||||
impl Default for BlackHoleParams {
|
||||
@ -80,6 +125,15 @@ impl Default for BlackHoleParams {
|
||||
bloom_strength: 0.8,
|
||||
exposure: 1.0,
|
||||
bloom_quality: if cfg!(target_arch = "wasm32") { BloomQuality::Low } else { BloomQuality::High },
|
||||
disk_half_thickness: if cfg!(target_arch = "wasm32") { 0.2 } else { 0.3 },
|
||||
filament_freq: 1.0,
|
||||
filament_sharpness: 2.0,
|
||||
density_freq: 0.8,
|
||||
density_strength: 1.0,
|
||||
arm_count: 2.0,
|
||||
arm_tightness: 2.0,
|
||||
arm_strength: 0.5,
|
||||
disk_quality: if cfg!(target_arch = "wasm32") { DiskQuality::Low } else { DiskQuality::High },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,6 +43,16 @@ pub struct BlackHoleUniforms {
|
||||
pub bloom_strength: f32,
|
||||
pub exposure: f32,
|
||||
pub _pad5: f32,
|
||||
// Disk volumetric (Phase 3.1)
|
||||
pub disk_half_thickness: f32,
|
||||
pub filament_freq: f32,
|
||||
pub filament_sharpness: f32,
|
||||
pub density_freq: f32,
|
||||
pub density_strength: f32,
|
||||
pub arm_count: f32,
|
||||
pub arm_tightness: f32,
|
||||
pub arm_strength: f32,
|
||||
pub disk_quality: u32,
|
||||
}
|
||||
|
||||
impl Default for BlackHoleUniforms {
|
||||
@ -79,6 +89,15 @@ impl Default for BlackHoleUniforms {
|
||||
bloom_strength: 0.8,
|
||||
exposure: 1.0,
|
||||
_pad5: 0.0,
|
||||
disk_half_thickness: 0.3,
|
||||
filament_freq: 1.0,
|
||||
filament_sharpness: 2.0,
|
||||
density_freq: 0.8,
|
||||
density_strength: 1.0,
|
||||
arm_count: 2.0,
|
||||
arm_tightness: 2.0,
|
||||
arm_strength: 0.5,
|
||||
disk_quality: 3, // High
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -620,6 +620,15 @@ fn mirror_params(
|
||||
u.bloom_threshold = params.bloom_threshold;
|
||||
u.bloom_strength = params.bloom_strength;
|
||||
u.exposure = params.exposure;
|
||||
u.disk_half_thickness = params.disk_half_thickness;
|
||||
u.filament_freq = params.filament_freq;
|
||||
u.filament_sharpness = params.filament_sharpness;
|
||||
u.density_freq = params.density_freq;
|
||||
u.density_strength = params.density_strength;
|
||||
u.arm_count = params.arm_count;
|
||||
u.arm_tightness = params.arm_tightness;
|
||||
u.arm_strength = params.arm_strength;
|
||||
u.disk_quality = params.disk_quality.as_u32();
|
||||
}
|
||||
// Update brightpass threshold (live-tunable).
|
||||
for (_, mat) in brightpass_materials.iter_mut() {
|
||||
|
||||
24
src/ui.rs
24
src/ui.rs
@ -36,6 +36,30 @@ pub fn ui_system(
|
||||
ui.add(egui::Slider::new(&mut params.disk_brightness, 0.0..=3.0).text("Brightness"));
|
||||
ui.add(egui::Slider::new(&mut params.disk_rotation_speed, 0.0..=3.0).text("Rotation speed"));
|
||||
});
|
||||
egui::CollapsingHeader::new("Disk Turbulence")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
use crate::params::DiskQuality;
|
||||
let mut q = params.disk_quality;
|
||||
egui::ComboBox::from_label("Disk quality")
|
||||
.selected_text(format!("{:?}", q))
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut q, DiskQuality::Off, "Off");
|
||||
ui.selectable_value(&mut q, DiskQuality::Low, "Low");
|
||||
ui.selectable_value(&mut q, DiskQuality::Medium, "Medium");
|
||||
ui.selectable_value(&mut q, DiskQuality::High, "High");
|
||||
});
|
||||
params.disk_quality = q;
|
||||
let on = q != DiskQuality::Off;
|
||||
ui.add_enabled(on, egui::Slider::new(&mut params.disk_half_thickness, 0.05..=1.0).text("Half thickness"));
|
||||
ui.add_enabled(on, egui::Slider::new(&mut params.filament_freq, 0.2..=4.0).text("Filament frequency"));
|
||||
ui.add_enabled(on, egui::Slider::new(&mut params.filament_sharpness, 1.0..=6.0).text("Filament sharpness"));
|
||||
ui.add_enabled(on, egui::Slider::new(&mut params.density_freq, 0.2..=3.0).text("Density frequency"));
|
||||
ui.add_enabled(on, egui::Slider::new(&mut params.density_strength, 0.0..=2.0).text("Density strength"));
|
||||
ui.add_enabled(on, egui::Slider::new(&mut params.arm_count, 0.0..=6.0).text("Arm count"));
|
||||
ui.add_enabled(on, egui::Slider::new(&mut params.arm_tightness, 0.0..=6.0).text("Arm tightness"));
|
||||
ui.add_enabled(on, egui::Slider::new(&mut params.arm_strength, 0.0..=1.0).text("Arm strength"));
|
||||
});
|
||||
egui::CollapsingHeader::new("Doppler").show(ui, |ui| {
|
||||
ui.checkbox(&mut params.doppler_enabled, "Enabled");
|
||||
ui.add_enabled(params.doppler_enabled, egui::Slider::new(&mut params.doppler_strength, 0.0..=3.0).text("Strength"));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user