Compare commits
1
Commits
9555353404
..
BLA
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea2a23999d |
@@ -1,139 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
A deep-zoom fractal explorer (Rust + wgpu + egui + WGSL). It zooms past the
|
||||
~10¹³× limit of plain `f64` using **perturbation theory**: one high-precision
|
||||
reference orbit is computed on the CPU (arbitrary precision via `dashu-float`),
|
||||
and every pixel is rendered on the GPU as a cheap `f32` delta from it, with
|
||||
rebasing to avoid glitches. The `f32` GPU tier reaches roughly 10³⁰×. Runs
|
||||
natively (Vulkan/Metal/DX12) and in the browser (WebGPU only — WebGL2 can't do
|
||||
storage buffers, which the fragment shader needs for the reference orbit).
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
cargo run --release # native, run (release matters: fractal math is hot)
|
||||
cargo test # reference-orbit math, share-link round-trip, WGSL validation
|
||||
cargo test --test shader_valid # just the WGSL parse/validate tests (naga, no GPU needed)
|
||||
cargo clippy
|
||||
cargo fmt # rustfmt.toml just pins edition = "2024"
|
||||
```
|
||||
|
||||
Web build (WebGPU):
|
||||
|
||||
```sh
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo install wasm-bindgen-cli --version 0.2.128 # must match the wasm-bindgen crate version
|
||||
./build-web.sh # -> ./dist
|
||||
python3 -m http.server -d dist 8080
|
||||
```
|
||||
|
||||
Native CLI flags (`src/cli.rs`, applied in `FractalApp::apply_cli`): `--kind`,
|
||||
`--power`, `--julia re,im`, `--share <fragment>`,
|
||||
`--view re,im,half_height[,iterations]`, `--de`, `--buddhabrot`,
|
||||
`--buddha-palette`, `--export` (+ `--export-path out.png`). `--headless`
|
||||
(`src/headless.rs`) skips the window entirely: it builds the same view from
|
||||
the other flags, creates its own offscreen wgpu device, and renders straight
|
||||
to a PNG (`--width`/`--height`, default 1920×1080) — implies `--export`'s
|
||||
save behavior without needing a GPU-backed window/event loop. Not yet
|
||||
supported with `--buddhabrot`. Run `mandelbrot --help` for the full list.
|
||||
|
||||
There's no GPU in most sandboxes: `cargo check`/`cargo test --test shader_valid`
|
||||
are the fast, headless way to validate a change. `cargo test` also runs but
|
||||
doesn't touch the GPU — the reference-orbit tests are pure CPU math (see
|
||||
below), and `shader_valid` parses/validates WGSL with `naga` statically instead
|
||||
of creating a pipeline.
|
||||
|
||||
## Architecture
|
||||
|
||||
### The perturbation pipeline (the core mechanism, spans several files)
|
||||
|
||||
For a pixel at parameter `c = C_ref + dc`, its orbit is written as
|
||||
`y_n = X_n + e_n`, where `X_n` is the (shared, high-precision) reference orbit
|
||||
and `e_n` is a small `f32` delta. Whenever `|y_n| < |e_n|` (or the reference
|
||||
runs out), rebase: `e ← y_n − X_0`, restart the reference index at 0. This is
|
||||
what makes deep zoom cheap — one expensive high-precision orbit, then every
|
||||
pixel is a handful of `f32` complex multiplies.
|
||||
|
||||
- `src/view.rs` — `ViewState`; center is arbitrary-precision `FBig` (`Big`
|
||||
type alias), pixel scale stays `f64` (still in-range at 10³⁰×). Precision
|
||||
(bits) scales with zoom depth (`precision_for`).
|
||||
- `src/fractal/reference.rs` — `FractalKind` enum (Mandelbrot, Burning Ship,
|
||||
Tricorn, Multibrot, Celtic, Perpendicular, Buffalo, Phoenix, Lambda) and
|
||||
`compute_reference`/`compute_set_reference`: iterate the chosen formula at
|
||||
high precision on the CPU, emitting `Z_n` as `f32` pairs — that's the
|
||||
reference orbit the GPU perturbs from.
|
||||
- `src/shaders/mandelbrot.wgsl` — the perturbation fragment shader.
|
||||
`advance_delta(z, e)` is the per-kind delta step (`z` = reference point,
|
||||
`e` = current delta); the caller adds `step_add` (= `dc`) afterward — this
|
||||
relies on `c` being additive in every current kind's formula (a kind where
|
||||
it isn't, e.g. a rational map with `c` in a denominator, would need its own
|
||||
step function that consumes `dc` internally instead, plus extra per-step
|
||||
reference data since the orbit point alone wouldn't be enough to recover an
|
||||
exact delta). `fprime(z)` is the derivative used for distance-estimation
|
||||
(DE) shading; exact for holomorphic kinds, an approximation (`~2Z`) for the
|
||||
abs-based ones. A `KIND_*` constant here must match the matching
|
||||
`FractalKind` variant's discriminant exactly.
|
||||
- `src/fractal/renderer.rs` — `FractalRenderer` (wgpu pipelines, uniform +
|
||||
storage buffers, bind groups), `Uniforms` (repr(C) layout that must match
|
||||
the WGSL `Uniforms` struct field-for-field, including padding), and
|
||||
`FractalCallback` (the `egui_wgpu::CallbackTrait` impl: `prepare()` uploads
|
||||
changed buffers and decides whether to re-run the iterate pass, the cheap
|
||||
colourise pass, or just blit the cached texture). Also `ExportRender`, a
|
||||
self-contained tiled renderer used for PNG export off the UI thread.
|
||||
- `src/worker.rs` — native background thread for reference-orbit computation
|
||||
(coalesces bursts of requests so a fast drag doesn't compute every
|
||||
intermediate view). The wasm32 build computes inline instead (see the
|
||||
`#[cfg(target_arch = "wasm32")]` branch in `app.rs::ensure_reference`) —
|
||||
**any signature change to `compute_reference`/`compute_set_reference` or
|
||||
`RefRequest`/`RefResult` must be applied to both call sites.**
|
||||
- `src/app.rs` — `FractalApp` (the egui app + all UI). Key methods:
|
||||
`should_request`/`ensure_reference` (decide when the reference is stale and
|
||||
dispatch/collect it), `make_uniforms` (assemble the per-frame `Uniforms`),
|
||||
`tick_animations` (drives the "morph c/p/λ" and auto-zoom animations),
|
||||
`default_view_for` (per-kind starting view). `KINDS`, `JULIA_PRESETS`, and
|
||||
`SET_PRESETS` are sized as `[T; FractalKind::<last variant> as usize + 1]` —
|
||||
adding a new `FractalKind` means bumping all three (and adding an empty
|
||||
`&[]` slot to the two preset arrays if the kind has none).
|
||||
- `src/fractal/share.rs` — `ShareState`: encodes the full view (mode, kind,
|
||||
full-precision decimal center, zoom, iterations, per-kind constants,
|
||||
coloring) as a `#`-fragment URL for bookmarking/sharing deep-zoom locations.
|
||||
|
||||
### Adding a new `FractalKind`
|
||||
|
||||
Touches, in order: `reference.rs` (enum variant + CPU iteration formula, and a
|
||||
test comparing against a naive `f64` iteration), `mandelbrot.wgsl` (matching
|
||||
`KIND_*` const + `advance_delta`/`fprime` arms), `buddhabrot.wgsl` (matching
|
||||
arm in `advance()`, if the kind makes sense as a Buddhabrot), `renderer.rs`
|
||||
`Uniforms` (only if the kind needs a new per-kind constant, e.g. Phoenix's
|
||||
`phoenix_p`), `share.rs` (encode/decode string tag), `app.rs` (`KINDS` label,
|
||||
`JULIA_PRESETS`/`SET_PRESETS` slot, `default_view_for` entry, and optionally a
|
||||
UI control for its constant + an animation toggle, following the
|
||||
Phoenix/Lambda pattern). If `c` doesn't enter the formula additively (e.g. a
|
||||
rational map with `c` in a denominator), the `advance_delta`/`step_add` split
|
||||
doesn't work — that needs its own step function plus extra per-step reference
|
||||
data uploaded in a second GPU buffer alongside the orbit.
|
||||
|
||||
### Buddhabrot is a separate pipeline
|
||||
|
||||
`src/fractal/buddhabrot.rs` + `src/shaders/buddhabrot.wgsl` implement the
|
||||
Monte-Carlo orbit-density histogram. It does **not** use the perturbation/
|
||||
reference-orbit machinery: a Buddhabrot sample's orbit scatters across the
|
||||
whole image rather than staying in one pixel, so it's plain `f32` iteration
|
||||
from the live view (no deep zoom) via a compute pass that accumulates into a
|
||||
histogram buffer, tone-mapped by a fragment pass every frame. Its own
|
||||
`KIND_*` iteration formulas in `advance()` must be kept in sync with
|
||||
`reference.rs` by hand (there's no shared code path).
|
||||
|
||||
### Two-pass render + caching (`renderer.rs`)
|
||||
|
||||
The interactive path splits iteration (expensive, perturbation) from
|
||||
colourising (cheap, palette remap) into separate offscreen textures, so
|
||||
palette/color-scale/offset tweaks skip re-iteration entirely (`geom_differs`
|
||||
vs `color_differs` in `renderer.rs` decide which pass reruns). While the user
|
||||
is actively panning/zooming, the app renders downscaled with AA off
|
||||
(`INTERACT_DOWNSCALE`) and snaps back to full resolution once input settles
|
||||
(`INTERACT_SETTLE`).
|
||||
+1
-5
@@ -8,16 +8,14 @@ bytemuck = { version = "1.25.2", features = ["derive"] }
|
||||
dashu-float = "0.6.0"
|
||||
eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"] }
|
||||
egui = "0.36.2"
|
||||
futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] }
|
||||
log = "0.4.34"
|
||||
png = "0.18.1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
env_logger = "0.11.11"
|
||||
clap = { version = "4.5.51", features = ["derive"] }
|
||||
pollster = "1.0.1"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] }
|
||||
console_error_panic_hook = "0.1.7"
|
||||
console_log = "1.1.0"
|
||||
js-sys = "0.3.105"
|
||||
@@ -28,8 +26,6 @@ web-sys = { version = "0.3.105", features = ["Window", "Location", "Url", "UrlSe
|
||||
# Release: optimize hard (fractal math is hot).
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
# codegen-units = 1
|
||||
debug = true
|
||||
|
||||
# Dev: keep our own crate debuggable, but optimize dependencies (dashu, wgpu,
|
||||
# egui) so the explorer is actually interactive during development.
|
||||
|
||||
@@ -20,7 +20,6 @@ wasm-bindgen \
|
||||
target/wasm32-unknown-unknown/release/mandelbrot.wasm
|
||||
|
||||
cp index.html "$OUT/index.html"
|
||||
cp favicon.ico "$OUT/favicon.ico"
|
||||
|
||||
echo "==> done: $OUT/ (index.html, mandelbrot.js, mandelbrot_bg.wasm)"
|
||||
echo " serve: python3 -m http.server -d $OUT 8080"
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 422 KiB |
+186
-1202
File diff suppressed because it is too large
Load Diff
-98
@@ -1,98 +0,0 @@
|
||||
// Native command-line arguments. Currently mirrors the old `MANDEL_*` debug
|
||||
// env vars one-for-one; this is the foundation a future headless (no-window,
|
||||
// render-to-file) mode will build on.
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
|
||||
use crate::fractal::FractalKind;
|
||||
|
||||
#[derive(Parser, Debug, Default)]
|
||||
#[command(name = "mandelbrot", about = "Deep-zoom fractal explorer", version)]
|
||||
pub struct Cli {
|
||||
/// Fractal formula to render.
|
||||
#[arg(long, value_enum)]
|
||||
pub kind: Option<KindArg>,
|
||||
|
||||
/// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 8].
|
||||
#[arg(long)]
|
||||
pub power: Option<u32>,
|
||||
|
||||
/// Start in Julia mode with this seed constant.
|
||||
#[arg(long, value_name = "RE,IM")]
|
||||
pub julia: Option<String>,
|
||||
|
||||
/// Restore a view from a share-link fragment (the part after '#').
|
||||
#[arg(long, value_name = "FRAGMENT")]
|
||||
pub share: Option<String>,
|
||||
|
||||
/// Jump to a view on startup.
|
||||
#[arg(long, value_name = "RE,IM,HALF_HEIGHT[,ITERATIONS]")]
|
||||
pub view: Option<String>,
|
||||
|
||||
/// Enable distance-estimation shading.
|
||||
#[arg(long)]
|
||||
pub de: bool,
|
||||
|
||||
/// Switch to the Buddhabrot renderer.
|
||||
#[arg(long)]
|
||||
pub buddhabrot: bool,
|
||||
|
||||
/// Buddhabrot tonemap palette index.
|
||||
#[arg(long, value_name = "INDEX")]
|
||||
pub buddha_palette: Option<u32>,
|
||||
|
||||
/// Render a PNG export on startup.
|
||||
#[arg(long)]
|
||||
pub export: bool,
|
||||
|
||||
/// Output path for --export/--headless (default: fractal-<timestamp>.png).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub export_path: Option<String>,
|
||||
|
||||
/// Run without opening a window: render the current view to a PNG and
|
||||
/// exit. Combine with --kind/--julia/--share/--view etc. to pick what to
|
||||
/// render. Not yet supported with --buddhabrot.
|
||||
#[arg(long)]
|
||||
pub headless: bool,
|
||||
|
||||
/// Output image width in pixels (--headless only).
|
||||
#[arg(long, value_name = "PX", default_value_t = 1920)]
|
||||
pub width: u32,
|
||||
|
||||
/// Output image height in pixels (--headless only).
|
||||
#[arg(long, value_name = "PX", default_value_t = 1080)]
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
pub enum KindArg {
|
||||
Mandelbrot,
|
||||
#[value(alias = "ship")]
|
||||
BurningShip,
|
||||
#[value(alias = "mandelbar")]
|
||||
Tricorn,
|
||||
#[value(alias = "multi")]
|
||||
Multibrot,
|
||||
Celtic,
|
||||
#[value(alias = "perp")]
|
||||
Perpendicular,
|
||||
Buffalo,
|
||||
Phoenix,
|
||||
Lambda,
|
||||
}
|
||||
|
||||
impl From<KindArg> for FractalKind {
|
||||
fn from(k: KindArg) -> Self {
|
||||
match k {
|
||||
KindArg::Mandelbrot => FractalKind::Mandelbrot,
|
||||
KindArg::BurningShip => FractalKind::BurningShip,
|
||||
KindArg::Tricorn => FractalKind::Tricorn,
|
||||
KindArg::Multibrot => FractalKind::Multibrot,
|
||||
KindArg::Celtic => FractalKind::Celtic,
|
||||
KindArg::Perpendicular => FractalKind::Perpendicular,
|
||||
KindArg::Buffalo => FractalKind::Buffalo,
|
||||
KindArg::Phoenix => FractalKind::Phoenix,
|
||||
KindArg::Lambda => FractalKind::Lambda,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
//! Buddhabrot / Nebulabrot rendering: a Monte-Carlo orbit-density histogram,
|
||||
//! accumulated progressively across frames by a compute pass and tone-mapped
|
||||
//! to colour by a fragment pass. See `shaders/buddhabrot.wgsl` for the "why"
|
||||
//! this is a separate pipeline from the escape-time perturbation renderer.
|
||||
|
||||
use eframe::egui_wgpu::{self, wgpu};
|
||||
|
||||
/// Random samples dispatched per accumulating frame. Chosen so a frame stays
|
||||
/// interactive on a modest GPU even when most samples run the full `b_cap`
|
||||
/// (e.g. the view sits entirely inside the set, so nothing escapes).
|
||||
const SAMPLES_PER_DISPATCH: u32 = 150_000;
|
||||
const WORKGROUP_SIZE: u32 = 64;
|
||||
|
||||
/// GPU-side parameters for both the accumulate (compute) and tonemap
|
||||
/// (fragment) passes. Layout must match `Uniforms` in `buddhabrot.wgsl`.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct BuddhabrotUniforms {
|
||||
pub center: [f32; 2],
|
||||
pub half_height: f32,
|
||||
pub aspect: f32,
|
||||
pub phoenix_p: [f32; 2],
|
||||
pub lambda_l: [f32; 2],
|
||||
pub bailout_sq: f32,
|
||||
/// Iteration formula (`FractalKind::shader_id`); `KIND_LAMBDA` samples z0
|
||||
/// instead of c (see the shader's doc comment).
|
||||
pub kind: u32,
|
||||
/// Exponent for the Multibrot kind.
|
||||
pub power: u32,
|
||||
/// Nested escape-iteration caps (r_cap <= g_cap <= b_cap) that bucket an
|
||||
/// orbit's points into the R/G/B histogram planes.
|
||||
pub r_cap: u32,
|
||||
pub g_cap: u32,
|
||||
pub b_cap: u32,
|
||||
/// RNG nonce, bumped every dispatch so each frame samples fresh points.
|
||||
pub seed: u32,
|
||||
pub samples_this_dispatch: u32,
|
||||
/// Tonemap brightness multiplier (user-controlled).
|
||||
pub exposure: f32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Running total of samples accumulated into the current histogram
|
||||
/// (across all dispatches since the last reset); normalizes brightness.
|
||||
pub total_samples: f32,
|
||||
/// Tonemap colour style: 0 = classic (R/G/B = raw caps), 1 = nebula
|
||||
/// (yellow core, blue halo), 2 = grayscale. Display-only, like `exposure`
|
||||
/// — excluded from `ContentKey` so changing it doesn't reset accumulation.
|
||||
pub palette: u32,
|
||||
pub _pad: [u32; 3],
|
||||
}
|
||||
|
||||
/// The subset of `BuddhabrotUniforms` that determines the *content* of the
|
||||
/// histogram (as opposed to `exposure`, a display-only rescale). A change in
|
||||
/// any of these invalidates the accumulated histogram.
|
||||
#[derive(Copy, Clone, PartialEq)]
|
||||
struct ContentKey {
|
||||
center: [f32; 2],
|
||||
half_height: f32,
|
||||
aspect: f32,
|
||||
phoenix_p: [f32; 2],
|
||||
lambda_l: [f32; 2],
|
||||
bailout_sq: f32,
|
||||
kind: u32,
|
||||
power: u32,
|
||||
r_cap: u32,
|
||||
g_cap: u32,
|
||||
b_cap: u32,
|
||||
}
|
||||
|
||||
impl From<&BuddhabrotUniforms> for ContentKey {
|
||||
fn from(u: &BuddhabrotUniforms) -> Self {
|
||||
Self {
|
||||
center: u.center,
|
||||
half_height: u.half_height,
|
||||
aspect: u.aspect,
|
||||
phoenix_p: u.phoenix_p,
|
||||
lambda_l: u.lambda_l,
|
||||
bailout_sq: u.bailout_sq,
|
||||
kind: u.kind,
|
||||
power: u.power,
|
||||
r_cap: u.r_cap,
|
||||
g_cap: u.g_cap,
|
||||
b_cap: u.b_cap,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The histogram buffer and its two bind groups, sized to the widget.
|
||||
struct Histogram {
|
||||
buffer: wgpu::Buffer,
|
||||
compute_bind_group: wgpu::BindGroup,
|
||||
tonemap_bind_group: wgpu::BindGroup,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
pub struct BuddhabrotRenderer {
|
||||
compute_pipeline: wgpu::ComputePipeline,
|
||||
compute_bind_group_layout: wgpu::BindGroupLayout,
|
||||
tonemap_pipeline: wgpu::RenderPipeline,
|
||||
tonemap_bind_group_layout: wgpu::BindGroupLayout,
|
||||
uniform_buffer: wgpu::Buffer,
|
||||
histogram: Option<Histogram>,
|
||||
/// What the current histogram's content was last accumulated for; a
|
||||
/// mismatch clears the histogram and restarts accumulation.
|
||||
last_content: Option<ContentKey>,
|
||||
/// Running sample count since the last reset (mirrors what was written
|
||||
/// into `total_samples`, since the callback doesn't own that state).
|
||||
total_samples: f32,
|
||||
seed: u32,
|
||||
}
|
||||
|
||||
impl BuddhabrotRenderer {
|
||||
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("buddhabrot"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/buddhabrot.wgsl").into()),
|
||||
});
|
||||
|
||||
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("buddhabrot uniforms"),
|
||||
size: std::mem::size_of::<BuddhabrotUniforms>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let compute_bind_group_layout =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("buddhabrot compute bind group layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: false },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let compute_pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("buddhabrot compute pipeline layout"),
|
||||
bind_group_layouts: &[Some(&compute_bind_group_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let compute_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("buddhabrot compute pipeline"),
|
||||
layout: Some(&compute_pipeline_layout),
|
||||
module: &shader,
|
||||
entry_point: Some("cs_main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
|
||||
let tonemap_bind_group_layout =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("buddhabrot tonemap bind group layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let tonemap_pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("buddhabrot tonemap pipeline layout"),
|
||||
bind_group_layouts: &[Some(&tonemap_bind_group_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let tonemap_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("buddhabrot tonemap pipeline"),
|
||||
layout: Some(&tonemap_pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_tonemap"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: target_format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
Self {
|
||||
compute_pipeline,
|
||||
compute_bind_group_layout,
|
||||
tonemap_pipeline,
|
||||
tonemap_bind_group_layout,
|
||||
uniform_buffer,
|
||||
histogram: None,
|
||||
last_content: None,
|
||||
total_samples: 0.0,
|
||||
seed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the histogram buffer exists at `width`×`height`, recreating (and
|
||||
/// resetting accumulation) on a size change.
|
||||
fn ensure_histogram(&mut self, device: &wgpu::Device, width: u32, height: u32) {
|
||||
if let Some(h) = &self.histogram
|
||||
&& h.width == width
|
||||
&& h.height == height
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let plane = (width as u64) * (height as u64);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("buddhabrot histogram"),
|
||||
size: plane * 3 * std::mem::size_of::<u32>() as u64,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let compute_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("buddhabrot compute bind group"),
|
||||
layout: &self.compute_bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.uniform_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: buffer.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let tonemap_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("buddhabrot tonemap bind group"),
|
||||
layout: &self.tonemap_bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.uniform_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: buffer.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
self.histogram = Some(Histogram {
|
||||
buffer,
|
||||
compute_bind_group,
|
||||
tonemap_bind_group,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
// New (zero-initialized) buffer: accumulation starts fresh.
|
||||
self.last_content = None;
|
||||
self.total_samples = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame paint callback. `accumulate` controls whether a new batch of
|
||||
/// samples is dispatched this frame (a content change always forces one
|
||||
/// dispatch regardless, so a parameter/view change is never left blank).
|
||||
pub struct BuddhabrotCallback {
|
||||
pub uniforms: BuddhabrotUniforms,
|
||||
pub accumulate: bool,
|
||||
/// Widget size in physical pixels — the histogram resolution.
|
||||
pub size_px: [u32; 2],
|
||||
}
|
||||
|
||||
impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
|
||||
fn prepare(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
_screen_descriptor: &egui_wgpu::ScreenDescriptor,
|
||||
egui_encoder: &mut wgpu::CommandEncoder,
|
||||
resources: &mut egui_wgpu::CallbackResources,
|
||||
) -> Vec<wgpu::CommandBuffer> {
|
||||
let Some(renderer) = resources.get_mut::<BuddhabrotRenderer>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let width = self.size_px[0].max(1);
|
||||
let height = self.size_px[1].max(1);
|
||||
renderer.ensure_histogram(device, width, height);
|
||||
|
||||
let content = ContentKey::from(&self.uniforms);
|
||||
let content_changed = renderer.last_content != Some(content);
|
||||
let should_dispatch = content_changed || self.accumulate;
|
||||
|
||||
if let Some(histogram) = &renderer.histogram {
|
||||
if content_changed {
|
||||
egui_encoder.clear_buffer(&histogram.buffer, 0, None);
|
||||
renderer.total_samples = 0.0;
|
||||
renderer.last_content = Some(content);
|
||||
}
|
||||
|
||||
let mut uniforms = self.uniforms;
|
||||
uniforms.width = width;
|
||||
uniforms.height = height;
|
||||
if should_dispatch {
|
||||
renderer.seed = renderer.seed.wrapping_add(1);
|
||||
renderer.total_samples += SAMPLES_PER_DISPATCH as f32;
|
||||
uniforms.seed = renderer.seed;
|
||||
uniforms.samples_this_dispatch = SAMPLES_PER_DISPATCH;
|
||||
} else {
|
||||
uniforms.samples_this_dispatch = 0;
|
||||
}
|
||||
uniforms.total_samples = renderer.total_samples;
|
||||
queue.write_buffer(&renderer.uniform_buffer, 0, bytemuck::bytes_of(&uniforms));
|
||||
|
||||
if should_dispatch {
|
||||
let mut pass = egui_encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("buddhabrot accumulate pass"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&renderer.compute_pipeline);
|
||||
pass.set_bind_group(0, &histogram.compute_bind_group, &[]);
|
||||
let workgroups = SAMPLES_PER_DISPATCH.div_ceil(WORKGROUP_SIZE);
|
||||
pass.dispatch_workgroups(workgroups, 1, 1);
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&self,
|
||||
_info: egui::PaintCallbackInfo,
|
||||
render_pass: &mut wgpu::RenderPass<'static>,
|
||||
resources: &egui_wgpu::CallbackResources,
|
||||
) {
|
||||
if let Some(renderer) = resources.get::<BuddhabrotRenderer>()
|
||||
&& let Some(histogram) = &renderer.histogram
|
||||
{
|
||||
render_pass.set_pipeline(&renderer.tonemap_pipeline);
|
||||
render_pass.set_bind_group(0, &histogram.tonemap_bind_group, &[]);
|
||||
render_pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-8
@@ -1,16 +1,13 @@
|
||||
//! GPU fractal rendering: wgpu pipeline, uniforms, reference orbit, and the
|
||||
//! egui paint callback.
|
||||
|
||||
pub mod buddhabrot;
|
||||
pub mod reference;
|
||||
pub mod renderer;
|
||||
pub mod share;
|
||||
|
||||
pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms};
|
||||
pub use reference::{FractalKind, compute_reference, compute_set_reference};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use renderer::encode_png_with_progress;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use renderer::export_to_png_blocking;
|
||||
pub use renderer::{ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms};
|
||||
pub use reference::{Bla, FractalKind, build_bla_table, compute_reference, compute_set_reference};
|
||||
pub use renderer::{
|
||||
ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms,
|
||||
encode_png_with_progress,
|
||||
};
|
||||
pub use share::ShareState;
|
||||
|
||||
+284
-231
@@ -10,31 +10,32 @@
|
||||
//! * Mandelbrot-set: `z0 = 0`, `c = view center` (the c-plane point per pixel).
|
||||
//! * Julia-set: `z0 = view center`, `c = fractal constant` (fixed per view).
|
||||
|
||||
use crate::view::{Big, big_from_f64};
|
||||
use crate::view::Big;
|
||||
|
||||
/// The iteration formula. Must be kept in sync with `advance_delta` and the
|
||||
/// `KIND_*` constants in the shader.
|
||||
#[repr(u8)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum FractalKind {
|
||||
/// `z -> z^2 + c`.
|
||||
Mandelbrot = 0,
|
||||
Mandelbrot,
|
||||
/// `z -> (|Re z| + i|Im z|)^2 + c`.
|
||||
BurningShip = 1,
|
||||
BurningShip,
|
||||
/// `z -> conj(z)^2 + c` (the Mandelbar).
|
||||
Tricorn = 2,
|
||||
Tricorn,
|
||||
/// `z -> z^power + c` (power >= 2).
|
||||
Multibrot = 3,
|
||||
/// `z -> |Re(z^2)| + i·Im(z^2) + c` (abs on the real output of the square).
|
||||
Celtic = 4,
|
||||
/// `z -> (x^2 - y^2) - 2·x·|y|·i + c` (abs on the imaginary input).
|
||||
Perpendicular = 5,
|
||||
/// `z -> |Re(z^2)| - |Im(z^2)|·i + c` (abs on both outputs).
|
||||
Buffalo = 6,
|
||||
/// `z -> z^2 + c + p·z_{n-1}` (two-term recurrence; `p` is `phoenix_p`).
|
||||
Phoenix = 7,
|
||||
/// `z -> lambda·z(1 - z)` (logistic map).
|
||||
Lambda = 8,
|
||||
Multibrot,
|
||||
}
|
||||
|
||||
impl FractalKind {
|
||||
/// Integer id matching the shader's `KIND_*` constants.
|
||||
pub fn shader_id(self) -> u32 {
|
||||
match self {
|
||||
FractalKind::Mandelbrot => 0,
|
||||
FractalKind::BurningShip => 1,
|
||||
FractalKind::Tricorn => 2,
|
||||
FractalKind::Multibrot => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference orbit escapes once |Z|^2 exceeds this. Kept larger than the pixel
|
||||
@@ -45,7 +46,6 @@ const REFERENCE_ESCAPE_SQ: f64 = 1.0e10;
|
||||
/// Compute the reference orbit `Z_0..Z_{len-1}` where `Z_0 = z0` and
|
||||
/// `Z_{n+1} = f(Z_n, c)` for the given `kind` (and `power`, for Multibrot), up
|
||||
/// to `max_iter` steps at `precision` bits. Each entry is `[re, im]` in f32.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn compute_reference(
|
||||
z0_re: &Big,
|
||||
z0_im: &Big,
|
||||
@@ -55,23 +55,12 @@ pub fn compute_reference(
|
||||
precision: usize,
|
||||
kind: FractalKind,
|
||||
power: u32,
|
||||
phoenix_p: (f64, f64),
|
||||
lambda_l: (f64, f64),
|
||||
) -> Vec<[f32; 2]> {
|
||||
let cr = c_re.clone().with_precision(precision).value();
|
||||
let ci = c_im.clone().with_precision(precision).value();
|
||||
|
||||
let mut zr = z0_re.clone().with_precision(precision).value();
|
||||
let mut zi = z0_im.clone().with_precision(precision).value();
|
||||
// Previous iterate, for the Phoenix two-term recurrence (Y_{-1} = 0).
|
||||
let mut zr_prev = big_zero(precision);
|
||||
let mut zi_prev = big_zero(precision);
|
||||
// Phoenix distortion constant `p` (a small fixed complex number).
|
||||
let pr = big_from_f64(phoenix_p.0, precision);
|
||||
let pi = big_from_f64(phoenix_p.1, precision);
|
||||
// Lambda distortion constant `l` (a small fixed complex number).
|
||||
let lr = big_from_f64(lambda_l.0, precision);
|
||||
let li = big_from_f64(lambda_l.1, precision);
|
||||
|
||||
let mut points: Vec<[f32; 2]> = Vec::with_capacity(max_iter as usize + 1);
|
||||
|
||||
@@ -108,45 +97,8 @@ pub fn compute_reference(
|
||||
let (pr, pi) = complex_pow(&zr, &zi, power.max(2), precision);
|
||||
(pr + &cr, pi + &ci)
|
||||
}
|
||||
FractalKind::Celtic => {
|
||||
// |Re(z^2)| + i·Im(z^2): abs the real output of the square.
|
||||
let re = big_abs(&zr.sqr() - &zi.sqr()) + &cr;
|
||||
let im = ((&zr * &zi) << 1) + &ci;
|
||||
(re, im)
|
||||
}
|
||||
FractalKind::Perpendicular => {
|
||||
// (x^2 - y^2) - 2·x·|y| i: abs the imaginary input.
|
||||
let re = &zr.sqr() - &zi.sqr() + &cr;
|
||||
let im = &ci - ((&zr * &big_abs(zi.clone())) << 1);
|
||||
(re, im)
|
||||
}
|
||||
FractalKind::Buffalo => {
|
||||
// |Re(z^2)| - |Im(z^2)| i: abs both outputs.
|
||||
let re = big_abs(&zr.sqr() - &zi.sqr()) + &cr;
|
||||
let im = &ci - big_abs((&zr * &zi) << 1);
|
||||
(re, im)
|
||||
}
|
||||
FractalKind::Phoenix => {
|
||||
// z^2 + c + p·z_{n-1}.
|
||||
let re2 = &zr.sqr() - &zi.sqr();
|
||||
let im2 = (&zr * &zi) << 1;
|
||||
let pzr = &pr * &zr_prev - &pi * &zi_prev;
|
||||
let pzi = &pr * &zi_prev + &pi * &zr_prev;
|
||||
(re2 + &cr + pzr, im2 + &ci + pzi)
|
||||
}
|
||||
FractalKind::Lambda => {
|
||||
// λ·z(1 - z): logistic map.
|
||||
let re2 = 1 - &zr;
|
||||
let im2 = -&zi;
|
||||
let lzr = &lr * &zr - &li * &zi;
|
||||
let lzi = &lr * &zi + &li * &zr;
|
||||
(&lzr * &re2 - &lzi * &im2, re2 * lzi + lzr * im2)
|
||||
}
|
||||
};
|
||||
|
||||
// Shift the previous iterate (only the Phoenix arm reads it).
|
||||
zr_prev = zr;
|
||||
zi_prev = zi;
|
||||
zr = new_zr.with_precision(precision).value();
|
||||
zi = new_zi.with_precision(precision).value();
|
||||
}
|
||||
@@ -180,7 +132,6 @@ fn complex_pow(zr: &Big, zi: &Big, power: u32, precision: usize) -> (Big, Big) {
|
||||
|
||||
/// Convenience: parameter-plane ("Mandelbrot-set") reference (`z0 = 0`,
|
||||
/// `c = center`) for any `kind`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn compute_set_reference(
|
||||
center_re: &Big,
|
||||
center_im: &Big,
|
||||
@@ -188,15 +139,157 @@ pub fn compute_set_reference(
|
||||
precision: usize,
|
||||
kind: FractalKind,
|
||||
power: u32,
|
||||
phoenix_p: (f64, f64),
|
||||
lambda_l: (f64, f64),
|
||||
) -> Vec<[f32; 2]> {
|
||||
let zero = big_zero(precision);
|
||||
compute_reference(
|
||||
&zero, &zero, center_re, center_im, max_iter, precision, kind, power, phoenix_p, lambda_l,
|
||||
&zero, &zero, center_re, center_im, max_iter, precision, kind, power,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bivariate Linear Approximation (BLA)
|
||||
//
|
||||
// Deep in a zoom the per-pixel delta stays far smaller than the reference, so
|
||||
// the nonlinear `e^2` term of the perturbation step is negligible and the step
|
||||
// is effectively linear: `e -> A e + B dc`. BLA precomputes, for runs of
|
||||
// iterations, the composed linear coefficients `(A, B)` plus a validity radius
|
||||
// `r` (the largest `|e|` for which dropping `e^2` stays within tolerance). A
|
||||
// pixel can then skip a whole run in one multiply whenever `|e| < r`.
|
||||
//
|
||||
// Runs are merged pairwise into a binary tree of levels: level `k` holds BLAs of
|
||||
// length `2^k` starting at multiples of `2^k`. The GPU walks levels high→low to
|
||||
// take the longest valid skip at the current index. Both sides recompute the
|
||||
// per-level counts from `ref_len` (count[0] = ref_len-1, count[k] = count[k-1]/2)
|
||||
// so no offset table needs to travel to the GPU — only this flat array does.
|
||||
//
|
||||
// Only the holomorphic square map (Mandelbrot/Julia, `A = 2 Z`, `B = 1`) is
|
||||
// supported; other kinds fall back to per-iteration stepping on the GPU.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One merged linear step: `e_{n+l} = A e_n + B dc`, valid while `|e_n| < r`.
|
||||
/// Laid out to match the WGSL `Bla` struct (two `vec2<f32>`, then `f32`, `u32`;
|
||||
/// 24-byte std430 stride).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct Bla {
|
||||
pub a: [f32; 2],
|
||||
pub b: [f32; 2],
|
||||
pub r: f32,
|
||||
pub l: u32,
|
||||
}
|
||||
|
||||
/// Relative budget for the dropped nonlinear term, chosen near the f32 orbit
|
||||
/// storage noise floor so BLA adds no visible error over plain perturbation.
|
||||
const BLA_EPS: f64 = 1.0e-6;
|
||||
|
||||
/// f64 working form of a BLA (merges accumulate in f64, stored as f32).
|
||||
#[derive(Clone, Copy)]
|
||||
struct BlaF {
|
||||
ar: f64,
|
||||
ai: f64,
|
||||
br: f64,
|
||||
bi: f64,
|
||||
r: f64,
|
||||
l: u32,
|
||||
}
|
||||
|
||||
impl BlaF {
|
||||
fn to_bla(self) -> Bla {
|
||||
Bla {
|
||||
a: [self.ar as f32, self.ai as f32],
|
||||
b: [self.br as f32, self.bi as f32],
|
||||
r: self.r as f32,
|
||||
l: self.l,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge two consecutive BLAs (`x` then `y`) into one covering both runs.
|
||||
fn merge_bla(x: BlaF, y: BlaF, dc_max: f64) -> BlaF {
|
||||
// A = Ay Ax ; B = Ay Bx + By (complex).
|
||||
let ar = y.ar * x.ar - y.ai * x.ai;
|
||||
let ai = y.ar * x.ai + y.ai * x.ar;
|
||||
let br = y.ar * x.br - y.ai * x.bi + y.br;
|
||||
let bi = y.ar * x.bi + y.ai * x.br + y.bi;
|
||||
// Valid if |e| < rx (so x holds) and |Ax e + Bx dc| < ry (so y holds):
|
||||
// |e| < (ry - |Bx| dc_max) / |Ax|.
|
||||
let ax_mag = (x.ar * x.ar + x.ai * x.ai).sqrt();
|
||||
let bx_mag = (x.br * x.br + x.bi * x.bi).sqrt();
|
||||
let r_y = if ax_mag > 0.0 {
|
||||
((y.r - bx_mag * dc_max) / ax_mag).max(0.0)
|
||||
} else {
|
||||
x.r
|
||||
};
|
||||
BlaF {
|
||||
ar,
|
||||
ai,
|
||||
br,
|
||||
bi,
|
||||
r: x.r.min(r_y),
|
||||
l: x.l + y.l,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the BLA table for a square-map reference orbit. `dc_max` is an upper
|
||||
/// bound on any pixel's `|dc|` in the current view (used to size merge radii).
|
||||
/// Returns a flat array with levels concatenated (level 0 first). Empty if the
|
||||
/// orbit is too short to skip.
|
||||
pub fn build_bla_table(points: &[[f32; 2]], dc_max: f64) -> Vec<Bla> {
|
||||
let m = points.len();
|
||||
if m < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Level 0: one single step from each index i (uses Z_i). A = 2 Z, B = 1.
|
||||
// Dropping e^2 is within tolerance while |e| < BLA_EPS |Z| (since the kept
|
||||
// linear term is |2 Z e|).
|
||||
let mut level0: Vec<BlaF> = Vec::with_capacity(m - 1);
|
||||
for z in &points[..m - 1] {
|
||||
let (zr, zi) = (z[0] as f64, z[1] as f64);
|
||||
let zmag = (zr * zr + zi * zi).sqrt();
|
||||
level0.push(BlaF {
|
||||
ar: 2.0 * zr,
|
||||
ai: 2.0 * zi,
|
||||
br: 1.0,
|
||||
bi: 0.0,
|
||||
r: BLA_EPS * zmag,
|
||||
l: 1,
|
||||
});
|
||||
}
|
||||
|
||||
let mut levels: Vec<Vec<BlaF>> = vec![level0];
|
||||
while levels.last().unwrap().len() >= 2 {
|
||||
let prev = levels.last().unwrap();
|
||||
let mut next = Vec::with_capacity(prev.len() / 2);
|
||||
let mut i = 0;
|
||||
while i + 1 < prev.len() {
|
||||
next.push(merge_bla(prev[i], prev[i + 1], dc_max));
|
||||
i += 2;
|
||||
}
|
||||
levels.push(next);
|
||||
}
|
||||
|
||||
let mut flat = Vec::with_capacity(levels.iter().map(|l| l.len()).sum());
|
||||
for level in &levels {
|
||||
flat.extend(level.iter().map(|b| b.to_bla()));
|
||||
}
|
||||
flat
|
||||
}
|
||||
|
||||
/// Start offset of BLA level `lv` within the flat table, computed from the orbit
|
||||
/// length exactly as the shader does (`count[0] = ref_len-1`, halving each
|
||||
/// level). Kept here so the traversal test mirrors the GPU indexing.
|
||||
#[cfg(test)]
|
||||
fn bla_level_start(ref_len: usize, lv: u32) -> (usize, usize) {
|
||||
let mut start = 0usize;
|
||||
let mut count = ref_len - 1;
|
||||
for _ in 0..lv {
|
||||
start += count;
|
||||
count /= 2;
|
||||
}
|
||||
(start, count)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -207,16 +300,7 @@ mod tests {
|
||||
fn reference_matches_naive_f64() {
|
||||
let cr = Big::try_from(-0.75_f64).unwrap();
|
||||
let ci = Big::try_from(0.1_f64).unwrap();
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
&ci,
|
||||
60,
|
||||
200,
|
||||
FractalKind::Mandelbrot,
|
||||
2,
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
);
|
||||
let points = compute_set_reference(&cr, &ci, 60, 200, FractalKind::Mandelbrot, 2);
|
||||
|
||||
// Independent naive f64 orbit.
|
||||
let (c_re, c_im) = (-0.75_f64, 0.1_f64);
|
||||
@@ -226,14 +310,8 @@ mod tests {
|
||||
// significant figures.
|
||||
let tol_re = 1e-4 * (1.0 + zr.abs());
|
||||
let tol_im = 1e-4 * (1.0 + zi.abs());
|
||||
assert!(
|
||||
(point[0] as f64 - zr).abs() < tol_re,
|
||||
"re mismatch: {point:?} vs {zr}"
|
||||
);
|
||||
assert!(
|
||||
(point[1] as f64 - zi).abs() < tol_im,
|
||||
"im mismatch: {point:?} vs {zi}"
|
||||
);
|
||||
assert!((point[0] as f64 - zr).abs() < tol_re, "re mismatch: {point:?} vs {zr}");
|
||||
assert!((point[1] as f64 - zi).abs() < tol_im, "im mismatch: {point:?} vs {zi}");
|
||||
let nzr = zr * zr - zi * zi + c_re;
|
||||
let nzi = 2.0 * zr * zi + c_im;
|
||||
zr = nzr;
|
||||
@@ -246,16 +324,7 @@ mod tests {
|
||||
fn interior_orbit_runs_full_length() {
|
||||
let cr = Big::try_from(-0.2_f64).unwrap();
|
||||
let ci = Big::try_from(0.0_f64).unwrap();
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
&ci,
|
||||
500,
|
||||
120,
|
||||
FractalKind::Mandelbrot,
|
||||
2,
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
);
|
||||
let points = compute_set_reference(&cr, &ci, 500, 120, FractalKind::Mandelbrot, 2);
|
||||
assert_eq!(points.len(), 501, "interior orbit should not escape");
|
||||
}
|
||||
|
||||
@@ -264,16 +333,7 @@ mod tests {
|
||||
fn burning_ship_reference_matches_naive_f64() {
|
||||
let cr = Big::try_from(-1.75_f64).unwrap();
|
||||
let ci = Big::try_from(-0.03_f64).unwrap();
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
&ci,
|
||||
60,
|
||||
200,
|
||||
FractalKind::BurningShip,
|
||||
2,
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
);
|
||||
let points = compute_set_reference(&cr, &ci, 60, 200, FractalKind::BurningShip, 2);
|
||||
|
||||
let (c_re, c_im) = (-1.75_f64, -0.03_f64);
|
||||
let (mut zr, mut zi) = (0.0_f64, 0.0_f64);
|
||||
@@ -293,16 +353,7 @@ mod tests {
|
||||
fn multibrot3_reference_matches_naive_f64() {
|
||||
let cr = Big::try_from(0.3_f64).unwrap();
|
||||
let ci = Big::try_from(0.2_f64).unwrap();
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
&ci,
|
||||
60,
|
||||
200,
|
||||
FractalKind::Multibrot,
|
||||
3,
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
);
|
||||
let points = compute_set_reference(&cr, &ci, 60, 200, FractalKind::Multibrot, 3);
|
||||
|
||||
let (c_re, c_im) = (0.3_f64, 0.2_f64);
|
||||
let (mut zr, mut zi) = (0.0_f64, 0.0_f64);
|
||||
@@ -335,8 +386,6 @@ mod tests {
|
||||
200,
|
||||
FractalKind::Mandelbrot,
|
||||
2,
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
);
|
||||
|
||||
let (mut zr, mut zi) = (0.15_f64, -0.1_f64);
|
||||
@@ -352,121 +401,125 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Celtic reference matches a naive f64 iteration: real = |x^2 - y^2| + cr.
|
||||
/// Step-by-step perturbation (drops nothing): `e_{n+1} = 2 Z_n e_n + e_n^2 + dc`,
|
||||
/// using the stored f32 orbit as `Z_n`. Returns `e` after `target_n` steps.
|
||||
fn advance_naive(points: &[[f32; 2]], dc: (f64, f64), target_n: usize) -> (f64, f64) {
|
||||
let (mut er, mut ei) = (0.0f64, 0.0f64);
|
||||
for z in &points[..target_n] {
|
||||
let (zr, zi) = (z[0] as f64, z[1] as f64);
|
||||
let tr = 2.0 * (zr * er - zi * ei);
|
||||
let ti = 2.0 * (zr * ei + zi * er);
|
||||
let sr = er * er - ei * ei;
|
||||
let si = 2.0 * er * ei;
|
||||
er = tr + sr + dc.0;
|
||||
ei = ti + si + dc.1;
|
||||
}
|
||||
(er, ei)
|
||||
}
|
||||
|
||||
/// Advance `e` to exactly `target_n` steps using the BLA table — the same
|
||||
/// walk the shader performs (longest valid skip first, else a full step),
|
||||
/// but never skipping past `target_n`. Returns `(e, took_a_multi_step_skip)`.
|
||||
fn advance_bla(
|
||||
points: &[[f32; 2]],
|
||||
table: &[Bla],
|
||||
dc: (f64, f64),
|
||||
target_n: usize,
|
||||
) -> ((f64, f64), bool) {
|
||||
let ref_len = points.len();
|
||||
let (mut er, mut ei) = (0.0f64, 0.0f64);
|
||||
let mut n = 0usize;
|
||||
let mut skipped = false;
|
||||
|
||||
while n < target_n {
|
||||
let emag2 = er * er + ei * ei;
|
||||
let mut applied = false;
|
||||
|
||||
// Highest level worth trying is bounded by how far we may advance.
|
||||
let span = target_n - n;
|
||||
let max_lv = (usize::BITS - 1 - span.leading_zeros()) as i64; // floor(log2(span))
|
||||
let mut lv = max_lv;
|
||||
while lv >= 0 {
|
||||
let lvu = lv as u32;
|
||||
let step = 1usize << lvu;
|
||||
if n % step == 0 && n + step <= target_n {
|
||||
let (start, count) = bla_level_start(ref_len, lvu);
|
||||
let idx = n >> lvu;
|
||||
if idx < count {
|
||||
let b = table[start + idx];
|
||||
let r = b.r as f64;
|
||||
if emag2 < r * r {
|
||||
let (ar, ai) = (b.a[0] as f64, b.a[1] as f64);
|
||||
let (br, bi) = (b.b[0] as f64, b.b[1] as f64);
|
||||
let ner = ar * er - ai * ei + br * dc.0 - bi * dc.1;
|
||||
let nei = ar * ei + ai * er + br * dc.1 + bi * dc.0;
|
||||
er = ner;
|
||||
ei = nei;
|
||||
n += step;
|
||||
skipped |= step > 1;
|
||||
applied = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
lv -= 1;
|
||||
}
|
||||
|
||||
if !applied {
|
||||
let (zr, zi) = (points[n][0] as f64, points[n][1] as f64);
|
||||
let tr = 2.0 * (zr * er - zi * ei);
|
||||
let ti = 2.0 * (zr * ei + zi * er);
|
||||
let sr = er * er - ei * ei;
|
||||
let si = 2.0 * er * ei;
|
||||
er = tr + sr + dc.0;
|
||||
ei = ti + si + dc.1;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
((er, ei), skipped)
|
||||
}
|
||||
|
||||
/// The BLA walk must reproduce step-by-step perturbation at deep zoom (where
|
||||
/// the delta is tiny and skips actually fire).
|
||||
#[test]
|
||||
fn celtic_reference_matches_naive_f64() {
|
||||
let cr = Big::try_from(-0.6_f64).unwrap();
|
||||
let ci = Big::try_from(0.4_f64).unwrap();
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
&ci,
|
||||
60,
|
||||
200,
|
||||
FractalKind::Celtic,
|
||||
2,
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
);
|
||||
|
||||
let (c_re, c_im) = (-0.6_f64, 0.4_f64);
|
||||
let (mut zr, mut zi) = (0.0_f64, 0.0_f64);
|
||||
for point in &points {
|
||||
let tol = 1e-4 * (1.0 + zr.abs().max(zi.abs()));
|
||||
assert!((point[0] as f64 - zr).abs() < tol, "re: {point:?} vs {zr}");
|
||||
assert!((point[1] as f64 - zi).abs() < tol, "im: {point:?} vs {zi}");
|
||||
let nzr = (zr * zr - zi * zi).abs() + c_re;
|
||||
let nzi = 2.0 * zr * zi + c_im;
|
||||
zr = nzr;
|
||||
zi = nzi;
|
||||
}
|
||||
}
|
||||
|
||||
/// Perpendicular reference matches a naive f64 iteration:
|
||||
/// real = x^2 - y^2 + cr, imag = -2·x·|y| + ci.
|
||||
#[test]
|
||||
fn perpendicular_reference_matches_naive_f64() {
|
||||
let cr = Big::try_from(-0.7_f64).unwrap();
|
||||
let ci = Big::try_from(-0.2_f64).unwrap();
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
&ci,
|
||||
60,
|
||||
200,
|
||||
FractalKind::Perpendicular,
|
||||
2,
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
);
|
||||
|
||||
let (c_re, c_im) = (-0.7_f64, -0.2_f64);
|
||||
let (mut zr, mut zi) = (0.0_f64, 0.0_f64);
|
||||
for point in &points {
|
||||
let tol = 1e-4 * (1.0 + zr.abs().max(zi.abs()));
|
||||
assert!((point[0] as f64 - zr).abs() < tol, "re: {point:?} vs {zr}");
|
||||
assert!((point[1] as f64 - zi).abs() < tol, "im: {point:?} vs {zi}");
|
||||
let nzr = zr * zr - zi * zi + c_re;
|
||||
let nzi = -2.0 * zr * zi.abs() + c_im;
|
||||
zr = nzr;
|
||||
zi = nzi;
|
||||
}
|
||||
}
|
||||
|
||||
/// Buffalo reference matches a naive f64 iteration:
|
||||
/// real = |x^2 - y^2| + cr, imag = -|2·x·y| + ci.
|
||||
#[test]
|
||||
fn buffalo_reference_matches_naive_f64() {
|
||||
let cr = Big::try_from(-1.2_f64).unwrap();
|
||||
let ci = Big::try_from(-0.35_f64).unwrap();
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
&ci,
|
||||
60,
|
||||
200,
|
||||
FractalKind::Buffalo,
|
||||
2,
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
);
|
||||
|
||||
let (c_re, c_im) = (-1.2_f64, -0.35_f64);
|
||||
let (mut zr, mut zi) = (0.0_f64, 0.0_f64);
|
||||
for point in &points {
|
||||
let tol = 1e-4 * (1.0 + zr.abs().max(zi.abs()));
|
||||
assert!((point[0] as f64 - zr).abs() < tol, "re: {point:?} vs {zr}");
|
||||
assert!((point[1] as f64 - zi).abs() < tol, "im: {point:?} vs {zi}");
|
||||
let nzr = (zr * zr - zi * zi).abs() + c_re;
|
||||
let nzi = -(2.0 * zr * zi).abs() + c_im;
|
||||
zr = nzr;
|
||||
zi = nzi;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phoenix reference matches a naive f64 two-term iteration
|
||||
/// `z_{n+1} = z_n^2 + c + p·z_{n-1}` (z_0 = 0, z_{-1} = 0).
|
||||
#[test]
|
||||
fn phoenix_reference_matches_naive_f64() {
|
||||
let cr = Big::try_from(0.5667_f64).unwrap();
|
||||
fn bla_matches_step_by_step() {
|
||||
// An interior center (never escapes), so `e` stays bounded and we can
|
||||
// iterate the full orbit; zoomed so |dc| ~ 1e-9 (deep enough for big
|
||||
// skips). Correctness of the walk is independent of which orbit we pick.
|
||||
let cr = Big::try_from(-0.5_f64).unwrap();
|
||||
let ci = Big::try_from(0.0_f64).unwrap();
|
||||
let p = (-0.5_f64, 0.0_f64);
|
||||
let points =
|
||||
compute_set_reference(&cr, &ci, 60, 200, FractalKind::Phoenix, 2, p, (0.0, 0.0));
|
||||
let points = compute_set_reference(&cr, &ci, 800, 160, FractalKind::Mandelbrot, 2);
|
||||
assert!(points.len() > 64, "need a long orbit to exercise BLA levels");
|
||||
|
||||
let (c_re, c_im) = (0.5667_f64, 0.0_f64);
|
||||
let (mut zr, mut zi) = (0.0_f64, 0.0_f64);
|
||||
let (mut pr, mut pi) = (0.0_f64, 0.0_f64); // previous iterate
|
||||
for point in &points {
|
||||
let tol = 1e-4 * (1.0 + zr.abs().max(zi.abs()));
|
||||
assert!((point[0] as f64 - zr).abs() < tol, "re: {point:?} vs {zr}");
|
||||
assert!((point[1] as f64 - zi).abs() < tol, "im: {point:?} vs {zi}");
|
||||
// p·z_{n-1} = (p.0 + i p.1)(pr + i pi).
|
||||
let pzr = p.0 * pr - p.1 * pi;
|
||||
let pzi = p.0 * pi + p.1 * pr;
|
||||
let nzr = zr * zr - zi * zi + c_re + pzr;
|
||||
let nzi = 2.0 * zr * zi + c_im + pzi;
|
||||
pr = zr;
|
||||
pi = zi;
|
||||
zr = nzr;
|
||||
zi = nzi;
|
||||
let half_height = 1.0e-9_f64;
|
||||
let aspect = 1.5_f64;
|
||||
let dc_max = half_height * (1.0 + aspect * aspect).sqrt();
|
||||
let table = build_bla_table(&points, dc_max);
|
||||
assert!(!table.is_empty());
|
||||
|
||||
let target_n = points.len() - 1;
|
||||
// A few pixel offsets across the view (all within dc_max).
|
||||
let offsets = [
|
||||
(0.0, 0.0),
|
||||
(0.6e-9, -0.4e-9),
|
||||
(-0.9e-9, 0.3e-9),
|
||||
(0.2e-9, 0.8e-9),
|
||||
];
|
||||
let mut any_skip = false;
|
||||
for dc in offsets {
|
||||
let naive = advance_naive(&points, dc, target_n);
|
||||
let (bla, skipped) = advance_bla(&points, &table, dc, target_n);
|
||||
any_skip |= skipped;
|
||||
|
||||
let ref_mag = (naive.0 * naive.0 + naive.1 * naive.1).sqrt();
|
||||
let err = ((bla.0 - naive.0).powi(2) + (bla.1 - naive.1).powi(2)).sqrt();
|
||||
// Only the dropped e^2 differs; must stay near the BLA_EPS budget.
|
||||
let tol = 1e-4 * ref_mag + 1e-15;
|
||||
assert!(
|
||||
err <= tol,
|
||||
"dc={dc:?}: BLA {bla:?} vs naive {naive:?} (err {err:e} > tol {tol:e})"
|
||||
);
|
||||
}
|
||||
assert!(any_skip, "BLA never took a multi-step skip — test is not exercising it");
|
||||
}
|
||||
}
|
||||
|
||||
+149
-410
@@ -13,43 +13,79 @@ use std::sync::Arc;
|
||||
|
||||
use eframe::egui_wgpu::{self, wgpu};
|
||||
|
||||
use crate::lights::{Light, MAX_LIGHT_COUNT};
|
||||
use super::reference::Bla;
|
||||
|
||||
/// Maximum reference-orbit length (points) the storage buffer can hold. Also
|
||||
/// bounds the iteration count. 128k points * 8 bytes = 1 MiB.
|
||||
pub const MAX_REF_POINTS: usize = 1 << 17;
|
||||
|
||||
/// Format of the intermediate iteration-data texture holding, per pixel,
|
||||
/// `(ci, DE factor, interior fraction)`. 32-bit float keeps the smooth iteration
|
||||
/// count precise at deep zoom. Color-renderable and read with nearest sampling
|
||||
/// (iteration data must never be linearly filtered across escape boundaries), so
|
||||
/// no `float32-filterable` feature is needed.
|
||||
const DATA_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
|
||||
/// Maximum BLA-table entries the storage buffer can hold. A full table has
|
||||
/// ~2× the orbit length (all levels summed); ~256k entries * 24 bytes = 6 MiB.
|
||||
pub const MAX_BLA_ENTRIES: usize = 2 * MAX_REF_POINTS;
|
||||
|
||||
/// True when the two uniforms differ in any field the iteration pass depends on
|
||||
/// (i.e. anything except the palette / colour scale / offset).
|
||||
fn geom_differs(a: &Uniforms, b: &Uniforms) -> bool {
|
||||
a.span != b.span
|
||||
|| a.max_iter != b.max_iter
|
||||
|| a.ref_len != b.ref_len
|
||||
|| a.bailout_sq != b.bailout_sq
|
||||
|| a.is_julia != b.is_julia
|
||||
|| a.aa_level != b.aa_level
|
||||
|| a.kind != b.kind
|
||||
|| a.power != b.power
|
||||
|| a.dc_offset != b.dc_offset
|
||||
|| a.phoenix_p != b.phoenix_p
|
||||
|| a.de_coloring != b.de_coloring
|
||||
/// The fractal pipeline's bind group layout: uniforms (0), reference orbit (1),
|
||||
/// BLA table (2). Shared by the live renderer and [`ExportRender`].
|
||||
fn fractal_bind_group_layout_desc<'a>() -> wgpu::BindGroupLayoutDescriptor<'a> {
|
||||
const STORAGE: wgpu::BindingType = wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
};
|
||||
wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("fractal bind group layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: STORAGE,
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: STORAGE,
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the two uniforms differ in a colour-only field (remappable by the
|
||||
/// cheap colourise pass without re-iterating).
|
||||
fn color_differs(a: &Uniforms, b: &Uniforms) -> bool {
|
||||
a.color_offset != b.color_offset
|
||||
|| a.color_scale != b.color_scale
|
||||
|| a.palette_id != b.palette_id
|
||||
|| a.shadow_palette_id != b.shadow_palette_id
|
||||
|| a.shadow != b.shadow
|
||||
/// Build the fractal bind group from its three buffers.
|
||||
fn fractal_bind_group(
|
||||
device: &wgpu::Device,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
uniform_buffer: &wgpu::Buffer,
|
||||
ref_buffer: &wgpu::Buffer,
|
||||
bla_buffer: &wgpu::Buffer,
|
||||
) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("fractal bind group"),
|
||||
layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: ref_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: bla_buffer.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// GPU-side view + coloring parameters. Layout must match `Uniforms` in the
|
||||
@@ -68,92 +104,60 @@ pub struct Uniforms {
|
||||
/// 0 = Mandelbrot, 1 = Julia.
|
||||
pub is_julia: u32,
|
||||
pub palette_id: u32,
|
||||
pub shadow_palette_id: u32,
|
||||
/// Supersampling factor per axis: 1 = off, 2 = 2×2 (4 samples).
|
||||
pub aa_level: u32,
|
||||
/// Iteration formula (`FractalKind::shader_id`).
|
||||
pub kind: u32,
|
||||
/// Exponent for the Multibrot kind.
|
||||
pub power: u32,
|
||||
pub _pad: [u32; 1],
|
||||
/// Complex offset of the view center from the reference center, so a stale
|
||||
/// or reused reference (computed at a slightly different center) still maps
|
||||
/// correctly. Added to every pixel's per-pixel offset.
|
||||
pub dc_offset: [f32; 2],
|
||||
/// Distortion constant `p` for the Phoenix map (`z^2 + c + p·z_{n-1}`);
|
||||
/// ignored by other kinds. Kept next to `dc_offset` so both `vec2`s land on
|
||||
/// 8-byte boundaries, matching the shader's layout.
|
||||
pub phoenix_p: [f32; 2],
|
||||
/// Distortion constant `l` for the Lambda map (`l·z(1 - z)`);
|
||||
/// ignored by other kinds.
|
||||
pub lambda_l: [f32; 2],
|
||||
/// 0 = escape-time coloring, 1 = distance-estimation shading.
|
||||
pub de_coloring: u32,
|
||||
// 0 = classic colors, 1 = shadows
|
||||
pub shadow: u32,
|
||||
/// 0 = per-iteration stepping, 1 = BLA iteration-skipping (square map only).
|
||||
/// This also fills the struct out to a 16-byte multiple (uniform requirement).
|
||||
pub use_bla: u32,
|
||||
}
|
||||
|
||||
/// Offscreen textures for the two-pass render, recreated whenever the widget's
|
||||
/// pixel size changes:
|
||||
/// * `data_view` — the iteration pass's output (see [`DATA_FORMAT`]).
|
||||
/// * `color_view` — the colourise pass's output; the blit source.
|
||||
/// plus the bind groups that read them.
|
||||
/// Offscreen texture the fractal is rendered into, plus the bind group used to
|
||||
/// blit it. Recreated whenever the widget's pixel size changes.
|
||||
struct CacheTarget {
|
||||
data_view: wgpu::TextureView,
|
||||
color_view: wgpu::TextureView,
|
||||
/// Colourise pass input: uniforms + the data texture.
|
||||
colorize_bind_group: wgpu::BindGroup,
|
||||
/// Blit pass input: the colour texture + sampler.
|
||||
view: wgpu::TextureView,
|
||||
blit_bind_group: wgpu::BindGroup,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// What the iteration-data texture was last computed with. If the next frame's
|
||||
/// geometry inputs match, iteration is skipped and only colour may be redone.
|
||||
struct IterState {
|
||||
/// State the cache texture was last rendered with. If the next frame's inputs
|
||||
/// match this, the cache is still valid and the fractal shader is skipped.
|
||||
struct RenderedState {
|
||||
uniforms: Uniforms,
|
||||
generation: u64,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// What the colour texture was last computed with. If the next frame's colour
|
||||
/// inputs (and size) match and iteration did not re-run, colourise is skipped.
|
||||
struct ColorState {
|
||||
uniforms: Uniforms,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
pub struct FractalRenderer {
|
||||
/// Iteration pass: perturbation iterate → data texture (`fs_data`).
|
||||
iterate_pipeline: wgpu::RenderPipeline,
|
||||
/// Combined iterate + colour in one pass (`fs_color`), used only by export.
|
||||
export_pipeline: wgpu::RenderPipeline,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
bind_group_layout: wgpu::BindGroupLayout,
|
||||
uniform_buffer: wgpu::Buffer,
|
||||
ref_buffer: wgpu::Buffer,
|
||||
lights_buffer: wgpu::Buffer,
|
||||
bla_buffer: wgpu::Buffer,
|
||||
bind_group: wgpu::BindGroup,
|
||||
target_format: wgpu::TextureFormat,
|
||||
/// Generation of the reference orbit currently uploaded to `ref_buffer`.
|
||||
/// Generation of the reference orbit + BLA table currently uploaded.
|
||||
uploaded_generation: u64,
|
||||
|
||||
/// Colourise pass: data texture → colour texture (palette mapping).
|
||||
colorize_pipeline: wgpu::RenderPipeline,
|
||||
colorize_bind_group_layout: wgpu::BindGroupLayout,
|
||||
|
||||
/// Blit pipeline + resources that copy the colour texture to egui's surface.
|
||||
/// Blit pipeline + resources that copy the cache texture to egui's surface.
|
||||
blit_pipeline: wgpu::RenderPipeline,
|
||||
blit_bind_group_layout: wgpu::BindGroupLayout,
|
||||
blit_sampler: wgpu::Sampler,
|
||||
/// The offscreen textures; `None` until the first frame sizes them.
|
||||
/// The offscreen cache; `None` until the first frame sizes it.
|
||||
cache: Option<CacheTarget>,
|
||||
/// What the data texture holds; `None` forces re-iteration.
|
||||
iterated: Option<IterState>,
|
||||
/// What the colour texture holds; `None` forces a recolour.
|
||||
colored: Option<ColorState>,
|
||||
/// What the cache currently holds; `None` forces a re-render.
|
||||
rendered: Option<RenderedState>,
|
||||
}
|
||||
|
||||
impl FractalRenderer {
|
||||
@@ -177,53 +181,23 @@ impl FractalRenderer {
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let lights_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("lights parameters"),
|
||||
size: (MAX_LIGHT_COUNT * std::mem::size_of::<Light>()) as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
let bla_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("bla table"),
|
||||
size: (MAX_BLA_ENTRIES * std::mem::size_of::<Bla>()) as u64,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("fractal bind group layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let bind_group_layout =
|
||||
device.create_bind_group_layout(&fractal_bind_group_layout_desc());
|
||||
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("fractal bind group"),
|
||||
layout: &bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: ref_buffer.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let bind_group = fractal_bind_group(
|
||||
device,
|
||||
&bind_group_layout,
|
||||
&uniform_buffer,
|
||||
&ref_buffer,
|
||||
&bla_buffer,
|
||||
);
|
||||
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("fractal pipeline layout"),
|
||||
@@ -231,9 +205,8 @@ impl FractalRenderer {
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
// Iteration pass: perturbation iterate → data texture (color-independent).
|
||||
let iterate_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("fractal iterate pipeline"),
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("fractal pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
@@ -243,107 +216,6 @@ impl FractalRenderer {
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_data"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: DATA_FORMAT,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// Combined iterate + colour in one pass — for PNG export only.
|
||||
let export_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("fractal export pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_color"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: target_format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// Colourise pass: data texture + colour uniforms → colour texture.
|
||||
let colorize_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("colorize"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/colorize.wgsl").into()),
|
||||
});
|
||||
let colorize_bind_group_layout =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("colorize bind group layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
// Nearest only: iteration data must not be filtered.
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let colorize_pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("colorize pipeline layout"),
|
||||
bind_group_layouts: &[Some(&colorize_bind_group_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let colorize_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("colorize pipeline"),
|
||||
layout: Some(&colorize_pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &colorize_shader,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &colorize_shader,
|
||||
entry_point: Some("fs_main"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: target_format,
|
||||
@@ -428,23 +300,19 @@ impl FractalRenderer {
|
||||
});
|
||||
|
||||
Self {
|
||||
iterate_pipeline,
|
||||
export_pipeline,
|
||||
pipeline,
|
||||
bind_group_layout,
|
||||
uniform_buffer,
|
||||
ref_buffer,
|
||||
lights_buffer,
|
||||
bla_buffer,
|
||||
bind_group,
|
||||
target_format,
|
||||
uploaded_generation: u64::MAX,
|
||||
colorize_pipeline,
|
||||
colorize_bind_group_layout,
|
||||
blit_pipeline,
|
||||
blit_bind_group_layout,
|
||||
blit_sampler,
|
||||
cache: None,
|
||||
iterated: None,
|
||||
colored: None,
|
||||
rendered: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,29 +326,13 @@ impl FractalRenderer {
|
||||
return;
|
||||
}
|
||||
|
||||
let extent = wgpu::Extent3d {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("fractal cache"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
|
||||
// Iteration-data texture (color-independent escape data).
|
||||
let data_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("fractal data"),
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: DATA_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let data_view = data_texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
// Colour texture (colourise output; blit source).
|
||||
let color_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("fractal color cache"),
|
||||
size: extent,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
@@ -488,26 +340,7 @@ impl FractalRenderer {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let color_view = color_texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
let colorize_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("colorize bind group"),
|
||||
layout: &self.colorize_bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.uniform_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&data_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.lights_buffer.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
let blit_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("blit bind group"),
|
||||
@@ -515,7 +348,7 @@ impl FractalRenderer {
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&color_view),
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
@@ -525,30 +358,21 @@ impl FractalRenderer {
|
||||
});
|
||||
|
||||
self.cache = Some(CacheTarget {
|
||||
data_view,
|
||||
color_view,
|
||||
colorize_bind_group,
|
||||
view,
|
||||
blit_bind_group,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
// New textures → old renders are gone.
|
||||
self.iterated = None;
|
||||
self.colored = None;
|
||||
// New texture → old render is gone.
|
||||
self.rendered = None;
|
||||
}
|
||||
|
||||
/// Handles needed to build a standalone [`ExportRender`] off the UI thread:
|
||||
/// the (immutable) pipeline and its bind-group layout, plus the target
|
||||
/// format. Cloned so the caller can drop the render-state lock before use.
|
||||
pub fn export_handles(
|
||||
&self,
|
||||
) -> (
|
||||
wgpu::RenderPipeline,
|
||||
wgpu::BindGroupLayout,
|
||||
wgpu::TextureFormat,
|
||||
) {
|
||||
pub fn export_handles(&self) -> (wgpu::RenderPipeline, wgpu::BindGroupLayout, wgpu::TextureFormat) {
|
||||
(
|
||||
self.export_pipeline.clone(),
|
||||
self.pipeline.clone(),
|
||||
self.bind_group_layout.clone(),
|
||||
self.target_format,
|
||||
)
|
||||
@@ -588,6 +412,7 @@ impl ExportRender {
|
||||
height: u32,
|
||||
uniforms: Uniforms,
|
||||
reference: &[[f32; 2]],
|
||||
bla: &[Bla],
|
||||
) -> Self {
|
||||
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("export uniforms"),
|
||||
@@ -608,20 +433,24 @@ impl ExportRender {
|
||||
queue.write_buffer(&ref_buffer, 0, bytemuck::cast_slice(&reference[..count]));
|
||||
}
|
||||
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("export bind group"),
|
||||
layout: bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: ref_buffer.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
let bla_count = bla.len().min(MAX_BLA_ENTRIES);
|
||||
let bla_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("export bla table"),
|
||||
size: (bla_count.max(1) * std::mem::size_of::<Bla>()) as u64,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
if bla_count > 0 {
|
||||
queue.write_buffer(&bla_buffer, 0, bytemuck::cast_slice(&bla[..bla_count]));
|
||||
}
|
||||
|
||||
let bind_group = fractal_bind_group(
|
||||
device,
|
||||
bind_group_layout,
|
||||
&uniform_buffer,
|
||||
&ref_buffer,
|
||||
&bla_buffer,
|
||||
);
|
||||
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("export target"),
|
||||
@@ -757,59 +586,6 @@ impl ExportRender {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `er` tile by tile (blocking on the GPU after each tile so progress
|
||||
/// reflects real work), read it back, and encode the result as PNG bytes.
|
||||
/// Blocks the calling thread throughout, so it's only for native targets:
|
||||
/// the UI export path runs it on a background thread, headless rendering
|
||||
/// runs it directly since it has no frame loop to share a thread with.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn export_to_png_blocking(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
er: &ExportRender,
|
||||
mut on_progress: impl FnMut(&'static str, f32),
|
||||
) -> Vec<u8> {
|
||||
// Progress budget: rendering fills [0, RENDER_END], encoding the rest.
|
||||
const RENDER_END: f32 = 0.6;
|
||||
|
||||
for t in 0..er.tiles {
|
||||
er.render_tile(device, queue, t);
|
||||
let _ = device.poll(wgpu::PollType::Wait {
|
||||
submission_index: None,
|
||||
timeout: None,
|
||||
});
|
||||
let done = (t + 1) as f32 / er.tiles as f32;
|
||||
on_progress("Rendering", RENDER_END * done);
|
||||
}
|
||||
er.copy_to_readback(device, queue);
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
er.readback()
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |res| {
|
||||
let _ = tx.send(res);
|
||||
});
|
||||
let _ = device.poll(wgpu::PollType::Wait {
|
||||
submission_index: None,
|
||||
timeout: None,
|
||||
});
|
||||
let _ = rx.recv();
|
||||
|
||||
on_progress("Encoding", RENDER_END);
|
||||
let png = {
|
||||
let data = er
|
||||
.readback()
|
||||
.slice(..)
|
||||
.get_mapped_range()
|
||||
.expect("map readback buffer");
|
||||
encode_png_with_progress(&data, er.width, er.height, er.padded_bpr, er.swap_rb, |f| {
|
||||
on_progress("Encoding", RENDER_END + (0.97 - RENDER_END) * f)
|
||||
})
|
||||
};
|
||||
er.readback().unmap();
|
||||
png
|
||||
}
|
||||
|
||||
/// Convert a padded BGRA/RGBA readback into tightly-packed RGBA8 and encode it
|
||||
/// as PNG bytes, reporting progress in `[0, 1]` via `on_progress` as rows are
|
||||
/// streamed to the compressor (encoding is the slow, subdividable phase).
|
||||
@@ -858,13 +634,13 @@ pub fn encode_png_with_progress(
|
||||
|
||||
/// A per-frame paint callback. Carries this frame's uniforms plus a reference to
|
||||
/// the current reference orbit (cheap `Arc` clone). The orbit is only re-uploaded
|
||||
/// when its `generation` changes; the expensive iteration pass re-runs only when
|
||||
/// a geometry input changes, and colour-only changes re-run just the cheap
|
||||
/// colourise pass (see `prepare`).
|
||||
/// to the GPU when its `generation` changes, and the fractal is only re-rendered
|
||||
/// into the cache when the uniforms, generation, or `size_px` change.
|
||||
pub struct FractalCallback {
|
||||
pub uniforms: Uniforms,
|
||||
pub lights: Vec<Light>,
|
||||
pub reference: Arc<Vec<[f32; 2]>>,
|
||||
/// BLA table for the current reference (empty when BLA is off/unsupported).
|
||||
pub bla: Arc<Vec<Bla>>,
|
||||
pub generation: u64,
|
||||
/// Widget size in physical pixels — the cache texture resolution.
|
||||
pub size_px: [u32; 2],
|
||||
@@ -894,47 +670,39 @@ impl egui_wgpu::CallbackTrait for FractalCallback {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.reference[..count]),
|
||||
);
|
||||
let bla_count = self.bla.len().min(MAX_BLA_ENTRIES);
|
||||
if bla_count > 0 {
|
||||
queue.write_buffer(
|
||||
&renderer.bla_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.bla[..bla_count]),
|
||||
);
|
||||
}
|
||||
renderer.uploaded_generation = self.generation;
|
||||
}
|
||||
|
||||
// Iteration (expensive) re-runs only when the geometry inputs change;
|
||||
// colourise (cheap) re-runs when it did, or when only a colour changed —
|
||||
// so palette / colour-scale / offset tweaks (e.g. colour cycling) skip
|
||||
// the perturbation entirely.
|
||||
let iter_dirty = renderer.iterated.as_ref().is_none_or(|r| {
|
||||
// Re-render the cache only when what it depends on changed.
|
||||
let dirty = renderer.rendered.as_ref().is_none_or(|r| {
|
||||
r.generation != self.generation
|
||||
|| r.width != width
|
||||
|| r.height != height
|
||||
|| geom_differs(&r.uniforms, &self.uniforms)
|
||||
|| bytemuck::bytes_of(&r.uniforms) != bytemuck::bytes_of(&self.uniforms)
|
||||
});
|
||||
let color_dirty = iter_dirty
|
||||
|| renderer.colored.as_ref().is_none_or(|c| {
|
||||
c.width != width || c.height != height || color_differs(&c.uniforms, &self.uniforms)
|
||||
})
|
||||
|| true;
|
||||
|
||||
if !color_dirty {
|
||||
return Vec::new(); // cache still valid; paint() just blits it
|
||||
if !dirty {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Both passes read the uniform buffer; refresh it once.
|
||||
queue.write_buffer(
|
||||
&renderer.uniform_buffer,
|
||||
0,
|
||||
bytemuck::bytes_of(&self.uniforms),
|
||||
);
|
||||
let mut bytes = [0; size_of::<Light>() * MAX_LIGHT_COUNT];
|
||||
bytes[..self.lights.len() * size_of::<Light>()]
|
||||
.copy_from_slice(bytemuck::cast_slice(&self.lights));
|
||||
queue.write_buffer(&renderer.lights_buffer, 0, &bytes);
|
||||
|
||||
if let Some(cache) = &renderer.cache {
|
||||
if iter_dirty {
|
||||
// Iteration pass: perturbation iterate → data texture.
|
||||
let mut pass = egui_encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("fractal iterate pass"),
|
||||
label: Some("fractal cache pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &cache.data_view,
|
||||
view: &cache.view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
@@ -947,46 +715,17 @@ impl egui_wgpu::CallbackTrait for FractalCallback {
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
pass.set_pipeline(&renderer.iterate_pipeline);
|
||||
pass.set_pipeline(&renderer.pipeline);
|
||||
pass.set_bind_group(0, &renderer.bind_group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
// Colourise pass: data texture → colour texture.
|
||||
let mut pass = egui_encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("fractal colorize pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &cache.color_view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
pass.set_pipeline(&renderer.colorize_pipeline);
|
||||
pass.set_bind_group(0, &cache.colorize_bind_group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
if iter_dirty {
|
||||
renderer.iterated = Some(IterState {
|
||||
renderer.rendered = Some(RenderedState {
|
||||
uniforms: self.uniforms,
|
||||
generation: self.generation,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
renderer.colored = Some(ColorState {
|
||||
uniforms: self.uniforms,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
|
||||
+4
-39
@@ -21,44 +21,28 @@ pub struct ShareState {
|
||||
pub half_height: f64,
|
||||
pub iterations: u32,
|
||||
pub julia_c: (f64, f64),
|
||||
/// Distortion constant for the Phoenix kind (ignored by others).
|
||||
pub phoenix_p: (f64, f64),
|
||||
/// Distortion constant for the Lambda kind (ignored by others).
|
||||
pub lambda_l: (f64, f64),
|
||||
pub color_scale: f32,
|
||||
pub color_offset: f32,
|
||||
/// Palette index (`palette_id` in the shader).
|
||||
pub palette: u32,
|
||||
/// Shadow palette index (`shadow_palette_id` in the shader).
|
||||
pub shadow_palette: u32,
|
||||
}
|
||||
|
||||
impl ShareState {
|
||||
pub fn encode(&self) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str(if self.julia { "m=j" } else { "m=m" });
|
||||
s.push_str(&format!(
|
||||
"&f={}",
|
||||
match self.kind {
|
||||
s.push_str(&format!("&f={}", match self.kind {
|
||||
FractalKind::Mandelbrot => "mandel",
|
||||
FractalKind::BurningShip => "burning",
|
||||
FractalKind::Multibrot => "multi",
|
||||
FractalKind::Tricorn => "tricorn",
|
||||
FractalKind::Celtic => "celtic",
|
||||
FractalKind::Perpendicular => "perp",
|
||||
FractalKind::Buffalo => "buffalo",
|
||||
FractalKind::Phoenix => "phoenix",
|
||||
FractalKind::Lambda => "lambda",
|
||||
}
|
||||
));
|
||||
}));
|
||||
s.push_str(&format!("&pw={}", self.power));
|
||||
s.push_str(&format!(
|
||||
"&re={}&im={}&hh={}&it={}",
|
||||
self.center_re, self.center_im, self.half_height, self.iterations
|
||||
));
|
||||
s.push_str(&format!("&jr={}&ji={}", self.julia_c.0, self.julia_c.1));
|
||||
s.push_str(&format!("&px={}&py={}", self.phoenix_p.0, self.phoenix_p.1));
|
||||
s.push_str(&format!("&lx={}&ly={}", self.lambda_l.0, self.lambda_l.1));
|
||||
s.push_str(&format!(
|
||||
"&cs={}&co={}&pal={}",
|
||||
self.color_scale, self.color_offset, self.palette
|
||||
@@ -84,12 +68,7 @@ impl ShareState {
|
||||
"multi" => FractalKind::Multibrot,
|
||||
"burning" => FractalKind::BurningShip,
|
||||
"tricorn" => FractalKind::Tricorn,
|
||||
"celtic" => FractalKind::Celtic,
|
||||
"perp" => FractalKind::Perpendicular,
|
||||
"buffalo" => FractalKind::Buffalo,
|
||||
"phoenix" => FractalKind::Phoenix,
|
||||
"lambda" => FractalKind::Lambda,
|
||||
_ => FractalKind::Mandelbrot,
|
||||
_ => FractalKind::Mandelbrot
|
||||
})
|
||||
.unwrap_or(FractalKind::Mandelbrot),
|
||||
power: map.get("pw").and_then(|s| s.parse().ok()).unwrap_or(2),
|
||||
@@ -101,18 +80,9 @@ impl ShareState {
|
||||
map.get("jr").and_then(|s| s.parse().ok()).unwrap_or(-0.8),
|
||||
map.get("ji").and_then(|s| s.parse().ok()).unwrap_or(0.156),
|
||||
),
|
||||
phoenix_p: (
|
||||
map.get("px").and_then(|s| s.parse().ok()).unwrap_or(-0.5),
|
||||
map.get("py").and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
),
|
||||
lambda_l: (
|
||||
map.get("lx").and_then(|s| s.parse().ok()).unwrap_or(-0.5),
|
||||
map.get("ly").and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
),
|
||||
color_scale: map.get("cs").and_then(|s| s.parse().ok()).unwrap_or(0.02),
|
||||
color_offset: map.get("co").and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
palette: map.get("pal").and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||
shadow_palette: map.get("spal").and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -125,19 +95,16 @@ mod tests {
|
||||
fn round_trip() {
|
||||
let s = ShareState {
|
||||
julia: true,
|
||||
kind: FractalKind::Phoenix,
|
||||
kind: FractalKind::Multibrot,
|
||||
power: 5,
|
||||
center_re: "-0.743643887037158704752191506114774".into(),
|
||||
center_im: "0.131825904205311970493132056385139".into(),
|
||||
half_height: 1.5e-20,
|
||||
iterations: 4000,
|
||||
julia_c: (-0.123, 0.745),
|
||||
phoenix_p: (-0.5, 0.1),
|
||||
lambda_l: (-0.5, 0.0),
|
||||
color_scale: 0.02,
|
||||
color_offset: 0.25,
|
||||
palette: 3,
|
||||
shadow_palette: 1,
|
||||
};
|
||||
let d = ShareState::decode(&s.encode()).unwrap();
|
||||
assert_eq!(d.julia, s.julia);
|
||||
@@ -148,9 +115,7 @@ mod tests {
|
||||
assert_eq!(d.half_height, s.half_height);
|
||||
assert_eq!(d.iterations, s.iterations);
|
||||
assert_eq!(d.julia_c, s.julia_c);
|
||||
assert_eq!(d.phoenix_p, s.phoenix_p);
|
||||
assert_eq!(d.palette, s.palette);
|
||||
assert_eq!(d.shadow_palette, s.shadow_palette);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
// Headless PNG rendering: parse the CLI, build the exact same view/state the
|
||||
// windowed app would from it, then render straight to a file. No window, no
|
||||
// event loop, no worker-thread debounce (nothing to debounce for a one-shot
|
||||
// render); it just creates its own wgpu device, computes the reference orbit
|
||||
// once, and renders through the same `ExportRender` path the "Export PNG"
|
||||
// button uses.
|
||||
|
||||
use eframe::egui_wgpu::wgpu;
|
||||
|
||||
use crate::app::{FractalApp, unix_timestamp};
|
||||
use crate::cli::Cli;
|
||||
use crate::fractal::{ExportRender, FractalRenderer, export_to_png_blocking};
|
||||
|
||||
/// Cap on the output image dimension (px), to stay within GPU texture limits.
|
||||
const MAX_DIM: u32 = 8192 * 16;
|
||||
|
||||
pub fn run(cli: Cli) -> Result<(), String> {
|
||||
if cli.buddhabrot {
|
||||
return Err("headless mode doesn't support --buddhabrot yet".into());
|
||||
}
|
||||
|
||||
let width = cli.width.clamp(16, MAX_DIM);
|
||||
let height = cli.height.clamp(16, MAX_DIM);
|
||||
let export_path = cli
|
||||
.export_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp()));
|
||||
|
||||
let mut app = FractalApp::default_state();
|
||||
app.apply_cli(cli);
|
||||
|
||||
eprintln!("computing reference orbit…");
|
||||
app.compute_reference_blocking();
|
||||
|
||||
let (device, queue) = pollster::block_on(request_device())?;
|
||||
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
|
||||
let renderer = FractalRenderer::new(&device, format);
|
||||
let (pipeline, bind_group_layout, format) = renderer.export_handles();
|
||||
|
||||
let uniforms = app.make_uniforms(width as f64 / height as f64);
|
||||
let er = ExportRender::new(
|
||||
&device,
|
||||
&queue,
|
||||
pipeline,
|
||||
&bind_group_layout,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
uniforms,
|
||||
app.reference_points(),
|
||||
);
|
||||
|
||||
eprintln!("rendering {width}×{height}…");
|
||||
let png = export_to_png_blocking(&device, &queue, &er, |phase, fraction| {
|
||||
eprint!("\r{phase} {:>3.0}%", fraction * 100.0);
|
||||
});
|
||||
eprintln!();
|
||||
|
||||
std::fs::write(&export_path, &png).map_err(|e| format!("save failed: {e}"))?;
|
||||
println!("saved {export_path} ({width}×{height})");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set up a wgpu device with no surface/window attached, matching the limits
|
||||
/// `main::wgpu_options` requests for the windowed app (the fractal fragment
|
||||
/// shader needs storage buffers, which downlevel/WebGL-style limits disallow).
|
||||
async fn request_device() -> Result<(wgpu::Device, wgpu::Queue), String> {
|
||||
let instance = wgpu::Instance::default();
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions::default())
|
||||
.await
|
||||
.map_err(|e| format!("no compatible GPU adapter: {e}"))?;
|
||||
adapter
|
||||
.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("headless fractal device"),
|
||||
required_features: wgpu::Features::empty(),
|
||||
required_limits: adapter.limits(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("failed to create device: {e}"))
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use egui::{Color32, Ui};
|
||||
|
||||
/// Maximum number of simultaneous lights.
|
||||
pub const MAX_LIGHT_COUNT: usize = 16;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Zeroable, Pod)]
|
||||
#[repr(C)]
|
||||
pub struct Light {
|
||||
pub azimuth: f32,
|
||||
pub altitude: f32,
|
||||
pub color: Color32,
|
||||
pub _pad: u32,
|
||||
}
|
||||
|
||||
impl Default for Light {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
azimuth: PI / 4.,
|
||||
altitude: PI / 4.,
|
||||
color: Color32::WHITE,
|
||||
_pad: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Light {
|
||||
pub fn widget(&mut self, ui: &mut Ui) -> bool {
|
||||
let formater = |v, _| format!("{}°", ((v as f32 * 180. / PI) as u32));
|
||||
ui.horizontal(|ui| {
|
||||
let del = ui.button("-").clicked();
|
||||
ui.label("color:");
|
||||
ui.color_edit_button_srgba(&mut self.color);
|
||||
ui.label("θ:");
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut self.azimuth)
|
||||
.range(0.0..=PI * 2.)
|
||||
.custom_formatter(formater)
|
||||
.speed(0.02),
|
||||
);
|
||||
ui.label("φ:");
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut self.altitude)
|
||||
.range(0.0..=PI / 2.)
|
||||
.custom_formatter(formater)
|
||||
.speed(0.02),
|
||||
);
|
||||
|
||||
del
|
||||
})
|
||||
.inner
|
||||
}
|
||||
}
|
||||
+3
-24
@@ -1,7 +1,3 @@
|
||||
// Without these, rust fails to infer Send/Sync trait impls
|
||||
// Probably caused by the new trait solver
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
// Fractal Explorer — Rust + wgpu + egui + WGSL deep-zoom Mandelbrot.
|
||||
//
|
||||
// A single binary drives both native and web (WASM/WebGPU) builds; the two
|
||||
@@ -10,13 +6,8 @@
|
||||
|
||||
mod app;
|
||||
mod fractal;
|
||||
mod lights;
|
||||
mod view;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod cli;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod headless;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod worker;
|
||||
|
||||
@@ -34,12 +25,13 @@ fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
|
||||
let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
|
||||
if let WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
|
||||
setup.device_descriptor =
|
||||
std::sync::Arc::new(|adapter: &wgpu::Adapter| wgpu::DeviceDescriptor {
|
||||
setup.device_descriptor = std::sync::Arc::new(|adapter: &wgpu::Adapter| {
|
||||
wgpu::DeviceDescriptor {
|
||||
label: Some("fractal wgpu device"),
|
||||
required_features: wgpu::Features::empty(),
|
||||
required_limits: adapter.limits(),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
@@ -51,24 +43,11 @@ fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() -> eframe::Result {
|
||||
use clap::Parser as _;
|
||||
|
||||
env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.parse_default_env()
|
||||
.init();
|
||||
|
||||
let cli = cli::Cli::parse();
|
||||
if cli.headless {
|
||||
return match headless::run(cli) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let native_options = eframe::NativeOptions {
|
||||
renderer: eframe::Renderer::Wgpu,
|
||||
wgpu_options: wgpu_options(),
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
// Buddhabrot / Nebulabrot rendering: a Monte-Carlo density histogram of
|
||||
// escaping orbits, accumulated progressively across frames by a compute pass,
|
||||
// then tone-mapped to colour by a fragment pass every frame.
|
||||
//
|
||||
// This does NOT use the deep-zoom perturbation/reference-orbit machinery in
|
||||
// mandelbrot.wgsl: Buddhabrot's structure is a global Monte-Carlo property of
|
||||
// the whole basin (a random sample's orbit scatters across the *whole* image,
|
||||
// not just its own pixel), so the "gather" per-pixel model doesn't apply, and
|
||||
// deep zoom isn't meaningful for it the way it is for the escape-time set.
|
||||
// Samples are iterated directly in f32 from the current view's bounds.
|
||||
//
|
||||
// Sampling convention: for KIND_LAMBDA the formula z -> l*z*(1-z) has no `c`
|
||||
// term at all (l is a fixed distortion constant, not a per-sample parameter),
|
||||
// so the randomly sampled point instead seeds z0 (a "Julia-Buddhabrot" over
|
||||
// z0 with l fixed). Every other kind samples c with z0 = 0, matching its
|
||||
// ordinary parameter plane.
|
||||
//
|
||||
// A sample's orbit is only plotted if it escapes within b_cap iterations (the
|
||||
// classic Buddhabrot rule: only escaping orbits are drawn). Its points are
|
||||
// then splat into up to three histogram channels by cap (r_cap <= g_cap <=
|
||||
// b_cap): fast-escaping (common) orbits light all three channels (bright),
|
||||
// slow-escaping (rare) orbits only light the b_cap channel — the classic
|
||||
// Nebulabrot false-colour split.
|
||||
//
|
||||
// Two-pass iteration avoids needing a per-thread orbit buffer sized to
|
||||
// max_iter: the first pass just finds the escape iteration (if any); the
|
||||
// second replays the same orbit from scratch, splatting each point.
|
||||
|
||||
struct Uniforms {
|
||||
center: vec2<f32>,
|
||||
half_height: f32,
|
||||
aspect: f32,
|
||||
phoenix_p: vec2<f32>,
|
||||
lambda_l: vec2<f32>,
|
||||
bailout_sq: f32,
|
||||
kind: u32,
|
||||
power: u32,
|
||||
r_cap: u32,
|
||||
g_cap: u32,
|
||||
b_cap: u32,
|
||||
seed: u32,
|
||||
samples_this_dispatch: u32,
|
||||
exposure: f32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
total_samples: f32,
|
||||
// Tonemap colour style: 0 = classic (R/G/B = raw caps), 1 = nebula
|
||||
// (yellow core, blue halo), 2 = grayscale.
|
||||
palette: u32,
|
||||
// Padding to a 16-byte multiple. NOT vec3<u32> — that type aligns to 16
|
||||
// bytes in WGSL (unlike Rust's `[u32; 3]`, which aligns to 4), which
|
||||
// silently added 32 bytes instead of 16 and mismatched the Rust struct's
|
||||
// size (a wgpu validation error at dispatch time: "size 96 where the
|
||||
// shader expects 112").
|
||||
_pad0: u32,
|
||||
_pad1: u32,
|
||||
_pad2: u32,
|
||||
};
|
||||
|
||||
const PALETTE_NEBULA: u32 = 0u;
|
||||
const PALETTE_YELLOW: u32 = 1u;
|
||||
const PALETTE_GRAYSCALE: u32 = 2u;
|
||||
|
||||
const KIND_MANDELBROT: u32 = 0u;
|
||||
const KIND_BURNING_SHIP: u32 = 1u;
|
||||
const KIND_TRICORN: u32 = 2u;
|
||||
const KIND_MULTIBROT: u32 = 3u;
|
||||
const KIND_CELTIC: u32 = 4u;
|
||||
const KIND_PERPENDICULAR: u32 = 5u;
|
||||
const KIND_BUFFALO: u32 = 6u;
|
||||
const KIND_PHOENIX: u32 = 7u;
|
||||
const KIND_LAMBDA: u32 = 8u;
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
// Compute pass: read-write atomic histogram (3 planes of width*height, R/G/B).
|
||||
@group(0) @binding(1) var<storage, read_write> histogram: array<atomic<u32>>;
|
||||
// Tonemap pass: read-only plain view of the same buffer.
|
||||
@group(0) @binding(2) var<storage, read> tm_histogram: array<u32>;
|
||||
|
||||
// --- RNG: a small, fast integer hash (WGSL has no native RNG). ---
|
||||
fn hash_u32(x: u32) -> u32 {
|
||||
var h = x;
|
||||
h = h ^ (h >> 16u);
|
||||
h = h * 0x7feb352du;
|
||||
h = h ^ (h >> 15u);
|
||||
h = h * 0x846ca68bu;
|
||||
h = h ^ (h >> 16u);
|
||||
return h;
|
||||
}
|
||||
fn rand01(seed: u32) -> f32 {
|
||||
return f32(hash_u32(seed)) * (1.0 / 4294967295.0);
|
||||
}
|
||||
|
||||
fn cmul(a: vec2<f32>, b: vec2<f32>) -> vec2<f32> {
|
||||
return vec2<f32>(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x);
|
||||
}
|
||||
|
||||
fn complex_pow(z: vec2<f32>, p: u32) -> vec2<f32> {
|
||||
var r = vec2<f32>(1.0, 0.0);
|
||||
for (var i: u32 = 0u; i < p; i = i + 1u) {
|
||||
r = cmul(r, z);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// One iteration step z_n -> z_{n+1} for the current kind. `zp` is the
|
||||
// previous iterate (z_{n-1}), used only by the Phoenix two-term recurrence.
|
||||
// Must match `FractalKind` in reference.rs (the direct, non-perturbative form
|
||||
// of the same formulas).
|
||||
fn advance(z: vec2<f32>, zp: vec2<f32>, c: vec2<f32>) -> vec2<f32> {
|
||||
if u.kind == KIND_BURNING_SHIP {
|
||||
return vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * abs(z.x * z.y)) + c;
|
||||
} else if u.kind == KIND_TRICORN {
|
||||
return vec2<f32>(z.x * z.x - z.y * z.y, -2.0 * z.x * z.y) + c;
|
||||
} else if u.kind == KIND_MULTIBROT {
|
||||
return complex_pow(z, clamp(u.power, 2u, 8u)) + c;
|
||||
} else if u.kind == KIND_CELTIC {
|
||||
return vec2<f32>(abs(z.x * z.x - z.y * z.y), 2.0 * z.x * z.y) + c;
|
||||
} else if u.kind == KIND_PERPENDICULAR {
|
||||
return vec2<f32>(z.x * z.x - z.y * z.y, -2.0 * z.x * abs(z.y)) + c;
|
||||
} else if u.kind == KIND_BUFFALO {
|
||||
return vec2<f32>(abs(z.x * z.x - z.y * z.y), -abs(2.0 * z.x * z.y)) + c;
|
||||
} else if u.kind == KIND_PHOENIX {
|
||||
let sq = vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y);
|
||||
return sq + c + cmul(u.phoenix_p, zp);
|
||||
} else if u.kind == KIND_LAMBDA {
|
||||
// l * z * (1 - z); c is unused (see file doc comment above).
|
||||
return cmul(u.lambda_l, cmul(z, vec2<f32>(1.0 - z.x, -z.y)));
|
||||
}
|
||||
return vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c; // Mandelbrot
|
||||
}
|
||||
|
||||
// Map a complex-plane point to a flat pixel index, or -1 if outside the
|
||||
// current viewport (the sampling region and the display region are the same).
|
||||
//
|
||||
// This must be the exact inverse of how `view.rs::pan_pixels`/`zoom_at_pixel`
|
||||
// relate screen pixels to world points (those are the confirmed-correct,
|
||||
// user-tested ground truth — NOT the shader-comment-derived convention tried
|
||||
// here previously, which was wrong: dragging/zooming treat +y screen exactly
|
||||
// like +x, no flip, so screen-down means im *increasing*, not decreasing).
|
||||
fn pixel_index(p: vec2<f32>) -> i32 {
|
||||
let half_w = u.half_height * u.aspect;
|
||||
let uu = (p.x - u.center.x) / half_w * 0.5 + 0.5;
|
||||
let vv = 0.5 + (p.y - u.center.y) / u.half_height * 0.5;
|
||||
if uu < 0.0 || uu >= 1.0 || vv < 0.0 || vv >= 1.0 {
|
||||
return -1;
|
||||
}
|
||||
let px = i32(uu * f32(u.width));
|
||||
let py = i32(vv * f32(u.height));
|
||||
return py * i32(u.width) + px;
|
||||
}
|
||||
|
||||
// Splat one visited orbit point into the R/G/B histogram planes it qualifies
|
||||
// for by the orbit's total escape iteration `n` (nested caps: a fast escape
|
||||
// lights all three; only a slow, rare one lights just the blue plane).
|
||||
fn splat(p: vec2<f32>, n: u32) {
|
||||
let idx = pixel_index(p);
|
||||
if idx < 0 {
|
||||
return;
|
||||
}
|
||||
let plane = i32(u.width) * i32(u.height);
|
||||
if n <= u.b_cap {
|
||||
atomicAdd(&histogram[idx + 2 * plane], 1u);
|
||||
}
|
||||
if n <= u.g_cap {
|
||||
atomicAdd(&histogram[idx + plane], 1u);
|
||||
}
|
||||
if n <= u.r_cap {
|
||||
atomicAdd(&histogram[idx], 1u);
|
||||
}
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn cs_main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if gid.x >= u.samples_this_dispatch {
|
||||
return;
|
||||
}
|
||||
|
||||
let base = hash_u32(gid.x ^ (u.seed * 0x9e3779b9u));
|
||||
let rx = rand01(base);
|
||||
let ry = rand01(hash_u32(base ^ 0x68bc21ebu));
|
||||
let half_w = u.half_height * u.aspect;
|
||||
let sample = vec2<f32>(
|
||||
u.center.x + (rx * 2.0 - 1.0) * half_w,
|
||||
u.center.y + (ry * 2.0 - 1.0) * u.half_height,
|
||||
);
|
||||
|
||||
var c = sample;
|
||||
var z0 = vec2<f32>(0.0, 0.0);
|
||||
if u.kind == KIND_LAMBDA {
|
||||
c = vec2<f32>(0.0, 0.0); // unused by the Lambda step
|
||||
z0 = sample;
|
||||
}
|
||||
|
||||
// First pass: just find the escape iteration (if any).
|
||||
var zp = vec2<f32>(0.0, 0.0);
|
||||
var z = z0;
|
||||
var n: u32 = 0u;
|
||||
var escaped = false;
|
||||
loop {
|
||||
if dot(z, z) > u.bailout_sq {
|
||||
escaped = true;
|
||||
break;
|
||||
}
|
||||
if n >= u.b_cap {
|
||||
break;
|
||||
}
|
||||
let next = advance(z, zp, c);
|
||||
zp = z;
|
||||
z = next;
|
||||
n = n + 1u;
|
||||
}
|
||||
if !escaped || n == 0u {
|
||||
return;
|
||||
}
|
||||
|
||||
// Second pass: replay the same orbit, splatting each visited point.
|
||||
// z0 itself is not splat: it's the same fixed point (0,0), or the sample
|
||||
// itself for Lambda, for every orbit — plotting it would just spike the
|
||||
// origin instead of showing the orbit's actual shape.
|
||||
zp = vec2<f32>(0.0, 0.0);
|
||||
z = z0;
|
||||
for (var i: u32 = 0u; i < n; i = i + 1u) {
|
||||
let next = advance(z, zp, c);
|
||||
zp = z;
|
||||
z = next;
|
||||
splat(z, n);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tonemap: histogram counts -> colour, drawn as a fullscreen triangle. ---
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4<f32> {
|
||||
var verts = array<vec2<f32>, 3>(
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>(3.0, -1.0),
|
||||
vec2<f32>(-1.0, 3.0),
|
||||
);
|
||||
return vec4<f32>(verts[idx], 0.0, 1.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_tonemap(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let x = i32(pos.x);
|
||||
let y = i32(pos.y);
|
||||
if x < 0 || y < 0 || x >= i32(u.width) || y >= i32(u.height) {
|
||||
return vec4<f32>(0.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
let idx = y * i32(u.width) + x;
|
||||
let plane = i32(u.width) * i32(u.height);
|
||||
let r = f32(tm_histogram[idx]);
|
||||
let g = f32(tm_histogram[idx + plane]);
|
||||
let b = f32(tm_histogram[idx + 2 * plane]);
|
||||
|
||||
// Normalize by the *average* density (total samples / pixel count) rather
|
||||
// than total samples alone, so the scale stays sane across widget sizes
|
||||
// and sample-dispatch rates. Buddhabrot density is extremely peaked (the
|
||||
// brightest pixels run tens of times the average), so the compressive
|
||||
// exponential tonemap only needs a small fraction of the average to reach
|
||||
// full brightness at those peaks; 0.05 is a hand-tuned starting point,
|
||||
// the exposure slider covers the rest.
|
||||
let avg_density = max(u.total_samples / f32(u.width * u.height), 1.0e-6);
|
||||
let scale = u.exposure * 0.05 / avg_density;
|
||||
// Per-cap brightness, each already compressed to [0,1]. Nested caps mean
|
||||
// r <= g <= b pointwise (every orbit counted in a smaller cap is also
|
||||
// counted in every larger one), so fb alone is the full escaping-orbit
|
||||
// density and fr picks out just the common, fast-escaping ones.
|
||||
let fr = 1.0 - exp(-r * scale);
|
||||
let fg = 1.0 - exp(-g * scale);
|
||||
let fb = 1.0 - exp(-b * scale);
|
||||
|
||||
var col: vec3<f32>;
|
||||
if u.palette == PALETTE_YELLOW {
|
||||
// fr is *not* a good stand-alone brightness signal: with c sampled
|
||||
// uniformly over the whole viewport, nearly every sample outside the
|
||||
// set escapes within a handful of iterations and splats a couple of
|
||||
// points near itself, so fr is a near-uniform wash across the entire
|
||||
// image (not concentrated near the boundary the way fb is) — adding
|
||||
// it directly (tried first, both raw and gamma-lifted) drags that
|
||||
// wash up to full brightness and floods the background with solid
|
||||
// colour. Instead use it as a *multiplicative* warm (yellow) tint on
|
||||
// top of fb's brightness, so it only shows up where fb is already
|
||||
// bright (i.e. real near-boundary density) and stays near-zero across
|
||||
// the background (fb ≈ 0 there, so warmth * fb ≈ 0 regardless of fr).
|
||||
col = vec3<f32>(
|
||||
fb + fb * fr * 1.3,
|
||||
fb + fb * fr * 0.6,
|
||||
fb,
|
||||
);
|
||||
} else if u.palette == PALETTE_GRAYSCALE {
|
||||
// fb is the full escaping-orbit density (the cumulative superset);
|
||||
// reuse it directly as a single luminance channel.
|
||||
col = vec3<f32>(fb, fb, fb);
|
||||
} else {
|
||||
col = vec3<f32>(fr, fg, fb); // classic: raw per-cap R/G/B
|
||||
}
|
||||
return vec4<f32>(clamp(col, vec3<f32>(0.0), vec3<f32>(1.0)), 1.0);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
// Colourise pass: map the iteration pass's per-pixel escape data (from
|
||||
// `mandelbrot.wgsl`'s `fs_data`) through the palette. This is the only
|
||||
// color-dependent step, so changing the palette / colour scale / offset (e.g.
|
||||
// colour cycling) re-runs just this cheap pass — the expensive perturbation
|
||||
// iteration in the data texture is reused untouched.
|
||||
//
|
||||
// The data texture holds, per texel: R = ci (palette parameter), G = DE
|
||||
// darkening factor, B = interior fraction (for boundary anti-aliasing). It is
|
||||
// the same resolution as this pass's target, so we read it with `textureLoad`
|
||||
// at the fragment's integer pixel coordinate (nearest — iteration data must not
|
||||
// be linearly filtered across escape boundaries).
|
||||
|
||||
// Must match `Uniforms` in mandelbrot.wgsl / the Rust `Uniforms` struct.
|
||||
struct Uniforms {
|
||||
span: vec2<f32>,
|
||||
max_iter: u32,
|
||||
ref_len: u32,
|
||||
color_offset: f32,
|
||||
color_scale: f32,
|
||||
bailout_sq: f32,
|
||||
is_julia: u32,
|
||||
palette_id: u32,
|
||||
shadow_palette_id: u32,
|
||||
aa_level: u32,
|
||||
kind: u32,
|
||||
power: u32,
|
||||
dc_offset: vec2<f32>,
|
||||
phoenix_p: vec2<f32>,
|
||||
lambda_l: vec2<f32>,
|
||||
de_coloring: u32,
|
||||
shadow: u32,
|
||||
};
|
||||
|
||||
struct Light {
|
||||
azimuth: f32,
|
||||
altitude: f32,
|
||||
color: u32,
|
||||
_pad: u32
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
@group(0) @binding(1) var data_tex: texture_2d<f32>;
|
||||
@group(0) @binding(2) var<uniform> lights: array<Light, 16>;
|
||||
|
||||
// Smooth cyclic palettes (Inigo Quilez cosine palettes). Must match the palette
|
||||
// in mandelbrot.wgsl.
|
||||
fn palette(id: u32, t: f32) -> vec3<f32> {
|
||||
if id == 4u {
|
||||
return vec3<f32>(t, t, t); // grayscale
|
||||
}
|
||||
let a = vec3<f32>(0.5, 0.5, 0.5);
|
||||
let b = vec3<f32>(0.5, 0.5, 0.5);
|
||||
var c = vec3<f32>(1.0, 1.0, 1.0);
|
||||
var d = vec3<f32>(0.00, 0.10, 0.20); // 0: amber / blue
|
||||
if id == 1u {
|
||||
d = vec3<f32>(0.00, 0.33, 0.67); // rainbow
|
||||
} else if id == 2u {
|
||||
d = vec3<f32>(0.30, 0.20, 0.20); // warm ember
|
||||
} else if id == 3u {
|
||||
c = vec3<f32>(1.0, 1.0, 0.5);
|
||||
d = vec3<f32>(0.80, 0.90, 0.30); // lime / magenta
|
||||
}
|
||||
return a + b * cos(6.28318530718 * (c * t + d));
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4<f32> {
|
||||
var verts = array<vec2<f32>, 3>(
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>(3.0, -1.0),
|
||||
vec2<f32>(-1.0, 3.0),
|
||||
);
|
||||
return vec4<f32>(verts[idx], 0.0, 1.0);
|
||||
}
|
||||
|
||||
fn load(x: i32, y: i32) -> vec3<f32> {
|
||||
let dist = textureLoad(data_tex, vec2<i32>(x, y), 0).g;
|
||||
return vec3<f32>(f32(x), f32(y), dist);
|
||||
}
|
||||
|
||||
fn compute_light(normal: vec3<f32>, light: vec3<f32>) -> vec3<f32> {
|
||||
return vec3<f32>(max(0., dot(normal, normalize(light))));
|
||||
}
|
||||
|
||||
fn uncharted2tonemap(x: vec3<f32>) -> vec3<f32> {
|
||||
let A = 0.15; // Shoulder strength
|
||||
let B = 0.50; // Linear strength
|
||||
let C = 0.10; // Linear angle
|
||||
let D = 0.20; // Toe strength
|
||||
let E = 0.02; // Toe numerator / shoarder angle/etc.
|
||||
let F = 0.30; // Toe denominator
|
||||
|
||||
return ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F;
|
||||
}
|
||||
|
||||
fn filmic(color: vec3<f32>, white_point: f32) -> vec3<f32> {
|
||||
let exposure_bias = 2.0;
|
||||
let curr = uncharted2tonemap(color * exposure_bias);
|
||||
|
||||
// Valeur blanche maximale de référence
|
||||
let white_scale = vec3(1.0) / uncharted2tonemap(vec3(white_point));
|
||||
return curr * white_scale;
|
||||
}
|
||||
|
||||
fn s(color: vec3<f32>, k: f32, c: f32) -> vec3<f32> {
|
||||
return 1. / (1. + exp(-k * (color - c)));
|
||||
}
|
||||
|
||||
fn contrast(color: vec3<f32>, k: f32, c: f32) -> vec3<f32> {
|
||||
let color_c = s(color, k, c);
|
||||
|
||||
return (color_c - s(vec3<f32>(0), k, c)) / (s(vec3<f32>(1), k, c) - s(vec3<f32>(0), k, c));
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
if u.shadow != 0u {
|
||||
if textureLoad(data_tex, vec2<i32>(i32(pos.x), i32(pos.y)), 0).b != 0. {
|
||||
return vec4<f32>(0.1, 0.1, 0.1, 1.0);
|
||||
} else {
|
||||
let d = array<vec3<f32>, 3>(load(i32(pos.x), i32(pos.y)), load(i32(pos.x + 1), i32(pos.y)), load(i32(pos.x), i32(pos.y + 1)));
|
||||
|
||||
let normal = normalize(cross(d[1] - d[0], d[2] - d[0]));
|
||||
|
||||
var color: vec3<f32>;
|
||||
if u.shadow_palette_id == 0u {
|
||||
color = compute_light(normal,vec3<f32>(.5, .5, .5)) + vec3<f32>(0.58, 0.85, 1.) * 0.2;
|
||||
|
||||
color = filmic(color, 2.5);
|
||||
color = contrast(color, 4., 0.67);
|
||||
} else if u.shadow_palette_id == 1u {
|
||||
color = compute_light(normal, vec3<f32>(0., .5, .5)) * vec3<f32>(1., 0.5, 0.5) + compute_light(normal, vec3<f32>(0.5, 0., .5)) * vec3<f32>(0.5, 1., 1.);
|
||||
|
||||
color = filmic(color, 4.2);
|
||||
} else {
|
||||
color = vec3<f32>(0);
|
||||
var light_count = 0;
|
||||
for (var i = 0u ; i < 16; i++) {
|
||||
let light_color = unpack4x8unorm(lights[i].color);
|
||||
if any(light_color != vec4<f32>(0)) {
|
||||
light_count += 1;
|
||||
}
|
||||
|
||||
color += compute_light(normal, vec3<f32>(
|
||||
cos(lights[i].azimuth) * cos(lights[i].altitude),
|
||||
sin(lights[i].azimuth) * cos(lights[i].altitude),
|
||||
sin(lights[i].altitude))) * light_color.xyz * light_color.a;
|
||||
}
|
||||
|
||||
color = filmic(color, 1. + f32(light_count));
|
||||
}
|
||||
|
||||
return vec4<f32>(color, 1.0);
|
||||
}
|
||||
} else {
|
||||
let d = textureLoad(data_tex, vec2<i32>(i32(pos.x), i32(pos.y)), 0);
|
||||
let ci = d.r;
|
||||
let de = d.g;
|
||||
let interior_frac = d.b;
|
||||
|
||||
let t = fract(ci * u.color_scale + u.color_offset);
|
||||
var col = palette(u.palette_id, t) * de;
|
||||
// Anti-alias the set boundary: fade toward black by the fraction of the
|
||||
// pixel's sub-samples that landed in the interior.
|
||||
col = col * (1.0 - interior_frac);
|
||||
return vec4<f32>(col, 1.0);
|
||||
}
|
||||
}
|
||||
+114
-154
@@ -21,37 +21,37 @@ struct Uniforms {
|
||||
bailout_sq: f32,
|
||||
is_julia: u32,
|
||||
palette_id: u32,
|
||||
shadow_palette_id: u32,
|
||||
aa_level: u32,
|
||||
// Iteration formula (see the KIND_* constants below).
|
||||
// Iteration formula: 0 Mandelbrot, 1 Burning Ship, 2 Tricorn, 3 Multibrot.
|
||||
kind: u32,
|
||||
// Exponent for the Multibrot kind.
|
||||
power: u32,
|
||||
dc_offset: vec2<f32>,
|
||||
// Distortion constant p for the Phoenix map (z^2 + c + p*z_{n-1}); unused
|
||||
// by other kinds. Placed by dc_offset so both vec2s stay 8-byte aligned.
|
||||
phoenix_p: vec2<f32>,
|
||||
// Distortion constant l for the Lambda map (l*z(1 - z_{n-1})); unused
|
||||
// by other kinds.
|
||||
lambda_l: vec2<f32>,
|
||||
// 0 = escape-time coloring, 1 = distance-estimation shading.
|
||||
de_coloring: u32,
|
||||
// 0 = classic colors, 1 = shadows
|
||||
shadow: u32,
|
||||
// 0 = per-iteration stepping, 1 = BLA iteration-skipping (square map only).
|
||||
use_bla: u32,
|
||||
};
|
||||
|
||||
// One merged linear step from the BLA table: e_{n+l} = A e + B dc, valid while
|
||||
// |e| < r. Matches the Rust `Bla` struct (24-byte std430 stride).
|
||||
struct Bla {
|
||||
a: vec2<f32>,
|
||||
b: vec2<f32>,
|
||||
r: f32,
|
||||
l: u32,
|
||||
};
|
||||
|
||||
const KIND_MANDELBROT: u32 = 0u;
|
||||
const KIND_BURNING_SHIP: u32 = 1u;
|
||||
const KIND_TRICORN: u32 = 2u;
|
||||
const KIND_MULTIBROT: u32 = 3u;
|
||||
const KIND_CELTIC: u32 = 4u;
|
||||
const KIND_PERPENDICULAR: u32 = 5u;
|
||||
const KIND_BUFFALO: u32 = 6u;
|
||||
const KIND_PHOENIX: u32 = 7u;
|
||||
const KIND_LAMBDA: u32 = 8u;
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
@group(0) @binding(1) var<storage, read> ref_orbit: array<vec2<f32>>;
|
||||
// BLA table, levels concatenated (level 0 first). Per-level counts are recomputed
|
||||
// from `ref_len` exactly as the CPU builder laid them out.
|
||||
@group(0) @binding(2) var<storage, read> bla_table: array<Bla>;
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@@ -89,7 +89,7 @@ fn conj(a: vec2<f32>) -> vec2<f32> {
|
||||
// the sign flips that happen all along the axes, where the ship's detail lives.
|
||||
fn diffabs(c: f32, d: f32) -> f32 {
|
||||
let cd = c + d;
|
||||
if c >= 0.0 {
|
||||
if (c >= 0.0) {
|
||||
return select(-(2.0 * c + d), d, cd >= 0.0);
|
||||
}
|
||||
return select(-d, 2.0 * c + d, cd > 0.0);
|
||||
@@ -127,7 +127,7 @@ fn multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: u32) -> vec2<f32> {
|
||||
// where `z` is the reference orbit value X_m. `step_add` (dc) is added by the
|
||||
// caller. Must match `FractalKind` on the CPU side.
|
||||
fn advance_delta(z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
|
||||
if u.kind == KIND_BURNING_SHIP {
|
||||
if (u.kind == KIND_BURNING_SHIP) {
|
||||
// (|x| + i|y|)^2 has real part x^2 - y^2 (an ordinary square delta) and
|
||||
// imaginary part 2|x y|. The imaginary delta is 2(|x y| - |X Y|); diffabs
|
||||
// computes it exactly, even where the product x y changes sign — which the
|
||||
@@ -136,35 +136,14 @@ fn advance_delta(z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
|
||||
let base = 2.0 * cmul(z, e) + cmul(e, e);
|
||||
let dp = z.x * e.y + z.y * e.x + e.x * e.y;
|
||||
return vec2<f32>(base.x, 2.0 * diffabs(z.x * z.y, dp));
|
||||
} else if u.kind == KIND_TRICORN {
|
||||
} else if (u.kind == KIND_TRICORN) {
|
||||
let cz = conj(z);
|
||||
let ce = conj(e);
|
||||
return 2.0 * cmul(cz, ce) + cmul(ce, ce);
|
||||
} else if u.kind == KIND_MULTIBROT {
|
||||
} else if (u.kind == KIND_MULTIBROT) {
|
||||
return multibrot_delta(z, e, clamp(u.power, 2u, 8u));
|
||||
} else if u.kind == KIND_CELTIC {
|
||||
// z^2 delta split: sq.x = delta of Re(z^2), sq.y = delta of Im(z^2).
|
||||
// Celtic abs the real output, so |Re(z^2)| delta = diffabs(Re(Z^2), sq.x).
|
||||
let sq = 2.0 * cmul(z, e) + cmul(e, e);
|
||||
return vec2<f32>(diffabs(z.x * z.x - z.y * z.y, sq.x), sq.y);
|
||||
} else if u.kind == KIND_BUFFALO {
|
||||
// Abs both outputs: real |Re(z^2)|, imag -|Im(z^2)| (Im(Z^2) = 2 X Y).
|
||||
let sq = 2.0 * cmul(z, e) + cmul(e, e);
|
||||
return vec2<f32>(diffabs(z.x * z.x - z.y * z.y, sq.x),
|
||||
-diffabs(2.0 * z.x * z.y, sq.y));
|
||||
} else if u.kind == KIND_PERPENDICULAR {
|
||||
// real x^2 - y^2 (ordinary square delta), imag -2 x |y|.
|
||||
// d(-2 x |y|) = -2[ X·(|Y+ey|-|Y|) + ex·|Y+ey| ]; diffabs gives |Y+ey|-|Y|.
|
||||
let sq = 2.0 * cmul(z, e) + cmul(e, e);
|
||||
let da = diffabs(z.y, e.y); // |Y + ey| - |Y|
|
||||
let abs_yf = abs(z.y) + da; // |Y + ey|
|
||||
return vec2<f32>(sq.x, -2.0 * (z.x * da + e.x * abs_yf));
|
||||
} else if u.kind == KIND_LAMBDA {
|
||||
// Lambda map: z^{n+1} = λ·z·(1-z). Delta: e = λ·e·(1-2z-e).
|
||||
let one_minus_2z_minus_e = vec2<f32>(1.0 - 2.0 * z.x - e.x, -2.0 * z.y - e.y);
|
||||
return cmul(u.lambda_l, cmul(e, one_minus_2z_minus_e));
|
||||
}
|
||||
return 2.0 * cmul(z, e) + cmul(e, e); // Mandelbrot (and Phoenix square part)
|
||||
return 2.0 * cmul(z, e) + cmul(e, e); // Mandelbrot
|
||||
}
|
||||
|
||||
// Derivative f'(Z) of the iteration map at the full value Z, used to propagate
|
||||
@@ -173,55 +152,82 @@ fn advance_delta(z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
|
||||
// Burning Ship / Tricorn we use |f'| ~ |2Z|, which keeps the DE magnitude close
|
||||
// enough to de-speckle filaments.
|
||||
fn fprime(z: vec2<f32>) -> vec2<f32> {
|
||||
if u.kind == KIND_MULTIBROT {
|
||||
if (u.kind == KIND_MULTIBROT) {
|
||||
let p = clamp(u.power, 2u, 8u);
|
||||
var zk = vec2<f32>(1.0, 0.0); // Z^0
|
||||
for (var k: u32 = 1u; k < p; k = k + 1u) {
|
||||
zk = cmul(zk, z); // -> Z^{p-1}
|
||||
}
|
||||
return f32(p) * zk;
|
||||
} else if u.kind == KIND_LAMBDA {
|
||||
// Lambda: f'(z) = λ·(1-2z).
|
||||
return cmul(u.lambda_l, vec2<f32>(1.0 - 2.0 * z.x, -2.0 * z.y));
|
||||
}
|
||||
return 2.0 * z;
|
||||
}
|
||||
|
||||
// Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id.
|
||||
fn palette(id: u32, t: f32) -> vec3<f32> {
|
||||
if id == 4u {
|
||||
if (id == 4u) {
|
||||
return vec3<f32>(t, t, t); // grayscale
|
||||
}
|
||||
let a = vec3<f32>(0.5, 0.5, 0.5);
|
||||
let b = vec3<f32>(0.5, 0.5, 0.5);
|
||||
var c = vec3<f32>(1.0, 1.0, 1.0);
|
||||
var d = vec3<f32>(0.00, 0.10, 0.20); // 0: amber / blue
|
||||
if id == 1u {
|
||||
if (id == 1u) {
|
||||
d = vec3<f32>(0.00, 0.33, 0.67); // rainbow
|
||||
} else if id == 2u {
|
||||
} else if (id == 2u) {
|
||||
d = vec3<f32>(0.30, 0.20, 0.20); // warm ember
|
||||
} else if id == 3u {
|
||||
} else if (id == 3u) {
|
||||
c = vec3<f32>(1.0, 1.0, 0.5);
|
||||
d = vec3<f32>(0.80, 0.90, 0.30); // lime / magenta
|
||||
}
|
||||
return a + b * cos(6.28318530718 * (c * t + d));
|
||||
}
|
||||
|
||||
// Escape data for one sample: `ci` is the (color-independent) palette parameter,
|
||||
// `de` the distance-estimate darkening factor in [0,1], `escaped` false for the
|
||||
// interior of the set. Splitting iteration from coloring lets a colour change be
|
||||
// remapped cheaply (see the colourise pass) without re-iterating.
|
||||
struct Sample {
|
||||
ci: f32,
|
||||
de: f32,
|
||||
escaped: bool,
|
||||
// Result of a BLA lookup at an orbit index.
|
||||
struct Hop {
|
||||
found: bool,
|
||||
a: vec2<f32>,
|
||||
b: vec2<f32>,
|
||||
l: u32,
|
||||
};
|
||||
|
||||
// Perturbation iterate a single sample. `offset` is the per-pixel offset in
|
||||
// complex units. For Mandelbrot it is the c-plane offset added every step (delta
|
||||
// starts at 0); for Julia it is the z-plane offset that seeds the initial delta
|
||||
// (c is fixed, so nothing is added per step).
|
||||
fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
||||
// Longest valid BLA skip starting at orbit index `n` for a delta of squared
|
||||
// magnitude `emag2`. Walks levels low->high, recomputing each level's flat-array
|
||||
// start and count from `ref_len` (count[0] = ref_len-1, halving each level).
|
||||
// Radii shrink with level and alignment is monotonic, so the valid levels form a
|
||||
// prefix: we keep the last valid one and stop at the first that fails.
|
||||
fn bla_find(n: u32, emag2: f32) -> Hop {
|
||||
var res: Hop;
|
||||
res.found = false;
|
||||
|
||||
var start: u32 = 0u;
|
||||
var count: u32 = u.ref_len - 1u;
|
||||
var lv: u32 = 0u;
|
||||
loop {
|
||||
if (count == 0u) { break; }
|
||||
let step = 1u << lv;
|
||||
if ((n & (step - 1u)) != 0u) { break; } // n not aligned to this level
|
||||
let idx = n >> lv;
|
||||
if (idx >= count) { break; } // run would exceed the orbit
|
||||
let b = bla_table[start + idx];
|
||||
if (emag2 >= b.r * b.r) { break; } // delta too large: not valid
|
||||
res.found = true;
|
||||
res.a = b.a;
|
||||
res.b = b.b;
|
||||
res.l = b.l;
|
||||
start = start + count;
|
||||
count = count / 2u;
|
||||
lv = lv + 1u;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// Perturbation iterate + color a single sample. `offset` is the per-pixel
|
||||
// offset in complex units. For Mandelbrot it is the c-plane offset added every
|
||||
// step (delta starts at 0); for Julia it is the z-plane offset that seeds the
|
||||
// initial delta (c is fixed, so nothing is added per step). Interior pixels
|
||||
// return black.
|
||||
fn shade(offset: vec2<f32>, px: f32) -> vec3<f32> {
|
||||
let z0 = ref_orbit[0]; // reference start (0 for Mandelbrot, center for Julia)
|
||||
|
||||
var step_add = offset;
|
||||
@@ -230,11 +236,7 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
||||
// (starts at 0, gains +1 each step); for Julia it is d/dz0 (starts at 1).
|
||||
var dz = vec2<f32>(0.0, 0.0);
|
||||
var dz_seed = vec2<f32>(1.0, 0.0);
|
||||
// Previous-iterate state for the Phoenix two-term recurrence (delta of
|
||||
// y_{n-1}, and its derivative for DE). Both start at 0 (y_{-1} = 0).
|
||||
var e_prev = vec2<f32>(0.0, 0.0);
|
||||
var dz_prev = vec2<f32>(0.0, 0.0);
|
||||
if u.is_julia != 0u {
|
||||
if (u.is_julia != 0u) {
|
||||
step_add = vec2<f32>(0.0, 0.0);
|
||||
e = offset;
|
||||
dz = vec2<f32>(1.0, 0.0);
|
||||
@@ -251,39 +253,45 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
||||
z = xm + e;
|
||||
|
||||
let z2 = dot(z, z);
|
||||
if z2 > u.bailout_sq {
|
||||
if (z2 > u.bailout_sq) {
|
||||
escaped = true;
|
||||
break;
|
||||
}
|
||||
if n >= u.max_iter {
|
||||
if (n >= u.max_iter) {
|
||||
break; // interior
|
||||
}
|
||||
|
||||
// Advance one step, or skip a whole run via BLA when the delta is small
|
||||
// enough (square map only; other kinds keep `use_bla == 0`). The orbit
|
||||
// derivative for DE follows the same linear map: over a run it advances
|
||||
// by the run's own (A, B) coefficients, matching the per-step recurrence.
|
||||
var did_skip = false;
|
||||
if (u.use_bla != 0u) {
|
||||
let hop = bla_find(m, dot(e, e));
|
||||
if (hop.found) {
|
||||
if (u.de_coloring != 0u) {
|
||||
dz = cmul(hop.a, dz) + cmul(hop.b, dz_seed);
|
||||
}
|
||||
e = cmul(hop.a, e) + cmul(hop.b, step_add);
|
||||
m = m + hop.l;
|
||||
n = n + hop.l;
|
||||
did_skip = true;
|
||||
}
|
||||
}
|
||||
if (!did_skip) {
|
||||
// Propagate the derivative of the full orbit (unaffected by rebasing,
|
||||
// which only re-expresses the same value). Only when DE is enabled.
|
||||
// Phoenix's two-term map adds p·dz_{n-1} and carries the previous dz.
|
||||
if u.de_coloring != 0u {
|
||||
var dz_new = cmul(fprime(z), dz) + dz_seed;
|
||||
if u.kind == KIND_PHOENIX {
|
||||
dz_new = dz_new + cmul(u.phoenix_p, dz_prev);
|
||||
dz_prev = dz;
|
||||
if (u.de_coloring != 0u) {
|
||||
dz = cmul(fprime(z), dz) + dz_seed;
|
||||
}
|
||||
dz = dz_new;
|
||||
}
|
||||
|
||||
// Advance the delta by this fractal's formula (+ dc for the set plane).
|
||||
// Phoenix additionally adds p·e_{n-1} and carries the previous delta.
|
||||
let e_old = e;
|
||||
e = advance_delta(xm, e) + step_add;
|
||||
if u.kind == KIND_PHOENIX {
|
||||
e = e + cmul(u.phoenix_p, e_prev);
|
||||
e_prev = e_old;
|
||||
}
|
||||
m = m + 1u;
|
||||
n = n + 1u;
|
||||
}
|
||||
|
||||
// Keep the reference index valid and the delta small.
|
||||
if m >= u.ref_len {
|
||||
if (m >= u.ref_len) {
|
||||
// Reference exhausted: any pixel that followed it this far has
|
||||
// effectively escaped (interior pixels rebase before reaching here).
|
||||
z = ref_orbit[u.ref_len - 1u] + e;
|
||||
@@ -291,21 +299,16 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
||||
break;
|
||||
}
|
||||
let y = ref_orbit[m] + e;
|
||||
if dot(y, y) < dot(e, e) {
|
||||
if (dot(y, y) < dot(e, e)) {
|
||||
// Rebase to index 0: carry the full value as the new delta. Valid
|
||||
// because y_n = X[0] + (y_n - X[0]); for Mandelbrot X[0]=0.
|
||||
// Phoenix: after rebasing the implied previous reference is Y[-1]=0,
|
||||
// so the previous delta becomes the full previous value y_n (= z).
|
||||
if u.kind == KIND_PHOENIX {
|
||||
e_prev = z;
|
||||
}
|
||||
e = y - z0;
|
||||
m = 0u;
|
||||
}
|
||||
}
|
||||
|
||||
if !escaped {
|
||||
return Sample(0.0, 1.0, false); // interior of the set
|
||||
if (!escaped) {
|
||||
return vec3<f32>(0.0, 0.0, 0.0); // interior of the set
|
||||
}
|
||||
|
||||
let z2 = dot(z, z);
|
||||
@@ -318,9 +321,10 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
||||
// sqrt compresses the huge iteration counts of deep zooms so the palette
|
||||
// varies smoothly instead of aliasing into speckle.
|
||||
let ci = sqrt(max(smooth_i, 0.0));
|
||||
let t = fract(ci * u.color_scale + u.color_offset);
|
||||
var col = palette(u.palette_id, t);
|
||||
|
||||
var de = 1.0;
|
||||
if u.de_coloring != 0u {
|
||||
if (u.de_coloring != 0u) {
|
||||
// Exterior distance estimate (complex-plane units): |z|·ln|z| / |dz|.
|
||||
// Divided by the pixel footprint it becomes a distance in pixels; we
|
||||
// darken toward the boundary (< ~1 px away) so filaments stay crisp
|
||||
@@ -328,81 +332,37 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
||||
// boundary simply reads as dark, which is the correct limit.
|
||||
let zmag = sqrt(max(z2, 1.0));
|
||||
let dzmag = sqrt(max(dot(dz, dz), 1e-20));
|
||||
let d = zmag * log(zmag) / dzmag;
|
||||
var max_de = 1.;
|
||||
if u.shadow != 0u {
|
||||
max_de = 1000.;
|
||||
let de = zmag * log(zmag) / dzmag;
|
||||
let de_px = de / max(px, 1e-30);
|
||||
col = col * clamp(de_px, 0.0, 1.0);
|
||||
}
|
||||
de = clamp(d / max(px, 1e-30), 0.0, max_de);
|
||||
}
|
||||
return Sample(ci, de, true);
|
||||
return col;
|
||||
}
|
||||
|
||||
// Map a sample's escape data through the palette (+ DE darkening). This is the
|
||||
// only color-dependent step, so it can be redone without re-iterating. Interior
|
||||
// samples are black.
|
||||
fn color_sample(s: Sample) -> vec3<f32> {
|
||||
if !s.escaped {
|
||||
return vec3<f32>(0.0, 0.0, 0.0);
|
||||
}
|
||||
let t = fract(s.ci * u.color_scale + u.color_offset);
|
||||
return palette(u.palette_id, t) * s.de;
|
||||
}
|
||||
|
||||
// Iteration pass: write per-pixel escape data (color-independent) so a colour
|
||||
// change is remapped by the cheap colourise pass without re-iterating.
|
||||
// R = ci (palette parameter), G = DE factor, B = interior fraction (for AA).
|
||||
// AA is grid-supersampled here; the interior fraction lets the colourise pass
|
||||
// anti-alias the set boundary (blend toward black) after the fact.
|
||||
@fragment
|
||||
fn fs_data(in: VsOut) -> @location(0) vec4<f32> {
|
||||
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||
let base = in.centered * u.span + u.dc_offset;
|
||||
|
||||
// Screen-space complex-units-per-pixel. Derivatives must be evaluated in
|
||||
// uniform control flow, so take them here; used to place sub-pixel AA
|
||||
// samples and to convert the distance estimate into pixels.
|
||||
let dx = dpdx(base);
|
||||
let dy = dpdy(base);
|
||||
let px = length(abs(dx) + abs(dy));
|
||||
let px = length(abs(dx) + abs(dy)); // ~ complex units per pixel (footprint)
|
||||
|
||||
let aa = max(u.aa_level, 1u);
|
||||
let inv = 1.0 / f32(aa);
|
||||
var ci_sum = 0.0;
|
||||
var de_sum = 0.0;
|
||||
var escaped_n = 0u;
|
||||
for (var sy: u32 = 0u; sy < aa; sy = sy + 1u) {
|
||||
for (var sx: u32 = 0u; sx < aa; sx = sx + 1u) {
|
||||
let jx = (f32(sx) + 0.5) * inv - 0.5;
|
||||
let jy = (f32(sy) + 0.5) * inv - 0.5;
|
||||
let s = iterate_sample(base + jx * dx + jy * dy, px);
|
||||
if s.escaped {
|
||||
ci_sum = ci_sum + s.ci;
|
||||
de_sum = de_sum + s.de;
|
||||
escaped_n = escaped_n + 1u;
|
||||
}
|
||||
}
|
||||
}
|
||||
let total = f32(aa * aa);
|
||||
let ci_avg = select(0.0, ci_sum / f32(escaped_n), escaped_n > 0u);
|
||||
let de_avg = select(1.0, de_sum / f32(escaped_n), escaped_n > 0u);
|
||||
let interior_frac = 1.0 - f32(escaped_n) / total;
|
||||
return vec4<f32>(ci_avg, de_avg, interior_frac, 1.0);
|
||||
if (aa <= 1u) {
|
||||
return vec4<f32>(shade(base, px), 1.0);
|
||||
}
|
||||
|
||||
// Combined iterate + colour in a single pass, for PNG export (which never needs
|
||||
// incremental recolouring). The interactive path uses fs_data + the colourise
|
||||
// pass so colour changes skip iteration.
|
||||
@fragment
|
||||
fn fs_color(in: VsOut) -> @location(0) vec4<f32> {
|
||||
let base = in.centered * u.span + u.dc_offset;
|
||||
let dx = dpdx(base);
|
||||
let dy = dpdy(base);
|
||||
let px = length(abs(dx) + abs(dy));
|
||||
|
||||
let aa = max(u.aa_level, 1u);
|
||||
let inv = 1.0 / f32(aa);
|
||||
var acc = vec3<f32>(0.0, 0.0, 0.0);
|
||||
let inv = 1.0 / f32(aa);
|
||||
for (var sy: u32 = 0u; sy < aa; sy = sy + 1u) {
|
||||
for (var sx: u32 = 0u; sx < aa; sx = sx + 1u) {
|
||||
// Sample centers evenly spread across the pixel, jitter in (-0.5, 0.5).
|
||||
let jx = (f32(sx) + 0.5) * inv - 0.5;
|
||||
let jy = (f32(sy) + 0.5) * inv - 0.5;
|
||||
acc = acc + color_sample(iterate_sample(base + jx * dx + jy * dy, px));
|
||||
acc = acc + shade(base + jx * dx + jy * dy, px);
|
||||
}
|
||||
}
|
||||
return vec4<f32>(acc / f32(aa * aa), 1.0);
|
||||
|
||||
+2
-2
@@ -82,7 +82,7 @@ impl ViewState {
|
||||
let bits = self.precision_bits();
|
||||
// Grab-and-drag: moving the mouse right shows content to the left.
|
||||
self.center_re = &self.center_re - &big_from_f64(dx * cpp, bits);
|
||||
self.center_im = &self.center_im - &big_from_f64(dy * cpp, bits);
|
||||
self.center_im = &self.center_im - &big_from_f64(dy * cpp, bits); // y-down -> imag-up
|
||||
}
|
||||
|
||||
/// Zoom by `factor` (<1 zooms in) keeping the complex point currently under
|
||||
@@ -97,7 +97,7 @@ impl ViewState {
|
||||
// off * cpp * (1 - factor). (Derivation: new_c = fixed + (c-fixed)*f.)
|
||||
let k = cpp * (1.0 - factor);
|
||||
self.center_re = &self.center_re + &big_from_f64(off_x * k, bits);
|
||||
self.center_im = &self.center_im + &big_from_f64(off_y * k, bits);
|
||||
self.center_im = &self.center_im + &big_from_f64(off_y * k, bits); // y flip
|
||||
self.half_height *= factor;
|
||||
}
|
||||
|
||||
|
||||
+12
-9
@@ -9,7 +9,7 @@
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
|
||||
use std::thread;
|
||||
|
||||
use crate::fractal::{FractalKind, compute_reference, compute_set_reference};
|
||||
use crate::fractal::{Bla, FractalKind, build_bla_table, compute_reference, compute_set_reference};
|
||||
use crate::view::{Big, big_from_f64};
|
||||
|
||||
pub struct RefRequest {
|
||||
@@ -22,10 +22,10 @@ pub struct RefRequest {
|
||||
pub precision: usize,
|
||||
pub kind: FractalKind,
|
||||
pub power: u32,
|
||||
/// Distortion constant for the Phoenix map (ignored by other kinds).
|
||||
pub phoenix_p: (f64, f64),
|
||||
/// Distortion constant for the Lambda map (ignored by other kinds).
|
||||
pub lambda_l: (f64, f64),
|
||||
/// Build the BLA iteration-skip table for this orbit (square map only).
|
||||
pub build_bla: bool,
|
||||
/// Upper bound on any pixel's `|dc|`, sizing the BLA merge radii.
|
||||
pub dc_max: f64,
|
||||
}
|
||||
|
||||
pub struct RefResult {
|
||||
@@ -33,6 +33,7 @@ pub struct RefResult {
|
||||
pub center_im: Big,
|
||||
pub half_height: f64,
|
||||
pub points: Vec<[f32; 2]>,
|
||||
pub bla: Vec<Bla>,
|
||||
}
|
||||
|
||||
pub struct RefWorker {
|
||||
@@ -60,12 +61,18 @@ impl RefWorker {
|
||||
}
|
||||
|
||||
let points = compute(&req);
|
||||
let bla = if req.build_bla {
|
||||
build_bla_table(&points, req.dc_max)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if res_tx
|
||||
.send(RefResult {
|
||||
center_re: req.center_re,
|
||||
center_im: req.center_im,
|
||||
half_height: req.half_height,
|
||||
points,
|
||||
bla,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
@@ -105,8 +112,6 @@ fn compute(req: &RefRequest) -> Vec<[f32; 2]> {
|
||||
req.precision,
|
||||
req.kind,
|
||||
req.power,
|
||||
req.phoenix_p,
|
||||
req.lambda_l,
|
||||
)
|
||||
} else {
|
||||
compute_set_reference(
|
||||
@@ -116,8 +121,6 @@ fn compute(req: &RefRequest) -> Vec<[f32; 2]> {
|
||||
req.precision,
|
||||
req.kind,
|
||||
req.power,
|
||||
req.phoenix_p,
|
||||
req.lambda_l,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,20 +25,7 @@ fn mandelbrot_shader_is_valid() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_shader_is_valid() {
|
||||
validate("colorize.wgsl", include_str!("../src/shaders/colorize.wgsl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blit_shader_is_valid() {
|
||||
validate("blit.wgsl", include_str!("../src/shaders/blit.wgsl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buddhabrot_shader_is_valid() {
|
||||
validate(
|
||||
"buddhabrot.wgsl",
|
||||
include_str!("../src/shaders/buddhabrot.wgsl"),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user