Compare commits
46
Commits
993185795f
..
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 | ||
|
|
61d4766088 | ||
|
|
51fa5ffa02 | ||
|
|
9cc5a80650 | ||
|
|
6a6796f528 | ||
|
|
b4ca187ba2 | ||
|
|
2bdb2356c6 | ||
|
|
5206a22bdd | ||
|
|
42dde04945 | ||
|
|
a3fd152dff | ||
|
|
7dd99cc1af | ||
|
|
63cee5d8b2 | ||
|
|
694f63ab9d | ||
|
|
7274f19b33 | ||
|
|
66669cd801 | ||
|
|
c2de1bc4bb | ||
|
|
07319874bf | ||
|
|
449b1b289b |
@@ -1,3 +1,6 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
dist
|
||||
frames*
|
||||
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
|
||||
~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
|
||||
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
|
||||
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
|
||||
cargo run --release # native, run (release matters: fractal math is hot)
|
||||
cargo test # reference-orbit math, share-link round-trip, WGSL validation
|
||||
cargo test --features wasm # same, on the web build's malachite big-float backend
|
||||
cargo test --test shader_valid # just the WGSL parse/validate tests (naga, no GPU needed)
|
||||
cargo clippy
|
||||
cargo fmt # rustfmt.toml just pins edition = "2024"
|
||||
cargo build --release --no-default-features # headless-only binary: no eframe/egui (the default `gui` feature)
|
||||
tools/bench/bench.sh --rev HEAD # perf of uncommitted edits vs HEAD (hyperfine; see tools/bench/README.md)
|
||||
```
|
||||
|
||||
Web build (WebGPU):
|
||||
@@ -27,14 +36,16 @@ Web build (WebGPU):
|
||||
```sh
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo install wasm-bindgen-cli --version 0.2.128 # must match the wasm-bindgen crate version
|
||||
./build-web.sh # -> ./dist
|
||||
./build-web.sh # -> ./dist (builds with --features wasm)
|
||||
python3 -m http.server -d dist 8080
|
||||
```
|
||||
|
||||
Native CLI flags (`src/cli.rs`, applied in `FractalApp::apply_cli`): `--kind`,
|
||||
`--power`, `--julia re,im`, `--phoenix-p re,im`, `--lambda-l re,im`,
|
||||
`--palette`, `--share <fragment>`,
|
||||
`--view re,im,half_height[,iterations]`, `--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
|
||||
entirely: it builds the same view from the other flags, creates its own
|
||||
offscreen wgpu device, and renders straight to a PNG (`--width`/`--height`,
|
||||
@@ -42,6 +53,44 @@ default 1920×1080, `--export-path out.png`) without needing a GPU-backed
|
||||
window/event loop. Not yet supported with `--buddhabrot`. Run
|
||||
`mandelbrot --help` for the full list.
|
||||
|
||||
`--headless` also has an animation mode, for feeding into `ffmpeg`: give any
|
||||
end-state flag alongside the start flags (`--view`/`--share`/`--kind`/
|
||||
`--julia`/...), plus `--frames N` or `--fps`/`--duration`. End-state flags:
|
||||
`--to-view re,im,half_height[,iterations]` or `--to-share <fragment>` (only
|
||||
position/zoom/iterations are pulled out of the link), `--to-iterations`,
|
||||
`--to-julia`, `--to-phoenix-p`, `--to-lambda-l`, `--to-complex-power`
|
||||
(or `--to-complex-power-re`/`--to-complex-power-im` to move one component),
|
||||
and `--to-kind` (per-step formula blend via `KindMorph`, camera untouched).
|
||||
Anything without a target stays at its start value; colors stay fixed.
|
||||
`--export-path` then names an output *directory* of `frame-00001.png`,
|
||||
`frame-00002.png`, ... instead of a single file. `--export-path -` writes
|
||||
to stdout instead (refused on a terminal): the PNG for a still, or for an
|
||||
animation raw RGBA8 frames in order (`unpad_rgba`, no PNG encode) for
|
||||
`ffmpeg -f rawvideo -pix_fmt rgba -s WxH -r FPS -i -`. A single writer
|
||||
thread reorders the frames. Its buffer is bounded by the orbit workers not
|
||||
starting a frame more than `window` past the last one written, not by
|
||||
blocking the writer, which could deadlock. `headless.rs::AnimTargets`
|
||||
collects the targets; the export pipeline is rebuilt only when the
|
||||
`PipelineKey` changes between frames (kind morph). `view::interpolate_view`
|
||||
does the camera: half-height geometrically (log-linear, since zoom spans many
|
||||
decades), center linearly through the complex plane at full `Big` precision;
|
||||
constants interpolate linearly. `--linear` swaps the default smoothstep
|
||||
easing for constant pacing. `--shards N --shard K` (1-based) renders only
|
||||
the K-th of N contiguous parts (`shard_range`), still timed against the whole
|
||||
animation (global `t`, global `frame-NNNNN.png` numbers; stdout streams just
|
||||
that part), so separately rendered clips join seamlessly. Without `--to-iterations` (or a share link's),
|
||||
iteration count auto-scales with zoom depth per frame (same
|
||||
`auto_iteration_count` the interactive app uses while zooming).
|
||||
`--to-yaw`/`--to-pitch` (degrees, from `--yaw`/`--pitch`, yaw unwrapped so
|
||||
`--to-yaw 720` is two turns) orbit the 3D camera with `--rendering-kind 3d`.
|
||||
Frames are pipelined across every core (`run_animation`): each frame's
|
||||
state is a pure function of `t` (`apply_frame`), so all frames'
|
||||
`FractalApp::reference_job`s are snapshotted up front and `RefJob::compute`d
|
||||
by a worker pool. The main thread renders them on the GPU as they arrive
|
||||
(out of order), and another pool PNG-encodes and writes them
|
||||
(`encode_png`, `Compression::Fast`). Channels are bounded. Once orbits and
|
||||
encoding are off the main thread, the GPU is usually the bottleneck.
|
||||
|
||||
There's no GPU in most sandboxes: `cargo check`/`cargo test --test shader_valid`
|
||||
are the fast, headless way to validate a change. `cargo test` also runs but
|
||||
doesn't touch the GPU — the reference-orbit tests are pure CPU math (see
|
||||
@@ -59,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
|
||||
pixel is a handful of `f32` complex multiplies.
|
||||
|
||||
- `src/view.rs` — `ViewState`; center is arbitrary-precision `FBig` (`Big`
|
||||
type alias), pixel scale stays `f64` (still in-range at 10³⁰×). Precision
|
||||
(bits) scales with zoom depth (`precision_for`).
|
||||
- `src/bignum/` — `Big`, the arbitrary-precision binary float, with one
|
||||
backend per library behind the same inherent API + operators: `rug`
|
||||
(GMP/MPFR, default, fastest, can't target wasm32) and `malachite-float`
|
||||
(pure Rust, the `wasm` feature, required for the web build; a
|
||||
`compile_error!` enforces it). Cargo features are additive, so rug is a
|
||||
non-wasm32 target dependency and the backend is picked by
|
||||
`cfg(feature = "wasm")`. Both follow the precision rule: a result has the
|
||||
larger operand precision, rounded to nearest; shifts are exact. Malachite's
|
||||
zero has no precision, so its wrapper stores `prec` alongside. Any new
|
||||
`Big` operation must be added to both backends (`cargo test` and
|
||||
`cargo test --features wasm` run the same tests on each).
|
||||
- `src/view.rs` — `ViewState`; center is arbitrary-precision `Big`
|
||||
(re-exported as `view::Big`). The pixel scale (`half_height`) is a `Scale`, an f64
|
||||
mantissa with its own i32 exponent, so it goes past f64's ~1e-308. Never
|
||||
collapse it (or a center difference) to a plain `f64` on a path used at
|
||||
depth. Rescale first: `Scale::scaled_f64(k)`, or shift the `Big` by
|
||||
`-scale_exp` before `to_f64()`, as `dc_offset`/`drift_from` do.
|
||||
`Display`/`FromStr` use scientific notation of any exponent (share links,
|
||||
`--view`, the zoom field). Precision (bits) scales with zoom depth
|
||||
(`precision_for`). `needs_deep` switches rendering to the deep pipeline
|
||||
once a pixel of the full-resolution render is below `DEEP_PIXEL_SIZE`
|
||||
(2^-122; the f32 path is exact down to 2^-124 with AA's quarter-pixel
|
||||
offsets, measured, and the deep path is ~40% slower, so the switch is as
|
||||
late as that allows). `deep_scale_exp` gives the scale exponent.
|
||||
`make_uniforms(aspect, height_px)` takes that full-resolution height, the
|
||||
same during the interaction-downscaled pass so the pipeline doesn't flip.
|
||||
- `src/fractal/kind.rs` — the `FractalKind` enum (Mandelbrot, Burning Ship,
|
||||
Tricorn, Multibrot, Celtic, Perpendicular, Buffalo, Phoenix, Lambda,
|
||||
Complex Multibrot) plus everything that only needs to switch on it:
|
||||
@@ -70,7 +142,24 @@ pixel is a handful of `f32` complex multiplies.
|
||||
`ALL` array used to enumerate every kind.
|
||||
- `src/fractal/reference.rs` — `compute_reference`/`compute_set_reference`:
|
||||
iterate the chosen formula at high precision on the CPU, emitting `Z_n` as
|
||||
`f32` pairs — that's the reference orbit the GPU perturbs from.
|
||||
`f32` pairs — that's the reference orbit the GPU perturbs from. At
|
||||
precision ≤ `F64_MAX_PRECISION` (80 bits, i.e. shallow views) it takes a
|
||||
plain-`f64` fast path (`compute_reference_f64`), so each kind's formula
|
||||
exists twice in this file (f64 + `Big`) and both must stay in sync;
|
||||
`f64_fast_path_matches_big` checks they agree. The result is a `RefOrbit`:
|
||||
`points` plus a parallel `exps`. A point below 2^-100 (only possible on the
|
||||
`Big` path) is stored as a normalized mantissa with its exponent in `exps`
|
||||
(the true value is `points[n]·2^exps[n]`). That happens when the orbit
|
||||
passes near 0 at a deep minibrot. `has_scaled()` then forces the deep
|
||||
pipeline, the only one that reads `exps`. Requests are made with 1.5×
|
||||
iteration headroom (`reference_iterations` in `app.rs`), so auto-iterations
|
||||
creeping up during a zoom doesn't recompute the orbit every frame. The
|
||||
interactive reference buffers start at 2^17 points and grow on demand
|
||||
(`FractalRenderer::ensure_ref_capacity`) up to `MAX_REF_POINTS` (2^24, the
|
||||
128 MiB WebGPU default binding size). That is also the hard iteration
|
||||
ceiling (`app.rs::MAX_ITERATIONS`), because the shader treats an exhausted
|
||||
reference as escaped. The UI slider only goes to 100k when dragged; typed
|
||||
values can go higher.
|
||||
- `src/shaders/*.wgsl` — none of these are standalone WGSL modules; WGSL has
|
||||
no `#include`, so each is compiled by concatenating plain-text fragments
|
||||
with `concat!`/`include_str!` at the `create_shader_module` call site (see
|
||||
@@ -83,7 +172,25 @@ pixel is a handful of `f32` complex multiplies.
|
||||
Because there's no namespacing, a definition must live in exactly one file
|
||||
among those concatenated together for a given shader — don't redefine a
|
||||
`common.wgsl`/`iterate_uniforms.wgsl` symbol locally.
|
||||
- `src/shaders/mandelbrot.wgsl` — the perturbation fragment shader.
|
||||
- `src/shaders/mandelbrot.wgsl` — the perturbation fragment shader. It is
|
||||
**specialized per pipeline** through WGSL `override` constants (`KIND`,
|
||||
`IS_JULIA`, `DE`), so the per-iteration kind/Julia/DE branches fold away at
|
||||
pipeline creation. Read those constants in the shader, never `u.kind` /
|
||||
`u.is_julia` / `u.de_coloring` (they're still uploaded for layout reasons).
|
||||
`renderer.rs` builds one pipeline set per `PipelineKey` lazily on first
|
||||
use, and `tests/shader_valid.rs` compiles every kind × Julia × DE × morph ×
|
||||
deep variant to SPIR-V. So a new kind needs no pipeline-list change, only its `KIND_*`
|
||||
constant. `buddhabrot.wgsl` does the same with its own `override KIND`.
|
||||
Interior pixels exit early through **periodicity detection**. It uses
|
||||
Brent-style checkpoints plus two guards: the cycle's multiplier must be
|
||||
clearly attracting (`PERIOD_MAX_MULT2`), and the contracting return must
|
||||
repeat in `PERIOD_CONFIRMATIONS` consecutive windows. Both guards are
|
||||
needed: without them, exterior pixels at cusps and minibrot edges turned
|
||||
black. Retune them only against f64 ground truth on such views. Each
|
||||
window saves its iterate closest to the critical point, not the one at the
|
||||
checkpoint. At an arbitrary phase the relative tolerance is far coarser
|
||||
than a deep minibrot's scale, and a black disk surrounded the minibrot
|
||||
(seen at ~1e-13 zoom). Phoenix is excluded (two-term map).
|
||||
`advance_delta(z, e)` is the per-kind delta step (`z` = reference point,
|
||||
`e` = current delta); the caller adds `step_add` (= `dc`) afterward — this
|
||||
relies on `c` being additive in every current kind's formula (a kind where
|
||||
@@ -93,10 +200,51 @@ pixel is a handful of `f32` complex multiplies.
|
||||
exact delta). `fprime(z)` is the derivative used for distance-estimation
|
||||
(DE) shading; exact for holomorphic kinds, an approximation (`~2Z`) for the
|
||||
abs-based ones. A `KIND_*` constant (from `common.wgsl`) must match the
|
||||
matching `FractalKind` variant's discriminant exactly.
|
||||
matching `FractalKind` variant's discriminant exactly. The per-kind bodies
|
||||
are `advance_delta_kind`/`fprime_kind`; `advance_delta`/`fprime` wrap them
|
||||
to blend two kinds during the kind-switch morph (`u.morph_from`,
|
||||
`u.morph_w`: each step is `(1-w)·f_kind + w·f_from`, mirrored on the CPU by
|
||||
the `morph` argument of `compute_reference`, in both its f64 and `Big`
|
||||
paths). The blend only exists in pipelines built with the `MORPH` override
|
||||
(part of `PipelineKey`, on while `morph_w > 0`); those also skip periodicity
|
||||
detection and the cardioid bypass. App side: `KindMorph` in
|
||||
`app.rs`; the uniforms use the morph the *current reference* was built with
|
||||
(`ref_morph`), not the live one, so orbit and delta formula never disagree.
|
||||
**Deep views** (`DEEP` override, `u.scale_exp != 0`) handle zooms where
|
||||
f32 deltas underflow. `make_uniforms` sets `scale_exp = E` (≈ log2 of the
|
||||
half-height) and uploads `span`/`dc_offset` × 2^-E. The per-pixel `offset`
|
||||
and `px` are therefore in units of 2^E. `iterate_sample` first runs a
|
||||
**deep prologue**:
|
||||
- The delta is carried as `w·2^sx` and the DE derivative as `v·2^sv`
|
||||
(separate exponents, since they drift apart near the critical point).
|
||||
- Each step goes through `advance_delta_scaled` →
|
||||
`deep_step_kind` → `advance_delta_scaled_kind`. These return the step at
|
||||
its own output scale `t`. Next to the critical point (X tiny or 0), the
|
||||
linear term vanishes and the step's value is ~e^p, far below 2^sx.
|
||||
`deep_step_kind` measures X and e in a common unit (the kinds are
|
||||
p-homogeneous) and the loop moves `sx` there. Assuming the e² terms merely
|
||||
flush when negligible was wrong exactly there: pixels near deep minibrots
|
||||
lost their delta and followed the reference forever.
|
||||
- Rebasing uses X at full range (`ref_fe`).
|
||||
- Once `|e| > 2^DEEP_EXIT_LOG2` (and dzs is normal), the state converts to
|
||||
f32 and the ordinary loop continues from the same `n`/`m`.
|
||||
- Periodicity detection restarts after the prologue with a sentinel save,
|
||||
because saving the hand-off `z` (an arbitrary phase) made exterior pixels
|
||||
shadowing a periodic nucleus reference read as interior.
|
||||
|
||||
The deep path is exact at any depth: forcing it everywhere (raise
|
||||
`DEEP_PIXEL_SIZE`, raise `DEEP_EXIT_LOG2` to about -8) must reproduce the
|
||||
plain f32 renders on non-chaotic views. That's the check to rerun after
|
||||
changing it. Known gaps: Lambda's critical point is 1/2, so its step keeps
|
||||
the input scale. Lambda set mode's reference sits at the origin, so it
|
||||
never reaches deep zooms anyway.
|
||||
- `src/fractal/renderer.rs` — `FractalRenderer` (wgpu pipelines, uniform +
|
||||
storage buffers, bind groups), `Uniforms` (repr(C) layout that must match
|
||||
the WGSL `Uniforms` struct field-for-field, including padding), and
|
||||
the WGSL `Uniforms` struct field-for-field, including padding; it includes
|
||||
CPU-precomputed data: `cm_coef`, the Complex Multibrot binomial
|
||||
coefficients from `app.rs::complex_binomials`, `light_count` for the
|
||||
packed `GpuLight` buffer from `lights.rs::gpu_lights`, and `scale_exp`,
|
||||
the deep view scale), `ref_exp_buffer` (binding 3, `RefOrbit::exps`), and
|
||||
`FractalCallback` (the `egui_wgpu::CallbackTrait` impl: `prepare()` uploads
|
||||
changed buffers and decides whether to re-run the iterate pass, the cheap
|
||||
colourise pass, or just blit the cached texture). Also `ExportRender`, a
|
||||
@@ -110,7 +258,10 @@ pixel is a handful of `f32` complex multiplies.
|
||||
- `src/app.rs` — `FractalApp` (the egui app + all UI). Key methods:
|
||||
`should_request`/`ensure_reference` (decide when the reference is stale and
|
||||
dispatch/collect it), `make_uniforms` (assemble the per-frame `Uniforms`),
|
||||
`tick_animations` (drives the "morph c/p/λ" and auto-zoom animations),
|
||||
`tick_animations` (drives the interactive animations: colour cycle,
|
||||
auto-zoom, c/p/λ circle drift via `ConstOrbit`, per-component complex-power
|
||||
oscillation via `AxisOsc`, 3D camera orbit, kind cycling through
|
||||
`switch_kind`),
|
||||
`default_view_for` (wraps `FractalKind::default_set_view`, adding the
|
||||
kind-independent Julia case). `JULIA_PRESETS` and `SET_PRESETS` are sized as
|
||||
`[T; FractalKind::<last variant> as usize + 1]` — adding a new `FractalKind`
|
||||
@@ -126,7 +277,9 @@ Touches, in order: `kind.rs` (enum variant + `ALL` slot + `label`/
|
||||
`description`/`formula`/`share_tag`/`from_share_tag`/`default_set_view`
|
||||
arms), `reference.rs` (CPU iteration formula arm, and a test comparing
|
||||
against a naive `f64` iteration), `common.wgsl` (matching `KIND_*` const),
|
||||
`mandelbrot.wgsl` (matching `advance_delta`/`fprime` arms), `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),
|
||||
`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,
|
||||
@@ -153,7 +306,28 @@ histogram buffer, tone-mapped by a fragment pass every frame. Its own
|
||||
The interactive path splits iteration (expensive, perturbation) from
|
||||
colourising (cheap, palette remap) into separate offscreen textures, so
|
||||
palette/color-scale/offset tweaks skip re-iteration entirely (`geom_differs`
|
||||
vs `color_differs` in `renderer.rs` decide which pass reruns). While the user
|
||||
is actively panning/zooming, the app renders downscaled with AA off
|
||||
(`INTERACT_DOWNSCALE`) and snaps back to full resolution once input settles
|
||||
(`INTERACT_SETTLE`).
|
||||
vs `color_differs` in `renderer.rs` decide which pass reruns). A frame where
|
||||
neither differs uploads and renders nothing and only blits. So any new
|
||||
uniform field must go into one of those two functions (or the lights
|
||||
comparison), or changing it won't redraw.
|
||||
|
||||
AA is **adaptive** on the interactive path. `fs_data` always iterates 1
|
||||
sample per pixel. When AA is on, `fs_refine` reads that texture and runs the
|
||||
2×2 grid only on pixels whose 4-neighbours differ (interior/exterior edge, or
|
||||
`ci`/DE beyond `AA_CI_EPS`/`AA_DE_EPS`), copying the rest. Colourise then
|
||||
reads the refined texture. PNG export (`fs_color`) still supersamples every
|
||||
pixel, except in 3D: the raymarcher needs the whole height field, so a 3D
|
||||
`ExportRender` (`RaymarchExport`) runs the interactive chain instead, with
|
||||
its tiles iterating `fs_data` into its own data texture and the last tile adding
|
||||
refine + colourise into the target.
|
||||
|
||||
The 3D view (`colorize.wgsl::ray_marching`) sphere-traces the DE height
|
||||
field straight from the data texture. It's cheap: rays start on the z = 0
|
||||
plane, and most hit within a few steps (about 4 on average). A min-height
|
||||
mip pyramid (quadtree height-field tracing) was tried and measured about 3×
|
||||
slower, because it needs about 12 costlier steps per ray. Don't reintroduce it.
|
||||
Orbiting the camera only re-runs the colourise pass, never iteration.
|
||||
|
||||
While the user is actively panning/zooming, the app renders downscaled with
|
||||
AA off (`INTERACT_DOWNSCALE`) and snaps back to full resolution once input
|
||||
settles (`INTERACT_SETTLE`).
|
||||
|
||||
+18
-5
@@ -3,18 +3,31 @@ name = "mandelbrot"
|
||||
version = "0.1.0"
|
||||
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]
|
||||
bytemuck = { version = "1.25.2", features = ["derive"] }
|
||||
dashu-float = "0.6.0"
|
||||
eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"] }
|
||||
egui = "0.36.2"
|
||||
eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"], optional = true }
|
||||
egui = { version = "0.36.2", optional = true }
|
||||
ecolor = { version = "0.36.2", features = ["bytemuck"] }
|
||||
wgpu = "30.0.1"
|
||||
glam = "0.33.8"
|
||||
log = "0.4.34"
|
||||
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]
|
||||
env_logger = "0.11.11"
|
||||
clap = { version = "4.5.51", features = ["derive"] }
|
||||
pollster = "1.0.1"
|
||||
rug = { version = "1.30.0", default-features = false, features = ["float", "std"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] }
|
||||
@@ -31,7 +44,7 @@ opt-level = 3
|
||||
# codegen-units = 1
|
||||
debug = true
|
||||
|
||||
# Dev: keep our own crate debuggable, but optimize dependencies (dashu, wgpu,
|
||||
# Dev: keep our own crate debuggable, but optimize dependencies (big floats, wgpu,
|
||||
# egui) so the explorer is actually interactive during development.
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
@@ -40,4 +53,4 @@ opt-level = 1
|
||||
opt-level = 3
|
||||
|
||||
[dev-dependencies]
|
||||
naga = { version = "30", features = ["wgsl-in"] }
|
||||
naga = { version = "30", features = ["wgsl-in", "spv-out"] }
|
||||
|
||||
@@ -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 —
|
||||
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
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
Native builds use `rug` (GMP/MPFR) for the reference orbit, which needs a C
|
||||
toolchain and `m4` (on Windows, MSYS2).
|
||||
|
||||
## Build & run — web (WebGPU)
|
||||
|
||||
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
|
||||
|
||||
./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
|
||||
```
|
||||
|
||||
@@ -60,7 +65,8 @@ qualifies. Deploy by serving the `dist/` directory as static files.
|
||||
|
||||
## 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).
|
||||
- `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`
|
||||
@@ -84,6 +90,7 @@ reference computation to a Web Worker.
|
||||
|
||||
```sh
|
||||
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)
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ export PATH="$HOME/.cargo/bin:$PATH"
|
||||
OUT="${1:-dist}"
|
||||
|
||||
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"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
+1146
-323
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use std::f32::consts::{PI, TAU};
|
||||
|
||||
use glam::Vec3;
|
||||
|
||||
/// Pitch is kept just short of straight up/down so the view never flips past
|
||||
/// the pole (and the raymarcher's rays always have `z > 0`).
|
||||
const PITCH_LIMIT: f32 = PI / 2.0 - 0.01;
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Camera {
|
||||
pub position: glam::Vec3,
|
||||
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
|
||||
pub aspect_ratio: f32,
|
||||
z_near: f32,
|
||||
z_far: f32,
|
||||
}
|
||||
|
||||
impl Camera {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
position: Vec3::new(0., 0., -1.),
|
||||
yaw: 0. * PI / 180.,
|
||||
pitch: -30. * PI / 180.,
|
||||
aspect_ratio: 1.,
|
||||
z_near: 0.1,
|
||||
z_far: 100.,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_aspect_ratio(&mut self, aspect_ratio: f32) {
|
||||
self.aspect_ratio = aspect_ratio;
|
||||
}
|
||||
|
||||
/// Adjust yaw/pitch by the given deltas (radians). Pitch is clamped just
|
||||
/// short of straight up/down to avoid the view flipping past the pole.
|
||||
/// Yaw is wrapped to [-π, π) so the 2D <-> 3D transition (which scales
|
||||
/// yaw by `t`) always unwinds the short way instead of every past turn.
|
||||
pub fn rotate(&mut self, dyaw: f32, dpitch: f32) {
|
||||
self.yaw = (self.yaw + dyaw + PI).rem_euclid(TAU) - PI;
|
||||
self.pitch = (self.pitch + dpitch).clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
||||
}
|
||||
|
||||
/// Set yaw/pitch (radians) outright. Pitch is clamped as in `rotate`,
|
||||
/// but yaw is left unwrapped: only a camera that stays in 3D (headless
|
||||
/// animation, which interpolates yaw across several turns) should use
|
||||
/// this; `rotate(0., 0.)` afterwards wraps it for the 2D <-> 3D transition.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn set_angles(&mut self, yaw: f32, pitch: f32) {
|
||||
self.yaw = yaw;
|
||||
self.pitch = pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
||||
}
|
||||
|
||||
/// `render_scale` is the 3D texture's per-axis scale (see
|
||||
/// `FractalApp::render_scale_3d`): the full 3D camera (`t = 1`) zooms in
|
||||
/// by the same factor, so one texel still covers one screen pixel and the
|
||||
/// extra texels become terrain beyond the screen edges.
|
||||
pub fn orthographic(&self, t: f32, render_scale: f32) -> glam::Mat4 {
|
||||
let yaw = self.yaw * t;
|
||||
let pitch = self.pitch * t;
|
||||
|
||||
let zoom = 0.5 * (1. + t * (render_scale - 1.));
|
||||
// Orbit pivot: the center of the fractal texture, which the raymarcher's
|
||||
// `sdf` lays out over world x ∈ [0, aspect], y ∈ [0, 1] on the z = 0 plane.
|
||||
let view = glam::Mat4::from_translation(Vec3::new(0.5 * self.aspect_ratio, 0.5, 0.))
|
||||
* glam::Mat4::from_rotation_z(-yaw)
|
||||
* glam::Mat4::from_rotation_x(-pitch)
|
||||
* glam::Mat4::from_translation(self.position);
|
||||
|
||||
glam::camera::lh::proj::directx::orthographic(
|
||||
-self.aspect_ratio / 4. / zoom,
|
||||
self.aspect_ratio / 4. / zoom,
|
||||
-0.25 / zoom,
|
||||
0.25 / zoom,
|
||||
self.z_near,
|
||||
self.z_far,
|
||||
) * view.inverse()
|
||||
}
|
||||
|
||||
pub fn direction(&self, t: f32) -> glam::Vec3 {
|
||||
let yaw = self.yaw * t;
|
||||
let pitch = self.pitch * t;
|
||||
|
||||
let forward =
|
||||
glam::Mat3::from_rotation_z(-yaw) * glam::Mat3::from_rotation_x(-pitch) * glam::Vec3::Z;
|
||||
|
||||
forward.normalize()
|
||||
}
|
||||
}
|
||||
+145
-15
@@ -13,7 +13,19 @@ pub struct Cli {
|
||||
#[arg(long, value_enum)]
|
||||
pub kind: Option<KindArg>,
|
||||
|
||||
/// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 8].
|
||||
/// Start in Julia mode with this seed constant.
|
||||
#[arg(long, value_name = "RE,IM")]
|
||||
pub julia: Option<String>,
|
||||
|
||||
/// Switch to the Buddhabrot renderer.
|
||||
#[arg(long)]
|
||||
pub buddhabrot: bool,
|
||||
|
||||
/// Rendering mode to use.
|
||||
#[arg(long)]
|
||||
pub rendering_kind: Option<RenderingKindArg>,
|
||||
|
||||
/// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 20].
|
||||
#[arg(long)]
|
||||
pub power: Option<u32>,
|
||||
|
||||
@@ -21,14 +33,6 @@ pub struct Cli {
|
||||
#[arg(long, value_name = "RE,IM")]
|
||||
pub complex_power: Option<String>,
|
||||
|
||||
/// Start in Julia mode with this seed constant.
|
||||
#[arg(long, value_name = "RE,IM")]
|
||||
pub julia: Option<String>,
|
||||
|
||||
/// Coloring palette index.
|
||||
#[arg(long, value_name = "INDEX")]
|
||||
pub palette: Option<u32>,
|
||||
|
||||
/// Phoenix constant p for the Phoenix kind (z -> z^2 + c + p*z_prev).
|
||||
#[arg(long, value_name = "RE,IM")]
|
||||
pub phoenix_p: Option<String>,
|
||||
@@ -45,25 +49,143 @@ pub struct Cli {
|
||||
#[arg(long, value_name = "RE,IM,HALF_HEIGHT[,ITERATIONS]")]
|
||||
pub view: Option<String>,
|
||||
|
||||
/// Jump to a specific position on startup.
|
||||
#[arg(long, short('p'), value_name = "RE,IM")]
|
||||
pub position: Option<String>,
|
||||
|
||||
/// Set a maximum iterations count on startup.
|
||||
#[arg(long, short('i'))]
|
||||
pub iterations: Option<u32>,
|
||||
|
||||
/// Set the zoom level on startup.
|
||||
#[arg(long("zoom"), short('z'))]
|
||||
pub half_height: Option<String>,
|
||||
|
||||
/// 3D camera yaw in degrees (with --rendering-kind 3d).
|
||||
#[arg(long, value_name = "DEG", allow_hyphen_values = true)]
|
||||
pub yaw: Option<f32>,
|
||||
|
||||
/// 3D camera pitch in degrees (with --rendering-kind 3d); negative tilts
|
||||
/// the view down toward the fractal. Clamped short of ±90.
|
||||
#[arg(long, value_name = "DEG", allow_hyphen_values = true)]
|
||||
pub pitch: Option<f32>,
|
||||
|
||||
/// Enable distance-estimation shading.
|
||||
#[arg(long)]
|
||||
pub de: bool,
|
||||
|
||||
/// Switch to the Buddhabrot renderer.
|
||||
/// Enable 2×2 antialiasing (supersampling; ~4× slower).
|
||||
#[arg(long)]
|
||||
pub buddhabrot: bool,
|
||||
pub antialias: bool,
|
||||
|
||||
/// Buddhabrot tonemap palette index.
|
||||
/// Coloring palette index.
|
||||
#[arg(long, value_name = "INDEX")]
|
||||
pub buddha_palette: Option<u32>,
|
||||
pub palette: Option<u32>,
|
||||
|
||||
/// Output path for --headless (default: fractal-<timestamp>.png).
|
||||
/// Output path for --headless (default: fractal-<timestamp>.png). When
|
||||
/// animating (--to-view/--to-share), this is a directory of
|
||||
/// frame-00001.png, frame-00002.png, ... instead (default:
|
||||
/// frames-<timestamp>/). "-" writes to stdout: the PNG for a single
|
||||
/// image, or raw RGBA8 frames in order for an animation, to pipe into
|
||||
/// `ffmpeg -f rawvideo -pix_fmt rgba -s WxH -r FPS -i - ...`.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub export_path: Option<String>,
|
||||
|
||||
/// End view for an animation: "re,im,half_height[,iterations]", the same
|
||||
/// syntax as --view. Combine with --view (or --share, --kind, --julia...)
|
||||
/// for the start view; headless then renders a sequence of frames
|
||||
/// interpolating the camera from start to end instead of a single PNG.
|
||||
#[arg(long, value_name = "RE,IM,HALF_HEIGHT[,ITERATIONS]")]
|
||||
pub to_view: Option<String>,
|
||||
|
||||
/// End view for an animation, as a share-link fragment (only the
|
||||
/// position/zoom/iterations are used; alternative to --to-view for
|
||||
/// pasting a location copied from the app's "Copy share link").
|
||||
#[arg(long, value_name = "FRAGMENT")]
|
||||
pub to_share: Option<String>,
|
||||
|
||||
/// Set a maximum iterations count at animation end.
|
||||
#[arg(long)]
|
||||
pub to_iterations: Option<u32>,
|
||||
|
||||
/// End Julia constant for an animation: c is interpolated from --julia
|
||||
/// to this over the frames.
|
||||
#[arg(long, value_name = "RE,IM", allow_hyphen_values = true)]
|
||||
pub to_julia: Option<String>,
|
||||
|
||||
/// End Phoenix constant p for an animation (from --phoenix-p).
|
||||
#[arg(long, value_name = "RE,IM", allow_hyphen_values = true)]
|
||||
pub to_phoenix_p: Option<String>,
|
||||
|
||||
/// End Lambda constant λ for an animation (from --lambda-l).
|
||||
#[arg(long, value_name = "RE,IM", allow_hyphen_values = true)]
|
||||
pub to_lambda_l: Option<String>,
|
||||
|
||||
/// End Complex Multibrot exponent for an animation (from
|
||||
/// --complex-power). Shorthand for --to-complex-power-re +
|
||||
/// --to-complex-power-im.
|
||||
#[arg(long, value_name = "RE,IM", allow_hyphen_values = true)]
|
||||
pub to_complex_power: Option<String>,
|
||||
|
||||
/// End real part of the Complex Multibrot exponent for an animation;
|
||||
/// the imaginary part stays put unless --to-complex-power-im is given.
|
||||
#[arg(long, value_name = "RE", allow_hyphen_values = true)]
|
||||
pub to_complex_power_re: Option<f64>,
|
||||
|
||||
/// End imaginary part of the Complex Multibrot exponent for an animation;
|
||||
/// the real part stays put unless --to-complex-power-re is given.
|
||||
#[arg(long, value_name = "IM", allow_hyphen_values = true)]
|
||||
pub to_complex_power_im: Option<f64>,
|
||||
|
||||
/// End 3D camera yaw for an animation, in degrees (from --yaw). Not
|
||||
/// wrapped: --yaw 0 --to-yaw 720 orbits twice.
|
||||
#[arg(long, value_name = "DEG", allow_hyphen_values = true)]
|
||||
pub to_yaw: Option<f32>,
|
||||
|
||||
/// End 3D camera pitch for an animation, in degrees (from --pitch).
|
||||
#[arg(long, value_name = "DEG", allow_hyphen_values = true)]
|
||||
pub to_pitch: Option<f32>,
|
||||
|
||||
/// Morph the iteration formula from the start kind (--kind) to this one
|
||||
/// over the animation. The camera is unaffected (use --to-view for that).
|
||||
#[arg(long, value_enum)]
|
||||
pub to_kind: Option<KindArg>,
|
||||
|
||||
/// Number of frames to render for an animation. Alternative to --fps +
|
||||
/// --duration.
|
||||
#[arg(long, value_name = "N")]
|
||||
pub frames: Option<u32>,
|
||||
|
||||
/// Frames per second, used with --duration to compute the frame count
|
||||
/// (ignored if --frames is given). Also used in the ffmpeg command
|
||||
/// hint printed after rendering.
|
||||
#[arg(long, value_name = "N", default_value_t = 30.0)]
|
||||
pub fps: f64,
|
||||
|
||||
/// Animation duration in seconds, used with --fps to compute the frame
|
||||
/// count (ignored if --frames is given).
|
||||
#[arg(long, value_name = "SECONDS")]
|
||||
pub duration: Option<f64>,
|
||||
|
||||
/// Pace animation frames linearly instead of easing in/out (smoothstep).
|
||||
#[arg(long)]
|
||||
pub linear: bool,
|
||||
|
||||
/// Split the animation into N equal parts (use with --shard) to render
|
||||
/// it across several runs. Each part keeps the whole animation's timing
|
||||
/// and easing, so the clips join seamlessly.
|
||||
#[arg(long, value_name = "N")]
|
||||
pub shards: Option<u32>,
|
||||
|
||||
/// Which part of --shards to render, 1-based. PNG frames keep their
|
||||
/// global numbering, so all shards can share one --export-path directory.
|
||||
#[arg(long, value_name = "K")]
|
||||
pub shard: Option<u32>,
|
||||
|
||||
/// Run without opening a window: render the current view to a PNG and
|
||||
/// exit. Combine with --kind/--julia/--share/--view etc. to pick what to
|
||||
/// render. Not yet supported with --buddhabrot.
|
||||
/// render, or any --to-* flag (--to-view, --to-julia, --to-kind, ...) to
|
||||
/// render an animation instead of a single frame. Not yet supported with --buddhabrot.
|
||||
#[arg(long)]
|
||||
pub headless: bool,
|
||||
|
||||
@@ -95,6 +217,14 @@ pub enum KindArg {
|
||||
ComplexMultibrot,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
pub enum RenderingKindArg {
|
||||
Classic,
|
||||
Shadow,
|
||||
#[value(alias = "3d")]
|
||||
Dimension3,
|
||||
}
|
||||
|
||||
impl From<KindArg> for FractalKind {
|
||||
fn from(k: KindArg) -> Self {
|
||||
match k {
|
||||
|
||||
+42
-14
@@ -3,7 +3,10 @@
|
||||
//! to colour by a fragment pass. See `shaders/buddhabrot.wgsl` for the "why"
|
||||
//! this is a separate pipeline from the escape-time perturbation renderer.
|
||||
|
||||
use eframe::egui_wgpu::{self, wgpu};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use eframe::egui_wgpu;
|
||||
|
||||
/// Random samples dispatched per accumulating frame. Chosen so a frame stays
|
||||
/// interactive on a modest GPU even when most samples run the full `b_cap`
|
||||
@@ -101,7 +104,12 @@ struct Histogram {
|
||||
}
|
||||
|
||||
pub struct BuddhabrotRenderer {
|
||||
compute_pipeline: wgpu::ComputePipeline,
|
||||
shader: wgpu::ShaderModule,
|
||||
compute_pipeline_layout: wgpu::PipelineLayout,
|
||||
/// Accumulation pipelines, specialized per fractal kind (the shader's
|
||||
/// `override KIND`, so `advance()` has no per-step kind branches) and
|
||||
/// built lazily on first use.
|
||||
compute_pipelines: HashMap<u32, wgpu::ComputePipeline>,
|
||||
compute_bind_group_layout: wgpu::BindGroupLayout,
|
||||
tonemap_pipeline: wgpu::RenderPipeline,
|
||||
tonemap_bind_group_layout: wgpu::BindGroupLayout,
|
||||
@@ -118,7 +126,9 @@ pub struct BuddhabrotRenderer {
|
||||
|
||||
impl BuddhabrotRenderer {
|
||||
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"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
concat!(
|
||||
@@ -127,7 +137,10 @@ impl BuddhabrotRenderer {
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
},
|
||||
wgpu::ShaderRuntimeChecks::unchecked(),
|
||||
)
|
||||
};
|
||||
|
||||
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("buddhabrot uniforms"),
|
||||
@@ -168,14 +181,6 @@ impl BuddhabrotRenderer {
|
||||
bind_group_layouts: &[Some(&compute_bind_group_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let compute_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("buddhabrot compute pipeline"),
|
||||
layout: Some(&compute_pipeline_layout),
|
||||
module: &shader,
|
||||
entry_point: Some("cs_main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
|
||||
let tonemap_bind_group_layout =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -236,7 +241,9 @@ impl BuddhabrotRenderer {
|
||||
});
|
||||
|
||||
Self {
|
||||
compute_pipeline,
|
||||
shader,
|
||||
compute_pipeline_layout,
|
||||
compute_pipelines: HashMap::new(),
|
||||
compute_bind_group_layout,
|
||||
tonemap_pipeline,
|
||||
tonemap_bind_group_layout,
|
||||
@@ -248,6 +255,23 @@ impl BuddhabrotRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The accumulation pipeline for `kind`, built on first use.
|
||||
fn compute_pipeline(&mut self, device: &wgpu::Device, kind: u32) -> &wgpu::ComputePipeline {
|
||||
self.compute_pipelines.entry(kind).or_insert_with(|| {
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("buddhabrot compute pipeline"),
|
||||
layout: Some(&self.compute_pipeline_layout),
|
||||
module: &self.shader,
|
||||
entry_point: Some("cs_main"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[("KIND", kind as f64)],
|
||||
..Default::default()
|
||||
},
|
||||
cache: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensure the histogram buffer exists at `width`×`height`, recreating (and
|
||||
/// resetting accumulation) on a size change.
|
||||
fn ensure_histogram(&mut self, device: &wgpu::Device, width: u32, height: u32) {
|
||||
@@ -318,6 +342,7 @@ pub struct BuddhabrotCallback {
|
||||
pub size_px: [u32; 2],
|
||||
}
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
|
||||
fn prepare(
|
||||
&self,
|
||||
@@ -334,6 +359,9 @@ impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
|
||||
let width = self.size_px[0].max(1);
|
||||
let height = self.size_px[1].max(1);
|
||||
renderer.ensure_histogram(device, width, height);
|
||||
let pipeline = renderer
|
||||
.compute_pipeline(device, self.uniforms.kind)
|
||||
.clone();
|
||||
|
||||
let content = ContentKey::from(&self.uniforms);
|
||||
let content_changed = renderer.last_content != Some(content);
|
||||
@@ -365,7 +393,7 @@ impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
|
||||
label: Some("buddhabrot accumulate pass"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&renderer.compute_pipeline);
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &histogram.compute_bind_group, &[]);
|
||||
let workgroups = SAMPLES_PER_DISPATCH.div_ceil(WORKGROUP_SIZE);
|
||||
pass.dispatch_workgroups(workgroups, 1, 1);
|
||||
|
||||
+3
-3
@@ -16,7 +16,7 @@ pub enum FractalKind {
|
||||
BurningShip = 1,
|
||||
/// `z -> conj(z)^2 + c` (the Mandelbar).
|
||||
Tricorn = 2,
|
||||
/// `z -> z^power + c` (power >= 2).
|
||||
/// `z -> z^power + c` (integer power in [2, 20]).
|
||||
Multibrot = 3,
|
||||
/// `z -> |Re(z^2)| + i·Im(z^2) + c` (abs on the real output of the square).
|
||||
Celtic = 4,
|
||||
@@ -26,7 +26,7 @@ pub enum FractalKind {
|
||||
Buffalo = 6,
|
||||
/// `z -> z^2 + c + p·z_{n-1}` (two-term recurrence; `p` is `phoenix_p`).
|
||||
Phoenix = 7,
|
||||
/// `z -> lambda·z(1 - z)` (logistic map).
|
||||
/// `z -> lambda·z(1 - z) + c` (logistic map).
|
||||
Lambda = 8,
|
||||
/// `z -> z^power + c`, where `power` is a complex constant (the
|
||||
/// `complex_power` argument), via the principal branch `z^p = exp(p·ln z)`.
|
||||
@@ -104,7 +104,7 @@ impl FractalKind {
|
||||
FractalKind::Perpendicular => "z = (x² − y²) − 2x|y|i + c".to_string(),
|
||||
FractalKind::Buffalo => "z = |Re(z²)| − i|Im(z²)| + c".to_string(),
|
||||
FractalKind::Phoenix => "z = z² + c + p·z_prev".to_string(),
|
||||
FractalKind::Lambda => "z = λ·z(1 − z)".to_string(),
|
||||
FractalKind::Lambda => "z = λ·z(1 − z) + c".to_string(),
|
||||
FractalKind::ComplexMultibrot => {
|
||||
format!("z = z^({:.3}{:+.3}i) + c", complex_power.0, complex_power.1)
|
||||
}
|
||||
|
||||
+5
-3
@@ -9,10 +9,12 @@ pub mod share;
|
||||
|
||||
pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms};
|
||||
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")]
|
||||
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};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use renderer::{encode_png, export_to_png_blocking, render_readback_blocking, unpad_rgba};
|
||||
pub use share::ShareState;
|
||||
|
||||
+593
-129
@@ -1,7 +1,7 @@
|
||||
//! High-precision reference-orbit computation for perturbation rendering.
|
||||
//!
|
||||
//! 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
|
||||
//! makes deep zoom cheap. See `shaders/mandelbrot.wgsl` for the delta side; the
|
||||
//! delta formula there must match the orbit formula here.
|
||||
@@ -9,6 +9,11 @@
|
||||
//! The `(z0, c)` form serves both set types:
|
||||
//! * Mandelbrot-set: `z0 = 0`, `c = view center` (the c-plane point per pixel).
|
||||
//! * Julia-set: `z0 = view center`, `c = fractal constant` (fixed per view).
|
||||
//!
|
||||
//! While switching fractal kinds, the formula is morphed *per iteration*:
|
||||
//! `Z_{n+1} = (1 - w)·f_kind(Z_n) + w·f_from(Z_n)` (see `morph` below). The
|
||||
//! map is linear in the two outputs, so the GPU delta is the same blend of
|
||||
//! the two kinds' deltas and perturbation/rebasing keep working unchanged.
|
||||
|
||||
use super::kind::FractalKind;
|
||||
use crate::view::{Big, big_from_f64};
|
||||
@@ -18,9 +23,103 @@ use crate::view::{Big, big_from_f64};
|
||||
/// bailout before the stored orbit runs out.
|
||||
const REFERENCE_ESCAPE_SQ: f64 = 1.0e10;
|
||||
|
||||
/// Up to this working precision (bits) the orbit is iterated in plain `f64`
|
||||
/// instead of `Big` — orders of magnitude faster, which matters most on the
|
||||
/// web (where the reference is computed inline on the UI thread).
|
||||
///
|
||||
/// `precision_for` asks for `zoom_bits + 48` guard bits, but the GPU only
|
||||
/// consumes the orbit as f32 deltas, so two things actually matter:
|
||||
/// * Each f64 step's rounding (~1e-16 relative) acts like a tiny local error
|
||||
/// in the pixel orbits too (perturbation reproduces whatever orbit it's
|
||||
/// given), far below the f32 delta noise — the orbit only has to be a
|
||||
/// consistent orbit, not the exact one.
|
||||
/// * The reference center gets rounded to f64 (<= ~2.2e-16 absolute for
|
||||
/// |c| <= 2), which shifts the image. At 80 bits (zoom_bits <= 32, i.e.
|
||||
/// half-height >= ~2.3e-10) a pixel is >= ~5e-13 wide, so that shift stays
|
||||
/// below 0.1% of a pixel.
|
||||
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
|
||||
/// `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.
|
||||
///
|
||||
/// `morph = Some((from, w))` blends in a second kind's formula at every step:
|
||||
/// `(1 - w)·f_kind + w·f_from` (used by the kind-switch animation).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn compute_reference(
|
||||
z0_re: &Big,
|
||||
@@ -34,140 +133,294 @@ pub fn compute_reference(
|
||||
phoenix_p: (f64, f64),
|
||||
lambda_l: (f64, f64),
|
||||
complex_power: (f64, f64),
|
||||
) -> Vec<[f32; 2]> {
|
||||
let cr = c_re.clone().with_precision(precision).value();
|
||||
let ci = c_im.clone().with_precision(precision).value();
|
||||
morph: Option<(FractalKind, f64)>,
|
||||
) -> RefOrbit {
|
||||
// A zero-weight morph is just the plain kind; skip the second formula.
|
||||
let morph = morph.filter(|&(_, w)| w != 0.0);
|
||||
if precision <= F64_MAX_PRECISION {
|
||||
let k = StepConstsF64 {
|
||||
c: (c_re.to_f64(), c_im.to_f64()),
|
||||
p: phoenix_p,
|
||||
l: lambda_l,
|
||||
cpow: complex_power,
|
||||
power,
|
||||
};
|
||||
return compute_reference_f64((z0_re.to_f64(), z0_im.to_f64()), max_iter, kind, &k, morph);
|
||||
}
|
||||
let k = StepConsts {
|
||||
cr: c_re.clone().with_precision(precision),
|
||||
ci: c_im.clone().with_precision(precision),
|
||||
pr: big_from_f64(phoenix_p.0, precision),
|
||||
pi: big_from_f64(phoenix_p.1, precision),
|
||||
lr: big_from_f64(lambda_l.0, precision),
|
||||
li: big_from_f64(lambda_l.1, precision),
|
||||
cpow_re: big_from_f64(complex_power.0, precision),
|
||||
cpow_im: big_from_f64(complex_power.1, precision),
|
||||
power,
|
||||
precision,
|
||||
};
|
||||
compute_reference_big(z0_re, z0_im, max_iter, kind, &k, morph)
|
||||
}
|
||||
|
||||
let mut zr = z0_re.clone().with_precision(precision).value();
|
||||
let mut zi = z0_im.clone().with_precision(precision).value();
|
||||
/// `f64` twin of [`StepConsts`].
|
||||
struct StepConstsF64 {
|
||||
c: (f64, f64),
|
||||
p: (f64, f64),
|
||||
l: (f64, f64),
|
||||
cpow: (f64, f64),
|
||||
power: u32,
|
||||
}
|
||||
|
||||
/// [`compute_reference`]'s fast path for shallow views (see
|
||||
/// [`F64_MAX_PRECISION`]): the same per-kind formulas in plain `f64`.
|
||||
fn compute_reference_f64(
|
||||
z0: (f64, f64),
|
||||
max_iter: u32,
|
||||
kind: FractalKind,
|
||||
k: &StepConstsF64,
|
||||
morph: Option<(FractalKind, f64)>,
|
||||
) -> RefOrbit {
|
||||
let (mut zr, mut zi) = z0;
|
||||
// Previous iterate, for the Phoenix two-term recurrence (Y_{-1} = 0).
|
||||
let mut zr_prev = big_zero(precision);
|
||||
let mut zi_prev = big_zero(precision);
|
||||
// Phoenix distortion constant `p` (a small fixed complex number).
|
||||
let pr = big_from_f64(phoenix_p.0, precision);
|
||||
let pi = big_from_f64(phoenix_p.1, precision);
|
||||
// Lambda distortion constant `l` (a small fixed complex number).
|
||||
let lr = big_from_f64(lambda_l.0, precision);
|
||||
let li = big_from_f64(lambda_l.1, precision);
|
||||
// Complex Multibrot exponent (a fixed complex number).
|
||||
let cpow_re = big_from_f64(complex_power.0, precision);
|
||||
let cpow_im = big_from_f64(complex_power.1, precision);
|
||||
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 {
|
||||
points.push([zr as f32, zi as f32]);
|
||||
if zr * zr + zi * zi > REFERENCE_ESCAPE_SQ {
|
||||
break;
|
||||
}
|
||||
|
||||
let (mut new_zr, mut new_zi) = step_f64(kind, k, zr, zi, prev);
|
||||
if let Some((from, w)) = morph {
|
||||
let (br, bi) = step_f64(from, k, zr, zi, prev);
|
||||
new_zr += w * (br - new_zr);
|
||||
new_zi += w * (bi - new_zi);
|
||||
}
|
||||
prev = (zr, zi);
|
||||
(zr, zi) = (new_zr, new_zi);
|
||||
}
|
||||
points
|
||||
}
|
||||
|
||||
/// `f64` twin of [`step`]: one step `f(Z_n)` of `kind`'s formula.
|
||||
fn step_f64(
|
||||
kind: FractalKind,
|
||||
k: &StepConstsF64,
|
||||
zr: f64,
|
||||
zi: f64,
|
||||
prev: (f64, f64),
|
||||
) -> (f64, f64) {
|
||||
let (cr, ci) = k.c;
|
||||
match kind {
|
||||
FractalKind::Mandelbrot => ((zr + zi) * (zr - zi) + cr, 2.0 * zr * zi + ci),
|
||||
FractalKind::BurningShip => (zr * zr - zi * zi + cr, (2.0 * zr * zi).abs() + ci),
|
||||
FractalKind::Tricorn => (zr * zr - zi * zi + cr, ci - 2.0 * zr * zi),
|
||||
FractalKind::Multibrot => {
|
||||
let (mut rr, mut ri) = (1.0f64, 0.0f64);
|
||||
for _ in 0..k.power.max(2) {
|
||||
(rr, ri) = (rr * zr - ri * zi, rr * zi + ri * zr);
|
||||
}
|
||||
(rr + cr, ri + ci)
|
||||
}
|
||||
FractalKind::Celtic => ((zr * zr - zi * zi).abs() + cr, 2.0 * zr * zi + ci),
|
||||
FractalKind::Perpendicular => (zr * zr - zi * zi + cr, ci - 2.0 * zr * zi.abs()),
|
||||
FractalKind::Buffalo => ((zr * zr - zi * zi).abs() + cr, ci - (2.0 * zr * zi).abs()),
|
||||
FractalKind::Phoenix => {
|
||||
let (pr, pi) = k.p;
|
||||
let (zr_prev, zi_prev) = prev;
|
||||
(
|
||||
zr * zr - zi * zi + cr + (pr * zr_prev - pi * zi_prev),
|
||||
2.0 * zr * zi + ci + (pr * zi_prev + pi * zr_prev),
|
||||
)
|
||||
}
|
||||
FractalKind::Lambda => {
|
||||
// λ·z(1 - z) + c.
|
||||
let (lr, li) = k.l;
|
||||
let (re2, im2) = (1.0 - zr, -zi);
|
||||
let (lzr, lzi) = (lr * zr - li * zi, lr * zi + li * zr);
|
||||
(lzr * re2 - lzi * im2 + cr, re2 * lzi + lzr * im2 + ci)
|
||||
}
|
||||
FractalKind::ComplexMultibrot => {
|
||||
let (pr, pi) = complex_pow_complex_f64(zr, zi, k.cpow.0, k.cpow.1);
|
||||
(pr + cr, pi + ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `f64` twin of [`complex_pow_complex`] (principal branch, `0^p = 0`).
|
||||
fn complex_pow_complex_f64(zr: f64, zi: f64, pr: f64, pi: f64) -> (f64, f64) {
|
||||
if zr == 0.0 && zi == 0.0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let ln_r = 0.5 * (zr * zr + zi * zi).ln();
|
||||
let theta = zi.atan2(zr);
|
||||
let mag = (pr * ln_r - pi * theta).exp();
|
||||
let (sin_a, cos_a) = (pr * theta + pi * ln_r).sin_cos();
|
||||
(mag * cos_a, mag * sin_a)
|
||||
}
|
||||
|
||||
/// Everything a single formula step needs besides the orbit state, converted
|
||||
/// to `Big` once up front.
|
||||
struct StepConsts {
|
||||
cr: Big,
|
||||
ci: Big,
|
||||
/// Phoenix distortion constant `p`.
|
||||
pr: Big,
|
||||
pi: Big,
|
||||
/// Lambda distortion constant `l`.
|
||||
lr: Big,
|
||||
li: Big,
|
||||
/// Complex Multibrot exponent.
|
||||
cpow_re: Big,
|
||||
cpow_im: Big,
|
||||
power: u32,
|
||||
precision: usize,
|
||||
}
|
||||
|
||||
/// [`compute_reference`] at arbitrary precision ([`Big`]), for deep views.
|
||||
fn compute_reference_big(
|
||||
z0_re: &Big,
|
||||
z0_im: &Big,
|
||||
max_iter: u32,
|
||||
kind: FractalKind,
|
||||
k: &StepConsts,
|
||||
morph: Option<(FractalKind, f64)>,
|
||||
) -> RefOrbit {
|
||||
let precision = k.precision;
|
||||
let morph = morph.map(|(from, w)| (from, big_from_f64(w, precision)));
|
||||
|
||||
let mut zr = z0_re.clone().with_precision(precision);
|
||||
let mut zi = z0_im.clone().with_precision(precision);
|
||||
// Previous iterate, for the Phoenix two-term recurrence (Y_{-1} = 0).
|
||||
let mut zr_prev = Big::zero(precision);
|
||||
let mut zi_prev = Big::zero(precision);
|
||||
|
||||
let mut points = RefOrbit::with_capacity(max_iter as usize + 1);
|
||||
|
||||
for _ in 0..=max_iter {
|
||||
let fr = zr.to_f64().value() as f32;
|
||||
let fi = zi.to_f64().value() as f32;
|
||||
points.push([fr, fi]);
|
||||
push_big_point(&mut points, &zr, &zi);
|
||||
|
||||
let [fr, fi] = *points.points.last().unwrap();
|
||||
let mag = (fr as f64) * (fr as f64) + (fi as f64) * (fi as f64);
|
||||
if mag > REFERENCE_ESCAPE_SQ {
|
||||
break;
|
||||
}
|
||||
|
||||
let (new_zr, new_zi) = match kind {
|
||||
FractalKind::Mandelbrot => {
|
||||
// Z^2 = (zr^2 - zi^2) + (2 zr zi) i.
|
||||
let re = &zr.sqr() - &zi.sqr() + &cr;
|
||||
let im = ((&zr * &zi) << 1) + &ci; // << 1 is exact ×2 in base 2
|
||||
(re, im)
|
||||
let (mut new_zr, mut new_zi) = step(kind, k, &zr, &zi, &zr_prev, &zi_prev);
|
||||
if let Some((from, w)) = &morph {
|
||||
// (1 - w)·a + w·b = a + w·(b - a).
|
||||
let (br, bi) = step(*from, k, &zr, &zi, &zr_prev, &zi_prev);
|
||||
new_zr = &new_zr + &(w * &(br - &new_zr));
|
||||
new_zi = &new_zi + &(w * &(bi - &new_zi));
|
||||
}
|
||||
FractalKind::BurningShip => {
|
||||
// (|zr| + i|zi|)^2 = (zr^2 - zi^2) + 2|zr zi| i.
|
||||
let re = &zr.sqr() - &zi.sqr() + &cr;
|
||||
let im = big_abs((&zr * &zi) << 1) + &ci;
|
||||
(re, im)
|
||||
}
|
||||
FractalKind::Tricorn => {
|
||||
// conj(z)^2 = (zr^2 - zi^2) - 2 zr zi i.
|
||||
let re = &zr.sqr() - &zi.sqr() + &cr;
|
||||
let im = &ci - ((&zr * &zi) << 1);
|
||||
(re, im)
|
||||
}
|
||||
FractalKind::Multibrot => {
|
||||
let (pr, pi) = complex_pow(&zr, &zi, power.max(2), precision);
|
||||
(pr + &cr, pi + &ci)
|
||||
}
|
||||
FractalKind::Celtic => {
|
||||
// |Re(z^2)| + i·Im(z^2): abs the real output of the square.
|
||||
let re = big_abs(&zr.sqr() - &zi.sqr()) + &cr;
|
||||
let im = ((&zr * &zi) << 1) + &ci;
|
||||
(re, im)
|
||||
}
|
||||
FractalKind::Perpendicular => {
|
||||
// (x^2 - y^2) - 2·x·|y| i: abs the imaginary input.
|
||||
let re = &zr.sqr() - &zi.sqr() + &cr;
|
||||
let im = &ci - ((&zr * &big_abs(zi.clone())) << 1);
|
||||
(re, im)
|
||||
}
|
||||
FractalKind::Buffalo => {
|
||||
// |Re(z^2)| - |Im(z^2)| i: abs both outputs.
|
||||
let re = big_abs(&zr.sqr() - &zi.sqr()) + &cr;
|
||||
let im = &ci - big_abs((&zr * &zi) << 1);
|
||||
(re, im)
|
||||
}
|
||||
FractalKind::Phoenix => {
|
||||
// z^2 + c + p·z_{n-1}.
|
||||
let re2 = &zr.sqr() - &zi.sqr();
|
||||
let im2 = (&zr * &zi) << 1;
|
||||
let pzr = &pr * &zr_prev - &pi * &zi_prev;
|
||||
let pzi = &pr * &zi_prev + &pi * &zr_prev;
|
||||
(re2 + &cr + pzr, im2 + &ci + pzi)
|
||||
}
|
||||
FractalKind::Lambda => {
|
||||
// λ·z(1 - z): logistic map.
|
||||
let re2 = 1 - &zr;
|
||||
let im2 = -&zi;
|
||||
let lzr = &lr * &zr - &li * &zi;
|
||||
let lzi = &lr * &zi + &li * &zr;
|
||||
(&lzr * &re2 - &lzi * &im2, re2 * lzi + lzr * im2)
|
||||
}
|
||||
FractalKind::ComplexMultibrot => {
|
||||
let (pr, pi) = complex_pow_complex(&zr, &zi, &cpow_re, &cpow_im, precision);
|
||||
(pr + &cr, pi + &ci)
|
||||
}
|
||||
};
|
||||
|
||||
// Shift the previous iterate (only the Phoenix arm reads it).
|
||||
zr_prev = zr;
|
||||
zi_prev = zi;
|
||||
zr = new_zr.with_precision(precision).value();
|
||||
zi = new_zi.with_precision(precision).value();
|
||||
zr = new_zr.with_precision(precision);
|
||||
zi = new_zi.with_precision(precision);
|
||||
}
|
||||
|
||||
points
|
||||
}
|
||||
|
||||
fn big_zero(precision: usize) -> Big {
|
||||
Big::from(0i32).with_precision(precision).value()
|
||||
/// One step `f(Z_n)` of `kind`'s formula (including its `+ c`), given the
|
||||
/// current and previous iterate.
|
||||
fn step(
|
||||
kind: FractalKind,
|
||||
k: &StepConsts,
|
||||
zr: &Big,
|
||||
zi: &Big,
|
||||
zr_prev: &Big,
|
||||
zi_prev: &Big,
|
||||
) -> (Big, Big) {
|
||||
let (cr, ci) = (&k.cr, &k.ci);
|
||||
match kind {
|
||||
FractalKind::Mandelbrot => {
|
||||
// Z^2 = (zr^2 - zi^2) + (2 zr zi) i, with zr^2 - zi^2 as
|
||||
// (zr + zi)(zr - zi): one multiply instead of two squares.
|
||||
let re = (zr + zi) * (zr - zi) + cr;
|
||||
let im = ((zr * zi) << 1) + ci; // << 1 is exact ×2 in base 2
|
||||
(re, im)
|
||||
}
|
||||
FractalKind::BurningShip => {
|
||||
// (|zr| + i|zi|)^2 = (zr^2 - zi^2) + 2|zr zi| i.
|
||||
let re = &zr.sqr() - &zi.sqr() + cr;
|
||||
let im = ((zr * zi) << 1).abs() + ci;
|
||||
(re, im)
|
||||
}
|
||||
FractalKind::Tricorn => {
|
||||
// conj(z)^2 = (zr^2 - zi^2) - 2 zr zi i.
|
||||
let re = &zr.sqr() - &zi.sqr() + cr;
|
||||
let im = ci - ((zr * zi) << 1);
|
||||
(re, im)
|
||||
}
|
||||
FractalKind::Multibrot => {
|
||||
let (pr, pi) = complex_pow(zr, zi, k.power.max(2), k.precision);
|
||||
(pr + cr, pi + ci)
|
||||
}
|
||||
FractalKind::Celtic => {
|
||||
// |Re(z^2)| + i·Im(z^2): abs the real output of the square.
|
||||
let re = (&zr.sqr() - &zi.sqr()).abs() + cr;
|
||||
let im = ((zr * zi) << 1) + ci;
|
||||
(re, im)
|
||||
}
|
||||
FractalKind::Perpendicular => {
|
||||
// (x^2 - y^2) - 2·x·|y| i: abs the imaginary input.
|
||||
let re = &zr.sqr() - &zi.sqr() + cr;
|
||||
let im = if zi.is_negative() {
|
||||
ci + ((zr * zi) << 1)
|
||||
} else {
|
||||
ci - ((zr * zi) << 1)
|
||||
};
|
||||
(re, im)
|
||||
}
|
||||
FractalKind::Buffalo => {
|
||||
// |Re(z^2)| - |Im(z^2)| i: abs both outputs.
|
||||
let re = (&zr.sqr() - &zi.sqr()).abs() + cr;
|
||||
let im = ci - ((zr * zi) << 1).abs();
|
||||
(re, im)
|
||||
}
|
||||
FractalKind::Phoenix => {
|
||||
// z^2 + c + p·z_{n-1}.
|
||||
let re2 = &zr.sqr() - &zi.sqr();
|
||||
let im2 = (zr * zi) << 1;
|
||||
let pzr = &k.pr * zr_prev - &k.pi * zi_prev;
|
||||
let pzi = &k.pr * zi_prev + &k.pi * zr_prev;
|
||||
(re2 + cr + pzr, im2 + ci + pzi)
|
||||
}
|
||||
FractalKind::Lambda => {
|
||||
// λ·z(1 - z) + c: logistic map plus the usual additive `c`.
|
||||
let re2 = Big::from_f64(1.0, k.precision) - zr;
|
||||
let im2 = -zi;
|
||||
let lzr = &k.lr * zr - &k.li * zi;
|
||||
let lzi = &k.lr * zi + &k.li * zr;
|
||||
let re = &lzr * &re2 - &lzi * &im2;
|
||||
let im = re2 * lzi + lzr * im2;
|
||||
(re + cr, im + ci)
|
||||
}
|
||||
FractalKind::ComplexMultibrot => {
|
||||
let (pr, pi) = complex_pow_complex(zr, zi, &k.cpow_re, &k.cpow_im, k.precision);
|
||||
(pr + cr, pi + ci)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 ri = big_zero(precision);
|
||||
let mut rr = Big::from_f64(1.0, precision);
|
||||
let mut ri = Big::zero(precision);
|
||||
for _ in 0..power {
|
||||
// (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 ni = (&rr * zi + &ri * zr).with_precision(precision).value();
|
||||
let nr = (&rr * zr - &ri * zi).with_precision(precision);
|
||||
let ni = (&rr * zi + &ri * zr).with_precision(precision);
|
||||
rr = nr;
|
||||
ri = ni;
|
||||
}
|
||||
(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
|
||||
/// `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`.
|
||||
@@ -175,14 +428,14 @@ fn is_big_zero(x: &Big) -> bool {
|
||||
/// panic; this is the correct limit for the `Re(p) > 0` region the UI
|
||||
/// exposes).
|
||||
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) {
|
||||
return (big_zero(precision), big_zero(precision));
|
||||
if zr.is_zero() && zi.is_zero() {
|
||||
return (Big::zero(precision), Big::zero(precision));
|
||||
}
|
||||
let r2 = &zr.sqr() + &zi.sqr();
|
||||
let ln_r = r2.ln() >> 1; // 0.5 * ln(r2) = ln(sqrt(r2)); exact halving.
|
||||
let theta = zi.atan2(zr);
|
||||
let exp_re = (pr * &ln_r - pi * &theta).with_precision(precision).value();
|
||||
let exp_im = (pr * &theta + pi * &ln_r).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);
|
||||
let mag = exp_re.exp();
|
||||
let (sin_a, cos_a) = exp_im.sin_cos();
|
||||
(&mag * &cos_a, &mag * &sin_a)
|
||||
@@ -201,8 +454,9 @@ pub fn compute_set_reference(
|
||||
phoenix_p: (f64, f64),
|
||||
lambda_l: (f64, f64),
|
||||
complex_power: (f64, f64),
|
||||
) -> Vec<[f32; 2]> {
|
||||
let zero = big_zero(precision);
|
||||
morph: Option<(FractalKind, f64)>,
|
||||
) -> RefOrbit {
|
||||
let zero = Big::zero(precision);
|
||||
compute_reference(
|
||||
&zero,
|
||||
&zero,
|
||||
@@ -215,6 +469,7 @@ pub fn compute_set_reference(
|
||||
phoenix_p,
|
||||
lambda_l,
|
||||
complex_power,
|
||||
morph,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -222,12 +477,23 @@ pub fn compute_set_reference(
|
||||
mod tests {
|
||||
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
|
||||
/// shallow point (where f64 is accurate).
|
||||
#[test]
|
||||
fn reference_matches_naive_f64() {
|
||||
let cr = Big::try_from(-0.75_f64).unwrap();
|
||||
let ci = Big::try_from(0.1_f64).unwrap();
|
||||
let cr = Big::from_f64(-0.75, 53);
|
||||
let ci = Big::from_f64(0.1, 53);
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
&ci,
|
||||
@@ -238,6 +504,7 @@ mod tests {
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
None,
|
||||
);
|
||||
|
||||
// Independent naive f64 orbit.
|
||||
@@ -263,11 +530,104 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The f64 fast path (shallow views) must produce the same orbit as the
|
||||
/// arbitrary-precision path, for every kind, in both planes, with and
|
||||
/// without a kind-switch morph.
|
||||
#[test]
|
||||
fn f64_fast_path_matches_big() {
|
||||
let bits_fast = F64_MAX_PRECISION;
|
||||
let bits_big = F64_MAX_PRECISION + 64;
|
||||
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 morph in [None, Some((FractalKind::Phoenix, 0.3))] {
|
||||
let run = |bits: usize| {
|
||||
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 args = (60, bits, kind, power, (0.1, -0.2), (0.9, 0.3), (2.3, 0.4));
|
||||
if julia {
|
||||
compute_reference(
|
||||
&a, &b, &jr, &ji, args.0, args.1, args.2, args.3, args.4, args.5,
|
||||
args.6, morph,
|
||||
)
|
||||
} else {
|
||||
compute_set_reference(
|
||||
&a, &b, args.0, args.1, args.2, args.3, args.4, args.5, args.6,
|
||||
morph,
|
||||
)
|
||||
}
|
||||
};
|
||||
let ctx = format!("{kind:?} power={power} julia={julia} morph={morph:?}");
|
||||
let (fast, big) = (run(bits_fast), run(bits_big));
|
||||
assert_eq!(fast.len(), big.len(), "{ctx}: length");
|
||||
for (i, (f, b)) in fast.iter().zip(&big).enumerate() {
|
||||
for k in 0..2 {
|
||||
let tol = 1e-5 * (1.0 + b[k].abs());
|
||||
assert!(
|
||||
(f[k] - b[k]).abs() <= tol,
|
||||
"{ctx}: point {i} {f:?} vs {b:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn interior_orbit_runs_full_length() {
|
||||
let cr = Big::try_from(-0.2_f64).unwrap();
|
||||
let ci = Big::try_from(0.0_f64).unwrap();
|
||||
let cr = Big::from_f64(-0.2, 53);
|
||||
let ci = Big::from_f64(0.0, 53);
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
&ci,
|
||||
@@ -278,6 +638,7 @@ mod tests {
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
None,
|
||||
);
|
||||
assert_eq!(points.len(), 501, "interior orbit should not escape");
|
||||
}
|
||||
@@ -285,8 +646,8 @@ mod tests {
|
||||
/// Burning Ship reference matches a naive f64 iteration of the same formula.
|
||||
#[test]
|
||||
fn burning_ship_reference_matches_naive_f64() {
|
||||
let cr = Big::try_from(-1.75_f64).unwrap();
|
||||
let ci = Big::try_from(-0.03_f64).unwrap();
|
||||
let cr = Big::from_f64(-1.75, 53);
|
||||
let ci = Big::from_f64(-0.03, 53);
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
&ci,
|
||||
@@ -297,6 +658,7 @@ mod tests {
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
None,
|
||||
);
|
||||
|
||||
let (c_re, c_im) = (-1.75_f64, -0.03_f64);
|
||||
@@ -315,8 +677,8 @@ mod tests {
|
||||
/// Multibrot (power 3) reference matches a naive f64 cube iteration.
|
||||
#[test]
|
||||
fn multibrot3_reference_matches_naive_f64() {
|
||||
let cr = Big::try_from(0.3_f64).unwrap();
|
||||
let ci = Big::try_from(0.2_f64).unwrap();
|
||||
let cr = Big::from_f64(0.3, 53);
|
||||
let ci = Big::from_f64(0.2, 53);
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
&ci,
|
||||
@@ -327,6 +689,7 @@ mod tests {
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
None,
|
||||
);
|
||||
|
||||
let (c_re, c_im) = (0.3_f64, 0.2_f64);
|
||||
@@ -347,10 +710,10 @@ mod tests {
|
||||
/// Julia orbit (fixed c, z0 = center) matches a naive f64 iteration.
|
||||
#[test]
|
||||
fn julia_reference_matches_naive_f64() {
|
||||
let z0_re = Big::try_from(0.15_f64).unwrap();
|
||||
let z0_im = Big::try_from(-0.1_f64).unwrap();
|
||||
let c_re = Big::try_from(-0.8_f64).unwrap();
|
||||
let c_im = Big::try_from(0.156_f64).unwrap();
|
||||
let z0_re = Big::from_f64(0.15, 53);
|
||||
let z0_im = Big::from_f64(-0.1, 53);
|
||||
let c_re = Big::from_f64(-0.8, 53);
|
||||
let c_im = Big::from_f64(0.156, 53);
|
||||
let points = compute_reference(
|
||||
&z0_re,
|
||||
&z0_im,
|
||||
@@ -363,6 +726,7 @@ mod tests {
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
None,
|
||||
);
|
||||
|
||||
let (mut zr, mut zi) = (0.15_f64, -0.1_f64);
|
||||
@@ -378,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.
|
||||
#[test]
|
||||
fn celtic_reference_matches_naive_f64() {
|
||||
let cr = Big::try_from(-0.6_f64).unwrap();
|
||||
let ci = Big::try_from(0.4_f64).unwrap();
|
||||
let cr = Big::from_f64(-0.6, 53);
|
||||
let ci = Big::from_f64(0.4, 53);
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
&ci,
|
||||
@@ -393,6 +789,7 @@ mod tests {
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
None,
|
||||
);
|
||||
|
||||
let (c_re, c_im) = (-0.6_f64, 0.4_f64);
|
||||
@@ -412,8 +809,8 @@ mod tests {
|
||||
/// real = x^2 - y^2 + cr, imag = -2·x·|y| + ci.
|
||||
#[test]
|
||||
fn perpendicular_reference_matches_naive_f64() {
|
||||
let cr = Big::try_from(-0.7_f64).unwrap();
|
||||
let ci = Big::try_from(-0.2_f64).unwrap();
|
||||
let cr = Big::from_f64(-0.7, 53);
|
||||
let ci = Big::from_f64(-0.2, 53);
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
&ci,
|
||||
@@ -424,6 +821,7 @@ mod tests {
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
None,
|
||||
);
|
||||
|
||||
let (c_re, c_im) = (-0.7_f64, -0.2_f64);
|
||||
@@ -443,8 +841,8 @@ mod tests {
|
||||
/// real = |x^2 - y^2| + cr, imag = -|2·x·y| + ci.
|
||||
#[test]
|
||||
fn buffalo_reference_matches_naive_f64() {
|
||||
let cr = Big::try_from(-1.2_f64).unwrap();
|
||||
let ci = Big::try_from(-0.35_f64).unwrap();
|
||||
let cr = Big::from_f64(-1.2, 53);
|
||||
let ci = Big::from_f64(-0.35, 53);
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
&ci,
|
||||
@@ -455,6 +853,7 @@ mod tests {
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
None,
|
||||
);
|
||||
|
||||
let (c_re, c_im) = (-1.2_f64, -0.35_f64);
|
||||
@@ -474,8 +873,8 @@ mod tests {
|
||||
/// `z_{n+1} = z_n^2 + c + p·z_{n-1}` (z_0 = 0, z_{-1} = 0).
|
||||
#[test]
|
||||
fn phoenix_reference_matches_naive_f64() {
|
||||
let cr = Big::try_from(0.5667_f64).unwrap();
|
||||
let ci = Big::try_from(0.0_f64).unwrap();
|
||||
let cr = Big::from_f64(0.5667, 53);
|
||||
let ci = Big::from_f64(0.0, 53);
|
||||
let p = (-0.5_f64, 0.0_f64);
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
@@ -487,6 +886,7 @@ mod tests {
|
||||
p,
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
None,
|
||||
);
|
||||
|
||||
let (c_re, c_im) = (0.5667_f64, 0.0_f64);
|
||||
@@ -512,8 +912,8 @@ mod tests {
|
||||
/// iteration of `z^p = exp(p·ln z)`.
|
||||
#[test]
|
||||
fn complex_multibrot_reference_matches_naive_f64() {
|
||||
let cr = Big::try_from(0.1_f64).unwrap();
|
||||
let ci = Big::try_from(-0.2_f64).unwrap();
|
||||
let cr = Big::from_f64(0.1, 53);
|
||||
let ci = Big::from_f64(-0.2, 53);
|
||||
let power = (2.5_f64, 0.3_f64);
|
||||
let points = compute_set_reference(
|
||||
&cr,
|
||||
@@ -525,6 +925,7 @@ mod tests {
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
power,
|
||||
None,
|
||||
);
|
||||
|
||||
// Naive f64 complex power via z^p = exp(p * ln z), ln z = ln|z| + i*arg(z).
|
||||
@@ -553,4 +954,67 @@ mod tests {
|
||||
zi = nzi;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_ref(cr: f64, ci: f64, kind: FractalKind, morph: Option<(FractalKind, f64)>) -> RefOrbit {
|
||||
compute_set_reference(
|
||||
&Big::from_f64(cr, 53),
|
||||
&Big::from_f64(ci, 53),
|
||||
60,
|
||||
200,
|
||||
kind,
|
||||
2,
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
morph,
|
||||
)
|
||||
}
|
||||
|
||||
/// Morph weight 0 is the plain kind; weight 1 is entirely the from-kind.
|
||||
#[test]
|
||||
fn morph_endpoints_match_plain_kinds() {
|
||||
let (cr, ci) = (-1.75, -0.03);
|
||||
let ship = set_ref(cr, ci, FractalKind::BurningShip, None);
|
||||
let mandel = set_ref(cr, ci, FractalKind::Mandelbrot, None);
|
||||
let w0 = set_ref(
|
||||
cr,
|
||||
ci,
|
||||
FractalKind::BurningShip,
|
||||
Some((FractalKind::Mandelbrot, 0.0)),
|
||||
);
|
||||
let w1 = set_ref(
|
||||
cr,
|
||||
ci,
|
||||
FractalKind::BurningShip,
|
||||
Some((FractalKind::Mandelbrot, 1.0)),
|
||||
);
|
||||
assert_eq!(w0, ship);
|
||||
assert_eq!(w1, mandel);
|
||||
}
|
||||
|
||||
/// A half-way Mandelbrot / Burning Ship morph matches a naive f64
|
||||
/// iteration of the per-step blend.
|
||||
#[test]
|
||||
fn morph_blend_matches_naive_f64() {
|
||||
let (c_re, c_im) = (-0.6_f64, 0.3_f64);
|
||||
let w = 0.5_f64;
|
||||
let points = set_ref(
|
||||
c_re,
|
||||
c_im,
|
||||
FractalKind::Mandelbrot,
|
||||
Some((FractalKind::BurningShip, w)),
|
||||
);
|
||||
|
||||
let (mut zr, mut zi) = (0.0_f64, 0.0_f64);
|
||||
for point in &points {
|
||||
let tol = 1e-4 * (1.0 + zr.abs().max(zi.abs()));
|
||||
assert!((point[0] as f64 - zr).abs() < tol, "re: {point:?} vs {zr}");
|
||||
assert!((point[1] as f64 - zi).abs() < tol, "im: {point:?} vs {zi}");
|
||||
let re = zr * zr - zi * zi + c_re; // identical for both kinds
|
||||
let im_m = 2.0 * zr * zi + c_im;
|
||||
let im_b = 2.0 * (zr * zi).abs() + c_im;
|
||||
zr = re;
|
||||
zi = (1.0 - w) * im_m + w * im_b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1266
-212
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
|
||||
//! 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
|
||||
//! strings.
|
||||
//! strings; `hh` is a `Scale` in scientific notation (any exponent).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::fractal::FractalKind;
|
||||
use crate::view::Scale;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShareState {
|
||||
@@ -18,7 +19,7 @@ pub struct ShareState {
|
||||
pub power: u32,
|
||||
pub center_re: String,
|
||||
pub center_im: String,
|
||||
pub half_height: f64,
|
||||
pub half_height: Scale,
|
||||
pub iterations: u32,
|
||||
pub julia_c: (f64, f64),
|
||||
/// Distortion constant for the Phoenix kind (ignored by others).
|
||||
@@ -115,7 +116,7 @@ mod tests {
|
||||
power: 5,
|
||||
center_re: "-0.743643887037158704752191506114774".into(),
|
||||
center_im: "0.131825904205311970493132056385139".into(),
|
||||
half_height: 1.5e-20,
|
||||
half_height: Scale::from_f64(1.5e-20),
|
||||
iterations: 4000,
|
||||
julia_c: (-0.123, 0.745),
|
||||
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();
|
||||
assert!(!d.julia);
|
||||
assert_eq!(d.iterations, 256);
|
||||
assert_eq!(d.half_height, Scale::from_f64(1.25));
|
||||
}
|
||||
|
||||
/// Zooms past f64's range survive a round trip.
|
||||
#[test]
|
||||
fn round_trip_past_f64_range() {
|
||||
let d = ShareState::decode("#m=m&re=0.0&im=0.0&hh=1.5e-1234&it=256").unwrap();
|
||||
assert_eq!(d.half_height, "1.5e-1234".parse().unwrap());
|
||||
let d2 = ShareState::decode(&d.encode()).unwrap();
|
||||
assert_eq!(d2.half_height, d.half_height);
|
||||
}
|
||||
}
|
||||
|
||||
+517
-13
@@ -3,17 +3,44 @@
|
||||
// event loop, no worker-thread debounce (nothing to debounce for a one-shot
|
||||
// render); it just creates its own wgpu device, computes the reference orbit
|
||||
// once, and renders through the same `ExportRender` path the "Export PNG"
|
||||
// button uses.
|
||||
// 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::fractal::{ExportRender, FractalRenderer, 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::{
|
||||
ViewState, big_from_decimal_str, interpolate_f64, interpolate_view, parse_view_spec,
|
||||
precision_for,
|
||||
};
|
||||
|
||||
/// Cap on the output image dimension (px), to stay within GPU texture limits.
|
||||
const MAX_DIM: u32 = 8192 * 16;
|
||||
|
||||
/// `--export-path` value meaning "write to stdout".
|
||||
const STDOUT_PATH: &str = "-";
|
||||
|
||||
/// Refuse to dump binary image data onto a terminal.
|
||||
fn check_stdout_piped() -> Result<(), String> {
|
||||
if std::io::stdout().is_terminal() {
|
||||
return Err(
|
||||
"--export-path - writes binary data to stdout; pipe it somewhere \
|
||||
(e.g. `| ffmpeg ...`)"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run(cli: Cli) -> Result<(), String> {
|
||||
if cli.buddhabrot {
|
||||
return Err("headless mode doesn't support --buddhabrot yet".into());
|
||||
@@ -21,13 +48,28 @@ pub fn run(cli: Cli) -> Result<(), String> {
|
||||
|
||||
let width = cli.width.clamp(16, MAX_DIM);
|
||||
let height = cli.height.clamp(16, MAX_DIM);
|
||||
let export_path = cli
|
||||
.export_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp()));
|
||||
|
||||
// These drive the animation path below; grab them before `apply_cli`
|
||||
// consumes `cli` to build the start state.
|
||||
let targets = AnimTargets::from_cli(&cli)?;
|
||||
let export_path = cli.export_path.clone();
|
||||
if export_path.as_deref() == Some(STDOUT_PATH) {
|
||||
check_stdout_piped()?;
|
||||
}
|
||||
|
||||
if targets.shard.is_some() && !targets.any() {
|
||||
return Err("--shard/--shards only apply to animations (give a --to-* target)".into());
|
||||
}
|
||||
|
||||
let mut app = FractalApp::default_state();
|
||||
app.apply_cli(cli);
|
||||
app.set_output_size(width, height);
|
||||
|
||||
if targets.any() {
|
||||
return run_animation(app, targets, width, height, export_path);
|
||||
}
|
||||
|
||||
let export_path = export_path.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp()));
|
||||
|
||||
eprintln!("computing reference orbit…");
|
||||
app.compute_reference_blocking();
|
||||
@@ -35,15 +77,13 @@ pub fn run(cli: Cli) -> Result<(), String> {
|
||||
let (device, queue) = pollster::block_on(request_device())?;
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
let renderer = FractalRenderer::new(&device, format);
|
||||
let (pipeline, bind_group_layout, format) = renderer.export_handles();
|
||||
let uniforms = app.make_uniforms(width as f64 / height as f64, height as f64);
|
||||
let handles = renderer.export_handles(&device, &uniforms);
|
||||
|
||||
let uniforms = app.make_uniforms(width as f64 / height as f64);
|
||||
let er = ExportRender::new(
|
||||
&device,
|
||||
&queue,
|
||||
pipeline,
|
||||
&bind_group_layout,
|
||||
format,
|
||||
&handles,
|
||||
width,
|
||||
height,
|
||||
uniforms,
|
||||
@@ -57,11 +97,448 @@ pub fn run(cli: Cli) -> Result<(), String> {
|
||||
});
|
||||
eprintln!();
|
||||
|
||||
if export_path == STDOUT_PATH {
|
||||
let mut out = std::io::stdout().lock();
|
||||
out.write_all(&png)
|
||||
.and_then(|()| out.flush())
|
||||
.map_err(|e| format!("writing to stdout failed: {e}"))?;
|
||||
eprintln!("wrote PNG to stdout ({width}×{height})");
|
||||
} else {
|
||||
std::fs::write(&export_path, &png).map_err(|e| format!("save failed: {e}"))?;
|
||||
println!("saved {export_path} ({width}×{height})");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `--to-*` end state of a headless animation, plus its pacing. Each
|
||||
/// target is optional; anything left unset stays at its start value.
|
||||
struct AnimTargets {
|
||||
to_view: Option<String>,
|
||||
to_share: Option<String>,
|
||||
to_iterations: Option<u32>,
|
||||
to_julia: Option<(f64, f64)>,
|
||||
to_phoenix_p: Option<(f64, f64)>,
|
||||
to_lambda_l: Option<(f64, f64)>,
|
||||
/// Complex Multibrot exponent, per component (either may move alone).
|
||||
to_cpow_re: Option<f64>,
|
||||
to_cpow_im: Option<f64>,
|
||||
to_kind: Option<FractalKind>,
|
||||
/// 3D camera, degrees.
|
||||
to_yaw: Option<f32>,
|
||||
to_pitch: Option<f32>,
|
||||
frames: Option<u32>,
|
||||
fps: f64,
|
||||
duration: Option<f64>,
|
||||
linear: bool,
|
||||
/// `--shard K --shards N`: render only the K-th (1-based) of N parts.
|
||||
shard: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl AnimTargets {
|
||||
fn from_cli(cli: &Cli) -> Result<Self, String> {
|
||||
let pair = |flag: &str, v: &Option<String>| -> Result<Option<(f64, f64)>, String> {
|
||||
v.as_deref()
|
||||
.map(|s| parse_complex_pair(s).ok_or_else(|| format!("invalid --{flag}: {s}")))
|
||||
.transpose()
|
||||
};
|
||||
let to_cpow = pair("to-complex-power", &cli.to_complex_power)?;
|
||||
let shard = match (cli.shard, cli.shards) {
|
||||
(None, None) | (None, Some(1)) => None,
|
||||
(Some(k), Some(n)) if (1..=n).contains(&k) => Some((k, n)),
|
||||
(Some(k), Some(n)) => {
|
||||
return Err(format!(
|
||||
"--shard {k} is out of range 1..={n} (--shards {n})"
|
||||
));
|
||||
}
|
||||
_ => return Err("--shard and --shards must be given together".into()),
|
||||
};
|
||||
Ok(Self {
|
||||
to_view: cli.to_view.clone(),
|
||||
to_share: cli.to_share.clone(),
|
||||
to_iterations: cli.to_iterations,
|
||||
to_julia: pair("to-julia", &cli.to_julia)?,
|
||||
to_phoenix_p: pair("to-phoenix-p", &cli.to_phoenix_p)?,
|
||||
to_lambda_l: pair("to-lambda-l", &cli.to_lambda_l)?,
|
||||
to_cpow_re: cli.to_complex_power_re.or(to_cpow.map(|p| p.0)),
|
||||
to_cpow_im: cli.to_complex_power_im.or(to_cpow.map(|p| p.1)),
|
||||
to_kind: cli.to_kind.map(Into::into),
|
||||
to_yaw: cli.to_yaw,
|
||||
to_pitch: cli.to_pitch,
|
||||
frames: cli.frames,
|
||||
fps: cli.fps,
|
||||
duration: cli.duration,
|
||||
linear: cli.linear,
|
||||
shard,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any end state was given, i.e. this is an animation.
|
||||
fn any(&self) -> bool {
|
||||
self.to_view.is_some()
|
||||
|| self.to_share.is_some()
|
||||
|| self.to_iterations.is_some()
|
||||
|| self.to_julia.is_some()
|
||||
|| self.to_phoenix_p.is_some()
|
||||
|| self.to_lambda_l.is_some()
|
||||
|| self.to_cpow_re.is_some()
|
||||
|| self.to_cpow_im.is_some()
|
||||
|| self.to_kind.is_some()
|
||||
|| self.to_yaw.is_some()
|
||||
|| self.to_pitch.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a sequence of frames interpolating from the app's current (start)
|
||||
/// state to `targets`, for feeding into ffmpeg: the camera, iteration count,
|
||||
/// per-kind constants (c, p, λ, complex power) and, through a kind morph,
|
||||
/// the iteration formula, and the 3D camera angles. Everything else (colors, ...) stays fixed at
|
||||
/// whatever `apply_cli` set up for the start. With `--export-path -`, frames
|
||||
/// are streamed in order to stdout as raw RGBA8 (for ffmpeg's `rawvideo`
|
||||
/// demuxer) instead of being written as PNGs.
|
||||
fn run_animation(
|
||||
mut app: FractalApp,
|
||||
targets: AnimTargets,
|
||||
width: u32,
|
||||
height: u32,
|
||||
export_path: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let fps = targets.fps;
|
||||
let frames = match targets.frames {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
let dur = targets
|
||||
.duration
|
||||
.ok_or("animation needs --frames, or --duration (with --fps)")?;
|
||||
((fps * dur).round() as u32).max(2)
|
||||
}
|
||||
};
|
||||
if frames < 2 {
|
||||
return Err("animation needs at least 2 frames".into());
|
||||
}
|
||||
// Frames are still timed against the whole animation (`apply_frame` takes
|
||||
// the global index); a shard only picks which of them this run renders.
|
||||
let range = match targets.shard {
|
||||
Some((_, n)) if n > frames => {
|
||||
return Err(format!("--shards {n} is more than the {frames} frames"));
|
||||
}
|
||||
Some((k, n)) => shard_range(frames, k, n),
|
||||
None => 0..frames,
|
||||
};
|
||||
let (first, count) = (range.start, range.len());
|
||||
|
||||
let from = app.view_state().clone();
|
||||
let (to, to_iterations_share) =
|
||||
parse_animation_target(targets.to_view.as_deref(), targets.to_share.as_deref())?
|
||||
.unwrap_or_else(|| (from.clone(), None));
|
||||
let to_iterations = targets.to_iterations.or(to_iterations_share);
|
||||
let from_iterations = app.max_iterations();
|
||||
if to_iterations.is_none() {
|
||||
// Iteration count auto-scales with zoom depth per frame, the same way it
|
||||
// does while zooming interactively — no need to interpolate it by hand.
|
||||
app.set_auto_iterations(true);
|
||||
}
|
||||
|
||||
let from_consts = app.constants();
|
||||
let [c0, p0, l0, cp0] = from_consts;
|
||||
let to_consts = [
|
||||
targets.to_julia.unwrap_or(c0),
|
||||
targets.to_phoenix_p.unwrap_or(p0),
|
||||
targets.to_lambda_l.unwrap_or(l0),
|
||||
(
|
||||
targets.to_cpow_re.unwrap_or(cp0.0),
|
||||
targets.to_cpow_im.unwrap_or(cp0.1),
|
||||
),
|
||||
];
|
||||
let from_kind = app.kind();
|
||||
let to_kind = targets.to_kind.unwrap_or(from_kind);
|
||||
let (yaw0, pitch0) = app.camera_angles();
|
||||
let yaw1 = targets.to_yaw.map_or(yaw0, f32::to_radians);
|
||||
let pitch1 = targets.to_pitch.map_or(pitch0, f32::to_radians);
|
||||
|
||||
let stream = export_path.as_deref() == Some(STDOUT_PATH);
|
||||
let out_dir = export_path.unwrap_or_else(|| format!("frames-{}", unix_timestamp()));
|
||||
if stream {
|
||||
eprintln!(
|
||||
"streaming raw video to stdout; ffmpeg input: \
|
||||
-f rawvideo -pix_fmt rgba -s {width}x{height} -r {fps} -i -"
|
||||
);
|
||||
} else {
|
||||
std::fs::create_dir_all(&out_dir)
|
||||
.map_err(|e| format!("failed to create {out_dir}: {e}"))?;
|
||||
}
|
||||
|
||||
// Everything about frame `i` is a pure function of its `t`, so the app can
|
||||
// be put into any frame's state at any time, in any order.
|
||||
let apply_frame = |app: &mut FractalApp, i: u32| {
|
||||
let raw_t = i as f64 / (frames - 1) as f64;
|
||||
let t = if targets.linear {
|
||||
raw_t
|
||||
} else {
|
||||
smoothstep(raw_t)
|
||||
};
|
||||
if let Some(to) = to_iterations {
|
||||
app.set_max_iterations(
|
||||
interpolate_f64(from_iterations as f64, to as f64, t).round() as u32,
|
||||
);
|
||||
}
|
||||
app.set_view(interpolate_view(&from, &to, t));
|
||||
app.set_constants(std::array::from_fn(|k| {
|
||||
(
|
||||
interpolate_f64(from_consts[k].0, to_consts[k].0, t),
|
||||
interpolate_f64(from_consts[k].1, to_consts[k].1, t),
|
||||
)
|
||||
}));
|
||||
app.set_kind_morph(from_kind, to_kind, t);
|
||||
app.set_camera_angles(
|
||||
interpolate_f64(yaw0 as f64, yaw1 as f64, t) as f32,
|
||||
interpolate_f64(pitch0 as f64, pitch1 as f64, t) as f32,
|
||||
);
|
||||
};
|
||||
|
||||
// Snapshot every frame's reference-orbit job up front (cheap: just the
|
||||
// parameters), so the orbits themselves can be computed in parallel.
|
||||
// `jobs[j]` is global frame `first + j`; the pipeline below works in
|
||||
// local indices `j`, so the stdout writer's ordering is per shard.
|
||||
let jobs: Vec<RefJob> = range
|
||||
.clone()
|
||||
.map(|i| {
|
||||
apply_frame(&mut app, i);
|
||||
app.reference_job()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (device, queue) = pollster::block_on(request_device())?;
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
let renderer = FractalRenderer::new(&device, format);
|
||||
let aspect = width as f64 / height as f64;
|
||||
|
||||
// Three-stage pipeline, connected by bounded channels (which also cap
|
||||
// memory): `threads` workers compute reference orbits (CPU, the expensive
|
||||
// part at deep zoom) → this thread renders each frame on the GPU → `threads`
|
||||
// workers PNG-encode and write frames. Frames flow through out of order
|
||||
// (at most ~`threads` apart); each is written under its own index.
|
||||
//
|
||||
// When streaming, the last stage instead unpads frames to raw RGBA and a
|
||||
// single writer thread puts them back in order before writing to stdout.
|
||||
// Its reorder buffer can't apply backpressure (blocking it while waiting
|
||||
// for frame `k` could stall the pipeline before `k` gets through), so the
|
||||
// orbit workers bound it instead: they don't start a frame more than
|
||||
// `window` ahead of the last one written.
|
||||
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
|
||||
let window = threads * 4;
|
||||
let next_job = AtomicUsize::new(0);
|
||||
let saved = AtomicUsize::new(0);
|
||||
let failed = AtomicBool::new(false);
|
||||
let error: Mutex<Option<String>> = Mutex::new(None);
|
||||
let fail = |e: String| {
|
||||
failed.store(true, Ordering::Relaxed);
|
||||
error.lock().unwrap().get_or_insert(e);
|
||||
};
|
||||
|
||||
if let Some((k, n)) = targets.shard {
|
||||
eprintln!(
|
||||
"shard {k}/{n}: frames {}–{} of {frames}",
|
||||
range.start + 1,
|
||||
range.end
|
||||
);
|
||||
}
|
||||
eprintln!("rendering {count} frames ({width}×{height}) on {threads} threads…");
|
||||
let (png_tx, png_rx) = mpsc::sync_channel::<(usize, Vec<u8>, u32, bool)>(threads * 2);
|
||||
let png_rx = Mutex::new(png_rx);
|
||||
let (raw_tx, raw_rx) = mpsc::sync_channel::<(usize, Vec<u8>)>(threads * 2);
|
||||
std::thread::scope(|scope| {
|
||||
let (ref_tx, ref_rx) = mpsc::sync_channel::<(usize, crate::fractal::RefOrbit)>(threads * 2);
|
||||
for _ in 0..threads {
|
||||
let ref_tx = ref_tx.clone();
|
||||
let (jobs, next_job, saved, failed) = (&jobs, &next_job, &saved, &failed);
|
||||
scope.spawn(move || {
|
||||
loop {
|
||||
let i = next_job.fetch_add(1, Ordering::Relaxed);
|
||||
if i >= jobs.len() || failed.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
while stream
|
||||
&& i >= saved.load(Ordering::Relaxed) + window
|
||||
&& !failed.load(Ordering::Relaxed)
|
||||
{
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
if ref_tx.send((i, jobs[i].compute())).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
drop(ref_tx);
|
||||
|
||||
if stream {
|
||||
let (saved, fail) = (&saved, &fail);
|
||||
scope.spawn(move || {
|
||||
let mut out = std::io::stdout().lock();
|
||||
let mut pending = BTreeMap::new();
|
||||
let mut next = 0;
|
||||
for (i, raw) in raw_rx.iter() {
|
||||
pending.insert(i, raw);
|
||||
while let Some(raw) = pending.remove(&next) {
|
||||
if let Err(e) = out.write_all(&raw) {
|
||||
fail(format!("writing to stdout failed: {e}"));
|
||||
return;
|
||||
}
|
||||
next += 1;
|
||||
saved.store(next, Ordering::Relaxed);
|
||||
eprint!("\r[{next:>4}/{count}] streamed");
|
||||
}
|
||||
}
|
||||
if let Err(e) = out.flush() {
|
||||
fail(format!("writing to stdout failed: {e}"));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
drop(raw_rx);
|
||||
}
|
||||
|
||||
for _ in 0..threads {
|
||||
let raw_tx = raw_tx.clone();
|
||||
let (png_rx, out_dir, saved, failed, fail) =
|
||||
(&png_rx, &out_dir, &saved, &failed, &fail);
|
||||
scope.spawn(move || {
|
||||
loop {
|
||||
// Hold the lock only for the receive, not the encode.
|
||||
let Ok((i, padded, bpr, swap_rb)) = png_rx.lock().unwrap().recv() else {
|
||||
break;
|
||||
};
|
||||
// After a failure, keep draining (without work) until the
|
||||
// GPU stage hangs up, so it can't block on a full channel.
|
||||
if failed.load(Ordering::Relaxed) {
|
||||
continue;
|
||||
}
|
||||
if stream {
|
||||
let raw = unpad_rgba(&padded, width, height, bpr, swap_rb);
|
||||
// Only fails once the writer has failed and hung up.
|
||||
let _ = raw_tx.send((i, raw));
|
||||
continue;
|
||||
}
|
||||
let png =
|
||||
encode_png(&padded, width, height, bpr, swap_rb, png::Compression::Fast);
|
||||
let path = format!("{out_dir}/frame-{:05}.png", first as usize + i + 1);
|
||||
if let Err(e) = std::fs::write(&path, &png) {
|
||||
fail(format!("save failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
let done = saved.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
eprint!("\r[{done:>4}/{count}] saved");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// GPU stage, on this thread (it owns the app and the device). The
|
||||
// shader specialization (kind, Julia, DE, morph) can change between
|
||||
// frames during a kind morph; build each pipeline once.
|
||||
let mut pipelines = HashMap::new();
|
||||
for (i, points) in ref_rx.iter() {
|
||||
if failed.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
apply_frame(&mut app, first + i as u32);
|
||||
app.finish_reference(jobs[i].clone(), points);
|
||||
|
||||
let uniforms = app.make_uniforms(aspect, height as f64);
|
||||
let handles = pipelines
|
||||
.entry(PipelineKey::from_uniforms(&uniforms))
|
||||
.or_insert_with(|| renderer.export_handles(&device, &uniforms));
|
||||
let er = ExportRender::new(
|
||||
&device,
|
||||
&queue,
|
||||
handles,
|
||||
width,
|
||||
height,
|
||||
uniforms,
|
||||
app.reference_points(),
|
||||
app.lights(),
|
||||
);
|
||||
let padded = render_readback_blocking(&device, &queue, &er);
|
||||
if png_tx.send((i, padded, er.padded_bpr, er.swap_rb)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Dropping the channel ends lets the workers drain and exit.
|
||||
drop(raw_tx);
|
||||
drop(png_tx);
|
||||
drop(ref_rx);
|
||||
});
|
||||
eprintln!();
|
||||
|
||||
if let Some(e) = error.into_inner().unwrap() {
|
||||
return Err(e);
|
||||
}
|
||||
let saved = saved.into_inner();
|
||||
if saved != count {
|
||||
return Err(format!("only {saved} of {count} frames were rendered"));
|
||||
}
|
||||
|
||||
if stream {
|
||||
eprintln!("streamed {count} frames ({width}×{height})");
|
||||
return Ok(());
|
||||
}
|
||||
println!("saved {count} frames to {out_dir}/ ({width}×{height})");
|
||||
if targets.shard.is_some() {
|
||||
println!(
|
||||
"tip: once every shard is rendered into {out_dir}/, they form the full sequence; \
|
||||
this shard alone: ffmpeg -framerate {fps} -start_number {} -i {out_dir}/frame-%05d.png \
|
||||
-frames:v {count} -c:v libx264 -pix_fmt yuv420p out.mp4",
|
||||
range.start + 1
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"tip: ffmpeg -framerate {fps} -i {out_dir}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p out.mp4"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse `--to-view`/`--to-share` (at most one is used) into the end view
|
||||
/// of an animation, or `None` if neither is set (the camera stays put). Only position/zoom/iterations are pulled from a
|
||||
/// share fragment — the rest of its state (kind, colors, ...) is ignored, so
|
||||
/// pasting a link from the app doesn't unexpectedly change the fractal kind
|
||||
/// mid-animation.
|
||||
fn parse_animation_target(
|
||||
to_view: Option<&str>,
|
||||
to_share: Option<&str>,
|
||||
) -> Result<Option<(ViewState, Option<u32>)>, String> {
|
||||
if let Some(spec) = to_view {
|
||||
return parse_view_spec(spec)
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("invalid --to-view spec: {spec}"));
|
||||
}
|
||||
let Some(frag) = to_share else {
|
||||
return Ok(None);
|
||||
};
|
||||
let state =
|
||||
ShareState::decode(frag).ok_or_else(|| format!("invalid --to-share fragment: {frag}"))?;
|
||||
let bits = precision_for(state.half_height);
|
||||
let re =
|
||||
big_from_decimal_str(&state.center_re, bits).ok_or("invalid --to-share center (re)")?;
|
||||
let im =
|
||||
big_from_decimal_str(&state.center_im, bits).ok_or("invalid --to-share center (im)")?;
|
||||
Ok(Some((
|
||||
ViewState::with_center(re, im, state.half_height),
|
||||
Some(state.iterations),
|
||||
)))
|
||||
}
|
||||
|
||||
/// Global frame indices of shard `shard` (1-based) out of `shards` equal
|
||||
/// parts of a `frames`-frame animation. Consecutive shards tile `0..frames`
|
||||
/// with no gap or overlap.
|
||||
fn shard_range(frames: u32, shard: u32, shards: u32) -> std::ops::Range<u32> {
|
||||
let bound = |k: u32| (k as u64 * frames as u64 / shards as u64) as u32;
|
||||
bound(shard - 1)..bound(shard)
|
||||
}
|
||||
|
||||
/// Ease-in/ease-out pacing: slow at both ends, fast through the middle.
|
||||
fn smoothstep(t: f64) -> f64 {
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// Set up a wgpu device with no surface/window attached, matching the limits
|
||||
/// `main::wgpu_options` requests for the windowed app (the fractal fragment
|
||||
/// shader needs storage buffers, which downlevel/WebGL-style limits disallow).
|
||||
@@ -81,3 +558,30 @@ async fn request_device() -> Result<(wgpu::Device, wgpu::Queue), String> {
|
||||
.await
|
||||
.map_err(|e| format!("failed to create device: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::shard_range;
|
||||
|
||||
#[test]
|
||||
fn shards_tile_all_frames() {
|
||||
for frames in [2, 3, 10, 97, 1000, u32::MAX] {
|
||||
for shards in [1, 2, 3, 7, 10] {
|
||||
if shards > frames {
|
||||
continue;
|
||||
}
|
||||
let mut next = 0;
|
||||
for k in 1..=shards {
|
||||
let r = shard_range(frames, k, shards);
|
||||
assert_eq!(
|
||||
r.start, next,
|
||||
"gap/overlap at shard {k}/{shards} of {frames}"
|
||||
);
|
||||
assert!(!r.is_empty(), "empty shard {k}/{shards} of {frames}");
|
||||
next = r.end;
|
||||
}
|
||||
assert_eq!(next, frames);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+44
-2
@@ -1,7 +1,9 @@
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use egui::{Color32, Ui};
|
||||
use ecolor::Color32;
|
||||
#[cfg(feature = "gui")]
|
||||
use egui::Ui;
|
||||
|
||||
/// Maximum number of simultaneous lights.
|
||||
pub const MAX_LIGHT_COUNT: usize = 16;
|
||||
@@ -27,8 +29,14 @@ impl Default for Light {
|
||||
}
|
||||
|
||||
impl Light {
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn widget(&mut self, ui: &mut Ui) -> bool {
|
||||
let formater = |v, _| format!("{}°", ((v as f32 * 180. / PI) as u32));
|
||||
let formater = |v, _| format!("{}°", ((v * 180. / std::f64::consts::PI) as u32));
|
||||
let parser = |s: &str| {
|
||||
s.parse::<u32>()
|
||||
.ok()
|
||||
.map(|x| x as f64 * std::f64::consts::PI / 180.)
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
let del = ui.button("-").clicked();
|
||||
ui.label("color:");
|
||||
@@ -38,6 +46,7 @@ impl Light {
|
||||
egui::DragValue::new(&mut self.azimuth)
|
||||
.range(0.0..=PI * 2.)
|
||||
.custom_formatter(formater)
|
||||
.custom_parser(parser)
|
||||
.speed(0.02),
|
||||
);
|
||||
ui.label("φ:");
|
||||
@@ -45,6 +54,7 @@ impl Light {
|
||||
egui::DragValue::new(&mut self.altitude)
|
||||
.range(0.0..=PI / 2.)
|
||||
.custom_formatter(formater)
|
||||
.custom_parser(parser)
|
||||
.speed(0.02),
|
||||
);
|
||||
|
||||
@@ -53,3 +63,35 @@ impl Light {
|
||||
.inner
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU-side light, matching WGSL `Light` in `iterate_uniforms.wgsl`: the unit
|
||||
/// direction toward the light (precomputed from azimuth/altitude so the
|
||||
/// shader does no per-pixel trig) plus the packed RGBA colour, whose alpha is
|
||||
/// the intensity. 16 bytes, so `array<Light, 16>` has a uniform-legal stride.
|
||||
#[derive(Clone, Copy, PartialEq, Zeroable, Pod, Default)]
|
||||
#[repr(C)]
|
||||
pub struct GpuLight {
|
||||
pub dir: [f32; 3],
|
||||
pub color: Color32,
|
||||
}
|
||||
|
||||
/// The light buffer's contents: the UI lights with a non-zero colour (the
|
||||
/// only ones that contribute, and the ones the filmic white point counts),
|
||||
/// packed to the front, plus how many there are (`Uniforms::light_count`).
|
||||
pub fn gpu_lights(lights: &[Light]) -> ([GpuLight; MAX_LIGHT_COUNT], u32) {
|
||||
let mut out = [GpuLight::default(); MAX_LIGHT_COUNT];
|
||||
let mut n = 0;
|
||||
for l in lights.iter().filter(|l| l.color != Color32::TRANSPARENT) {
|
||||
if n == MAX_LIGHT_COUNT {
|
||||
break;
|
||||
}
|
||||
let (sa, ca) = l.altitude.sin_cos();
|
||||
let (sz, cz) = l.azimuth.sin_cos();
|
||||
out[n] = GpuLight {
|
||||
dir: [cz * ca, sz * ca, sa],
|
||||
color: l.color,
|
||||
};
|
||||
n += 1;
|
||||
}
|
||||
(out, n as u32)
|
||||
}
|
||||
|
||||
+24
-4
@@ -1,6 +1,8 @@
|
||||
// Without these, rust fails to infer Send/Sync trait impls
|
||||
// Probably caused by the new trait solver
|
||||
#![recursion_limit = "256"]
|
||||
// Without the `gui` feature, UI-only state and helpers go unused.
|
||||
#![cfg_attr(not(feature = "gui"), allow(dead_code, unused_imports))]
|
||||
|
||||
// Fractal Explorer — Rust + wgpu + egui + WGSL deep-zoom Mandelbrot.
|
||||
//
|
||||
@@ -9,6 +11,8 @@
|
||||
// and calls the wasm `main`, which boots eframe onto the page's <canvas>.
|
||||
|
||||
mod app;
|
||||
mod bignum;
|
||||
mod camera;
|
||||
mod fractal;
|
||||
mod lights;
|
||||
mod view;
|
||||
@@ -20,8 +24,17 @@ mod headless;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
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;
|
||||
|
||||
#[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
|
||||
/// reference orbit from a **storage buffer**, so the device must allow storage
|
||||
/// buffers in the fragment stage. eframe's default requests WebGL2-downlevel
|
||||
@@ -29,6 +42,7 @@ use app::FractalApp;
|
||||
/// * 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
|
||||
/// all) — failing cleanly on browsers without WebGPU, per the design.
|
||||
#[cfg(feature = "gui")]
|
||||
fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
use eframe::egui_wgpu::{WgpuSetup, wgpu};
|
||||
|
||||
@@ -50,7 +64,7 @@ fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() -> eframe::Result {
|
||||
fn main() -> MainResult {
|
||||
use clap::Parser as _;
|
||||
|
||||
env_logger::builder()
|
||||
@@ -69,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 {
|
||||
renderer: eframe::Renderer::Wgpu,
|
||||
wgpu_options: wgpu_options(),
|
||||
@@ -79,6 +100,7 @@ fn main() -> eframe::Result {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
eframe::run_native(
|
||||
"Fractal Explorer",
|
||||
native_options,
|
||||
@@ -122,9 +144,7 @@ fn main() {
|
||||
match result {
|
||||
Ok(_) => loading.remove(),
|
||||
Err(e) => {
|
||||
loading.set_inner_html(
|
||||
"<p>The app has crashed. See the developer console for details.</p>",
|
||||
);
|
||||
loading.set_inner_html(&format!("<p>The app has crashed.</br>{e:?}</p>"));
|
||||
log::error!("failed to start eframe: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+17
-11
@@ -58,6 +58,12 @@ struct Uniforms {
|
||||
complex_power: vec2<f32>,
|
||||
};
|
||||
|
||||
// Fractal kind, as a pipeline-overridable constant (set per compute pipeline
|
||||
// from `u.kind`, see `BuddhabrotRenderer::compute_pipeline`): every kind
|
||||
// branch in the iteration loop folds away at pipeline creation. Read this,
|
||||
// never `u.kind`.
|
||||
override KIND: u32 = 0u;
|
||||
|
||||
const PALETTE_NEBULA: u32 = 0u;
|
||||
const PALETTE_YELLOW: u32 = 1u;
|
||||
const PALETTE_GRAYSCALE: u32 = 2u;
|
||||
@@ -95,25 +101,25 @@ fn complex_pow(z: vec2<f32>, p: u32) -> vec2<f32> {
|
||||
// Must match `FractalKind` in reference.rs (the direct, non-perturbative form
|
||||
// of the same formulas).
|
||||
fn advance(z: vec2<f32>, zp: vec2<f32>, c: vec2<f32>) -> vec2<f32> {
|
||||
if u.kind == KIND_BURNING_SHIP {
|
||||
if KIND == KIND_BURNING_SHIP {
|
||||
return vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * abs(z.x * z.y)) + c;
|
||||
} else if u.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;
|
||||
} else if u.kind == KIND_MULTIBROT {
|
||||
return complex_pow(z, clamp(u.power, 2u, 8u)) + c;
|
||||
} else if u.kind == KIND_CELTIC {
|
||||
} else if KIND == KIND_MULTIBROT {
|
||||
return complex_pow(z, clamp(u.power, 2u, MULTIBROT_MAX_POWER)) + c;
|
||||
} else if KIND == KIND_CELTIC {
|
||||
return vec2<f32>(abs(z.x * z.x - z.y * z.y), 2.0 * z.x * z.y) + c;
|
||||
} else if u.kind == KIND_PERPENDICULAR {
|
||||
} else if KIND == KIND_PERPENDICULAR {
|
||||
return vec2<f32>(z.x * z.x - z.y * z.y, -2.0 * z.x * abs(z.y)) + c;
|
||||
} else if u.kind == KIND_BUFFALO {
|
||||
} else if KIND == KIND_BUFFALO {
|
||||
return vec2<f32>(abs(z.x * z.x - z.y * z.y), -abs(2.0 * z.x * z.y)) + c;
|
||||
} else if u.kind == KIND_PHOENIX {
|
||||
} else if KIND == KIND_PHOENIX {
|
||||
let sq = vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y);
|
||||
return sq + c + cmul(u.phoenix_p, zp);
|
||||
} else if u.kind == KIND_LAMBDA {
|
||||
} else if KIND == KIND_LAMBDA {
|
||||
// l * z * (1 - z); c is unused (see file doc comment above).
|
||||
return cmul(u.lambda_l, cmul(z, vec2<f32>(1.0 - z.x, -z.y)));
|
||||
} else if u.kind == KIND_COMPLEX_MULTIBROT {
|
||||
} else if KIND == KIND_COMPLEX_MULTIBROT {
|
||||
return cpow(z, u.complex_power) + c;
|
||||
}
|
||||
return vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c; // Mandelbrot
|
||||
@@ -176,7 +182,7 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
|
||||
var c = sample;
|
||||
var z0 = vec2<f32>(0.0, 0.0);
|
||||
if u.kind == KIND_LAMBDA {
|
||||
if KIND == KIND_LAMBDA {
|
||||
c = vec2<f32>(0.0, 0.0); // unused by the Lambda step
|
||||
z0 = sample;
|
||||
}
|
||||
|
||||
+160
-11
@@ -19,20 +19,41 @@ 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_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
if u.shadow != 0u {
|
||||
fn shadow_fragment(pos: vec2<f32>) -> vec4<f32> {
|
||||
let x = i32(pos.x);
|
||||
let y = i32(pos.y);
|
||||
if textureLoad(data_tex, vec2<i32>(x, y), 0).b != 0. {
|
||||
return vec4<f32>(0.1, 0.1, 0.1, 1.0);
|
||||
} else {
|
||||
let h0 = textureLoad(data_tex, vec2<i32>(x, y), 0).g;
|
||||
let h1 = textureLoad(data_tex, vec2<i32>(x + 1, y), 0).g;
|
||||
let h2 = textureLoad(data_tex, vec2<i32>(x, y + 1), 0).g;
|
||||
let normal = normal_from_heights(h0, h1, h2);
|
||||
return vec4<f32>(shadow_color(normal), 1.0);
|
||||
let size = textureDimensions(data_tex);
|
||||
let here = textureLoad(data_tex, vec2<i32>(x, y), 0);
|
||||
if here.b != 0. {
|
||||
return vec4<f32>(shadow_interior_color(), 1.0);
|
||||
}
|
||||
// Forward differences, except on the last column/row where x+1 / y+1
|
||||
// is off the texture: fall back to a backward difference, mirrored
|
||||
// (h0 + (h0 - h[-1])) so the slope keeps the sign normal_from_heights
|
||||
// expects — plugging h[-1] in directly would flip the normal there.
|
||||
let h0 = here.g;
|
||||
var h1: f32;
|
||||
if x + 1 < i32(size.x) {
|
||||
h1 = textureLoad(data_tex, vec2<i32>(x + 1, y), 0).g;
|
||||
} else {
|
||||
h1 = 2.0 * h0 - textureLoad(data_tex, vec2<i32>(x - 1, y), 0).g;
|
||||
}
|
||||
var h2: f32;
|
||||
if y + 1 < i32(size.y) {
|
||||
h2 = textureLoad(data_tex, vec2<i32>(x, y + 1), 0).g;
|
||||
} else {
|
||||
h2 = 2.0 * h0 - textureLoad(data_tex, vec2<i32>(x, y - 1), 0).g;
|
||||
}
|
||||
let normal = normal_from_heights(h0, h1, h2);
|
||||
return vec4<f32>(shadow_color(normal, here.r), 1.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
if u.shadow == 2u {
|
||||
return ray_marching(pos);
|
||||
} else if u.shadow == 1u {
|
||||
return shadow_fragment(pos.xy);
|
||||
} else {
|
||||
let d = textureLoad(data_tex, vec2<i32>(i32(pos.x), i32(pos.y)), 0);
|
||||
let ci = d.r;
|
||||
@@ -46,3 +67,131 @@ fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
return vec4<f32>(col, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Colour of rays that miss the fractal's footprint.
|
||||
const RAY_MISS: vec4<f32> = vec4<f32>(1.0, 0.0, 0.0, 1.0);
|
||||
|
||||
// Per-frame constants of the raymarch, computed once per pixel in
|
||||
// `ray_marching` rather than on each of the up-to-100 `sdf` steps.
|
||||
struct MarchConsts {
|
||||
size: vec2<f32>,
|
||||
// (size.x / aspect_ratio, size.y): world xy -> texel scale.
|
||||
to_texel: vec2<f32>,
|
||||
size_i: vec2<i32>,
|
||||
inv_size_y: f32,
|
||||
};
|
||||
|
||||
fn sdf(pos: vec3<f32>, k: MarchConsts) -> f32 {
|
||||
let texture_pos_f32 = pos.xy * k.to_texel;
|
||||
let texture_pos = clamp(vec2<i32>(texture_pos_f32), vec2<i32>(0, 0), k.size_i - vec2<i32>(1, 1));
|
||||
|
||||
let to_texture = max(-min(texture_pos_f32, vec2(0.)), max(texture_pos_f32 - k.size, vec2(0.)));
|
||||
let dist_to_texture = length(to_texture) * k.inv_size_y;
|
||||
|
||||
let px = textureLoad(data_tex, texture_pos, 0);
|
||||
let de = (px.g * k.inv_size_y) * 0.5;
|
||||
// Height is measured toward -z, the side the camera sits on (it looks
|
||||
// along +z), so the terrain is solid on +z: interior plateau at z = 0,
|
||||
// exterior sloping away from the camera as `de` grows.
|
||||
let signed_z = -pos.z;
|
||||
let z = max(signed_z, 0.);
|
||||
var d: f32;
|
||||
if px.b != 0. {
|
||||
d = z;
|
||||
} else {
|
||||
d = min(sqrt(z * z + de * de), signed_z + 1. - exp(-de * 5.));
|
||||
}
|
||||
// Outside the texture footprint, `d` is the distance from the clamped
|
||||
// point q on the footprint's edge. The terrain lies over the (convex)
|
||||
// footprint, so |p - x|² ≥ |q - x|² + |p - q|² for every terrain point x:
|
||||
// combine in quadrature (not by adding, which overshoots). p can't be in
|
||||
// the solid out here, so a negative `d` counts as 0.
|
||||
if dist_to_texture > 0. {
|
||||
let d_pos = max(d, 0.);
|
||||
return sqrt(d_pos * d_pos + dist_to_texture * dist_to_texture);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
fn ray_marching(pos: vec4<f32>) -> vec4<f32> {
|
||||
let size_i = vec2<i32>(textureDimensions(data_tex));
|
||||
let size = vec2<f32>(size_i);
|
||||
let aspect_ratio = u.screen_dim.x / u.screen_dim.y;
|
||||
let k = MarchConsts(size, vec2<f32>(size.x / aspect_ratio, size.y), size_i, 1.0 / size.y);
|
||||
|
||||
let in_texture = vec2<f32>(
|
||||
(pos.x / size.x) * 2. - 1.,
|
||||
(pos.y / size.y) * 2. - 1.,
|
||||
);
|
||||
|
||||
var world_pos = u.camera_inv_proj * vec4<f32>(in_texture, 0., 1.0);
|
||||
|
||||
let ray_origin = world_pos.xyz;
|
||||
let ray_dir = u.camera_direction;
|
||||
|
||||
// Start where the ray crosses z = 0, the topmost possible surface (the
|
||||
// camera pitch is clamped short of ±90°, so ray_dir.z > 0).
|
||||
let start = ray_origin - ray_dir * (ray_origin.z / ray_dir.z);
|
||||
|
||||
// The terrain only exists over the footprint x in [0, aspect],
|
||||
// y in [0, 1]: clip the ray's xy to it up front, so rays that miss it cost
|
||||
// nothing and the rest start marching at its edge. A huge finite 1/d on
|
||||
// an axis the ray doesn't move along (top-down, during the 2D <-> 3D
|
||||
// transition) keeps the slab maths finite.
|
||||
let inv = select(1.0 / ray_dir.xy, vec2<f32>(1e30), abs(ray_dir.xy) < vec2<f32>(1e-20));
|
||||
let ta = -start.xy * inv;
|
||||
let tb = (vec2<f32>(aspect_ratio, 1.0) - start.xy) * inv;
|
||||
let t_leave = min(max(ta.x, tb.x), max(ta.y, tb.y));
|
||||
var t = max(max(min(ta.x, tb.x), min(ta.y, tb.y)), 0.0);
|
||||
if t >= t_leave {
|
||||
return RAY_MISS;
|
||||
}
|
||||
|
||||
// About 1/50 of a texel at typical sizes: tighter only adds steps
|
||||
// without visibly moving the hit.
|
||||
let dist_threshold = 0.00001;
|
||||
// Rays grazing the exponential slope see a tiny `dist` for many steps in
|
||||
// a row and would crawl along it until the step budget runs out. Force a
|
||||
// step of at least half a texel (the height field is nearest-sampled, so
|
||||
// nothing finer exists), and bisect back if that lands inside the solid.
|
||||
let min_step = 0.5 * k.inv_size_y;
|
||||
var hit = false;
|
||||
var t_prev = t;
|
||||
for (var i = 0u; i < 100u; i++) {
|
||||
let dist = sdf(start + t * ray_dir, k);
|
||||
if dist < dist_threshold {
|
||||
hit = true;
|
||||
if dist < 0. {
|
||||
// Overshot: t_prev is outside, t inside. Refine the crossing.
|
||||
var lo = t_prev;
|
||||
var hi = t;
|
||||
for (var j = 0u; j < 8u; j++) {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if sdf(start + mid * ray_dir, k) < dist_threshold {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
t = hi;
|
||||
}
|
||||
break;
|
||||
}
|
||||
t_prev = t;
|
||||
t += max(dist, min_step);
|
||||
// Past the footprint's far edge: nothing left to hit.
|
||||
if t >= t_leave {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Out of steps while still over the footprint: the ray is skimming the
|
||||
// surface, so shade where it got to rather than reporting a miss.
|
||||
if !hit && t < t_leave {
|
||||
hit = true;
|
||||
}
|
||||
// Shade outside the loop, so its registers don't weigh on the march.
|
||||
if !hit {
|
||||
return RAY_MISS;
|
||||
}
|
||||
return shadow_fragment((start.xy + t * ray_dir.xy) * k.to_texel);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ const KIND_MANDELBROT: u32 = 0u;
|
||||
const KIND_BURNING_SHIP: u32 = 1u;
|
||||
const KIND_TRICORN: u32 = 2u;
|
||||
const KIND_MULTIBROT: u32 = 3u;
|
||||
// Highest Multibrot power (the UI/CLI/share-link clamp in app.rs matches).
|
||||
// `bailout_sq` in app.rs shrinks the bailout with the power so z^p stays a
|
||||
// finite f32.
|
||||
const MULTIBROT_MAX_POWER: u32 = 20u;
|
||||
const KIND_CELTIC: u32 = 4u;
|
||||
const KIND_PERPENDICULAR: u32 = 5u;
|
||||
const KIND_BUFFALO: u32 = 6u;
|
||||
|
||||
@@ -19,6 +19,8 @@ struct Uniforms {
|
||||
kind: u32,
|
||||
// Exponent for the Multibrot kind.
|
||||
power: u32,
|
||||
// Kind-switch morph: the kind blended *from* (see morph_w).
|
||||
morph_from: u32,
|
||||
dc_offset: vec2<f32>,
|
||||
// Distortion constant p for the Phoenix map (z^2 + c + p*z_{n-1}); unused
|
||||
// by other kinds. Placed by dc_offset so both vec2s stay 8-byte aligned.
|
||||
@@ -31,8 +33,29 @@ struct Uniforms {
|
||||
complex_power: vec2<f32>,
|
||||
// 0 = escape-time coloring, 1 = distance-estimation shading.
|
||||
de_coloring: u32,
|
||||
// 0 = classic colors, 1 = shadows
|
||||
// 0 = classic colors, 1 = shadows, 2 = 3D raymarching rendering
|
||||
shadow: u32,
|
||||
// camera direction vector
|
||||
camera_direction: vec3<f32>,
|
||||
// Number of live entries at the start of `lights` (fills the vec3's tail
|
||||
// padding slot).
|
||||
light_count: u32,
|
||||
// inverse of the camera's view-projection matrix, for reconstructing a
|
||||
// world-space ray origin per pixel in the raymarcher
|
||||
camera_inv_proj: mat4x4<f32>,
|
||||
// Screen dimensions
|
||||
screen_dim: vec2<f32>,
|
||||
// Kind-switch morph weight: each step is (1 - w)*f_kind + w*f_morph_from;
|
||||
// 0 = no morph. Only read by MORPH pipelines (see mandelbrot.wgsl).
|
||||
morph_w: f32,
|
||||
// Deep views: binary exponent E of the view scale. `span` and `dc_offset`
|
||||
// are uploaded multiplied by 2^-E so they stay in f32's range; 0 = not
|
||||
// deep (plain f32 values). Only read by DEEP pipelines (mandelbrot.wgsl).
|
||||
scale_exp: i32,
|
||||
// Complex binomial coefficients C(complex_power, k) for k = 1..16, two per
|
||||
// vec4 (k odd in .xy, k even in .zw), for the Complex Multibrot delta
|
||||
// series. Precomputed on the CPU since they only depend on the power.
|
||||
cm_coef: array<vec4<f32>, 8>,
|
||||
};
|
||||
|
||||
// Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id.
|
||||
@@ -67,20 +90,21 @@ fn classic_color(ci: f32, de: f32) -> vec3<f32> {
|
||||
return palette(u.palette_id, t) * sqrt(de);
|
||||
}
|
||||
|
||||
// A single directional/point light, set by the UI's light list. `color`'s
|
||||
// alpha channel doubles as intensity (see `shadow_color`'s use of
|
||||
// `light_color.a`). Each shader that binds a `lights: array<Light, 16>`
|
||||
// uniform (colorize.wgsl, mandelbrot.wgsl's export shadow path) uses this
|
||||
// same layout.
|
||||
// A single directional light, built on the CPU from the UI's light list
|
||||
// (`GpuLight` in lights.rs): `dir` is the unit direction toward the light
|
||||
// (precomputed from azimuth/altitude so the shader does no trig), `color` a
|
||||
// packed RGBA8 whose alpha doubles as intensity. Only the first
|
||||
// `u.light_count` entries are live, all with a non-zero colour. Each shader
|
||||
// that binds a `lights: array<Light, 16>` uniform (colorize.wgsl,
|
||||
// mandelbrot.wgsl's export shadow path) uses this same layout.
|
||||
struct Light {
|
||||
azimuth: f32,
|
||||
altitude: f32,
|
||||
dir: vec3<f32>,
|
||||
color: u32,
|
||||
_pad: u32,
|
||||
};
|
||||
|
||||
// Lambertian term for a unit `light` direction.
|
||||
fn compute_light(normal: vec3<f32>, light: vec3<f32>) -> vec3<f32> {
|
||||
return vec3<f32>(max(0., dot(normal, normalize(light))));
|
||||
return vec3<f32>(max(0., dot(normal, light)));
|
||||
}
|
||||
|
||||
fn uncharted2tonemap(x: vec3<f32>) -> vec3<f32> {
|
||||
@@ -126,36 +150,48 @@ fn normal_from_heights(h0: f32, h1: f32, h2: f32) -> vec3<f32> {
|
||||
}
|
||||
|
||||
// Shade a DE-derived surface normal per `u.shadow_palette_id`: 0 = grayscale
|
||||
// key light, 1 = red/blue two-tone, 2 = the user's custom `lights` list.
|
||||
// key light, 1 = red/blue two-tone, 2 = the user's custom `lights` list,
|
||||
// 3 = the classic escape-time palette at `ci` (the smoothed iteration count),
|
||||
// lit by the grayscale key light.
|
||||
// Shared by the interactive shadow pass (colorize.wgsl) and the PNG-export
|
||||
// shadow path (mandelbrot.wgsl's `fs_color`), which must render identically.
|
||||
fn shadow_color(normal: vec3<f32>) -> vec3<f32> {
|
||||
fn shadow_color(normal: vec3<f32>, ci: f32) -> vec3<f32> {
|
||||
var color: vec3<f32>;
|
||||
if u.shadow_palette_id == 0u {
|
||||
color = compute_light(normal, vec3<f32>(.5, .5, .5)) + vec3<f32>(0.58, 0.85, 1.) * 0.2;
|
||||
color = compute_light(normal, vec3<f32>(0.57735027, 0.57735027, 0.57735027)) + vec3<f32>(0.58, 0.85, 1.) * 0.2;
|
||||
|
||||
color = filmic(color, 2.5);
|
||||
color = contrast(color, 4., 0.67);
|
||||
} else if u.shadow_palette_id == 1u {
|
||||
color = compute_light(normal, vec3<f32>(0., .5, .5)) * vec3<f32>(1., 0.5, 0.5) + compute_light(normal, vec3<f32>(0.5, 0., .5)) * vec3<f32>(0.5, 1., 1.);
|
||||
color = compute_light(normal, vec3<f32>(0., 0.70710678, 0.70710678)) * vec3<f32>(1., 0.5, 0.5) + compute_light(normal, vec3<f32>(0.70710678, 0., 0.70710678)) * vec3<f32>(0.5, 1., 1.);
|
||||
|
||||
color = filmic(color, 4.2);
|
||||
} else if u.shadow_palette_id == 3u {
|
||||
// No DE darkening as in `classic_color`: in shadow/3D modes the DE
|
||||
// is a height (unclamped, not capped at 1), and the lighting already
|
||||
// shows the relief.
|
||||
let t = fract(ci * u.color_scale + u.color_offset);
|
||||
let ambient = 0.25;
|
||||
let light = compute_light(normal, vec3<f32>(0.57735027, 0.57735027, 0.57735027));
|
||||
color = palette(u.palette_id, t) * (ambient + (1.0 - ambient) * light);
|
||||
} else {
|
||||
color = vec3<f32>(0);
|
||||
var light_count = 0;
|
||||
for (var i = 0u; i < 16; i++) {
|
||||
let light_count = min(u.light_count, 16u);
|
||||
for (var i = 0u; i < light_count; i++) {
|
||||
let light_color = unpack4x8unorm(lights[i].color);
|
||||
if any(light_color != vec4<f32>(0)) {
|
||||
light_count += 1;
|
||||
}
|
||||
|
||||
color += compute_light(normal, vec3<f32>(
|
||||
cos(lights[i].azimuth) * cos(lights[i].altitude),
|
||||
sin(lights[i].azimuth) * cos(lights[i].altitude),
|
||||
sin(lights[i].altitude))) * light_color.xyz * light_color.a;
|
||||
color += compute_light(normal, lights[i].dir) * light_color.xyz * light_color.a;
|
||||
}
|
||||
|
||||
color = filmic(color, 1. + f32(light_count));
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
// Colour of an interior (non-escaped) pixel in shadow/3D modes: black under
|
||||
// the classic palette, like classic 2D mode, otherwise a dark gray plateau.
|
||||
fn shadow_interior_color() -> vec3<f32> {
|
||||
if u.shadow_palette_id == 3u {
|
||||
return vec3<f32>(0.0);
|
||||
}
|
||||
return vec3<f32>(0.1);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+1033
-136
File diff suppressed because it is too large
Load Diff
+511
-51
@@ -1,61 +1,304 @@
|
||||
//! Camera / view state over the complex plane.
|
||||
//!
|
||||
//! The center is stored in arbitrary precision (`FBig`) — this is what lets us
|
||||
//! zoom far past f64's ~1e13x limit. The pixel *scale* stays `f64`: even at
|
||||
//! 10^30x zoom the scale is ~1e-33, comfortably inside f64's range. Only the
|
||||
//! center needs the extra digits.
|
||||
//! The center is stored in arbitrary precision ([`Big`]) — this is what lets us
|
||||
//! zoom far past f64's ~1e13x limit. The pixel *scale* is a [`Scale`]: an
|
||||
//! f64 mantissa with its own `i32` binary exponent, so it isn't bound by
|
||||
//! f64's ~1e-308 range either (the floor, `Scale::MIN`, only keeps the GPU's
|
||||
//! i32 exponent arithmetic far from overflow). Once a pixel is smaller than
|
||||
//! `DEEP_PIXEL_SIZE` the GPU switches to rescaled deltas (see `needs_deep`),
|
||||
//! since f32 alone bottoms out near 1e-38.
|
||||
|
||||
use core::str::FromStr;
|
||||
|
||||
use dashu_float::round::mode::HalfAway;
|
||||
use dashu_float::{DBig, FBig};
|
||||
|
||||
/// Arbitrary-precision binary float (base 2, round-half-away). One coordinate.
|
||||
pub type Big = FBig<HalfAway, 2>;
|
||||
pub use crate::bignum::Big;
|
||||
|
||||
/// Half-height (complex units) of the default view; also the zoom-1 reference.
|
||||
pub const DEFAULT_HALF_HEIGHT: f64 = 1.25;
|
||||
|
||||
/// Below this pixel size (complex units per pixel) the GPU renders with the
|
||||
/// deep pipeline, whose per-pixel deltas start out as an f32 mantissa times
|
||||
/// `2^scale_exp`. Plain f32 stays exact as long as the smallest per-pixel
|
||||
/// offsets (a quarter pixel, for the AA grid) are normal floats (>= 2^-126),
|
||||
/// i.e. down to 2^-124 per pixel. Measured: pixel-identical to the deep path
|
||||
/// down to 2^-124 (no AA), first errors at 2^-126, all black by 2^-136. The
|
||||
/// deep path is slower, so the switch is as late as that allows, with two
|
||||
/// binades of margin.
|
||||
pub const DEEP_PIXEL_SIZE: f64 = 1.0 / (1u128 << 122) as f64; // 2^-122
|
||||
|
||||
/// Whether a view rendered `height_px` pixels tall needs the deep pipeline
|
||||
/// (see `DEEP_PIXEL_SIZE`).
|
||||
pub fn needs_deep(half_height: Scale, height_px: f64) -> bool {
|
||||
half_height.mul_f64(2.0 / height_px.max(1.0)) < Scale::from_f64(DEEP_PIXEL_SIZE)
|
||||
}
|
||||
|
||||
/// Binary exponent `E` of the deep view scale: `floor(log2(half_height))`,
|
||||
/// so the rescaled span is in `[2, 4)`. Never 0, which means "not deep"
|
||||
/// (see `Uniforms::scale_exp`).
|
||||
pub fn deep_scale_exp(half_height: Scale) -> i32 {
|
||||
let e = half_height.exponent();
|
||||
if e == 0 { -1 } else { e }
|
||||
}
|
||||
|
||||
/// `x * 2^k` for any `k`, saturating to 0 / infinity like the true value
|
||||
/// would (`powi` alone overflows at 2^±1024 even when the product fits).
|
||||
fn ldexp(mut x: f64, mut k: i32) -> f64 {
|
||||
while k > 1000 {
|
||||
x *= 2f64.powi(1000);
|
||||
k -= 1000;
|
||||
if x.is_infinite() || x == 0.0 {
|
||||
return x;
|
||||
}
|
||||
}
|
||||
while k < -1000 {
|
||||
x *= 2f64.powi(-1000);
|
||||
k += 1000;
|
||||
if x == 0.0 || x.is_infinite() {
|
||||
return x;
|
||||
}
|
||||
}
|
||||
x * 2f64.powi(k)
|
||||
}
|
||||
|
||||
/// A positive real with f64 precision and an `i32` binary exponent:
|
||||
/// `m · 2^e`, `m` in `[1, 2)`. The view's half-height (and the pixel size
|
||||
/// derived from it) is one of these, so zoom isn't bound by f64's range.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Scale {
|
||||
m: f64,
|
||||
e: i32,
|
||||
}
|
||||
|
||||
impl Scale {
|
||||
/// Deepest scale the view can reach: 2^-(2^20) (about 1e-315653). Far
|
||||
/// past anything a reference orbit can practically be computed for; it
|
||||
/// only keeps the shader's i32 exponent sums (scale × degree) from
|
||||
/// overflowing.
|
||||
pub const MIN: Scale = Scale {
|
||||
m: 1.0,
|
||||
e: -(1 << 20),
|
||||
};
|
||||
|
||||
/// `m · 2^e`, normalized. Non-positive or NaN input gives `MIN`.
|
||||
pub fn from_parts(m: f64, e: i32) -> Self {
|
||||
if m.is_nan() || m <= 0.0 {
|
||||
return Self::MIN;
|
||||
}
|
||||
if m.is_infinite() {
|
||||
return Scale {
|
||||
m: 1.0,
|
||||
e: i32::MAX / 2,
|
||||
};
|
||||
}
|
||||
// Bring m into [1, 2) through its own binary exponent (exact).
|
||||
let k = m.log2().floor() as i32;
|
||||
let mut m = ldexp(m, -k);
|
||||
let mut e = e.saturating_add(k);
|
||||
// log2 can round across a power of two.
|
||||
if m >= 2.0 {
|
||||
m /= 2.0;
|
||||
e = e.saturating_add(1);
|
||||
} else if m < 1.0 {
|
||||
m *= 2.0;
|
||||
e = e.saturating_sub(1);
|
||||
}
|
||||
Scale { m, e }.max(Self::MIN)
|
||||
}
|
||||
|
||||
pub fn from_f64(x: f64) -> Self {
|
||||
Self::from_parts(x, 0)
|
||||
}
|
||||
|
||||
/// `2^l`.
|
||||
pub fn from_log2(l: f64) -> Self {
|
||||
let e = l.floor();
|
||||
Self::from_parts((l - e).exp2(), e as i32)
|
||||
}
|
||||
|
||||
/// The value as an f64 (0 or infinity outside its range).
|
||||
pub fn to_f64(self) -> f64 {
|
||||
ldexp(self.m, self.e)
|
||||
}
|
||||
|
||||
/// `self · 2^k` as an f64: the value in units of `2^-k`.
|
||||
pub fn scaled_f64(self, k: i32) -> f64 {
|
||||
ldexp(self.m, self.e.saturating_add(k))
|
||||
}
|
||||
|
||||
/// `floor(log2(self))`.
|
||||
pub fn exponent(self) -> i32 {
|
||||
self.e
|
||||
}
|
||||
|
||||
pub fn log2(self) -> f64 {
|
||||
self.m.log2() + self.e as f64
|
||||
}
|
||||
|
||||
pub fn log10(self) -> f64 {
|
||||
self.log2() * core::f64::consts::LOG10_2
|
||||
}
|
||||
|
||||
/// `self · f` (`f > 0`).
|
||||
pub fn mul_f64(self, f: f64) -> Self {
|
||||
Self::from_parts(self.m * f, self.e)
|
||||
}
|
||||
|
||||
/// `self / other`, as an f64.
|
||||
pub fn ratio(self, other: Scale) -> f64 {
|
||||
ldexp(self.m / other.m, self.e.saturating_sub(other.e))
|
||||
}
|
||||
|
||||
pub fn max(self, other: Scale) -> Self {
|
||||
if other > self { other } else { self }
|
||||
}
|
||||
|
||||
pub fn min(self, other: Scale) -> Self {
|
||||
if other < self { other } else { self }
|
||||
}
|
||||
|
||||
pub fn clamp(self, lo: Scale, hi: Scale) -> Self {
|
||||
self.max(lo).min(hi)
|
||||
}
|
||||
|
||||
/// `f · self` as an exact `Big` at `bits` of precision (`f` any f64).
|
||||
pub fn big_times(self, f: f64, bits: usize) -> Big {
|
||||
big_from_f64(f * self.m, bits) << self.e as isize
|
||||
}
|
||||
|
||||
/// Exact binary value as a `Big`.
|
||||
fn to_big(self) -> Big {
|
||||
big_from_f64(self.m, 53) << self.e as isize
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Scale {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
|
||||
// Normalized and positive: the exponent decides, then the mantissa.
|
||||
Some(self.e.cmp(&other.e).then(self.m.partial_cmp(&other.m)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl core::fmt::Display for Scale {
|
||||
/// Scientific notation, `1.5e-20` / `3.7e-4000`. The precision flag
|
||||
/// (`{:.4}`) sets mantissa digits after the point; without it, enough
|
||||
/// digits to parse back to the same value.
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
let x = self.to_f64();
|
||||
if x.is_normal() {
|
||||
return match f.precision() {
|
||||
Some(p) => write!(f, "{x:.p$e}"),
|
||||
None => write!(f, "{x:e}"),
|
||||
};
|
||||
}
|
||||
// Out of f64's range: round the exact decimal expansion instead.
|
||||
let 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.
|
||||
const GUARD_BITS: usize = 48;
|
||||
/// Upper bound on center precision (f32 GPU perturbation degrades long before
|
||||
/// this; the cap just prevents pathological allocation).
|
||||
const MAX_PRECISION_BITS: usize = 2048;
|
||||
/// Upper bound on center precision: what `Scale::MIN` needs. Only a guard
|
||||
/// against pathological input; the reference orbit is impractically slow
|
||||
/// long before this.
|
||||
pub const MAX_PRECISION_BITS: usize = (1 << 20) + GUARD_BITS;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ViewState {
|
||||
pub center_re: Big,
|
||||
pub center_im: Big,
|
||||
/// Half the view height in complex-plane units. Zooming in shrinks this.
|
||||
pub half_height: f64,
|
||||
pub half_height: Scale,
|
||||
}
|
||||
|
||||
impl Default for ViewState {
|
||||
fn default() -> Self {
|
||||
let bits = precision_for(DEFAULT_HALF_HEIGHT);
|
||||
let bits = precision_for(Scale::from_f64(DEFAULT_HALF_HEIGHT));
|
||||
Self {
|
||||
center_re: big_from_f64(-0.5, bits),
|
||||
center_im: big_from_f64(0.0, bits),
|
||||
half_height: DEFAULT_HALF_HEIGHT,
|
||||
half_height: Scale::from_f64(DEFAULT_HALF_HEIGHT),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewState {
|
||||
/// Complex-plane span (width, height) for the given pixel aspect ratio.
|
||||
pub fn span(&self, aspect: f64) -> (f64, f64) {
|
||||
let h = self.half_height * 2.0;
|
||||
(h * aspect, h)
|
||||
pub fn span(&self, aspect: f64) -> (Scale, Scale) {
|
||||
let h = self.half_height.mul_f64(2.0);
|
||||
(h.mul_f64(aspect), h)
|
||||
}
|
||||
|
||||
/// Complex-plane units per pixel, given the viewport height in pixels.
|
||||
pub fn complex_per_pixel(&self, height_px: f64) -> f64 {
|
||||
(self.half_height * 2.0) / height_px
|
||||
pub fn complex_per_pixel(&self, height_px: f64) -> Scale {
|
||||
self.half_height.mul_f64(2.0 / height_px)
|
||||
}
|
||||
|
||||
/// Current magnification relative to the default view.
|
||||
pub fn magnification(&self) -> f64 {
|
||||
DEFAULT_HALF_HEIGHT / self.half_height
|
||||
/// log10 of the current magnification relative to the default view.
|
||||
pub fn magnification_log10(&self) -> f64 {
|
||||
DEFAULT_HALF_HEIGHT.log10() - self.half_height.log10()
|
||||
}
|
||||
|
||||
/// Current zoom level.
|
||||
pub fn zoom(&self) -> Scale {
|
||||
self.half_height
|
||||
}
|
||||
|
||||
/// Bits of precision the center currently needs for this zoom level.
|
||||
@@ -68,10 +311,10 @@ impl ViewState {
|
||||
pub fn sync_precision(&mut self) {
|
||||
let bits = self.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 {
|
||||
self.center_im = self.center_im.clone().with_precision(bits).value();
|
||||
self.center_im = self.center_im.clone().with_precision(bits);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +324,8 @@ impl ViewState {
|
||||
let cpp = self.complex_per_pixel(height_px);
|
||||
let bits = self.precision_bits();
|
||||
// Grab-and-drag: moving the mouse right shows content to the left.
|
||||
self.center_re = &self.center_re - &big_from_f64(dx * cpp, bits);
|
||||
self.center_im = &self.center_im - &big_from_f64(dy * cpp, bits);
|
||||
self.center_re = &self.center_re - &cpp.big_times(dx, bits);
|
||||
self.center_im = &self.center_im - &cpp.big_times(dy, bits);
|
||||
}
|
||||
|
||||
/// Zoom by `factor` (<1 zooms in) keeping the complex point currently under
|
||||
@@ -95,14 +338,14 @@ impl ViewState {
|
||||
// The cursor's complex offset from the center is (off * cpp). Keeping it
|
||||
// fixed while scaling the view by `factor` moves the center by
|
||||
// off * cpp * (1 - factor). (Derivation: new_c = fixed + (c-fixed)*f.)
|
||||
let k = cpp * (1.0 - factor);
|
||||
self.center_re = &self.center_re + &big_from_f64(off_x * k, bits);
|
||||
self.center_im = &self.center_im + &big_from_f64(off_y * k, bits);
|
||||
self.half_height *= factor;
|
||||
let k = 1.0 - factor;
|
||||
self.center_re = &self.center_re + &cpp.big_times(off_x * k, bits);
|
||||
self.center_im = &self.center_im + &cpp.big_times(off_y * k, bits);
|
||||
self.half_height = self.half_height.mul_f64(factor);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
center_re,
|
||||
center_im,
|
||||
@@ -116,36 +359,253 @@ impl ViewState {
|
||||
/// 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.
|
||||
pub fn big_from_decimal_str(s: &str, bits: usize) -> Option<Big> {
|
||||
let dec = DBig::from_str(s.trim()).ok()?;
|
||||
Some(dec.with_base_and_precision::<2>(bits.max(53)).value())
|
||||
Big::from_decimal_str(s, bits)
|
||||
}
|
||||
|
||||
/// Parse a "re,im,half_height[,iterations]" spec (re/im decimal, parsed at
|
||||
/// full precision) into a view and an optional iteration count. Shared by
|
||||
/// `FractalApp::apply_view_spec` (the `--view` CLI flag) and headless
|
||||
/// animation's `--to-view`.
|
||||
pub fn parse_view_spec(spec: &str) -> Option<(ViewState, Option<u32>)> {
|
||||
let parts: Vec<&str> = spec.split(',').collect();
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let half_height = parse_half_height_spec(parts[2])?;
|
||||
let bits = precision_for(half_height);
|
||||
let re = big_from_decimal_str(parts[0], bits)?;
|
||||
let im = big_from_decimal_str(parts[1], bits)?;
|
||||
let iterations = parts.get(3).and_then(|s| s.trim().parse::<u32>().ok());
|
||||
Some((ViewState::with_center(re, im, half_height), iterations))
|
||||
}
|
||||
|
||||
/// Parse a half_height spec. Shared by
|
||||
/// `FractalApp::apply_half_height_spec` (the `--zoom` CLI flag) and headless
|
||||
/// animation's `--to-zoom`.
|
||||
pub fn parse_half_height_spec(spec: &str) -> Option<Scale> {
|
||||
spec.parse::<Scale>().ok()
|
||||
}
|
||||
/// Parse a "re,im" spec (re/im decimal, parsed at
|
||||
/// full precision) into a view. Shared by
|
||||
/// `FractalApp::apply_re_im_spec` (the `--position` CLI flag) and headless
|
||||
/// animation's `--to-position`.
|
||||
pub fn parse_re_im_spec(spec: &str, bits: usize) -> Option<(Big, Big)> {
|
||||
let parts: Vec<&str> = spec.split(',').collect();
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
let re = big_from_decimal_str(parts[0], bits)?;
|
||||
let im = big_from_decimal_str(parts[1], bits)?;
|
||||
Some((re, im))
|
||||
}
|
||||
|
||||
/// Interpolate between two views for an animation frame, `t` in `[0, 1]`.
|
||||
/// The half-height interpolates geometrically (log-linear), since zoom depth
|
||||
/// spans many decades and a linear sweep would crawl at the start and blow
|
||||
/// past the target at the end. The center has to shrink its offset from the
|
||||
/// target at that *same* geometric rate: blending it linearly in `t` instead
|
||||
/// barely moves it while the view is still huge (early frames), so the
|
||||
/// target stays effectively off-screen — offset/half_height ratio blows up —
|
||||
/// for nearly the whole animation, and only lands on `to`'s center in the
|
||||
/// literal last frame where `t == 1` forces an exact match. `g(t)` below
|
||||
/// tracks the same `q^t` decay used for `half_height` (keeping the
|
||||
/// offset/half_height ratio roughly constant, i.e. the target's on-screen
|
||||
/// position steady) but is shifted so it lands on exactly 1 at `t = 0` and
|
||||
/// exactly 0 at `t = 1`.
|
||||
///
|
||||
/// With `d = log2(q)`, `g = q^t · (1 - q^(1-t)) / (1 - q)`, all in `Scale`
|
||||
/// / `expm1` form: zooming in by more than f64's range, `q` (and `q^t`)
|
||||
/// underflow, yet `g · (from - to)` must keep tracking the half-height.
|
||||
pub fn interpolate_view(from: &ViewState, to: &ViewState, t: f64) -> ViewState {
|
||||
let (l0, l1) = (from.half_height.log2(), to.half_height.log2());
|
||||
let d = l1 - l0;
|
||||
let half_height = if t <= 0.0 {
|
||||
from.half_height
|
||||
} else if t >= 1.0 {
|
||||
to.half_height
|
||||
} else {
|
||||
Scale::from_log2(l0 + t * d)
|
||||
};
|
||||
let bits = precision_for(half_height);
|
||||
let ln2 = core::f64::consts::LN_2;
|
||||
let g_big = if d.abs() < 1e-12 {
|
||||
big_from_f64(1.0 - t, bits)
|
||||
} else if d < 0.0 {
|
||||
// Zooming in: q^t may be far below f64's range, keep it as a Scale.
|
||||
let f = ((1.0 - t) * d * ln2).exp_m1() / (d * ln2).exp_m1();
|
||||
Scale::from_log2(t * d).big_times(f, bits)
|
||||
} else {
|
||||
// Zooming out: g = (1 - q^(t-1)) / (1 - q^-1), every term bounded.
|
||||
big_from_f64(((t - 1.0) * d * ln2).exp_m1() / (-d * ln2).exp_m1(), bits)
|
||||
};
|
||||
let re0 = from.center_re.clone().with_precision(bits);
|
||||
let im0 = from.center_im.clone().with_precision(bits);
|
||||
let re1 = to.center_re.clone().with_precision(bits);
|
||||
let im1 = to.center_im.clone().with_precision(bits);
|
||||
let center_re = &re1 + &(&(&re0 - &re1) * &g_big);
|
||||
let center_im = &im1 + &(&(&im0 - &im1) * &g_big);
|
||||
ViewState::with_center(center_re, center_im, half_height)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn interpolate_f64(from: f64, to: f64, t: f64) -> f64 {
|
||||
from + (to - from) * t
|
||||
}
|
||||
|
||||
/// Render a `Big` as a decimal string with `sig_digits` significant digits.
|
||||
pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String {
|
||||
let dec = x
|
||||
.to_decimal()
|
||||
.value()
|
||||
.with_precision(sig_digits.max(1))
|
||||
.value();
|
||||
format!("{dec}")
|
||||
x.to_decimal_string(sig_digits)
|
||||
}
|
||||
|
||||
/// 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
|
||||
// log2(1 / half_height) significant bits, plus a guard margin.
|
||||
let zoom_bits = if half_height > 0.0 && half_height.is_finite() {
|
||||
(-half_height.log2()).ceil().max(0.0) as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let zoom_bits = (-half_height.log2()).ceil().max(0.0) as usize;
|
||||
(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 {
|
||||
Big::try_from(x)
|
||||
.unwrap_or_default()
|
||||
.with_precision(bits)
|
||||
.value()
|
||||
Big::from_f64(x, bits)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sc(x: f64) -> Scale {
|
||||
Scale::from_f64(x)
|
||||
}
|
||||
|
||||
fn re_im_f64(v: &ViewState) -> (f64, f64) {
|
||||
let re: f64 = v.center_re.to_f64();
|
||||
let im: f64 = v.center_im.to_f64();
|
||||
(re, im)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolate_view_hits_exact_endpoints() {
|
||||
let bits = precision_for(sc(1.0));
|
||||
let from =
|
||||
ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), sc(1.5));
|
||||
let to = ViewState::with_center(
|
||||
big_from_f64(-0.7515, precision_for(sc(1e-20))),
|
||||
big_from_f64(0.1013, precision_for(sc(1e-20))),
|
||||
sc(1e-20),
|
||||
);
|
||||
|
||||
let start = interpolate_view(&from, &to, 0.0);
|
||||
assert_eq!(re_im_f64(&start), re_im_f64(&from));
|
||||
assert_eq!(start.half_height, from.half_height);
|
||||
|
||||
let end = interpolate_view(&from, &to, 1.0);
|
||||
assert_eq!(re_im_f64(&end), re_im_f64(&to));
|
||||
assert_eq!(end.half_height, to.half_height);
|
||||
}
|
||||
|
||||
/// Regression test: a deep zoom's center used to be blended linearly in
|
||||
/// `t` while `half_height` shrank geometrically, so partway through the
|
||||
/// animation the offset from the target would already be far larger than
|
||||
/// the (tiny, geometrically-shrunk) view — the target only snapped into
|
||||
/// frame on the very last frame. The offset/half_height ratio should
|
||||
/// instead stay roughly bounded throughout.
|
||||
#[test]
|
||||
fn interpolate_view_keeps_target_offset_bounded() {
|
||||
let bits = precision_for(sc(1.0));
|
||||
let from =
|
||||
ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), sc(1.5));
|
||||
let to = ViewState::with_center(
|
||||
big_from_f64(-0.7515, precision_for(sc(1e-20))),
|
||||
big_from_f64(0.1013, precision_for(sc(1e-20))),
|
||||
sc(1e-20),
|
||||
);
|
||||
let (to_re, to_im) = re_im_f64(&to);
|
||||
|
||||
for i in 1..10 {
|
||||
let t = i as f64 / 10.0;
|
||||
let mid = interpolate_view(&from, &to, t);
|
||||
let (re, im) = re_im_f64(&mid);
|
||||
let offset = ((re - to_re).powi(2) + (im - to_im).powi(2)).sqrt();
|
||||
let ratio = offset / mid.half_height.to_f64();
|
||||
assert!(
|
||||
ratio < 10.0,
|
||||
"t={t}: offset/half_height ratio {ratio} blew up (offset={offset}, half_height={})",
|
||||
mid.half_height
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_parse_display_round_trip() {
|
||||
for s in [
|
||||
"1.25",
|
||||
"1e-20",
|
||||
"1.5e-20",
|
||||
"3.7e-4000",
|
||||
"1e-400",
|
||||
"9.99999e-310",
|
||||
] {
|
||||
let a: Scale = s.parse().unwrap();
|
||||
let b: Scale = a.to_string().parse().unwrap();
|
||||
assert_eq!(a, b, "{s} -> {a}");
|
||||
}
|
||||
assert_eq!("1.25".parse::<Scale>().unwrap().to_f64(), 1.25);
|
||||
assert_eq!(sc(1.5e-20).to_string(), "1.5e-20");
|
||||
let deep: Scale = "3.7e-4000".parse().unwrap();
|
||||
assert_eq!(deep.to_string(), "3.7e-4000");
|
||||
assert_eq!(format!("{deep:.2}"), "3.70e-4000");
|
||||
assert!((deep.log10() - (3.7f64.log10() - 4000.0)).abs() < 1e-9);
|
||||
assert!("0".parse::<Scale>().is_err());
|
||||
assert!("-1e-500".parse::<Scale>().is_err());
|
||||
assert!("abc".parse::<Scale>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_arithmetic() {
|
||||
let a: Scale = "1e-1000".parse().unwrap();
|
||||
let b = a.mul_f64(0.25);
|
||||
assert!((b.ratio(a) - 0.25).abs() < 1e-15);
|
||||
assert!(b < a && a > b);
|
||||
assert_eq!(a.mul_f64(3.0).mul_f64(1.0 / 3.0).exponent(), a.exponent());
|
||||
assert_eq!(sc(1.0).exponent(), 0);
|
||||
assert_eq!(sc(0.75).exponent(), -1);
|
||||
assert_eq!(sc(4.0).scaled_f64(-2), 1.0);
|
||||
assert_eq!(
|
||||
a.scaled_f64(-a.exponent()),
|
||||
a.mul_f64(1.0).scaled_f64(-a.exponent())
|
||||
);
|
||||
assert!((1.0..2.0).contains(&a.scaled_f64(-a.exponent())));
|
||||
assert_eq!(a.to_f64(), 0.0);
|
||||
assert_eq!(Scale::MIN.mul_f64(0.5), Scale::MIN);
|
||||
let p = precision_for(a);
|
||||
assert!((3322 + 48..=3323 + 48).contains(&p), "{p}");
|
||||
}
|
||||
|
||||
/// Past f64's range, the center must still land on the target at the
|
||||
/// same geometric pace as the half-height.
|
||||
#[test]
|
||||
fn interpolate_view_past_f64_range() {
|
||||
let from = ViewState::with_center(big_from_f64(-0.5, 64), big_from_f64(0.0, 64), sc(1.5));
|
||||
let hh: Scale = "1e-1000".parse().unwrap();
|
||||
let bits = precision_for(hh);
|
||||
let to =
|
||||
ViewState::with_center(big_from_f64(-0.7515, bits), big_from_f64(0.1013, bits), hh);
|
||||
let end = interpolate_view(&from, &to, 1.0);
|
||||
assert_eq!(end.half_height, hh);
|
||||
assert_eq!(re_im_f64(&end), re_im_f64(&to));
|
||||
let mut prev = from.half_height;
|
||||
for i in 1..20 {
|
||||
let t = i as f64 / 20.0;
|
||||
let mid = interpolate_view(&from, &to, t);
|
||||
assert!(mid.half_height < prev);
|
||||
prev = mid.half_height;
|
||||
// Offset from the target, in units of the view's half-height.
|
||||
let k = -mid.half_height.exponent() as isize;
|
||||
let dre = ((&mid.center_re - &to.center_re) << k).to_f64();
|
||||
let dim = ((&mid.center_im - &to.center_im) << k).to_f64();
|
||||
let ratio = (dre * dre + dim * dim).sqrt() / mid.half_height.scaled_f64(k as i32);
|
||||
assert!(ratio > 0.01 && ratio < 10.0, "t={t}: ratio {ratio}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-7
@@ -1,7 +1,7 @@
|
||||
//! Native background worker for reference-orbit computation.
|
||||
//!
|
||||
//! 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
|
||||
//! drag) down to the most recent one. On the web we compute inline instead
|
||||
//! (browsers need a Web Worker for threads); see `app.rs`.
|
||||
@@ -9,13 +9,13 @@
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
|
||||
use std::thread;
|
||||
|
||||
use crate::fractal::{FractalKind, compute_reference, compute_set_reference};
|
||||
use crate::view::{Big, big_from_f64};
|
||||
use crate::fractal::{FractalKind, RefOrbit, compute_reference, compute_set_reference};
|
||||
use crate::view::{Big, Scale, big_from_f64};
|
||||
|
||||
pub struct RefRequest {
|
||||
pub center_re: Big,
|
||||
pub center_im: Big,
|
||||
pub half_height: f64,
|
||||
pub half_height: Scale,
|
||||
pub julia: bool,
|
||||
pub julia_c: (f64, f64),
|
||||
pub max_iter: u32,
|
||||
@@ -28,13 +28,19 @@ pub struct RefRequest {
|
||||
pub lambda_l: (f64, f64),
|
||||
/// Complex exponent for the Complex Multibrot kind (ignored by other kinds).
|
||||
pub complex_power: (f64, f64),
|
||||
/// Kind-switch morph: `(from_kind, weight)` blended into every step.
|
||||
pub morph: Option<(FractalKind, f32)>,
|
||||
}
|
||||
|
||||
pub struct RefResult {
|
||||
pub center_re: Big,
|
||||
pub center_im: Big,
|
||||
pub half_height: f64,
|
||||
pub points: Vec<[f32; 2]>,
|
||||
pub half_height: Scale,
|
||||
pub points: RefOrbit,
|
||||
/// The kind and morph `points` was computed with (echoed from the
|
||||
/// request).
|
||||
pub kind: FractalKind,
|
||||
pub morph: Option<(FractalKind, f32)>,
|
||||
}
|
||||
|
||||
pub struct RefWorker {
|
||||
@@ -68,6 +74,8 @@ impl RefWorker {
|
||||
center_im: req.center_im,
|
||||
half_height: req.half_height,
|
||||
points,
|
||||
kind: req.kind,
|
||||
morph: req.morph,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
@@ -94,7 +102,7 @@ impl RefWorker {
|
||||
}
|
||||
}
|
||||
|
||||
fn compute(req: &RefRequest) -> Vec<[f32; 2]> {
|
||||
fn compute(req: &RefRequest) -> RefOrbit {
|
||||
if req.julia {
|
||||
let jr = big_from_f64(req.julia_c.0, req.precision);
|
||||
let ji = big_from_f64(req.julia_c.1, req.precision);
|
||||
@@ -110,6 +118,7 @@ fn compute(req: &RefRequest) -> Vec<[f32; 2]> {
|
||||
req.phoenix_p,
|
||||
req.lambda_l,
|
||||
req.complex_power,
|
||||
req.morph.map(|(k, w)| (k, w as f64)),
|
||||
)
|
||||
} else {
|
||||
compute_set_reference(
|
||||
@@ -122,6 +131,7 @@ fn compute(req: &RefRequest) -> Vec<[f32; 2]> {
|
||||
req.phoenix_p,
|
||||
req.lambda_l,
|
||||
req.complex_power,
|
||||
req.morph.map(|(k, w)| (k, w as f64)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+122
-15
@@ -3,7 +3,7 @@
|
||||
//! shader with the same `naga` version wgpu uses — catching shader errors
|
||||
//! without needing a GPU or a display.
|
||||
|
||||
fn validate(name: &str, src: &str) {
|
||||
fn validate(name: &str, src: &str) -> (naga::Module, naga::valid::ModuleInfo) {
|
||||
let module = match naga::front::wgsl::parse_str(src) {
|
||||
Ok(m) => m,
|
||||
Err(e) => panic!("{name}: WGSL parse error:\n{}", e.emit_to_string(src)),
|
||||
@@ -12,21 +12,102 @@ fn validate(name: &str, src: &str) {
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
);
|
||||
if let Err(e) = validator.validate(&module) {
|
||||
panic!("{name}: WGSL validation error:\n{}", e.emit_to_string(src));
|
||||
match validator.validate(&module) {
|
||||
Ok(info) => (module, info),
|
||||
Err(e) => panic!("{name}: WGSL validation error:\n{}", e.emit_to_string(src)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mandelbrot_shader_is_valid() {
|
||||
validate(
|
||||
"mandelbrot.wgsl",
|
||||
concat!(
|
||||
/// Number of fractal kinds, i.e. the `const KIND_*` declarations in
|
||||
/// common.wgsl (one per `FractalKind` variant, values 0..N).
|
||||
fn kind_count() -> u32 {
|
||||
let n = include_str!("../src/shaders/common.wgsl")
|
||||
.lines()
|
||||
.filter(|l| l.starts_with("const KIND_"))
|
||||
.count() as u32;
|
||||
assert!(n >= 10, "found only {n} KIND_* constants in common.wgsl");
|
||||
n
|
||||
}
|
||||
|
||||
/// Specialize `module`'s `override`s with `constants` for `entry_point` (as
|
||||
/// wgpu does at pipeline creation) and compile the result to SPIR-V, so a
|
||||
/// shader that only breaks once a particular override value folds a branch
|
||||
/// in or out is still caught.
|
||||
fn specialize(
|
||||
name: &str,
|
||||
module: &naga::Module,
|
||||
info: &naga::valid::ModuleInfo,
|
||||
stage: naga::ShaderStage,
|
||||
entry_point: &str,
|
||||
constants: &[(&str, f64)],
|
||||
) {
|
||||
let mut pc = naga::back::PipelineConstants::default();
|
||||
for (k, v) in constants {
|
||||
pc.insert((*k).to_string(), *v);
|
||||
}
|
||||
let (module, info) = naga::back::pipeline_constants::process_overrides(
|
||||
module,
|
||||
info,
|
||||
Some((stage, entry_point)),
|
||||
&pc,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{name} {entry_point} {constants:?}: override error: {e:?}"));
|
||||
let pipeline = naga::back::spv::PipelineOptions {
|
||||
shader_stage: stage,
|
||||
entry_point: entry_point.to_string(),
|
||||
};
|
||||
naga::back::spv::write_vec(
|
||||
&module,
|
||||
&info,
|
||||
&naga::back::spv::Options::default(),
|
||||
Some(&pipeline),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{name} {entry_point} {constants:?}: SPIR-V error: {e:?}"));
|
||||
}
|
||||
|
||||
const MANDELBROT_SRC: &str = concat!(
|
||||
include_str!("../src/shaders/common.wgsl"),
|
||||
include_str!("../src/shaders/iterate_uniforms.wgsl"),
|
||||
include_str!("../src/shaders/mandelbrot.wgsl"),
|
||||
),
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn mandelbrot_shader_is_valid() {
|
||||
validate("mandelbrot.wgsl", MANDELBROT_SRC);
|
||||
}
|
||||
|
||||
/// Every specialization renderer.rs can build (`PipelineKey`: kind × Julia ×
|
||||
/// DE × morph × deep), for every fragment entry point.
|
||||
#[test]
|
||||
fn mandelbrot_shader_specializations_compile() {
|
||||
let (module, info) = validate("mandelbrot.wgsl", MANDELBROT_SRC);
|
||||
for kind in 0..kind_count() {
|
||||
for julia in [0.0, 1.0] {
|
||||
for de in [0.0, 1.0] {
|
||||
for morph in [0.0, 1.0] {
|
||||
for deep in [0.0, 1.0] {
|
||||
let constants = [
|
||||
("KIND", kind as f64),
|
||||
("IS_JULIA", julia),
|
||||
("DE", de),
|
||||
("MORPH", morph),
|
||||
("DEEP", deep),
|
||||
];
|
||||
for entry in ["fs_data", "fs_refine", "fs_color"] {
|
||||
specialize(
|
||||
"mandelbrot.wgsl",
|
||||
&module,
|
||||
&info,
|
||||
naga::ShaderStage::Fragment,
|
||||
entry,
|
||||
&constants,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -41,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]
|
||||
fn blit_shader_is_valid() {
|
||||
validate(
|
||||
@@ -52,13 +144,28 @@ fn blit_shader_is_valid() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buddhabrot_shader_is_valid() {
|
||||
validate(
|
||||
"buddhabrot.wgsl",
|
||||
concat!(
|
||||
const BUDDHABROT_SRC: &str = concat!(
|
||||
include_str!("../src/shaders/common.wgsl"),
|
||||
include_str!("../src/shaders/buddhabrot.wgsl"),
|
||||
),
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn buddhabrot_shader_is_valid() {
|
||||
validate("buddhabrot.wgsl", BUDDHABROT_SRC);
|
||||
}
|
||||
|
||||
/// Every per-kind accumulation pipeline buddhabrot.rs can build.
|
||||
#[test]
|
||||
fn buddhabrot_shader_specializations_compile() {
|
||||
let (module, info) = validate("buddhabrot.wgsl", BUDDHABROT_SRC);
|
||||
for kind in 0..kind_count() {
|
||||
specialize(
|
||||
"buddhabrot.wgsl",
|
||||
&module,
|
||||
&info,
|
||||
naga::ShaderStage::Compute,
|
||||
"cs_main",
|
||||
&[("KIND", kind as f64)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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