Compare commits
29
Commits
61d4766088
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da992c139a | ||
|
|
49130f5ecf | ||
|
|
493a743a16 | ||
|
|
0d7aadb991 | ||
|
|
ae7ca97c41 | ||
|
|
3f5853a23c | ||
|
|
2e116ba47b | ||
|
|
a15e951f84 | ||
|
|
27c5a1a603 | ||
|
|
d78594af7e | ||
|
|
4212839f0d | ||
|
|
48560de315 | ||
|
|
8750bb1590 | ||
|
|
f30921f7a5 | ||
|
|
b6467fc18c | ||
|
|
28ff2471b3 | ||
|
|
1bb3113581 | ||
|
|
bfd3d18f3d | ||
|
|
fbf0ef64af | ||
|
|
0d86e7b0e0 | ||
|
|
583a535806 | ||
|
|
ba385be18d | ||
|
|
120e73fb10 | ||
|
|
8889a09bf8 | ||
|
|
94f9c488bd | ||
|
|
a092be84b0 | ||
|
|
73570fdab8 | ||
|
|
9b0dec25e2 | ||
|
|
38cbb1f132 |
+1
-1
@@ -3,4 +3,4 @@ Cargo.lock
|
|||||||
dist
|
dist
|
||||||
frames*
|
frames*
|
||||||
out.mp4
|
out.mp4
|
||||||
|
__pycache__
|
||||||
|
|||||||
@@ -6,9 +6,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
A deep-zoom fractal explorer (Rust + wgpu + egui + WGSL). It zooms past the
|
A deep-zoom fractal explorer (Rust + wgpu + egui + WGSL). It zooms past the
|
||||||
~10¹³× limit of plain `f64` using **perturbation theory**: one high-precision
|
~10¹³× limit of plain `f64` using **perturbation theory**: one high-precision
|
||||||
reference orbit is computed on the CPU (arbitrary precision via `dashu-float`),
|
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
|
and every pixel is rendered on the GPU as a cheap `f32` delta from it, with
|
||||||
rebasing to avoid glitches. The `f32` GPU tier reaches roughly 10³⁰×. Runs
|
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
|
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).
|
storage buffers, which the fragment shader needs for the reference orbit).
|
||||||
|
|
||||||
@@ -17,9 +23,12 @@ storage buffers, which the fragment shader needs for the reference orbit).
|
|||||||
```sh
|
```sh
|
||||||
cargo run --release # native, run (release matters: fractal math is hot)
|
cargo run --release # native, run (release matters: fractal math is hot)
|
||||||
cargo test # reference-orbit math, share-link round-trip, WGSL validation
|
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 test --test shader_valid # just the WGSL parse/validate tests (naga, no GPU needed)
|
||||||
cargo clippy
|
cargo clippy
|
||||||
cargo fmt # rustfmt.toml just pins edition = "2024"
|
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):
|
Web build (WebGPU):
|
||||||
@@ -27,14 +36,16 @@ Web build (WebGPU):
|
|||||||
```sh
|
```sh
|
||||||
rustup target add wasm32-unknown-unknown
|
rustup target add wasm32-unknown-unknown
|
||||||
cargo install wasm-bindgen-cli --version 0.2.128 # must match the wasm-bindgen crate version
|
cargo install wasm-bindgen-cli --version 0.2.128 # must match the wasm-bindgen crate version
|
||||||
./build-web.sh # -> ./dist
|
./build-web.sh # -> ./dist (builds with --features wasm)
|
||||||
python3 -m http.server -d dist 8080
|
python3 -m http.server -d dist 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Native CLI flags (`src/cli.rs`, applied in `FractalApp::apply_cli`): `--kind`,
|
Native CLI flags (`src/cli.rs`, applied in `FractalApp::apply_cli`): `--kind`,
|
||||||
`--power`, `--julia re,im`, `--phoenix-p re,im`, `--lambda-l re,im`,
|
`--power`, `--julia re,im`, `--phoenix-p re,im`, `--lambda-l re,im`,
|
||||||
`--palette`, `--share <fragment>`,
|
`--palette`, `--share <fragment>`,
|
||||||
`--view re,im,half_height[,iterations]`, `--de`, `--buddhabrot`,
|
`--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
|
`--buddha-palette`. `--headless` (`src/headless.rs`) skips the window
|
||||||
entirely: it builds the same view from the other flags, creates its own
|
entirely: it builds the same view from the other flags, creates its own
|
||||||
offscreen wgpu device, and renders straight to a PNG (`--width`/`--height`,
|
offscreen wgpu device, and renders straight to a PNG (`--width`/`--height`,
|
||||||
@@ -42,20 +53,43 @@ default 1920×1080, `--export-path out.png`) without needing a GPU-backed
|
|||||||
window/event loop. Not yet supported with `--buddhabrot`. Run
|
window/event loop. Not yet supported with `--buddhabrot`. Run
|
||||||
`mandelbrot --help` for the full list.
|
`mandelbrot --help` for the full list.
|
||||||
|
|
||||||
`--headless` also has an animation mode, for feeding into `ffmpeg`: add
|
`--headless` also has an animation mode, for feeding into `ffmpeg`: give any
|
||||||
`--to-view re,im,half_height[,iterations]` (or `--to-share <fragment>`, which
|
end-state flag alongside the start flags (`--view`/`--share`/`--kind`/
|
||||||
only pulls position/zoom/iterations out of the link) alongside a start view
|
`--julia`/...), plus `--frames N` or `--fps`/`--duration`. End-state flags:
|
||||||
(`--view`/`--share`/`--kind`/`--julia`), plus `--frames N` or
|
`--to-view re,im,half_height[,iterations]` or `--to-share <fragment>` (only
|
||||||
`--fps`/`--duration`. `--export-path` then names an output *directory* of
|
position/zoom/iterations are pulled out of the link), `--to-iterations`,
|
||||||
`frame-00001.png`, `frame-00002.png`, ... instead of a single file. Only the
|
`--to-julia`, `--to-phoenix-p`, `--to-lambda-l`, `--to-complex-power`
|
||||||
camera (center + half-height) is animated — kind, colors, and per-kind
|
(or `--to-complex-power-re`/`--to-complex-power-im` to move one component),
|
||||||
constants stay fixed at whatever the start flags set. `view::interpolate_view`
|
and `--to-kind` (per-step formula blend via `KindMorph`, camera untouched).
|
||||||
does the interpolation: half-height geometrically (log-linear, since zoom
|
Anything without a target stays at its start value; colors stay fixed.
|
||||||
spans many decades), center linearly through the complex plane at full
|
`--export-path` then names an output *directory* of `frame-00001.png`,
|
||||||
`Big` precision; `--linear` swaps the default smoothstep easing for constant
|
`frame-00002.png`, ... instead of a single file. `--export-path -` writes
|
||||||
pacing. Iteration count auto-scales with zoom depth per frame (same
|
to stdout instead (refused on a terminal): the PNG for a still, or for an
|
||||||
`auto_iteration_count` the interactive app uses while zooming), overriding
|
animation raw RGBA8 frames in order (`unpad_rgba`, no PNG encode) for
|
||||||
any iteration count from `--view`/`--share`/`--to-view`/`--to-share`.
|
`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`
|
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
|
are the fast, headless way to validate a change. `cargo test` also runs but
|
||||||
@@ -74,9 +108,32 @@ runs out), rebase: `e ← y_n − X_0`, restart the reference index at 0. This i
|
|||||||
what makes deep zoom cheap — one expensive high-precision orbit, then every
|
what makes deep zoom cheap — one expensive high-precision orbit, then every
|
||||||
pixel is a handful of `f32` complex multiplies.
|
pixel is a handful of `f32` complex multiplies.
|
||||||
|
|
||||||
- `src/view.rs` — `ViewState`; center is arbitrary-precision `FBig` (`Big`
|
- `src/bignum/` — `Big`, the arbitrary-precision binary float, with one
|
||||||
type alias), pixel scale stays `f64` (still in-range at 10³⁰×). Precision
|
backend per library behind the same inherent API + operators: `rug`
|
||||||
(bits) scales with zoom depth (`precision_for`).
|
(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,
|
- `src/fractal/kind.rs` — the `FractalKind` enum (Mandelbrot, Burning Ship,
|
||||||
Tricorn, Multibrot, Celtic, Perpendicular, Buffalo, Phoenix, Lambda,
|
Tricorn, Multibrot, Celtic, Perpendicular, Buffalo, Phoenix, Lambda,
|
||||||
Complex Multibrot) plus everything that only needs to switch on it:
|
Complex Multibrot) plus everything that only needs to switch on it:
|
||||||
@@ -88,10 +145,21 @@ pixel is a handful of `f32` complex multiplies.
|
|||||||
`f32` pairs — that's the reference orbit the GPU perturbs from. At
|
`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
|
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
|
plain-`f64` fast path (`compute_reference_f64`), so each kind's formula
|
||||||
exists twice in this file (f64 + `FBig`) and both must stay in sync;
|
exists twice in this file (f64 + `Big`) and both must stay in sync;
|
||||||
`f64_fast_path_matches_big` checks they agree. Requests are made with 1.5×
|
`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
|
iteration headroom (`reference_iterations` in `app.rs`), so auto-iterations
|
||||||
creeping up during a zoom doesn't recompute the orbit every frame.
|
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
|
- `src/shaders/*.wgsl` — none of these are standalone WGSL modules; WGSL has
|
||||||
no `#include`, so each is compiled by concatenating plain-text fragments
|
no `#include`, so each is compiled by concatenating plain-text fragments
|
||||||
with `concat!`/`include_str!` at the `create_shader_module` call site (see
|
with `concat!`/`include_str!` at the `create_shader_module` call site (see
|
||||||
@@ -110,16 +178,19 @@ pixel is a handful of `f32` complex multiplies.
|
|||||||
pipeline creation. Read those constants in the shader, never `u.kind` /
|
pipeline creation. Read those constants in the shader, never `u.kind` /
|
||||||
`u.is_julia` / `u.de_coloring` (they're still uploaded for layout reasons).
|
`u.is_julia` / `u.de_coloring` (they're still uploaded for layout reasons).
|
||||||
`renderer.rs` builds one pipeline set per `PipelineKey` lazily on first
|
`renderer.rs` builds one pipeline set per `PipelineKey` lazily on first
|
||||||
use, and `tests/shader_valid.rs` compiles every kind × Julia × DE variant to
|
use, and `tests/shader_valid.rs` compiles every kind × Julia × DE × morph ×
|
||||||
SPIR-V. So a new kind needs no pipeline-list change, only its `KIND_*`
|
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`.
|
constant. `buddhabrot.wgsl` does the same with its own `override KIND`.
|
||||||
Interior pixels exit early through **periodicity detection**. It uses
|
Interior pixels exit early through **periodicity detection**. It uses
|
||||||
Brent-style checkpoints plus two guards: the cycle's multiplier must be
|
Brent-style checkpoints plus two guards: the cycle's multiplier must be
|
||||||
clearly attracting (`PERIOD_MAX_MULT2`), and the contracting return must
|
clearly attracting (`PERIOD_MAX_MULT2`), and the contracting return must
|
||||||
repeat in `PERIOD_CONFIRMATIONS` consecutive windows. Both guards are
|
repeat in `PERIOD_CONFIRMATIONS` consecutive windows. Both guards are
|
||||||
needed: without them, exterior pixels at cusps and minibrot edges turned
|
needed: without them, exterior pixels at cusps and minibrot edges turned
|
||||||
black. Retune them only against f64 ground truth on such views. Phoenix is
|
black. Retune them only against f64 ground truth on such views. Each
|
||||||
excluded (two-term map).
|
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,
|
`advance_delta(z, e)` is the per-kind delta step (`z` = reference point,
|
||||||
`e` = current delta); the caller adds `step_add` (= `dc`) afterward — this
|
`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
|
relies on `c` being additive in every current kind's formula (a kind where
|
||||||
@@ -133,18 +204,47 @@ pixel is a handful of `f32` complex multiplies.
|
|||||||
are `advance_delta_kind`/`fprime_kind`; `advance_delta`/`fprime` wrap them
|
are `advance_delta_kind`/`fprime_kind`; `advance_delta`/`fprime` wrap them
|
||||||
to blend two kinds during the kind-switch morph (`u.morph_from`,
|
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
|
`u.morph_w`: each step is `(1-w)·f_kind + w·f_from`, mirrored on the CPU by
|
||||||
the `morph` argument of `compute_reference`, in both its f64 and `FBig`
|
the `morph` argument of `compute_reference`, in both its f64 and `Big`
|
||||||
paths). The blend only exists in pipelines built with the `MORPH` override
|
paths). The blend only exists in pipelines built with the `MORPH` override
|
||||||
(part of `PipelineKey`, on while `morph_w > 0`); those also skip periodicity
|
(part of `PipelineKey`, on while `morph_w > 0`); those also skip periodicity
|
||||||
detection and the cardioid bypass. App side: `KindMorph` in
|
detection and the cardioid bypass. App side: `KindMorph` in
|
||||||
`app.rs`; the uniforms use the morph the *current reference* was built with
|
`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.
|
(`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 +
|
- `src/fractal/renderer.rs` — `FractalRenderer` (wgpu pipelines, uniform +
|
||||||
storage buffers, bind groups), `Uniforms` (repr(C) layout that must match
|
storage buffers, bind groups), `Uniforms` (repr(C) layout that must match
|
||||||
the WGSL `Uniforms` struct field-for-field, including padding; it includes
|
the WGSL `Uniforms` struct field-for-field, including padding; it includes
|
||||||
CPU-precomputed data: `cm_coef`, the Complex Multibrot binomial
|
CPU-precomputed data: `cm_coef`, the Complex Multibrot binomial
|
||||||
coefficients from `app.rs::complex_binomials`, and `light_count` for the
|
coefficients from `app.rs::complex_binomials`, `light_count` for the
|
||||||
packed `GpuLight` buffer from `lights.rs::gpu_lights`), and
|
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
|
`FractalCallback` (the `egui_wgpu::CallbackTrait` impl: `prepare()` uploads
|
||||||
changed buffers and decides whether to re-run the iterate pass, the cheap
|
changed buffers and decides whether to re-run the iterate pass, the cheap
|
||||||
colourise pass, or just blit the cached texture). Also `ExportRender`, a
|
colourise pass, or just blit the cached texture). Also `ExportRender`, a
|
||||||
@@ -158,7 +258,10 @@ pixel is a handful of `f32` complex multiplies.
|
|||||||
- `src/app.rs` — `FractalApp` (the egui app + all UI). Key methods:
|
- `src/app.rs` — `FractalApp` (the egui app + all UI). Key methods:
|
||||||
`should_request`/`ensure_reference` (decide when the reference is stale and
|
`should_request`/`ensure_reference` (decide when the reference is stale and
|
||||||
dispatch/collect it), `make_uniforms` (assemble the per-frame `Uniforms`),
|
dispatch/collect it), `make_uniforms` (assemble the per-frame `Uniforms`),
|
||||||
`tick_animations` (drives the "morph c/p/λ" and auto-zoom animations),
|
`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
|
`default_view_for` (wraps `FractalKind::default_set_view`, adding the
|
||||||
kind-independent Julia case). `JULIA_PRESETS` and `SET_PRESETS` are sized as
|
kind-independent Julia case). `JULIA_PRESETS` and `SET_PRESETS` are sized as
|
||||||
`[T; FractalKind::<last variant> as usize + 1]` — adding a new `FractalKind`
|
`[T; FractalKind::<last variant> as usize + 1]` — adding a new `FractalKind`
|
||||||
@@ -174,7 +277,9 @@ Touches, in order: `kind.rs` (enum variant + `ALL` slot + `label`/
|
|||||||
`description`/`formula`/`share_tag`/`from_share_tag`/`default_set_view`
|
`description`/`formula`/`share_tag`/`from_share_tag`/`default_set_view`
|
||||||
arms), `reference.rs` (CPU iteration formula arm, and a test comparing
|
arms), `reference.rs` (CPU iteration formula arm, and a test comparing
|
||||||
against a naive `f64` iteration), `common.wgsl` (matching `KIND_*` const),
|
against a naive `f64` iteration), `common.wgsl` (matching `KIND_*` const),
|
||||||
`mandelbrot.wgsl` (matching `advance_delta`/`fprime` arms), `buddhabrot.wgsl`
|
`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),
|
(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,
|
`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,
|
e.g. Phoenix's `phoenix_p`), `app.rs` (`JULIA_PRESETS`/`SET_PRESETS` slot,
|
||||||
@@ -211,7 +316,10 @@ 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
|
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
|
`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
|
reads the refined texture. PNG export (`fs_color`) still supersamples every
|
||||||
pixel.
|
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
|
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
|
field straight from the data texture. It's cheap: rays start on the z = 0
|
||||||
|
|||||||
+16
-4
@@ -3,19 +3,31 @@ 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"] }
|
||||||
|
wgpu = "30.0.1"
|
||||||
glam = "0.33.8"
|
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"] }
|
clap = { version = "4.5.51", features = ["derive"] }
|
||||||
pollster = "1.0.1"
|
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"] }
|
futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] }
|
||||||
@@ -32,7 +44,7 @@ opt-level = 3
|
|||||||
# codegen-units = 1
|
# codegen-units = 1
|
||||||
debug = true
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|||||||
+1
-1
@@ -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"
|
||||||
|
|||||||
+647
-259
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-3
@@ -2,6 +2,10 @@ use std::f32::consts::{PI, TAU};
|
|||||||
|
|
||||||
use glam::Vec3;
|
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)]
|
#[derive(Default, Clone)]
|
||||||
pub struct Camera {
|
pub struct Camera {
|
||||||
pub position: glam::Vec3,
|
pub position: glam::Vec3,
|
||||||
@@ -35,16 +39,29 @@ impl Camera {
|
|||||||
/// Yaw is wrapped to [-π, π) so the 2D <-> 3D transition (which scales
|
/// Yaw is wrapped to [-π, π) so the 2D <-> 3D transition (which scales
|
||||||
/// yaw by `t`) always unwinds the short way instead of every past turn.
|
/// yaw by `t`) always unwinds the short way instead of every past turn.
|
||||||
pub fn rotate(&mut self, dyaw: f32, dpitch: f32) {
|
pub fn rotate(&mut self, dyaw: f32, dpitch: f32) {
|
||||||
const PITCH_LIMIT: f32 = PI / 2.0 - 0.01;
|
|
||||||
self.yaw = (self.yaw + dyaw + PI).rem_euclid(TAU) - PI;
|
self.yaw = (self.yaw + dyaw + PI).rem_euclid(TAU) - PI;
|
||||||
self.pitch = (self.pitch + dpitch).clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
self.pitch = (self.pitch + dpitch).clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn orthographic(&self, t: f32) -> glam::Mat4 {
|
/// 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 yaw = self.yaw * t;
|
||||||
let pitch = self.pitch * t;
|
let pitch = self.pitch * t;
|
||||||
|
|
||||||
let zoom = 0.5 * (1. + t);
|
let zoom = 0.5 * (1. + t * (render_scale - 1.));
|
||||||
// Orbit pivot: the center of the fractal texture, which the raymarcher's
|
// 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.
|
// `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.))
|
let view = glam::Mat4::from_translation(Vec3::new(0.5 * self.aspect_ratio, 0.5, 0.))
|
||||||
|
|||||||
+73
-4
@@ -25,7 +25,7 @@ pub struct Cli {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub rendering_kind: Option<RenderingKindArg>,
|
pub rendering_kind: Option<RenderingKindArg>,
|
||||||
|
|
||||||
/// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 8].
|
/// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 20].
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub power: Option<u32>,
|
pub power: Option<u32>,
|
||||||
|
|
||||||
@@ -61,10 +61,23 @@ pub struct Cli {
|
|||||||
#[arg(long("zoom"), short('z'))]
|
#[arg(long("zoom"), short('z'))]
|
||||||
pub half_height: Option<String>,
|
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.
|
/// Enable distance-estimation shading.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub de: bool,
|
pub de: bool,
|
||||||
|
|
||||||
|
/// Enable 2×2 antialiasing (supersampling; ~4× slower).
|
||||||
|
#[arg(long)]
|
||||||
|
pub antialias: bool,
|
||||||
|
|
||||||
/// Coloring palette index.
|
/// Coloring palette index.
|
||||||
#[arg(long, value_name = "INDEX")]
|
#[arg(long, value_name = "INDEX")]
|
||||||
pub palette: Option<u32>,
|
pub palette: Option<u32>,
|
||||||
@@ -72,7 +85,9 @@ pub struct Cli {
|
|||||||
/// Output path for --headless (default: fractal-<timestamp>.png). When
|
/// Output path for --headless (default: fractal-<timestamp>.png). When
|
||||||
/// animating (--to-view/--to-share), this is a directory of
|
/// animating (--to-view/--to-share), this is a directory of
|
||||||
/// frame-00001.png, frame-00002.png, ... instead (default:
|
/// frame-00001.png, frame-00002.png, ... instead (default:
|
||||||
/// frames-<timestamp>/).
|
/// 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")]
|
#[arg(long, value_name = "PATH")]
|
||||||
pub export_path: Option<String>,
|
pub export_path: Option<String>,
|
||||||
|
|
||||||
@@ -93,6 +108,49 @@ pub struct Cli {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub to_iterations: Option<u32>,
|
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 +
|
/// Number of frames to render for an animation. Alternative to --fps +
|
||||||
/// --duration.
|
/// --duration.
|
||||||
#[arg(long, value_name = "N")]
|
#[arg(long, value_name = "N")]
|
||||||
@@ -113,10 +171,21 @@ pub struct Cli {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub linear: bool,
|
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
|
/// 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
|
/// exit. Combine with --kind/--julia/--share/--view etc. to pick what to
|
||||||
/// render, or --to-view/--to-share to render an animation instead of a
|
/// render, or any --to-* flag (--to-view, --to-julia, --to-kind, ...) to
|
||||||
/// single frame. Not yet supported with --buddhabrot.
|
/// render an animation instead of a single frame. Not yet supported with --buddhabrot.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub headless: bool,
|
pub headless: bool,
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use eframe::egui_wgpu::{self, wgpu};
|
#[cfg(feature = "gui")]
|
||||||
|
use eframe::egui_wgpu;
|
||||||
|
|
||||||
/// Random samples dispatched per accumulating frame. Chosen so a frame stays
|
/// 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`
|
/// interactive on a modest GPU even when most samples run the full `b_cap`
|
||||||
@@ -125,7 +126,9 @@ pub struct BuddhabrotRenderer {
|
|||||||
|
|
||||||
impl BuddhabrotRenderer {
|
impl BuddhabrotRenderer {
|
||||||
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
|
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
|
||||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
let shader = unsafe {
|
||||||
|
device.create_shader_module_trusted(
|
||||||
|
wgpu::ShaderModuleDescriptor {
|
||||||
label: Some("buddhabrot"),
|
label: Some("buddhabrot"),
|
||||||
source: wgpu::ShaderSource::Wgsl(
|
source: wgpu::ShaderSource::Wgsl(
|
||||||
concat!(
|
concat!(
|
||||||
@@ -134,7 +137,10 @@ impl BuddhabrotRenderer {
|
|||||||
)
|
)
|
||||||
.into(),
|
.into(),
|
||||||
),
|
),
|
||||||
});
|
},
|
||||||
|
wgpu::ShaderRuntimeChecks::unchecked(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("buddhabrot uniforms"),
|
label: Some("buddhabrot uniforms"),
|
||||||
@@ -336,6 +342,7 @@ pub struct BuddhabrotCallback {
|
|||||||
pub size_px: [u32; 2],
|
pub size_px: [u32; 2],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
|
impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
|
||||||
fn prepare(
|
fn prepare(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
+3
-3
@@ -16,7 +16,7 @@ pub enum FractalKind {
|
|||||||
BurningShip = 1,
|
BurningShip = 1,
|
||||||
/// `z -> conj(z)^2 + c` (the Mandelbar).
|
/// `z -> conj(z)^2 + c` (the Mandelbar).
|
||||||
Tricorn = 2,
|
Tricorn = 2,
|
||||||
/// `z -> z^power + c` (power >= 2).
|
/// `z -> z^power + c` (integer power in [2, 20]).
|
||||||
Multibrot = 3,
|
Multibrot = 3,
|
||||||
/// `z -> |Re(z^2)| + i·Im(z^2) + c` (abs on the real output of the square).
|
/// `z -> |Re(z^2)| + i·Im(z^2) + c` (abs on the real output of the square).
|
||||||
Celtic = 4,
|
Celtic = 4,
|
||||||
@@ -26,7 +26,7 @@ pub enum FractalKind {
|
|||||||
Buffalo = 6,
|
Buffalo = 6,
|
||||||
/// `z -> z^2 + c + p·z_{n-1}` (two-term recurrence; `p` is `phoenix_p`).
|
/// `z -> z^2 + c + p·z_{n-1}` (two-term recurrence; `p` is `phoenix_p`).
|
||||||
Phoenix = 7,
|
Phoenix = 7,
|
||||||
/// `z -> lambda·z(1 - z)` (logistic map).
|
/// `z -> lambda·z(1 - z) + c` (logistic map).
|
||||||
Lambda = 8,
|
Lambda = 8,
|
||||||
/// `z -> z^power + c`, where `power` is a complex constant (the
|
/// `z -> z^power + c`, where `power` is a complex constant (the
|
||||||
/// `complex_power` argument), via the principal branch `z^p = exp(p·ln z)`.
|
/// `complex_power` argument), via the principal branch `z^p = exp(p·ln z)`.
|
||||||
@@ -104,7 +104,7 @@ impl FractalKind {
|
|||||||
FractalKind::Perpendicular => "z = (x² − y²) − 2x|y|i + 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::Buffalo => "z = |Re(z²)| − i|Im(z²)| + c".to_string(),
|
||||||
FractalKind::Phoenix => "z = z² + c + p·z_prev".to_string(),
|
FractalKind::Phoenix => "z = z² + c + p·z_prev".to_string(),
|
||||||
FractalKind::Lambda => "z = λ·z(1 − z)".to_string(),
|
FractalKind::Lambda => "z = λ·z(1 − z) + c".to_string(),
|
||||||
FractalKind::ComplexMultibrot => {
|
FractalKind::ComplexMultibrot => {
|
||||||
format!("z = z^({:.3}{:+.3}i) + c", complex_power.0, complex_power.1)
|
format!("z = z^({:.3}{:+.3}i) + c", complex_power.0, complex_power.1)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -9,10 +9,12 @@ pub mod share;
|
|||||||
|
|
||||||
pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms};
|
pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms};
|
||||||
pub use kind::FractalKind;
|
pub use kind::FractalKind;
|
||||||
pub use reference::{compute_reference, compute_set_reference};
|
pub use reference::{RefOrbit, compute_reference, compute_set_reference};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use renderer::PipelineKey;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub use renderer::encode_png_with_progress;
|
pub use renderer::encode_png_with_progress;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub use renderer::export_to_png_blocking;
|
|
||||||
pub use renderer::{ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms};
|
pub use 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;
|
||||||
|
|||||||
+236
-147
@@ -1,7 +1,7 @@
|
|||||||
//! High-precision reference-orbit computation for perturbation rendering.
|
//! High-precision reference-orbit computation for perturbation rendering.
|
||||||
//!
|
//!
|
||||||
//! We iterate the fractal's formula `Z_{n+1} = f(Z_n, C)` at high precision
|
//! We iterate the fractal's formula `Z_{n+1} = f(Z_n, C)` at high precision
|
||||||
//! (`dashu-float`), storing each `Z_n` as an `f32` pair. Every pixel is then
|
//! ([`Big`]: `rug` natively, pure Rust on the web), storing each `Z_n` as an `f32` pair. Every pixel is then
|
||||||
//! rendered on the GPU as a small `f32` delta from this orbit — that is what
|
//! rendered on the GPU as a small `f32` delta from this orbit — that is what
|
||||||
//! makes deep zoom cheap. See `shaders/mandelbrot.wgsl` for the delta side; the
|
//! makes deep zoom cheap. See `shaders/mandelbrot.wgsl` for the delta side; the
|
||||||
//! delta formula there must match the orbit formula here.
|
//! delta formula there must match the orbit formula here.
|
||||||
@@ -24,7 +24,7 @@ use crate::view::{Big, big_from_f64};
|
|||||||
const REFERENCE_ESCAPE_SQ: f64 = 1.0e10;
|
const REFERENCE_ESCAPE_SQ: f64 = 1.0e10;
|
||||||
|
|
||||||
/// Up to this working precision (bits) the orbit is iterated in plain `f64`
|
/// Up to this working precision (bits) the orbit is iterated in plain `f64`
|
||||||
/// instead of `FBig` — orders of magnitude faster, which matters most on the
|
/// instead of `Big` — orders of magnitude faster, which matters most on the
|
||||||
/// web (where the reference is computed inline on the UI thread).
|
/// web (where the reference is computed inline on the UI thread).
|
||||||
///
|
///
|
||||||
/// `precision_for` asks for `zoom_bits + 48` guard bits, but the GPU only
|
/// `precision_for` asks for `zoom_bits + 48` guard bits, but the GPU only
|
||||||
@@ -39,6 +39,81 @@ const REFERENCE_ESCAPE_SQ: f64 = 1.0e10;
|
|||||||
/// below 0.1% of a pixel.
|
/// below 0.1% of a pixel.
|
||||||
const F64_MAX_PRECISION: usize = 80;
|
const F64_MAX_PRECISION: usize = 80;
|
||||||
|
|
||||||
|
/// Orbit points with a magnitude below `2^TINY_LOG2` are stored normalized
|
||||||
|
/// (mantissa + exponent, see [`RefOrbit::exps`]): f32's smallest normal is
|
||||||
|
/// ~2^-126, and the GPU's deep (rescaled) phase needs these points' exact
|
||||||
|
/// value to decide rebasing. The margin keeps a few mantissa bits clear of
|
||||||
|
/// the subnormal range for the smaller component.
|
||||||
|
const TINY_LOG2: i32 = -100;
|
||||||
|
|
||||||
|
/// A reference orbit as uploaded to the GPU.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct RefOrbit {
|
||||||
|
/// `Z_n` as f32 pairs. For points with a non-zero `exps[n]`, a mantissa
|
||||||
|
/// instead: the true value is `points[n] * 2^exps[n]`.
|
||||||
|
pub points: Vec<[f32; 2]>,
|
||||||
|
/// Per-point binary exponent (same length as `points`). Non-zero only for
|
||||||
|
/// points too small for f32's exponent range (see [`TINY_LOG2`]); only
|
||||||
|
/// the deep shader pipeline reads it, so [`Self::has_scaled`] forces it.
|
||||||
|
pub exps: Vec<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RefOrbit {
|
||||||
|
fn with_capacity(n: usize) -> Self {
|
||||||
|
// Most orbits escape long before `max_iter`; don't reserve hundreds of
|
||||||
|
// MB up front for a multi-million iteration request.
|
||||||
|
let n = n.min(1 << 17);
|
||||||
|
Self {
|
||||||
|
points: Vec::with_capacity(n),
|
||||||
|
exps: Vec::with_capacity(n),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, point: [f32; 2]) {
|
||||||
|
self.points.push(point);
|
||||||
|
self.exps.push(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any point is stored as mantissa + exponent, i.e. the orbit
|
||||||
|
/// can only be read by the deep pipeline.
|
||||||
|
pub fn has_scaled(&self) -> bool {
|
||||||
|
self.exps.iter().any(|&e| e != 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::ops::Deref for RefOrbit {
|
||||||
|
type Target = [[f32; 2]];
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> IntoIterator for &'a RefOrbit {
|
||||||
|
type Item = &'a [f32; 2];
|
||||||
|
type IntoIter = core::slice::Iter<'a, [f32; 2]>;
|
||||||
|
fn into_iter(self) -> Self::IntoIter {
|
||||||
|
self.points.iter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store `(zr, zi)` into `orbit`, as plain f32 unless its magnitude is below
|
||||||
|
/// `2^TINY_LOG2`, in which case both components share an exponent `k` and
|
||||||
|
/// the stored mantissa `Z * 2^-k` has its larger component in `[0.5, 1)`.
|
||||||
|
fn push_big_point(orbit: &mut RefOrbit, zr: &Big, zi: &Big) {
|
||||||
|
let (lr, li) = (zr.log2_floor(), zi.log2_floor());
|
||||||
|
let top = lr.max(li);
|
||||||
|
match top {
|
||||||
|
Some(top) if top < TINY_LOG2 as isize => {
|
||||||
|
let k = top + 1;
|
||||||
|
let mr = (zr.clone() << -k).to_f64() as f32;
|
||||||
|
let mi = (zi.clone() << -k).to_f64() as f32;
|
||||||
|
orbit.points.push([mr, mi]);
|
||||||
|
orbit.exps.push(k as i32);
|
||||||
|
}
|
||||||
|
_ => orbit.push([zr.to_f64() as f32, zi.to_f64() as f32]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Compute the reference orbit `Z_0..Z_{len-1}` where `Z_0 = z0` and
|
/// Compute the reference orbit `Z_0..Z_{len-1}` where `Z_0 = z0` and
|
||||||
/// `Z_{n+1} = f(Z_n, c)` for the given `kind` (and `power`, for Multibrot), up
|
/// `Z_{n+1} = f(Z_n, c)` for the given `kind` (and `power`, for Multibrot), up
|
||||||
/// to `max_iter` steps at `precision` bits. Each entry is `[re, im]` in f32.
|
/// to `max_iter` steps at `precision` bits. Each entry is `[re, im]` in f32.
|
||||||
@@ -59,64 +134,22 @@ pub fn compute_reference(
|
|||||||
lambda_l: (f64, f64),
|
lambda_l: (f64, f64),
|
||||||
complex_power: (f64, f64),
|
complex_power: (f64, f64),
|
||||||
morph: Option<(FractalKind, f64)>,
|
morph: Option<(FractalKind, f64)>,
|
||||||
) -> Vec<[f32; 2]> {
|
) -> RefOrbit {
|
||||||
compute_reference_inner(
|
|
||||||
z0_re,
|
|
||||||
z0_im,
|
|
||||||
c_re,
|
|
||||||
c_im,
|
|
||||||
max_iter,
|
|
||||||
precision,
|
|
||||||
kind,
|
|
||||||
power,
|
|
||||||
phoenix_p,
|
|
||||||
lambda_l,
|
|
||||||
complex_power,
|
|
||||||
morph,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`compute_reference`] plus `set_plane` (see [`StepConsts::set_plane`]):
|
|
||||||
/// picks the `f64` fast path or the `FBig` path by precision.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn compute_reference_inner(
|
|
||||||
z0_re: &Big,
|
|
||||||
z0_im: &Big,
|
|
||||||
c_re: &Big,
|
|
||||||
c_im: &Big,
|
|
||||||
max_iter: u32,
|
|
||||||
precision: usize,
|
|
||||||
kind: FractalKind,
|
|
||||||
power: u32,
|
|
||||||
phoenix_p: (f64, f64),
|
|
||||||
lambda_l: (f64, f64),
|
|
||||||
complex_power: (f64, f64),
|
|
||||||
morph: Option<(FractalKind, f64)>,
|
|
||||||
set_plane: bool,
|
|
||||||
) -> Vec<[f32; 2]> {
|
|
||||||
// A zero-weight morph is just the plain kind; skip the second formula.
|
// A zero-weight morph is just the plain kind; skip the second formula.
|
||||||
let morph = morph.filter(|&(_, w)| w != 0.0);
|
let morph = morph.filter(|&(_, w)| w != 0.0);
|
||||||
if precision <= F64_MAX_PRECISION {
|
if precision <= F64_MAX_PRECISION {
|
||||||
let k = StepConstsF64 {
|
let k = StepConstsF64 {
|
||||||
c: (c_re.to_f64().value(), c_im.to_f64().value()),
|
c: (c_re.to_f64(), c_im.to_f64()),
|
||||||
p: phoenix_p,
|
p: phoenix_p,
|
||||||
l: lambda_l,
|
l: lambda_l,
|
||||||
cpow: complex_power,
|
cpow: complex_power,
|
||||||
power,
|
power,
|
||||||
set_plane,
|
|
||||||
};
|
};
|
||||||
return compute_reference_f64(
|
return compute_reference_f64((z0_re.to_f64(), z0_im.to_f64()), max_iter, kind, &k, morph);
|
||||||
(z0_re.to_f64().value(), z0_im.to_f64().value()),
|
|
||||||
max_iter,
|
|
||||||
kind,
|
|
||||||
&k,
|
|
||||||
morph,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let k = StepConsts {
|
let k = StepConsts {
|
||||||
cr: c_re.clone().with_precision(precision).value(),
|
cr: c_re.clone().with_precision(precision),
|
||||||
ci: c_im.clone().with_precision(precision).value(),
|
ci: c_im.clone().with_precision(precision),
|
||||||
pr: big_from_f64(phoenix_p.0, precision),
|
pr: big_from_f64(phoenix_p.0, precision),
|
||||||
pi: big_from_f64(phoenix_p.1, precision),
|
pi: big_from_f64(phoenix_p.1, precision),
|
||||||
lr: big_from_f64(lambda_l.0, precision),
|
lr: big_from_f64(lambda_l.0, precision),
|
||||||
@@ -125,7 +158,6 @@ fn compute_reference_inner(
|
|||||||
cpow_im: big_from_f64(complex_power.1, precision),
|
cpow_im: big_from_f64(complex_power.1, precision),
|
||||||
power,
|
power,
|
||||||
precision,
|
precision,
|
||||||
set_plane,
|
|
||||||
};
|
};
|
||||||
compute_reference_big(z0_re, z0_im, max_iter, kind, &k, morph)
|
compute_reference_big(z0_re, z0_im, max_iter, kind, &k, morph)
|
||||||
}
|
}
|
||||||
@@ -137,7 +169,6 @@ struct StepConstsF64 {
|
|||||||
l: (f64, f64),
|
l: (f64, f64),
|
||||||
cpow: (f64, f64),
|
cpow: (f64, f64),
|
||||||
power: u32,
|
power: u32,
|
||||||
set_plane: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`compute_reference`]'s fast path for shallow views (see
|
/// [`compute_reference`]'s fast path for shallow views (see
|
||||||
@@ -148,12 +179,12 @@ fn compute_reference_f64(
|
|||||||
kind: FractalKind,
|
kind: FractalKind,
|
||||||
k: &StepConstsF64,
|
k: &StepConstsF64,
|
||||||
morph: Option<(FractalKind, f64)>,
|
morph: Option<(FractalKind, f64)>,
|
||||||
) -> Vec<[f32; 2]> {
|
) -> RefOrbit {
|
||||||
let (mut zr, mut zi) = z0;
|
let (mut zr, mut zi) = z0;
|
||||||
// Previous iterate, for the Phoenix two-term recurrence (Y_{-1} = 0).
|
// Previous iterate, for the Phoenix two-term recurrence (Y_{-1} = 0).
|
||||||
let mut prev = (0.0f64, 0.0f64);
|
let mut prev = (0.0f64, 0.0f64);
|
||||||
|
|
||||||
let mut points: Vec<[f32; 2]> = Vec::with_capacity(max_iter as usize + 1);
|
let mut points = RefOrbit::with_capacity(max_iter as usize + 1);
|
||||||
for _ in 0..=max_iter {
|
for _ in 0..=max_iter {
|
||||||
points.push([zr as f32, zi as f32]);
|
points.push([zr as f32, zi as f32]);
|
||||||
if zr * zr + zi * zi > REFERENCE_ESCAPE_SQ {
|
if zr * zr + zi * zi > REFERENCE_ESCAPE_SQ {
|
||||||
@@ -204,16 +235,11 @@ fn step_f64(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
FractalKind::Lambda => {
|
FractalKind::Lambda => {
|
||||||
// λ·z(1 - z) (+ c on the parameter plane).
|
// λ·z(1 - z) + c.
|
||||||
let (lr, li) = k.l;
|
let (lr, li) = k.l;
|
||||||
let (re2, im2) = (1.0 - zr, -zi);
|
let (re2, im2) = (1.0 - zr, -zi);
|
||||||
let (lzr, lzi) = (lr * zr - li * zi, lr * zi + li * zr);
|
let (lzr, lzi) = (lr * zr - li * zi, lr * zi + li * zr);
|
||||||
let (re, im) = (lzr * re2 - lzi * im2, re2 * lzi + lzr * im2);
|
(lzr * re2 - lzi * im2 + cr, re2 * lzi + lzr * im2 + ci)
|
||||||
if k.set_plane {
|
|
||||||
(re + cr, im + ci)
|
|
||||||
} else {
|
|
||||||
(re, im)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
FractalKind::ComplexMultibrot => {
|
FractalKind::ComplexMultibrot => {
|
||||||
let (pr, pi) = complex_pow_complex_f64(zr, zi, k.cpow.0, k.cpow.1);
|
let (pr, pi) = complex_pow_complex_f64(zr, zi, k.cpow.0, k.cpow.1);
|
||||||
@@ -250,12 +276,9 @@ struct StepConsts {
|
|||||||
cpow_im: Big,
|
cpow_im: Big,
|
||||||
power: u32,
|
power: u32,
|
||||||
precision: usize,
|
precision: usize,
|
||||||
/// Parameter plane: the GPU adds `dc` every step for every kind, so the
|
|
||||||
/// Lambda map (which has no `c` of its own) is `λ·z(1 - z) + c` there.
|
|
||||||
set_plane: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`compute_reference`] at arbitrary precision (`FBig`), for deep views.
|
/// [`compute_reference`] at arbitrary precision ([`Big`]), for deep views.
|
||||||
fn compute_reference_big(
|
fn compute_reference_big(
|
||||||
z0_re: &Big,
|
z0_re: &Big,
|
||||||
z0_im: &Big,
|
z0_im: &Big,
|
||||||
@@ -263,23 +286,22 @@ fn compute_reference_big(
|
|||||||
kind: FractalKind,
|
kind: FractalKind,
|
||||||
k: &StepConsts,
|
k: &StepConsts,
|
||||||
morph: Option<(FractalKind, f64)>,
|
morph: Option<(FractalKind, f64)>,
|
||||||
) -> Vec<[f32; 2]> {
|
) -> RefOrbit {
|
||||||
let precision = k.precision;
|
let precision = k.precision;
|
||||||
let morph = morph.map(|(from, w)| (from, big_from_f64(w, precision)));
|
let morph = morph.map(|(from, w)| (from, big_from_f64(w, precision)));
|
||||||
|
|
||||||
let mut zr = z0_re.clone().with_precision(precision).value();
|
let mut zr = z0_re.clone().with_precision(precision);
|
||||||
let mut zi = z0_im.clone().with_precision(precision).value();
|
let mut zi = z0_im.clone().with_precision(precision);
|
||||||
// Previous iterate, for the Phoenix two-term recurrence (Y_{-1} = 0).
|
// Previous iterate, for the Phoenix two-term recurrence (Y_{-1} = 0).
|
||||||
let mut zr_prev = big_zero(precision);
|
let mut zr_prev = Big::zero(precision);
|
||||||
let mut zi_prev = big_zero(precision);
|
let mut zi_prev = Big::zero(precision);
|
||||||
|
|
||||||
let mut points: Vec<[f32; 2]> = Vec::with_capacity(max_iter as usize + 1);
|
let mut points = RefOrbit::with_capacity(max_iter as usize + 1);
|
||||||
|
|
||||||
for _ in 0..=max_iter {
|
for _ in 0..=max_iter {
|
||||||
let fr = zr.to_f64().value() as f32;
|
push_big_point(&mut points, &zr, &zi);
|
||||||
let fi = zi.to_f64().value() as f32;
|
|
||||||
points.push([fr, fi]);
|
|
||||||
|
|
||||||
|
let [fr, fi] = *points.points.last().unwrap();
|
||||||
let mag = (fr as f64) * (fr as f64) + (fi as f64) * (fi as f64);
|
let mag = (fr as f64) * (fr as f64) + (fi as f64) * (fi as f64);
|
||||||
if mag > REFERENCE_ESCAPE_SQ {
|
if mag > REFERENCE_ESCAPE_SQ {
|
||||||
break;
|
break;
|
||||||
@@ -296,8 +318,8 @@ fn compute_reference_big(
|
|||||||
// Shift the previous iterate (only the Phoenix arm reads it).
|
// Shift the previous iterate (only the Phoenix arm reads it).
|
||||||
zr_prev = zr;
|
zr_prev = zr;
|
||||||
zi_prev = zi;
|
zi_prev = zi;
|
||||||
zr = new_zr.with_precision(precision).value();
|
zr = new_zr.with_precision(precision);
|
||||||
zi = new_zi.with_precision(precision).value();
|
zi = new_zi.with_precision(precision);
|
||||||
}
|
}
|
||||||
|
|
||||||
points
|
points
|
||||||
@@ -325,7 +347,7 @@ fn step(
|
|||||||
FractalKind::BurningShip => {
|
FractalKind::BurningShip => {
|
||||||
// (|zr| + i|zi|)^2 = (zr^2 - zi^2) + 2|zr zi| i.
|
// (|zr| + i|zi|)^2 = (zr^2 - zi^2) + 2|zr zi| i.
|
||||||
let re = &zr.sqr() - &zi.sqr() + cr;
|
let re = &zr.sqr() - &zi.sqr() + cr;
|
||||||
let im = big_abs((zr * zi) << 1) + ci;
|
let im = ((zr * zi) << 1).abs() + ci;
|
||||||
(re, im)
|
(re, im)
|
||||||
}
|
}
|
||||||
FractalKind::Tricorn => {
|
FractalKind::Tricorn => {
|
||||||
@@ -340,14 +362,14 @@ fn step(
|
|||||||
}
|
}
|
||||||
FractalKind::Celtic => {
|
FractalKind::Celtic => {
|
||||||
// |Re(z^2)| + i·Im(z^2): abs the real output of the square.
|
// |Re(z^2)| + i·Im(z^2): abs the real output of the square.
|
||||||
let re = big_abs(&zr.sqr() - &zi.sqr()) + cr;
|
let re = (&zr.sqr() - &zi.sqr()).abs() + cr;
|
||||||
let im = ((zr * zi) << 1) + ci;
|
let im = ((zr * zi) << 1) + ci;
|
||||||
(re, im)
|
(re, im)
|
||||||
}
|
}
|
||||||
FractalKind::Perpendicular => {
|
FractalKind::Perpendicular => {
|
||||||
// (x^2 - y^2) - 2·x·|y| i: abs the imaginary input.
|
// (x^2 - y^2) - 2·x·|y| i: abs the imaginary input.
|
||||||
let re = &zr.sqr() - &zi.sqr() + cr;
|
let re = &zr.sqr() - &zi.sqr() + cr;
|
||||||
let im = if zi.to_f64().value() < 0.0 {
|
let im = if zi.is_negative() {
|
||||||
ci + ((zr * zi) << 1)
|
ci + ((zr * zi) << 1)
|
||||||
} else {
|
} else {
|
||||||
ci - ((zr * zi) << 1)
|
ci - ((zr * zi) << 1)
|
||||||
@@ -356,8 +378,8 @@ fn step(
|
|||||||
}
|
}
|
||||||
FractalKind::Buffalo => {
|
FractalKind::Buffalo => {
|
||||||
// |Re(z^2)| - |Im(z^2)| i: abs both outputs.
|
// |Re(z^2)| - |Im(z^2)| i: abs both outputs.
|
||||||
let re = big_abs(&zr.sqr() - &zi.sqr()) + cr;
|
let re = (&zr.sqr() - &zi.sqr()).abs() + cr;
|
||||||
let im = ci - big_abs((zr * zi) << 1);
|
let im = ci - ((zr * zi) << 1).abs();
|
||||||
(re, im)
|
(re, im)
|
||||||
}
|
}
|
||||||
FractalKind::Phoenix => {
|
FractalKind::Phoenix => {
|
||||||
@@ -369,18 +391,14 @@ fn step(
|
|||||||
(re2 + cr + pzr, im2 + ci + pzi)
|
(re2 + cr + pzr, im2 + ci + pzi)
|
||||||
}
|
}
|
||||||
FractalKind::Lambda => {
|
FractalKind::Lambda => {
|
||||||
// λ·z(1 - z): logistic map (+ c on the parameter plane).
|
// λ·z(1 - z) + c: logistic map plus the usual additive `c`.
|
||||||
let re2 = 1 - zr;
|
let re2 = Big::from_f64(1.0, k.precision) - zr;
|
||||||
let im2 = -zi;
|
let im2 = -zi;
|
||||||
let lzr = &k.lr * zr - &k.li * zi;
|
let lzr = &k.lr * zr - &k.li * zi;
|
||||||
let lzi = &k.lr * zi + &k.li * zr;
|
let lzi = &k.lr * zi + &k.li * zr;
|
||||||
let re = &lzr * &re2 - &lzi * &im2;
|
let re = &lzr * &re2 - &lzi * &im2;
|
||||||
let im = re2 * lzi + lzr * im2;
|
let im = re2 * lzi + lzr * im2;
|
||||||
if k.set_plane {
|
|
||||||
(re + cr, im + ci)
|
(re + cr, im + ci)
|
||||||
} else {
|
|
||||||
(re, im)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
FractalKind::ComplexMultibrot => {
|
FractalKind::ComplexMultibrot => {
|
||||||
let (pr, pi) = complex_pow_complex(zr, zi, &k.cpow_re, &k.cpow_im, k.precision);
|
let (pr, pi) = complex_pow_complex(zr, zi, &k.cpow_re, &k.cpow_im, k.precision);
|
||||||
@@ -389,36 +407,20 @@ fn step(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn big_zero(precision: usize) -> Big {
|
|
||||||
Big::from(0i32).with_precision(precision).value()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Absolute value of a `Big`. The sign check via f64 is exact except for values
|
|
||||||
/// so tiny that |x| ≈ x either way — negligible against the f32 orbit storage.
|
|
||||||
fn big_abs(x: Big) -> Big {
|
|
||||||
if x.to_f64().value() < 0.0 { -x } else { x }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `(zr + i zi)^power` by repeated complex multiply at `precision` bits.
|
/// `(zr + i zi)^power` by repeated complex multiply at `precision` bits.
|
||||||
fn complex_pow(zr: &Big, zi: &Big, power: u32, precision: usize) -> (Big, Big) {
|
fn complex_pow(zr: &Big, zi: &Big, power: u32, precision: usize) -> (Big, Big) {
|
||||||
let mut rr = Big::from(1i32).with_precision(precision).value();
|
let mut rr = Big::from_f64(1.0, precision);
|
||||||
let mut ri = big_zero(precision);
|
let mut ri = Big::zero(precision);
|
||||||
for _ in 0..power {
|
for _ in 0..power {
|
||||||
// (rr + i ri)(zr + i zi) = (rr zr - ri zi) + (rr zi + ri zr) i.
|
// (rr + i ri)(zr + i zi) = (rr zr - ri zi) + (rr zi + ri zr) i.
|
||||||
let nr = (&rr * zr - &ri * zi).with_precision(precision).value();
|
let nr = (&rr * zr - &ri * zi).with_precision(precision);
|
||||||
let ni = (&rr * zi + &ri * zr).with_precision(precision).value();
|
let ni = (&rr * zi + &ri * zr).with_precision(precision);
|
||||||
rr = nr;
|
rr = nr;
|
||||||
ri = ni;
|
ri = ni;
|
||||||
}
|
}
|
||||||
(rr, ri)
|
(rr, ri)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `true` if `x` is (numerically) zero. The f64 check is exact for a true
|
|
||||||
/// zero; only matters here to special-case `ln(0)`.
|
|
||||||
fn is_big_zero(x: &Big) -> bool {
|
|
||||||
x.to_f64().value() == 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `(zr + i zi)^(pr + i pi)` for a complex exponent, via the principal branch
|
/// `(zr + i zi)^(pr + i pi)` for a complex exponent, via the principal branch
|
||||||
/// `z^p = exp(p·ln z)` where `ln z = ln|z| + i·arg(z)`. Used by
|
/// `z^p = exp(p·ln z)` where `ln z = ln|z| + i·arg(z)`. Used by
|
||||||
/// `ComplexMultibrot`; must be kept in sync with the shader's `cpow`.
|
/// `ComplexMultibrot`; must be kept in sync with the shader's `cpow`.
|
||||||
@@ -426,14 +428,14 @@ fn is_big_zero(x: &Big) -> bool {
|
|||||||
/// panic; this is the correct limit for the `Re(p) > 0` region the UI
|
/// panic; this is the correct limit for the `Re(p) > 0` region the UI
|
||||||
/// exposes).
|
/// exposes).
|
||||||
fn complex_pow_complex(zr: &Big, zi: &Big, pr: &Big, pi: &Big, precision: usize) -> (Big, Big) {
|
fn complex_pow_complex(zr: &Big, zi: &Big, pr: &Big, pi: &Big, precision: usize) -> (Big, Big) {
|
||||||
if is_big_zero(zr) && is_big_zero(zi) {
|
if zr.is_zero() && zi.is_zero() {
|
||||||
return (big_zero(precision), big_zero(precision));
|
return (Big::zero(precision), Big::zero(precision));
|
||||||
}
|
}
|
||||||
let r2 = &zr.sqr() + &zi.sqr();
|
let r2 = &zr.sqr() + &zi.sqr();
|
||||||
let ln_r = r2.ln() >> 1; // 0.5 * ln(r2) = ln(sqrt(r2)); exact halving.
|
let ln_r = r2.ln() >> 1; // 0.5 * ln(r2) = ln(sqrt(r2)); exact halving.
|
||||||
let theta = zi.atan2(zr);
|
let theta = zi.atan2(zr);
|
||||||
let exp_re = (pr * &ln_r - pi * &theta).with_precision(precision).value();
|
let exp_re = (pr * &ln_r - pi * &theta).with_precision(precision);
|
||||||
let exp_im = (pr * &theta + pi * &ln_r).with_precision(precision).value();
|
let exp_im = (pr * &theta + pi * &ln_r).with_precision(precision);
|
||||||
let mag = exp_re.exp();
|
let mag = exp_re.exp();
|
||||||
let (sin_a, cos_a) = exp_im.sin_cos();
|
let (sin_a, cos_a) = exp_im.sin_cos();
|
||||||
(&mag * &cos_a, &mag * &sin_a)
|
(&mag * &cos_a, &mag * &sin_a)
|
||||||
@@ -453,9 +455,9 @@ pub fn compute_set_reference(
|
|||||||
lambda_l: (f64, f64),
|
lambda_l: (f64, f64),
|
||||||
complex_power: (f64, f64),
|
complex_power: (f64, f64),
|
||||||
morph: Option<(FractalKind, f64)>,
|
morph: Option<(FractalKind, f64)>,
|
||||||
) -> Vec<[f32; 2]> {
|
) -> RefOrbit {
|
||||||
let zero = big_zero(precision);
|
let zero = Big::zero(precision);
|
||||||
compute_reference_inner(
|
compute_reference(
|
||||||
&zero,
|
&zero,
|
||||||
&zero,
|
&zero,
|
||||||
center_re,
|
center_re,
|
||||||
@@ -468,7 +470,6 @@ pub fn compute_set_reference(
|
|||||||
lambda_l,
|
lambda_l,
|
||||||
complex_power,
|
complex_power,
|
||||||
morph,
|
morph,
|
||||||
true,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,12 +477,23 @@ pub fn compute_set_reference(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Sign and zero tests must hold far below f64's range, where
|
||||||
|
/// `to_f64` reads as ±0.
|
||||||
|
#[test]
|
||||||
|
fn sign_and_zero_below_f64_range() {
|
||||||
|
let tiny = Big::from_f64(1.0, 64) >> 5000;
|
||||||
|
assert!(!tiny.is_zero());
|
||||||
|
assert!(Big::zero(64).is_zero());
|
||||||
|
assert_eq!((-tiny.clone()).abs(), tiny);
|
||||||
|
assert_eq!(tiny.clone().abs(), tiny);
|
||||||
|
}
|
||||||
|
|
||||||
/// The high-precision reference must agree with a plain f64 iteration for a
|
/// The high-precision reference must agree with a plain f64 iteration for a
|
||||||
/// shallow point (where f64 is accurate).
|
/// shallow point (where f64 is accurate).
|
||||||
#[test]
|
#[test]
|
||||||
fn reference_matches_naive_f64() {
|
fn reference_matches_naive_f64() {
|
||||||
let cr = Big::try_from(-0.75_f64).unwrap();
|
let cr = Big::from_f64(-0.75, 53);
|
||||||
let ci = Big::try_from(0.1_f64).unwrap();
|
let ci = Big::from_f64(0.1, 53);
|
||||||
let points = compute_set_reference(
|
let points = compute_set_reference(
|
||||||
&cr,
|
&cr,
|
||||||
&ci,
|
&ci,
|
||||||
@@ -525,13 +537,17 @@ mod tests {
|
|||||||
fn f64_fast_path_matches_big() {
|
fn f64_fast_path_matches_big() {
|
||||||
let bits_fast = F64_MAX_PRECISION;
|
let bits_fast = F64_MAX_PRECISION;
|
||||||
let bits_big = F64_MAX_PRECISION + 64;
|
let bits_big = F64_MAX_PRECISION + 64;
|
||||||
for kind in FractalKind::ALL {
|
let cases = FractalKind::ALL
|
||||||
|
.into_iter()
|
||||||
|
.map(|kind| (kind, 3))
|
||||||
|
.chain([(FractalKind::Multibrot, 20)]); // highest supported power
|
||||||
|
for (kind, power) in cases {
|
||||||
for julia in [false, true] {
|
for julia in [false, true] {
|
||||||
for morph in [None, Some((FractalKind::Phoenix, 0.3))] {
|
for morph in [None, Some((FractalKind::Phoenix, 0.3))] {
|
||||||
let run = |bits: usize| {
|
let run = |bits: usize| {
|
||||||
let (a, b) = (big_from_f64(-0.3, bits), big_from_f64(0.2, bits));
|
let (a, b) = (big_from_f64(-0.3, bits), big_from_f64(0.2, bits));
|
||||||
let (jr, ji) = (big_from_f64(-0.4, bits), big_from_f64(0.55, bits));
|
let (jr, ji) = (big_from_f64(-0.4, bits), big_from_f64(0.55, bits));
|
||||||
let args = (60, bits, kind, 3, (0.1, -0.2), (0.9, 0.3), (2.3, 0.4));
|
let args = (60, bits, kind, power, (0.1, -0.2), (0.9, 0.3), (2.3, 0.4));
|
||||||
if julia {
|
if julia {
|
||||||
compute_reference(
|
compute_reference(
|
||||||
&a, &b, &jr, &ji, args.0, args.1, args.2, args.3, args.4, args.5,
|
&a, &b, &jr, &ji, args.0, args.1, args.2, args.3, args.4, args.5,
|
||||||
@@ -544,7 +560,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let ctx = format!("{kind:?} julia={julia} morph={morph:?}");
|
let ctx = format!("{kind:?} power={power} julia={julia} morph={morph:?}");
|
||||||
let (fast, big) = (run(bits_fast), run(bits_big));
|
let (fast, big) = (run(bits_fast), run(bits_big));
|
||||||
assert_eq!(fast.len(), big.len(), "{ctx}: length");
|
assert_eq!(fast.len(), big.len(), "{ctx}: length");
|
||||||
for (i, (f, b)) in fast.iter().zip(&big).enumerate() {
|
for (i, (f, b)) in fast.iter().zip(&big).enumerate() {
|
||||||
@@ -561,11 +577,57 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Orbit points below f32's range are stored as a normalized mantissa
|
||||||
|
/// plus exponent (for the deep GPU phase); every other point stays a
|
||||||
|
/// plain f32 with exponent 0.
|
||||||
|
#[test]
|
||||||
|
fn tiny_points_are_stored_normalized() {
|
||||||
|
let bits = 400;
|
||||||
|
// c = -1 + δ: X_2 = c(c + 1) = -δ + δ², far below f32's range.
|
||||||
|
let delta = 1e-45_f64;
|
||||||
|
let cr = big_from_f64(-1.0, bits) + big_from_f64(delta, bits);
|
||||||
|
let ci = big_from_f64(0.0, bits);
|
||||||
|
let orbit = compute_set_reference(
|
||||||
|
&cr,
|
||||||
|
&ci,
|
||||||
|
3,
|
||||||
|
bits,
|
||||||
|
FractalKind::Mandelbrot,
|
||||||
|
2,
|
||||||
|
(0.0, 0.0),
|
||||||
|
(0.0, 0.0),
|
||||||
|
(0.0, 0.0),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(orbit.exps.len(), orbit.points.len());
|
||||||
|
assert!(orbit.has_scaled());
|
||||||
|
assert_eq!(&orbit.exps[..2], &[0, 0], "X_0 = 0 and X_1 = c are plain");
|
||||||
|
let [mr, mi] = orbit.points[2];
|
||||||
|
let k = orbit.exps[2];
|
||||||
|
assert!(k < TINY_LOG2, "exponent {k}");
|
||||||
|
assert!(
|
||||||
|
(0.5..1.0).contains(&mr.abs()),
|
||||||
|
"mantissa {mr} not normalized"
|
||||||
|
);
|
||||||
|
assert_eq!(mi, 0.0);
|
||||||
|
let x2 = mr as f64 * 2f64.powi(k);
|
||||||
|
assert!(
|
||||||
|
(x2 + delta).abs() < 1e-6 * delta,
|
||||||
|
"X_2 = {x2}, expected {}",
|
||||||
|
-delta
|
||||||
|
);
|
||||||
|
|
||||||
|
// A shallow orbit stays entirely plain.
|
||||||
|
let plain = set_ref(-0.75, 0.1, FractalKind::Mandelbrot, None);
|
||||||
|
assert!(!plain.has_scaled());
|
||||||
|
assert!(plain.exps.iter().all(|&e| e == 0));
|
||||||
|
}
|
||||||
|
|
||||||
/// A point inside the main cardioid never escapes: full-length orbit.
|
/// A point inside the main cardioid never escapes: full-length orbit.
|
||||||
#[test]
|
#[test]
|
||||||
fn interior_orbit_runs_full_length() {
|
fn interior_orbit_runs_full_length() {
|
||||||
let cr = Big::try_from(-0.2_f64).unwrap();
|
let cr = Big::from_f64(-0.2, 53);
|
||||||
let ci = Big::try_from(0.0_f64).unwrap();
|
let ci = Big::from_f64(0.0, 53);
|
||||||
let points = compute_set_reference(
|
let points = compute_set_reference(
|
||||||
&cr,
|
&cr,
|
||||||
&ci,
|
&ci,
|
||||||
@@ -584,8 +646,8 @@ mod tests {
|
|||||||
/// Burning Ship reference matches a naive f64 iteration of the same formula.
|
/// Burning Ship reference matches a naive f64 iteration of the same formula.
|
||||||
#[test]
|
#[test]
|
||||||
fn burning_ship_reference_matches_naive_f64() {
|
fn burning_ship_reference_matches_naive_f64() {
|
||||||
let cr = Big::try_from(-1.75_f64).unwrap();
|
let cr = Big::from_f64(-1.75, 53);
|
||||||
let ci = Big::try_from(-0.03_f64).unwrap();
|
let ci = Big::from_f64(-0.03, 53);
|
||||||
let points = compute_set_reference(
|
let points = compute_set_reference(
|
||||||
&cr,
|
&cr,
|
||||||
&ci,
|
&ci,
|
||||||
@@ -615,8 +677,8 @@ mod tests {
|
|||||||
/// Multibrot (power 3) reference matches a naive f64 cube iteration.
|
/// Multibrot (power 3) reference matches a naive f64 cube iteration.
|
||||||
#[test]
|
#[test]
|
||||||
fn multibrot3_reference_matches_naive_f64() {
|
fn multibrot3_reference_matches_naive_f64() {
|
||||||
let cr = Big::try_from(0.3_f64).unwrap();
|
let cr = Big::from_f64(0.3, 53);
|
||||||
let ci = Big::try_from(0.2_f64).unwrap();
|
let ci = Big::from_f64(0.2, 53);
|
||||||
let points = compute_set_reference(
|
let points = compute_set_reference(
|
||||||
&cr,
|
&cr,
|
||||||
&ci,
|
&ci,
|
||||||
@@ -648,10 +710,10 @@ mod tests {
|
|||||||
/// Julia orbit (fixed c, z0 = center) matches a naive f64 iteration.
|
/// Julia orbit (fixed c, z0 = center) matches a naive f64 iteration.
|
||||||
#[test]
|
#[test]
|
||||||
fn julia_reference_matches_naive_f64() {
|
fn julia_reference_matches_naive_f64() {
|
||||||
let z0_re = Big::try_from(0.15_f64).unwrap();
|
let z0_re = Big::from_f64(0.15, 53);
|
||||||
let z0_im = Big::try_from(-0.1_f64).unwrap();
|
let z0_im = Big::from_f64(-0.1, 53);
|
||||||
let c_re = Big::try_from(-0.8_f64).unwrap();
|
let c_re = Big::from_f64(-0.8, 53);
|
||||||
let c_im = Big::try_from(0.156_f64).unwrap();
|
let c_im = Big::from_f64(0.156, 53);
|
||||||
let points = compute_reference(
|
let points = compute_reference(
|
||||||
&z0_re,
|
&z0_re,
|
||||||
&z0_im,
|
&z0_im,
|
||||||
@@ -680,11 +742,43 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lambda Julia orbit adds the Julia `c`: `z -> λ·z(1 - z) + c`.
|
||||||
|
#[test]
|
||||||
|
fn lambda_julia_reference_matches_naive_f64() {
|
||||||
|
let (lr, li) = (-0.5_f64, 0.2_f64);
|
||||||
|
let (cr, ci) = (0.1_f64, -0.3_f64);
|
||||||
|
let points = compute_reference(
|
||||||
|
&Big::from_f64(0.2, 53),
|
||||||
|
&Big::from_f64(0.1, 53),
|
||||||
|
&Big::from_f64(cr, 53),
|
||||||
|
&Big::from_f64(ci, 53),
|
||||||
|
60,
|
||||||
|
200,
|
||||||
|
FractalKind::Lambda,
|
||||||
|
2,
|
||||||
|
(0.0, 0.0),
|
||||||
|
(lr, li),
|
||||||
|
(0.0, 0.0),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let (mut zr, mut zi) = (0.2_f64, 0.1_f64);
|
||||||
|
for point in &points {
|
||||||
|
let tol = 1e-4 * (1.0 + zr.abs().max(zi.abs()));
|
||||||
|
assert!((point[0] as f64 - zr).abs() < tol, "{point:?} vs {zr}");
|
||||||
|
assert!((point[1] as f64 - zi).abs() < tol, "{point:?} vs {zi}");
|
||||||
|
let (lzr, lzi) = (lr * zr - li * zi, lr * zi + li * zr);
|
||||||
|
let (ar, ai) = (1.0 - zr, -zi);
|
||||||
|
zr = lzr * ar - lzi * ai + cr;
|
||||||
|
zi = lzr * ai + lzi * ar + ci;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Celtic reference matches a naive f64 iteration: real = |x^2 - y^2| + cr.
|
/// Celtic reference matches a naive f64 iteration: real = |x^2 - y^2| + cr.
|
||||||
#[test]
|
#[test]
|
||||||
fn celtic_reference_matches_naive_f64() {
|
fn celtic_reference_matches_naive_f64() {
|
||||||
let cr = Big::try_from(-0.6_f64).unwrap();
|
let cr = Big::from_f64(-0.6, 53);
|
||||||
let ci = Big::try_from(0.4_f64).unwrap();
|
let ci = Big::from_f64(0.4, 53);
|
||||||
let points = compute_set_reference(
|
let points = compute_set_reference(
|
||||||
&cr,
|
&cr,
|
||||||
&ci,
|
&ci,
|
||||||
@@ -715,8 +809,8 @@ mod tests {
|
|||||||
/// real = x^2 - y^2 + cr, imag = -2·x·|y| + ci.
|
/// real = x^2 - y^2 + cr, imag = -2·x·|y| + ci.
|
||||||
#[test]
|
#[test]
|
||||||
fn perpendicular_reference_matches_naive_f64() {
|
fn perpendicular_reference_matches_naive_f64() {
|
||||||
let cr = Big::try_from(-0.7_f64).unwrap();
|
let cr = Big::from_f64(-0.7, 53);
|
||||||
let ci = Big::try_from(-0.2_f64).unwrap();
|
let ci = Big::from_f64(-0.2, 53);
|
||||||
let points = compute_set_reference(
|
let points = compute_set_reference(
|
||||||
&cr,
|
&cr,
|
||||||
&ci,
|
&ci,
|
||||||
@@ -747,8 +841,8 @@ mod tests {
|
|||||||
/// real = |x^2 - y^2| + cr, imag = -|2·x·y| + ci.
|
/// real = |x^2 - y^2| + cr, imag = -|2·x·y| + ci.
|
||||||
#[test]
|
#[test]
|
||||||
fn buffalo_reference_matches_naive_f64() {
|
fn buffalo_reference_matches_naive_f64() {
|
||||||
let cr = Big::try_from(-1.2_f64).unwrap();
|
let cr = Big::from_f64(-1.2, 53);
|
||||||
let ci = Big::try_from(-0.35_f64).unwrap();
|
let ci = Big::from_f64(-0.35, 53);
|
||||||
let points = compute_set_reference(
|
let points = compute_set_reference(
|
||||||
&cr,
|
&cr,
|
||||||
&ci,
|
&ci,
|
||||||
@@ -779,8 +873,8 @@ mod tests {
|
|||||||
/// `z_{n+1} = z_n^2 + c + p·z_{n-1}` (z_0 = 0, z_{-1} = 0).
|
/// `z_{n+1} = z_n^2 + c + p·z_{n-1}` (z_0 = 0, z_{-1} = 0).
|
||||||
#[test]
|
#[test]
|
||||||
fn phoenix_reference_matches_naive_f64() {
|
fn phoenix_reference_matches_naive_f64() {
|
||||||
let cr = Big::try_from(0.5667_f64).unwrap();
|
let cr = Big::from_f64(0.5667, 53);
|
||||||
let ci = Big::try_from(0.0_f64).unwrap();
|
let ci = Big::from_f64(0.0, 53);
|
||||||
let p = (-0.5_f64, 0.0_f64);
|
let p = (-0.5_f64, 0.0_f64);
|
||||||
let points = compute_set_reference(
|
let points = compute_set_reference(
|
||||||
&cr,
|
&cr,
|
||||||
@@ -818,8 +912,8 @@ mod tests {
|
|||||||
/// iteration of `z^p = exp(p·ln z)`.
|
/// iteration of `z^p = exp(p·ln z)`.
|
||||||
#[test]
|
#[test]
|
||||||
fn complex_multibrot_reference_matches_naive_f64() {
|
fn complex_multibrot_reference_matches_naive_f64() {
|
||||||
let cr = Big::try_from(0.1_f64).unwrap();
|
let cr = Big::from_f64(0.1, 53);
|
||||||
let ci = Big::try_from(-0.2_f64).unwrap();
|
let ci = Big::from_f64(-0.2, 53);
|
||||||
let power = (2.5_f64, 0.3_f64);
|
let power = (2.5_f64, 0.3_f64);
|
||||||
let points = compute_set_reference(
|
let points = compute_set_reference(
|
||||||
&cr,
|
&cr,
|
||||||
@@ -861,15 +955,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_ref(
|
fn set_ref(cr: f64, ci: f64, kind: FractalKind, morph: Option<(FractalKind, f64)>) -> RefOrbit {
|
||||||
cr: f64,
|
|
||||||
ci: f64,
|
|
||||||
kind: FractalKind,
|
|
||||||
morph: Option<(FractalKind, f64)>,
|
|
||||||
) -> Vec<[f32; 2]> {
|
|
||||||
compute_set_reference(
|
compute_set_reference(
|
||||||
&Big::try_from(cr).unwrap(),
|
&Big::from_f64(cr, 53),
|
||||||
&Big::try_from(ci).unwrap(),
|
&Big::from_f64(ci, 53),
|
||||||
60,
|
60,
|
||||||
200,
|
200,
|
||||||
kind,
|
kind,
|
||||||
|
|||||||
+924
-101
File diff suppressed because it is too large
Load Diff
+15
-4
@@ -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,7 +19,7 @@ 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).
|
/// Distortion constant for the Phoenix kind (ignored by others).
|
||||||
@@ -115,7 +116,7 @@ mod tests {
|
|||||||
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),
|
phoenix_p: (-0.5, 0.1),
|
||||||
@@ -146,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+424
-82
@@ -3,13 +3,21 @@
|
|||||||
// event loop, no worker-thread debounce (nothing to debounce for a one-shot
|
// 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
|
// render); it just creates its own wgpu device, computes the reference orbit
|
||||||
// once, and renders through the same `ExportRender` path the "Export PNG"
|
// once, and renders through the same `ExportRender` path the "Export PNG"
|
||||||
// button uses.
|
// 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 eframe::egui_wgpu::wgpu;
|
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, unix_timestamp};
|
use crate::app::{FractalApp, RefJob, parse_complex_pair, unix_timestamp};
|
||||||
use crate::cli::Cli;
|
use crate::cli::Cli;
|
||||||
use crate::fractal::{ExportRender, FractalRenderer, ShareState, export_to_png_blocking};
|
use crate::fractal::{
|
||||||
|
ExportRender, FractalKind, FractalRenderer, PipelineKey, ShareState, encode_png,
|
||||||
|
export_to_png_blocking, render_readback_blocking, unpad_rgba,
|
||||||
|
};
|
||||||
use crate::view::{
|
use crate::view::{
|
||||||
ViewState, big_from_decimal_str, interpolate_f64, interpolate_view, parse_view_spec,
|
ViewState, big_from_decimal_str, interpolate_f64, interpolate_view, parse_view_spec,
|
||||||
precision_for,
|
precision_for,
|
||||||
@@ -18,6 +26,21 @@ use crate::view::{
|
|||||||
/// Cap on the output image dimension (px), to stay within GPU texture limits.
|
/// Cap on the output image dimension (px), to stay within GPU texture limits.
|
||||||
const MAX_DIM: u32 = 8192 * 16;
|
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> {
|
pub fn run(cli: Cli) -> Result<(), String> {
|
||||||
if cli.buddhabrot {
|
if cli.buddhabrot {
|
||||||
return Err("headless mode doesn't support --buddhabrot yet".into());
|
return Err("headless mode doesn't support --buddhabrot yet".into());
|
||||||
@@ -28,32 +51,22 @@ pub fn run(cli: Cli) -> Result<(), String> {
|
|||||||
|
|
||||||
// These drive the animation path below; grab them before `apply_cli`
|
// These drive the animation path below; grab them before `apply_cli`
|
||||||
// consumes `cli` to build the start state.
|
// consumes `cli` to build the start state.
|
||||||
let to_view = cli.to_view.clone();
|
let targets = AnimTargets::from_cli(&cli)?;
|
||||||
let to_share = cli.to_share.clone();
|
|
||||||
let to_iterations = cli.to_iterations;
|
|
||||||
let frames_arg = cli.frames;
|
|
||||||
let fps = cli.fps;
|
|
||||||
let duration = cli.duration;
|
|
||||||
let linear = cli.linear;
|
|
||||||
let export_path = cli.export_path.clone();
|
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();
|
let mut app = FractalApp::default_state();
|
||||||
app.apply_cli(cli);
|
app.apply_cli(cli);
|
||||||
|
app.set_output_size(width, height);
|
||||||
|
|
||||||
if to_view.is_some() || to_share.is_some() {
|
if targets.any() {
|
||||||
return run_animation(
|
return run_animation(app, targets, width, height, export_path);
|
||||||
app,
|
|
||||||
to_view,
|
|
||||||
to_share,
|
|
||||||
to_iterations,
|
|
||||||
frames_arg,
|
|
||||||
fps,
|
|
||||||
duration,
|
|
||||||
linear,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
export_path,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let export_path = export_path.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp()));
|
let export_path = export_path.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp()));
|
||||||
@@ -64,15 +77,13 @@ pub fn run(cli: Cli) -> Result<(), String> {
|
|||||||
let (device, queue) = pollster::block_on(request_device())?;
|
let (device, queue) = pollster::block_on(request_device())?;
|
||||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||||
let renderer = FractalRenderer::new(&device, format);
|
let renderer = FractalRenderer::new(&device, format);
|
||||||
let uniforms = app.make_uniforms(width as f64 / height as f64);
|
let uniforms = app.make_uniforms(width as f64 / height as f64, height as f64);
|
||||||
let (pipeline, bind_group_layout, format) = renderer.export_handles(&device, &uniforms);
|
let handles = renderer.export_handles(&device, &uniforms);
|
||||||
|
|
||||||
let er = ExportRender::new(
|
let er = ExportRender::new(
|
||||||
&device,
|
&device,
|
||||||
&queue,
|
&queue,
|
||||||
pipeline,
|
&handles,
|
||||||
&bind_group_layout,
|
|
||||||
format,
|
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
uniforms,
|
uniforms,
|
||||||
@@ -86,48 +97,140 @@ pub fn run(cli: Cli) -> Result<(), String> {
|
|||||||
});
|
});
|
||||||
eprintln!();
|
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}"))?;
|
std::fs::write(&export_path, &png).map_err(|e| format!("save failed: {e}"))?;
|
||||||
println!("saved {export_path} ({width}×{height})");
|
println!("saved {export_path} ({width}×{height})");
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a sequence of frames sweeping the camera from the app's current
|
/// The `--to-*` end state of a headless animation, plus its pacing. Each
|
||||||
/// (start) view to an end view, for feeding into ffmpeg. Everything other
|
/// target is optional; anything left unset stays at its start value.
|
||||||
/// than the view (kind, colors, iteration cap policy, ...) stays fixed at
|
struct AnimTargets {
|
||||||
/// whatever `apply_cli` set up for the start; only the camera moves.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn run_animation(
|
|
||||||
mut app: FractalApp,
|
|
||||||
to_view: Option<String>,
|
to_view: Option<String>,
|
||||||
to_share: Option<String>,
|
to_share: Option<String>,
|
||||||
mut to_iterations: Option<u32>,
|
to_iterations: Option<u32>,
|
||||||
frames_arg: 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,
|
fps: f64,
|
||||||
duration: Option<f64>,
|
duration: Option<f64>,
|
||||||
linear: bool,
|
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,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
export_path: Option<String>,
|
export_path: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let frames = match frames_arg {
|
let fps = targets.fps;
|
||||||
|
let frames = match targets.frames {
|
||||||
Some(n) => n,
|
Some(n) => n,
|
||||||
None => {
|
None => {
|
||||||
let dur = duration.ok_or("animation needs --frames, or --duration (with --fps)")?;
|
let dur = targets
|
||||||
|
.duration
|
||||||
|
.ok_or("animation needs --frames, or --duration (with --fps)")?;
|
||||||
((fps * dur).round() as u32).max(2)
|
((fps * dur).round() as u32).max(2)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if frames < 2 {
|
if frames < 2 {
|
||||||
return Err("animation needs at least 2 frames".into());
|
return Err("animation needs at least 2 frames".into());
|
||||||
}
|
}
|
||||||
|
// Frames are still timed against the whole animation (`apply_frame` takes
|
||||||
let (to, to_iterations_share) =
|
// the global index); a shard only picks which of them this run renders.
|
||||||
parse_animation_target(to_view.as_deref(), to_share.as_deref())?;
|
let range = match targets.shard {
|
||||||
if to_iterations.is_none()
|
Some((_, n)) if n > frames => {
|
||||||
&& let Some(to_iterations_share) = to_iterations_share
|
return Err(format!("--shards {n} is more than the {frames} frames"));
|
||||||
{
|
|
||||||
to_iterations = Some(to_iterations_share);
|
|
||||||
}
|
}
|
||||||
|
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 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();
|
let from_iterations = app.max_iterations();
|
||||||
if to_iterations.is_none() {
|
if to_iterations.is_none() {
|
||||||
// Iteration count auto-scales with zoom depth per frame, the same way it
|
// Iteration count auto-scales with zoom depth per frame, the same way it
|
||||||
@@ -135,77 +238,281 @@ fn run_animation(
|
|||||||
app.set_auto_iterations(true);
|
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()));
|
let out_dir = export_path.unwrap_or_else(|| format!("frames-{}", unix_timestamp()));
|
||||||
std::fs::create_dir_all(&out_dir).map_err(|e| format!("failed to create {out_dir}: {e}"))?;
|
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}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
let (device, queue) = pollster::block_on(request_device())?;
|
// Everything about frame `i` is a pure function of its `t`, so the app can
|
||||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
// be put into any frame's state at any time, in any order.
|
||||||
let renderer = FractalRenderer::new(&device, format);
|
let apply_frame = |app: &mut FractalApp, i: u32| {
|
||||||
// Only the camera animates, so the shader specialization (kind, Julia,
|
|
||||||
// DE) is the same for every frame.
|
|
||||||
let (pipeline, bind_group_layout, format) =
|
|
||||||
renderer.export_handles(&device, &app.make_uniforms(width as f64 / height as f64));
|
|
||||||
|
|
||||||
for i in 0..frames {
|
|
||||||
let raw_t = i as f64 / (frames - 1) as f64;
|
let raw_t = i as f64 / (frames - 1) as f64;
|
||||||
let t = if linear { raw_t } else { smoothstep(raw_t) };
|
let t = if targets.linear {
|
||||||
|
raw_t
|
||||||
|
} else {
|
||||||
|
smoothstep(raw_t)
|
||||||
|
};
|
||||||
if let Some(to) = to_iterations {
|
if let Some(to) = to_iterations {
|
||||||
app.set_max_iterations(
|
app.set_max_iterations(
|
||||||
interpolate_f64(from_iterations as f64, to as f64, t).round() as u32,
|
interpolate_f64(from_iterations as f64, to as f64, t).round() as u32,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
app.set_view(interpolate_view(&from, &to, t));
|
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,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
eprintln!("[{:>4}/{frames}] computing reference orbit…", i + 1);
|
// Snapshot every frame's reference-orbit job up front (cheap: just the
|
||||||
app.compute_reference_blocking();
|
// 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 uniforms = app.make_uniforms(width as f64 / height as f64);
|
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(
|
let er = ExportRender::new(
|
||||||
&device,
|
&device,
|
||||||
&queue,
|
&queue,
|
||||||
pipeline.clone(),
|
handles,
|
||||||
&bind_group_layout,
|
|
||||||
format,
|
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
uniforms,
|
uniforms,
|
||||||
app.reference_points(),
|
app.reference_points(),
|
||||||
app.lights(),
|
app.lights(),
|
||||||
);
|
);
|
||||||
|
let padded = render_readback_blocking(&device, &queue, &er);
|
||||||
let png = export_to_png_blocking(&device, &queue, &er, |phase, fraction| {
|
if png_tx.send((i, padded, er.padded_bpr, er.swap_rb)).is_err() {
|
||||||
eprint!(
|
break;
|
||||||
"\r[{:>4}/{frames}] {phase} {:>3.0}%",
|
}
|
||||||
i + 1,
|
}
|
||||||
fraction * 100.0
|
// Dropping the channel ends lets the workers drain and exit.
|
||||||
);
|
drop(raw_tx);
|
||||||
|
drop(png_tx);
|
||||||
|
drop(ref_rx);
|
||||||
});
|
});
|
||||||
eprintln!();
|
eprintln!();
|
||||||
|
|
||||||
let path = format!("{out_dir}/frame-{:05}.png", i + 1);
|
if let Some(e) = error.into_inner().unwrap() {
|
||||||
std::fs::write(&path, &png).map_err(|e| format!("save failed: {e}"))?;
|
return Err(e);
|
||||||
|
}
|
||||||
|
let saved = saved.into_inner();
|
||||||
|
if saved != count {
|
||||||
|
return Err(format!("only {saved} of {count} frames were rendered"));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("saved {frames} frames to {out_dir}/ ({width}×{height})");
|
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!(
|
println!(
|
||||||
"tip: ffmpeg -framerate {fps} -i {out_dir}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p out.mp4"
|
"tip: ffmpeg -framerate {fps} -i {out_dir}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p out.mp4"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `--to-view`/`--to-share` (exactly one must be set) into the end
|
/// Parse `--to-view`/`--to-share` (at most one is used) into the end view
|
||||||
/// view of an animation. Only position/zoom/iterations are pulled from a
|
/// 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
|
/// 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
|
/// pasting a link from the app doesn't unexpectedly change the fractal kind
|
||||||
/// mid-animation.
|
/// mid-animation.
|
||||||
fn parse_animation_target(
|
fn parse_animation_target(
|
||||||
to_view: Option<&str>,
|
to_view: Option<&str>,
|
||||||
to_share: Option<&str>,
|
to_share: Option<&str>,
|
||||||
) -> Result<(ViewState, Option<u32>), String> {
|
) -> Result<Option<(ViewState, Option<u32>)>, String> {
|
||||||
if let Some(spec) = to_view {
|
if let Some(spec) = to_view {
|
||||||
return parse_view_spec(spec).ok_or_else(|| format!("invalid --to-view spec: {spec}"));
|
return parse_view_spec(spec)
|
||||||
|
.map(Some)
|
||||||
|
.ok_or_else(|| format!("invalid --to-view spec: {spec}"));
|
||||||
}
|
}
|
||||||
let frag = to_share.expect("run_animation only called with one of to_view/to_share set");
|
let Some(frag) = to_share else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
let state =
|
let state =
|
||||||
ShareState::decode(frag).ok_or_else(|| format!("invalid --to-share fragment: {frag}"))?;
|
ShareState::decode(frag).ok_or_else(|| format!("invalid --to-share fragment: {frag}"))?;
|
||||||
let bits = precision_for(state.half_height);
|
let bits = precision_for(state.half_height);
|
||||||
@@ -213,10 +520,18 @@ fn parse_animation_target(
|
|||||||
big_from_decimal_str(&state.center_re, bits).ok_or("invalid --to-share center (re)")?;
|
big_from_decimal_str(&state.center_re, bits).ok_or("invalid --to-share center (re)")?;
|
||||||
let im =
|
let im =
|
||||||
big_from_decimal_str(&state.center_im, bits).ok_or("invalid --to-share center (im)")?;
|
big_from_decimal_str(&state.center_im, bits).ok_or("invalid --to-share center (im)")?;
|
||||||
Ok((
|
Ok(Some((
|
||||||
ViewState::with_center(re, im, state.half_height),
|
ViewState::with_center(re, im, state.half_height),
|
||||||
Some(state.iterations),
|
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.
|
/// Ease-in/ease-out pacing: slow at both ends, fast through the middle.
|
||||||
@@ -243,3 +558,30 @@ async fn request_device() -> Result<(wgpu::Device, wgpu::Queue), String> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("failed to create device: {e}"))
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+4
-1
@@ -1,7 +1,9 @@
|
|||||||
use std::f32::consts::PI;
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
use egui::{Color32, Ui};
|
use ecolor::Color32;
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
use egui::Ui;
|
||||||
|
|
||||||
/// Maximum number of simultaneous lights.
|
/// Maximum number of simultaneous lights.
|
||||||
pub const MAX_LIGHT_COUNT: usize = 16;
|
pub const MAX_LIGHT_COUNT: usize = 16;
|
||||||
@@ -27,6 +29,7 @@ impl Default for Light {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Light {
|
impl Light {
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
pub fn widget(&mut self, ui: &mut Ui) -> bool {
|
pub fn widget(&mut self, ui: &mut Ui) -> bool {
|
||||||
let formater = |v, _| format!("{}°", ((v * 180. / std::f64::consts::PI) as u32));
|
let formater = |v, _| format!("{}°", ((v * 180. / std::f64::consts::PI) as u32));
|
||||||
let parser = |s: &str| {
|
let parser = |s: &str| {
|
||||||
|
|||||||
+22
-1
@@ -1,6 +1,8 @@
|
|||||||
// Without these, rust fails to infer Send/Sync trait impls
|
// Without these, rust fails to infer Send/Sync trait impls
|
||||||
// Probably caused by the new trait solver
|
// Probably caused by the new trait solver
|
||||||
#![recursion_limit = "256"]
|
#![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.
|
||||||
//
|
//
|
||||||
@@ -9,6 +11,7 @@
|
|||||||
// 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 camera;
|
||||||
mod fractal;
|
mod fractal;
|
||||||
mod lights;
|
mod lights;
|
||||||
@@ -21,8 +24,17 @@ 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
|
||||||
@@ -30,6 +42,7 @@ 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};
|
||||||
|
|
||||||
@@ -51,7 +64,7 @@ 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 _;
|
use clap::Parser as _;
|
||||||
|
|
||||||
env_logger::builder()
|
env_logger::builder()
|
||||||
@@ -70,6 +83,13 @@ fn main() -> eframe::Result {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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(),
|
||||||
@@ -80,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,
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ fn advance(z: vec2<f32>, zp: vec2<f32>, c: vec2<f32>) -> vec2<f32> {
|
|||||||
} else if KIND == KIND_TRICORN {
|
} else if KIND == KIND_TRICORN {
|
||||||
return vec2<f32>(z.x * z.x - z.y * z.y, -2.0 * z.x * z.y) + c;
|
return vec2<f32>(z.x * z.x - z.y * z.y, -2.0 * z.x * z.y) + c;
|
||||||
} else if KIND == KIND_MULTIBROT {
|
} else if KIND == KIND_MULTIBROT {
|
||||||
return complex_pow(z, clamp(u.power, 2u, 8u)) + c;
|
return complex_pow(z, clamp(u.power, 2u, MULTIBROT_MAX_POWER)) + c;
|
||||||
} else if KIND == KIND_CELTIC {
|
} else if KIND == KIND_CELTIC {
|
||||||
return vec2<f32>(abs(z.x * z.x - z.y * z.y), 2.0 * z.x * z.y) + c;
|
return vec2<f32>(abs(z.x * z.x - z.y * z.y), 2.0 * z.x * z.y) + c;
|
||||||
} else if KIND == KIND_PERPENDICULAR {
|
} else if KIND == KIND_PERPENDICULAR {
|
||||||
|
|||||||
@@ -150,19 +150,45 @@ fn ray_marching(pos: vec4<f32>) -> vec4<f32> {
|
|||||||
// About 1/50 of a texel at typical sizes: tighter only adds steps
|
// About 1/50 of a texel at typical sizes: tighter only adds steps
|
||||||
// without visibly moving the hit.
|
// without visibly moving the hit.
|
||||||
let dist_threshold = 0.00001;
|
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 hit = false;
|
||||||
|
var t_prev = t;
|
||||||
for (var i = 0u; i < 100u; i++) {
|
for (var i = 0u; i < 100u; i++) {
|
||||||
let dist = sdf(start + t * ray_dir, k);
|
let dist = sdf(start + t * ray_dir, k);
|
||||||
if dist < dist_threshold {
|
if dist < dist_threshold {
|
||||||
hit = true;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
t += dist;
|
t_prev = t;
|
||||||
|
t += max(dist, min_step);
|
||||||
// Past the footprint's far edge: nothing left to hit.
|
// Past the footprint's far edge: nothing left to hit.
|
||||||
if t >= t_leave {
|
if t >= t_leave {
|
||||||
break;
|
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.
|
// Shade outside the loop, so its registers don't weigh on the march.
|
||||||
if !hit {
|
if !hit {
|
||||||
return RAY_MISS;
|
return RAY_MISS;
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ const KIND_MANDELBROT: u32 = 0u;
|
|||||||
const KIND_BURNING_SHIP: u32 = 1u;
|
const KIND_BURNING_SHIP: u32 = 1u;
|
||||||
const KIND_TRICORN: u32 = 2u;
|
const KIND_TRICORN: u32 = 2u;
|
||||||
const KIND_MULTIBROT: u32 = 3u;
|
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_CELTIC: u32 = 4u;
|
||||||
const KIND_PERPENDICULAR: u32 = 5u;
|
const KIND_PERPENDICULAR: u32 = 5u;
|
||||||
const KIND_BUFFALO: u32 = 6u;
|
const KIND_BUFFALO: u32 = 6u;
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ struct Uniforms {
|
|||||||
// Kind-switch morph weight: each step is (1 - w)*f_kind + w*f_morph_from;
|
// 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).
|
// 0 = no morph. Only read by MORPH pipelines (see mandelbrot.wgsl).
|
||||||
morph_w: f32,
|
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
|
// 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
|
// 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.
|
// series. Precomputed on the CPU since they only depend on the power.
|
||||||
@@ -164,7 +168,7 @@ fn shadow_color(normal: vec3<f32>, ci: f32) -> vec3<f32> {
|
|||||||
color = filmic(color, 4.2);
|
color = filmic(color, 4.2);
|
||||||
} else if u.shadow_palette_id == 3u {
|
} else if u.shadow_palette_id == 3u {
|
||||||
// No DE darkening as in `classic_color`: in shadow/3D modes the DE
|
// No DE darkening as in `classic_color`: in shadow/3D modes the DE
|
||||||
// is a height (clamped to 1000, not 1), and the lighting already
|
// is a height (unclamped, not capped at 1), and the lighting already
|
||||||
// shows the relief.
|
// shows the relief.
|
||||||
let t = fract(ci * u.color_scale + u.color_offset);
|
let t = fract(ci * u.color_scale + u.color_offset);
|
||||||
let ambient = 0.25;
|
let ambient = 0.25;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+681
-23
@@ -11,6 +11,11 @@
|
|||||||
// true value |y| drops below the delta |e|, or the reference runs out, we reset
|
// true value |y| drops below the delta |e|, or the reference runs out, we reset
|
||||||
// the reference index to 0 and carry the full value as the new delta (valid
|
// the reference index to 0 and carry the full value as the new delta (valid
|
||||||
// because X_0 = 0).
|
// because X_0 = 0).
|
||||||
|
//
|
||||||
|
// Past ~1e30 zoom the deltas themselves leave f32's exponent range (smallest
|
||||||
|
// normal ~1.2e-38), so `DEEP` pipelines start each pixel in a rescaled form,
|
||||||
|
// e = w * 2^s with an f32 mantissa `w` and an i32 exponent `s`, and hand over
|
||||||
|
// to the plain f32 loop once the delta is big enough (see `iterate_sample`).
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||||
@group(0) @binding(1) var<storage, read> ref_orbit: array<vec2<f32>>;
|
@group(0) @binding(1) var<storage, read> ref_orbit: array<vec2<f32>>;
|
||||||
@@ -20,6 +25,11 @@
|
|||||||
// Only read by the adaptive-AA refine pass (`fs_refine`): the 1-sample-per-
|
// Only read by the adaptive-AA refine pass (`fs_refine`): the 1-sample-per-
|
||||||
// pixel data texture written by `fs_data`, which decides where to supersample.
|
// pixel data texture written by `fs_data`, which decides where to supersample.
|
||||||
@group(1) @binding(0) var coarse_tex: texture_2d<f32>;
|
@group(1) @binding(0) var coarse_tex: texture_2d<f32>;
|
||||||
|
// Per-point binary exponents of the reference orbit (`RefOrbit::exps`): the
|
||||||
|
// true X[m] is ref_orbit[m] * 2^ref_exp[m]. Non-zero only for points below
|
||||||
|
// f32's range, which only deep references contain; only `DEEP` pipelines read
|
||||||
|
// it (see `ref_at`).
|
||||||
|
@group(0) @binding(3) var<storage, read> ref_exp: array<i32>;
|
||||||
|
|
||||||
// Pipeline-overridable specialization constants, set per pipeline from the
|
// Pipeline-overridable specialization constants, set per pipeline from the
|
||||||
// uniforms' `kind` / `is_julia` / `de_coloring` (see `PipelineKey` in
|
// uniforms' `kind` / `is_julia` / `de_coloring` (see `PipelineKey` in
|
||||||
@@ -35,6 +45,10 @@ override DE: bool = false;
|
|||||||
// second kind, `u.morph_from`. That one is a runtime value (it only lives for
|
// second kind, `u.morph_from`. That one is a runtime value (it only lives for
|
||||||
// the length of the animation), so only MORPH pipelines pay for its branches.
|
// the length of the animation), so only MORPH pipelines pay for its branches.
|
||||||
override MORPH: bool = false;
|
override MORPH: bool = false;
|
||||||
|
// Deep view (`u.scale_exp != 0`): the per-pixel offset `dc`, the pixel size
|
||||||
|
// and `u.span` / `u.dc_offset` are all in units of 2^scale_exp, and each pixel
|
||||||
|
// starts in the rescaled deep phase (see `iterate_sample`).
|
||||||
|
override DEEP: bool = false;
|
||||||
|
|
||||||
struct VsOut {
|
struct VsOut {
|
||||||
@builtin(position) pos: vec4<f32>,
|
@builtin(position) pos: vec4<f32>,
|
||||||
@@ -82,14 +96,18 @@ fn diffabs(c: f32, d: f32) -> f32 {
|
|||||||
// tiny, but that only perturbs `s` by a relative f32 epsilon, and the result
|
// tiny, but that only perturbs `s` by a relative f32 epsilon, and the result
|
||||||
// is `e * s`, so the delta keeps full relative precision.
|
// is `e * s`, so the delta keeps full relative precision.
|
||||||
fn multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: u32) -> vec2<f32> {
|
fn multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: u32) -> vec2<f32> {
|
||||||
let y = z + e;
|
return cmul(e, multibrot_sum(z, z + e, p));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sum in `multibrot_delta`, sum_{k=0}^{p-1} y^k Z^{p-1-k} with y = Z+e.
|
||||||
|
fn multibrot_sum(z: vec2<f32>, y: vec2<f32>, p: u32) -> vec2<f32> {
|
||||||
var s = vec2<f32>(1.0, 0.0);
|
var s = vec2<f32>(1.0, 0.0);
|
||||||
var zj = vec2<f32>(1.0, 0.0);
|
var zj = vec2<f32>(1.0, 0.0);
|
||||||
for (var j: u32 = 1u; j < p; j = j + 1u) {
|
for (var j: u32 = 1u; j < p; j = j + 1u) {
|
||||||
zj = cmul(zj, z); // Z^j
|
zj = cmul(zj, z); // Z^j
|
||||||
s = cmul(s, y) + zj;
|
s = cmul(s, y) + zj;
|
||||||
}
|
}
|
||||||
return cmul(e, s);
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maximum number of terms in `complex_multibrot_delta`'s series (matches the
|
// Maximum number of terms in `complex_multibrot_delta`'s series (matches the
|
||||||
@@ -121,11 +139,21 @@ fn cm_coef(k: u32) -> vec2<f32> {
|
|||||||
// dynamics, not a deep-zoom edge case — and the series above would diverge.
|
// dynamics, not a deep-zoom edge case — and the series above would diverge.
|
||||||
// But forming Z+e directly is numerically safe exactly there (e isn't many
|
// But forming Z+e directly is numerically safe exactly there (e isn't many
|
||||||
// orders of magnitude smaller than Z), so fall back to a plain subtraction.
|
// orders of magnitude smaller than Z), so fall back to a plain subtraction.
|
||||||
|
//
|
||||||
|
// The series is also wrong when Z -> Z+e crosses the principal branch cut
|
||||||
|
// (negative real axis) that `cpow` and the CPU reference use. There it
|
||||||
|
// continues Z's branch, but the true map jumps by a factor e^{2πip}. Which
|
||||||
|
// pixels crossed then depended on the reference's position, so whole disks
|
||||||
|
// flipped branch while panning. With |w| < 0.5, arg(1+w) is within ±30°, so an
|
||||||
|
// Im sign flip with Re(Z) < 0 is exactly a cut crossing. The direct form is
|
||||||
|
// fine there: the true delta across the cut is large, not tiny.
|
||||||
fn complex_multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: vec2<f32>) -> vec2<f32> {
|
fn complex_multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: vec2<f32>) -> vec2<f32> {
|
||||||
|
let y = z + e;
|
||||||
|
let crosses_cut = z.x < 0.0 && ((z.y < 0.0) != (y.y < 0.0));
|
||||||
// |w|^2 = |e|^2 / |Z|^2; inf or nan (Z ~ 0, or both ~ 0) correctly fails
|
// |w|^2 = |e|^2 / |Z|^2; inf or nan (Z ~ 0, or both ~ 0) correctly fails
|
||||||
// the `< 0.25` test below and falls through to the direct branch.
|
// the `< 0.25` test below and falls through to the direct branch.
|
||||||
let w2 = dot(e, e) / dot(z, z);
|
let w2 = dot(e, e) / dot(z, z);
|
||||||
if w2 < 0.25 {
|
if w2 < 0.25 && !crosses_cut {
|
||||||
let w = cdiv(e, z);
|
let w = cdiv(e, z);
|
||||||
var wk = w; // w^1
|
var wk = w; // w^1
|
||||||
var acc = vec2<f32>(0.0, 0.0);
|
var acc = vec2<f32>(0.0, 0.0);
|
||||||
@@ -138,7 +166,7 @@ fn complex_multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: vec2<f32>) -> vec2<f32
|
|||||||
}
|
}
|
||||||
return cmul(cpow(z, p), acc);
|
return cmul(cpow(z, p), acc);
|
||||||
}
|
}
|
||||||
return cpow(z + e, p) - cpow(z, p);
|
return cpow(y, p) - cpow(z, p);
|
||||||
}
|
}
|
||||||
|
|
||||||
// One perturbation step of `kind`'s delta: e -> f(Z+e) - f(Z), where `z` is
|
// One perturbation step of `kind`'s delta: e -> f(Z+e) - f(Z), where `z` is
|
||||||
@@ -159,7 +187,7 @@ fn advance_delta_kind(kind: u32, z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
|
|||||||
let ce = conj(e);
|
let ce = conj(e);
|
||||||
return 2.0 * cmul(cz, ce) + cmul(ce, ce);
|
return 2.0 * cmul(cz, ce) + cmul(ce, ce);
|
||||||
} else if kind == KIND_MULTIBROT {
|
} else if kind == KIND_MULTIBROT {
|
||||||
return multibrot_delta(z, e, clamp(u.power, 2u, 8u));
|
return multibrot_delta(z, e, clamp(u.power, 2u, MULTIBROT_MAX_POWER));
|
||||||
} else if kind == KIND_CELTIC {
|
} else if kind == KIND_CELTIC {
|
||||||
// z^2 delta split: sq.x = delta of Re(z^2), sq.y = delta of Im(z^2).
|
// z^2 delta split: sq.x = delta of Re(z^2), sq.y = delta of Im(z^2).
|
||||||
// Celtic abs the real output, so |Re(z^2)| delta = diffabs(Re(Z^2), sq.x).
|
// Celtic abs the real output, so |Re(z^2)| delta = diffabs(Re(Z^2), sq.x).
|
||||||
@@ -194,7 +222,7 @@ fn advance_delta_kind(kind: u32, z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
|
|||||||
// enough to de-speckle filaments.
|
// enough to de-speckle filaments.
|
||||||
fn fprime_kind(kind: u32, z: vec2<f32>) -> vec2<f32> {
|
fn fprime_kind(kind: u32, z: vec2<f32>) -> vec2<f32> {
|
||||||
if kind == KIND_MULTIBROT {
|
if kind == KIND_MULTIBROT {
|
||||||
let p = clamp(u.power, 2u, 8u);
|
let p = clamp(u.power, 2u, MULTIBROT_MAX_POWER);
|
||||||
var zk = z; // Z^1
|
var zk = z; // Z^1
|
||||||
for (var k: u32 = 2u; k < p; k = k + 1u) {
|
for (var k: u32 = 2u; k < p; k = k + 1u) {
|
||||||
zk = cmul(zk, z); // -> Z^{p-1}
|
zk = cmul(zk, z); // -> Z^{p-1}
|
||||||
@@ -232,6 +260,324 @@ fn fprime(z: vec2<f32>) -> vec2<f32> {
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Deep (rescaled) phase helpers -------------------------------------
|
||||||
|
//
|
||||||
|
// A deep value is an f32 mantissa times 2^exponent. All scaling is by exact
|
||||||
|
// powers of two, so it never rounds.
|
||||||
|
|
||||||
|
const LN2: f32 = 0.6931471805599453;
|
||||||
|
// Where `ldexp_sat` saturates. Only ever compared against small values
|
||||||
|
// (see `diffabs_scaled`), and far enough from f32's max that doubling it,
|
||||||
|
// or squaring a value of the size it's compared with, stays finite.
|
||||||
|
const LDEXP_SAT: f32 = 1.2676506e30; // 2^100
|
||||||
|
|
||||||
|
// x * 2^k for any k. WGSL's `ldexp` is only defined for exponents inside
|
||||||
|
// f32's range, so go through `frexp`: results below the smallest normal
|
||||||
|
// flush to 0, results above 2^100 saturate to ±2^100.
|
||||||
|
fn ldexp_sat(x: f32, k: i32) -> f32 {
|
||||||
|
if x == 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let f = frexp(x);
|
||||||
|
let ex = f.exp + k;
|
||||||
|
if ex > 100 {
|
||||||
|
return select(-LDEXP_SAT, LDEXP_SAT, x > 0.0);
|
||||||
|
}
|
||||||
|
if ex < -125 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
return ldexp(f.fract, ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ldexp2_sat(v: vec2<f32>, k: i32) -> vec2<f32> {
|
||||||
|
return vec2<f32>(ldexp_sat(v.x, k), ldexp_sat(v.y, k));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A complex number as mantissa * 2^e, with the mantissa's larger component in
|
||||||
|
// [0.5, 1) (or exactly 0, with e = 0).
|
||||||
|
struct Fe {
|
||||||
|
m: vec2<f32>,
|
||||||
|
e: i32,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn fe_make(v: vec2<f32>, e: i32) -> Fe {
|
||||||
|
let a = max(abs(v.x), abs(v.y));
|
||||||
|
if a == 0.0 {
|
||||||
|
return Fe(vec2<f32>(0.0, 0.0), 0);
|
||||||
|
}
|
||||||
|
let k = frexp(a).exp;
|
||||||
|
return Fe(ldexp2_sat(v, -k), e + k);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference point X[m] as f32 (points stored normalized flush to 0 here;
|
||||||
|
// only deep references have any, see `ref_exp`).
|
||||||
|
fn ref_at(m: u32) -> vec2<f32> {
|
||||||
|
let x = ref_orbit[m];
|
||||||
|
if DEEP {
|
||||||
|
let k = ref_exp[m];
|
||||||
|
if k != 0 {
|
||||||
|
return ldexp2_sat(x, k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
// X[m] with its full exponent range (deep phase only).
|
||||||
|
fn ref_fe(m: u32) -> Fe {
|
||||||
|
return fe_make(ref_orbit[m], ref_exp[m]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complex log of m * 2^e (m != 0).
|
||||||
|
fn clog_fe(m: vec2<f32>, e: i32) -> vec2<f32> {
|
||||||
|
return vec2<f32>(0.5 * log(dot(m, m)) + f32(e) * LN2, atan2(m.y, m.x));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cexp(a: vec2<f32>) -> vec2<f32> {
|
||||||
|
return exp(a.x) * vec2<f32>(cos(a.y), sin(a.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffabs(c, 2^s * d) / 2^s = diffabs(c / 2^s, d): diffabs is positively
|
||||||
|
// homogeneous. Saturating c / 2^s is harmless: once it dwarfs |d| the result
|
||||||
|
// is just ±d.
|
||||||
|
fn diffabs_scaled(c: f32, d: f32, s: i32) -> f32 {
|
||||||
|
return diffabs(ldexp_sat(c, -s), d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum of two `Fe`s, at the larger one's exponent.
|
||||||
|
fn fe_add(a: Fe, b: Fe) -> Fe {
|
||||||
|
if a.m.x == 0.0 && a.m.y == 0.0 {
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
if b.m.x == 0.0 && b.m.y == 0.0 {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
let e = max(a.e, b.e);
|
||||||
|
return fe_make(ldexp2_sat(a.m, a.e - e) + ldexp2_sat(b.m, b.e - e), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One deep step's delta, (f(X + e) - f(X)) / 2^t: the mantissa `w` and its
|
||||||
|
// exponent `t`. `t` is the input scale s except next to the critical point,
|
||||||
|
// where the linear part of the step vanishes and the result is ~e^2, far
|
||||||
|
// below the input's scale.
|
||||||
|
struct DeepStep {
|
||||||
|
w: vec2<f32>,
|
||||||
|
t: i32,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The per-kind formula of a deep step: (f(X + e) - f(X)) / 2^t for the
|
||||||
|
// delta e = w * 2^s, given X measured in units of 2^u (`x` = X / 2^u) and
|
||||||
|
// `sc` = 2^se, se = s - u, the delta's scale in those units. The result's
|
||||||
|
// scale is t = s + (p-1)·u for a degree-p kind, since every kind here but
|
||||||
|
// Lambda is p-homogeneous in (X, e) jointly (`diffabs_scaled` rescales the
|
||||||
|
// fold-point comparisons the same way). Usually u = 0 (x = X, sc = 2^s,
|
||||||
|
// t = s); see `deep_step_kind` for when it isn't. Lambda always gets u = 0.
|
||||||
|
// Every kind needs an arm here too.
|
||||||
|
fn advance_delta_scaled_kind(kind: u32, x: vec2<f32>, w: vec2<f32>, sc: f32, se: i32) -> vec2<f32> {
|
||||||
|
if kind == KIND_BURNING_SHIP {
|
||||||
|
let base = 2.0 * cmul(x, w) + sc * cmul(w, w);
|
||||||
|
let dp = x.x * w.y + x.y * w.x + sc * w.x * w.y;
|
||||||
|
return vec2<f32>(base.x, 2.0 * diffabs_scaled(x.x * x.y, dp, se));
|
||||||
|
} else if kind == KIND_TRICORN {
|
||||||
|
let cx = conj(x);
|
||||||
|
let cw = conj(w);
|
||||||
|
return 2.0 * cmul(cx, cw) + sc * cmul(cw, cw);
|
||||||
|
} else if kind == KIND_MULTIBROT {
|
||||||
|
return cmul(w, multibrot_sum(x, x + sc * w, clamp(u.power, 2u, MULTIBROT_MAX_POWER)));
|
||||||
|
} else if kind == KIND_CELTIC {
|
||||||
|
let sq = 2.0 * cmul(x, w) + sc * cmul(w, w);
|
||||||
|
return vec2<f32>(diffabs_scaled(x.x * x.x - x.y * x.y, sq.x, se), sq.y);
|
||||||
|
} else if kind == KIND_BUFFALO {
|
||||||
|
let sq = 2.0 * cmul(x, w) + sc * cmul(w, w);
|
||||||
|
return vec2<f32>(diffabs_scaled(x.x * x.x - x.y * x.y, sq.x, se),
|
||||||
|
-diffabs_scaled(2.0 * x.x * x.y, sq.y, se));
|
||||||
|
} else if kind == KIND_PERPENDICULAR {
|
||||||
|
let sq = 2.0 * cmul(x, w) + sc * cmul(w, w);
|
||||||
|
let da = diffabs_scaled(x.y, w.y, se); // (|Y + ey| - |Y|) / 2^s
|
||||||
|
let abs_yf = abs(x.y) + sc * da; // |Y + ey| * 2^(s-t)
|
||||||
|
return vec2<f32>(sq.x, -2.0 * (x.x * da + w.x * abs_yf));
|
||||||
|
} else if kind == KIND_LAMBDA {
|
||||||
|
let t = vec2<f32>(1.0 - 2.0 * x.x - sc * w.x, -2.0 * x.y - sc * w.y);
|
||||||
|
return cmul(u.lambda_l, cmul(w, t));
|
||||||
|
}
|
||||||
|
return 2.0 * cmul(x, w) + sc * cmul(w, w); // Mandelbrot (and Phoenix square part)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Below this exponent a reference point counts as next to the critical point
|
||||||
|
// 0 (see `deep_step_kind`). Above it, the e^2 terms 2^s * w^2 can only flush
|
||||||
|
// to 0 when they are below 2^-50 of the linear ones.
|
||||||
|
const DEEP_X_NEAR_LOG2: i32 = -60;
|
||||||
|
|
||||||
|
// One deep step of `kind` for the delta e = w * 2^s from X (`x` as f32, `xf`
|
||||||
|
// at full range). Usually the input scale is kept (t = s). But when X is
|
||||||
|
// tiny (next to the critical point, e.g. at a minibrot's period), the linear
|
||||||
|
// term vanishes and the step's value is ~e^p, which would flush to 0 at
|
||||||
|
// scale s. There every z^p-like kind is p-homogeneous in (X, e) jointly, so
|
||||||
|
// both are measured in units of 2^u (u = the larger one's exponent) and the
|
||||||
|
// result lands at t = s + (p-1)·u (`ue` below).
|
||||||
|
fn deep_step_kind(kind: u32, x: vec2<f32>, xf: Fe, w: vec2<f32>, sc: f32, s: i32) -> DeepStep {
|
||||||
|
if kind == KIND_COMPLEX_MULTIBROT {
|
||||||
|
return complex_multibrot_step(xf, w, s);
|
||||||
|
}
|
||||||
|
let x_zero = xf.m.x == 0.0 && xf.m.y == 0.0;
|
||||||
|
// Lambda's critical point is 1/2 and its step has a constant linear
|
||||||
|
// term (λ·e), so it never needs this.
|
||||||
|
if kind == KIND_LAMBDA || (!x_zero && xf.e >= DEEP_X_NEAR_LOG2) {
|
||||||
|
return DeepStep(advance_delta_scaled_kind(kind, x, w, sc, s), s);
|
||||||
|
}
|
||||||
|
let kw = deep_log2(w, vec2<f32>(0.0, 0.0));
|
||||||
|
if kw == DEEP_ZERO {
|
||||||
|
return DeepStep(w, s); // e = 0: f(X) - f(X)
|
||||||
|
}
|
||||||
|
var ue = s + kw;
|
||||||
|
if !x_zero {
|
||||||
|
ue = max(ue, xf.e);
|
||||||
|
}
|
||||||
|
var deg = 2;
|
||||||
|
if kind == KIND_MULTIBROT {
|
||||||
|
deg = i32(clamp(u.power, 2u, MULTIBROT_MAX_POWER));
|
||||||
|
}
|
||||||
|
let xk = ldexp2_sat(xf.m, xf.e - ue);
|
||||||
|
let se = s - ue;
|
||||||
|
let dw = advance_delta_scaled_kind(kind, xk, w, ldexp_sat(1.0, se), se);
|
||||||
|
return DeepStep(dw, s + (deg - 1) * ue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scaled `complex_multibrot_delta` with X as a full-range `Fe`. Same series
|
||||||
|
// as the f32 version, rewritten as X^(p-1) * w * sum_k C(p,k) r^(k-1)
|
||||||
|
// (r = e/X) so nothing is formed at the delta's true scale; X^(p-1)'s own
|
||||||
|
// exponent goes into the result's `t`. When |e/X| >= 0.5, X is itself tiny
|
||||||
|
// (|X| <= 2|e|), so both terms of the direct form are taken in log space at
|
||||||
|
// the larger one's scale. Branch-cut crossings leave the deep phase before
|
||||||
|
// stepping (`deep_cut_crossing`).
|
||||||
|
fn complex_multibrot_step(xf: Fe, w: vec2<f32>, s: i32) -> DeepStep {
|
||||||
|
let p = u.complex_power;
|
||||||
|
let wf = fe_make(w, s);
|
||||||
|
if wf.m.x == 0.0 && wf.m.y == 0.0 {
|
||||||
|
return DeepStep(vec2<f32>(0.0, 0.0), s);
|
||||||
|
}
|
||||||
|
if xf.m.x == 0.0 && xf.m.y == 0.0 {
|
||||||
|
// e^p.
|
||||||
|
let l = cmul(p, clog_fe(wf.m, wf.e));
|
||||||
|
let k = i32(floor(l.x / LN2));
|
||||||
|
return DeepStep(cexp(l - vec2<f32>(f32(k) * LN2, 0.0)), k);
|
||||||
|
}
|
||||||
|
let r = ldexp2_sat(cdiv(wf.m, xf.m), wf.e - xf.e);
|
||||||
|
if dot(r, r) < 0.25 {
|
||||||
|
var acc = cm_coef(1u);
|
||||||
|
var rk = r; // r^(k-1)
|
||||||
|
for (var k: u32 = 2u; k <= COMPLEX_MULTIBROT_TERMS; k = k + 1u) {
|
||||||
|
acc = acc + cmul(cm_coef(k), rk);
|
||||||
|
rk = cmul(rk, r);
|
||||||
|
if dot(rk, rk) < 1e-18 * dot(acc, acc) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// X^(p-1) = cexp(l) = cexp(l - k·ln2) * 2^k.
|
||||||
|
let l = cmul(p - vec2<f32>(1.0, 0.0), clog_fe(xf.m, xf.e));
|
||||||
|
let k = i32(floor(l.x / LN2));
|
||||||
|
let x_pm1 = cexp(l - vec2<f32>(f32(k) * LN2, 0.0));
|
||||||
|
return DeepStep(cmul(cmul(w, x_pm1), acc), s + k);
|
||||||
|
}
|
||||||
|
// (X + e)^p - X^p, both in log space (X + e may be exactly 0).
|
||||||
|
let xs = ldexp2_sat(xf.m, xf.e - s);
|
||||||
|
let yf = fe_make(xs + w, s);
|
||||||
|
let lb = cmul(p, clog_fe(xf.m, xf.e));
|
||||||
|
var k = i32(floor(lb.x / LN2));
|
||||||
|
var la = vec2<f32>(0.0, 0.0);
|
||||||
|
let y_zero = yf.m.x == 0.0 && yf.m.y == 0.0;
|
||||||
|
if !y_zero {
|
||||||
|
la = cmul(p, clog_fe(yf.m, yf.e));
|
||||||
|
k = max(k, i32(floor(la.x / LN2)));
|
||||||
|
}
|
||||||
|
let kl = vec2<f32>(f32(k) * LN2, 0.0);
|
||||||
|
var ya = vec2<f32>(0.0, 0.0);
|
||||||
|
if !y_zero {
|
||||||
|
ya = cexp(la - kl);
|
||||||
|
}
|
||||||
|
return DeepStep(ya - cexp(lb - kl), k);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep-phase twin of `advance_delta` (same morph blend, at the larger of the
|
||||||
|
// two kinds' output scales).
|
||||||
|
fn advance_delta_scaled(x: vec2<f32>, xf: Fe, w: vec2<f32>, sc: f32, s: i32) -> DeepStep {
|
||||||
|
let a = deep_step_kind(KIND, x, xf, w, sc, s);
|
||||||
|
if MORPH {
|
||||||
|
let b = deep_step_kind(u.morph_from, x, xf, w, sc, s);
|
||||||
|
let t = max(a.t, b.t);
|
||||||
|
return DeepStep(mix(ldexp2_sat(a.w, a.t - t), ldexp2_sat(b.w, b.t - t), u.morph_w), t);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether this step would take Complex Multibrot's X + e across the branch
|
||||||
|
// cut (see `complex_multibrot_delta`). The delta then jumps to the size of X,
|
||||||
|
// so the deep phase ends and the f32 loop takes the step.
|
||||||
|
fn deep_cut_crossing(xf: Fe, w: vec2<f32>, s: i32) -> bool {
|
||||||
|
let cm = KIND == KIND_COMPLEX_MULTIBROT || (MORPH && u.morph_from == KIND_COMPLEX_MULTIBROT);
|
||||||
|
if !cm {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let yn = xf.m + ldexp2_sat(w, s - xf.e); // (X + e) / 2^xe
|
||||||
|
return xf.m.x < 0.0 && ((xf.m.y < 0.0) != (yn.y < 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary exponent of the largest component of a pair of complex mantissas
|
||||||
|
// (`frexp` convention: |x| < 2^k), or DEEP_ZERO when both are exactly 0.
|
||||||
|
const DEEP_ZERO: i32 = -100000;
|
||||||
|
fn deep_log2(a: vec2<f32>, b: vec2<f32>) -> i32 {
|
||||||
|
let m = max(max(abs(a.x), abs(a.y)), max(abs(b.x), abs(b.y)));
|
||||||
|
if m == 0.0 {
|
||||||
|
return DEEP_ZERO;
|
||||||
|
}
|
||||||
|
return frexp(m).exp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// f'(y) at the full value y = X + w * 2^s, as an `Fe`. Plain `fprime` in f32
|
||||||
|
// unless y is below f32's comfortable range, which only happens next to the
|
||||||
|
// critical point 0 (after a rebase), where X is itself tiny: then y is
|
||||||
|
// formed in the 2^s-scaled domain and f' ~ p·y^(p-1) keeps its exponent.
|
||||||
|
fn deep_fprime(xf: Fe, w: vec2<f32>, s: i32, yt: vec2<f32>) -> Fe {
|
||||||
|
if MORPH || max(abs(yt.x), abs(yt.y)) >= DEEP_TINY {
|
||||||
|
return Fe(fprime(yt), 0);
|
||||||
|
}
|
||||||
|
let yf = fe_make(ldexp2_sat(xf.m, xf.e - s) + w, s);
|
||||||
|
if yf.m.x == 0.0 && yf.m.y == 0.0 {
|
||||||
|
return Fe(fprime(vec2<f32>(0.0, 0.0)), 0);
|
||||||
|
}
|
||||||
|
if KIND == KIND_MULTIBROT {
|
||||||
|
let p = clamp(u.power, 2u, MULTIBROT_MAX_POWER);
|
||||||
|
var ym = yf.m; // m^(p-1)
|
||||||
|
for (var k: u32 = 2u; k < p; k = k + 1u) {
|
||||||
|
ym = cmul(ym, yf.m);
|
||||||
|
}
|
||||||
|
return fe_make(f32(p) * ym, yf.e * i32(p - 1u));
|
||||||
|
} else if KIND == KIND_LAMBDA {
|
||||||
|
return Fe(fprime(vec2<f32>(0.0, 0.0)), 0); // λ(1 - 2y) ~ λ
|
||||||
|
} else if KIND == KIND_COMPLEX_MULTIBROT {
|
||||||
|
// p·y^(p-1) = p·exp(l), l = (p-1)·ln y; keep exp(Re l)'s exponent.
|
||||||
|
let p = u.complex_power;
|
||||||
|
let l = cmul(p - vec2<f32>(1.0, 0.0), clog_fe(yf.m, yf.e));
|
||||||
|
let k = i32(floor(l.x / LN2));
|
||||||
|
return fe_make(cmul(p, cexp(vec2<f32>(l.x - f32(k) * LN2, l.y))), k);
|
||||||
|
}
|
||||||
|
// z^2-like kinds: 2y (|f'| = |2y| for the abs variants too, see fprime).
|
||||||
|
return Fe(2.0 * yf.m, yf.e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deep phase hands over to the f32 loop once the delta's magnitude
|
||||||
|
// reaches 2^DEEP_EXIT_LOG2: by then |e|^2 is still a normal f32 and the
|
||||||
|
// pixel offset dc (< 2^-99 on deep views) is below f32 rounding of e. The DE
|
||||||
|
// derivative only has to be a comfortably normal f32 (DEEP_EXIT_DZ_LOG2).
|
||||||
|
const DEEP_EXIT_LOG2: i32 = -48;
|
||||||
|
const DEEP_EXIT_DZ_LOG2: i32 = -100;
|
||||||
|
// Below this, a full value y goes through `deep_fprime`'s extended path.
|
||||||
|
const DEEP_TINY: f32 = 7.888609e-31; // 2^-100
|
||||||
|
// The mantissas are renormalized once their exponent drifts past ±this.
|
||||||
|
const DEEP_RENORM_LOG2: i32 = 16;
|
||||||
|
// A reference point can only matter for rebasing when it's within this many
|
||||||
|
// binades above the delta's scale (|w| < 2^DEEP_RENORM_LOG2).
|
||||||
|
const DEEP_NEAR_LOG2: i32 = 24;
|
||||||
|
|
||||||
// Weight of the Phoenix kind's p*z_{n-1} term in the current map: 1 for plain
|
// Weight of the Phoenix kind's p*z_{n-1} term in the current map: 1 for plain
|
||||||
// Phoenix, its morph share while switching to/from Phoenix, else 0.
|
// Phoenix, its morph share while switching to/from Phoenix, else 0.
|
||||||
fn phoenix_weight() -> f32 {
|
fn phoenix_weight() -> f32 {
|
||||||
@@ -245,10 +591,19 @@ fn phoenix_weight() -> f32 {
|
|||||||
return w;
|
return w;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Periodicity (interior) detection, Brent-style: the full orbit value is
|
// Periodicity (interior) detection, Brent-style: windows end at iterations
|
||||||
// saved at iterations PERIOD_FIRST_CHECK, 2x that, 4x ..., and every later
|
// PERIOD_FIRST_CHECK, 2x that, 4x ..., and at each window end the window's
|
||||||
// iterate is compared against the last saved one. Returning within
|
// iterate closest to the critical point is saved. Every later iterate is
|
||||||
// PERIOD_EPS2 (relative, squared) means the orbit has closed a cycle.
|
// compared against the last saved one. Returning within PERIOD_EPS2
|
||||||
|
// (relative, squared) means the orbit has closed a cycle.
|
||||||
|
//
|
||||||
|
// Why the closest-to-critical iterate rather than the one at the window end:
|
||||||
|
// near a deep minibrot, an orbit follows the minibrot's cycle with a
|
||||||
|
// deviation at the minibrot's own scale. At an arbitrary phase |z| ~ 1, so
|
||||||
|
// that deviation is far below the relative tolerance and *any* nearby
|
||||||
|
// exterior orbit "returned" (a large black disk around a minibrot at
|
||||||
|
// ~1e-13 zoom). At the phase nearest the critical point, z itself is at the
|
||||||
|
// minibrot's scale, so the relative tolerance measures the actual return.
|
||||||
//
|
//
|
||||||
// A close return alone isn't trusted. A pixel just *outside* the set (at a
|
// A close return alone isn't trusted. A pixel just *outside* the set (at a
|
||||||
// minibrot's edge, or a cusp) can shadow a cycle for thousands of iterations
|
// minibrot's edge, or a cusp) can shadow a cycle for thousands of iterations
|
||||||
@@ -290,6 +645,15 @@ fn periodic_enabled() -> bool {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Critical point of the current map (where f' = 0), the periodicity save
|
||||||
|
// point's reference: 0 for every z^p-like kind, 1/2 for Lambda's λz(1-z).
|
||||||
|
fn critical_point() -> vec2<f32> {
|
||||||
|
if KIND == KIND_LAMBDA {
|
||||||
|
return vec2<f32>(0.5, 0.0);
|
||||||
|
}
|
||||||
|
return vec2<f32>(0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
// Escape data for one sample: `ci` is the (color-independent) palette parameter,
|
// Escape data for one sample: `ci` is the (color-independent) palette parameter,
|
||||||
// `de` the distance-estimate darkening factor in [0,1], `escaped` false for the
|
// `de` the distance-estimate darkening factor in [0,1], `escaped` false for the
|
||||||
// interior of the set. Splitting iteration from coloring lets a colour change be
|
// interior of the set. Splitting iteration from coloring lets a colour change be
|
||||||
@@ -303,13 +667,14 @@ struct Sample {
|
|||||||
// Perturbation iterate a single sample. `offset` is the per-pixel offset in
|
// Perturbation iterate a single sample. `offset` is the per-pixel offset in
|
||||||
// complex units. For Mandelbrot it is the c-plane offset added every step (delta
|
// complex units. For Mandelbrot it is the c-plane offset added every step (delta
|
||||||
// starts at 0); for Julia it is the z-plane offset that seeds the initial delta
|
// starts at 0); for Julia it is the z-plane offset that seeds the initial delta
|
||||||
// (c is fixed, so nothing is added per step).
|
// (c is fixed, so nothing is added per step). In `DEEP` pipelines both
|
||||||
|
// `offset` and `px` are in units of 2^u.scale_exp.
|
||||||
fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
||||||
// Loop invariants, read once instead of on every iteration.
|
// Loop invariants, read once instead of on every iteration.
|
||||||
let max_iter = u.max_iter;
|
let max_iter = u.max_iter;
|
||||||
let bailout_sq = u.bailout_sq;
|
let bailout_sq = u.bailout_sq;
|
||||||
let ref_len = u.ref_len;
|
let ref_len = u.ref_len;
|
||||||
let z0 = ref_orbit[0]; // reference start (0 for Mandelbrot, center for Julia)
|
let z0 = ref_at(0u); // reference start (0 for Mandelbrot, center for Julia)
|
||||||
|
|
||||||
// Main cardioid / period-2 bulb bypass: those points never escape, so skip
|
// Main cardioid / period-2 bulb bypass: those points never escape, so skip
|
||||||
// iterating them (they'd otherwise all burn the full max_iter). `offset` is
|
// iterating them (they'd otherwise all burn the full max_iter). `offset` is
|
||||||
@@ -317,7 +682,7 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
|||||||
// orbit itself, since X_1 = X_0^2 + C_ref = C_ref. That's only f32-accurate,
|
// orbit itself, since X_1 = X_0^2 + C_ref = C_ref. That's only f32-accurate,
|
||||||
// so skip the test once a pixel is smaller than that error (deep zoom),
|
// so skip the test once a pixel is smaller than that error (deep zoom),
|
||||||
// where it could misclassify pixels right at the boundary.
|
// where it could misclassify pixels right at the boundary.
|
||||||
if KIND == KIND_MANDELBROT && !MORPH && !IS_JULIA && ref_len > 1u && px > 1e-6 {
|
if KIND == KIND_MANDELBROT && !MORPH && !IS_JULIA && !DEEP && ref_len > 1u && px > 1e-6 {
|
||||||
let c = ref_orbit[1] + offset;
|
let c = ref_orbit[1] + offset;
|
||||||
let xq = c.x - 0.25;
|
let xq = c.x - 0.25;
|
||||||
let q = xq * xq + c.y * c.y;
|
let q = xq * xq + c.y * c.y;
|
||||||
@@ -333,6 +698,8 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
|||||||
// seeds the delta and nothing is added per step.
|
// seeds the delta and nothing is added per step.
|
||||||
var step_add = offset;
|
var step_add = offset;
|
||||||
var e = vec2<f32>(0.0, 0.0);
|
var e = vec2<f32>(0.0, 0.0);
|
||||||
|
// Pixel size in complex units (`px` is pre-scaled in DEEP pipelines).
|
||||||
|
var px_t = px;
|
||||||
// Orbit derivative for distance estimation, pre-multiplied by the pixel
|
// Orbit derivative for distance estimation, pre-multiplied by the pixel
|
||||||
// size `px`. For the set plane it is px·d/dc (starts at 0, gains +px each
|
// size `px`. For the set plane it is px·d/dc (starts at 0, gains +px each
|
||||||
// step); for Julia it is px·d/dz0 (starts at px). The raw derivative grows
|
// step); for Julia it is px·d/dz0 (starts at px). The raw derivative grows
|
||||||
@@ -343,7 +710,7 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
|||||||
if IS_JULIA {
|
if IS_JULIA {
|
||||||
step_add = vec2<f32>(0.0, 0.0);
|
step_add = vec2<f32>(0.0, 0.0);
|
||||||
e = offset;
|
e = offset;
|
||||||
dzs = vec2<f32>(px, 0.0);
|
dzs = vec2<f32>(px_t, 0.0);
|
||||||
}
|
}
|
||||||
// Previous-iterate state for the Phoenix two-term recurrence (delta of
|
// Previous-iterate state for the Phoenix two-term recurrence (delta of
|
||||||
// y_{n-1}, and its scaled derivative for DE). Both start at 0 (y_{-1} = 0).
|
// y_{n-1}, and its scaled derivative for DE). Both start at 0 (y_{-1} = 0).
|
||||||
@@ -368,9 +735,254 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
|||||||
var check_at = PERIOD_FIRST_CHECK;
|
var check_at = PERIOD_FIRST_CHECK;
|
||||||
var period_hit = false;
|
var period_hit = false;
|
||||||
var period_streak = 0u;
|
var period_streak = 0u;
|
||||||
|
// This window's save candidate: its iterate closest to the critical
|
||||||
|
// point, that distance squared, and the |f'|^2 product since it.
|
||||||
|
let crit = critical_point();
|
||||||
|
var z_cand = z;
|
||||||
|
var cand_d2 = 3.0e38;
|
||||||
|
var mult2_cand = 1.0;
|
||||||
|
|
||||||
|
// Deep phase (DEEP pipelines only): iterate the delta as e = w * 2^sx,
|
||||||
|
// with `w` an f32 mantissa kept near 1 by renormalizing and `sx` an i32
|
||||||
|
// exponent, while |e| is too small for f32 (it starts at the pixel offset,
|
||||||
|
// ~2^scale_exp). The DE derivative is linear in the same way and carried
|
||||||
|
// as dzs = v * 2^sv, with its own exponent: near the critical point (after
|
||||||
|
// a rebase) f' is tiny, so dzs and e can drift far apart. Once both are
|
||||||
|
// big enough (or a rebase makes the delta large), the state is converted
|
||||||
|
// to plain f32 and the loop below carries on from the same n / m.
|
||||||
|
if DEEP {
|
||||||
|
let scale_e = u.scale_exp;
|
||||||
|
var sx = scale_e;
|
||||||
|
var sv = scale_e;
|
||||||
|
var sc = ldexp_sat(1.0, sx);
|
||||||
|
var w = vec2<f32>(0.0, 0.0);
|
||||||
|
var v = vec2<f32>(0.0, 0.0);
|
||||||
|
var w_prev = vec2<f32>(0.0, 0.0);
|
||||||
|
var v_prev = vec2<f32>(0.0, 0.0);
|
||||||
|
// Per-step additions (dc and px for the set plane), in units of 2^sx
|
||||||
|
// and 2^sv.
|
||||||
|
var d = offset;
|
||||||
|
var pd = px;
|
||||||
|
if IS_JULIA {
|
||||||
|
w = offset;
|
||||||
|
v = vec2<f32>(px, 0.0);
|
||||||
|
d = vec2<f32>(0.0, 0.0);
|
||||||
|
pd = 0.0;
|
||||||
|
}
|
||||||
|
let z0f = ref_fe(0u);
|
||||||
|
var xf = z0f;
|
||||||
|
var xt = z0;
|
||||||
|
var xf_old = xf;
|
||||||
|
var xt_old = xt;
|
||||||
|
var w_old = w;
|
||||||
|
var rebase_exit = false;
|
||||||
|
var cut_exit = false;
|
||||||
|
loop {
|
||||||
|
// Full value y = X + e as f32 (e flushes to 0 when negligible).
|
||||||
|
let yt = xt + ldexp2_sat(w, sx);
|
||||||
|
if dot(yt, yt) > bailout_sq {
|
||||||
|
escaped = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if n >= max_iter {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if deep_cut_crossing(xf, w, sx) {
|
||||||
|
cut_exit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if DE {
|
||||||
|
let fp = deep_fprime(xf, w, sx, yt);
|
||||||
|
if fp.e == 0 {
|
||||||
|
var v_new = cmul(fp.m, v);
|
||||||
|
if !IS_JULIA {
|
||||||
|
v_new.x = v_new.x + pd;
|
||||||
|
}
|
||||||
|
if phoenix_w > 0.0 {
|
||||||
|
v_new = v_new + phoenix_w * cmul(u.phoenix_p, v_prev);
|
||||||
|
v_prev = v;
|
||||||
|
}
|
||||||
|
v = v_new;
|
||||||
|
} else {
|
||||||
|
// f' is below f32's range (next to the critical point):
|
||||||
|
// sum the terms at the largest one's scale and move
|
||||||
|
// there, like the delta below.
|
||||||
|
var acc = fe_make(cmul(fp.m, v), fp.e + sv);
|
||||||
|
if !IS_JULIA {
|
||||||
|
acc = fe_add(acc, fe_make(vec2<f32>(px, 0.0), scale_e));
|
||||||
|
}
|
||||||
|
if phoenix_w > 0.0 {
|
||||||
|
acc = fe_add(acc, fe_make(phoenix_w * cmul(u.phoenix_p, v_prev), sv));
|
||||||
|
}
|
||||||
|
if acc.m.x == 0.0 && acc.m.y == 0.0 {
|
||||||
|
if phoenix_w > 0.0 {
|
||||||
|
v_prev = v;
|
||||||
|
}
|
||||||
|
v = acc.m;
|
||||||
|
} else {
|
||||||
|
if phoenix_w > 0.0 {
|
||||||
|
v_prev = ldexp2_sat(v, sv - acc.e);
|
||||||
|
}
|
||||||
|
v = acc.m;
|
||||||
|
sv = acc.e;
|
||||||
|
if !IS_JULIA {
|
||||||
|
pd = ldexp_sat(px, scale_e - sv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w_old = w;
|
||||||
|
let st = advance_delta_scaled(xt, xf, w, sc, sx);
|
||||||
|
if st.t == sx {
|
||||||
|
w = st.w + d;
|
||||||
|
if phoenix_w > 0.0 {
|
||||||
|
w = w + phoenix_w * cmul(u.phoenix_p, w_prev);
|
||||||
|
w_prev = w_old;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// The step's value is at another scale (next to the
|
||||||
|
// critical point it is ~e^2, far below 2^sx): add dc and the
|
||||||
|
// Phoenix term at the largest addend's scale and move there.
|
||||||
|
var acc = fe_make(st.w, st.t);
|
||||||
|
if !IS_JULIA {
|
||||||
|
acc = fe_add(acc, fe_make(offset, scale_e));
|
||||||
|
}
|
||||||
|
if phoenix_w > 0.0 {
|
||||||
|
acc = fe_add(acc, fe_make(phoenix_w * cmul(u.phoenix_p, w_prev), sx));
|
||||||
|
}
|
||||||
|
if acc.m.x == 0.0 && acc.m.y == 0.0 {
|
||||||
|
w = acc.m;
|
||||||
|
if phoenix_w > 0.0 {
|
||||||
|
w_prev = w_old;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// w_old (the pre-step delta) is still needed at the new
|
||||||
|
// scale: it's the next previous delta, and a rebase reads it.
|
||||||
|
w_old = ldexp2_sat(w_old, sx - acc.e);
|
||||||
|
if phoenix_w > 0.0 {
|
||||||
|
w_prev = w_old;
|
||||||
|
}
|
||||||
|
w = acc.m;
|
||||||
|
sx = acc.e;
|
||||||
|
sc = ldexp_sat(1.0, sx);
|
||||||
|
if !IS_JULIA {
|
||||||
|
d = ldexp2_sat(offset, scale_e - sx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m = m + 1u;
|
||||||
|
n = n + 1u;
|
||||||
|
|
||||||
|
if m >= ref_len {
|
||||||
|
escaped = true; // see the f32 loop's reference-exhausted case
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
xf_old = xf;
|
||||||
|
xt_old = xt;
|
||||||
|
xf = ref_fe(m);
|
||||||
|
xt = ldexp2_sat(xf.m, xf.e);
|
||||||
|
|
||||||
|
// Rebase test |X + e| < |e|, in units of 2^sx. Only possible when
|
||||||
|
// |X| is within a few binades of |e| (|w| < 2^DEEP_RENORM_LOG2).
|
||||||
|
if xf.e - sx < DEEP_NEAR_LOG2 {
|
||||||
|
let q = ldexp2_sat(xf.m, xf.e - sx) + w;
|
||||||
|
if dot(q, q) < dot(w, w) {
|
||||||
|
// The new delta y - X[0] stays tiny only if X[0] is
|
||||||
|
// (always, for the set plane), and for Phoenix only if the
|
||||||
|
// previous full value y_{n-1} (its new previous delta) is.
|
||||||
|
let z0_small = (z0f.m.x == 0.0 && z0f.m.y == 0.0)
|
||||||
|
|| z0f.e - sx < DEEP_NEAR_LOG2;
|
||||||
|
let prev_small = phoenix_w == 0.0 || xf_old.e - sx < DEEP_NEAR_LOG2;
|
||||||
|
if !(z0_small && prev_small) {
|
||||||
|
rebase_exit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if phoenix_w > 0.0 {
|
||||||
|
w_prev = ldexp2_sat(xf_old.m, xf_old.e - sx) + w_old;
|
||||||
|
}
|
||||||
|
w = q - ldexp2_sat(z0f.m, z0f.e - sx);
|
||||||
|
xf = z0f;
|
||||||
|
xt = z0;
|
||||||
|
m = 0u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave once both the delta and the derivative fit in f32 (an
|
||||||
|
// exactly-zero one always does); otherwise renormalize the
|
||||||
|
// mantissas when they drift (exact: powers of two only).
|
||||||
|
let kw = deep_log2(w, w_prev);
|
||||||
|
let kv = deep_log2(v, v_prev);
|
||||||
|
let w_ok = kw == DEEP_ZERO || sx + kw > DEEP_EXIT_LOG2;
|
||||||
|
let v_ok = !DE || kv == DEEP_ZERO || sv + kv > DEEP_EXIT_DZ_LOG2;
|
||||||
|
if w_ok && v_ok {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if kw != DEEP_ZERO && abs(kw) > DEEP_RENORM_LOG2 {
|
||||||
|
w = ldexp2_sat(w, -kw);
|
||||||
|
w_prev = ldexp2_sat(w_prev, -kw);
|
||||||
|
sx = sx + kw;
|
||||||
|
sc = ldexp_sat(1.0, sx);
|
||||||
|
if !IS_JULIA {
|
||||||
|
d = ldexp2_sat(offset, scale_e - sx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if DE && kv != DEEP_ZERO && abs(kv) > DEEP_RENORM_LOG2 {
|
||||||
|
v = ldexp2_sat(v, -kv);
|
||||||
|
v_prev = ldexp2_sat(v_prev, -kv);
|
||||||
|
sv = sv + kv;
|
||||||
|
if !IS_JULIA {
|
||||||
|
pd = ldexp_sat(px, scale_e - sv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand over to the f32 loop. dc and px may flush to 0 here: they
|
||||||
|
// are below f32 rounding of the (now large enough) delta and
|
||||||
|
// derivative.
|
||||||
|
px_t = ldexp_sat(px, scale_e);
|
||||||
|
if !IS_JULIA {
|
||||||
|
step_add = ldexp2_sat(offset, scale_e);
|
||||||
|
}
|
||||||
|
e = ldexp2_sat(w, sx);
|
||||||
|
e_prev = ldexp2_sat(w_prev, sx);
|
||||||
|
dzs = ldexp2_sat(v, sv);
|
||||||
|
dzs_prev = ldexp2_sat(v_prev, sv);
|
||||||
|
xm = xt;
|
||||||
|
if rebase_exit {
|
||||||
|
// Rebase in plain f32: the new delta (y - X[0], and for Phoenix
|
||||||
|
// the previous full value) is no longer tiny.
|
||||||
|
if phoenix_w > 0.0 {
|
||||||
|
e_prev = xt_old + ldexp2_sat(w_old, sx);
|
||||||
|
}
|
||||||
|
e = (xt + e) - z0;
|
||||||
|
xm = z0;
|
||||||
|
m = 0u;
|
||||||
|
}
|
||||||
|
if cut_exit && e.y == 0.0 && w.y != 0.0 {
|
||||||
|
// Keep the side of the cut the pixel is on even if e.y flushed
|
||||||
|
// to 0: that decides the branch in the next (f32) step.
|
||||||
|
e.y = select(-1.17549435e-38, 1.17549435e-38, w.y > 0.0);
|
||||||
|
}
|
||||||
|
z = xm + e;
|
||||||
|
z2 = dot(z, z);
|
||||||
|
// Restart periodicity detection from here (check_at must stay ahead
|
||||||
|
// of n, or no window would ever close). Nothing is saved until the
|
||||||
|
// first window closes: `z` here is at an arbitrary phase, and the
|
||||||
|
// pixel still shadows the reference (exactly periodic when it's a
|
||||||
|
// minibrot nucleus), so comparing against it would flag exterior
|
||||||
|
// pixels as interior (see PERIOD_FIRST_CHECK). The sentinel is far
|
||||||
|
// outside the bailout radius, so no return can match it.
|
||||||
|
z_saved = vec2<f32>(1e18, 1e18);
|
||||||
|
z_cand = z;
|
||||||
|
while check_at <= n {
|
||||||
|
check_at = check_at * 2u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if z2 > bailout_sq {
|
// (`escaped` may already be set by the deep phase.)
|
||||||
|
if escaped || z2 > bailout_sq {
|
||||||
escaped = true;
|
escaped = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -387,12 +999,14 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
|||||||
fp = fprime(z);
|
fp = fprime(z);
|
||||||
}
|
}
|
||||||
if periodic {
|
if periodic {
|
||||||
mult2 = mult2 * dot(fp, fp);
|
let fp2 = dot(fp, fp);
|
||||||
|
mult2 = mult2 * fp2;
|
||||||
|
mult2_cand = mult2_cand * fp2;
|
||||||
}
|
}
|
||||||
if DE {
|
if DE {
|
||||||
var dzs_new = cmul(fp, dzs);
|
var dzs_new = cmul(fp, dzs);
|
||||||
if !IS_JULIA {
|
if !IS_JULIA {
|
||||||
dzs_new.x = dzs_new.x + px;
|
dzs_new.x = dzs_new.x + px_t;
|
||||||
}
|
}
|
||||||
if phoenix_w > 0.0 {
|
if phoenix_w > 0.0 {
|
||||||
dzs_new = dzs_new + phoenix_w * cmul(u.phoenix_p, dzs_prev);
|
dzs_new = dzs_new + phoenix_w * cmul(u.phoenix_p, dzs_prev);
|
||||||
@@ -421,7 +1035,7 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
|||||||
escaped = true;
|
escaped = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
xm = ref_orbit[m];
|
xm = ref_at(m);
|
||||||
z = xm + e;
|
z = xm + e;
|
||||||
z2 = dot(z, z);
|
z2 = dot(z, z);
|
||||||
if z2 < dot(e, e) {
|
if z2 < dot(e, e) {
|
||||||
@@ -449,13 +1063,20 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let dc2 = dot(z - crit, z - crit);
|
||||||
|
if dc2 < cand_d2 {
|
||||||
|
cand_d2 = dc2;
|
||||||
|
z_cand = z;
|
||||||
|
mult2_cand = 1.0;
|
||||||
|
}
|
||||||
if n == check_at {
|
if n == check_at {
|
||||||
if !period_hit {
|
if !period_hit {
|
||||||
period_streak = 0u;
|
period_streak = 0u;
|
||||||
}
|
}
|
||||||
period_hit = false;
|
period_hit = false;
|
||||||
z_saved = z;
|
z_saved = z_cand;
|
||||||
mult2 = 1.0;
|
mult2 = mult2_cand;
|
||||||
|
cand_d2 = 3.0e38;
|
||||||
check_at = check_at * 2u;
|
check_at = check_at * 2u;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -492,14 +1113,51 @@ fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
|
|||||||
// filaments stay crisp instead of aliasing into speckle. If |dzs|
|
// filaments stay crisp instead of aliasing into speckle. If |dzs|
|
||||||
// overflowed (far sub-pixel from the set), de -> 0 and the boundary
|
// overflowed (far sub-pixel from the set), de -> 0 and the boundary
|
||||||
// simply reads as dark, which is the correct limit.
|
// simply reads as dark, which is the correct limit.
|
||||||
|
// Shadow/3D use DE as a height field, so it must stay unclamped: any
|
||||||
|
// cap flattens everything farther than that from the set into a
|
||||||
|
// uniform plateau (a visible circle around the set when zoomed out).
|
||||||
|
// 3D saturates heights smoothly itself (`sdf` in colorize.wgsl).
|
||||||
let zmag = sqrt(max(z2, 1.0));
|
let zmag = sqrt(max(z2, 1.0));
|
||||||
let dzmag = sqrt(max(dot(dzs_esc, dzs_esc), 1e-30));
|
let dzmag = sqrt(max(dot(dzs_esc, dzs_esc), 1e-30));
|
||||||
let max_de = select(1.0, 1000.0, u.shadow != 0u);
|
// |z|·ln|z|/|dz| is G/|G'| (G the potential, ln|z|/2^n). Far from the
|
||||||
de = clamp(zmag * log(zmag) / dzmag, 0.0, max_de);
|
// set G ~ ln r, so it grows like r·ln r rather than r: zoomed far out,
|
||||||
|
// the 3D cone got steeper with every zoom step, down to a texel-wide
|
||||||
|
// needle cut off above the plateau. Replacing G by 2(1 - e^(-G/2))
|
||||||
|
// leaves it unchanged near the set (G -> 0, and ~0.8x at the edge of
|
||||||
|
// the default view) but caps it at 2, so far away DE grows like 2r
|
||||||
|
// and the cone stops narrowing. G underflows to 0 past ~150
|
||||||
|
// iterations, where the factor is 1 anyway. G divides by the map's
|
||||||
|
// degree d per step, not 2: with 2^-n, pixels either side of a band
|
||||||
|
// boundary got G off by d/2, a DE seam on every band for d != 2.
|
||||||
|
let g = log(zmag) * pow(escape_degree(), -f32(n));
|
||||||
|
let far = select(1.0, 2.0 * (1.0 - exp(-g / 2.0)) / g, g > 1e-4);
|
||||||
|
let max_de = select(1.0, 1e30, u.shadow != 0u);
|
||||||
|
de = clamp(zmag * log(zmag) / dzmag * far, 0.0, max_de);
|
||||||
}
|
}
|
||||||
return Sample(ci, de, true);
|
return Sample(ci, de, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Degree d of the current map at infinity (|f(z)| ~ |z|^d), which sets how
|
||||||
|
// fast the potential G = ln|z_n| / d^n shrinks per step. Complex Multibrot's
|
||||||
|
// |z^p| = |z|^Re(p)·e^(-Im(p)·arg z) grows like |z|^Re(p) (arg is bounded).
|
||||||
|
// A morph blend is dominated by the higher degree.
|
||||||
|
fn kind_degree(kind: u32) -> f32 {
|
||||||
|
if kind == KIND_MULTIBROT {
|
||||||
|
return f32(clamp(u.power, 2u, MULTIBROT_MAX_POWER));
|
||||||
|
} else if kind == KIND_COMPLEX_MULTIBROT {
|
||||||
|
return max(u.complex_power.x, 1.0);
|
||||||
|
}
|
||||||
|
return 2.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_degree() -> f32 {
|
||||||
|
let d = kind_degree(KIND);
|
||||||
|
if MORPH {
|
||||||
|
return max(d, kind_degree(u.morph_from));
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
// 1 / ln(2), for the smooth iteration count's log2(ln|z| / ln 2).
|
// 1 / ln(2), for the smooth iteration count's log2(ln|z| / ln 2).
|
||||||
const INV_LN2: f32 = 1.4426950408889634;
|
const INV_LN2: f32 = 1.4426950408889634;
|
||||||
|
|
||||||
@@ -576,7 +1234,7 @@ fn fs_data(in: VsOut) -> @location(0) vec4<f32> {
|
|||||||
// 4-neighbour's 1-spp sample differs from its own by more than this. `ci`
|
// 4-neighbour's 1-spp sample differs from its own by more than this. `ci`
|
||||||
// steps are palette-phase steps of `ci * color_scale` (color_scale <= 1 in the
|
// steps are palette-phase steps of `ci * color_scale` (color_scale <= 1 in the
|
||||||
// UI), so 0.02 keeps anything visibly banded; DE is compared relative to its
|
// UI), so 0.02 keeps anything visibly banded; DE is compared relative to its
|
||||||
// own magnitude (it's in pixels, up to 1000 for shadow/3D height fields).
|
// own magnitude (it's in pixels, unbounded for shadow/3D height fields).
|
||||||
const AA_CI_EPS: f32 = 0.02;
|
const AA_CI_EPS: f32 = 0.02;
|
||||||
const AA_DE_EPS: f32 = 0.1;
|
const AA_DE_EPS: f32 = 0.1;
|
||||||
|
|
||||||
|
|||||||
+400
-86
@@ -1,65 +1,303 @@
|
|||||||
//! 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.
|
/// Current zoom level.
|
||||||
pub fn zoom(&self) -> f64 {
|
pub fn zoom(&self) -> Scale {
|
||||||
self.half_height
|
self.half_height
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,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);
|
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
|
||||||
@@ -100,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);
|
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,
|
||||||
@@ -121,8 +359,7 @@ 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
|
/// Parse a "re,im,half_height[,iterations]" spec (re/im decimal, parsed at
|
||||||
@@ -134,10 +371,7 @@ pub fn parse_view_spec(spec: &str) -> Option<(ViewState, Option<u32>)> {
|
|||||||
if parts.len() < 3 {
|
if parts.len() < 3 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let half_height = parts[2].trim().parse::<f64>().ok()?;
|
let half_height = parse_half_height_spec(parts[2])?;
|
||||||
if !(half_height > 0.0 && half_height.is_finite()) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let bits = precision_for(half_height);
|
let bits = precision_for(half_height);
|
||||||
let re = big_from_decimal_str(parts[0], bits)?;
|
let re = big_from_decimal_str(parts[0], bits)?;
|
||||||
let im = big_from_decimal_str(parts[1], bits)?;
|
let im = big_from_decimal_str(parts[1], bits)?;
|
||||||
@@ -148,12 +382,8 @@ pub fn parse_view_spec(spec: &str) -> Option<(ViewState, Option<u32>)> {
|
|||||||
/// Parse a half_height spec. Shared by
|
/// Parse a half_height spec. Shared by
|
||||||
/// `FractalApp::apply_half_height_spec` (the `--zoom` CLI flag) and headless
|
/// `FractalApp::apply_half_height_spec` (the `--zoom` CLI flag) and headless
|
||||||
/// animation's `--to-zoom`.
|
/// animation's `--to-zoom`.
|
||||||
pub fn parse_half_height_spec(spec: &str) -> Option<f64> {
|
pub fn parse_half_height_spec(spec: &str) -> Option<Scale> {
|
||||||
let half_height = spec.trim().parse::<f64>().ok()?;
|
spec.parse::<Scale>().ok()
|
||||||
if !(half_height > 0.0 && half_height.is_finite()) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(half_height)
|
|
||||||
}
|
}
|
||||||
/// Parse a "re,im" spec (re/im decimal, parsed at
|
/// Parse a "re,im" spec (re/im decimal, parsed at
|
||||||
/// full precision) into a view. Shared by
|
/// full precision) into a view. Shared by
|
||||||
@@ -182,77 +412,87 @@ pub fn parse_re_im_spec(spec: &str, bits: usize) -> Option<(Big, Big)> {
|
|||||||
/// offset/half_height ratio roughly constant, i.e. the target's on-screen
|
/// 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
|
/// position steady) but is shifted so it lands on exactly 1 at `t = 0` and
|
||||||
/// exactly 0 at `t = 1`.
|
/// 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 {
|
pub fn interpolate_view(from: &ViewState, to: &ViewState, t: f64) -> ViewState {
|
||||||
let q = to.half_height / from.half_height;
|
let (l0, l1) = (from.half_height.log2(), to.half_height.log2());
|
||||||
let half_height = from.half_height * q.powf(t);
|
let d = l1 - l0;
|
||||||
let bits = precision_for(half_height);
|
let half_height = if t <= 0.0 {
|
||||||
let g = if (q - 1.0).abs() < 1e-12 {
|
from.half_height
|
||||||
1.0 - t
|
} else if t >= 1.0 {
|
||||||
|
to.half_height
|
||||||
} else {
|
} else {
|
||||||
(q.powf(t) - q) / (1.0 - q)
|
Scale::from_log2(l0 + t * d)
|
||||||
};
|
};
|
||||||
let g_big = big_from_f64(g, bits);
|
let bits = precision_for(half_height);
|
||||||
let re0 = from.center_re.clone().with_precision(bits).value();
|
let ln2 = core::f64::consts::LN_2;
|
||||||
let im0 = from.center_im.clone().with_precision(bits).value();
|
let g_big = if d.abs() < 1e-12 {
|
||||||
let re1 = to.center_re.clone().with_precision(bits).value();
|
big_from_f64(1.0 - t, bits)
|
||||||
let im1 = to.center_im.clone().with_precision(bits).value();
|
} 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_re = &re1 + &(&(&re0 - &re1) * &g_big);
|
||||||
let center_im = &im1 + &(&(&im0 - &im1) * &g_big);
|
let center_im = &im1 + &(&(&im0 - &im1) * &g_big);
|
||||||
ViewState::with_center(center_re, center_im, half_height)
|
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 {
|
pub fn interpolate_f64(from: f64, to: f64, t: f64) -> f64 {
|
||||||
from + (to - from) * t
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn sc(x: f64) -> Scale {
|
||||||
|
Scale::from_f64(x)
|
||||||
|
}
|
||||||
|
|
||||||
fn re_im_f64(v: &ViewState) -> (f64, f64) {
|
fn re_im_f64(v: &ViewState) -> (f64, f64) {
|
||||||
let re: f64 = v.center_re.to_decimal().value().to_f64().value();
|
let re: f64 = v.center_re.to_f64();
|
||||||
let im: f64 = v.center_im.to_decimal().value().to_f64().value();
|
let im: f64 = v.center_im.to_f64();
|
||||||
(re, im)
|
(re, im)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interpolate_view_hits_exact_endpoints() {
|
fn interpolate_view_hits_exact_endpoints() {
|
||||||
let bits = precision_for(1.0);
|
let bits = precision_for(sc(1.0));
|
||||||
let from = ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), 1.5);
|
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(
|
let to = ViewState::with_center(
|
||||||
big_from_f64(-0.7515, precision_for(1e-20)),
|
big_from_f64(-0.7515, precision_for(sc(1e-20))),
|
||||||
big_from_f64(0.1013, precision_for(1e-20)),
|
big_from_f64(0.1013, precision_for(sc(1e-20))),
|
||||||
1e-20,
|
sc(1e-20),
|
||||||
);
|
);
|
||||||
|
|
||||||
let start = interpolate_view(&from, &to, 0.0);
|
let start = interpolate_view(&from, &to, 0.0);
|
||||||
@@ -272,12 +512,13 @@ mod tests {
|
|||||||
/// instead stay roughly bounded throughout.
|
/// instead stay roughly bounded throughout.
|
||||||
#[test]
|
#[test]
|
||||||
fn interpolate_view_keeps_target_offset_bounded() {
|
fn interpolate_view_keeps_target_offset_bounded() {
|
||||||
let bits = precision_for(1.0);
|
let bits = precision_for(sc(1.0));
|
||||||
let from = ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), 1.5);
|
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(
|
let to = ViewState::with_center(
|
||||||
big_from_f64(-0.7515, precision_for(1e-20)),
|
big_from_f64(-0.7515, precision_for(sc(1e-20))),
|
||||||
big_from_f64(0.1013, precision_for(1e-20)),
|
big_from_f64(0.1013, precision_for(sc(1e-20))),
|
||||||
1e-20,
|
sc(1e-20),
|
||||||
);
|
);
|
||||||
let (to_re, to_im) = re_im_f64(&to);
|
let (to_re, to_im) = re_im_f64(&to);
|
||||||
|
|
||||||
@@ -286,7 +527,7 @@ mod tests {
|
|||||||
let mid = interpolate_view(&from, &to, t);
|
let mid = interpolate_view(&from, &to, t);
|
||||||
let (re, im) = re_im_f64(&mid);
|
let (re, im) = re_im_f64(&mid);
|
||||||
let offset = ((re - to_re).powi(2) + (im - to_im).powi(2)).sqrt();
|
let offset = ((re - to_re).powi(2) + (im - to_im).powi(2)).sqrt();
|
||||||
let ratio = offset / mid.half_height;
|
let ratio = offset / mid.half_height.to_f64();
|
||||||
assert!(
|
assert!(
|
||||||
ratio < 10.0,
|
ratio < 10.0,
|
||||||
"t={t}: offset/half_height ratio {ratio} blew up (offset={offset}, half_height={})",
|
"t={t}: offset/half_height ratio {ratio} blew up (offset={offset}, half_height={})",
|
||||||
@@ -294,4 +535,77 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -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,13 +9,13 @@
|
|||||||
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,
|
||||||
@@ -35,8 +35,8 @@ pub struct RefRequest {
|
|||||||
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
|
/// The kind and morph `points` was computed with (echoed from the
|
||||||
/// request).
|
/// request).
|
||||||
pub kind: FractalKind,
|
pub kind: FractalKind,
|
||||||
@@ -102,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);
|
||||||
|
|||||||
+15
-1
@@ -77,7 +77,7 @@ fn mandelbrot_shader_is_valid() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Every specialization renderer.rs can build (`PipelineKey`: kind × Julia ×
|
/// Every specialization renderer.rs can build (`PipelineKey`: kind × Julia ×
|
||||||
/// DE × morph), for every fragment entry point.
|
/// DE × morph × deep), for every fragment entry point.
|
||||||
#[test]
|
#[test]
|
||||||
fn mandelbrot_shader_specializations_compile() {
|
fn mandelbrot_shader_specializations_compile() {
|
||||||
let (module, info) = validate("mandelbrot.wgsl", MANDELBROT_SRC);
|
let (module, info) = validate("mandelbrot.wgsl", MANDELBROT_SRC);
|
||||||
@@ -85,11 +85,13 @@ fn mandelbrot_shader_specializations_compile() {
|
|||||||
for julia in [0.0, 1.0] {
|
for julia in [0.0, 1.0] {
|
||||||
for de in [0.0, 1.0] {
|
for de in [0.0, 1.0] {
|
||||||
for morph in [0.0, 1.0] {
|
for morph in [0.0, 1.0] {
|
||||||
|
for deep in [0.0, 1.0] {
|
||||||
let constants = [
|
let constants = [
|
||||||
("KIND", kind as f64),
|
("KIND", kind as f64),
|
||||||
("IS_JULIA", julia),
|
("IS_JULIA", julia),
|
||||||
("DE", de),
|
("DE", de),
|
||||||
("MORPH", morph),
|
("MORPH", morph),
|
||||||
|
("DEEP", deep),
|
||||||
];
|
];
|
||||||
for entry in ["fs_data", "fs_refine", "fs_color"] {
|
for entry in ["fs_data", "fs_refine", "fs_color"] {
|
||||||
specialize(
|
specialize(
|
||||||
@@ -106,6 +108,7 @@ fn mandelbrot_shader_specializations_compile() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn colorize_shader_is_valid() {
|
fn colorize_shader_is_valid() {
|
||||||
@@ -119,6 +122,17 @@ fn colorize_shader_is_valid() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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(
|
validate(
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
results/
|
||||||
|
.worktrees/
|
||||||
@@ -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.
|
||||||
Executable
+128
@@ -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
|
||||||
@@ -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`.
|
||||||
Executable
+145
@@ -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:]))
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
clips/
|
||||||
|
work/
|
||||||
|
results/
|
||||||
@@ -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
|
||||||
Executable
+66
@@ -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
|
||||||
Executable
+87
@@ -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"
|
||||||
Reference in New Issue
Block a user