Compare commits
1
Commits
b6467fc18c
..
BLA
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea2a23999d |
@@ -1,6 +1,3 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
dist
|
||||
frames*
|
||||
out.mp4
|
||||
|
||||
|
||||
@@ -1,302 +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. Plain `f32` deltas run out of exponent range
|
||||
once a pixel is ~2^-124 wide (~10³⁴× at 1080p), so from 2^-122 per pixel
|
||||
(`view::DEEP_PIXEL_SIZE`) a `DEEP` shader variant starts each pixel with
|
||||
rescaled deltas (f32 mantissa × 2^i32). There's no practical depth limit:
|
||||
`half_height` is a `view::Scale` (f64 mantissa × 2^i32), floored only at
|
||||
`Scale::MIN` = 2^-(2^20) to keep shader exponent sums in i32. 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`, `--phoenix-p re,im`, `--lambda-l re,im`,
|
||||
`--palette`, `--share <fragment>`,
|
||||
`--view re,im,half_height[,iterations]`, `--rendering-kind`,
|
||||
`--yaw`/`--pitch` (3D camera, degrees), `--de`, `--buddhabrot`,
|
||||
`--buddha-palette`. `--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, `--export-path out.png`) without needing a GPU-backed
|
||||
window/event loop. Not yet supported with `--buddhabrot`. Run
|
||||
`mandelbrot --help` for the full list.
|
||||
|
||||
`--headless` also has an animation mode, for feeding into `ffmpeg`: give any
|
||||
end-state flag alongside the start flags (`--view`/`--share`/`--kind`/
|
||||
`--julia`/...), plus `--frames N` or `--fps`/`--duration`. End-state flags:
|
||||
`--to-view re,im,half_height[,iterations]` or `--to-share <fragment>` (only
|
||||
position/zoom/iterations are pulled out of the link), `--to-iterations`,
|
||||
`--to-julia`, `--to-phoenix-p`, `--to-lambda-l`, `--to-complex-power`
|
||||
(or `--to-complex-power-re`/`--to-complex-power-im` to move one component),
|
||||
and `--to-kind` (per-step formula blend via `KindMorph`, camera untouched).
|
||||
Anything without a target stays at its start value; colors stay fixed.
|
||||
`--export-path` then names an output *directory* of `frame-00001.png`,
|
||||
`frame-00002.png`, ... instead of a single file. `headless.rs::AnimTargets`
|
||||
collects the targets; the export pipeline is rebuilt only when the
|
||||
`PipelineKey` changes between frames (kind morph). `view::interpolate_view`
|
||||
does the camera: half-height geometrically (log-linear, since zoom spans many
|
||||
decades), center linearly through the complex plane at full `Big` precision;
|
||||
constants interpolate linearly. `--linear` swaps the default smoothstep
|
||||
easing for constant pacing. Without `--to-iterations` (or a share link's),
|
||||
iteration count auto-scales with zoom depth per frame (same
|
||||
`auto_iteration_count` the interactive app uses while zooming).
|
||||
`--to-yaw`/`--to-pitch` (degrees, from `--yaw`/`--pitch`, yaw unwrapped so
|
||||
`--to-yaw 720` is two turns) orbit the 3D camera with `--rendering-kind 3d`.
|
||||
Frames are pipelined across every core (`run_animation`): each frame's
|
||||
state is a pure function of `t` (`apply_frame`), so all frames'
|
||||
`FractalApp::reference_job`s are snapshotted up front and `RefJob::compute`d
|
||||
by a worker pool. The main thread renders them on the GPU as they arrive
|
||||
(out of order), and another pool PNG-encodes and writes them
|
||||
(`encode_png`, `Compression::Fast`). Channels are bounded. Once orbits and
|
||||
encoding are off the main thread, the GPU is usually the bottleneck.
|
||||
|
||||
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). The pixel scale (`half_height`) is a `Scale`, an f64
|
||||
mantissa with its own i32 exponent, so it goes past f64's ~1e-308. Never
|
||||
collapse it (or a center difference) to a plain `f64` on a path used at
|
||||
depth. Rescale first: `Scale::scaled_f64(k)`, or shift the `Big` by
|
||||
`-scale_exp` before `to_f64()`, as `dc_offset`/`drift_from` do.
|
||||
`Display`/`FromStr` use scientific notation of any exponent (share links,
|
||||
`--view`, the zoom field). Precision (bits) scales with zoom depth
|
||||
(`precision_for`). `needs_deep` switches rendering to the deep pipeline
|
||||
once a pixel of the full-resolution render is below `DEEP_PIXEL_SIZE`
|
||||
(2^-122; the f32 path is exact down to 2^-124 with AA's quarter-pixel
|
||||
offsets, measured, and the deep path is ~40% slower, so the switch is as
|
||||
late as that allows). `deep_scale_exp` gives the scale exponent.
|
||||
`make_uniforms(aspect, height_px)` takes that full-resolution height, the
|
||||
same during the interaction-downscaled pass so the pipeline doesn't flip.
|
||||
- `src/fractal/kind.rs` — the `FractalKind` enum (Mandelbrot, Burning Ship,
|
||||
Tricorn, Multibrot, Celtic, Perpendicular, Buffalo, Phoenix, Lambda,
|
||||
Complex Multibrot) plus everything that only needs to switch on it:
|
||||
`label`/`description`/`formula` (UI text), `share_tag`/`from_share_tag`
|
||||
(share-link encoding), `default_set_view` (per-kind starting view), and the
|
||||
`ALL` array used to enumerate every kind.
|
||||
- `src/fractal/reference.rs` — `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. At
|
||||
precision ≤ `F64_MAX_PRECISION` (80 bits, i.e. shallow views) it takes a
|
||||
plain-`f64` fast path (`compute_reference_f64`), so each kind's formula
|
||||
exists twice in this file (f64 + `FBig`) and both must stay in sync;
|
||||
`f64_fast_path_matches_big` checks they agree. The result is a `RefOrbit`:
|
||||
`points` plus a parallel `exps`. A point below 2^-100 (only possible on the
|
||||
`FBig` path) is stored as a normalized mantissa with its exponent in `exps`
|
||||
(the true value is `points[n]·2^exps[n]`). That happens when the orbit
|
||||
passes near 0 at a deep minibrot. `has_scaled()` then forces the deep
|
||||
pipeline, the only one that reads `exps`. Requests are made with 1.5×
|
||||
iteration headroom (`reference_iterations` in `app.rs`), so auto-iterations
|
||||
creeping up during a zoom doesn't recompute the orbit every frame.
|
||||
- `src/shaders/*.wgsl` — none of these are standalone WGSL modules; WGSL has
|
||||
no `#include`, so each is compiled by concatenating plain-text fragments
|
||||
with `concat!`/`include_str!` at the `create_shader_module` call site (see
|
||||
`renderer.rs`, `buddhabrot.rs`, and `tests/shader_valid.rs`, which must
|
||||
concatenate the same pieces to validate what actually gets built).
|
||||
`common.wgsl` (fullscreen-triangle vertex helper, `cmul`/`cpow`, `KIND_*`
|
||||
constants) is prepended to every shader. `iterate_uniforms.wgsl` (the
|
||||
perturbation-pipeline `Uniforms` struct + `palette()`) is additionally
|
||||
prepended to `mandelbrot.wgsl` and `colorize.wgsl`, which share that layout.
|
||||
Because there's no namespacing, a definition must live in exactly one file
|
||||
among those concatenated together for a given shader — don't redefine a
|
||||
`common.wgsl`/`iterate_uniforms.wgsl` symbol locally.
|
||||
- `src/shaders/mandelbrot.wgsl` — the perturbation fragment shader. It is
|
||||
**specialized per pipeline** through WGSL `override` constants (`KIND`,
|
||||
`IS_JULIA`, `DE`), so the per-iteration kind/Julia/DE branches fold away at
|
||||
pipeline creation. Read those constants in the shader, never `u.kind` /
|
||||
`u.is_julia` / `u.de_coloring` (they're still uploaded for layout reasons).
|
||||
`renderer.rs` builds one pipeline set per `PipelineKey` lazily on first
|
||||
use, and `tests/shader_valid.rs` compiles every kind × Julia × DE × morph ×
|
||||
deep variant to SPIR-V. So a new kind needs no pipeline-list change, only its `KIND_*`
|
||||
constant. `buddhabrot.wgsl` does the same with its own `override KIND`.
|
||||
Interior pixels exit early through **periodicity detection**. It uses
|
||||
Brent-style checkpoints plus two guards: the cycle's multiplier must be
|
||||
clearly attracting (`PERIOD_MAX_MULT2`), and the contracting return must
|
||||
repeat in `PERIOD_CONFIRMATIONS` consecutive windows. Both guards are
|
||||
needed: without them, exterior pixels at cusps and minibrot edges turned
|
||||
black. Retune them only against f64 ground truth on such views. Each
|
||||
window saves its iterate closest to the critical point, not the one at the
|
||||
checkpoint. At an arbitrary phase the relative tolerance is far coarser
|
||||
than a deep minibrot's scale, and a black disk surrounded the minibrot
|
||||
(seen at ~1e-13 zoom). Phoenix is excluded (two-term map).
|
||||
`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 (from `common.wgsl`) must match the
|
||||
matching `FractalKind` variant's discriminant exactly. The per-kind bodies
|
||||
are `advance_delta_kind`/`fprime_kind`; `advance_delta`/`fprime` wrap them
|
||||
to blend two kinds during the kind-switch morph (`u.morph_from`,
|
||||
`u.morph_w`: each step is `(1-w)·f_kind + w·f_from`, mirrored on the CPU by
|
||||
the `morph` argument of `compute_reference`, in both its f64 and `FBig`
|
||||
paths). The blend only exists in pipelines built with the `MORPH` override
|
||||
(part of `PipelineKey`, on while `morph_w > 0`); those also skip periodicity
|
||||
detection and the cardioid bypass. App side: `KindMorph` in
|
||||
`app.rs`; the uniforms use the morph the *current reference* was built with
|
||||
(`ref_morph`), not the live one, so orbit and delta formula never disagree.
|
||||
**Deep views** (`DEEP` override, `u.scale_exp != 0`) handle zooms where
|
||||
f32 deltas underflow. `make_uniforms` sets `scale_exp = E` (≈ log2 of the
|
||||
half-height) and uploads `span`/`dc_offset` × 2^-E. The per-pixel `offset`
|
||||
and `px` are therefore in units of 2^E. `iterate_sample` first runs a
|
||||
**deep prologue**:
|
||||
- The delta is carried as `w·2^sx` and the DE derivative as `v·2^sv`
|
||||
(separate exponents, since they drift apart near the critical point).
|
||||
- Each step goes through `advance_delta_scaled` →
|
||||
`deep_step_kind` → `advance_delta_scaled_kind`. These return the step at
|
||||
its own output scale `t`. Next to the critical point (X tiny or 0), the
|
||||
linear term vanishes and the step's value is ~e^p, far below 2^sx.
|
||||
`deep_step_kind` measures X and e in a common unit (the kinds are
|
||||
p-homogeneous) and the loop moves `sx` there. Assuming the e² terms merely
|
||||
flush when negligible was wrong exactly there: pixels near deep minibrots
|
||||
lost their delta and followed the reference forever.
|
||||
- Rebasing uses X at full range (`ref_fe`).
|
||||
- Once `|e| > 2^DEEP_EXIT_LOG2` (and dzs is normal), the state converts to
|
||||
f32 and the ordinary loop continues from the same `n`/`m`.
|
||||
- Periodicity detection restarts after the prologue with a sentinel save,
|
||||
because saving the hand-off `z` (an arbitrary phase) made exterior pixels
|
||||
shadowing a periodic nucleus reference read as interior.
|
||||
|
||||
The deep path is exact at any depth: forcing it everywhere (raise
|
||||
`DEEP_PIXEL_SIZE`, raise `DEEP_EXIT_LOG2` to about -8) must reproduce the
|
||||
plain f32 renders on non-chaotic views. That's the check to rerun after
|
||||
changing it. Known gaps: Lambda's critical point is 1/2, so its step keeps
|
||||
the input scale. Lambda set mode's reference sits at the origin, so it
|
||||
never reaches deep zooms anyway.
|
||||
- `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; it includes
|
||||
CPU-precomputed data: `cm_coef`, the Complex Multibrot binomial
|
||||
coefficients from `app.rs::complex_binomials`, `light_count` for the
|
||||
packed `GpuLight` buffer from `lights.rs::gpu_lights`, and `scale_exp`,
|
||||
the deep view scale), `ref_exp_buffer` (binding 3, `RefOrbit::exps`), 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 interactive animations: colour cycle,
|
||||
auto-zoom, c/p/λ circle drift via `ConstOrbit`, per-component complex-power
|
||||
oscillation via `AxisOsc`, 3D camera orbit, kind cycling through
|
||||
`switch_kind`),
|
||||
`default_view_for` (wraps `FractalKind::default_set_view`, adding the
|
||||
kind-independent Julia case). `JULIA_PRESETS` and `SET_PRESETS` are sized as
|
||||
`[T; FractalKind::<last variant> as usize + 1]` — adding a new `FractalKind`
|
||||
means bumping both (and adding an empty `&[]` slot to each if the kind has
|
||||
none), plus adding it to `FractalKind::ALL` in `kind.rs`.
|
||||
- `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: `kind.rs` (enum variant + `ALL` slot + `label`/
|
||||
`description`/`formula`/`share_tag`/`from_share_tag`/`default_set_view`
|
||||
arms), `reference.rs` (CPU iteration formula arm, and a test comparing
|
||||
against a naive `f64` iteration), `common.wgsl` (matching `KIND_*` const),
|
||||
`mandelbrot.wgsl` (matching `advance_delta`/`fprime` arms, plus the deep
|
||||
path's `advance_delta_scaled_kind` arm, its degree in `deep_step_kind` and,
|
||||
if not z²-like, a `deep_fprime` arm), `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`), `app.rs` (`JULIA_PRESETS`/`SET_PRESETS` slot,
|
||||
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). A frame where
|
||||
neither differs uploads and renders nothing and only blits. So any new
|
||||
uniform field must go into one of those two functions (or the lights
|
||||
comparison), or changing it won't redraw.
|
||||
|
||||
AA is **adaptive** on the interactive path. `fs_data` always iterates 1
|
||||
sample per pixel. When AA is on, `fs_refine` reads that texture and runs the
|
||||
2×2 grid only on pixels whose 4-neighbours differ (interior/exterior edge, or
|
||||
`ci`/DE beyond `AA_CI_EPS`/`AA_DE_EPS`), copying the rest. Colourise then
|
||||
reads the refined texture. PNG export (`fs_color`) still supersamples every
|
||||
pixel, except in 3D: the raymarcher needs the whole height field, so a 3D
|
||||
`ExportRender` (`RaymarchExport`) runs the interactive chain instead, with
|
||||
its tiles iterating `fs_data` into its own data texture and the last tile adding
|
||||
refine + colourise into the target.
|
||||
|
||||
The 3D view (`colorize.wgsl::ray_marching`) sphere-traces the DE height
|
||||
field straight from the data texture. It's cheap: rays start on the z = 0
|
||||
plane, and most hit within a few steps (about 4 on average). A min-height
|
||||
mip pyramid (quadtree height-field tracing) was tried and measured about 3×
|
||||
slower, because it needs about 12 costlier steps per ray. Don't reintroduce it.
|
||||
Orbiting the camera only re-runs the colourise pass, never iteration.
|
||||
|
||||
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`).
|
||||
+2
-7
@@ -8,17 +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"
|
||||
glam = "0.33.8"
|
||||
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"
|
||||
@@ -29,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.
|
||||
@@ -41,4 +36,4 @@ opt-level = 1
|
||||
opt-level = 3
|
||||
|
||||
[dev-dependencies]
|
||||
naga = { version = "30", features = ["wgsl-in", "spv-out"] }
|
||||
naga = { version = "30", features = ["wgsl-in"] }
|
||||
|
||||
@@ -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 |
+322
-2132
File diff suppressed because it is too large
Load Diff
@@ -1,91 +0,0 @@
|
||||
use std::f32::consts::{PI, TAU};
|
||||
|
||||
use glam::Vec3;
|
||||
|
||||
/// Pitch is kept just short of straight up/down so the view never flips past
|
||||
/// the pole (and the raymarcher's rays always have `z > 0`).
|
||||
const PITCH_LIMIT: f32 = PI / 2.0 - 0.01;
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Camera {
|
||||
pub position: glam::Vec3,
|
||||
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
|
||||
pub aspect_ratio: f32,
|
||||
z_near: f32,
|
||||
z_far: f32,
|
||||
}
|
||||
|
||||
impl Camera {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
position: Vec3::new(0., 0., -1.),
|
||||
yaw: 0. * PI / 180.,
|
||||
pitch: -30. * PI / 180.,
|
||||
aspect_ratio: 1.,
|
||||
z_near: 0.1,
|
||||
z_far: 100.,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_aspect_ratio(&mut self, aspect_ratio: f32) {
|
||||
self.aspect_ratio = aspect_ratio;
|
||||
}
|
||||
|
||||
/// Adjust yaw/pitch by the given deltas (radians). Pitch is clamped just
|
||||
/// short of straight up/down to avoid the view flipping past the pole.
|
||||
/// Yaw is wrapped to [-π, π) so the 2D <-> 3D transition (which scales
|
||||
/// yaw by `t`) always unwinds the short way instead of every past turn.
|
||||
pub fn rotate(&mut self, dyaw: f32, dpitch: f32) {
|
||||
self.yaw = (self.yaw + dyaw + PI).rem_euclid(TAU) - PI;
|
||||
self.pitch = (self.pitch + dpitch).clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
||||
}
|
||||
|
||||
/// Set yaw/pitch (radians) outright. Pitch is clamped as in `rotate`,
|
||||
/// but yaw is left unwrapped: only a camera that stays in 3D (headless
|
||||
/// animation, which interpolates yaw across several turns) should use
|
||||
/// this; `rotate(0., 0.)` afterwards wraps it for the 2D <-> 3D transition.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn set_angles(&mut self, yaw: f32, pitch: f32) {
|
||||
self.yaw = yaw;
|
||||
self.pitch = pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
||||
}
|
||||
|
||||
/// `render_scale` is the 3D texture's per-axis scale (see
|
||||
/// `FractalApp::render_scale_3d`): the full 3D camera (`t = 1`) zooms in
|
||||
/// by the same factor, so one texel still covers one screen pixel and the
|
||||
/// extra texels become terrain beyond the screen edges.
|
||||
pub fn orthographic(&self, t: f32, render_scale: f32) -> glam::Mat4 {
|
||||
let yaw = self.yaw * t;
|
||||
let pitch = self.pitch * t;
|
||||
|
||||
let zoom = 0.5 * (1. + t * (render_scale - 1.));
|
||||
// Orbit pivot: the center of the fractal texture, which the raymarcher's
|
||||
// `sdf` lays out over world x ∈ [0, aspect], y ∈ [0, 1] on the z = 0 plane.
|
||||
let view = glam::Mat4::from_translation(Vec3::new(0.5 * self.aspect_ratio, 0.5, 0.))
|
||||
* glam::Mat4::from_rotation_z(-yaw)
|
||||
* glam::Mat4::from_rotation_x(-pitch)
|
||||
* glam::Mat4::from_translation(self.position);
|
||||
|
||||
glam::camera::lh::proj::directx::orthographic(
|
||||
-self.aspect_ratio / 4. / zoom,
|
||||
self.aspect_ratio / 4. / zoom,
|
||||
-0.25 / zoom,
|
||||
0.25 / zoom,
|
||||
self.z_near,
|
||||
self.z_far,
|
||||
) * view.inverse()
|
||||
}
|
||||
|
||||
pub fn direction(&self, t: f32) -> glam::Vec3 {
|
||||
let yaw = self.yaw * t;
|
||||
let pitch = self.pitch * t;
|
||||
|
||||
let forward =
|
||||
glam::Mat3::from_rotation_z(-yaw) * glam::Mat3::from_rotation_x(-pitch) * glam::Vec3::Z;
|
||||
|
||||
forward.normalize()
|
||||
}
|
||||
}
|
||||
-226
@@ -1,226 +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>,
|
||||
|
||||
/// Start in Julia mode with this seed constant.
|
||||
#[arg(long, value_name = "RE,IM")]
|
||||
pub julia: Option<String>,
|
||||
|
||||
/// Switch to the Buddhabrot renderer.
|
||||
#[arg(long)]
|
||||
pub buddhabrot: bool,
|
||||
|
||||
/// Rendering mode to use.
|
||||
#[arg(long)]
|
||||
pub rendering_kind: Option<RenderingKindArg>,
|
||||
|
||||
/// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 20].
|
||||
#[arg(long)]
|
||||
pub power: Option<u32>,
|
||||
|
||||
/// Complex exponent for the Complex Multibrot kind (z -> z^power + c).
|
||||
#[arg(long, value_name = "RE,IM")]
|
||||
pub complex_power: Option<String>,
|
||||
|
||||
/// Phoenix constant p for the Phoenix kind (z -> z^2 + c + p*z_prev).
|
||||
#[arg(long, value_name = "RE,IM")]
|
||||
pub phoenix_p: Option<String>,
|
||||
|
||||
/// Lambda constant λ for the Lambda kind (z -> λ*z*(1 - z)).
|
||||
#[arg(long, value_name = "RE,IM")]
|
||||
pub lambda_l: 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>,
|
||||
|
||||
/// Jump to a specific position on startup.
|
||||
#[arg(long, short('p'), value_name = "RE,IM")]
|
||||
pub position: Option<String>,
|
||||
|
||||
/// Set a maximum iterations count on startup.
|
||||
#[arg(long, short('i'))]
|
||||
pub iterations: Option<u32>,
|
||||
|
||||
/// Set the zoom level on startup.
|
||||
#[arg(long("zoom"), short('z'))]
|
||||
pub half_height: Option<String>,
|
||||
|
||||
/// 3D camera yaw in degrees (with --rendering-kind 3d).
|
||||
#[arg(long, value_name = "DEG", allow_hyphen_values = true)]
|
||||
pub yaw: Option<f32>,
|
||||
|
||||
/// 3D camera pitch in degrees (with --rendering-kind 3d); negative tilts
|
||||
/// the view down toward the fractal. Clamped short of ±90.
|
||||
#[arg(long, value_name = "DEG", allow_hyphen_values = true)]
|
||||
pub pitch: Option<f32>,
|
||||
|
||||
/// Enable distance-estimation shading.
|
||||
#[arg(long)]
|
||||
pub de: bool,
|
||||
|
||||
/// Coloring palette index.
|
||||
#[arg(long, value_name = "INDEX")]
|
||||
pub palette: Option<u32>,
|
||||
|
||||
/// Output path for --headless (default: fractal-<timestamp>.png). When
|
||||
/// animating (--to-view/--to-share), this is a directory of
|
||||
/// frame-00001.png, frame-00002.png, ... instead (default:
|
||||
/// frames-<timestamp>/).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub export_path: Option<String>,
|
||||
|
||||
/// End view for an animation: "re,im,half_height[,iterations]", the same
|
||||
/// syntax as --view. Combine with --view (or --share, --kind, --julia...)
|
||||
/// for the start view; headless then renders a sequence of frames
|
||||
/// interpolating the camera from start to end instead of a single PNG.
|
||||
#[arg(long, value_name = "RE,IM,HALF_HEIGHT[,ITERATIONS]")]
|
||||
pub to_view: Option<String>,
|
||||
|
||||
/// End view for an animation, as a share-link fragment (only the
|
||||
/// position/zoom/iterations are used; alternative to --to-view for
|
||||
/// pasting a location copied from the app's "Copy share link").
|
||||
#[arg(long, value_name = "FRAGMENT")]
|
||||
pub to_share: Option<String>,
|
||||
|
||||
/// Set a maximum iterations count at animation end.
|
||||
#[arg(long)]
|
||||
pub to_iterations: Option<u32>,
|
||||
|
||||
/// End Julia constant for an animation: c is interpolated from --julia
|
||||
/// to this over the frames.
|
||||
#[arg(long, value_name = "RE,IM", allow_hyphen_values = true)]
|
||||
pub to_julia: Option<String>,
|
||||
|
||||
/// End Phoenix constant p for an animation (from --phoenix-p).
|
||||
#[arg(long, value_name = "RE,IM", allow_hyphen_values = true)]
|
||||
pub to_phoenix_p: Option<String>,
|
||||
|
||||
/// End Lambda constant λ for an animation (from --lambda-l).
|
||||
#[arg(long, value_name = "RE,IM", allow_hyphen_values = true)]
|
||||
pub to_lambda_l: Option<String>,
|
||||
|
||||
/// End Complex Multibrot exponent for an animation (from
|
||||
/// --complex-power). Shorthand for --to-complex-power-re +
|
||||
/// --to-complex-power-im.
|
||||
#[arg(long, value_name = "RE,IM", allow_hyphen_values = true)]
|
||||
pub to_complex_power: Option<String>,
|
||||
|
||||
/// End real part of the Complex Multibrot exponent for an animation;
|
||||
/// the imaginary part stays put unless --to-complex-power-im is given.
|
||||
#[arg(long, value_name = "RE", allow_hyphen_values = true)]
|
||||
pub to_complex_power_re: Option<f64>,
|
||||
|
||||
/// End imaginary part of the Complex Multibrot exponent for an animation;
|
||||
/// the real part stays put unless --to-complex-power-re is given.
|
||||
#[arg(long, value_name = "IM", allow_hyphen_values = true)]
|
||||
pub to_complex_power_im: Option<f64>,
|
||||
|
||||
/// End 3D camera yaw for an animation, in degrees (from --yaw). Not
|
||||
/// wrapped: --yaw 0 --to-yaw 720 orbits twice.
|
||||
#[arg(long, value_name = "DEG", allow_hyphen_values = true)]
|
||||
pub to_yaw: Option<f32>,
|
||||
|
||||
/// End 3D camera pitch for an animation, in degrees (from --pitch).
|
||||
#[arg(long, value_name = "DEG", allow_hyphen_values = true)]
|
||||
pub to_pitch: Option<f32>,
|
||||
|
||||
/// Morph the iteration formula from the start kind (--kind) to this one
|
||||
/// over the animation. The camera is unaffected (use --to-view for that).
|
||||
#[arg(long, value_enum)]
|
||||
pub to_kind: Option<KindArg>,
|
||||
|
||||
/// Number of frames to render for an animation. Alternative to --fps +
|
||||
/// --duration.
|
||||
#[arg(long, value_name = "N")]
|
||||
pub frames: Option<u32>,
|
||||
|
||||
/// Frames per second, used with --duration to compute the frame count
|
||||
/// (ignored if --frames is given). Also used in the ffmpeg command
|
||||
/// hint printed after rendering.
|
||||
#[arg(long, value_name = "N", default_value_t = 30.0)]
|
||||
pub fps: f64,
|
||||
|
||||
/// Animation duration in seconds, used with --fps to compute the frame
|
||||
/// count (ignored if --frames is given).
|
||||
#[arg(long, value_name = "SECONDS")]
|
||||
pub duration: Option<f64>,
|
||||
|
||||
/// Pace animation frames linearly instead of easing in/out (smoothstep).
|
||||
#[arg(long)]
|
||||
pub linear: bool,
|
||||
|
||||
/// 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, or any --to-* flag (--to-view, --to-julia, --to-kind, ...) to
|
||||
/// render an animation instead of a single frame. 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,
|
||||
#[value(alias = "cmulti")]
|
||||
ComplexMultibrot,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
pub enum RenderingKindArg {
|
||||
Classic,
|
||||
Shadow,
|
||||
#[value(alias = "3d")]
|
||||
Dimension3,
|
||||
}
|
||||
|
||||
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,
|
||||
KindArg::ComplexMultibrot => FractalKind::ComplexMultibrot,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,412 +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 std::collections::HashMap;
|
||||
|
||||
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,
|
||||
/// Padding so `complex_power` (a vec2, 8-byte aligned in the shader)
|
||||
/// starts on an 8-byte boundary.
|
||||
pub _pad0: u32,
|
||||
/// Complex exponent for the Complex Multibrot kind; ignored by other kinds.
|
||||
pub complex_power: [f32; 2],
|
||||
}
|
||||
|
||||
/// 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,
|
||||
complex_power: [f32; 2],
|
||||
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,
|
||||
complex_power: u.complex_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 {
|
||||
shader: wgpu::ShaderModule,
|
||||
compute_pipeline_layout: wgpu::PipelineLayout,
|
||||
/// Accumulation pipelines, specialized per fractal kind (the shader's
|
||||
/// `override KIND`, so `advance()` has no per-step kind branches) and
|
||||
/// built lazily on first use.
|
||||
compute_pipelines: HashMap<u32, 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(
|
||||
concat!(
|
||||
include_str!("../shaders/common.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 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 {
|
||||
shader,
|
||||
compute_pipeline_layout,
|
||||
compute_pipelines: HashMap::new(),
|
||||
compute_bind_group_layout,
|
||||
tonemap_pipeline,
|
||||
tonemap_bind_group_layout,
|
||||
uniform_buffer,
|
||||
histogram: None,
|
||||
last_content: None,
|
||||
total_samples: 0.0,
|
||||
seed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The accumulation pipeline for `kind`, built on first use.
|
||||
fn compute_pipeline(&mut self, device: &wgpu::Device, kind: u32) -> &wgpu::ComputePipeline {
|
||||
self.compute_pipelines.entry(kind).or_insert_with(|| {
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("buddhabrot compute pipeline"),
|
||||
layout: Some(&self.compute_pipeline_layout),
|
||||
module: &self.shader,
|
||||
entry_point: Some("cs_main"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[("KIND", kind as f64)],
|
||||
..Default::default()
|
||||
},
|
||||
cache: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 pipeline = renderer
|
||||
.compute_pipeline(device, self.uniforms.kind)
|
||||
.clone();
|
||||
|
||||
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(&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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
//! `FractalKind`: the enum selecting which iteration formula is in use, plus
|
||||
//! everything that only needs to switch on it (UI label/description/formula
|
||||
//! text, share-link tag, default parameter-plane view). The CPU/GPU orbit
|
||||
//! math itself lives in `reference.rs` (CPU reference orbit) and
|
||||
//! `shaders/mandelbrot.wgsl` (GPU perturbation delta) since both must also
|
||||
//! stay in sync with `common.wgsl`'s `KIND_*` constants.
|
||||
|
||||
/// 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,
|
||||
/// `z -> (|Re z| + i|Im z|)^2 + c`.
|
||||
BurningShip = 1,
|
||||
/// `z -> conj(z)^2 + c` (the Mandelbar).
|
||||
Tricorn = 2,
|
||||
/// `z -> z^power + c` (integer power in [2, 20]).
|
||||
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) + c` (logistic map).
|
||||
Lambda = 8,
|
||||
/// `z -> z^power + c`, where `power` is a complex constant (the
|
||||
/// `complex_power` argument), via the principal branch `z^p = exp(p·ln z)`.
|
||||
ComplexMultibrot = 9,
|
||||
}
|
||||
|
||||
impl FractalKind {
|
||||
/// Every kind, in declaration/discriminant order. Sized arrays keyed by
|
||||
/// `kind as usize` (`JULIA_PRESETS`, `SET_PRESETS`) must have one slot per
|
||||
/// entry here.
|
||||
pub const ALL: [FractalKind; 10] = [
|
||||
FractalKind::Mandelbrot,
|
||||
FractalKind::BurningShip,
|
||||
FractalKind::Tricorn,
|
||||
FractalKind::Multibrot,
|
||||
FractalKind::Celtic,
|
||||
FractalKind::Perpendicular,
|
||||
FractalKind::Buffalo,
|
||||
FractalKind::Phoenix,
|
||||
FractalKind::Lambda,
|
||||
FractalKind::ComplexMultibrot,
|
||||
];
|
||||
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
FractalKind::Mandelbrot => {
|
||||
"The Mandelbrot set is the most famous fractal set, obtained with the simplest escape-time formula. This set represents all Julia fractals: each points of the Mandelbrot set is related to a specific Julia fractal."
|
||||
}
|
||||
FractalKind::BurningShip => {
|
||||
"A variation of the famous Mandelbrot set, using absolute values on the real and imaginary part of each iterations."
|
||||
}
|
||||
FractalKind::Tricorn => {
|
||||
"The Tricorn set is obtained using the same formula as the Mandelbrot set, taking the complex conjugate of the previous iteration."
|
||||
}
|
||||
FractalKind::Multibrot => {
|
||||
"Multibrot use the same formula as the Mandelbrot set, with a bigger exposant."
|
||||
}
|
||||
FractalKind::Celtic => "",
|
||||
FractalKind::Perpendicular => "",
|
||||
FractalKind::Buffalo => "",
|
||||
FractalKind::Phoenix => "",
|
||||
FractalKind::Lambda => "",
|
||||
FractalKind::ComplexMultibrot => {
|
||||
"Like Multibrot, but the exponent itself is a complex number instead of a plain integer, via z^p = exp(p·ln z)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UI label for this kind (combo box / info panel heading).
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
FractalKind::Mandelbrot => "Mandelbrot",
|
||||
FractalKind::BurningShip => "Burning Ship",
|
||||
FractalKind::Tricorn => "Tricorn",
|
||||
FractalKind::Multibrot => "Multibrot",
|
||||
FractalKind::Celtic => "Celtic",
|
||||
FractalKind::Perpendicular => "Perpendicular",
|
||||
FractalKind::Buffalo => "Buffalo",
|
||||
FractalKind::Phoenix => "Phoenix",
|
||||
FractalKind::Lambda => "Lambda",
|
||||
FractalKind::ComplexMultibrot => "Complex Multibrot",
|
||||
}
|
||||
}
|
||||
|
||||
/// The iteration formula in human-readable notation (mirrors the doc
|
||||
/// comments on the variants above). `power` is only used by Multibrot;
|
||||
/// `complex_power` only by Complex Multibrot.
|
||||
pub fn formula(&self, power: u32, complex_power: (f64, f64)) -> String {
|
||||
match self {
|
||||
FractalKind::Mandelbrot => "z = z² + c".to_string(),
|
||||
FractalKind::BurningShip => "z = (|Re(z)| + i|Im(z)|)² + c".to_string(),
|
||||
FractalKind::Tricorn => "z = conj(z)² + c".to_string(),
|
||||
FractalKind::Multibrot => format!("z = z^{power} + c"),
|
||||
FractalKind::Celtic => "z = |Re(z²)| + i·Im(z²) + c".to_string(),
|
||||
FractalKind::Perpendicular => "z = (x² − y²) − 2x|y|i + c".to_string(),
|
||||
FractalKind::Buffalo => "z = |Re(z²)| − i|Im(z²)| + c".to_string(),
|
||||
FractalKind::Phoenix => "z = z² + c + p·z_prev".to_string(),
|
||||
FractalKind::Lambda => "z = λ·z(1 − z) + c".to_string(),
|
||||
FractalKind::ComplexMultibrot => {
|
||||
format!("z = z^({:.3}{:+.3}i) + c", complex_power.0, complex_power.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Short tag used to identify this kind in a share-link fragment.
|
||||
pub fn share_tag(&self) -> &'static str {
|
||||
match self {
|
||||
FractalKind::Mandelbrot => "mandel",
|
||||
FractalKind::BurningShip => "burning",
|
||||
FractalKind::Tricorn => "tricorn",
|
||||
FractalKind::Multibrot => "multi",
|
||||
FractalKind::Celtic => "celtic",
|
||||
FractalKind::Perpendicular => "perp",
|
||||
FractalKind::Buffalo => "buffalo",
|
||||
FractalKind::Phoenix => "phoenix",
|
||||
FractalKind::Lambda => "lambda",
|
||||
FractalKind::ComplexMultibrot => "cmulti",
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of `share_tag`; unknown tags fall back to `None` so the caller
|
||||
/// can decide the default (matches historical share-link behavior).
|
||||
pub fn from_share_tag(tag: &str) -> Option<FractalKind> {
|
||||
Some(match tag {
|
||||
"mandel" => FractalKind::Mandelbrot,
|
||||
"burning" => FractalKind::BurningShip,
|
||||
"tricorn" => FractalKind::Tricorn,
|
||||
"multi" => FractalKind::Multibrot,
|
||||
"celtic" => FractalKind::Celtic,
|
||||
"perp" => FractalKind::Perpendicular,
|
||||
"buffalo" => FractalKind::Buffalo,
|
||||
"phoenix" => FractalKind::Phoenix,
|
||||
"lambda" => FractalKind::Lambda,
|
||||
"cmulti" => FractalKind::ComplexMultibrot,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Default parameter-plane (Mandelbrot-mode) view for this kind, as
|
||||
/// `(center_re, center_im, half_height)`. The Julia (dynamical) plane
|
||||
/// doesn't vary by kind, so it isn't covered here.
|
||||
pub fn default_set_view(&self) -> (f64, f64, f64) {
|
||||
match self {
|
||||
FractalKind::Mandelbrot => (-0.5, 0.0, 1.25),
|
||||
FractalKind::BurningShip => (-0.5, -0.5, 1.3),
|
||||
FractalKind::Tricorn => (-0.25, 0.0, 1.7),
|
||||
FractalKind::Multibrot => (0.0, 0.0, 1.5),
|
||||
FractalKind::Celtic => (-0.5, 0.0, 1.6),
|
||||
FractalKind::Perpendicular => (-0.5, 0.0, 1.5),
|
||||
FractalKind::Buffalo => (-0.5, 0.5, 1.5),
|
||||
FractalKind::Phoenix => (-0.5, 0.0, 1.5),
|
||||
FractalKind::Lambda => (-0.5, 0.0, 2.4),
|
||||
FractalKind::ComplexMultibrot => (0.0, 0.0, 1.5),
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-12
@@ -1,20 +1,13 @@
|
||||
//! GPU fractal rendering: wgpu pipeline, uniforms, reference orbit, and the
|
||||
//! egui paint callback.
|
||||
|
||||
pub mod buddhabrot;
|
||||
pub mod kind;
|
||||
pub mod reference;
|
||||
pub mod renderer;
|
||||
pub mod share;
|
||||
|
||||
pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms};
|
||||
pub use kind::FractalKind;
|
||||
pub use reference::{RefOrbit, compute_reference, compute_set_reference};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use renderer::PipelineKey;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use renderer::encode_png_with_progress;
|
||||
pub use renderer::{ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use renderer::{encode_png, export_to_png_blocking, render_readback_blocking};
|
||||
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;
|
||||
|
||||
+327
-849
File diff suppressed because it is too large
Load Diff
+191
-1050
File diff suppressed because it is too large
Load Diff
+20
-54
@@ -2,14 +2,13 @@
|
||||
//! iterations, Julia constant, coloring) as a compact URL fragment so deep-zoom
|
||||
//! locations can be shared or bookmarked.
|
||||
//!
|
||||
//! Format: `m=m&f=<str>&re=<dec>&im=<dec>&hh=<sci>&it=<u32>&cs=<f32>&co=<f32>` with
|
||||
//! Format: `m=m&f=<str>&re=<dec>&im=<dec>&hh=<f64>&it=<u32>&cs=<f32>&co=<f32>` with
|
||||
//! `m=j&jr=<f64>&ji=<f64>` added for Julia. `re`/`im` are full-precision decimal
|
||||
//! strings; `hh` is a `Scale` in scientific notation (any exponent).
|
||||
//! strings.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::fractal::FractalKind;
|
||||
use crate::view::Scale;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShareState {
|
||||
@@ -19,43 +18,34 @@ pub struct ShareState {
|
||||
pub power: u32,
|
||||
pub center_re: String,
|
||||
pub center_im: String,
|
||||
pub half_height: Scale,
|
||||
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),
|
||||
/// Complex exponent for the Complex Multibrot kind (ignored by others).
|
||||
pub complex_power: (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={}", self.kind.share_tag()));
|
||||
s.push_str(&format!("&f={}", match self.kind {
|
||||
FractalKind::Mandelbrot => "mandel",
|
||||
FractalKind::BurningShip => "burning",
|
||||
FractalKind::Multibrot => "multi",
|
||||
FractalKind::Tricorn => "tricorn",
|
||||
}));
|
||||
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!(
|
||||
"&cpr={}&cpi={}",
|
||||
self.complex_power.0, self.complex_power.1
|
||||
));
|
||||
s.push_str(&format!(
|
||||
"&cs={}&co={}&pal={}&spal={}",
|
||||
self.color_scale, self.color_offset, self.palette, self.shadow_palette
|
||||
"&cs={}&co={}&pal={}",
|
||||
self.color_scale, self.color_offset, self.palette
|
||||
));
|
||||
s
|
||||
}
|
||||
@@ -73,7 +63,13 @@ impl ShareState {
|
||||
julia: map.get("m").map(|m| *m == "j").unwrap_or(false),
|
||||
kind: map
|
||||
.get("f")
|
||||
.and_then(|f| FractalKind::from_share_tag(f))
|
||||
.map(|f| match *f {
|
||||
"mandel" => FractalKind::Mandelbrot,
|
||||
"multi" => FractalKind::Multibrot,
|
||||
"burning" => FractalKind::BurningShip,
|
||||
"tricorn" => FractalKind::Tricorn,
|
||||
_ => FractalKind::Mandelbrot
|
||||
})
|
||||
.unwrap_or(FractalKind::Mandelbrot),
|
||||
power: map.get("pw").and_then(|s| s.parse().ok()).unwrap_or(2),
|
||||
center_re: (*map.get("re")?).to_string(),
|
||||
@@ -84,22 +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),
|
||||
),
|
||||
complex_power: (
|
||||
map.get("cpr").and_then(|s| s.parse().ok()).unwrap_or(2.0),
|
||||
map.get("cpi").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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -112,20 +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: Scale::from_f64(1.5e-20),
|
||||
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),
|
||||
complex_power: (2.5, 0.3),
|
||||
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);
|
||||
@@ -136,10 +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.complex_power, s.complex_power);
|
||||
assert_eq!(d.palette, s.palette);
|
||||
assert_eq!(d.shadow_palette, s.shadow_palette);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -147,15 +123,5 @@ mod tests {
|
||||
let d = ShareState::decode("#m=m&re=0.0&im=0.0&hh=1.25&it=256").unwrap();
|
||||
assert!(!d.julia);
|
||||
assert_eq!(d.iterations, 256);
|
||||
assert_eq!(d.half_height, Scale::from_f64(1.25));
|
||||
}
|
||||
|
||||
/// Zooms past f64's range survive a round trip.
|
||||
#[test]
|
||||
fn round_trip_past_f64_range() {
|
||||
let d = ShareState::decode("#m=m&re=0.0&im=0.0&hh=1.5e-1234&it=256").unwrap();
|
||||
assert_eq!(d.half_height, "1.5e-1234".parse().unwrap());
|
||||
let d2 = ShareState::decode(&d.encode()).unwrap();
|
||||
assert_eq!(d2.half_height, d.half_height);
|
||||
}
|
||||
}
|
||||
|
||||
-413
@@ -1,413 +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 std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Mutex, mpsc};
|
||||
|
||||
use eframe::egui_wgpu::wgpu;
|
||||
|
||||
use crate::app::{FractalApp, RefJob, parse_complex_pair, unix_timestamp};
|
||||
use crate::cli::Cli;
|
||||
use crate::fractal::{
|
||||
ExportRender, FractalKind, FractalRenderer, PipelineKey, ShareState, encode_png,
|
||||
export_to_png_blocking, render_readback_blocking,
|
||||
};
|
||||
use crate::view::{
|
||||
ViewState, big_from_decimal_str, interpolate_f64, interpolate_view, parse_view_spec,
|
||||
precision_for,
|
||||
};
|
||||
|
||||
/// 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);
|
||||
|
||||
// These drive the animation path below; grab them before `apply_cli`
|
||||
// consumes `cli` to build the start state.
|
||||
let targets = AnimTargets::from_cli(&cli)?;
|
||||
let export_path = cli.export_path.clone();
|
||||
|
||||
let mut app = FractalApp::default_state();
|
||||
app.apply_cli(cli);
|
||||
app.set_output_size(width, height);
|
||||
|
||||
if targets.any() {
|
||||
return run_animation(app, targets, width, height, export_path);
|
||||
}
|
||||
|
||||
let export_path = export_path.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp()));
|
||||
|
||||
eprintln!("computing reference orbit…");
|
||||
app.compute_reference_blocking();
|
||||
|
||||
let (device, queue) = pollster::block_on(request_device())?;
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
let renderer = FractalRenderer::new(&device, format);
|
||||
let uniforms = app.make_uniforms(width as f64 / height as f64, height as f64);
|
||||
let handles = renderer.export_handles(&device, &uniforms);
|
||||
|
||||
let er = ExportRender::new(
|
||||
&device,
|
||||
&queue,
|
||||
&handles,
|
||||
width,
|
||||
height,
|
||||
uniforms,
|
||||
app.reference_points(),
|
||||
app.lights(),
|
||||
);
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
/// The `--to-*` end state of a headless animation, plus its pacing. Each
|
||||
/// target is optional; anything left unset stays at its start value.
|
||||
struct AnimTargets {
|
||||
to_view: Option<String>,
|
||||
to_share: Option<String>,
|
||||
to_iterations: Option<u32>,
|
||||
to_julia: Option<(f64, f64)>,
|
||||
to_phoenix_p: Option<(f64, f64)>,
|
||||
to_lambda_l: Option<(f64, f64)>,
|
||||
/// Complex Multibrot exponent, per component (either may move alone).
|
||||
to_cpow_re: Option<f64>,
|
||||
to_cpow_im: Option<f64>,
|
||||
to_kind: Option<FractalKind>,
|
||||
/// 3D camera, degrees.
|
||||
to_yaw: Option<f32>,
|
||||
to_pitch: Option<f32>,
|
||||
frames: Option<u32>,
|
||||
fps: f64,
|
||||
duration: Option<f64>,
|
||||
linear: bool,
|
||||
}
|
||||
|
||||
impl AnimTargets {
|
||||
fn from_cli(cli: &Cli) -> Result<Self, String> {
|
||||
let pair = |flag: &str, v: &Option<String>| -> Result<Option<(f64, f64)>, String> {
|
||||
v.as_deref()
|
||||
.map(|s| parse_complex_pair(s).ok_or_else(|| format!("invalid --{flag}: {s}")))
|
||||
.transpose()
|
||||
};
|
||||
let to_cpow = pair("to-complex-power", &cli.to_complex_power)?;
|
||||
Ok(Self {
|
||||
to_view: cli.to_view.clone(),
|
||||
to_share: cli.to_share.clone(),
|
||||
to_iterations: cli.to_iterations,
|
||||
to_julia: pair("to-julia", &cli.to_julia)?,
|
||||
to_phoenix_p: pair("to-phoenix-p", &cli.to_phoenix_p)?,
|
||||
to_lambda_l: pair("to-lambda-l", &cli.to_lambda_l)?,
|
||||
to_cpow_re: cli.to_complex_power_re.or(to_cpow.map(|p| p.0)),
|
||||
to_cpow_im: cli.to_complex_power_im.or(to_cpow.map(|p| p.1)),
|
||||
to_kind: cli.to_kind.map(Into::into),
|
||||
to_yaw: cli.to_yaw,
|
||||
to_pitch: cli.to_pitch,
|
||||
frames: cli.frames,
|
||||
fps: cli.fps,
|
||||
duration: cli.duration,
|
||||
linear: cli.linear,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any end state was given, i.e. this is an animation.
|
||||
fn any(&self) -> bool {
|
||||
self.to_view.is_some()
|
||||
|| self.to_share.is_some()
|
||||
|| self.to_iterations.is_some()
|
||||
|| self.to_julia.is_some()
|
||||
|| self.to_phoenix_p.is_some()
|
||||
|| self.to_lambda_l.is_some()
|
||||
|| self.to_cpow_re.is_some()
|
||||
|| self.to_cpow_im.is_some()
|
||||
|| self.to_kind.is_some()
|
||||
|| self.to_yaw.is_some()
|
||||
|| self.to_pitch.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a sequence of frames interpolating from the app's current (start)
|
||||
/// state to `targets`, for feeding into ffmpeg: the camera, iteration count,
|
||||
/// per-kind constants (c, p, λ, complex power) and, through a kind morph,
|
||||
/// the iteration formula, and the 3D camera angles. Everything else (colors, ...) stays fixed at
|
||||
/// whatever `apply_cli` set up for the start.
|
||||
fn run_animation(
|
||||
mut app: FractalApp,
|
||||
targets: AnimTargets,
|
||||
width: u32,
|
||||
height: u32,
|
||||
export_path: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let fps = targets.fps;
|
||||
let frames = match targets.frames {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
let dur = targets
|
||||
.duration
|
||||
.ok_or("animation needs --frames, or --duration (with --fps)")?;
|
||||
((fps * dur).round() as u32).max(2)
|
||||
}
|
||||
};
|
||||
if frames < 2 {
|
||||
return Err("animation needs at least 2 frames".into());
|
||||
}
|
||||
|
||||
let from = app.view_state().clone();
|
||||
let (to, to_iterations_share) =
|
||||
parse_animation_target(targets.to_view.as_deref(), targets.to_share.as_deref())?
|
||||
.unwrap_or_else(|| (from.clone(), None));
|
||||
let to_iterations = targets.to_iterations.or(to_iterations_share);
|
||||
let from_iterations = app.max_iterations();
|
||||
if to_iterations.is_none() {
|
||||
// Iteration count auto-scales with zoom depth per frame, the same way it
|
||||
// does while zooming interactively — no need to interpolate it by hand.
|
||||
app.set_auto_iterations(true);
|
||||
}
|
||||
|
||||
let from_consts = app.constants();
|
||||
let [c0, p0, l0, cp0] = from_consts;
|
||||
let to_consts = [
|
||||
targets.to_julia.unwrap_or(c0),
|
||||
targets.to_phoenix_p.unwrap_or(p0),
|
||||
targets.to_lambda_l.unwrap_or(l0),
|
||||
(
|
||||
targets.to_cpow_re.unwrap_or(cp0.0),
|
||||
targets.to_cpow_im.unwrap_or(cp0.1),
|
||||
),
|
||||
];
|
||||
let from_kind = app.kind();
|
||||
let to_kind = targets.to_kind.unwrap_or(from_kind);
|
||||
let (yaw0, pitch0) = app.camera_angles();
|
||||
let yaw1 = targets.to_yaw.map_or(yaw0, f32::to_radians);
|
||||
let pitch1 = targets.to_pitch.map_or(pitch0, f32::to_radians);
|
||||
|
||||
let out_dir = export_path.unwrap_or_else(|| format!("frames-{}", unix_timestamp()));
|
||||
std::fs::create_dir_all(&out_dir).map_err(|e| format!("failed to create {out_dir}: {e}"))?;
|
||||
|
||||
// Everything about frame `i` is a pure function of its `t`, so the app can
|
||||
// be put into any frame's state at any time, in any order.
|
||||
let apply_frame = |app: &mut FractalApp, i: u32| {
|
||||
let raw_t = i as f64 / (frames - 1) as f64;
|
||||
let t = if targets.linear {
|
||||
raw_t
|
||||
} else {
|
||||
smoothstep(raw_t)
|
||||
};
|
||||
if let Some(to) = to_iterations {
|
||||
app.set_max_iterations(
|
||||
interpolate_f64(from_iterations as f64, to as f64, t).round() as u32,
|
||||
);
|
||||
}
|
||||
app.set_view(interpolate_view(&from, &to, t));
|
||||
app.set_constants(std::array::from_fn(|k| {
|
||||
(
|
||||
interpolate_f64(from_consts[k].0, to_consts[k].0, t),
|
||||
interpolate_f64(from_consts[k].1, to_consts[k].1, t),
|
||||
)
|
||||
}));
|
||||
app.set_kind_morph(from_kind, to_kind, t);
|
||||
app.set_camera_angles(
|
||||
interpolate_f64(yaw0 as f64, yaw1 as f64, t) as f32,
|
||||
interpolate_f64(pitch0 as f64, pitch1 as f64, t) as f32,
|
||||
);
|
||||
};
|
||||
|
||||
// Snapshot every frame's reference-orbit job up front (cheap: just the
|
||||
// parameters), so the orbits themselves can be computed in parallel.
|
||||
let jobs: Vec<RefJob> = (0..frames)
|
||||
.map(|i| {
|
||||
apply_frame(&mut app, i);
|
||||
app.reference_job()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (device, queue) = pollster::block_on(request_device())?;
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
let renderer = FractalRenderer::new(&device, format);
|
||||
let aspect = width as f64 / height as f64;
|
||||
|
||||
// Three-stage pipeline, connected by bounded channels (which also cap
|
||||
// memory): `threads` workers compute reference orbits (CPU, the expensive
|
||||
// part at deep zoom) → this thread renders each frame on the GPU → `threads`
|
||||
// workers PNG-encode and write frames. Frames flow through out of order
|
||||
// (at most ~`threads` apart); each is written under its own index.
|
||||
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
|
||||
let next_job = AtomicUsize::new(0);
|
||||
let saved = AtomicUsize::new(0);
|
||||
let failed = AtomicBool::new(false);
|
||||
let error: Mutex<Option<String>> = Mutex::new(None);
|
||||
let fail = |e: String| {
|
||||
failed.store(true, Ordering::Relaxed);
|
||||
error.lock().unwrap().get_or_insert(e);
|
||||
};
|
||||
|
||||
eprintln!("rendering {frames} frames ({width}×{height}) on {threads} threads…");
|
||||
let (png_tx, png_rx) = mpsc::sync_channel::<(usize, Vec<u8>, u32, bool)>(threads * 2);
|
||||
let png_rx = Mutex::new(png_rx);
|
||||
std::thread::scope(|scope| {
|
||||
let (ref_tx, ref_rx) = mpsc::sync_channel::<(usize, crate::fractal::RefOrbit)>(threads * 2);
|
||||
for _ in 0..threads {
|
||||
let ref_tx = ref_tx.clone();
|
||||
let (jobs, next_job, failed) = (&jobs, &next_job, &failed);
|
||||
scope.spawn(move || {
|
||||
loop {
|
||||
let i = next_job.fetch_add(1, Ordering::Relaxed);
|
||||
if i >= jobs.len() || failed.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
if ref_tx.send((i, jobs[i].compute())).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
drop(ref_tx);
|
||||
|
||||
for _ in 0..threads {
|
||||
let (png_rx, out_dir, saved, failed, fail) =
|
||||
(&png_rx, &out_dir, &saved, &failed, &fail);
|
||||
scope.spawn(move || {
|
||||
loop {
|
||||
// Hold the lock only for the receive, not the encode.
|
||||
let Ok((i, padded, bpr, swap_rb)) = png_rx.lock().unwrap().recv() else {
|
||||
break;
|
||||
};
|
||||
if failed.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let png =
|
||||
encode_png(&padded, width, height, bpr, swap_rb, png::Compression::Fast);
|
||||
let path = format!("{out_dir}/frame-{:05}.png", i + 1);
|
||||
if let Err(e) = std::fs::write(&path, &png) {
|
||||
fail(format!("save failed: {e}"));
|
||||
break;
|
||||
}
|
||||
let done = saved.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
eprint!("\r[{done:>4}/{frames}] saved");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// GPU stage, on this thread (it owns the app and the device). The
|
||||
// shader specialization (kind, Julia, DE, morph) can change between
|
||||
// frames during a kind morph; build each pipeline once.
|
||||
let mut pipelines = HashMap::new();
|
||||
for (i, points) in ref_rx.iter() {
|
||||
if failed.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
apply_frame(&mut app, i as u32);
|
||||
app.finish_reference(jobs[i].clone(), points);
|
||||
|
||||
let uniforms = app.make_uniforms(aspect, height as f64);
|
||||
let handles = pipelines
|
||||
.entry(PipelineKey::from_uniforms(&uniforms))
|
||||
.or_insert_with(|| renderer.export_handles(&device, &uniforms));
|
||||
let er = ExportRender::new(
|
||||
&device,
|
||||
&queue,
|
||||
handles,
|
||||
width,
|
||||
height,
|
||||
uniforms,
|
||||
app.reference_points(),
|
||||
app.lights(),
|
||||
);
|
||||
let padded = render_readback_blocking(&device, &queue, &er);
|
||||
if png_tx.send((i, padded, er.padded_bpr, er.swap_rb)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Dropping the channel ends lets the workers drain and exit.
|
||||
drop(png_tx);
|
||||
drop(ref_rx);
|
||||
});
|
||||
eprintln!();
|
||||
|
||||
if let Some(e) = error.into_inner().unwrap() {
|
||||
return Err(e);
|
||||
}
|
||||
let saved = saved.into_inner();
|
||||
if saved != frames as usize {
|
||||
return Err(format!("only {saved} of {frames} frames were rendered"));
|
||||
}
|
||||
|
||||
println!("saved {frames} frames to {out_dir}/ ({width}×{height})");
|
||||
println!(
|
||||
"tip: ffmpeg -framerate {fps} -i {out_dir}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p out.mp4"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse `--to-view`/`--to-share` (at most one is used) into the end view
|
||||
/// of an animation, or `None` if neither is set (the camera stays put). Only position/zoom/iterations are pulled from a
|
||||
/// share fragment — the rest of its state (kind, colors, ...) is ignored, so
|
||||
/// pasting a link from the app doesn't unexpectedly change the fractal kind
|
||||
/// mid-animation.
|
||||
fn parse_animation_target(
|
||||
to_view: Option<&str>,
|
||||
to_share: Option<&str>,
|
||||
) -> Result<Option<(ViewState, Option<u32>)>, String> {
|
||||
if let Some(spec) = to_view {
|
||||
return parse_view_spec(spec)
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("invalid --to-view spec: {spec}"));
|
||||
}
|
||||
let Some(frag) = to_share else {
|
||||
return Ok(None);
|
||||
};
|
||||
let state =
|
||||
ShareState::decode(frag).ok_or_else(|| format!("invalid --to-share fragment: {frag}"))?;
|
||||
let bits = precision_for(state.half_height);
|
||||
let re =
|
||||
big_from_decimal_str(&state.center_re, bits).ok_or("invalid --to-share center (re)")?;
|
||||
let im =
|
||||
big_from_decimal_str(&state.center_im, bits).ok_or("invalid --to-share center (im)")?;
|
||||
Ok(Some((
|
||||
ViewState::with_center(re, im, state.half_height),
|
||||
Some(state.iterations),
|
||||
)))
|
||||
}
|
||||
|
||||
/// Ease-in/ease-out pacing: slow at both ends, fast through the middle.
|
||||
fn smoothstep(t: f64) -> f64 {
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// 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,94 +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 * 180. / std::f64::consts::PI) as u32));
|
||||
let parser = |s: &str| {
|
||||
s.parse::<u32>()
|
||||
.ok()
|
||||
.map(|x| x as f64 * std::f64::consts::PI / 180.)
|
||||
};
|
||||
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)
|
||||
.custom_parser(parser)
|
||||
.speed(0.02),
|
||||
);
|
||||
ui.label("φ:");
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut self.altitude)
|
||||
.range(0.0..=PI / 2.)
|
||||
.custom_formatter(formater)
|
||||
.custom_parser(parser)
|
||||
.speed(0.02),
|
||||
);
|
||||
|
||||
del
|
||||
})
|
||||
.inner
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU-side light, matching WGSL `Light` in `iterate_uniforms.wgsl`: the unit
|
||||
/// direction toward the light (precomputed from azimuth/altitude so the
|
||||
/// shader does no per-pixel trig) plus the packed RGBA colour, whose alpha is
|
||||
/// the intensity. 16 bytes, so `array<Light, 16>` has a uniform-legal stride.
|
||||
#[derive(Clone, Copy, PartialEq, Zeroable, Pod, Default)]
|
||||
#[repr(C)]
|
||||
pub struct GpuLight {
|
||||
pub dir: [f32; 3],
|
||||
pub color: Color32,
|
||||
}
|
||||
|
||||
/// The light buffer's contents: the UI lights with a non-zero colour (the
|
||||
/// only ones that contribute, and the ones the filmic white point counts),
|
||||
/// packed to the front, plus how many there are (`Uniforms::light_count`).
|
||||
pub fn gpu_lights(lights: &[Light]) -> ([GpuLight; MAX_LIGHT_COUNT], u32) {
|
||||
let mut out = [GpuLight::default(); MAX_LIGHT_COUNT];
|
||||
let mut n = 0;
|
||||
for l in lights.iter().filter(|l| l.color != Color32::TRANSPARENT) {
|
||||
if n == MAX_LIGHT_COUNT {
|
||||
break;
|
||||
}
|
||||
let (sa, ca) = l.altitude.sin_cos();
|
||||
let (sz, cz) = l.azimuth.sin_cos();
|
||||
out[n] = GpuLight {
|
||||
dir: [cz * ca, sz * ca, sa],
|
||||
color: l.color,
|
||||
};
|
||||
n += 1;
|
||||
}
|
||||
(out, n as u32)
|
||||
}
|
||||
+6
-26
@@ -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
|
||||
@@ -9,15 +5,9 @@
|
||||
// and calls the wasm `main`, which boots eframe onto the page's <canvas>.
|
||||
|
||||
mod app;
|
||||
mod camera;
|
||||
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;
|
||||
|
||||
@@ -35,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")]
|
||||
{
|
||||
@@ -52,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(),
|
||||
@@ -123,7 +101,9 @@ fn main() {
|
||||
match result {
|
||||
Ok(_) => loading.remove(),
|
||||
Err(e) => {
|
||||
loading.set_inner_html(&format!("<p>The app has crashed.</br>{e:?}</p>"));
|
||||
loading.set_inner_html(
|
||||
"<p>The app has crashed. See the developer console for details.</p>",
|
||||
);
|
||||
log::error!("failed to start eframe: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,12 @@ struct VsOut {
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut {
|
||||
let p = fullscreen_triangle_pos(idx);
|
||||
var verts = array<vec2<f32>, 3>(
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>(3.0, -1.0),
|
||||
vec2<f32>(-1.0, 3.0),
|
||||
);
|
||||
let p = verts[idx];
|
||||
var out: VsOut;
|
||||
out.pos = vec4<f32>(p, 0.0, 1.0);
|
||||
// Map NDC to texture UV. v is flipped so the cache's top row (rendered at
|
||||
|
||||
@@ -1,289 +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 so `complex_power` (a vec2, 8-byte aligned) starts on an
|
||||
// 8-byte boundary. 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,
|
||||
// Complex exponent for the Complex Multibrot kind; unused by other kinds.
|
||||
complex_power: vec2<f32>,
|
||||
};
|
||||
|
||||
// Fractal kind, as a pipeline-overridable constant (set per compute pipeline
|
||||
// from `u.kind`, see `BuddhabrotRenderer::compute_pipeline`): every kind
|
||||
// branch in the iteration loop folds away at pipeline creation. Read this,
|
||||
// never `u.kind`.
|
||||
override KIND: u32 = 0u;
|
||||
|
||||
const PALETTE_NEBULA: u32 = 0u;
|
||||
const PALETTE_YELLOW: u32 = 1u;
|
||||
const PALETTE_GRAYSCALE: u32 = 2u;
|
||||
|
||||
@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 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 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 KIND == KIND_TRICORN {
|
||||
return vec2<f32>(z.x * z.x - z.y * z.y, -2.0 * z.x * z.y) + c;
|
||||
} else if KIND == KIND_MULTIBROT {
|
||||
return complex_pow(z, clamp(u.power, 2u, MULTIBROT_MAX_POWER)) + c;
|
||||
} else if KIND == KIND_CELTIC {
|
||||
return vec2<f32>(abs(z.x * z.x - z.y * z.y), 2.0 * z.x * z.y) + c;
|
||||
} else if KIND == KIND_PERPENDICULAR {
|
||||
return vec2<f32>(z.x * z.x - z.y * z.y, -2.0 * z.x * abs(z.y)) + c;
|
||||
} else if 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 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 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)));
|
||||
} else if KIND == KIND_COMPLEX_MULTIBROT {
|
||||
return cpow(z, u.complex_power) + c;
|
||||
}
|
||||
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 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> {
|
||||
return vec4<f32>(fullscreen_triangle_pos(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,197 +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).
|
||||
|
||||
@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>;
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4<f32> {
|
||||
return vec4<f32>(fullscreen_triangle_pos(idx), 0.0, 1.0);
|
||||
}
|
||||
|
||||
fn shadow_fragment(pos: vec2<f32>) -> vec4<f32> {
|
||||
let x = i32(pos.x);
|
||||
let y = i32(pos.y);
|
||||
let size = textureDimensions(data_tex);
|
||||
let here = textureLoad(data_tex, vec2<i32>(x, y), 0);
|
||||
if here.b != 0. {
|
||||
return vec4<f32>(shadow_interior_color(), 1.0);
|
||||
}
|
||||
// Forward differences, except on the last column/row where x+1 / y+1
|
||||
// is off the texture: fall back to a backward difference, mirrored
|
||||
// (h0 + (h0 - h[-1])) so the slope keeps the sign normal_from_heights
|
||||
// expects — plugging h[-1] in directly would flip the normal there.
|
||||
let h0 = here.g;
|
||||
var h1: f32;
|
||||
if x + 1 < i32(size.x) {
|
||||
h1 = textureLoad(data_tex, vec2<i32>(x + 1, y), 0).g;
|
||||
} else {
|
||||
h1 = 2.0 * h0 - textureLoad(data_tex, vec2<i32>(x - 1, y), 0).g;
|
||||
}
|
||||
var h2: f32;
|
||||
if y + 1 < i32(size.y) {
|
||||
h2 = textureLoad(data_tex, vec2<i32>(x, y + 1), 0).g;
|
||||
} else {
|
||||
h2 = 2.0 * h0 - textureLoad(data_tex, vec2<i32>(x, y - 1), 0).g;
|
||||
}
|
||||
let normal = normal_from_heights(h0, h1, h2);
|
||||
return vec4<f32>(shadow_color(normal, here.r), 1.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
if u.shadow == 2u {
|
||||
return ray_marching(pos);
|
||||
} else if u.shadow == 1u {
|
||||
return shadow_fragment(pos.xy);
|
||||
} 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;
|
||||
|
||||
var col = classic_color(ci, 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Colour of rays that miss the fractal's footprint.
|
||||
const RAY_MISS: vec4<f32> = vec4<f32>(1.0, 0.0, 0.0, 1.0);
|
||||
|
||||
// Per-frame constants of the raymarch, computed once per pixel in
|
||||
// `ray_marching` rather than on each of the up-to-100 `sdf` steps.
|
||||
struct MarchConsts {
|
||||
size: vec2<f32>,
|
||||
// (size.x / aspect_ratio, size.y): world xy -> texel scale.
|
||||
to_texel: vec2<f32>,
|
||||
size_i: vec2<i32>,
|
||||
inv_size_y: f32,
|
||||
};
|
||||
|
||||
fn sdf(pos: vec3<f32>, k: MarchConsts) -> f32 {
|
||||
let texture_pos_f32 = pos.xy * k.to_texel;
|
||||
let texture_pos = clamp(vec2<i32>(texture_pos_f32), vec2<i32>(0, 0), k.size_i - vec2<i32>(1, 1));
|
||||
|
||||
let to_texture = max(-min(texture_pos_f32, vec2(0.)), max(texture_pos_f32 - k.size, vec2(0.)));
|
||||
let dist_to_texture = length(to_texture) * k.inv_size_y;
|
||||
|
||||
let px = textureLoad(data_tex, texture_pos, 0);
|
||||
let de = (px.g * k.inv_size_y) * 0.5;
|
||||
// Height is measured toward -z, the side the camera sits on (it looks
|
||||
// along +z), so the terrain is solid on +z: interior plateau at z = 0,
|
||||
// exterior sloping away from the camera as `de` grows.
|
||||
let signed_z = -pos.z;
|
||||
let z = max(signed_z, 0.);
|
||||
var d: f32;
|
||||
if px.b != 0. {
|
||||
d = z;
|
||||
} else {
|
||||
d = min(sqrt(z * z + de * de), signed_z + 1. - exp(-de * 5.));
|
||||
}
|
||||
// Outside the texture footprint, `d` is the distance from the clamped
|
||||
// point q on the footprint's edge. The terrain lies over the (convex)
|
||||
// footprint, so |p - x|² ≥ |q - x|² + |p - q|² for every terrain point x:
|
||||
// combine in quadrature (not by adding, which overshoots). p can't be in
|
||||
// the solid out here, so a negative `d` counts as 0.
|
||||
if dist_to_texture > 0. {
|
||||
let d_pos = max(d, 0.);
|
||||
return sqrt(d_pos * d_pos + dist_to_texture * dist_to_texture);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
fn ray_marching(pos: vec4<f32>) -> vec4<f32> {
|
||||
let size_i = vec2<i32>(textureDimensions(data_tex));
|
||||
let size = vec2<f32>(size_i);
|
||||
let aspect_ratio = u.screen_dim.x / u.screen_dim.y;
|
||||
let k = MarchConsts(size, vec2<f32>(size.x / aspect_ratio, size.y), size_i, 1.0 / size.y);
|
||||
|
||||
let in_texture = vec2<f32>(
|
||||
(pos.x / size.x) * 2. - 1.,
|
||||
(pos.y / size.y) * 2. - 1.,
|
||||
);
|
||||
|
||||
var world_pos = u.camera_inv_proj * vec4<f32>(in_texture, 0., 1.0);
|
||||
|
||||
let ray_origin = world_pos.xyz;
|
||||
let ray_dir = u.camera_direction;
|
||||
|
||||
// Start where the ray crosses z = 0, the topmost possible surface (the
|
||||
// camera pitch is clamped short of ±90°, so ray_dir.z > 0).
|
||||
let start = ray_origin - ray_dir * (ray_origin.z / ray_dir.z);
|
||||
|
||||
// The terrain only exists over the footprint x in [0, aspect],
|
||||
// y in [0, 1]: clip the ray's xy to it up front, so rays that miss it cost
|
||||
// nothing and the rest start marching at its edge. A huge finite 1/d on
|
||||
// an axis the ray doesn't move along (top-down, during the 2D <-> 3D
|
||||
// transition) keeps the slab maths finite.
|
||||
let inv = select(1.0 / ray_dir.xy, vec2<f32>(1e30), abs(ray_dir.xy) < vec2<f32>(1e-20));
|
||||
let ta = -start.xy * inv;
|
||||
let tb = (vec2<f32>(aspect_ratio, 1.0) - start.xy) * inv;
|
||||
let t_leave = min(max(ta.x, tb.x), max(ta.y, tb.y));
|
||||
var t = max(max(min(ta.x, tb.x), min(ta.y, tb.y)), 0.0);
|
||||
if t >= t_leave {
|
||||
return RAY_MISS;
|
||||
}
|
||||
|
||||
// About 1/50 of a texel at typical sizes: tighter only adds steps
|
||||
// without visibly moving the hit.
|
||||
let dist_threshold = 0.00001;
|
||||
// Rays grazing the exponential slope see a tiny `dist` for many steps in
|
||||
// a row and would crawl along it until the step budget runs out. Force a
|
||||
// step of at least half a texel (the height field is nearest-sampled, so
|
||||
// nothing finer exists), and bisect back if that lands inside the solid.
|
||||
let min_step = 0.5 * k.inv_size_y;
|
||||
var hit = false;
|
||||
var t_prev = t;
|
||||
for (var i = 0u; i < 100u; i++) {
|
||||
let dist = sdf(start + t * ray_dir, k);
|
||||
if dist < dist_threshold {
|
||||
hit = true;
|
||||
if dist < 0. {
|
||||
// Overshot: t_prev is outside, t inside. Refine the crossing.
|
||||
var lo = t_prev;
|
||||
var hi = t;
|
||||
for (var j = 0u; j < 8u; j++) {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if sdf(start + mid * ray_dir, k) < dist_threshold {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
t = hi;
|
||||
}
|
||||
break;
|
||||
}
|
||||
t_prev = t;
|
||||
t += max(dist, min_step);
|
||||
// Past the footprint's far edge: nothing left to hit.
|
||||
if t >= t_leave {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Out of steps while still over the footprint: the ray is skimming the
|
||||
// surface, so shade where it got to rather than reporting a miss.
|
||||
if !hit && t < t_leave {
|
||||
hit = true;
|
||||
}
|
||||
// Shade outside the loop, so its registers don't weigh on the march.
|
||||
if !hit {
|
||||
return RAY_MISS;
|
||||
}
|
||||
return shadow_fragment((start.xy + t * ray_dir.xy) * k.to_texel);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// Shared helpers, concatenated into every shader at build time via
|
||||
// `concat!`/`include_str!` (see renderer.rs / buddhabrot.rs). Keep this file
|
||||
// free of anything that differs between pipelines (e.g. a `Uniforms` struct —
|
||||
// mandelbrot/colorize and buddhabrot each have their own shape) since every
|
||||
// shader gets the whole thing spliced in.
|
||||
|
||||
// Fullscreen triangle vertex position: one triangle that covers the whole
|
||||
// viewport (cheaper than a quad's two), shared by every full-screen vertex
|
||||
// shader in this project.
|
||||
fn fullscreen_triangle_pos(idx: u32) -> vec2<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 verts[idx];
|
||||
}
|
||||
|
||||
// Complex multiply.
|
||||
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);
|
||||
}
|
||||
|
||||
// z^p for a complex exponent p, via the principal branch z^p = exp(p * ln z),
|
||||
// ln z = ln|z| + i*arg(z). z = 0 maps to 0 (the correct limit for the
|
||||
// Re(p) > 0 region the UI exposes; ln(0) would otherwise be -inf).
|
||||
fn cpow(z: vec2<f32>, p: vec2<f32>) -> vec2<f32> {
|
||||
let r2 = dot(z, z);
|
||||
if r2 < 1e-30 {
|
||||
return vec2<f32>(0.0, 0.0);
|
||||
}
|
||||
let ln_r = 0.5 * log(r2);
|
||||
let theta = atan2(z.y, z.x);
|
||||
let mag = exp(p.x * ln_r - p.y * theta);
|
||||
let ang = p.x * theta + p.y * ln_r;
|
||||
return mag * vec2<f32>(cos(ang), sin(ang));
|
||||
}
|
||||
|
||||
// Iteration formula selector, shared by the perturbation (mandelbrot.wgsl)
|
||||
// and direct (buddhabrot.wgsl) iteration paths. Must match `FractalKind` in
|
||||
// reference.rs.
|
||||
const KIND_MANDELBROT: u32 = 0u;
|
||||
const KIND_BURNING_SHIP: u32 = 1u;
|
||||
const KIND_TRICORN: u32 = 2u;
|
||||
const KIND_MULTIBROT: u32 = 3u;
|
||||
// Highest Multibrot power (the UI/CLI/share-link clamp in app.rs matches).
|
||||
// `bailout_sq` in app.rs shrinks the bailout with the power so z^p stays a
|
||||
// finite f32.
|
||||
const MULTIBROT_MAX_POWER: u32 = 20u;
|
||||
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;
|
||||
const KIND_COMPLEX_MULTIBROT: u32 = 9u;
|
||||
@@ -1,197 +0,0 @@
|
||||
// Shared by mandelbrot.wgsl (writes the per-pixel data texture) and
|
||||
// colorize.wgsl (reads it): the iteration pass and the colour remap pass
|
||||
// must agree on both the uniform layout and the palette function.
|
||||
|
||||
// Must match the Rust `Uniforms` struct in renderer.rs field-for-field,
|
||||
// including padding.
|
||||
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,
|
||||
// Iteration formula (see the KIND_* constants in common.wgsl).
|
||||
kind: u32,
|
||||
// Exponent for the Multibrot kind.
|
||||
power: u32,
|
||||
// Kind-switch morph: the kind blended *from* (see morph_w).
|
||||
morph_from: 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>,
|
||||
// Complex exponent for the Complex Multibrot kind (z^power + c); unused
|
||||
// by other kinds.
|
||||
complex_power: vec2<f32>,
|
||||
// 0 = escape-time coloring, 1 = distance-estimation shading.
|
||||
de_coloring: u32,
|
||||
// 0 = classic colors, 1 = shadows, 2 = 3D raymarching rendering
|
||||
shadow: u32,
|
||||
// camera direction vector
|
||||
camera_direction: vec3<f32>,
|
||||
// Number of live entries at the start of `lights` (fills the vec3's tail
|
||||
// padding slot).
|
||||
light_count: u32,
|
||||
// inverse of the camera's view-projection matrix, for reconstructing a
|
||||
// world-space ray origin per pixel in the raymarcher
|
||||
camera_inv_proj: mat4x4<f32>,
|
||||
// Screen dimensions
|
||||
screen_dim: vec2<f32>,
|
||||
// Kind-switch morph weight: each step is (1 - w)*f_kind + w*f_morph_from;
|
||||
// 0 = no morph. Only read by MORPH pipelines (see mandelbrot.wgsl).
|
||||
morph_w: f32,
|
||||
// Deep views: binary exponent E of the view scale. `span` and `dc_offset`
|
||||
// are uploaded multiplied by 2^-E so they stay in f32's range; 0 = not
|
||||
// deep (plain f32 values). Only read by DEEP pipelines (mandelbrot.wgsl).
|
||||
scale_exp: i32,
|
||||
// Complex binomial coefficients C(complex_power, k) for k = 1..16, two per
|
||||
// vec4 (k odd in .xy, k even in .zw), for the Complex Multibrot delta
|
||||
// series. Precomputed on the CPU since they only depend on the power.
|
||||
cm_coef: array<vec4<f32>, 8>,
|
||||
};
|
||||
|
||||
// Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id.
|
||||
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));
|
||||
}
|
||||
|
||||
// Classic (non-shadow) escape colouring: palette lookup at the smoothed
|
||||
// iteration count `ci`, darkened by the distance-estimate factor `de`
|
||||
// (sqrt-compressed so the darkening falls off more gently near the
|
||||
// boundary). Shared by the colourise pass's classic branch (colorize.wgsl,
|
||||
// applied to an already-averaged data texel) and the PNG-export pass
|
||||
// (mandelbrot.wgsl's `fs_color`, applied per sub-sample pre-AA) — the two
|
||||
// places a fully escaped point is turned into a final pixel colour.
|
||||
fn classic_color(ci: f32, de: f32) -> vec3<f32> {
|
||||
let t = fract(ci * u.color_scale + u.color_offset);
|
||||
return palette(u.palette_id, t) * sqrt(de);
|
||||
}
|
||||
|
||||
// A single directional light, built on the CPU from the UI's light list
|
||||
// (`GpuLight` in lights.rs): `dir` is the unit direction toward the light
|
||||
// (precomputed from azimuth/altitude so the shader does no trig), `color` a
|
||||
// packed RGBA8 whose alpha doubles as intensity. Only the first
|
||||
// `u.light_count` entries are live, all with a non-zero colour. Each shader
|
||||
// that binds a `lights: array<Light, 16>` uniform (colorize.wgsl,
|
||||
// mandelbrot.wgsl's export shadow path) uses this same layout.
|
||||
struct Light {
|
||||
dir: vec3<f32>,
|
||||
color: u32,
|
||||
};
|
||||
|
||||
// Lambertian term for a unit `light` direction.
|
||||
fn compute_light(normal: vec3<f32>, light: vec3<f32>) -> vec3<f32> {
|
||||
return vec3<f32>(max(0., dot(normal, 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));
|
||||
}
|
||||
|
||||
// Surface normal from three height samples (`h0` at the pixel, `h1` one pixel
|
||||
// to the right, `h2` one pixel down), treating DE as a height field. Only the
|
||||
// differences matter, so callers don't need to pass pixel coordinates — a
|
||||
// texture-backed caller (colorize.wgsl) and a live-sampled caller
|
||||
// (mandelbrot.wgsl's export shadow path) can share this.
|
||||
fn normal_from_heights(h0: f32, h1: f32, h2: f32) -> vec3<f32> {
|
||||
let d0 = vec3<f32>(0.0, 0.0, h0);
|
||||
let d1 = vec3<f32>(1.0, 0.0, h1);
|
||||
let d2 = vec3<f32>(0.0, 1.0, h2);
|
||||
return normalize(cross(d1 - d0, d2 - d0));
|
||||
}
|
||||
|
||||
// Shade a DE-derived surface normal per `u.shadow_palette_id`: 0 = grayscale
|
||||
// key light, 1 = red/blue two-tone, 2 = the user's custom `lights` list,
|
||||
// 3 = the classic escape-time palette at `ci` (the smoothed iteration count),
|
||||
// lit by the grayscale key light.
|
||||
// Shared by the interactive shadow pass (colorize.wgsl) and the PNG-export
|
||||
// shadow path (mandelbrot.wgsl's `fs_color`), which must render identically.
|
||||
fn shadow_color(normal: vec3<f32>, ci: f32) -> vec3<f32> {
|
||||
var color: vec3<f32>;
|
||||
if u.shadow_palette_id == 0u {
|
||||
color = compute_light(normal, vec3<f32>(0.57735027, 0.57735027, 0.57735027)) + 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., 0.70710678, 0.70710678)) * vec3<f32>(1., 0.5, 0.5) + compute_light(normal, vec3<f32>(0.70710678, 0., 0.70710678)) * vec3<f32>(0.5, 1., 1.);
|
||||
|
||||
color = filmic(color, 4.2);
|
||||
} else if u.shadow_palette_id == 3u {
|
||||
// No DE darkening as in `classic_color`: in shadow/3D modes the DE
|
||||
// is a height (unclamped, not capped at 1), and the lighting already
|
||||
// shows the relief.
|
||||
let t = fract(ci * u.color_scale + u.color_offset);
|
||||
let ambient = 0.25;
|
||||
let light = compute_light(normal, vec3<f32>(0.57735027, 0.57735027, 0.57735027));
|
||||
color = palette(u.palette_id, t) * (ambient + (1.0 - ambient) * light);
|
||||
} else {
|
||||
color = vec3<f32>(0);
|
||||
let light_count = min(u.light_count, 16u);
|
||||
for (var i = 0u; i < light_count; i++) {
|
||||
let light_color = unpack4x8unorm(lights[i].color);
|
||||
color += compute_light(normal, lights[i].dir) * light_color.xyz * light_color.a;
|
||||
}
|
||||
|
||||
color = filmic(color, 1. + f32(light_count));
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
// Colour of an interior (non-escaped) pixel in shadow/3D modes: black under
|
||||
// the classic palette, like classic 2D mode, otherwise a dark gray plateau.
|
||||
fn shadow_interior_color() -> vec3<f32> {
|
||||
if u.shadow_palette_id == 3u {
|
||||
return vec3<f32>(0.0);
|
||||
}
|
||||
return vec3<f32>(0.1);
|
||||
}
|
||||
+212
-1159
File diff suppressed because it is too large
Load Diff
+30
-500
@@ -1,12 +1,9 @@
|
||||
//! Camera / view state over the complex plane.
|
||||
//!
|
||||
//! The center is stored in arbitrary precision (`FBig`) — this is what lets us
|
||||
//! zoom far past f64's ~1e13x limit. The pixel *scale* is a [`Scale`]: an
|
||||
//! f64 mantissa with its own `i32` binary exponent, so it isn't bound by
|
||||
//! f64's ~1e-308 range either (the floor, `Scale::MIN`, only keeps the GPU's
|
||||
//! i32 exponent arithmetic far from overflow). Once a pixel is smaller than
|
||||
//! `DEEP_PIXEL_SIZE` the GPU switches to rescaled deltas (see `needs_deep`),
|
||||
//! since f32 alone bottoms out near 1e-38.
|
||||
//! zoom far past f64's ~1e13x limit. The pixel *scale* stays `f64`: even at
|
||||
//! 10^30x zoom the scale is ~1e-33, comfortably inside f64's range. Only the
|
||||
//! center needs the extra digits.
|
||||
|
||||
use core::str::FromStr;
|
||||
|
||||
@@ -19,287 +16,46 @@ pub type Big = FBig<HalfAway, 2>;
|
||||
/// Half-height (complex units) of the default view; also the zoom-1 reference.
|
||||
pub const DEFAULT_HALF_HEIGHT: f64 = 1.25;
|
||||
|
||||
/// Below this pixel size (complex units per pixel) the GPU renders with the
|
||||
/// deep pipeline, whose per-pixel deltas start out as an f32 mantissa times
|
||||
/// `2^scale_exp`. Plain f32 stays exact as long as the smallest per-pixel
|
||||
/// offsets (a quarter pixel, for the AA grid) are normal floats (>= 2^-126),
|
||||
/// i.e. down to 2^-124 per pixel. Measured: pixel-identical to the deep path
|
||||
/// down to 2^-124 (no AA), first errors at 2^-126, all black by 2^-136. The
|
||||
/// deep path is slower, so the switch is as late as that allows, with two
|
||||
/// binades of margin.
|
||||
pub const DEEP_PIXEL_SIZE: f64 = 1.0 / (1u128 << 122) as f64; // 2^-122
|
||||
|
||||
/// Whether a view rendered `height_px` pixels tall needs the deep pipeline
|
||||
/// (see `DEEP_PIXEL_SIZE`).
|
||||
pub fn needs_deep(half_height: Scale, height_px: f64) -> bool {
|
||||
half_height.mul_f64(2.0 / height_px.max(1.0)) < Scale::from_f64(DEEP_PIXEL_SIZE)
|
||||
}
|
||||
|
||||
/// Binary exponent `E` of the deep view scale: `floor(log2(half_height))`,
|
||||
/// so the rescaled span is in `[2, 4)`. Never 0, which means "not deep"
|
||||
/// (see `Uniforms::scale_exp`).
|
||||
pub fn deep_scale_exp(half_height: Scale) -> i32 {
|
||||
let e = half_height.exponent();
|
||||
if e == 0 { -1 } else { e }
|
||||
}
|
||||
|
||||
/// `x * 2^k` for any `k`, saturating to 0 / infinity like the true value
|
||||
/// would (`powi` alone overflows at 2^±1024 even when the product fits).
|
||||
fn ldexp(mut x: f64, mut k: i32) -> f64 {
|
||||
while k > 1000 {
|
||||
x *= 2f64.powi(1000);
|
||||
k -= 1000;
|
||||
if x.is_infinite() || x == 0.0 {
|
||||
return x;
|
||||
}
|
||||
}
|
||||
while k < -1000 {
|
||||
x *= 2f64.powi(-1000);
|
||||
k += 1000;
|
||||
if x == 0.0 || x.is_infinite() {
|
||||
return x;
|
||||
}
|
||||
}
|
||||
x * 2f64.powi(k)
|
||||
}
|
||||
|
||||
/// A positive real with f64 precision and an `i32` binary exponent:
|
||||
/// `m · 2^e`, `m` in `[1, 2)`. The view's half-height (and the pixel size
|
||||
/// derived from it) is one of these, so zoom isn't bound by f64's range.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Scale {
|
||||
m: f64,
|
||||
e: i32,
|
||||
}
|
||||
|
||||
impl Scale {
|
||||
/// Deepest scale the view can reach: 2^-(2^20) (about 1e-315653). Far
|
||||
/// past anything a reference orbit can practically be computed for; it
|
||||
/// only keeps the shader's i32 exponent sums (scale × degree) from
|
||||
/// overflowing.
|
||||
pub const MIN: Scale = Scale {
|
||||
m: 1.0,
|
||||
e: -(1 << 20),
|
||||
};
|
||||
|
||||
/// `m · 2^e`, normalized. Non-positive or NaN input gives `MIN`.
|
||||
pub fn from_parts(m: f64, e: i32) -> Self {
|
||||
if m.is_nan() || m <= 0.0 {
|
||||
return Self::MIN;
|
||||
}
|
||||
if m.is_infinite() {
|
||||
return Scale {
|
||||
m: 1.0,
|
||||
e: i32::MAX / 2,
|
||||
};
|
||||
}
|
||||
// Bring m into [1, 2) through its own binary exponent (exact).
|
||||
let k = m.log2().floor() as i32;
|
||||
let mut m = ldexp(m, -k);
|
||||
let mut e = e.saturating_add(k);
|
||||
// log2 can round across a power of two.
|
||||
if m >= 2.0 {
|
||||
m /= 2.0;
|
||||
e = e.saturating_add(1);
|
||||
} else if m < 1.0 {
|
||||
m *= 2.0;
|
||||
e = e.saturating_sub(1);
|
||||
}
|
||||
Scale { m, e }.max(Self::MIN)
|
||||
}
|
||||
|
||||
pub fn from_f64(x: f64) -> Self {
|
||||
Self::from_parts(x, 0)
|
||||
}
|
||||
|
||||
/// `2^l`.
|
||||
pub fn from_log2(l: f64) -> Self {
|
||||
let e = l.floor();
|
||||
Self::from_parts((l - e).exp2(), e as i32)
|
||||
}
|
||||
|
||||
/// The value as an f64 (0 or infinity outside its range).
|
||||
pub fn to_f64(self) -> f64 {
|
||||
ldexp(self.m, self.e)
|
||||
}
|
||||
|
||||
/// `self · 2^k` as an f64: the value in units of `2^-k`.
|
||||
pub fn scaled_f64(self, k: i32) -> f64 {
|
||||
ldexp(self.m, self.e.saturating_add(k))
|
||||
}
|
||||
|
||||
/// `floor(log2(self))`.
|
||||
pub fn exponent(self) -> i32 {
|
||||
self.e
|
||||
}
|
||||
|
||||
pub fn log2(self) -> f64 {
|
||||
self.m.log2() + self.e as f64
|
||||
}
|
||||
|
||||
pub fn log10(self) -> f64 {
|
||||
self.log2() * core::f64::consts::LOG10_2
|
||||
}
|
||||
|
||||
/// `self · f` (`f > 0`).
|
||||
pub fn mul_f64(self, f: f64) -> Self {
|
||||
Self::from_parts(self.m * f, self.e)
|
||||
}
|
||||
|
||||
/// `self / other`, as an f64.
|
||||
pub fn ratio(self, other: Scale) -> f64 {
|
||||
ldexp(self.m / other.m, self.e.saturating_sub(other.e))
|
||||
}
|
||||
|
||||
pub fn max(self, other: Scale) -> Self {
|
||||
if other > self { other } else { self }
|
||||
}
|
||||
|
||||
pub fn min(self, other: Scale) -> Self {
|
||||
if other < self { other } else { self }
|
||||
}
|
||||
|
||||
pub fn clamp(self, lo: Scale, hi: Scale) -> Self {
|
||||
self.max(lo).min(hi)
|
||||
}
|
||||
|
||||
/// `f · self` as an exact `Big` at `bits` of precision (`f` any f64).
|
||||
pub fn big_times(self, f: f64, bits: usize) -> Big {
|
||||
big_from_f64(f * self.m, bits) << self.e as isize
|
||||
}
|
||||
|
||||
/// Exact binary value as a `Big`.
|
||||
fn to_big(self) -> Big {
|
||||
big_from_f64(self.m, 53) << self.e as isize
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Scale {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
|
||||
// Normalized and positive: the exponent decides, then the mantissa.
|
||||
Some(self.e.cmp(&other.e).then(self.m.partial_cmp(&other.m)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl core::fmt::Display for Scale {
|
||||
/// Scientific notation, `1.5e-20` / `3.7e-4000`. The precision flag
|
||||
/// (`{:.4}`) sets mantissa digits after the point; without it, enough
|
||||
/// digits to parse back to the same value.
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
let x = self.to_f64();
|
||||
if x.is_normal() {
|
||||
return match f.precision() {
|
||||
Some(p) => write!(f, "{x:.p$e}"),
|
||||
None => write!(f, "{x:e}"),
|
||||
};
|
||||
}
|
||||
// Out of f64's range: round the exact decimal expansion instead.
|
||||
let sig = f.precision().map_or(17, |p| p + 1);
|
||||
let dec = self
|
||||
.to_big()
|
||||
.to_decimal()
|
||||
.value()
|
||||
.with_precision(sig)
|
||||
.value();
|
||||
let repr = dec.repr();
|
||||
let digits = repr.significand().to_string();
|
||||
let digits = digits.trim_end_matches('0');
|
||||
let digits = if digits.is_empty() { "0" } else { digits };
|
||||
// value = significand · 10^exponent; move the point after the first digit.
|
||||
let exp10 = repr.exponent() + repr.significand().to_string().len() as isize - 1;
|
||||
let (head, tail) = digits.split_at(1);
|
||||
let tail = match f.precision() {
|
||||
Some(p) => format!("{tail:0<p$}"),
|
||||
None => tail.to_string(),
|
||||
};
|
||||
if tail.is_empty() {
|
||||
write!(f, "{head}e{exp10}")
|
||||
} else {
|
||||
write!(f, "{head}.{tail}e{exp10}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Scale {
|
||||
type Err = ();
|
||||
|
||||
/// Parses any positive decimal (`1.25`, `1.5e-20`, `3.7e-4000`), rounding
|
||||
/// to the nearest f64 mantissa. Values past `MIN` clamp to it.
|
||||
fn from_str(s: &str) -> Result<Self, ()> {
|
||||
let s = s.trim();
|
||||
if let Ok(x) = s.parse::<f64>()
|
||||
&& x.is_normal()
|
||||
{
|
||||
return if x > 0.0 {
|
||||
Ok(Self::from_f64(x))
|
||||
} else {
|
||||
Err(())
|
||||
};
|
||||
}
|
||||
// Too small (or large) for f64: go through an exact decimal.
|
||||
let dec = DBig::from_str(s).map_err(|_| ())?;
|
||||
let bin: Big = dec.with_base_and_precision::<2>(64).value();
|
||||
if bin < Big::ZERO {
|
||||
return Err(());
|
||||
}
|
||||
let repr = bin.repr();
|
||||
let digits = repr.digits();
|
||||
if digits == 0 {
|
||||
return Err(());
|
||||
}
|
||||
let top = repr.exponent() + digits as isize - 1;
|
||||
let m = (bin.clone() >> top).to_f64().value();
|
||||
let e = top.clamp(i32::MIN as isize, i32::MAX as isize) as i32;
|
||||
Ok(Self::from_parts(m, e))
|
||||
}
|
||||
}
|
||||
|
||||
/// Guard bits added on top of the zoom-dictated precision.
|
||||
const GUARD_BITS: usize = 48;
|
||||
/// Upper bound on center precision: what `Scale::MIN` needs. Only a guard
|
||||
/// against pathological input; the reference orbit is impractically slow
|
||||
/// long before this.
|
||||
pub const MAX_PRECISION_BITS: usize = (1 << 20) + GUARD_BITS;
|
||||
/// Upper bound on center precision (f32 GPU perturbation degrades long before
|
||||
/// this; the cap just prevents pathological allocation).
|
||||
const MAX_PRECISION_BITS: usize = 2048;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ViewState {
|
||||
pub center_re: Big,
|
||||
pub center_im: Big,
|
||||
/// Half the view height in complex-plane units. Zooming in shrinks this.
|
||||
pub half_height: Scale,
|
||||
pub half_height: f64,
|
||||
}
|
||||
|
||||
impl Default for ViewState {
|
||||
fn default() -> Self {
|
||||
let bits = precision_for(Scale::from_f64(DEFAULT_HALF_HEIGHT));
|
||||
let bits = precision_for(DEFAULT_HALF_HEIGHT);
|
||||
Self {
|
||||
center_re: big_from_f64(-0.5, bits),
|
||||
center_im: big_from_f64(0.0, bits),
|
||||
half_height: Scale::from_f64(DEFAULT_HALF_HEIGHT),
|
||||
half_height: DEFAULT_HALF_HEIGHT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewState {
|
||||
/// Complex-plane span (width, height) for the given pixel aspect ratio.
|
||||
pub fn span(&self, aspect: f64) -> (Scale, Scale) {
|
||||
let h = self.half_height.mul_f64(2.0);
|
||||
(h.mul_f64(aspect), h)
|
||||
pub fn span(&self, aspect: f64) -> (f64, f64) {
|
||||
let h = self.half_height * 2.0;
|
||||
(h * aspect, h)
|
||||
}
|
||||
|
||||
/// Complex-plane units per pixel, given the viewport height in pixels.
|
||||
pub fn complex_per_pixel(&self, height_px: f64) -> Scale {
|
||||
self.half_height.mul_f64(2.0 / height_px)
|
||||
pub fn complex_per_pixel(&self, height_px: f64) -> f64 {
|
||||
(self.half_height * 2.0) / height_px
|
||||
}
|
||||
|
||||
/// log10 of the current magnification relative to the default view.
|
||||
pub fn magnification_log10(&self) -> f64 {
|
||||
DEFAULT_HALF_HEIGHT.log10() - self.half_height.log10()
|
||||
}
|
||||
|
||||
/// Current zoom level.
|
||||
pub fn zoom(&self) -> Scale {
|
||||
self.half_height
|
||||
/// Current magnification relative to the default view.
|
||||
pub fn magnification(&self) -> f64 {
|
||||
DEFAULT_HALF_HEIGHT / self.half_height
|
||||
}
|
||||
|
||||
/// Bits of precision the center currently needs for this zoom level.
|
||||
@@ -325,8 +81,8 @@ impl ViewState {
|
||||
let cpp = self.complex_per_pixel(height_px);
|
||||
let bits = self.precision_bits();
|
||||
// Grab-and-drag: moving the mouse right shows content to the left.
|
||||
self.center_re = &self.center_re - &cpp.big_times(dx, bits);
|
||||
self.center_im = &self.center_im - &cpp.big_times(dy, bits);
|
||||
self.center_re = &self.center_re - &big_from_f64(dx * 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
|
||||
@@ -339,14 +95,14 @@ impl ViewState {
|
||||
// The cursor's complex offset from the center is (off * cpp). Keeping it
|
||||
// fixed while scaling the view by `factor` moves the center by
|
||||
// off * cpp * (1 - factor). (Derivation: new_c = fixed + (c-fixed)*f.)
|
||||
let k = 1.0 - factor;
|
||||
self.center_re = &self.center_re + &cpp.big_times(off_x * k, bits);
|
||||
self.center_im = &self.center_im + &cpp.big_times(off_y * k, bits);
|
||||
self.half_height = self.half_height.mul_f64(factor);
|
||||
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); // y flip
|
||||
self.half_height *= factor;
|
||||
}
|
||||
|
||||
/// Build a view from full-precision center coordinates and a half-height.
|
||||
pub fn with_center(center_re: Big, center_im: Big, half_height: Scale) -> Self {
|
||||
pub fn with_center(center_re: Big, center_im: Big, half_height: f64) -> Self {
|
||||
let mut v = Self {
|
||||
center_re,
|
||||
center_im,
|
||||
@@ -364,96 +120,6 @@ pub fn big_from_decimal_str(s: &str, bits: usize) -> Option<Big> {
|
||||
Some(dec.with_base_and_precision::<2>(bits.max(53)).value())
|
||||
}
|
||||
|
||||
/// Parse a "re,im,half_height[,iterations]" spec (re/im decimal, parsed at
|
||||
/// full precision) into a view and an optional iteration count. Shared by
|
||||
/// `FractalApp::apply_view_spec` (the `--view` CLI flag) and headless
|
||||
/// animation's `--to-view`.
|
||||
pub fn parse_view_spec(spec: &str) -> Option<(ViewState, Option<u32>)> {
|
||||
let parts: Vec<&str> = spec.split(',').collect();
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let half_height = parse_half_height_spec(parts[2])?;
|
||||
let bits = precision_for(half_height);
|
||||
let re = big_from_decimal_str(parts[0], bits)?;
|
||||
let im = big_from_decimal_str(parts[1], bits)?;
|
||||
let iterations = parts.get(3).and_then(|s| s.trim().parse::<u32>().ok());
|
||||
Some((ViewState::with_center(re, im, half_height), iterations))
|
||||
}
|
||||
|
||||
/// Parse a half_height spec. Shared by
|
||||
/// `FractalApp::apply_half_height_spec` (the `--zoom` CLI flag) and headless
|
||||
/// animation's `--to-zoom`.
|
||||
pub fn parse_half_height_spec(spec: &str) -> Option<Scale> {
|
||||
spec.parse::<Scale>().ok()
|
||||
}
|
||||
/// Parse a "re,im" spec (re/im decimal, parsed at
|
||||
/// full precision) into a view. Shared by
|
||||
/// `FractalApp::apply_re_im_spec` (the `--position` CLI flag) and headless
|
||||
/// animation's `--to-position`.
|
||||
pub fn parse_re_im_spec(spec: &str, bits: usize) -> Option<(Big, Big)> {
|
||||
let parts: Vec<&str> = spec.split(',').collect();
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
let re = big_from_decimal_str(parts[0], bits)?;
|
||||
let im = big_from_decimal_str(parts[1], bits)?;
|
||||
Some((re, im))
|
||||
}
|
||||
|
||||
/// Interpolate between two views for an animation frame, `t` in `[0, 1]`.
|
||||
/// The half-height interpolates geometrically (log-linear), since zoom depth
|
||||
/// spans many decades and a linear sweep would crawl at the start and blow
|
||||
/// past the target at the end. The center has to shrink its offset from the
|
||||
/// target at that *same* geometric rate: blending it linearly in `t` instead
|
||||
/// barely moves it while the view is still huge (early frames), so the
|
||||
/// target stays effectively off-screen — offset/half_height ratio blows up —
|
||||
/// for nearly the whole animation, and only lands on `to`'s center in the
|
||||
/// literal last frame where `t == 1` forces an exact match. `g(t)` below
|
||||
/// tracks the same `q^t` decay used for `half_height` (keeping the
|
||||
/// offset/half_height ratio roughly constant, i.e. the target's on-screen
|
||||
/// position steady) but is shifted so it lands on exactly 1 at `t = 0` and
|
||||
/// exactly 0 at `t = 1`.
|
||||
///
|
||||
/// With `d = log2(q)`, `g = q^t · (1 - q^(1-t)) / (1 - q)`, all in `Scale`
|
||||
/// / `expm1` form: zooming in by more than f64's range, `q` (and `q^t`)
|
||||
/// underflow, yet `g · (from - to)` must keep tracking the half-height.
|
||||
pub fn interpolate_view(from: &ViewState, to: &ViewState, t: f64) -> ViewState {
|
||||
let (l0, l1) = (from.half_height.log2(), to.half_height.log2());
|
||||
let d = l1 - l0;
|
||||
let half_height = if t <= 0.0 {
|
||||
from.half_height
|
||||
} else if t >= 1.0 {
|
||||
to.half_height
|
||||
} else {
|
||||
Scale::from_log2(l0 + t * d)
|
||||
};
|
||||
let bits = precision_for(half_height);
|
||||
let ln2 = core::f64::consts::LN_2;
|
||||
let g_big = if d.abs() < 1e-12 {
|
||||
big_from_f64(1.0 - t, bits)
|
||||
} else if d < 0.0 {
|
||||
// Zooming in: q^t may be far below f64's range, keep it as a Scale.
|
||||
let f = ((1.0 - t) * d * ln2).exp_m1() / (d * ln2).exp_m1();
|
||||
Scale::from_log2(t * d).big_times(f, bits)
|
||||
} else {
|
||||
// Zooming out: g = (1 - q^(t-1)) / (1 - q^-1), every term bounded.
|
||||
big_from_f64(((t - 1.0) * d * ln2).exp_m1() / (-d * ln2).exp_m1(), bits)
|
||||
};
|
||||
let re0 = from.center_re.clone().with_precision(bits).value();
|
||||
let im0 = from.center_im.clone().with_precision(bits).value();
|
||||
let re1 = to.center_re.clone().with_precision(bits).value();
|
||||
let im1 = to.center_im.clone().with_precision(bits).value();
|
||||
let center_re = &re1 + &(&(&re0 - &re1) * &g_big);
|
||||
let center_im = &im1 + &(&(&im0 - &im1) * &g_big);
|
||||
ViewState::with_center(center_re, center_im, half_height)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn interpolate_f64(from: f64, to: f64, t: f64) -> f64 {
|
||||
from + (to - from) * t
|
||||
}
|
||||
|
||||
/// Render a `Big` as a decimal string with `sig_digits` significant digits.
|
||||
pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String {
|
||||
let dec = x
|
||||
@@ -465,10 +131,14 @@ pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String {
|
||||
}
|
||||
|
||||
/// Precision (bits) needed to resolve the center at a given half-height.
|
||||
pub fn precision_for(half_height: Scale) -> usize {
|
||||
pub fn precision_for(half_height: f64) -> usize {
|
||||
// We need enough bits to distinguish points a pixel apart, i.e. roughly
|
||||
// log2(1 / half_height) significant bits, plus a guard margin.
|
||||
let zoom_bits = (-half_height.log2()).ceil().max(0.0) as usize;
|
||||
let zoom_bits = if half_height > 0.0 && half_height.is_finite() {
|
||||
(-half_height.log2()).ceil().max(0.0) as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(zoom_bits + GUARD_BITS).clamp(53, MAX_PRECISION_BITS)
|
||||
}
|
||||
|
||||
@@ -479,143 +149,3 @@ pub fn big_from_f64(x: f64, bits: usize) -> Big {
|
||||
.with_precision(bits)
|
||||
.value()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sc(x: f64) -> Scale {
|
||||
Scale::from_f64(x)
|
||||
}
|
||||
|
||||
fn re_im_f64(v: &ViewState) -> (f64, f64) {
|
||||
let re: f64 = v.center_re.to_decimal().value().to_f64().value();
|
||||
let im: f64 = v.center_im.to_decimal().value().to_f64().value();
|
||||
(re, im)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolate_view_hits_exact_endpoints() {
|
||||
let bits = precision_for(sc(1.0));
|
||||
let from =
|
||||
ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), sc(1.5));
|
||||
let to = ViewState::with_center(
|
||||
big_from_f64(-0.7515, precision_for(sc(1e-20))),
|
||||
big_from_f64(0.1013, precision_for(sc(1e-20))),
|
||||
sc(1e-20),
|
||||
);
|
||||
|
||||
let start = interpolate_view(&from, &to, 0.0);
|
||||
assert_eq!(re_im_f64(&start), re_im_f64(&from));
|
||||
assert_eq!(start.half_height, from.half_height);
|
||||
|
||||
let end = interpolate_view(&from, &to, 1.0);
|
||||
assert_eq!(re_im_f64(&end), re_im_f64(&to));
|
||||
assert_eq!(end.half_height, to.half_height);
|
||||
}
|
||||
|
||||
/// Regression test: a deep zoom's center used to be blended linearly in
|
||||
/// `t` while `half_height` shrank geometrically, so partway through the
|
||||
/// animation the offset from the target would already be far larger than
|
||||
/// the (tiny, geometrically-shrunk) view — the target only snapped into
|
||||
/// frame on the very last frame. The offset/half_height ratio should
|
||||
/// instead stay roughly bounded throughout.
|
||||
#[test]
|
||||
fn interpolate_view_keeps_target_offset_bounded() {
|
||||
let bits = precision_for(sc(1.0));
|
||||
let from =
|
||||
ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), sc(1.5));
|
||||
let to = ViewState::with_center(
|
||||
big_from_f64(-0.7515, precision_for(sc(1e-20))),
|
||||
big_from_f64(0.1013, precision_for(sc(1e-20))),
|
||||
sc(1e-20),
|
||||
);
|
||||
let (to_re, to_im) = re_im_f64(&to);
|
||||
|
||||
for i in 1..10 {
|
||||
let t = i as f64 / 10.0;
|
||||
let mid = interpolate_view(&from, &to, t);
|
||||
let (re, im) = re_im_f64(&mid);
|
||||
let offset = ((re - to_re).powi(2) + (im - to_im).powi(2)).sqrt();
|
||||
let ratio = offset / mid.half_height.to_f64();
|
||||
assert!(
|
||||
ratio < 10.0,
|
||||
"t={t}: offset/half_height ratio {ratio} blew up (offset={offset}, half_height={})",
|
||||
mid.half_height
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_parse_display_round_trip() {
|
||||
for s in [
|
||||
"1.25",
|
||||
"1e-20",
|
||||
"1.5e-20",
|
||||
"3.7e-4000",
|
||||
"1e-400",
|
||||
"9.99999e-310",
|
||||
] {
|
||||
let a: Scale = s.parse().unwrap();
|
||||
let b: Scale = a.to_string().parse().unwrap();
|
||||
assert_eq!(a, b, "{s} -> {a}");
|
||||
}
|
||||
assert_eq!("1.25".parse::<Scale>().unwrap().to_f64(), 1.25);
|
||||
assert_eq!(sc(1.5e-20).to_string(), "1.5e-20");
|
||||
let deep: Scale = "3.7e-4000".parse().unwrap();
|
||||
assert_eq!(deep.to_string(), "3.7e-4000");
|
||||
assert_eq!(format!("{deep:.2}"), "3.70e-4000");
|
||||
assert!((deep.log10() - (3.7f64.log10() - 4000.0)).abs() < 1e-9);
|
||||
assert!("0".parse::<Scale>().is_err());
|
||||
assert!("-1e-500".parse::<Scale>().is_err());
|
||||
assert!("abc".parse::<Scale>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_arithmetic() {
|
||||
let a: Scale = "1e-1000".parse().unwrap();
|
||||
let b = a.mul_f64(0.25);
|
||||
assert!((b.ratio(a) - 0.25).abs() < 1e-15);
|
||||
assert!(b < a && a > b);
|
||||
assert_eq!(a.mul_f64(3.0).mul_f64(1.0 / 3.0).exponent(), a.exponent());
|
||||
assert_eq!(sc(1.0).exponent(), 0);
|
||||
assert_eq!(sc(0.75).exponent(), -1);
|
||||
assert_eq!(sc(4.0).scaled_f64(-2), 1.0);
|
||||
assert_eq!(
|
||||
a.scaled_f64(-a.exponent()),
|
||||
a.mul_f64(1.0).scaled_f64(-a.exponent())
|
||||
);
|
||||
assert!((1.0..2.0).contains(&a.scaled_f64(-a.exponent())));
|
||||
assert_eq!(a.to_f64(), 0.0);
|
||||
assert_eq!(Scale::MIN.mul_f64(0.5), Scale::MIN);
|
||||
let p = precision_for(a);
|
||||
assert!((3322 + 48..=3323 + 48).contains(&p), "{p}");
|
||||
}
|
||||
|
||||
/// Past f64's range, the center must still land on the target at the
|
||||
/// same geometric pace as the half-height.
|
||||
#[test]
|
||||
fn interpolate_view_past_f64_range() {
|
||||
let from = ViewState::with_center(big_from_f64(-0.5, 64), big_from_f64(0.0, 64), sc(1.5));
|
||||
let hh: Scale = "1e-1000".parse().unwrap();
|
||||
let bits = precision_for(hh);
|
||||
let to =
|
||||
ViewState::with_center(big_from_f64(-0.7515, bits), big_from_f64(0.1013, bits), hh);
|
||||
let end = interpolate_view(&from, &to, 1.0);
|
||||
assert_eq!(end.half_height, hh);
|
||||
assert_eq!(re_im_f64(&end), re_im_f64(&to));
|
||||
let mut prev = from.half_height;
|
||||
for i in 1..20 {
|
||||
let t = i as f64 / 20.0;
|
||||
let mid = interpolate_view(&from, &to, t);
|
||||
assert!(mid.half_height < prev);
|
||||
prev = mid.half_height;
|
||||
// Offset from the target, in units of the view's half-height.
|
||||
let k = -mid.half_height.exponent() as isize;
|
||||
let dre = ((&mid.center_re - &to.center_re) << k).to_f64().value();
|
||||
let dim = ((&mid.center_im - &to.center_im) << k).to_f64().value();
|
||||
let ratio = (dre * dre + dim * dim).sqrt() / mid.half_height.scaled_f64(k as i32);
|
||||
assert!(ratio > 0.01 && ratio < 10.0, "t={t}: ratio {ratio}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-28
@@ -9,38 +9,31 @@
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
|
||||
use std::thread;
|
||||
|
||||
use crate::fractal::{FractalKind, RefOrbit, compute_reference, compute_set_reference};
|
||||
use crate::view::{Big, Scale, big_from_f64};
|
||||
use crate::fractal::{Bla, FractalKind, build_bla_table, compute_reference, compute_set_reference};
|
||||
use crate::view::{Big, big_from_f64};
|
||||
|
||||
pub struct RefRequest {
|
||||
pub center_re: Big,
|
||||
pub center_im: Big,
|
||||
pub half_height: Scale,
|
||||
pub half_height: f64,
|
||||
pub julia: bool,
|
||||
pub julia_c: (f64, f64),
|
||||
pub max_iter: u32,
|
||||
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),
|
||||
/// Complex exponent for the Complex Multibrot kind (ignored by other kinds).
|
||||
pub complex_power: (f64, f64),
|
||||
/// Kind-switch morph: `(from_kind, weight)` blended into every step.
|
||||
pub morph: Option<(FractalKind, f32)>,
|
||||
/// 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 {
|
||||
pub center_re: Big,
|
||||
pub center_im: Big,
|
||||
pub half_height: Scale,
|
||||
pub points: RefOrbit,
|
||||
/// The kind and morph `points` was computed with (echoed from the
|
||||
/// request).
|
||||
pub kind: FractalKind,
|
||||
pub morph: Option<(FractalKind, f32)>,
|
||||
pub half_height: f64,
|
||||
pub points: Vec<[f32; 2]>,
|
||||
pub bla: Vec<Bla>,
|
||||
}
|
||||
|
||||
pub struct RefWorker {
|
||||
@@ -68,14 +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,
|
||||
kind: req.kind,
|
||||
morph: req.morph,
|
||||
bla,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
@@ -102,7 +99,7 @@ impl RefWorker {
|
||||
}
|
||||
}
|
||||
|
||||
fn compute(req: &RefRequest) -> RefOrbit {
|
||||
fn compute(req: &RefRequest) -> Vec<[f32; 2]> {
|
||||
if req.julia {
|
||||
let jr = big_from_f64(req.julia_c.0, req.precision);
|
||||
let ji = big_from_f64(req.julia_c.1, req.precision);
|
||||
@@ -115,10 +112,6 @@ fn compute(req: &RefRequest) -> RefOrbit {
|
||||
req.precision,
|
||||
req.kind,
|
||||
req.power,
|
||||
req.phoenix_p,
|
||||
req.lambda_l,
|
||||
req.complex_power,
|
||||
req.morph.map(|(k, w)| (k, w as f64)),
|
||||
)
|
||||
} else {
|
||||
compute_set_reference(
|
||||
@@ -128,10 +121,6 @@ fn compute(req: &RefRequest) -> RefOrbit {
|
||||
req.precision,
|
||||
req.kind,
|
||||
req.power,
|
||||
req.phoenix_p,
|
||||
req.lambda_l,
|
||||
req.complex_power,
|
||||
req.morph.map(|(k, w)| (k, w as f64)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-135
@@ -3,7 +3,7 @@
|
||||
//! shader with the same `naga` version wgpu uses — catching shader errors
|
||||
//! without needing a GPU or a display.
|
||||
|
||||
fn validate(name: &str, src: &str) -> (naga::Module, naga::valid::ModuleInfo) {
|
||||
fn validate(name: &str, src: &str) {
|
||||
let module = match naga::front::wgsl::parse_str(src) {
|
||||
Ok(m) => m,
|
||||
Err(e) => panic!("{name}: WGSL parse error:\n{}", e.emit_to_string(src)),
|
||||
@@ -12,149 +12,20 @@ fn validate(name: &str, src: &str) -> (naga::Module, naga::valid::ModuleInfo) {
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
);
|
||||
match validator.validate(&module) {
|
||||
Ok(info) => (module, info),
|
||||
Err(e) => panic!("{name}: WGSL validation error:\n{}", e.emit_to_string(src)),
|
||||
if let Err(e) = validator.validate(&module) {
|
||||
panic!("{name}: WGSL validation error:\n{}", e.emit_to_string(src));
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of fractal kinds, i.e. the `const KIND_*` declarations in
|
||||
/// common.wgsl (one per `FractalKind` variant, values 0..N).
|
||||
fn kind_count() -> u32 {
|
||||
let n = include_str!("../src/shaders/common.wgsl")
|
||||
.lines()
|
||||
.filter(|l| l.starts_with("const KIND_"))
|
||||
.count() as u32;
|
||||
assert!(n >= 10, "found only {n} KIND_* constants in common.wgsl");
|
||||
n
|
||||
}
|
||||
|
||||
/// Specialize `module`'s `override`s with `constants` for `entry_point` (as
|
||||
/// wgpu does at pipeline creation) and compile the result to SPIR-V, so a
|
||||
/// shader that only breaks once a particular override value folds a branch
|
||||
/// in or out is still caught.
|
||||
fn specialize(
|
||||
name: &str,
|
||||
module: &naga::Module,
|
||||
info: &naga::valid::ModuleInfo,
|
||||
stage: naga::ShaderStage,
|
||||
entry_point: &str,
|
||||
constants: &[(&str, f64)],
|
||||
) {
|
||||
let mut pc = naga::back::PipelineConstants::default();
|
||||
for (k, v) in constants {
|
||||
pc.insert((*k).to_string(), *v);
|
||||
}
|
||||
let (module, info) = naga::back::pipeline_constants::process_overrides(
|
||||
module,
|
||||
info,
|
||||
Some((stage, entry_point)),
|
||||
&pc,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{name} {entry_point} {constants:?}: override error: {e:?}"));
|
||||
let pipeline = naga::back::spv::PipelineOptions {
|
||||
shader_stage: stage,
|
||||
entry_point: entry_point.to_string(),
|
||||
};
|
||||
naga::back::spv::write_vec(
|
||||
&module,
|
||||
&info,
|
||||
&naga::back::spv::Options::default(),
|
||||
Some(&pipeline),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{name} {entry_point} {constants:?}: SPIR-V error: {e:?}"));
|
||||
}
|
||||
|
||||
const MANDELBROT_SRC: &str = concat!(
|
||||
include_str!("../src/shaders/common.wgsl"),
|
||||
include_str!("../src/shaders/iterate_uniforms.wgsl"),
|
||||
include_str!("../src/shaders/mandelbrot.wgsl"),
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn mandelbrot_shader_is_valid() {
|
||||
validate("mandelbrot.wgsl", MANDELBROT_SRC);
|
||||
}
|
||||
|
||||
/// Every specialization renderer.rs can build (`PipelineKey`: kind × Julia ×
|
||||
/// DE × morph × deep), for every fragment entry point.
|
||||
#[test]
|
||||
fn mandelbrot_shader_specializations_compile() {
|
||||
let (module, info) = validate("mandelbrot.wgsl", MANDELBROT_SRC);
|
||||
for kind in 0..kind_count() {
|
||||
for julia in [0.0, 1.0] {
|
||||
for de in [0.0, 1.0] {
|
||||
for morph in [0.0, 1.0] {
|
||||
for deep in [0.0, 1.0] {
|
||||
let constants = [
|
||||
("KIND", kind as f64),
|
||||
("IS_JULIA", julia),
|
||||
("DE", de),
|
||||
("MORPH", morph),
|
||||
("DEEP", deep),
|
||||
];
|
||||
for entry in ["fs_data", "fs_refine", "fs_color"] {
|
||||
specialize(
|
||||
"mandelbrot.wgsl",
|
||||
&module,
|
||||
&info,
|
||||
naga::ShaderStage::Fragment,
|
||||
entry,
|
||||
&constants,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_shader_is_valid() {
|
||||
validate(
|
||||
"colorize.wgsl",
|
||||
concat!(
|
||||
include_str!("../src/shaders/common.wgsl"),
|
||||
include_str!("../src/shaders/iterate_uniforms.wgsl"),
|
||||
include_str!("../src/shaders/colorize.wgsl"),
|
||||
),
|
||||
"mandelbrot.wgsl",
|
||||
include_str!("../src/shaders/mandelbrot.wgsl"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blit_shader_is_valid() {
|
||||
validate(
|
||||
"blit.wgsl",
|
||||
concat!(
|
||||
include_str!("../src/shaders/common.wgsl"),
|
||||
include_str!("../src/shaders/blit.wgsl"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const BUDDHABROT_SRC: &str = concat!(
|
||||
include_str!("../src/shaders/common.wgsl"),
|
||||
include_str!("../src/shaders/buddhabrot.wgsl"),
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn buddhabrot_shader_is_valid() {
|
||||
validate("buddhabrot.wgsl", BUDDHABROT_SRC);
|
||||
}
|
||||
|
||||
/// Every per-kind accumulation pipeline buddhabrot.rs can build.
|
||||
#[test]
|
||||
fn buddhabrot_shader_specializations_compile() {
|
||||
let (module, info) = validate("buddhabrot.wgsl", BUDDHABROT_SRC);
|
||||
for kind in 0..kind_count() {
|
||||
specialize(
|
||||
"buddhabrot.wgsl",
|
||||
&module,
|
||||
&info,
|
||||
naga::ShaderStage::Compute,
|
||||
"cs_main",
|
||||
&[("KIND", kind as f64)],
|
||||
);
|
||||
}
|
||||
validate("blit.wgsl", include_str!("../src/shaders/blit.wgsl"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user