Compare commits

71 Commits
Author SHA1 Message Date
survandClaude Opus 5.5 da992c139a feat: add encoder tuning harness for 4K60 fractal videos
make_clips.sh renders lossless FFV1 4K60 test clips through the Makefile's
colour conversion; sweep.sh encodes them with a list of ffmpeg configs and
scores each with VMAF 4K (mean/p1/min), VMAF-NEG, CAMBI banding and PSNR-Y.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:59:14 +02:00
surv 49130f5ecf perf: remove shader checks 2026-09-28 17:22:08 +02:00
surv 493a743a16 feat: Add benchmark tool 2026-09-28 17:17:38 +02:00
surv 0d7aadb991 perf: switch from dashu to rug & malachite 2026-09-28 11:54:22 +02:00
surv ae7ca97c41 feat: add tools script to find interesting places 2026-09-28 10:52:37 +02:00
surv 3f5853a23c feat: add Makefile to run ffmpeg 2026-09-28 10:44:36 +02:00
surv 2e116ba47b feat: add AA in headless exports 2026-09-28 10:44:24 +02:00
surv a15e951f84 feat: add --shards CLI option to split an animation into multiple run 2026-09-28 09:03:20 +02:00
surv 27c5a1a603 feat: allow emmiting frames on stdout to pipe them in ffmpeg 2026-09-28 08:40:00 +02:00
surv d78594af7e feat: bind GUI real time rendering behind a feature 2026-09-27 14:43:23 +02:00
surv 4212839f0d fix: avoid hitting GPU watchdog 2026-09-27 14:05:09 +02:00
surv 48560de315 fix: Raise max iterations limit 2026-09-27 13:23:53 +02:00
surv 8750bb1590 fix: Smooth 2d shadow view of complex multibrot 2026-09-27 13:20:48 +02:00
surv f30921f7a5 fix: Smooth 3d view of complex multibrot 2026-09-27 13:17:35 +02:00
surv b6467fc18c fix: DE of multibrot fractal 2026-09-27 12:31:30 +02:00
surv 28ff2471b3 feat: multibrot exponent up to 20 2026-09-27 12:23:11 +02:00
surv 1bb3113581 fix: remove zoom limits 2026-09-27 12:17:08 +02:00
surv bfd3d18f3d feat: Add arbitrary precision numbers at deep zooms 2026-09-25 15:30:05 +02:00
surv fbf0ef64af fix: add c parameter again for lambda fractal in julia mode 2026-09-25 10:06:01 +02:00
surv 0d86e7b0e0 fix: periodicity checking stop flagging false positives 2026-09-25 10:02:14 +02:00
surv 583a535806 feat: allow bigger render distance in 3d view 2026-09-25 09:46:36 +02:00
surv ba385be18d fix: remove red band above small slope in 3d 2026-09-25 09:32:11 +02:00
surv 120e73fb10 fix: small zoom in 3d mode no longer create visual artefacts 2026-09-25 09:20:55 +02:00
surv 8889a09bf8 fix: remove weird gray circle around fractals 2026-09-25 09:13:59 +02:00
surv 94f9c488bd fix: complex multibrot rendering 2026-09-25 08:43:59 +02:00
surv a092be84b0 chore: remove warnings on web version 2026-09-25 08:12:02 +02:00
surv 73570fdab8 perf: Use multithreading for headless animation export 2026-09-25 08:09:34 +02:00
surv 9b0dec25e2 perf: reduce memory usage\nAllocate textures only when needed 2026-09-24 22:18:44 +02:00
surv 38cbb1f132 feat: add more animations and 3d export 2026-09-24 22:12:56 +02:00
surv 61d4766088 perf: disable AA when switching fractals 2026-09-24 21:56:39 +02:00
surv 51fa5ffa02 feat: add interpolation between fractals 2026-09-24 21:43:36 +02:00
surv 9cc5a80650 feat: add classic colors in 3d 2026-09-24 21:39:00 +02:00
surv 6a6796f528 fix: reduce memory usage on web 2026-09-24 21:06:34 +02:00
surv b4ca187ba2 fix: shadow and 3d rendering at deep zoom and on weird fractals 2026-09-24 20:38:37 +02:00
surv 2bdb2356c6 perf: improve 3d ray marching performance 2026-09-24 19:45:17 +02:00
surv 5206a22bdd fix: UX issues solved 2026-09-24 19:16:56 +02:00
surv 42dde04945 perf: add periodicity checking 2026-09-24 18:53:17 +02:00
surv a3fd152dff perf: improve overall performance 2026-09-24 18:23:19 +02:00
surv 7dd99cc1af feat: update gitignore 2026-09-24 16:48:06 +02:00
surv 63cee5d8b2 fix: correct 3d rendering 2026-09-23 18:34:00 +02:00
surv 694f63ab9d feat: improve CLI arguments 2026-09-22 11:39:11 +02:00
surv 7274f19b33 feat: improve UI/UX 2026-09-22 09:59:58 +02:00
surv 66669cd801 feat: add 3d support 2026-09-22 09:36:30 +02:00
surv c2de1bc4bb fix: disable auto-iterations when passing an iterations parameter in CLI through --view 2026-09-21 22:42:55 +02:00
surv 07319874bf fixup: add animation export 2026-09-20 15:23:15 +02:00
surv 449b1b289b feat: add animation export 2026-09-20 14:12:54 +02:00
surv 993185795f feat: improve CLI 2026-09-20 13:37:33 +02:00
surv 456d744ac6 feat: Better default values 2026-09-20 13:37:33 +02:00
surv 90d917ede0 refactor: cleanup code 2026-09-20 13:37:33 +02:00
surv 5a81247d76 refactor: add common shaders helpers + fix shadow export 2026-09-20 13:37:33 +02:00
surv d84e752e15 feat: improve colors when using distance estimate 2026-09-20 13:37:33 +02:00
surv eb041c22c5 feat: improve UI/UX 2026-09-20 13:37:33 +02:00
surv 06b52fe954 feat: Add complex multibrot fractal 2026-09-20 13:37:33 +02:00
surv c7d687c107 feat: minor visual fix for PNG export 2026-09-20 13:37:33 +02:00
survandClaude Sonnet 5 c2b38ea21b feat: add keyboard shortcuts for pan/zoom/iterations/AA
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:37:33 +02:00
survandClaude Sonnet 5 16a916d35a feat: add fractal info and help overlays
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:37:33 +02:00
survandClaude Sonnet 5 45654c0846 feat: add a headless mode, driven by clap CLI args
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:37:33 +02:00
survandClaude Sonnet 5 cc0609ffbb feat: add clap CLI arg parser, replacing MANDEL_* debug env vars
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 20:50:17 +02:00
surv 0f25c89a7b feat: Add a CLAUDE.md file 2026-09-19 20:50:17 +02:00
surv 908c13bfb0 feat: add shadow coloring 2026-09-19 20:50:17 +02:00
surv 7c347a5abf feat: add buddhabrot fractal 2026-09-16 22:04:21 +02:00
surv afcd3c74da feat: add lambda fractal 2026-09-16 20:32:48 +02:00
surv 0b90b72a00 feat: remove warning and increase performance 2026-09-16 20:14:39 +02:00
surv ad63a1da09 feat: add more presets 2026-09-16 15:32:13 +02:00
surv f373db91e6 chore: Refactor files and fix warnings 2026-09-16 15:31:40 +02:00
surv f039d38bfa feat: Add FPS counter 2026-09-15 21:22:39 +02:00
surv fbe7f4da13 perf: editing theme doesn't require a complete reredenring 2026-09-15 21:14:14 +02:00
surv a5b26ce738 feat: Add animations 2026-09-15 21:00:29 +02:00
surv 311b797724 feat: Add fullscreen button 2026-09-15 20:28:51 +02:00
surv 7f4f31a09f feat: Add more fractals 2026-09-15 19:28:49 +02:00
surv d77baf5e15 feat: add favicon 2026-09-15 18:29:38 +02:00
41 changed files with 10680 additions and 831 deletions
+3
View File
@@ -1,3 +1,6 @@
/target /target
Cargo.lock Cargo.lock
dist dist
frames*
out.mp4
__pycache__
+333
View File
@@ -0,0 +1,333 @@
# 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 `bignum::Big`:
`rug` natively, `malachite-float` on the web),
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 --features wasm # same, on the web build's malachite big-float backend
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"
cargo build --release --no-default-features # headless-only binary: no eframe/egui (the default `gui` feature)
tools/bench/bench.sh --rev HEAD # perf of uncommitted edits vs HEAD (hyperfine; see tools/bench/README.md)
```
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 (builds with --features wasm)
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`, `--antialias` (2×2),
`--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. `--export-path -` writes
to stdout instead (refused on a terminal): the PNG for a still, or for an
animation raw RGBA8 frames in order (`unpad_rgba`, no PNG encode) for
`ffmpeg -f rawvideo -pix_fmt rgba -s WxH -r FPS -i -`. A single writer
thread reorders the frames. Its buffer is bounded by the orbit workers not
starting a frame more than `window` past the last one written, not by
blocking the writer, which could deadlock. `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. `--shards N --shard K` (1-based) renders only
the K-th of N contiguous parts (`shard_range`), still timed against the whole
animation (global `t`, global `frame-NNNNN.png` numbers; stdout streams just
that part), so separately rendered clips join seamlessly. 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/bignum/` — `Big`, the arbitrary-precision binary float, with one
backend per library behind the same inherent API + operators: `rug`
(GMP/MPFR, default, fastest, can't target wasm32) and `malachite-float`
(pure Rust, the `wasm` feature, required for the web build; a
`compile_error!` enforces it). Cargo features are additive, so rug is a
non-wasm32 target dependency and the backend is picked by
`cfg(feature = "wasm")`. Both follow the precision rule: a result has the
larger operand precision, rounded to nearest; shifts are exact. Malachite's
zero has no precision, so its wrapper stores `prec` alongside. Any new
`Big` operation must be added to both backends (`cargo test` and
`cargo test --features wasm` run the same tests on each).
- `src/view.rs` — `ViewState`; center is arbitrary-precision `Big`
(re-exported as `view::Big`). 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 + `Big`) 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
`Big` 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. The
interactive reference buffers start at 2^17 points and grow on demand
(`FractalRenderer::ensure_ref_capacity`) up to `MAX_REF_POINTS` (2^24, the
128 MiB WebGPU default binding size). That is also the hard iteration
ceiling (`app.rs::MAX_ITERATIONS`), because the shader treats an exhausted
reference as escaped. The UI slider only goes to 100k when dragged; typed
values can go higher.
- `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 `Big`
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`).
+23 -6
View File
@@ -3,19 +3,34 @@ name = "mandelbrot"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[features]
default = ["gui"]
# Windowed egui app. Without it only `--headless` rendering is built.
gui = ["dep:eframe", "dep:egui"]
# Pure-Rust big floats for the web build (`rug` needs GMP/MPFR, which can't
# target wasm32). Required for wasm32, optional natively (to test that backend).
wasm = ["dep:malachite-float", "dep:malachite-base"]
[dependencies] [dependencies]
bytemuck = { version = "1.25.2", features = ["derive"] } 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"], optional = true }
eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"] } egui = { version = "0.36.2", optional = true }
egui = "0.36.2" ecolor = { version = "0.36.2", features = ["bytemuck"] }
futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] } wgpu = "30.0.1"
glam = "0.33.8"
log = "0.4.34" log = "0.4.34"
png = "0.18.1" png = "0.18.1"
malachite-float = { version = "0.12", optional = true }
malachite-base = { version = "0.12", optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
env_logger = "0.11.11" env_logger = "0.11.11"
clap = { version = "4.5.51", features = ["derive"] }
pollster = "1.0.1"
rug = { version = "1.30.0", default-features = false, features = ["float", "std"] }
[target.'cfg(target_arch = "wasm32")'.dependencies] [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_error_panic_hook = "0.1.7"
console_log = "1.1.0" console_log = "1.1.0"
js-sys = "0.3.105" js-sys = "0.3.105"
@@ -26,8 +41,10 @@ web-sys = { version = "0.3.105", features = ["Window", "Location", "Url", "UrlSe
# Release: optimize hard (fractal math is hot). # Release: optimize hard (fractal math is hot).
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
# codegen-units = 1
debug = true
# Dev: keep our own crate debuggable, but optimize dependencies (dashu, wgpu, # Dev: keep our own crate debuggable, but optimize dependencies (big floats, wgpu,
# egui) so the explorer is actually interactive during development. # egui) so the explorer is actually interactive during development.
[profile.dev] [profile.dev]
opt-level = 1 opt-level = 1
@@ -36,4 +53,4 @@ opt-level = 1
opt-level = 3 opt-level = 3
[dev-dependencies] [dev-dependencies]
naga = { version = "30", features = ["wgsl-in"] } naga = { version = "30", features = ["wgsl-in", "spv-out"] }
+35
View File
@@ -0,0 +1,35 @@
.PHONY: hevc-nvenc x265 av1
FFMPEG=ffmpeg
IN_FMT=rgba
RESOLUTION=1920x1080
FPS=60
OUT=out.mp4
FFMPEG_CMD=$(FFMPEG) -f rawvideo -pix_fmt $(IN_FMT) -s $(RESOLUTION) -framerate $(FPS) -i - -vf "scale=out_color_matrix=bt709:out_range=tv:flags=accurate_rnd+full_chroma_int+bitexact,format=p010le"
hevc-nvenc:
$(FFMPEG_CMD) \
-c:v hevc_nvenc -preset p7 -tune hq -rc vbr -cq 14 -b:v 0 -maxrate 200M -bufsize 400M \
-profile:v main10 -pix_fmt p010le \
-spatial-aq 1 -aq-strength 6 -temporal-aq 1 \
-rc-lookahead 32 -bf 4 -b_ref_mode middle -multipass fullres \
-colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv \
-tag:v hvc1 -movflags +faststart \
$(OUT)
x265:
$(FFMPEG_CMD) \
-c:v libx265 -crf 18 -preset medium -pix_fmt yuv420p10le \
-x265-params "aq-mode=3:no-sao=1" \
-colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv \
-tag:v hvc1 -movflags +faststart \
$(OUT)
av1:
$(FFMPEG_CMD) \
-c:v libsvtav1 -crf 24 -preset 6 -pix_fmt yuv420p10le \
-svtav1-params "film-grain=0:enable-overlays=0" \
-colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv \
$(OUT)
+9 -2
View File
@@ -3,7 +3,8 @@
A fast, interactive deep-zoom fractal explorer — Mandelbrot and Julia sets — A fast, interactive deep-zoom fractal explorer — Mandelbrot and Julia sets —
built with **Rust + wgpu + egui + WGSL**. It zooms far past the ~10¹³× limit of built with **Rust + wgpu + egui + WGSL**. It zooms far past the ~10¹³× limit of
plain `f64` using **perturbation theory**: one high-precision reference orbit is plain `f64` using **perturbation theory**: one high-precision reference orbit is
computed on the CPU (arbitrary precision via `dashu-float`), and every pixel is computed on the CPU (arbitrary precision via `rug` natively, `malachite-float`
on the web), and every pixel is
rendered on the GPU as a cheap `f32` delta from it, with **rebasing** to avoid rendered on the GPU as a cheap `f32` delta from it, with **rebasing** to avoid
glitches. Runs natively (Vulkan/Metal/DX12) and in the browser (WebGPU). glitches. Runs natively (Vulkan/Metal/DX12) and in the browser (WebGPU).
@@ -27,6 +28,9 @@ The `f32` GPU tier reaches roughly **10³⁰× magnification** with sharp detail
cargo run --release cargo run --release
``` ```
Native builds use `rug` (GMP/MPFR) for the reference orbit, which needs a C
toolchain and `m4` (on Windows, MSYS2).
## Build & run — web (WebGPU) ## Build & run — web (WebGPU)
Requires the `wasm32-unknown-unknown` target and `wasm-bindgen-cli` (matching the Requires the `wasm32-unknown-unknown` target and `wasm-bindgen-cli` (matching the
@@ -37,6 +41,7 @@ rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.128 # once cargo install wasm-bindgen-cli --version 0.2.128 # once
./build-web.sh # outputs ./dist (index.html, .js, .wasm) ./build-web.sh # outputs ./dist (index.html, .js, .wasm)
# (builds with --features wasm: pure-Rust big floats)
python3 -m http.server -d dist 8080 # serve over http python3 -m http.server -d dist 8080 # serve over http
``` ```
@@ -60,7 +65,8 @@ qualifies. Deploy by serving the `dist/` directory as static files.
## How it works ## How it works
- `src/view.rs` — view state. Center is arbitrary precision (`FBig`); the pixel - `src/view.rs` — view state. Center is arbitrary precision (`Big`, from
`src/bignum/`); the pixel
scale stays `f64` (even at 10³⁰× it is ~10⁻³³, within `f64` range). scale stays `f64` (even at 10³⁰× it is ~10⁻³³, within `f64` range).
- `src/fractal/reference.rs` — high-precision reference orbit `Z_{n+1}=Z_n²+C`. - `src/fractal/reference.rs` — high-precision reference orbit `Z_{n+1}=Z_n²+C`.
- `src/shaders/mandelbrot.wgsl` — per-pixel perturbation `e_{n+1}=2·Z_n·e_n+e_n²+δc` - `src/shaders/mandelbrot.wgsl` — per-pixel perturbation `e_{n+1}=2·Z_n·e_n+e_n²+δc`
@@ -84,6 +90,7 @@ reference computation to a Web Worker.
```sh ```sh
cargo test cargo test
cargo test --features wasm # same suite on the web build's big-float backend
``` ```
Covers the reference orbit (vs. a naive `f64` iteration, Mandelbrot and Julia) Covers the reference orbit (vs. a naive `f64` iteration, Mandelbrot and Julia)
+2 -1
View File
@@ -8,7 +8,7 @@ export PATH="$HOME/.cargo/bin:$PATH"
OUT="${1:-dist}" OUT="${1:-dist}"
echo "==> cargo build (wasm32, release)" echo "==> cargo build (wasm32, release)"
cargo build --release --target wasm32-unknown-unknown cargo build --release --target wasm32-unknown-unknown --features wasm
echo "==> wasm-bindgen -> $OUT" echo "==> wasm-bindgen -> $OUT"
mkdir -p "$OUT" mkdir -p "$OUT"
@@ -20,6 +20,7 @@ wasm-bindgen \
target/wasm32-unknown-unknown/release/mandelbrot.wasm target/wasm32-unknown-unknown/release/mandelbrot.wasm
cp index.html "$OUT/index.html" cp index.html "$OUT/index.html"
cp favicon.ico "$OUT/favicon.ico"
echo "==> done: $OUT/ (index.html, mandelbrot.js, mandelbrot_bg.wasm)" echo "==> done: $OUT/ (index.html, mandelbrot.js, mandelbrot_bg.wasm)"
echo " serve: python3 -m http.server -d $OUT 8080" echo " serve: python3 -m http.server -d $OUT 8080"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

+2186 -292
View File
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
//! Arbitrary-precision binary float [`Big`] (one coordinate of a deep-zoom
//! center, or of a reference orbit point), over one of two backends:
//!
//! - native (default): `rug` (GMP/MPFR), the fastest, but C code that can't
//! target `wasm32-unknown-unknown`;
//! - `wasm` feature: a pure-Rust library, required for the web build.
//!
//! Both expose the same inherent API plus the operators below, and follow
//! the same precision rule: an operation's result has the larger of its
//! operands' precisions (in bits), rounded to nearest. Shifts (`<<`/`>>` by
//! an `isize`) are exact multiplications by powers of two.
#[cfg(not(feature = "wasm"))]
mod rug_backend;
#[cfg(not(feature = "wasm"))]
pub use rug_backend::Big;
#[cfg(feature = "wasm")]
mod web_backend;
#[cfg(feature = "wasm")]
pub use web_backend::Big;
// Native `--features wasm` builds (to test the web backend) still link rug.
#[cfg(all(feature = "wasm", not(target_arch = "wasm32")))]
use rug as _;
#[cfg(all(target_arch = "wasm32", not(feature = "wasm")))]
compile_error!("the web build needs `--features wasm` (rug can't target wasm32)");
use core::ops::{Add, Mul, Neg, Shl, Shr, Sub};
/// Implements `$Trait` for every owned/borrowed combination of `Big`
/// operands, through the backend's by-reference `$imp`.
macro_rules! forward_binop {
($Trait:ident, $method:ident, $imp:ident) => {
impl $Trait<&Big> for &Big {
type Output = Big;
fn $method(self, rhs: &Big) -> Big {
self.$imp(rhs)
}
}
impl $Trait<Big> for &Big {
type Output = Big;
fn $method(self, rhs: Big) -> Big {
self.$imp(&rhs)
}
}
impl $Trait<&Big> for Big {
type Output = Big;
fn $method(self, rhs: &Big) -> Big {
(&self).$imp(rhs)
}
}
impl $Trait<Big> for Big {
type Output = Big;
fn $method(self, rhs: Big) -> Big {
(&self).$imp(&rhs)
}
}
};
}
forward_binop!(Add, add, add_ref);
forward_binop!(Sub, sub, sub_ref);
forward_binop!(Mul, mul, mul_ref);
impl Neg for Big {
type Output = Big;
fn neg(self) -> Big {
self.negated()
}
}
impl Neg for &Big {
type Output = Big;
fn neg(self) -> Big {
self.clone().negated()
}
}
impl Shl<isize> for Big {
type Output = Big;
fn shl(self, k: isize) -> Big {
self.mul_pow2(k)
}
}
impl Shl<isize> for &Big {
type Output = Big;
fn shl(self, k: isize) -> Big {
self.clone().mul_pow2(k)
}
}
impl Shr<isize> for Big {
type Output = Big;
fn shr(self, k: isize) -> Big {
self.mul_pow2(-k)
}
}
impl Shr<isize> for &Big {
type Output = Big;
fn shr(self, k: isize) -> Big {
self.clone().mul_pow2(-k)
}
}
/// Significant decimal digits of a value, as a backend's
/// `to_decimal_parts` returns them: the value is `±0.digits × 10^exp10`,
/// `digits` has no trailing zeros, and it is empty for zero.
pub struct DecimalParts {
pub negative: bool,
pub digits: String,
pub exp10: isize,
}
impl DecimalParts {
/// Build from a significand digit string (maybe with trailing zeros)
/// whose value is `0.digits × 10^exp10`.
fn new(negative: bool, digits: &str, exp10: isize) -> Self {
let digits = digits.trim_end_matches('0').to_string();
Self {
negative: negative && !digits.is_empty(),
digits,
exp10,
}
}
}
impl Big {
/// Decimal string with `sig_digits` significant digits, in plain
/// positional notation (`-0.000123`, `4500`), trailing zeros trimmed.
pub fn to_decimal_string(&self, sig_digits: usize) -> String {
let DecimalParts {
negative,
digits,
exp10,
} = self.to_decimal_parts(sig_digits.max(1));
if digits.is_empty() {
return "0".to_string();
}
let sign = if negative { "-" } else { "" };
let len = digits.len() as isize;
if exp10 <= 0 {
let zeros = "0".repeat((-exp10) as usize);
format!("{sign}0.{zeros}{digits}")
} else if exp10 >= len {
let zeros = "0".repeat((exp10 - len) as usize);
format!("{sign}{digits}{zeros}")
} else {
let (int, frac) = digits.split_at(exp10 as usize);
format!("{sign}{int}.{frac}")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn big(x: f64) -> Big {
Big::from_f64(x, 200)
}
#[test]
fn decimal_string_is_positional() {
assert_eq!(
big(-0.7515).to_decimal_string(20),
"-0.75149999999999994582"
);
assert_eq!(big(-0.7515).to_decimal_string(5), "-0.7515");
assert_eq!(big(0.0).to_decimal_string(5), "0");
assert_eq!(big(1.0).to_decimal_string(5), "1");
assert_eq!(big(123.456).to_decimal_string(5), "123.46");
assert_eq!(big(1e20).to_decimal_string(5), "100000000000000000000");
assert_eq!(big(0.1).to_decimal_string(5), "0.1");
let tiny = big(1.0) >> 200;
let s = tiny.to_decimal_string(5);
assert_eq!(s, format!("0.{}6223", "0".repeat(60)));
}
#[test]
fn decimal_round_trip() {
let s = "-0.743643887037158704752191506114774";
let x = Big::from_decimal_str(s, 200).unwrap();
assert_eq!(x.to_decimal_string(33), s);
assert_eq!(
Big::from_decimal_str("1.5e-20", 64).unwrap().to_f64(),
1.5e-20
);
assert!(Big::from_decimal_str("abc", 64).is_none());
}
#[test]
fn precision_is_max_of_operands() {
let a = Big::from_f64(1.0, 100);
let b = Big::from_f64(3.0, 200);
assert_eq!((&a + &b).precision(), 200);
assert_eq!((&a * &b).precision(), 200);
assert_eq!((&b - &a).precision(), 200);
assert_eq!(a.clone().with_precision(300).precision(), 300);
}
#[test]
fn exact_shifts_and_log2() {
let x = Big::from_f64(0.75, 64) >> 5000;
assert_eq!(x.log2_floor(), Some(-5001));
assert_eq!((x << 5000).to_f64(), 0.75);
assert_eq!(Big::zero(64).log2_floor(), None);
assert!(Big::from_f64(-1.0, 64).is_negative());
assert!(!Big::zero(64).is_negative());
}
#[test]
fn transcendentals() {
let x = Big::from_f64(0.5, 128);
assert!((x.ln().to_f64() - 0.5f64.ln()).abs() < 1e-15);
assert!((x.exp().to_f64() - 0.5f64.exp()).abs() < 1e-15);
let (s, c) = x.sin_cos();
assert!((s.to_f64() - 0.5f64.sin()).abs() < 1e-15);
assert!((c.to_f64() - 0.5f64.cos()).abs() < 1e-15);
let y = Big::from_f64(-0.3, 128);
assert!((y.atan2(&x).to_f64() - (-0.3f64).atan2(0.5)).abs() < 1e-15);
}
}
+128
View File
@@ -0,0 +1,128 @@
//! [`Big`] over `rug::Float` (MPFR), for native builds.
use core::cmp::Ordering;
use rug::{Assign, Float};
use super::DecimalParts;
/// Arbitrary-precision binary float. See the module docs.
#[derive(Clone, Debug, PartialEq)]
pub struct Big(Float);
/// MPFR precision for `bits`, clamped to what it accepts.
fn prec(bits: usize) -> u32 {
(bits as u32).clamp(rug::float::prec_min(), rug::float::prec_max())
}
impl Big {
/// `x` at `bits` of precision (exact when `bits >= 53`). Non-finite `x`
/// reads as 0.
pub fn from_f64(x: f64, bits: usize) -> Self {
let x = if x.is_finite() { x } else { 0.0 };
Self(Float::with_val(prec(bits), x))
}
pub fn zero(bits: usize) -> Self {
Self(Float::new(prec(bits)))
}
/// Parse a decimal (`-0.75`, `1.5e-20`, any number of digits) at `bits`
/// (at least 53) of precision.
pub fn from_decimal_str(s: &str, bits: usize) -> Option<Self> {
let parsed = Float::parse(s.trim()).ok()?;
let x = Float::with_val(prec(bits.max(53)), parsed);
x.is_finite().then_some(Self(x))
}
pub fn precision(&self) -> usize {
self.0.prec() as usize
}
/// Change the precision, rounding to nearest if it shrinks.
pub fn with_precision(mut self, bits: usize) -> Self {
self.0.set_prec(prec(bits));
self
}
pub fn to_f64(&self) -> f64 {
self.0.to_f64()
}
pub fn is_zero(&self) -> bool {
self.0.is_zero()
}
pub fn is_negative(&self) -> bool {
self.0.cmp0() == Some(Ordering::Less)
}
pub fn abs(self) -> Self {
Self(self.0.abs())
}
pub fn sqr(&self) -> Self {
Self(Float::with_val(self.0.prec(), self.0.square_ref()))
}
/// `floor(log2|x|)`, or `None` for zero. Exact, at any exponent.
pub fn log2_floor(&self) -> Option<isize> {
// MPFR normalizes the significand to [0.5, 1).
self.0.get_exp().map(|e| e as isize - 1)
}
pub fn ln(&self) -> Self {
Self(Float::with_val(self.0.prec(), self.0.ln_ref()))
}
pub fn exp(&self) -> Self {
Self(Float::with_val(self.0.prec(), self.0.exp_ref()))
}
/// `atan2(self, x)`: the angle of `(x, self)`.
pub fn atan2(&self, x: &Big) -> Self {
let p = self.0.prec().max(x.0.prec());
Self(Float::with_val(p, self.0.atan2_ref(&x.0)))
}
pub fn sin_cos(&self) -> (Self, Self) {
let p = self.0.prec();
let (mut s, mut c) = (Float::new(p), Float::new(p));
(&mut s, &mut c).assign(self.0.sin_cos_ref());
(Self(s), Self(c))
}
/// `sig` significant decimal digits (rounded to nearest).
pub fn to_decimal_parts(&self, sig: usize) -> DecimalParts {
if self.0.is_zero() {
return DecimalParts::new(false, "", 0);
}
let (negative, digits, exp) = self.0.to_sign_string_exp(10, Some(sig));
DecimalParts::new(negative, &digits, exp.unwrap_or(0) as isize)
}
pub(super) fn add_ref(&self, rhs: &Big) -> Big {
let p = self.0.prec().max(rhs.0.prec());
Big(Float::with_val(p, &self.0 + &rhs.0))
}
pub(super) fn sub_ref(&self, rhs: &Big) -> Big {
let p = self.0.prec().max(rhs.0.prec());
Big(Float::with_val(p, &self.0 - &rhs.0))
}
pub(super) fn mul_ref(&self, rhs: &Big) -> Big {
let p = self.0.prec().max(rhs.0.prec());
Big(Float::with_val(p, &self.0 * &rhs.0))
}
pub(super) fn negated(self) -> Big {
Big(-self.0)
}
/// `self · 2^k`, exact. `|k|` stays far below `i32::MAX` (precision and
/// zoom depth are capped around 2^20 bits).
pub(super) fn mul_pow2(self, k: isize) -> Big {
Big(self.0 << k as i32)
}
}
+173
View File
@@ -0,0 +1,173 @@
//! [`Big`] over `malachite-float`, pure Rust, for the web build.
use malachite_base::num::basic::traits::Zero;
use malachite_base::num::conversion::string::options::ToSciOptions;
use malachite_base::num::conversion::traits::{RoundingFrom, ToSci};
use malachite_base::rounding_modes::RoundingMode::Nearest;
use malachite_float::Float;
use super::DecimalParts;
/// Arbitrary-precision binary float. See the module docs.
///
/// The precision is stored next to the value: malachite's zero has none,
/// but "zero at `p` bits" must still lift a sum with it to `p` bits.
#[derive(Clone, Debug)]
pub struct Big {
f: Float,
prec: u64,
}
impl PartialEq for Big {
fn eq(&self, other: &Self) -> bool {
self.f == other.f
}
}
fn prec(bits: usize) -> u64 {
(bits as u64).max(1)
}
impl Big {
fn new(f: Float, prec: u64) -> Self {
// Only finite values reach here; anything else (overflow) is a bug.
debug_assert!(f.is_finite(), "non-finite Big: {f}");
Self { f, prec }
}
/// `x` at `bits` of precision (exact when `bits >= 53`). Non-finite `x`
/// reads as 0.
pub fn from_f64(x: f64, bits: usize) -> Self {
let p = prec(bits);
if !x.is_finite() {
return Self::zero(bits);
}
Self::new(Float::from_primitive_float_prec(x, p).0, p)
}
pub fn zero(bits: usize) -> Self {
Self::new(Float::ZERO, prec(bits))
}
/// Parse a decimal (`-0.75`, `1.5e-20`, any number of digits) at `bits`
/// (at least 53) of precision.
pub fn from_decimal_str(s: &str, bits: usize) -> Option<Self> {
let p = prec(bits.max(53));
let (f, _) = Float::from_sci_string_prec(s.trim(), p)?;
f.is_finite().then(|| Self::new(f, p))
}
pub fn precision(&self) -> usize {
self.prec as usize
}
/// Change the precision, rounding to nearest if it shrinks.
pub fn with_precision(mut self, bits: usize) -> Self {
let p = prec(bits);
if self.f.get_prec().is_some() {
self.f.set_prec(p);
}
self.prec = p;
self
}
pub fn to_f64(&self) -> f64 {
f64::rounding_from(&self.f, Nearest).0
}
pub fn is_zero(&self) -> bool {
self.f.is_zero()
}
pub fn is_negative(&self) -> bool {
self.f.is_sign_negative() && !self.f.is_zero()
}
pub fn abs(self) -> Self {
if self.f.is_sign_negative() {
self.negated()
} else {
self
}
}
pub fn sqr(&self) -> Self {
Self::new(self.f.square_prec_ref(self.prec).0, self.prec)
}
/// `floor(log2|x|)`, or `None` for zero. Exact, at any exponent.
pub fn log2_floor(&self) -> Option<isize> {
// The significand is normalized to [0.5, 1).
self.f.get_exponent().map(|e| e as isize - 1)
}
pub fn ln(&self) -> Self {
Self::new(self.f.ln_prec_ref(self.prec).0, self.prec)
}
pub fn exp(&self) -> Self {
Self::new(self.f.exp_prec_ref(self.prec).0, self.prec)
}
/// `atan2(self, x)`: the angle of `(x, self)`.
pub fn atan2(&self, x: &Big) -> Self {
let p = self.prec.max(x.prec);
Self::new(self.f.atan2_prec_ref_ref(&x.f, p).0, p)
}
pub fn sin_cos(&self) -> (Self, Self) {
let (s, c, _, _) = self.f.sin_cos_prec_ref(self.prec);
(Self::new(s, self.prec), Self::new(c, self.prec))
}
/// `sig` significant decimal digits (rounded to nearest).
pub fn to_decimal_parts(&self, sig: usize) -> DecimalParts {
if self.f.is_zero() {
return DecimalParts::new(false, "", 0);
}
let mut options = ToSciOptions::default();
options.set_precision(sig as u64);
let s = self.f.to_sci_with_options(options).to_string();
// `[-]int[.frac][e±N]`, positional or scientific depending on size.
let (negative, s) = match s.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, s.as_str()),
};
let (mantissa, exp) = match s.split_once(['e', 'E']) {
Some((m, e)) => (m, e.trim_start_matches('+').parse::<isize>().unwrap_or(0)),
None => (s, 0),
};
let (int, frac) = mantissa.split_once('.').unwrap_or((mantissa, ""));
let all = format!("{int}{frac}");
let digits = all.trim_start_matches('0');
let leading_zeros = (all.len() - digits.len()) as isize;
// 0.digits · 10^exp10 = int.frac · 10^exp.
let exp10 = int.len() as isize - leading_zeros + exp;
DecimalParts::new(negative, digits, exp10)
}
pub(super) fn add_ref(&self, rhs: &Big) -> Big {
let p = self.prec.max(rhs.prec);
Big::new(self.f.add_prec_ref_ref(&rhs.f, p).0, p)
}
pub(super) fn sub_ref(&self, rhs: &Big) -> Big {
let p = self.prec.max(rhs.prec);
Big::new(self.f.sub_prec_ref_ref(&rhs.f, p).0, p)
}
pub(super) fn mul_ref(&self, rhs: &Big) -> Big {
let p = self.prec.max(rhs.prec);
Big::new(self.f.mul_prec_ref_ref(&rhs.f, p).0, p)
}
pub(super) fn negated(self) -> Big {
Big::new(-self.f, self.prec)
}
/// `self · 2^k`, exact (malachite's exponent range is ±2^30, far past
/// what zoom depth needs).
pub(super) fn mul_pow2(self, k: isize) -> Big {
Big::new(self.f << k as i64, self.prec)
}
}
+91
View File
@@ -0,0 +1,91 @@
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()
}
}
+243
View File
@@ -0,0 +1,243 @@
// 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,
/// Enable 2×2 antialiasing (supersampling; ~4× slower).
#[arg(long)]
pub antialias: 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>/). "-" writes to stdout: the PNG for a single
/// image, or raw RGBA8 frames in order for an animation, to pipe into
/// `ffmpeg -f rawvideo -pix_fmt rgba -s WxH -r FPS -i - ...`.
#[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,
/// Split the animation into N equal parts (use with --shard) to render
/// it across several runs. Each part keeps the whole animation's timing
/// and easing, so the clips join seamlessly.
#[arg(long, value_name = "N")]
pub shards: Option<u32>,
/// Which part of --shards to render, 1-based. PNG frames keep their
/// global numbering, so all shards can share one --export-path directory.
#[arg(long, value_name = "K")]
pub shard: Option<u32>,
/// 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,
}
}
}
+419
View File
@@ -0,0 +1,419 @@
//! 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;
#[cfg(feature = "gui")]
use eframe::egui_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 = unsafe {
device.create_shader_module_trusted(
wgpu::ShaderModuleDescriptor {
label: Some("buddhabrot"),
source: wgpu::ShaderSource::Wgsl(
concat!(
include_str!("../shaders/common.wgsl"),
include_str!("../shaders/buddhabrot.wgsl"),
)
.into(),
),
},
wgpu::ShaderRuntimeChecks::unchecked(),
)
};
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],
}
#[cfg(feature = "gui")]
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);
}
}
}
+165
View File
@@ -0,0 +1,165 @@
//! `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),
}
}
}
+12 -5
View File
@@ -1,13 +1,20 @@
//! GPU fractal rendering: wgpu pipeline, uniforms, reference orbit, and the //! GPU fractal rendering: wgpu pipeline, uniforms, reference orbit, and the
//! egui paint callback. //! egui paint callback.
pub mod buddhabrot;
pub mod kind;
pub mod reference; pub mod reference;
pub mod renderer; pub mod renderer;
pub mod share; pub mod share;
pub use reference::{FractalKind, compute_reference, compute_set_reference}; pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms};
pub use renderer::{ pub use kind::FractalKind;
ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms, pub use reference::{RefOrbit, compute_reference, compute_set_reference};
encode_png_with_progress, #[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, unpad_rgba};
pub use share::ShareState; pub use share::ShareState;
+856 -95
View File
File diff suppressed because it is too large Load Diff
+1556 -116
View File
File diff suppressed because it is too large Load Diff
+54 -20
View File
@@ -2,13 +2,14 @@
//! iterations, Julia constant, coloring) as a compact URL fragment so deep-zoom //! iterations, Julia constant, coloring) as a compact URL fragment so deep-zoom
//! locations can be shared or bookmarked. //! locations can be shared or bookmarked.
//! //!
//! Format: `m=m&f=<str>&re=<dec>&im=<dec>&hh=<f64>&it=<u32>&cs=<f32>&co=<f32>` with //! Format: `m=m&f=<str>&re=<dec>&im=<dec>&hh=<sci>&it=<u32>&cs=<f32>&co=<f32>` with
//! `m=j&jr=<f64>&ji=<f64>` added for Julia. `re`/`im` are full-precision decimal //! `m=j&jr=<f64>&ji=<f64>` added for Julia. `re`/`im` are full-precision decimal
//! strings. //! strings; `hh` is a `Scale` in scientific notation (any exponent).
use std::collections::HashMap; use std::collections::HashMap;
use crate::fractal::FractalKind; use crate::fractal::FractalKind;
use crate::view::Scale;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct ShareState { pub struct ShareState {
@@ -18,34 +19,43 @@ pub struct ShareState {
pub power: u32, pub power: u32,
pub center_re: String, pub center_re: String,
pub center_im: String, pub center_im: String,
pub half_height: f64, pub half_height: Scale,
pub iterations: u32, pub iterations: u32,
pub julia_c: (f64, f64), 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_scale: f32,
pub color_offset: f32, pub color_offset: f32,
/// Palette index (`palette_id` in the shader). /// Palette index (`palette_id` in the shader).
pub palette: u32, pub palette: u32,
/// Shadow palette index (`shadow_palette_id` in the shader).
pub shadow_palette: u32,
} }
impl ShareState { impl ShareState {
pub fn encode(&self) -> String { pub fn encode(&self) -> String {
let mut s = String::new(); let mut s = String::new();
s.push_str(if self.julia { "m=j" } else { "m=m" }); s.push_str(if self.julia { "m=j" } else { "m=m" });
s.push_str(&format!("&f={}", match self.kind { s.push_str(&format!("&f={}", self.kind.share_tag()));
FractalKind::Mandelbrot => "mandel",
FractalKind::BurningShip => "burning",
FractalKind::Multibrot => "multi",
FractalKind::Tricorn => "tricorn",
}));
s.push_str(&format!("&pw={}", self.power)); s.push_str(&format!("&pw={}", self.power));
s.push_str(&format!( s.push_str(&format!(
"&re={}&im={}&hh={}&it={}", "&re={}&im={}&hh={}&it={}",
self.center_re, self.center_im, self.half_height, self.iterations 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!("&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!( s.push_str(&format!(
"&cs={}&co={}&pal={}", "&cpr={}&cpi={}",
self.color_scale, self.color_offset, self.palette 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
)); ));
s s
} }
@@ -63,13 +73,7 @@ impl ShareState {
julia: map.get("m").map(|m| *m == "j").unwrap_or(false), julia: map.get("m").map(|m| *m == "j").unwrap_or(false),
kind: map kind: map
.get("f") .get("f")
.map(|f| match *f { .and_then(|f| FractalKind::from_share_tag(f))
"mandel" => FractalKind::Mandelbrot,
"multi" => FractalKind::Multibrot,
"burning" => FractalKind::BurningShip,
"tricorn" => FractalKind::Tricorn,
_ => FractalKind::Mandelbrot
})
.unwrap_or(FractalKind::Mandelbrot), .unwrap_or(FractalKind::Mandelbrot),
power: map.get("pw").and_then(|s| s.parse().ok()).unwrap_or(2), power: map.get("pw").and_then(|s| s.parse().ok()).unwrap_or(2),
center_re: (*map.get("re")?).to_string(), center_re: (*map.get("re")?).to_string(),
@@ -80,9 +84,22 @@ impl ShareState {
map.get("jr").and_then(|s| s.parse().ok()).unwrap_or(-0.8), 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), 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_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), 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), 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),
}) })
} }
} }
@@ -95,16 +112,20 @@ mod tests {
fn round_trip() { fn round_trip() {
let s = ShareState { let s = ShareState {
julia: true, julia: true,
kind: FractalKind::Multibrot, kind: FractalKind::Phoenix,
power: 5, power: 5,
center_re: "-0.743643887037158704752191506114774".into(), center_re: "-0.743643887037158704752191506114774".into(),
center_im: "0.131825904205311970493132056385139".into(), center_im: "0.131825904205311970493132056385139".into(),
half_height: 1.5e-20, half_height: Scale::from_f64(1.5e-20),
iterations: 4000, iterations: 4000,
julia_c: (-0.123, 0.745), 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_scale: 0.02,
color_offset: 0.25, color_offset: 0.25,
palette: 3, palette: 3,
shadow_palette: 1,
}; };
let d = ShareState::decode(&s.encode()).unwrap(); let d = ShareState::decode(&s.encode()).unwrap();
assert_eq!(d.julia, s.julia); assert_eq!(d.julia, s.julia);
@@ -115,7 +136,10 @@ mod tests {
assert_eq!(d.half_height, s.half_height); assert_eq!(d.half_height, s.half_height);
assert_eq!(d.iterations, s.iterations); assert_eq!(d.iterations, s.iterations);
assert_eq!(d.julia_c, s.julia_c); 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.palette, s.palette);
assert_eq!(d.shadow_palette, s.shadow_palette);
} }
#[test] #[test]
@@ -123,5 +147,15 @@ mod tests {
let d = ShareState::decode("#m=m&re=0.0&im=0.0&hh=1.25&it=256").unwrap(); let d = ShareState::decode("#m=m&re=0.0&im=0.0&hh=1.25&it=256").unwrap();
assert!(!d.julia); assert!(!d.julia);
assert_eq!(d.iterations, 256); 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);
} }
} }
+587
View File
@@ -0,0 +1,587 @@
// 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. `--export-path -` writes to stdout instead: the PNG for a
// single image, or a raw RGBA8 video stream for an animation (for piping
// into ffmpeg).
use std::collections::{BTreeMap, HashMap};
use std::io::{IsTerminal, Write};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Mutex, mpsc};
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, unpad_rgba,
};
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;
/// `--export-path` value meaning "write to stdout".
const STDOUT_PATH: &str = "-";
/// Refuse to dump binary image data onto a terminal.
fn check_stdout_piped() -> Result<(), String> {
if std::io::stdout().is_terminal() {
return Err(
"--export-path - writes binary data to stdout; pipe it somewhere \
(e.g. `| ffmpeg ...`)"
.into(),
);
}
Ok(())
}
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();
if export_path.as_deref() == Some(STDOUT_PATH) {
check_stdout_piped()?;
}
if targets.shard.is_some() && !targets.any() {
return Err("--shard/--shards only apply to animations (give a --to-* target)".into());
}
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!();
if export_path == STDOUT_PATH {
let mut out = std::io::stdout().lock();
out.write_all(&png)
.and_then(|()| out.flush())
.map_err(|e| format!("writing to stdout failed: {e}"))?;
eprintln!("wrote PNG to stdout ({width}×{height})");
} else {
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,
/// `--shard K --shards N`: render only the K-th (1-based) of N parts.
shard: Option<(u32, u32)>,
}
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)?;
let shard = match (cli.shard, cli.shards) {
(None, None) | (None, Some(1)) => None,
(Some(k), Some(n)) if (1..=n).contains(&k) => Some((k, n)),
(Some(k), Some(n)) => {
return Err(format!(
"--shard {k} is out of range 1..={n} (--shards {n})"
));
}
_ => return Err("--shard and --shards must be given together".into()),
};
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,
shard,
})
}
/// 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. With `--export-path -`, frames
/// are streamed in order to stdout as raw RGBA8 (for ffmpeg's `rawvideo`
/// demuxer) instead of being written as PNGs.
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());
}
// Frames are still timed against the whole animation (`apply_frame` takes
// the global index); a shard only picks which of them this run renders.
let range = match targets.shard {
Some((_, n)) if n > frames => {
return Err(format!("--shards {n} is more than the {frames} frames"));
}
Some((k, n)) => shard_range(frames, k, n),
None => 0..frames,
};
let (first, count) = (range.start, range.len());
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 stream = export_path.as_deref() == Some(STDOUT_PATH);
let out_dir = export_path.unwrap_or_else(|| format!("frames-{}", unix_timestamp()));
if stream {
eprintln!(
"streaming raw video to stdout; ffmpeg input: \
-f rawvideo -pix_fmt rgba -s {width}x{height} -r {fps} -i -"
);
} else {
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.
// `jobs[j]` is global frame `first + j`; the pipeline below works in
// local indices `j`, so the stdout writer's ordering is per shard.
let jobs: Vec<RefJob> = range
.clone()
.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.
//
// When streaming, the last stage instead unpads frames to raw RGBA and a
// single writer thread puts them back in order before writing to stdout.
// Its reorder buffer can't apply backpressure (blocking it while waiting
// for frame `k` could stall the pipeline before `k` gets through), so the
// orbit workers bound it instead: they don't start a frame more than
// `window` ahead of the last one written.
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let window = threads * 4;
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);
};
if let Some((k, n)) = targets.shard {
eprintln!(
"shard {k}/{n}: frames {}–{} of {frames}",
range.start + 1,
range.end
);
}
eprintln!("rendering {count} 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);
let (raw_tx, raw_rx) = mpsc::sync_channel::<(usize, Vec<u8>)>(threads * 2);
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, saved, failed) = (&jobs, &next_job, &saved, &failed);
scope.spawn(move || {
loop {
let i = next_job.fetch_add(1, Ordering::Relaxed);
if i >= jobs.len() || failed.load(Ordering::Relaxed) {
break;
}
while stream
&& i >= saved.load(Ordering::Relaxed) + window
&& !failed.load(Ordering::Relaxed)
{
std::thread::sleep(std::time::Duration::from_millis(2));
}
if ref_tx.send((i, jobs[i].compute())).is_err() {
break;
}
}
});
}
drop(ref_tx);
if stream {
let (saved, fail) = (&saved, &fail);
scope.spawn(move || {
let mut out = std::io::stdout().lock();
let mut pending = BTreeMap::new();
let mut next = 0;
for (i, raw) in raw_rx.iter() {
pending.insert(i, raw);
while let Some(raw) = pending.remove(&next) {
if let Err(e) = out.write_all(&raw) {
fail(format!("writing to stdout failed: {e}"));
return;
}
next += 1;
saved.store(next, Ordering::Relaxed);
eprint!("\r[{next:>4}/{count}] streamed");
}
}
if let Err(e) = out.flush() {
fail(format!("writing to stdout failed: {e}"));
}
});
} else {
drop(raw_rx);
}
for _ in 0..threads {
let raw_tx = raw_tx.clone();
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;
};
// After a failure, keep draining (without work) until the
// GPU stage hangs up, so it can't block on a full channel.
if failed.load(Ordering::Relaxed) {
continue;
}
if stream {
let raw = unpad_rgba(&padded, width, height, bpr, swap_rb);
// Only fails once the writer has failed and hung up.
let _ = raw_tx.send((i, raw));
continue;
}
let png =
encode_png(&padded, width, height, bpr, swap_rb, png::Compression::Fast);
let path = format!("{out_dir}/frame-{:05}.png", first as usize + i + 1);
if let Err(e) = std::fs::write(&path, &png) {
fail(format!("save failed: {e}"));
continue;
}
let done = saved.fetch_add(1, Ordering::Relaxed) + 1;
eprint!("\r[{done:>4}/{count}] 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, first + 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(raw_tx);
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 != count {
return Err(format!("only {saved} of {count} frames were rendered"));
}
if stream {
eprintln!("streamed {count} frames ({width}×{height})");
return Ok(());
}
println!("saved {count} frames to {out_dir}/ ({width}×{height})");
if targets.shard.is_some() {
println!(
"tip: once every shard is rendered into {out_dir}/, they form the full sequence; \
this shard alone: ffmpeg -framerate {fps} -start_number {} -i {out_dir}/frame-%05d.png \
-frames:v {count} -c:v libx264 -pix_fmt yuv420p out.mp4",
range.start + 1
);
} else {
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),
)))
}
/// Global frame indices of shard `shard` (1-based) out of `shards` equal
/// parts of a `frames`-frame animation. Consecutive shards tile `0..frames`
/// with no gap or overlap.
fn shard_range(frames: u32, shard: u32, shards: u32) -> std::ops::Range<u32> {
let bound = |k: u32| (k as u64 * frames as u64 / shards as u64) as u32;
bound(shard - 1)..bound(shard)
}
/// 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}"))
}
#[cfg(test)]
mod tests {
use super::shard_range;
#[test]
fn shards_tile_all_frames() {
for frames in [2, 3, 10, 97, 1000, u32::MAX] {
for shards in [1, 2, 3, 7, 10] {
if shards > frames {
continue;
}
let mut next = 0;
for k in 1..=shards {
let r = shard_range(frames, k, shards);
assert_eq!(
r.start, next,
"gap/overlap at shard {k}/{shards} of {frames}"
);
assert!(!r.is_empty(), "empty shard {k}/{shards} of {frames}");
next = r.end;
}
assert_eq!(next, frames);
}
}
}
}
+97
View File
@@ -0,0 +1,97 @@
use std::f32::consts::PI;
use bytemuck::{Pod, Zeroable};
use ecolor::Color32;
#[cfg(feature = "gui")]
use egui::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 {
#[cfg(feature = "gui")]
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)
}
+48 -7
View File
@@ -1,3 +1,9 @@
// Without these, rust fails to infer Send/Sync trait impls
// Probably caused by the new trait solver
#![recursion_limit = "256"]
// Without the `gui` feature, UI-only state and helpers go unused.
#![cfg_attr(not(feature = "gui"), allow(dead_code, unused_imports))]
// Fractal Explorer — Rust + wgpu + egui + WGSL deep-zoom Mandelbrot. // Fractal Explorer — Rust + wgpu + egui + WGSL deep-zoom Mandelbrot.
// //
// A single binary drives both native and web (WASM/WebGPU) builds; the two // A single binary drives both native and web (WASM/WebGPU) builds; the two
@@ -5,14 +11,30 @@
// and calls the wasm `main`, which boots eframe onto the page's <canvas>. // and calls the wasm `main`, which boots eframe onto the page's <canvas>.
mod app; mod app;
mod bignum;
mod camera;
mod fractal; mod fractal;
mod lights;
mod view; mod view;
#[cfg(not(target_arch = "wasm32"))]
mod cli;
#[cfg(not(target_arch = "wasm32"))]
mod headless;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
mod worker; mod worker;
#[cfg(all(target_arch = "wasm32", not(feature = "gui")))]
compile_error!("the web build needs the `gui` feature");
#[cfg(feature = "gui")]
use app::FractalApp; use app::FractalApp;
#[cfg(all(feature = "gui", not(target_arch = "wasm32")))]
type MainResult = eframe::Result;
#[cfg(all(not(feature = "gui"), not(target_arch = "wasm32")))]
type MainResult = Result<(), String>;
/// wgpu configuration for eframe. The fractal fragment shader reads the /// wgpu configuration for eframe. The fractal fragment shader reads the
/// reference orbit from a **storage buffer**, so the device must allow storage /// reference orbit from a **storage buffer**, so the device must allow storage
/// buffers in the fragment stage. eframe's default requests WebGL2-downlevel /// buffers in the fragment stage. eframe's default requests WebGL2-downlevel
@@ -20,18 +42,18 @@ use app::FractalApp;
/// * request the adapter's real limits (which include storage buffers), and /// * request the adapter's real limits (which include storage buffers), and
/// * force the WebGPU backend on the web (WebGL2 can't do storage buffers at /// * force the WebGPU backend on the web (WebGL2 can't do storage buffers at
/// all) — failing cleanly on browsers without WebGPU, per the design. /// all) — failing cleanly on browsers without WebGPU, per the design.
#[cfg(feature = "gui")]
fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration { fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
use eframe::egui_wgpu::{WgpuSetup, wgpu}; use eframe::egui_wgpu::{WgpuSetup, wgpu};
let mut options = eframe::egui_wgpu::WgpuConfiguration::default(); let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
if let WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup { if let WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
setup.device_descriptor = std::sync::Arc::new(|adapter: &wgpu::Adapter| { setup.device_descriptor =
wgpu::DeviceDescriptor { std::sync::Arc::new(|adapter: &wgpu::Adapter| wgpu::DeviceDescriptor {
label: Some("fractal wgpu device"), label: Some("fractal wgpu device"),
required_features: wgpu::Features::empty(), required_features: wgpu::Features::empty(),
required_limits: adapter.limits(), required_limits: adapter.limits(),
..Default::default() ..Default::default()
}
}); });
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
@@ -42,12 +64,32 @@ fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result { fn main() -> MainResult {
use clap::Parser as _;
env_logger::builder() env_logger::builder()
.filter_level(log::LevelFilter::Info) .filter_level(log::LevelFilter::Info)
.parse_default_env() .parse_default_env()
.init(); .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);
}
};
}
#[cfg(not(feature = "gui"))]
{
eprintln!("error: built without the \"gui\" feature; only --headless is supported");
std::process::exit(1);
}
#[cfg(feature = "gui")]
let native_options = eframe::NativeOptions { let native_options = eframe::NativeOptions {
renderer: eframe::Renderer::Wgpu, renderer: eframe::Renderer::Wgpu,
wgpu_options: wgpu_options(), wgpu_options: wgpu_options(),
@@ -58,6 +100,7 @@ fn main() -> eframe::Result {
..Default::default() ..Default::default()
}; };
#[cfg(feature = "gui")]
eframe::run_native( eframe::run_native(
"Fractal Explorer", "Fractal Explorer",
native_options, native_options,
@@ -101,9 +144,7 @@ fn main() {
match result { match result {
Ok(_) => loading.remove(), Ok(_) => loading.remove(),
Err(e) => { Err(e) => {
loading.set_inner_html( loading.set_inner_html(&format!("<p>The app has crashed.</br>{e:?}</p>"));
"<p>The app has crashed. See the developer console for details.</p>",
);
log::error!("failed to start eframe: {e:?}"); log::error!("failed to start eframe: {e:?}");
} }
} }
+1 -6
View File
@@ -13,12 +13,7 @@ struct VsOut {
@vertex @vertex
fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut { fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut {
var verts = array<vec2<f32>, 3>( let p = fullscreen_triangle_pos(idx);
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; var out: VsOut;
out.pos = vec4<f32>(p, 0.0, 1.0); 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 // Map NDC to texture UV. v is flipped so the cache's top row (rendered at
+289
View File
@@ -0,0 +1,289 @@
// 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);
}
+197
View File
@@ -0,0 +1,197 @@
// 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);
}
+55
View File
@@ -0,0 +1,55 @@
// 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;
+197
View File
@@ -0,0 +1,197 @@
// 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);
}
+121
View File
@@ -0,0 +1,121 @@
// Distance field for the shadow and 3D views of kinds whose map is
// discontinuous (Complex Multibrot's principal-branch z^p). The per-pixel
// DE, |z|·ln|z|/|dz| = G/|G'|, is only a distance when the potential G is
// continuous. Across a branch-cut preimage G jumps (|z| jumps by
// e^(2π·Im p)), each side's DE describes the set as continued on its own
// branch: seams in shadow mode, walls in the raymarched terrain. A true
// distance to the set is continuous, so this rebuilds one from the pixels that are unambiguously
// next to the set ("seeds": interior texels, and exterior texels whose DE is
// below SEED_DE, i.e. within about a pixel of it):
// h(x) = min over seeds y of DE(y) + DIST_SLOPE·|x - y|
// computed by jump flooding: `fs_seed` marks seeds, `fs_jump` runs with
// `step` halving from about half the texture size down to 1 (plus one more
// step-1 pass), each texel keeping the best seed among itself and its 8
// neighbours at ±step, and `fs_compose` writes the result, eased by
// `ease_distance`, as the data texture's G channel. Seeds are carried as coordinates, so the distance is
// Euclidean rather than an 8-direction chamfer approximation.
//
// The set only counts where it's in view: next to the image edge, set just
// outside it is missed and the height there reads a bit high.
//
// Texel layout of the seed textures: (seed x, seed y, seed DE, 1), or all 0
// for "no seed yet". R (palette parameter) and B (interior fraction) of the
// data texture pass through untouched.
struct LipschitzStep {
step: i32,
_pad0: i32,
_pad1: i32,
_pad2: i32,
};
@group(0) @binding(0) var data_tex: texture_2d<f32>;
@group(0) @binding(1) var seed_tex: texture_2d<f32>;
@group(0) @binding(2) var<uniform> ls: LipschitzStep;
// DE units per texel of a true distance: DE is in units of `pixel_size`,
// the texel's (|dx| + |dy|) footprint, i.e. sqrt(2) texels.
const DIST_SLOPE: f32 = 0.70710678;
// Exterior texels with a DE below this (in DE units) are seeds.
const SEED_DE: f32 = 1.0;
@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_seed(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let p = vec2<i32>(pos.xy);
let d = textureLoad(data_tex, p, 0);
if d.b != 0.0 {
return vec4<f32>(vec2<f32>(p), 0.0, 1.0);
}
if d.g < SEED_DE {
return vec4<f32>(vec2<f32>(p), d.g, 1.0);
}
return vec4<f32>(0.0);
}
// Height at `p` through seed `s` (a seed texel), or +inf for "no seed".
fn through(p: vec2<i32>, s: vec4<f32>) -> f32 {
if s.a == 0.0 {
return 3.0e38;
}
return s.z + DIST_SLOPE * distance(vec2<f32>(p), s.xy);
}
@fragment
fn fs_jump(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let p = vec2<i32>(pos.xy);
let hi = vec2<i32>(textureDimensions(seed_tex)) - vec2<i32>(1, 1);
let st = ls.step;
var best = textureLoad(seed_tex, p, 0);
var best_h = through(p, best);
for (var dy = -1; dy <= 1; dy++) {
for (var dx = -1; dx <= 1; dx++) {
if dx == 0 && dy == 0 {
continue;
}
let q = p + st * vec2<i32>(dx, dy);
if any(q < vec2<i32>(0, 0)) || any(q > hi) {
continue;
}
let s = textureLoad(seed_tex, q, 0);
let h = through(p, s);
if h < best_h {
best = s;
best_h = h;
}
}
}
return best;
}
// Scale of `ease_distance`, in screen heights.
const EASE_K: f32 = 0.02;
// Concave easing of the distance field `h` (DE units, `size_y` texels
// tall): K·ln(1 + x/K) in screen heights, the unit `sdf` in colorize.wgsl
// reads. Slope 1 at the set, flattening far from it, so the terrain rises
// steeply out of the set and then levels off. The slope never exceeds 1,
// so the field stays a valid (conservative) distance for the sphere tracer.
fn ease_distance(h: f32, size_y: f32) -> f32 {
return EASE_K * log(1.0 + h / (size_y * EASE_K)) * size_y;
}
@fragment
fn fs_compose(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let p = vec2<i32>(pos.xy);
let d = textureLoad(data_tex, p, 0);
if d.b != 0.0 {
return d;
}
// Not min(h, DE): that keeps the too-low side of every jump, so the
// walls come back as shards.
let h = through(p, textureLoad(seed_tex, p, 0));
if h >= 3.0e38 {
return d; // no set anywhere in view: keep the DE
}
return vec4<f32>(d.r, ease_distance(h, f32(textureDimensions(data_tex).y)), d.b, d.a);
}
+1178 -160
View File
File diff suppressed because it is too large Load Diff
+511 -51
View File
@@ -1,61 +1,304 @@
//! Camera / view state over the complex plane. //! Camera / view state over the complex plane.
//! //!
//! The center is stored in arbitrary precision (`FBig`) — this is what lets us //! The center is stored in arbitrary precision ([`Big`]) — this is what lets us
//! zoom far past f64's ~1e13x limit. The pixel *scale* stays `f64`: even at //! zoom far past f64's ~1e13x limit. The pixel *scale* is a [`Scale`]: an
//! 10^30x zoom the scale is ~1e-33, comfortably inside f64's range. Only the //! f64 mantissa with its own `i32` binary exponent, so it isn't bound by
//! center needs the extra digits. //! 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.
use core::str::FromStr; use core::str::FromStr;
use dashu_float::round::mode::HalfAway; pub use crate::bignum::Big;
use dashu_float::{DBig, FBig};
/// Arbitrary-precision binary float (base 2, round-half-away). One coordinate.
pub type Big = FBig<HalfAway, 2>;
/// Half-height (complex units) of the default view; also the zoom-1 reference. /// Half-height (complex units) of the default view; also the zoom-1 reference.
pub const DEFAULT_HALF_HEIGHT: f64 = 1.25; 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 big = self.to_big();
let sci = |sig: usize, pad: Option<usize>| {
let parts = big.to_decimal_parts(sig);
let digits = if parts.digits.is_empty() {
"0"
} else {
&parts.digits
};
// value = 0.digits · 10^exp10; move the point after the first digit.
let exp10 = parts.exp10 - 1;
let (head, tail) = digits.split_at(1);
let tail = match pad {
Some(p) => format!("{tail:0<p$}"),
None => tail.to_string(),
};
if tail.is_empty() {
format!("{head}e{exp10}")
} else {
format!("{head}.{tail}e{exp10}")
}
};
match f.precision() {
Some(p) => f.write_str(&sci(p + 1, Some(p))),
None => {
// Like `{:e}` on f64: the shortest string that parses back.
let s = (1..17)
.map(|sig| sci(sig, None))
.find(|s| s.parse::<Scale>() == Ok(*self))
.unwrap_or_else(|| sci(17, None));
f.write_str(&s)
}
}
}
}
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 bin = Big::from_decimal_str(s, 64).ok_or(())?;
if bin.is_negative() {
return Err(());
}
let top = bin.log2_floor().ok_or(())?;
let m = (bin >> top).to_f64();
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. /// Guard bits added on top of the zoom-dictated precision.
const GUARD_BITS: usize = 48; const GUARD_BITS: usize = 48;
/// Upper bound on center precision (f32 GPU perturbation degrades long before /// Upper bound on center precision: what `Scale::MIN` needs. Only a guard
/// this; the cap just prevents pathological allocation). /// against pathological input; the reference orbit is impractically slow
const MAX_PRECISION_BITS: usize = 2048; /// long before this.
pub const MAX_PRECISION_BITS: usize = (1 << 20) + GUARD_BITS;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct ViewState { pub struct ViewState {
pub center_re: Big, pub center_re: Big,
pub center_im: Big, pub center_im: Big,
/// Half the view height in complex-plane units. Zooming in shrinks this. /// Half the view height in complex-plane units. Zooming in shrinks this.
pub half_height: f64, pub half_height: Scale,
} }
impl Default for ViewState { impl Default for ViewState {
fn default() -> Self { fn default() -> Self {
let bits = precision_for(DEFAULT_HALF_HEIGHT); let bits = precision_for(Scale::from_f64(DEFAULT_HALF_HEIGHT));
Self { Self {
center_re: big_from_f64(-0.5, bits), center_re: big_from_f64(-0.5, bits),
center_im: big_from_f64(0.0, bits), center_im: big_from_f64(0.0, bits),
half_height: DEFAULT_HALF_HEIGHT, half_height: Scale::from_f64(DEFAULT_HALF_HEIGHT),
} }
} }
} }
impl ViewState { impl ViewState {
/// Complex-plane span (width, height) for the given pixel aspect ratio. /// Complex-plane span (width, height) for the given pixel aspect ratio.
pub fn span(&self, aspect: f64) -> (f64, f64) { pub fn span(&self, aspect: f64) -> (Scale, Scale) {
let h = self.half_height * 2.0; let h = self.half_height.mul_f64(2.0);
(h * aspect, h) (h.mul_f64(aspect), h)
} }
/// Complex-plane units per pixel, given the viewport height in pixels. /// Complex-plane units per pixel, given the viewport height in pixels.
pub fn complex_per_pixel(&self, height_px: f64) -> f64 { pub fn complex_per_pixel(&self, height_px: f64) -> Scale {
(self.half_height * 2.0) / height_px self.half_height.mul_f64(2.0 / height_px)
} }
/// Current magnification relative to the default view. /// log10 of the current magnification relative to the default view.
pub fn magnification(&self) -> f64 { pub fn magnification_log10(&self) -> f64 {
DEFAULT_HALF_HEIGHT / self.half_height DEFAULT_HALF_HEIGHT.log10() - self.half_height.log10()
}
/// Current zoom level.
pub fn zoom(&self) -> Scale {
self.half_height
} }
/// Bits of precision the center currently needs for this zoom level. /// Bits of precision the center currently needs for this zoom level.
@@ -68,10 +311,10 @@ impl ViewState {
pub fn sync_precision(&mut self) { pub fn sync_precision(&mut self) {
let bits = self.precision_bits(); let bits = self.precision_bits();
if self.center_re.precision() < bits { if self.center_re.precision() < bits {
self.center_re = self.center_re.clone().with_precision(bits).value(); self.center_re = self.center_re.clone().with_precision(bits);
} }
if self.center_im.precision() < bits { if self.center_im.precision() < bits {
self.center_im = self.center_im.clone().with_precision(bits).value(); self.center_im = self.center_im.clone().with_precision(bits);
} }
} }
@@ -81,8 +324,8 @@ impl ViewState {
let cpp = self.complex_per_pixel(height_px); let cpp = self.complex_per_pixel(height_px);
let bits = self.precision_bits(); let bits = self.precision_bits();
// Grab-and-drag: moving the mouse right shows content to the left. // Grab-and-drag: moving the mouse right shows content to the left.
self.center_re = &self.center_re - &big_from_f64(dx * cpp, bits); self.center_re = &self.center_re - &cpp.big_times(dx, bits);
self.center_im = &self.center_im - &big_from_f64(dy * cpp, bits); // y-down -> imag-up self.center_im = &self.center_im - &cpp.big_times(dy, bits);
} }
/// Zoom by `factor` (<1 zooms in) keeping the complex point currently under /// Zoom by `factor` (<1 zooms in) keeping the complex point currently under
@@ -95,14 +338,14 @@ impl ViewState {
// The cursor's complex offset from the center is (off * cpp). Keeping it // The cursor's complex offset from the center is (off * cpp). Keeping it
// fixed while scaling the view by `factor` moves the center by // fixed while scaling the view by `factor` moves the center by
// off * cpp * (1 - factor). (Derivation: new_c = fixed + (c-fixed)*f.) // off * cpp * (1 - factor). (Derivation: new_c = fixed + (c-fixed)*f.)
let k = cpp * (1.0 - factor); let k = 1.0 - factor;
self.center_re = &self.center_re + &big_from_f64(off_x * k, bits); self.center_re = &self.center_re + &cpp.big_times(off_x * k, bits);
self.center_im = &self.center_im + &big_from_f64(off_y * k, bits); // y flip self.center_im = &self.center_im + &cpp.big_times(off_y * k, bits);
self.half_height *= factor; self.half_height = self.half_height.mul_f64(factor);
} }
/// Build a view from full-precision center coordinates and a half-height. /// Build a view from full-precision center coordinates and a half-height.
pub fn with_center(center_re: Big, center_im: Big, half_height: f64) -> Self { pub fn with_center(center_re: Big, center_im: Big, half_height: Scale) -> Self {
let mut v = Self { let mut v = Self {
center_re, center_re,
center_im, center_im,
@@ -116,36 +359,253 @@ impl ViewState {
/// Parse a decimal string (any number of digits) losslessly into a `Big` with at /// Parse a decimal string (any number of digits) losslessly into a `Big` with at
/// least `bits` of precision. Used for share links and debug view specs. /// least `bits` of precision. Used for share links and debug view specs.
pub fn big_from_decimal_str(s: &str, bits: usize) -> Option<Big> { pub fn big_from_decimal_str(s: &str, bits: usize) -> Option<Big> {
let dec = DBig::from_str(s.trim()).ok()?; Big::from_decimal_str(s, bits)
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);
let im0 = from.center_im.clone().with_precision(bits);
let re1 = to.center_re.clone().with_precision(bits);
let im1 = to.center_im.clone().with_precision(bits);
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. /// Render a `Big` as a decimal string with `sig_digits` significant digits.
pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String { pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String {
let dec = x x.to_decimal_string(sig_digits)
.to_decimal()
.value()
.with_precision(sig_digits.max(1))
.value();
format!("{dec}")
} }
/// Precision (bits) needed to resolve the center at a given half-height. /// Precision (bits) needed to resolve the center at a given half-height.
pub fn precision_for(half_height: f64) -> usize { pub fn precision_for(half_height: Scale) -> usize {
// We need enough bits to distinguish points a pixel apart, i.e. roughly // We need enough bits to distinguish points a pixel apart, i.e. roughly
// log2(1 / half_height) significant bits, plus a guard margin. // log2(1 / half_height) significant bits, plus a guard margin.
let zoom_bits = if half_height > 0.0 && half_height.is_finite() { let zoom_bits = (-half_height.log2()).ceil().max(0.0) as usize;
(-half_height.log2()).ceil().max(0.0) as usize
} else {
0
};
(zoom_bits + GUARD_BITS).clamp(53, MAX_PRECISION_BITS) (zoom_bits + GUARD_BITS).clamp(53, MAX_PRECISION_BITS)
} }
/// Build an `FBig` from an f64 with an explicit precision context. /// Build a `Big` from an f64 with an explicit precision.
pub fn big_from_f64(x: f64, bits: usize) -> Big { pub fn big_from_f64(x: f64, bits: usize) -> Big {
Big::try_from(x) Big::from_f64(x, bits)
.unwrap_or_default() }
.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_f64();
let im: f64 = v.center_im.to_f64();
(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();
let dim = ((&mid.center_im - &to.center_im) << k).to_f64();
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}");
}
}
} }
+29 -7
View File
@@ -1,7 +1,7 @@
//! Native background worker for reference-orbit computation. //! Native background worker for reference-orbit computation.
//! //!
//! At deep zoom the high-precision reference can take many milliseconds (tens of //! At deep zoom the high-precision reference can take many milliseconds (tens of
//! thousands of `FBig` iterations), which would stutter the UI if done inline. //! thousands of `Big` iterations), which would stutter the UI if done inline.
//! This runs it on a thread and coalesces bursts of requests (e.g. during a //! This runs it on a thread and coalesces bursts of requests (e.g. during a
//! drag) down to the most recent one. On the web we compute inline instead //! drag) down to the most recent one. On the web we compute inline instead
//! (browsers need a Web Worker for threads); see `app.rs`. //! (browsers need a Web Worker for threads); see `app.rs`.
@@ -9,26 +9,38 @@
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel}; use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use std::thread; use std::thread;
use crate::fractal::{FractalKind, compute_reference, compute_set_reference}; use crate::fractal::{FractalKind, RefOrbit, compute_reference, compute_set_reference};
use crate::view::{Big, big_from_f64}; use crate::view::{Big, Scale, big_from_f64};
pub struct RefRequest { pub struct RefRequest {
pub center_re: Big, pub center_re: Big,
pub center_im: Big, pub center_im: Big,
pub half_height: f64, pub half_height: Scale,
pub julia: bool, pub julia: bool,
pub julia_c: (f64, f64), pub julia_c: (f64, f64),
pub max_iter: u32, pub max_iter: u32,
pub precision: usize, pub precision: usize,
pub kind: FractalKind, pub kind: FractalKind,
pub power: u32, 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)>,
} }
pub struct RefResult { pub struct RefResult {
pub center_re: Big, pub center_re: Big,
pub center_im: Big, pub center_im: Big,
pub half_height: f64, pub half_height: Scale,
pub points: Vec<[f32; 2]>, pub points: RefOrbit,
/// The kind and morph `points` was computed with (echoed from the
/// request).
pub kind: FractalKind,
pub morph: Option<(FractalKind, f32)>,
} }
pub struct RefWorker { pub struct RefWorker {
@@ -62,6 +74,8 @@ impl RefWorker {
center_im: req.center_im, center_im: req.center_im,
half_height: req.half_height, half_height: req.half_height,
points, points,
kind: req.kind,
morph: req.morph,
}) })
.is_err() .is_err()
{ {
@@ -88,7 +102,7 @@ impl RefWorker {
} }
} }
fn compute(req: &RefRequest) -> Vec<[f32; 2]> { fn compute(req: &RefRequest) -> RefOrbit {
if req.julia { if req.julia {
let jr = big_from_f64(req.julia_c.0, req.precision); let jr = big_from_f64(req.julia_c.0, req.precision);
let ji = big_from_f64(req.julia_c.1, req.precision); let ji = big_from_f64(req.julia_c.1, req.precision);
@@ -101,6 +115,10 @@ fn compute(req: &RefRequest) -> Vec<[f32; 2]> {
req.precision, req.precision,
req.kind, req.kind,
req.power, req.power,
req.phoenix_p,
req.lambda_l,
req.complex_power,
req.morph.map(|(k, w)| (k, w as f64)),
) )
} else { } else {
compute_set_reference( compute_set_reference(
@@ -110,6 +128,10 @@ fn compute(req: &RefRequest) -> Vec<[f32; 2]> {
req.precision, req.precision,
req.kind, req.kind,
req.power, req.power,
req.phoenix_p,
req.lambda_l,
req.complex_power,
req.morph.map(|(k, w)| (k, w as f64)),
) )
} }
} }
+147 -7
View File
@@ -3,7 +3,7 @@
//! shader with the same `naga` version wgpu uses — catching shader errors //! shader with the same `naga` version wgpu uses — catching shader errors
//! without needing a GPU or a display. //! without needing a GPU or a display.
fn validate(name: &str, src: &str) { fn validate(name: &str, src: &str) -> (naga::Module, naga::valid::ModuleInfo) {
let module = match naga::front::wgsl::parse_str(src) { let module = match naga::front::wgsl::parse_str(src) {
Ok(m) => m, Ok(m) => m,
Err(e) => panic!("{name}: WGSL parse error:\n{}", e.emit_to_string(src)), Err(e) => panic!("{name}: WGSL parse error:\n{}", e.emit_to_string(src)),
@@ -12,20 +12,160 @@ fn validate(name: &str, src: &str) {
naga::valid::ValidationFlags::all(), naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(), naga::valid::Capabilities::all(),
); );
if let Err(e) = validator.validate(&module) { match validator.validate(&module) {
panic!("{name}: WGSL validation error:\n{}", e.emit_to_string(src)); Ok(info) => (module, info),
Err(e) => 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] #[test]
fn mandelbrot_shader_is_valid() { fn colorize_shader_is_valid() {
validate( validate(
"mandelbrot.wgsl", "colorize.wgsl",
include_str!("../src/shaders/mandelbrot.wgsl"), concat!(
include_str!("../src/shaders/common.wgsl"),
include_str!("../src/shaders/iterate_uniforms.wgsl"),
include_str!("../src/shaders/colorize.wgsl"),
),
);
}
#[test]
fn lipschitz_shader_is_valid() {
validate(
"lipschitz.wgsl",
concat!(
include_str!("../src/shaders/common.wgsl"),
include_str!("../src/shaders/lipschitz.wgsl"),
),
); );
} }
#[test] #[test]
fn blit_shader_is_valid() { fn blit_shader_is_valid() {
validate("blit.wgsl", include_str!("../src/shaders/blit.wgsl")); 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)],
);
}
} }
+2
View File
@@ -0,0 +1,2 @@
results/
.worktrees/
+48
View File
@@ -0,0 +1,48 @@
# Benchmarks
`bench.sh` times `mandelbrot --headless` on a fixed set of views with
[hyperfine](https://github.com/sharkdp/hyperfine). That's the whole real
pipeline: the reference orbit on the CPU, then the GPU render, readback and PNG
encode. It builds the headless-only binary (`--no-default-features`) itself.
```sh
tools/bench/bench.sh --rev HEAD # did my uncommitted edits help? (vs last commit)
tools/bench/bench.sh --rev main seahorse-1e300 # one scenario vs another branch
tools/bench/bench.sh --quick # all scenarios, 960×540, 3 runs
tools/bench/bench.sh # all scenarios, 1920×1080, 5 runs
tools/bench/bench.sh --list
```
`--rev REF` checks REF out into `.worktrees/<sha>` and builds it there, with its
own target dir, so your `target/` stays warm. Each scenario then runs both
binaries back to back in one hyperfine call. The line to read is
`working tree ran 1.23 ± 0.04 times faster than main (…)`. A ratio within about
2σ of 1.00 is noise.
Results (hyperfine JSON and markdown per scenario) go to
`results/<sha>[-dirty]/`, or `results/<rev>-vs-<sha>/` with `--rev`.
Both `results/` and `.worktrees/` are git-ignored. To clean up:
`rm -rf tools/bench/.worktrees && git worktree prune`.
## Scenarios
| name | mostly measures |
|---|---|
| `shallow` | GPU, plain f32 path, f64 orbit fast path |
| `seahorse-1e30` | GPU f32 perturbation from a `Big` orbit |
| `seahorse-1e100` | the `DEEP` shader pipeline |
| `elephant-1e100-aa-de` | 2×2 antialiasing + DE shading |
| `burning-ship` | a non-holomorphic kind |
| `3d` | the raymarched 3D export path |
| `anim-zoom` | 24-frame zoom (⅓ size): the parallel orbit, GPU and PNG pipeline |
| `seahorse-1e300` *(slow)* | ~270k iterations per pixel on the deep path, ~40 s per run even with `--quick`. It's GPU-bound: the orbit itself is under a second. Only runs when named or with `--all` |
Views come from `tools/deep-zoom/LOCATIONS.md`. Each wall time includes process
start, device creation and shader compilation, so a small change to a GPU-bound
scenario shows up diluted.
## Tips
- Keep the machine idle and on AC power. Other GPU apps (browsers, video) add a lot of noise.
- Use `--quick` while you iterate and a full run to confirm.
- Timings are only comparable on the same machine and GPU.
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Benchmark `mandelbrot --headless` on a fixed set of scenarios with hyperfine.
#
# Usage:
# bench.sh [--quick] [--runs N] [--rev REF] [--all] [--list] [scenario...]
#
# (default) time the working tree; results go to results/<sha>[-dirty]/
# --rev REF also build REF (in a git worktree) and time both binaries side
# by side; hyperfine prints "X ± σ times faster" per scenario
# --quick 960×540 and 3 runs instead of 1920×1080 and 5
# --runs N timed runs per command (after 1 warmup run)
# --all include the slow scenarios
# --list print the scenario names and exit
#
# With no scenario names, all but the slow ones run. See README.md.
set -euo pipefail
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
root=$(git -C "$here" rev-parse --show-toplevel)
# Views from tools/deep-zoom/LOCATIONS.md.
SEAHORSE_40='-0.77568376800905379745347652613832924487504096622022,0.13646736829469012473375311880735014411233827361594'
SEAHORSE_101='-0.77568376800905379746948350393474104572824650461579342765769586746577714101553375163935420167637213972439444483,0.13646736829469012473327440961784876519777211159246218448277041663416833530840062457397927487282583495378053495'
SEAHORSE_300='-0.775683768009053797469483503934741045728246504615800394794952001400666017222819589894883159368782255028252296689050972508942991790804637379375064948276511044167168394677127466362472192356953918438738697548775738756167744421444715134058363508346750264411321139485834882343906172015818621107808449401958194265462,0.136467368294690124733274409617848765197772111592467696407800195400835665974815500875448611244178656625706823059759836009902469392551108149827753781145338163746214967880988693412636771323356061132825037923307975359146666033691080243216637006179374407866237575096209504710178751614036006531433101633993532313930'
ELEPHANT_100='0.2920738619144539179997175176922057340804018914331633820622644536373359023331882946705674169275530423410817910,0.01577091721139011808246902823033833768260048863232968822435568049949556139801802963232184866659058960379114747'
# name -> headless args (no size or --export-path; those are added per run).
# Order matters for display, hence the separate list. SLOW ones (tens of
# seconds per run) only run when named or with --all.
SCENARIOS=(shallow seahorse-1e30 seahorse-1e100 elephant-1e100-aa-de burning-ship 3d anim-zoom)
SLOW=(seahorse-1e300)
declare -A ARGS=(
[shallow]="--view=-0.75,0,1.5"
[seahorse-1e30]="--view=$SEAHORSE_40,1e-30"
[seahorse-1e100]="--view=$SEAHORSE_101,3.4e-101"
[seahorse-1e300]="--view=$SEAHORSE_300,5.5e-300"
[elephant-1e100-aa-de]="--view=$ELEPHANT_100,1e-75 --antialias --de"
[burning-ship]="--kind burning-ship"
[3d]="--rendering-kind 3d --de --pitch -40 --view=-0.75,0,1.5"
[anim-zoom]="--view=-0.75,0,1.5 --to-view=$SEAHORSE_40,1e-30 --frames 24"
)
quick=0
runs=
rev=
picked=()
while (($#)); do
case $1 in
--quick) quick=1 ;;
--runs) runs=$2; shift ;;
--rev) rev=$2; shift ;;
--all) picked+=("${SCENARIOS[@]}" "${SLOW[@]}") ;;
--list) printf '%s\n' "${SCENARIOS[@]}"; printf '%s (slow)\n' "${SLOW[@]}"; exit 0 ;;
-h|--help) sed -n '2,16s/^# \{0,1\}//p' "$0"; exit 0 ;;
-*) echo "unknown option: $1" >&2; exit 2 ;;
*)
[[ -v ARGS[$1] ]] || { echo "unknown scenario: $1 (try --list)" >&2; exit 2; }
picked+=("$1") ;;
esac
shift
done
((${#picked[@]})) || picked=("${SCENARIOS[@]}")
if ((quick)); then
width=960 height=540 runs=${runs:-3}
else
width=1920 height=1080 runs=${runs:-5}
fi
command -v hyperfine >/dev/null || { echo "hyperfine not found (https://github.com/sharkdp/hyperfine)" >&2; exit 1; }
# Headless-only binary: no eframe/egui, faster to build, same render path.
build() { cargo build --release --no-default-features --quiet --manifest-path "$1/Cargo.toml"; }
echo "building working tree…" >&2
build "$root"
new_bin="$root/target/release/mandelbrot"
if [[ -n $rev ]]; then
rev_sha=$(git -C "$root" rev-parse --short "$rev^{commit}")
wt="$here/.worktrees/$rev_sha"
[[ -d $wt ]] || git -C "$root" worktree add --detach --quiet "$wt" "$rev_sha"
echo "building $rev ($rev_sha)…" >&2
# Separate target dir so switching revisions doesn't invalidate target/.
CARGO_TARGET_DIR="$here/.worktrees/target" build "$wt"
old_bin="$here/.worktrees/target/release/mandelbrot"
fi
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
sha=$(git -C "$root" rev-parse --short HEAD)
git -C "$root" diff --quiet HEAD -- . ':!tools/bench' || sha+=-dirty
out="$here/results/${rev:+$rev_sha-vs-}$sha"
mkdir -p "$out"
# Full headless command line for scenario $2 with binary $1.
command_for() {
local bin=$1 name=$2 w=$width h=$height dest=$tmp/$2.png
if [[ $name == anim-* ]]; then
# Animation: a directory of frames, at a smaller size.
w=$((width / 3)) h=$((height / 3)) dest=$tmp/$name
fi
echo "$bin --headless --width $w --height $h --export-path $dest ${ARGS[$name]}"
}
for name in "${picked[@]}"; do
echo >&2
echo "=== $name ===" >&2
if [[ -n $rev ]]; then
cmds=(-n "$rev ($rev_sha)" "$(command_for "$old_bin" "$name")"
-n "working tree" "$(command_for "$new_bin" "$name")")
else
cmds=(-n "$name" "$(command_for "$new_bin" "$name")")
fi
hyperfine --warmup 1 --runs "$runs" --style basic \
--export-json "$out/$name.json" --export-markdown "$out/$name.md" \
"${cmds[@]}"
done
echo
echo "## Summary (${width}×${height}, $runs runs; results in ${out#"$root"/}/)"
for name in "${picked[@]}"; do
echo
echo "### $name"
cat "$out/$name.md"
done
+167
View File
@@ -0,0 +1,167 @@
# Deep-zoom locations
Beautiful deep Mandelbrot locations, all computed with `find_deep.py` and checked with a
`--headless` render. Every one is an exact minibrot nucleus (Newton's method at
`2·depth + 60` digits), not a coordinate copied from somewhere, so the digits are right all
the way down.
## How to use them
Each location has two views on the same centre:
- **Minibrot:** half-height ≈ 3× the minibrot's size. A tiny copy of the whole set sits in
concentric colour bands, and there are more bands the deeper it is.
- **Embedded Julia set:** half-height ≈ size^0.75, about ¾ of the way down in log-zoom.
This is where each location looks unique. The minibrots all look alike.
```sh
cargo run --release -- --view=RE,IM,HALF_HEIGHT # interactive
cargo run --release -- --headless --view=RE,IM,HALF_HEIGHT --export-path shot.png
# zoom video from the full set down to a minibrot, piped to ffmpeg (see Makefile):
cargo run --release -- --headless --view=-0.75,0,1.5 --to-view=RE,IM,HALF_HEIGHT \
--fps 60 --duration 60 --export-path - | make hevc-nvenc
```
Pass negative coordinates as `--view=-0.77...` (with the `=`): clap would read `--view -0.77...` as a flag.
Iterations scale with depth automatically (about 90k at 10⁻¹⁰⁰ and 270k at 10⁻³⁰⁰), which is
enough for these periods.
## Infinite spirals (Misiurewicz points)
These are the anchor points. Zoom straight into one and the same spiral repeats forever,
at any depth. Every minibrot below sits just beside one of them.
| Region | Point | Preperiod, period | Looks like |
|---|---|---|---|
| Seahorse valley | `-0.7756837680090537974694835039347410457282465046158` `0.13646736829469012473327440961784876519777211159247` | 24, 2 | Two-armed spirals of seahorses, repeating at every depth. |
| Elephant valley | `0.29207386191445391799971751769220573408040189143315` `0.015770917211390118082469028230338337682600488632339` | 32, 3 | Three-armed spirals of elephant trunks. |
| Top antenna | `-0.10110375797210418675706890936004723524958119917843` `0.95629400795235409290719434118824484558046591980423` | 38, 4 | Four-armed branch points on the upper antenna, with thin dendrites instead of spirals. |
## Seahorse valley
### Seahorse valley, minibrot at 3.7e-41 (period 1034)
The embedded Julia set is a pale double spiral: two S-shaped arms of tiny seahorses wind into a bright centre, with a third lace band sweeping around them. The best one for a short video, and it renders in seconds.
```sh
# embedded Julia set
--view=-0.77568376800905379745347652613832924487504096622022,0.13646736829469012473375311880735014411233827361594,1e-30
# minibrot
--view=-0.77568376800905379745347652613832924487504096622022,0.13646736829469012473375311880735014411233827361594,1.1e-40
```
### Seahorse valley, minibrot at 1.14e-101 (period 2868)
At the Julia depth there's a dark blue field with a glowing core and lace threads crossing it.
```sh
# embedded Julia set
--view=-0.77568376800905379746948350393474104572824650461579342765769586746577714101553375163935420167637213972439444483,0.13646736829469012473327440961784876519777211159246218448277041663416833530840062457397927487282583495378053495,1e-76
# minibrot
--view=-0.77568376800905379746948350393474104572824650461579342765769586746577714101553375163935420167637213972439444483,0.13646736829469012473327440961784876519777211159246218448277041663416833530840062457397927487282583495378053495,3.4e-101
```
### Seahorse valley, minibrot at 1.37e-200 (period 5895)
Two filaments cross in an X over a warm brown background, with a small dark spiral where they meet.
```sh
# embedded Julia set
--view=-0.77568376800905379746948350393474104572824650461580039479495200140066601722281958989488315936878225494657853849784368247991666558116221905228489656259693587754973261632614186930985854124920967267440737850057557,0.13646736829469012473327440961784876519777211159246769640780019540083566597481550087544861124417865661202932094564677695883049019713653069907950852152823160218141353878979871215911794286224832089496659451498185,1e-150
# minibrot
--view=-0.77568376800905379746948350393474104572824650461580039479495200140066601722281958989488315936878225494657853849784368247991666558116221905228489656259693587754973261632614186930985854124920967267440737850057557,0.13646736829469012473327440961784876519777211159246769640780019540083566597481550087544861124417865661202932094564677695883049019713653069907950852152823160218141353878979871215911794286224832089496659451498185,4.1e-200
```
### Seahorse valley, minibrot at 1.83e-300 (period 8922)
Faint cream threads, and a spiral wound so tight it reads as a point. Period 8922, so the minibrot needs a long render.
```sh
# embedded Julia set
--view=-0.775683768009053797469483503934741045728246504615800394794952001400666017222819589894883159368782255028252296689050972508942991790804637379375064948276511044167168394677127466362472192356953918438738697548775738756167744421444715134058363508346750264411321139485834882343906172015818621107808449401958194265462,0.136467368294690124733274409617848765197772111592467696407800195400835665974815500875448611244178656625706823059759836009902469392551108149827753781145338163746214967880988693412636771323356061132825037923307975359146666033691080243216637006179374407866237575096209504710178751614036006531433101633993532313930,1e-225
# minibrot
--view=-0.775683768009053797469483503934741045728246504615800394794952001400666017222819589894883159368782255028252296689050972508942991790804637379375064948276511044167168394677127466362472192356953918438738697548775738756167744421444715134058363508346750264411321139485834882343906172015818621107808449401958194265462,0.136467368294690124733274409617848765197772111592467696407800195400835665974815500875448611244178656625706823059759836009902469392551108149827753781145338163746214967880988693412636771323356061132825037923307975359146666033691080243216637006179374407866237575096209504710178751614036006531433101633993532313930,5.5e-300
```
## Elephant valley
### Elephant valley, minibrot at 1.29e-100 (period 782)
A cream-coloured double S-spiral, like the seahorse one but with fatter, beaded arms.
```sh
# embedded Julia set
--view=0.2920738619144539179997175176922057340804018914331633820622644536373359023331882946705674169275530423410817910,0.01577091721139011808246902823033833768260048863232968822435568049949556139801802963232184866659058960379114747,1e-75
# minibrot
--view=0.2920738619144539179997175176922057340804018914331633820622644536373359023331882946705674169275530423410817910,0.01577091721139011808246902823033833768260048863232968822435568049949556139801802963232184866659058960379114747,3.9e-100
```
### Elephant valley, minibrot at 1.43e-199 (period 1585)
A single huge spiral arm made of blue beads curling across the whole frame.
```sh
# embedded Julia set
--view=0.2920738619144539179997175176922057340804018914331538397786950546981063169810374784665382516408545756138262706218874924432355556243165250541700374639889797148498919340003273815669351046658761147565052417000965,0.01577091721139011808246902823033833768260048863233880759961760295375606839216565332458201188913912642954367433579737643167232896069795830226759356080847862646789481028238105648099709502522309228500630759855757,1e-149
# minibrot
--view=0.2920738619144539179997175176922057340804018914331538397786950546981063169810374784665382516408545756138262706218874924432355556243165250541700374639889797148498919340003273815669351046658761147565052417000965,0.01577091721139011808246902823033833768260048863233880759961760295375606839216565332458201188913912642954367433579737643167232896069795830226759356080847862646789481028238105648099709502522309228500630759855757,4.3e-199
```
### Elephant valley, minibrot at 3.82e-300 (period 2395)
A spiral galaxy: several beaded arms around a pinwheel centre. One of the prettiest frames here.
```sh
# embedded Julia set
--view=0.292073861914453917999717517692205734080401891433153839778695054698106316981037478466538251640854575589843066619974864800862332115945960921998657430606126553202998390050225413256445451049563878137878839982267258218894836912449161037745627504826537881261626243871678392881757578278459983842286413975991353319739,0.0157709172113901180824690282303383376826004886323388075996176029537560683921656533245820118891391263013273917522825544701901299061542128984468742890308365571942081484001405437827548345359998503228665872823816067983725089244499770271352539008763406229496083105636482420467114597267096082878888045800333807911146,1e-225
# minibrot
--view=0.292073861914453917999717517692205734080401891433153839778695054698106316981037478466538251640854575589843066619974864800862332115945960921998657430606126553202998390050225413256445451049563878137878839982267258218894836912449161037745627504826537881261626243871678392881757578278459983842286413975991353319739,0.0157709172113901180824690282303383376826004886323388075996176029537560683921656533245820118891391263013273917522825544701901299061542128984468742890308365571942081484001405437827548345359998503228665872823816067983725089244499770271352539008763406229496083105636482420467114597267096082878888045800333807911146,1.1e-299
```
## Top antenna
### Top antenna, minibrot at 6.11e-101 (period 245)
A dark branching dendrite like a frost cross, with four-way junctions.
```sh
# embedded Julia set
--view=-0.10110375797210418675706890936004723524958119917842627217950669362738928440088634862532432662296073501093701563,0.95629400795235409290719434118824484558046591980423293884325556254155313535666457147658005358708674409029391915,1e-75
# minibrot
--view=-0.10110375797210418675706890936004723524958119917842627217950669362738928440088634862532432662296073501093701563,0.95629400795235409290719434118824484558046591980423293884325556254155313535666457147658005358708674409029391915,1.8e-100
```
### Top antenna, minibrot at 5.28e-200 (period 473)
A chain of six-armed snowflakes linked by thin filaments.
```sh
# embedded Julia set
--view=-0.10110375797210418675706890936004723524958119917842839398761868801597328463328677019373309617158827470368429189213012997868613724029746440432852247903869013157556141380437202406412221853553594880182186959159214,0.95629400795235409290719434118824484558046591980423399388630140415938010604675760901574969597486831722998038561988250849596064882005832681021199867736493316924923123916964900533995659228499205141785254396348248,1e-149
# minibrot
--view=-0.10110375797210418675706890936004723524958119917842839398761868801597328463328677019373309617158827470368429189213012997868613724029746440432852247903869013157556141380437202406412221853553594880182186959159214,0.95629400795235409290719434118824484558046591980423399388630140415938010604675760901574969597486831722998038561988250849596064882005832681021199867736493316924923123916964900533995659228499205141785254396348248,1.6e-199
```
### Top antenna, minibrot at 8.34e-301 (period 705)
A single twelve-armed star, very symmetric. The antenna minibrots have short periods (245-705), so these are the fastest deep renders.
```sh
# embedded Julia set
--view=-0.1011037579721041867570689093600472352495811991784283939876186880159732846332867701937330961715882746718394428627914481009391257007785408536971203165750666758077852384490279260184029373211737264310068845597273991782305778295187471341711226626762421910644338164121042775094463557363347706110614218633830535086184,0.9562940079523540929071943411882448455804659198042339938863014041593801060467576090157496959748683171680339080646917869452781885502668216669916185990922330667568398963038228431738928191227884090004548615469303071899797403388188723954061899156655653387543351937028936674153437083087070219108726088551230849901498,1e-225
# minibrot
--view=-0.1011037579721041867570689093600472352495811991784283939876186880159732846332867701937330961715882746718394428627914481009391257007785408536971203165750666758077852384490279260184029373211737264310068845597273991782305778295187471341711226626762421910644338164121042775094463557363347706110614218633830535086184,0.9562940079523540929071943411882448455804659198042339938863014041593801060467576090157496959748683171680339080646917869452781885502668216669916185990922330667568398963038228431738928191227884090004548615469303071899797403388188723954061899156655653387543351937028936674153437083087070219108726088551230849901498,2.5e-300
```
## Finding more
```sh
./find_deep.py --list # known regions
./find_deep.py seahorse 250 # minibrot of size ~1e-500 near the seahorse point
./find_deep.py seahorse 100 0.2,0.7 # a different minibrot at the same depth
./find_deep.py -0.1011,0.9563,38,4 80 # any Misiurewicz point, given as re,im,k,p
```
A disk of radius 10⁻ᴰ around a Misiurewicz point holds a minibrot of size about 10⁻²ᴰ.
Newton sometimes fails to converge (elephant at depth 100 with the default offset did). The
script detects this from the size and asks for a different offset. Needs `mpmath`.
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Find deep Mandelbrot minibrots near a Misiurewicz point.
Misiurewicz points (preperiodic c, f^(k+p)(0) = f^k(0)) have self-similar
spirals at every depth. A disk of radius r = 10^-D around one contains a
minibrot of size ~r^2, found by:
1. the ball-period method: the first n where the disk's image covers 0
gives the period p of a nucleus inside it;
2. Newton's method on f^p(0) = 0 at high precision for the nucleus;
3. the standard size estimate (Heiland-Allen) for its scale.
Zooming at the nucleus shows spirals, then an embedded Julia set (around
size^0.75), then the minibrot (half-height ~3x size).
Usage:
find_deep.py REGION DEPTH [OFFSET_RE,OFFSET_IM]
find_deep.py --list
REGION is a name from REGIONS or "re,im,k,p" for any Misiurewicz point.
OFFSET (default 0.6,0.3, in units of r) picks a different minibrot at the
same depth. If the printed size is far below 10^-(2*DEPTH), Newton didn't
converge: try another offset.
Needs mpmath (pip install mpmath).
"""
import sys
from mpmath import log10, mp, mpc, mpf
# name: (approximate seed, preperiod k, period p). The exact point is
# refined by Newton at the requested precision.
REGIONS = {
"seahorse": (("-0.77568377", "0.13646737"), 24, 2),
"elephant": (("0.2925", "0.0149"), 32, 3),
"antenna": (("-0.1011", "0.9563"), 38, 4),
}
def converged(d):
return abs(d) < mpf(10) ** (-mp.dps + 20)
def misiurewicz(c, k, p, steps=200):
"""Newton on f^(k+p)(0) - f^k(0) = 0."""
for _ in range(steps):
z = dz = mpc(0)
zk = dzk = None
for i in range(1, k + p + 1):
dz = 2 * z * dz + 1
z = z * z + c
if i == k:
zk, dzk = z, dz
d = (z - zk) / (dz - dzk)
c -= d
if converged(d):
break
return c
def period_in_ball(c, r, maxit=1_000_000):
"""First n where the disk of radius r around c maps onto a disk containing 0."""
z = dz = mpc(0)
for n in range(1, maxit):
dz = 2 * z * dz + 1
z = z * z + c
if abs(z) < abs(dz) * r:
return n
if abs(z) > 4:
return None
return None
def nucleus(c, p, steps=200):
"""Newton on f^p(0) = 0."""
for _ in range(steps):
z = dz = mpc(0)
for _ in range(p):
dz = 2 * z * dz + 1
z = z * z + c
d = z / dz
c -= d
if converged(d):
break
return c
def size(c, p):
"""Approximate size of the minibrot with nucleus c and period p."""
z = mpc(0)
l = b = mpc(1)
for _ in range(1, p):
z = z * z + c
l = 2 * z * l
b = b + 1 / l
return abs(1 / (b * l * l))
def main(argv):
if len(argv) >= 1 and argv[0] == "--list":
for name, ((re, im), k, p) in REGIONS.items():
print(f"{name:10} ~{re}{'+' if not im.startswith('-') else ''}{im}i M({k},{p})")
return 0
if len(argv) not in (2, 3):
print(__doc__, file=sys.stderr)
return 2
region, depth = argv[0], int(argv[1])
if region in REGIONS:
(re, im), k, p = REGIONS[region]
else:
re, im, k, p = region.split(",")
k, p = int(k), int(p)
off_re, off_im = argv[2].split(",") if len(argv) == 3 else ("0.6", "0.3")
mp.dps = 2 * depth + 60
m = misiurewicz(mpc(re, im), k, p)
r = mpf(10) ** (-depth)
c0 = m + r * mpc(off_re, off_im)
period = period_in_ball(c0, r)
if period is None:
print("no period found (the disk escapes)", file=sys.stderr)
return 1
n = nucleus(c0, period)
s = size(n, period)
if s < mpf(10) ** (-2 * depth - 10):
print(f"size {mp.nstr(s, 3)} is implausibly small: Newton didn't converge, "
"try another offset", file=sys.stderr)
return 1
digits = int(-log10(s)) + 10
log_s = float(log10(s))
re_s = mp.nstr(n.real, digits, strip_zeros=False)
im_s = mp.nstr(n.imag, digits, strip_zeros=False)
print(f"# {region} depth={depth} period={period} size={mp.nstr(s, 3)}")
print("# minibrot:")
print(f"--view={re_s},{im_s},{mp.nstr(3 * s, 2)}")
print(f"# embedded Julia set on the way down:")
print(f"--view={re_s},{im_s},1e{round(0.75 * log_s)}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+3
View File
@@ -0,0 +1,3 @@
clips/
work/
results/
+8
View File
@@ -0,0 +1,8 @@
# Pass 1: which presets fit the 3-5 fps budget at 4K on this machine.
x265-fast | -pix_fmt yuv420p10le -c:v libx265 -profile:v main10 -preset fast -crf 18 -x265-params log-level=error:aq-mode=3:no-sao=1
x265-medium | -pix_fmt yuv420p10le -c:v libx265 -profile:v main10 -preset medium -crf 18 -x265-params log-level=error:aq-mode=3:no-sao=1
x265-slow | -pix_fmt yuv420p10le -c:v libx265 -profile:v main10 -preset slow -crf 18 -x265-params log-level=error:aq-mode=3:no-sao=1
av1-p5 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 5 -crf 26 -svtav1-params film-grain=0
av1-p6 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 6 -crf 26 -svtav1-params film-grain=0
av1-p7 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 7 -crf 26 -svtav1-params film-grain=0
av1-p8 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 8 -crf 26 -svtav1-params film-grain=0
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Render the lossless 4K60 test clips that sweep.sh encodes.
#
# Each clip goes through the same RGBA -> bt709 tv-range 10-bit conversion as
# the Makefile, then into lossless FFV1. The clips are therefore exactly what the
# encoders see, so VMAF against them measures the encoder alone.
#
# tools/encode/make_clips.sh [--size WxH] [--seconds S] [--force] [clip...]
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
root=$(cd "$here/../.." && pwd)
out="$here/clips"
size=3840x2160
seconds=4
fps=60
force=0
only=()
while [[ $# -gt 0 ]]; do
case $1 in
--size) size=$2; shift 2 ;;
--seconds) seconds=$2; shift 2 ;;
--force) force=1; shift ;;
-h|--help) sed -n '2,9p' "$0"; exit 0 ;;
*) only+=("$1"); shift ;;
esac
done
# Seahorse valley, minibrot at 3.7e-41 (tools/deep-zoom/LOCATIONS.md).
SH_RE=-0.77568376800905379745347652613832924487504096622022
SH_IM=0.13646736829469012473375311880735014411233827361594
# name | mandelbrot flags. Zoom speeds bracket a typical 60 s dive (~0.7 decade/s).
clips=(
# Worst case: dense filaments, 1.5 decades/s of scaling motion.
"filaments|--view=$SH_RE,$SH_IM,1e-8 --to-view=$SH_RE,$SH_IM,3e-15 --linear"
# Smooth concentric bands closing in on the minibrot: banding/blocking risk.
"bands|--view=$SH_RE,$SH_IM,1e-38 --to-view=$SH_RE,$SH_IM,1.1e-40 --linear"
# Embedded Julia set, slow drift: fine static texture, psy/detail retention.
"julia|--view=$SH_RE,$SH_IM,2e-30 --to-view=$SH_RE,$SH_IM,1e-30 --linear"
)
[[ -x $root/target/release/mandelbrot ]] || cargo build --release --manifest-path "$root/Cargo.toml"
mkdir -p "$out"
w=${size%x*}
h=${size#*x}
for entry in "${clips[@]}"; do
name=${entry%%|*}
flags=${entry#*|}
if [[ ${#only[@]} -gt 0 && ! " ${only[*]} " =~ " $name " ]]; then continue; fi
dst="$out/$name.mkv"
if [[ -f $dst && $force = 0 ]]; then echo "skip $name (exists, --force to redo)"; continue; fi
echo "render $name ($size, ${seconds}s @ ${fps}fps)"
# shellcheck disable=SC2086
"$root/target/release/mandelbrot" --headless --antialias $flags \
--width "$w" --height "$h" --fps "$fps" --duration "$seconds" --export-path - |
ffmpeg -hide_banner -loglevel error -y \
-f rawvideo -pix_fmt rgba -s "$size" -framerate "$fps" -i - \
-vf "scale=out_color_matrix=bt709:out_range=tv:flags=accurate_rnd+full_chroma_int+bitexact,format=yuv420p10le" \
-c:v ffv1 -level 3 -slices 16 -g 1 \
-colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv \
"$dst.tmp.mkv"
mv "$dst.tmp.mkv" "$dst"
done
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Encode every test clip with every config, then score each encode.
#
# tools/encode/sweep.sh [--clips a,b] [--keep] [--tag NAME] CONFIG_FILE...
#
# A config line is `name | ffmpeg output args` (blank lines and # comments are
# skipped). The args go between `-i clip.mkv` and the output file, e.g.
# x265-crf18 | -c:v libx265 -preset medium -crf 18 -x265-params aq-mode=3
# Each row of results/<tag>.tsv has encode fps, bitrate, VMAF 4K (mean, 1st
# percentile, min), VMAF-NEG mean, CAMBI banding (mean, max) and PSNR-Y.
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
clipdir="$here/clips"
model=/usr/share/model
export SVT_LOG=1 # errors only
clips=""
keep=0
tag=$(date +%Y%m%d-%H%M%S)
configs=()
while [[ $# -gt 0 ]]; do
case $1 in
--clips) clips=$2; shift 2 ;;
--keep) keep=1; shift ;;
--tag) tag=$2; shift 2 ;;
-h|--help) sed -n '2,11p' "$0"; exit 0 ;;
*) configs+=("$1"); shift ;;
esac
done
[[ ${#configs[@]} -gt 0 ]] || { echo "usage: $0 [--clips a,b] [--keep] [--tag NAME] CONFIG_FILE..." >&2; exit 1; }
if [[ -z $clips ]]; then
clips=$(cd "$clipdir" && ls ./*.mkv | sed 's|^\./||; s|\.mkv$||' | paste -sd,)
fi
[[ -n $clips ]] || { echo "no clips in $clipdir: run make_clips.sh first" >&2; exit 1; }
mkdir -p "$here/results" "$here/work"
tsv="$here/results/$tag.tsv"
[[ -f $tsv ]] || printf 'clip\tconfig\tfps\tmbps\tvmaf\tvmaf_p1\tvmaf_min\tvmaf_neg\tcambi\tcambi_max\tpsnr_y\n' > "$tsv"
IFS=, read -ra clip_list <<< "$clips"
for cfg in "${configs[@]}"; do
while IFS= read -r line || [[ -n $line ]]; do
[[ $line =~ ^[[:space:]]*(#|$) ]] && continue
name=$(sed 's/[[:space:]]*|.*//' <<< "$line")
args=$(sed 's/^[^|]*|[[:space:]]*//' <<< "$line")
for clip in "${clip_list[@]}"; do
ref="$clipdir/$clip.mkv"
enc="$here/work/$clip--$name.mp4"
log="$here/work/$clip--$name.json"
frames=$(ffprobe -v error -count_packets -select_streams v:0 \
-show_entries stream=nb_read_packets -of csv=p=0 "$ref")
printf '%-10s %-40s ' "$clip" "$name"
t0=$(date +%s.%N)
# shellcheck disable=SC2086
ffmpeg -hide_banner -loglevel error -y -i "$ref" $args -an "$enc" < /dev/null
t1=$(date +%s.%N)
# 4K model for both VMAF variants; CAMBI flags banding, which VMAF
# barely sees and which is the main risk on smooth fractal gradients.
ffmpeg -hide_banner -loglevel error -i "$enc" -i "$ref" -lavfi \
"[0:v]setpts=PTS-STARTPTS[d];[1:v]setpts=PTS-STARTPTS[r];[d][r]libvmaf=log_fmt=json:log_path=$log:n_threads=$(nproc):n_subsample=2:model='path=$model/vmaf_4k_v0.6.1.json\\:name=vmaf|path=$model/vmaf_4k_v0.6.1neg.json\\:name=neg':feature='name=cambi|name=psnr'" \
-f null - < /dev/null
row=$(python3 - "$log" "$enc" "$frames" "$t0" "$t1" <<'PY'
import json, os, sys
log, enc, frames, t0, t1 = sys.argv[1], sys.argv[2], int(sys.argv[3]), float(sys.argv[4]), float(sys.argv[5])
fr = json.load(open(log))["frames"]
col = lambda k: sorted(f["metrics"][k] for f in fr)
v = col("vmaf")
p1 = v[max(0, int(0.01 * len(v)) - 1)] if len(v) >= 100 else v[0]
mean = lambda xs: sum(xs) / len(xs)
cambi = col("cambi")
fps = frames / (t1 - t0)
mbps = os.path.getsize(enc) * 8 / (frames / 60) / 1e6
print(f"{fps:.2f}\t{mbps:.1f}\t{mean(v):.2f}\t{p1:.2f}\t{v[0]:.2f}\t{mean(col('neg')):.2f}\t{mean(cambi):.2f}\t{cambi[-1]:.2f}\t{mean(col('psnr_y')):.2f}")
PY
)
printf '%s\t%s\t%s\n' "$clip" "$name" "$row" >> "$tsv"
awk -F'\t' '{printf "%6s fps %7s Mb/s vmaf %s p1 %s min %s neg %s cambi %s/%s psnr %s\n",$1,$2,$3,$4,$5,$6,$7,$8,$9}' <<< "$row"
[[ $keep = 1 ]] || rm -f "$enc"
done
done < "$cfg"
done
echo "results: $tsv"