Compare commits
3
Commits
main
..
783e3e1f30
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
783e3e1f30 | ||
|
|
0f01a16d60 | ||
|
|
6caa23accd |
@@ -1,6 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
dist
|
dist
|
||||||
frames*
|
|
||||||
out.mp4
|
|
||||||
__pycache__
|
|
||||||
|
|||||||
@@ -6,15 +6,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
A deep-zoom fractal explorer (Rust + wgpu + egui + WGSL). It zooms past the
|
A deep-zoom fractal explorer (Rust + wgpu + egui + WGSL). It zooms past the
|
||||||
~10¹³× limit of plain `f64` using **perturbation theory**: one high-precision
|
~10¹³× limit of plain `f64` using **perturbation theory**: one high-precision
|
||||||
reference orbit is computed on the CPU (arbitrary precision via `bignum::Big`:
|
reference orbit is computed on the CPU (arbitrary precision via `dashu-float`),
|
||||||
`rug` natively, `malachite-float` on the web),
|
|
||||||
and every pixel is rendered on the GPU as a cheap `f32` delta from it, with
|
and every pixel is rendered on the GPU as a cheap `f32` delta from it, with
|
||||||
rebasing to avoid glitches. Plain `f32` deltas run out of exponent range
|
rebasing to avoid glitches. The `f32` GPU tier reaches roughly 10³⁰×. Runs
|
||||||
once a pixel is ~2^-124 wide (~10³⁴× at 1080p), so from 2^-122 per pixel
|
|
||||||
(`view::DEEP_PIXEL_SIZE`) a `DEEP` shader variant starts each pixel with
|
|
||||||
rescaled deltas (f32 mantissa × 2^i32). There's no practical depth limit:
|
|
||||||
`half_height` is a `view::Scale` (f64 mantissa × 2^i32), floored only at
|
|
||||||
`Scale::MIN` = 2^-(2^20) to keep shader exponent sums in i32. Runs
|
|
||||||
natively (Vulkan/Metal/DX12) and in the browser (WebGPU only — WebGL2 can't do
|
natively (Vulkan/Metal/DX12) and in the browser (WebGPU only — WebGL2 can't do
|
||||||
storage buffers, which the fragment shader needs for the reference orbit).
|
storage buffers, which the fragment shader needs for the reference orbit).
|
||||||
|
|
||||||
@@ -23,12 +17,9 @@ storage buffers, which the fragment shader needs for the reference orbit).
|
|||||||
```sh
|
```sh
|
||||||
cargo run --release # native, run (release matters: fractal math is hot)
|
cargo run --release # native, run (release matters: fractal math is hot)
|
||||||
cargo test # reference-orbit math, share-link round-trip, WGSL validation
|
cargo test # reference-orbit math, share-link round-trip, WGSL validation
|
||||||
cargo test --features wasm # same, on the web build's malachite big-float backend
|
|
||||||
cargo test --test shader_valid # just the WGSL parse/validate tests (naga, no GPU needed)
|
cargo test --test shader_valid # just the WGSL parse/validate tests (naga, no GPU needed)
|
||||||
cargo clippy
|
cargo clippy
|
||||||
cargo fmt # rustfmt.toml just pins edition = "2024"
|
cargo fmt # rustfmt.toml just pins edition = "2024"
|
||||||
cargo build --release --no-default-features # headless-only binary: no eframe/egui (the default `gui` feature)
|
|
||||||
tools/bench/bench.sh --rev HEAD # perf of uncommitted edits vs HEAD (hyperfine; see tools/bench/README.md)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Web build (WebGPU):
|
Web build (WebGPU):
|
||||||
@@ -36,60 +27,19 @@ Web build (WebGPU):
|
|||||||
```sh
|
```sh
|
||||||
rustup target add wasm32-unknown-unknown
|
rustup target add wasm32-unknown-unknown
|
||||||
cargo install wasm-bindgen-cli --version 0.2.128 # must match the wasm-bindgen crate version
|
cargo install wasm-bindgen-cli --version 0.2.128 # must match the wasm-bindgen crate version
|
||||||
./build-web.sh # -> ./dist (builds with --features wasm)
|
./build-web.sh # -> ./dist
|
||||||
python3 -m http.server -d dist 8080
|
python3 -m http.server -d dist 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Native CLI flags (`src/cli.rs`, applied in `FractalApp::apply_cli`): `--kind`,
|
Native CLI flags (`src/cli.rs`, applied in `FractalApp::apply_cli`): `--kind`,
|
||||||
`--power`, `--julia re,im`, `--phoenix-p re,im`, `--lambda-l re,im`,
|
`--power`, `--julia re,im`, `--share <fragment>`,
|
||||||
`--palette`, `--share <fragment>`,
|
`--view re,im,half_height[,iterations]`, `--de`, `--buddhabrot`,
|
||||||
`--view re,im,half_height[,iterations]`, `--rendering-kind`,
|
`--buddha-palette`, `--export` (+ `--export-path out.png`). `--headless`
|
||||||
`--yaw`/`--pitch` (3D camera, degrees), `--de`, `--antialias` (2×2),
|
(`src/headless.rs`) skips the window entirely: it builds the same view from
|
||||||
`--buddhabrot`,
|
the other flags, creates its own offscreen wgpu device, and renders straight
|
||||||
`--buddha-palette`. `--headless` (`src/headless.rs`) skips the window
|
to a PNG (`--width`/`--height`, default 1920×1080) — implies `--export`'s
|
||||||
entirely: it builds the same view from the other flags, creates its own
|
save behavior without needing a GPU-backed window/event loop. Not yet
|
||||||
offscreen wgpu device, and renders straight to a PNG (`--width`/`--height`,
|
supported with `--buddhabrot`. Run `mandelbrot --help` for the full list.
|
||||||
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`
|
There's no GPU in most sandboxes: `cargo check`/`cargo test --test shader_valid`
|
||||||
are the fast, headless way to validate a change. `cargo test` also runs but
|
are the fast, headless way to validate a change. `cargo test` also runs but
|
||||||
@@ -108,89 +58,15 @@ runs out), rebase: `e ← y_n − X_0`, restart the reference index at 0. This i
|
|||||||
what makes deep zoom cheap — one expensive high-precision orbit, then every
|
what makes deep zoom cheap — one expensive high-precision orbit, then every
|
||||||
pixel is a handful of `f32` complex multiplies.
|
pixel is a handful of `f32` complex multiplies.
|
||||||
|
|
||||||
- `src/bignum/` — `Big`, the arbitrary-precision binary float, with one
|
- `src/view.rs` — `ViewState`; center is arbitrary-precision `FBig` (`Big`
|
||||||
backend per library behind the same inherent API + operators: `rug`
|
type alias), pixel scale stays `f64` (still in-range at 10³⁰×). Precision
|
||||||
(GMP/MPFR, default, fastest, can't target wasm32) and `malachite-float`
|
(bits) scales with zoom depth (`precision_for`).
|
||||||
(pure Rust, the `wasm` feature, required for the web build; a
|
- `src/fractal/reference.rs` — `FractalKind` enum (Mandelbrot, Burning Ship,
|
||||||
`compile_error!` enforces it). Cargo features are additive, so rug is a
|
Tricorn, Multibrot, Celtic, Perpendicular, Buffalo, Phoenix, Lambda) and
|
||||||
non-wasm32 target dependency and the backend is picked by
|
`compute_reference`/`compute_set_reference`: iterate the chosen formula at
|
||||||
`cfg(feature = "wasm")`. Both follow the precision rule: a result has the
|
high precision on the CPU, emitting `Z_n` as `f32` pairs — that's the
|
||||||
larger operand precision, rounded to nearest; shifts are exact. Malachite's
|
reference orbit the GPU perturbs from.
|
||||||
zero has no precision, so its wrapper stores `prec` alongside. Any new
|
- `src/shaders/mandelbrot.wgsl` — the perturbation fragment shader.
|
||||||
`Big` operation must be added to both backends (`cargo test` and
|
|
||||||
`cargo test --features wasm` run the same tests on each).
|
|
||||||
- `src/view.rs` — `ViewState`; center is arbitrary-precision `Big`
|
|
||||||
(re-exported as `view::Big`). The pixel scale (`half_height`) is a `Scale`, an f64
|
|
||||||
mantissa with its own i32 exponent, so it goes past f64's ~1e-308. Never
|
|
||||||
collapse it (or a center difference) to a plain `f64` on a path used at
|
|
||||||
depth. Rescale first: `Scale::scaled_f64(k)`, or shift the `Big` by
|
|
||||||
`-scale_exp` before `to_f64()`, as `dc_offset`/`drift_from` do.
|
|
||||||
`Display`/`FromStr` use scientific notation of any exponent (share links,
|
|
||||||
`--view`, the zoom field). Precision (bits) scales with zoom depth
|
|
||||||
(`precision_for`). `needs_deep` switches rendering to the deep pipeline
|
|
||||||
once a pixel of the full-resolution render is below `DEEP_PIXEL_SIZE`
|
|
||||||
(2^-122; the f32 path is exact down to 2^-124 with AA's quarter-pixel
|
|
||||||
offsets, measured, and the deep path is ~40% slower, so the switch is as
|
|
||||||
late as that allows). `deep_scale_exp` gives the scale exponent.
|
|
||||||
`make_uniforms(aspect, height_px)` takes that full-resolution height, the
|
|
||||||
same during the interaction-downscaled pass so the pipeline doesn't flip.
|
|
||||||
- `src/fractal/kind.rs` — the `FractalKind` enum (Mandelbrot, Burning Ship,
|
|
||||||
Tricorn, Multibrot, Celtic, Perpendicular, Buffalo, Phoenix, Lambda,
|
|
||||||
Complex Multibrot) plus everything that only needs to switch on it:
|
|
||||||
`label`/`description`/`formula` (UI text), `share_tag`/`from_share_tag`
|
|
||||||
(share-link encoding), `default_set_view` (per-kind starting view), and the
|
|
||||||
`ALL` array used to enumerate every kind.
|
|
||||||
- `src/fractal/reference.rs` — `compute_reference`/`compute_set_reference`:
|
|
||||||
iterate the chosen formula at high precision on the CPU, emitting `Z_n` as
|
|
||||||
`f32` pairs — that's the reference orbit the GPU perturbs from. At
|
|
||||||
precision ≤ `F64_MAX_PRECISION` (80 bits, i.e. shallow views) it takes a
|
|
||||||
plain-`f64` fast path (`compute_reference_f64`), so each kind's formula
|
|
||||||
exists twice in this file (f64 + `Big`) and both must stay in sync;
|
|
||||||
`f64_fast_path_matches_big` checks they agree. The result is a `RefOrbit`:
|
|
||||||
`points` plus a parallel `exps`. A point below 2^-100 (only possible on the
|
|
||||||
`Big` path) is stored as a normalized mantissa with its exponent in `exps`
|
|
||||||
(the true value is `points[n]·2^exps[n]`). That happens when the orbit
|
|
||||||
passes near 0 at a deep minibrot. `has_scaled()` then forces the deep
|
|
||||||
pipeline, the only one that reads `exps`. Requests are made with 1.5×
|
|
||||||
iteration headroom (`reference_iterations` in `app.rs`), so auto-iterations
|
|
||||||
creeping up during a zoom doesn't recompute the orbit every frame. The
|
|
||||||
interactive reference buffers start at 2^17 points and grow on demand
|
|
||||||
(`FractalRenderer::ensure_ref_capacity`) up to `MAX_REF_POINTS` (2^24, the
|
|
||||||
128 MiB WebGPU default binding size). That is also the hard iteration
|
|
||||||
ceiling (`app.rs::MAX_ITERATIONS`), because the shader treats an exhausted
|
|
||||||
reference as escaped. The UI slider only goes to 100k when dragged; typed
|
|
||||||
values can go higher.
|
|
||||||
- `src/shaders/*.wgsl` — none of these are standalone WGSL modules; WGSL has
|
|
||||||
no `#include`, so each is compiled by concatenating plain-text fragments
|
|
||||||
with `concat!`/`include_str!` at the `create_shader_module` call site (see
|
|
||||||
`renderer.rs`, `buddhabrot.rs`, and `tests/shader_valid.rs`, which must
|
|
||||||
concatenate the same pieces to validate what actually gets built).
|
|
||||||
`common.wgsl` (fullscreen-triangle vertex helper, `cmul`/`cpow`, `KIND_*`
|
|
||||||
constants) is prepended to every shader. `iterate_uniforms.wgsl` (the
|
|
||||||
perturbation-pipeline `Uniforms` struct + `palette()`) is additionally
|
|
||||||
prepended to `mandelbrot.wgsl` and `colorize.wgsl`, which share that layout.
|
|
||||||
Because there's no namespacing, a definition must live in exactly one file
|
|
||||||
among those concatenated together for a given shader — don't redefine a
|
|
||||||
`common.wgsl`/`iterate_uniforms.wgsl` symbol locally.
|
|
||||||
- `src/shaders/mandelbrot.wgsl` — the perturbation fragment shader. It is
|
|
||||||
**specialized per pipeline** through WGSL `override` constants (`KIND`,
|
|
||||||
`IS_JULIA`, `DE`), so the per-iteration kind/Julia/DE branches fold away at
|
|
||||||
pipeline creation. Read those constants in the shader, never `u.kind` /
|
|
||||||
`u.is_julia` / `u.de_coloring` (they're still uploaded for layout reasons).
|
|
||||||
`renderer.rs` builds one pipeline set per `PipelineKey` lazily on first
|
|
||||||
use, and `tests/shader_valid.rs` compiles every kind × Julia × DE × morph ×
|
|
||||||
deep variant to SPIR-V. So a new kind needs no pipeline-list change, only its `KIND_*`
|
|
||||||
constant. `buddhabrot.wgsl` does the same with its own `override KIND`.
|
|
||||||
Interior pixels exit early through **periodicity detection**. It uses
|
|
||||||
Brent-style checkpoints plus two guards: the cycle's multiplier must be
|
|
||||||
clearly attracting (`PERIOD_MAX_MULT2`), and the contracting return must
|
|
||||||
repeat in `PERIOD_CONFIRMATIONS` consecutive windows. Both guards are
|
|
||||||
needed: without them, exterior pixels at cusps and minibrot edges turned
|
|
||||||
black. Retune them only against f64 ground truth on such views. Each
|
|
||||||
window saves its iterate closest to the critical point, not the one at the
|
|
||||||
checkpoint. At an arbitrary phase the relative tolerance is far coarser
|
|
||||||
than a deep minibrot's scale, and a black disk surrounded the minibrot
|
|
||||||
(seen at ~1e-13 zoom). Phoenix is excluded (two-term map).
|
|
||||||
`advance_delta(z, e)` is the per-kind delta step (`z` = reference point,
|
`advance_delta(z, e)` is the per-kind delta step (`z` = reference point,
|
||||||
`e` = current delta); the caller adds `step_add` (= `dc`) afterward — this
|
`e` = current delta); the caller adds `step_add` (= `dc`) afterward — this
|
||||||
relies on `c` being additive in every current kind's formula (a kind where
|
relies on `c` being additive in every current kind's formula (a kind where
|
||||||
@@ -199,52 +75,11 @@ pixel is a handful of `f32` complex multiplies.
|
|||||||
reference data since the orbit point alone wouldn't be enough to recover an
|
reference data since the orbit point alone wouldn't be enough to recover an
|
||||||
exact delta). `fprime(z)` is the derivative used for distance-estimation
|
exact delta). `fprime(z)` is the derivative used for distance-estimation
|
||||||
(DE) shading; exact for holomorphic kinds, an approximation (`~2Z`) for the
|
(DE) shading; exact for holomorphic kinds, an approximation (`~2Z`) for the
|
||||||
abs-based ones. A `KIND_*` constant (from `common.wgsl`) must match the
|
abs-based ones. A `KIND_*` constant here must match the matching
|
||||||
matching `FractalKind` variant's discriminant exactly. The per-kind bodies
|
`FractalKind` variant's discriminant exactly.
|
||||||
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 +
|
- `src/fractal/renderer.rs` — `FractalRenderer` (wgpu pipelines, uniform +
|
||||||
storage buffers, bind groups), `Uniforms` (repr(C) layout that must match
|
storage buffers, bind groups), `Uniforms` (repr(C) layout that must match
|
||||||
the WGSL `Uniforms` struct field-for-field, including padding; it includes
|
the WGSL `Uniforms` struct field-for-field, including padding), and
|
||||||
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
|
`FractalCallback` (the `egui_wgpu::CallbackTrait` impl: `prepare()` uploads
|
||||||
changed buffers and decides whether to re-run the iterate pass, the cheap
|
changed buffers and decides whether to re-run the iterate pass, the cheap
|
||||||
colourise pass, or just blit the cached texture). Also `ExportRender`, a
|
colourise pass, or just blit the cached texture). Also `ExportRender`, a
|
||||||
@@ -258,37 +93,29 @@ pixel is a handful of `f32` complex multiplies.
|
|||||||
- `src/app.rs` — `FractalApp` (the egui app + all UI). Key methods:
|
- `src/app.rs` — `FractalApp` (the egui app + all UI). Key methods:
|
||||||
`should_request`/`ensure_reference` (decide when the reference is stale and
|
`should_request`/`ensure_reference` (decide when the reference is stale and
|
||||||
dispatch/collect it), `make_uniforms` (assemble the per-frame `Uniforms`),
|
dispatch/collect it), `make_uniforms` (assemble the per-frame `Uniforms`),
|
||||||
`tick_animations` (drives the interactive animations: colour cycle,
|
`tick_animations` (drives the "morph c/p/λ" and auto-zoom animations),
|
||||||
auto-zoom, c/p/λ circle drift via `ConstOrbit`, per-component complex-power
|
`default_view_for` (per-kind starting view). `KINDS`, `JULIA_PRESETS`, and
|
||||||
oscillation via `AxisOsc`, 3D camera orbit, kind cycling through
|
`SET_PRESETS` are sized as `[T; FractalKind::<last variant> as usize + 1]` —
|
||||||
`switch_kind`),
|
adding a new `FractalKind` means bumping all three (and adding an empty
|
||||||
`default_view_for` (wraps `FractalKind::default_set_view`, adding the
|
`&[]` slot to the two preset arrays if the kind has none).
|
||||||
kind-independent Julia case). `JULIA_PRESETS` and `SET_PRESETS` are sized as
|
|
||||||
`[T; FractalKind::<last variant> as usize + 1]` — adding a new `FractalKind`
|
|
||||||
means bumping both (and adding an empty `&[]` slot to each if the kind has
|
|
||||||
none), plus adding it to `FractalKind::ALL` in `kind.rs`.
|
|
||||||
- `src/fractal/share.rs` — `ShareState`: encodes the full view (mode, kind,
|
- `src/fractal/share.rs` — `ShareState`: encodes the full view (mode, kind,
|
||||||
full-precision decimal center, zoom, iterations, per-kind constants,
|
full-precision decimal center, zoom, iterations, per-kind constants,
|
||||||
coloring) as a `#`-fragment URL for bookmarking/sharing deep-zoom locations.
|
coloring) as a `#`-fragment URL for bookmarking/sharing deep-zoom locations.
|
||||||
|
|
||||||
### Adding a new `FractalKind`
|
### Adding a new `FractalKind`
|
||||||
|
|
||||||
Touches, in order: `kind.rs` (enum variant + `ALL` slot + `label`/
|
Touches, in order: `reference.rs` (enum variant + CPU iteration formula, and a
|
||||||
`description`/`formula`/`share_tag`/`from_share_tag`/`default_set_view`
|
test comparing against a naive `f64` iteration), `mandelbrot.wgsl` (matching
|
||||||
arms), `reference.rs` (CPU iteration formula arm, and a test comparing
|
`KIND_*` const + `advance_delta`/`fprime` arms), `buddhabrot.wgsl` (matching
|
||||||
against a naive `f64` iteration), `common.wgsl` (matching `KIND_*` const),
|
arm in `advance()`, if the kind makes sense as a Buddhabrot), `renderer.rs`
|
||||||
`mandelbrot.wgsl` (matching `advance_delta`/`fprime` arms, plus the deep
|
`Uniforms` (only if the kind needs a new per-kind constant, e.g. Phoenix's
|
||||||
path's `advance_delta_scaled_kind` arm, its degree in `deep_step_kind` and,
|
`phoenix_p`), `share.rs` (encode/decode string tag), `app.rs` (`KINDS` label,
|
||||||
if not z²-like, a `deep_fprime` arm), `buddhabrot.wgsl`
|
`JULIA_PRESETS`/`SET_PRESETS` slot, `default_view_for` entry, and optionally a
|
||||||
(matching arm in `advance()`, if the kind makes sense as a Buddhabrot),
|
UI control for its constant + an animation toggle, following the
|
||||||
`renderer.rs` `Uniforms` (only if the kind needs a new per-kind constant,
|
Phoenix/Lambda pattern). If `c` doesn't enter the formula additively (e.g. a
|
||||||
e.g. Phoenix's `phoenix_p`), `app.rs` (`JULIA_PRESETS`/`SET_PRESETS` slot,
|
rational map with `c` in a denominator), the `advance_delta`/`step_add` split
|
||||||
and optionally a UI control for its constant + an animation toggle,
|
doesn't work — that needs its own step function plus extra per-step reference
|
||||||
following the Phoenix/Lambda pattern). If `c` doesn't enter the formula
|
data uploaded in a second GPU buffer alongside the orbit.
|
||||||
additively (e.g. a rational map with `c` in a denominator), the
|
|
||||||
`advance_delta`/`step_add` split doesn't work — that needs its own step
|
|
||||||
function plus extra per-step reference data uploaded in a second GPU buffer
|
|
||||||
alongside the orbit.
|
|
||||||
|
|
||||||
### Buddhabrot is a separate pipeline
|
### Buddhabrot is a separate pipeline
|
||||||
|
|
||||||
@@ -306,28 +133,7 @@ histogram buffer, tone-mapped by a fragment pass every frame. Its own
|
|||||||
The interactive path splits iteration (expensive, perturbation) from
|
The interactive path splits iteration (expensive, perturbation) from
|
||||||
colourising (cheap, palette remap) into separate offscreen textures, so
|
colourising (cheap, palette remap) into separate offscreen textures, so
|
||||||
palette/color-scale/offset tweaks skip re-iteration entirely (`geom_differs`
|
palette/color-scale/offset tweaks skip re-iteration entirely (`geom_differs`
|
||||||
vs `color_differs` in `renderer.rs` decide which pass reruns). A frame where
|
vs `color_differs` in `renderer.rs` decide which pass reruns). While the user
|
||||||
neither differs uploads and renders nothing and only blits. So any new
|
is actively panning/zooming, the app renders downscaled with AA off
|
||||||
uniform field must go into one of those two functions (or the lights
|
(`INTERACT_DOWNSCALE`) and snaps back to full resolution once input settles
|
||||||
comparison), or changing it won't redraw.
|
(`INTERACT_SETTLE`).
|
||||||
|
|
||||||
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`).
|
|
||||||
|
|||||||
+5
-18
@@ -3,31 +3,18 @@ name = "mandelbrot"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[features]
|
|
||||||
default = ["gui"]
|
|
||||||
# Windowed egui app. Without it only `--headless` rendering is built.
|
|
||||||
gui = ["dep:eframe", "dep:egui"]
|
|
||||||
# Pure-Rust big floats for the web build (`rug` needs GMP/MPFR, which can't
|
|
||||||
# target wasm32). Required for wasm32, optional natively (to test that backend).
|
|
||||||
wasm = ["dep:malachite-float", "dep:malachite-base"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bytemuck = { version = "1.25.2", features = ["derive"] }
|
bytemuck = { version = "1.25.2", features = ["derive"] }
|
||||||
eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"], optional = true }
|
dashu-float = "0.6.0"
|
||||||
egui = { version = "0.36.2", optional = true }
|
eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"] }
|
||||||
ecolor = { version = "0.36.2", features = ["bytemuck"] }
|
egui = "0.36.2"
|
||||||
wgpu = "30.0.1"
|
|
||||||
glam = "0.33.8"
|
|
||||||
log = "0.4.34"
|
log = "0.4.34"
|
||||||
png = "0.18.1"
|
png = "0.18.1"
|
||||||
malachite-float = { version = "0.12", optional = true }
|
|
||||||
malachite-base = { version = "0.12", optional = true }
|
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
env_logger = "0.11.11"
|
env_logger = "0.11.11"
|
||||||
clap = { version = "4.5.51", features = ["derive"] }
|
clap = { version = "4.5.51", features = ["derive"] }
|
||||||
pollster = "1.0.1"
|
pollster = "1.0.1"
|
||||||
rug = { version = "1.30.0", default-features = false, features = ["float", "std"] }
|
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] }
|
futures-channel = { version = "0.3.34", default-features = false, features = ["alloc", "std"] }
|
||||||
@@ -44,7 +31,7 @@ opt-level = 3
|
|||||||
# codegen-units = 1
|
# codegen-units = 1
|
||||||
debug = true
|
debug = true
|
||||||
|
|
||||||
# Dev: keep our own crate debuggable, but optimize dependencies (big floats, wgpu,
|
# Dev: keep our own crate debuggable, but optimize dependencies (dashu, wgpu,
|
||||||
# egui) so the explorer is actually interactive during development.
|
# egui) so the explorer is actually interactive during development.
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 1
|
opt-level = 1
|
||||||
@@ -53,4 +40,4 @@ opt-level = 1
|
|||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
naga = { version = "30", features = ["wgsl-in", "spv-out"] }
|
naga = { version = "30", features = ["wgsl-in"] }
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
.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,8 +3,7 @@
|
|||||||
A fast, interactive deep-zoom fractal explorer — Mandelbrot and Julia sets —
|
A fast, interactive deep-zoom fractal explorer — Mandelbrot and Julia sets —
|
||||||
built with **Rust + wgpu + egui + WGSL**. It zooms far past the ~10¹³× limit of
|
built with **Rust + wgpu + egui + WGSL**. It zooms far past the ~10¹³× limit of
|
||||||
plain `f64` using **perturbation theory**: one high-precision reference orbit is
|
plain `f64` using **perturbation theory**: one high-precision reference orbit is
|
||||||
computed on the CPU (arbitrary precision via `rug` natively, `malachite-float`
|
computed on the CPU (arbitrary precision via `dashu-float`), and every pixel is
|
||||||
on the web), and every pixel is
|
|
||||||
rendered on the GPU as a cheap `f32` delta from it, with **rebasing** to avoid
|
rendered on the GPU as a cheap `f32` delta from it, with **rebasing** to avoid
|
||||||
glitches. Runs natively (Vulkan/Metal/DX12) and in the browser (WebGPU).
|
glitches. Runs natively (Vulkan/Metal/DX12) and in the browser (WebGPU).
|
||||||
|
|
||||||
@@ -28,9 +27,6 @@ The `f32` GPU tier reaches roughly **10³⁰× magnification** with sharp detail
|
|||||||
cargo run --release
|
cargo run --release
|
||||||
```
|
```
|
||||||
|
|
||||||
Native builds use `rug` (GMP/MPFR) for the reference orbit, which needs a C
|
|
||||||
toolchain and `m4` (on Windows, MSYS2).
|
|
||||||
|
|
||||||
## Build & run — web (WebGPU)
|
## Build & run — web (WebGPU)
|
||||||
|
|
||||||
Requires the `wasm32-unknown-unknown` target and `wasm-bindgen-cli` (matching the
|
Requires the `wasm32-unknown-unknown` target and `wasm-bindgen-cli` (matching the
|
||||||
@@ -41,7 +37,6 @@ rustup target add wasm32-unknown-unknown
|
|||||||
cargo install wasm-bindgen-cli --version 0.2.128 # once
|
cargo install wasm-bindgen-cli --version 0.2.128 # once
|
||||||
|
|
||||||
./build-web.sh # outputs ./dist (index.html, .js, .wasm)
|
./build-web.sh # outputs ./dist (index.html, .js, .wasm)
|
||||||
# (builds with --features wasm: pure-Rust big floats)
|
|
||||||
python3 -m http.server -d dist 8080 # serve over http
|
python3 -m http.server -d dist 8080 # serve over http
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -65,8 +60,7 @@ qualifies. Deploy by serving the `dist/` directory as static files.
|
|||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
- `src/view.rs` — view state. Center is arbitrary precision (`Big`, from
|
- `src/view.rs` — view state. Center is arbitrary precision (`FBig`); the pixel
|
||||||
`src/bignum/`); the pixel
|
|
||||||
scale stays `f64` (even at 10³⁰× it is ~10⁻³³, within `f64` range).
|
scale stays `f64` (even at 10³⁰× it is ~10⁻³³, within `f64` range).
|
||||||
- `src/fractal/reference.rs` — high-precision reference orbit `Z_{n+1}=Z_n²+C`.
|
- `src/fractal/reference.rs` — high-precision reference orbit `Z_{n+1}=Z_n²+C`.
|
||||||
- `src/shaders/mandelbrot.wgsl` — per-pixel perturbation `e_{n+1}=2·Z_n·e_n+e_n²+δc`
|
- `src/shaders/mandelbrot.wgsl` — per-pixel perturbation `e_{n+1}=2·Z_n·e_n+e_n²+δc`
|
||||||
@@ -90,7 +84,6 @@ reference computation to a Web Worker.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo test
|
cargo test
|
||||||
cargo test --features wasm # same suite on the web build's big-float backend
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Covers the reference orbit (vs. a naive `f64` iteration, Mandelbrot and Julia)
|
Covers the reference orbit (vs. a naive `f64` iteration, Mandelbrot and Julia)
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ export PATH="$HOME/.cargo/bin:$PATH"
|
|||||||
OUT="${1:-dist}"
|
OUT="${1:-dist}"
|
||||||
|
|
||||||
echo "==> cargo build (wasm32, release)"
|
echo "==> cargo build (wasm32, release)"
|
||||||
cargo build --release --target wasm32-unknown-unknown --features wasm
|
cargo build --release --target wasm32-unknown-unknown
|
||||||
|
|
||||||
echo "==> wasm-bindgen -> $OUT"
|
echo "==> wasm-bindgen -> $OUT"
|
||||||
mkdir -p "$OUT"
|
mkdir -p "$OUT"
|
||||||
|
|||||||
+455
-1270
File diff suppressed because it is too large
Load Diff
@@ -1,225 +0,0 @@
|
|||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
//! [`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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
//! [`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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
use std::f32::consts::{PI, TAU};
|
|
||||||
|
|
||||||
use glam::Vec3;
|
|
||||||
|
|
||||||
/// Pitch is kept just short of straight up/down so the view never flips past
|
|
||||||
/// the pole (and the raymarcher's rays always have `z > 0`).
|
|
||||||
const PITCH_LIMIT: f32 = PI / 2.0 - 0.01;
|
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
|
||||||
pub struct Camera {
|
|
||||||
pub position: glam::Vec3,
|
|
||||||
|
|
||||||
pub yaw: f32,
|
|
||||||
pub pitch: f32,
|
|
||||||
|
|
||||||
pub aspect_ratio: f32,
|
|
||||||
z_near: f32,
|
|
||||||
z_far: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Camera {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
position: Vec3::new(0., 0., -1.),
|
|
||||||
yaw: 0. * PI / 180.,
|
|
||||||
pitch: -30. * PI / 180.,
|
|
||||||
aspect_ratio: 1.,
|
|
||||||
z_near: 0.1,
|
|
||||||
z_far: 100.,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_aspect_ratio(&mut self, aspect_ratio: f32) {
|
|
||||||
self.aspect_ratio = aspect_ratio;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adjust yaw/pitch by the given deltas (radians). Pitch is clamped just
|
|
||||||
/// short of straight up/down to avoid the view flipping past the pole.
|
|
||||||
/// Yaw is wrapped to [-π, π) so the 2D <-> 3D transition (which scales
|
|
||||||
/// yaw by `t`) always unwinds the short way instead of every past turn.
|
|
||||||
pub fn rotate(&mut self, dyaw: f32, dpitch: f32) {
|
|
||||||
self.yaw = (self.yaw + dyaw + PI).rem_euclid(TAU) - PI;
|
|
||||||
self.pitch = (self.pitch + dpitch).clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set yaw/pitch (radians) outright. Pitch is clamped as in `rotate`,
|
|
||||||
/// but yaw is left unwrapped: only a camera that stays in 3D (headless
|
|
||||||
/// animation, which interpolates yaw across several turns) should use
|
|
||||||
/// this; `rotate(0., 0.)` afterwards wraps it for the 2D <-> 3D transition.
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub fn set_angles(&mut self, yaw: f32, pitch: f32) {
|
|
||||||
self.yaw = yaw;
|
|
||||||
self.pitch = pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `render_scale` is the 3D texture's per-axis scale (see
|
|
||||||
/// `FractalApp::render_scale_3d`): the full 3D camera (`t = 1`) zooms in
|
|
||||||
/// by the same factor, so one texel still covers one screen pixel and the
|
|
||||||
/// extra texels become terrain beyond the screen edges.
|
|
||||||
pub fn orthographic(&self, t: f32, render_scale: f32) -> glam::Mat4 {
|
|
||||||
let yaw = self.yaw * t;
|
|
||||||
let pitch = self.pitch * t;
|
|
||||||
|
|
||||||
let zoom = 0.5 * (1. + t * (render_scale - 1.));
|
|
||||||
// Orbit pivot: the center of the fractal texture, which the raymarcher's
|
|
||||||
// `sdf` lays out over world x ∈ [0, aspect], y ∈ [0, 1] on the z = 0 plane.
|
|
||||||
let view = glam::Mat4::from_translation(Vec3::new(0.5 * self.aspect_ratio, 0.5, 0.))
|
|
||||||
* glam::Mat4::from_rotation_z(-yaw)
|
|
||||||
* glam::Mat4::from_rotation_x(-pitch)
|
|
||||||
* glam::Mat4::from_translation(self.position);
|
|
||||||
|
|
||||||
glam::camera::lh::proj::directx::orthographic(
|
|
||||||
-self.aspect_ratio / 4. / zoom,
|
|
||||||
self.aspect_ratio / 4. / zoom,
|
|
||||||
-0.25 / zoom,
|
|
||||||
0.25 / zoom,
|
|
||||||
self.z_near,
|
|
||||||
self.z_far,
|
|
||||||
) * view.inverse()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn direction(&self, t: f32) -> glam::Vec3 {
|
|
||||||
let yaw = self.yaw * t;
|
|
||||||
let pitch = self.pitch * t;
|
|
||||||
|
|
||||||
let forward =
|
|
||||||
glam::Mat3::from_rotation_z(-yaw) * glam::Mat3::from_rotation_x(-pitch) * glam::Vec3::Z;
|
|
||||||
|
|
||||||
forward.normalize()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+13
-158
@@ -13,33 +13,13 @@ pub struct Cli {
|
|||||||
#[arg(long, value_enum)]
|
#[arg(long, value_enum)]
|
||||||
pub kind: Option<KindArg>,
|
pub kind: Option<KindArg>,
|
||||||
|
|
||||||
/// Start in Julia mode with this seed constant.
|
/// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 8].
|
||||||
#[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)]
|
#[arg(long)]
|
||||||
pub power: Option<u32>,
|
pub power: Option<u32>,
|
||||||
|
|
||||||
/// Complex exponent for the Complex Multibrot kind (z -> z^power + c).
|
/// Start in Julia mode with this seed constant.
|
||||||
#[arg(long, value_name = "RE,IM")]
|
#[arg(long, value_name = "RE,IM")]
|
||||||
pub complex_power: Option<String>,
|
pub julia: Option<String>,
|
||||||
|
|
||||||
/// Phoenix constant p for the Phoenix kind (z -> z^2 + c + p*z_prev).
|
|
||||||
#[arg(long, value_name = "RE,IM")]
|
|
||||||
pub phoenix_p: Option<String>,
|
|
||||||
|
|
||||||
/// Lambda constant λ for the Lambda kind (z -> λ*z*(1 - z)).
|
|
||||||
#[arg(long, value_name = "RE,IM")]
|
|
||||||
pub lambda_l: Option<String>,
|
|
||||||
|
|
||||||
/// Restore a view from a share-link fragment (the part after '#').
|
/// Restore a view from a share-link fragment (the part after '#').
|
||||||
#[arg(long, value_name = "FRAGMENT")]
|
#[arg(long, value_name = "FRAGMENT")]
|
||||||
@@ -49,143 +29,29 @@ pub struct Cli {
|
|||||||
#[arg(long, value_name = "RE,IM,HALF_HEIGHT[,ITERATIONS]")]
|
#[arg(long, value_name = "RE,IM,HALF_HEIGHT[,ITERATIONS]")]
|
||||||
pub view: Option<String>,
|
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.
|
/// Enable distance-estimation shading.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub de: bool,
|
pub de: bool,
|
||||||
|
|
||||||
/// Enable 2×2 antialiasing (supersampling; ~4× slower).
|
/// Switch to the Buddhabrot renderer.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub antialias: bool,
|
pub buddhabrot: bool,
|
||||||
|
|
||||||
/// Coloring palette index.
|
/// Buddhabrot tonemap palette index.
|
||||||
#[arg(long, value_name = "INDEX")]
|
#[arg(long, value_name = "INDEX")]
|
||||||
pub palette: Option<u32>,
|
pub buddha_palette: Option<u32>,
|
||||||
|
|
||||||
/// Output path for --headless (default: fractal-<timestamp>.png). When
|
/// Render a PNG export on startup.
|
||||||
/// animating (--to-view/--to-share), this is a directory of
|
#[arg(long)]
|
||||||
/// frame-00001.png, frame-00002.png, ... instead (default:
|
pub export: bool,
|
||||||
/// frames-<timestamp>/). "-" writes to stdout: the PNG for a single
|
|
||||||
/// image, or raw RGBA8 frames in order for an animation, to pipe into
|
/// Output path for --export/--headless (default: fractal-<timestamp>.png).
|
||||||
/// `ffmpeg -f rawvideo -pix_fmt rgba -s WxH -r FPS -i - ...`.
|
|
||||||
#[arg(long, value_name = "PATH")]
|
#[arg(long, value_name = "PATH")]
|
||||||
pub export_path: Option<String>,
|
pub export_path: Option<String>,
|
||||||
|
|
||||||
/// 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
|
/// Run without opening a window: render the current view to a PNG and
|
||||||
/// exit. Combine with --kind/--julia/--share/--view etc. to pick what to
|
/// exit. Combine with --kind/--julia/--share/--view etc. to pick what to
|
||||||
/// render, or any --to-* flag (--to-view, --to-julia, --to-kind, ...) to
|
/// render. Not yet supported with --buddhabrot.
|
||||||
/// render an animation instead of a single frame. Not yet supported with --buddhabrot.
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub headless: bool,
|
pub headless: bool,
|
||||||
|
|
||||||
@@ -213,16 +79,6 @@ pub enum KindArg {
|
|||||||
Buffalo,
|
Buffalo,
|
||||||
Phoenix,
|
Phoenix,
|
||||||
Lambda,
|
Lambda,
|
||||||
#[value(alias = "cmulti")]
|
|
||||||
ComplexMultibrot,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
|
||||||
pub enum RenderingKindArg {
|
|
||||||
Classic,
|
|
||||||
Shadow,
|
|
||||||
#[value(alias = "3d")]
|
|
||||||
Dimension3,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<KindArg> for FractalKind {
|
impl From<KindArg> for FractalKind {
|
||||||
@@ -237,7 +93,6 @@ impl From<KindArg> for FractalKind {
|
|||||||
KindArg::Buffalo => FractalKind::Buffalo,
|
KindArg::Buffalo => FractalKind::Buffalo,
|
||||||
KindArg::Phoenix => FractalKind::Phoenix,
|
KindArg::Phoenix => FractalKind::Phoenix,
|
||||||
KindArg::Lambda => FractalKind::Lambda,
|
KindArg::Lambda => FractalKind::Lambda,
|
||||||
KindArg::ComplexMultibrot => FractalKind::ComplexMultibrot,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-57
@@ -3,10 +3,7 @@
|
|||||||
//! to colour by a fragment pass. See `shaders/buddhabrot.wgsl` for the "why"
|
//! to colour by a fragment pass. See `shaders/buddhabrot.wgsl` for the "why"
|
||||||
//! this is a separate pipeline from the escape-time perturbation renderer.
|
//! this is a separate pipeline from the escape-time perturbation renderer.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use eframe::egui_wgpu::{self, wgpu};
|
||||||
|
|
||||||
#[cfg(feature = "gui")]
|
|
||||||
use eframe::egui_wgpu;
|
|
||||||
|
|
||||||
/// Random samples dispatched per accumulating frame. Chosen so a frame stays
|
/// Random samples dispatched per accumulating frame. Chosen so a frame stays
|
||||||
/// interactive on a modest GPU even when most samples run the full `b_cap`
|
/// interactive on a modest GPU even when most samples run the full `b_cap`
|
||||||
@@ -49,11 +46,7 @@ pub struct BuddhabrotUniforms {
|
|||||||
/// (yellow core, blue halo), 2 = grayscale. Display-only, like `exposure`
|
/// (yellow core, blue halo), 2 = grayscale. Display-only, like `exposure`
|
||||||
/// — excluded from `ContentKey` so changing it doesn't reset accumulation.
|
/// — excluded from `ContentKey` so changing it doesn't reset accumulation.
|
||||||
pub palette: u32,
|
pub palette: u32,
|
||||||
/// Padding so `complex_power` (a vec2, 8-byte aligned in the shader)
|
pub _pad: [u32; 3],
|
||||||
/// starts on an 8-byte boundary.
|
|
||||||
pub _pad0: u32,
|
|
||||||
/// Complex exponent for the Complex Multibrot kind; ignored by other kinds.
|
|
||||||
pub complex_power: [f32; 2],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The subset of `BuddhabrotUniforms` that determines the *content* of the
|
/// The subset of `BuddhabrotUniforms` that determines the *content* of the
|
||||||
@@ -69,7 +62,6 @@ struct ContentKey {
|
|||||||
bailout_sq: f32,
|
bailout_sq: f32,
|
||||||
kind: u32,
|
kind: u32,
|
||||||
power: u32,
|
power: u32,
|
||||||
complex_power: [f32; 2],
|
|
||||||
r_cap: u32,
|
r_cap: u32,
|
||||||
g_cap: u32,
|
g_cap: u32,
|
||||||
b_cap: u32,
|
b_cap: u32,
|
||||||
@@ -86,7 +78,6 @@ impl From<&BuddhabrotUniforms> for ContentKey {
|
|||||||
bailout_sq: u.bailout_sq,
|
bailout_sq: u.bailout_sq,
|
||||||
kind: u.kind,
|
kind: u.kind,
|
||||||
power: u.power,
|
power: u.power,
|
||||||
complex_power: u.complex_power,
|
|
||||||
r_cap: u.r_cap,
|
r_cap: u.r_cap,
|
||||||
g_cap: u.g_cap,
|
g_cap: u.g_cap,
|
||||||
b_cap: u.b_cap,
|
b_cap: u.b_cap,
|
||||||
@@ -104,12 +95,7 @@ struct Histogram {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct BuddhabrotRenderer {
|
pub struct BuddhabrotRenderer {
|
||||||
shader: wgpu::ShaderModule,
|
compute_pipeline: wgpu::ComputePipeline,
|
||||||
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,
|
compute_bind_group_layout: wgpu::BindGroupLayout,
|
||||||
tonemap_pipeline: wgpu::RenderPipeline,
|
tonemap_pipeline: wgpu::RenderPipeline,
|
||||||
tonemap_bind_group_layout: wgpu::BindGroupLayout,
|
tonemap_bind_group_layout: wgpu::BindGroupLayout,
|
||||||
@@ -126,21 +112,10 @@ pub struct BuddhabrotRenderer {
|
|||||||
|
|
||||||
impl BuddhabrotRenderer {
|
impl BuddhabrotRenderer {
|
||||||
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
|
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
|
||||||
let shader = unsafe {
|
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
device.create_shader_module_trusted(
|
label: Some("buddhabrot"),
|
||||||
wgpu::ShaderModuleDescriptor {
|
source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/buddhabrot.wgsl").into()),
|
||||||
label: Some("buddhabrot"),
|
});
|
||||||
source: wgpu::ShaderSource::Wgsl(
|
|
||||||
concat!(
|
|
||||||
include_str!("../shaders/common.wgsl"),
|
|
||||||
include_str!("../shaders/buddhabrot.wgsl"),
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
wgpu::ShaderRuntimeChecks::unchecked(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("buddhabrot uniforms"),
|
label: Some("buddhabrot uniforms"),
|
||||||
@@ -181,6 +156,14 @@ impl BuddhabrotRenderer {
|
|||||||
bind_group_layouts: &[Some(&compute_bind_group_layout)],
|
bind_group_layouts: &[Some(&compute_bind_group_layout)],
|
||||||
immediate_size: 0,
|
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 =
|
let tonemap_bind_group_layout =
|
||||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
@@ -241,9 +224,7 @@ impl BuddhabrotRenderer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
shader,
|
compute_pipeline,
|
||||||
compute_pipeline_layout,
|
|
||||||
compute_pipelines: HashMap::new(),
|
|
||||||
compute_bind_group_layout,
|
compute_bind_group_layout,
|
||||||
tonemap_pipeline,
|
tonemap_pipeline,
|
||||||
tonemap_bind_group_layout,
|
tonemap_bind_group_layout,
|
||||||
@@ -255,23 +236,6 @@ 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
|
/// Ensure the histogram buffer exists at `width`×`height`, recreating (and
|
||||||
/// resetting accumulation) on a size change.
|
/// resetting accumulation) on a size change.
|
||||||
fn ensure_histogram(&mut self, device: &wgpu::Device, width: u32, height: u32) {
|
fn ensure_histogram(&mut self, device: &wgpu::Device, width: u32, height: u32) {
|
||||||
@@ -342,7 +306,6 @@ pub struct BuddhabrotCallback {
|
|||||||
pub size_px: [u32; 2],
|
pub size_px: [u32; 2],
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "gui")]
|
|
||||||
impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
|
impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
|
||||||
fn prepare(
|
fn prepare(
|
||||||
&self,
|
&self,
|
||||||
@@ -359,9 +322,6 @@ impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
|
|||||||
let width = self.size_px[0].max(1);
|
let width = self.size_px[0].max(1);
|
||||||
let height = self.size_px[1].max(1);
|
let height = self.size_px[1].max(1);
|
||||||
renderer.ensure_histogram(device, width, height);
|
renderer.ensure_histogram(device, width, height);
|
||||||
let pipeline = renderer
|
|
||||||
.compute_pipeline(device, self.uniforms.kind)
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
let content = ContentKey::from(&self.uniforms);
|
let content = ContentKey::from(&self.uniforms);
|
||||||
let content_changed = renderer.last_content != Some(content);
|
let content_changed = renderer.last_content != Some(content);
|
||||||
@@ -393,7 +353,7 @@ impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
|
|||||||
label: Some("buddhabrot accumulate pass"),
|
label: Some("buddhabrot accumulate pass"),
|
||||||
timestamp_writes: None,
|
timestamp_writes: None,
|
||||||
});
|
});
|
||||||
pass.set_pipeline(&pipeline);
|
pass.set_pipeline(&renderer.compute_pipeline);
|
||||||
pass.set_bind_group(0, &histogram.compute_bind_group, &[]);
|
pass.set_bind_group(0, &histogram.compute_bind_group, &[]);
|
||||||
let workgroups = SAMPLES_PER_DISPATCH.div_ceil(WORKGROUP_SIZE);
|
let workgroups = SAMPLES_PER_DISPATCH.div_ceil(WORKGROUP_SIZE);
|
||||||
pass.dispatch_workgroups(workgroups, 1, 1);
|
pass.dispatch_workgroups(workgroups, 1, 1);
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
//! `FractalKind`: the enum selecting which iteration formula is in use, plus
|
|
||||||
//! everything that only needs to switch on it (UI label/description/formula
|
|
||||||
//! text, share-link tag, default parameter-plane view). The CPU/GPU orbit
|
|
||||||
//! math itself lives in `reference.rs` (CPU reference orbit) and
|
|
||||||
//! `shaders/mandelbrot.wgsl` (GPU perturbation delta) since both must also
|
|
||||||
//! stay in sync with `common.wgsl`'s `KIND_*` constants.
|
|
||||||
|
|
||||||
/// The iteration formula. Must be kept in sync with `advance_delta` and the
|
|
||||||
/// `KIND_*` constants in the shader.
|
|
||||||
#[repr(u8)]
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
||||||
pub enum FractalKind {
|
|
||||||
/// `z -> z^2 + c`.
|
|
||||||
Mandelbrot = 0,
|
|
||||||
/// `z -> (|Re z| + i|Im z|)^2 + c`.
|
|
||||||
BurningShip = 1,
|
|
||||||
/// `z -> conj(z)^2 + c` (the Mandelbar).
|
|
||||||
Tricorn = 2,
|
|
||||||
/// `z -> z^power + c` (integer power in [2, 20]).
|
|
||||||
Multibrot = 3,
|
|
||||||
/// `z -> |Re(z^2)| + i·Im(z^2) + c` (abs on the real output of the square).
|
|
||||||
Celtic = 4,
|
|
||||||
/// `z -> (x^2 - y^2) - 2·x·|y|·i + c` (abs on the imaginary input).
|
|
||||||
Perpendicular = 5,
|
|
||||||
/// `z -> |Re(z^2)| - |Im(z^2)|·i + c` (abs on both outputs).
|
|
||||||
Buffalo = 6,
|
|
||||||
/// `z -> z^2 + c + p·z_{n-1}` (two-term recurrence; `p` is `phoenix_p`).
|
|
||||||
Phoenix = 7,
|
|
||||||
/// `z -> lambda·z(1 - z) + c` (logistic map).
|
|
||||||
Lambda = 8,
|
|
||||||
/// `z -> z^power + c`, where `power` is a complex constant (the
|
|
||||||
/// `complex_power` argument), via the principal branch `z^p = exp(p·ln z)`.
|
|
||||||
ComplexMultibrot = 9,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FractalKind {
|
|
||||||
/// Every kind, in declaration/discriminant order. Sized arrays keyed by
|
|
||||||
/// `kind as usize` (`JULIA_PRESETS`, `SET_PRESETS`) must have one slot per
|
|
||||||
/// entry here.
|
|
||||||
pub const ALL: [FractalKind; 10] = [
|
|
||||||
FractalKind::Mandelbrot,
|
|
||||||
FractalKind::BurningShip,
|
|
||||||
FractalKind::Tricorn,
|
|
||||||
FractalKind::Multibrot,
|
|
||||||
FractalKind::Celtic,
|
|
||||||
FractalKind::Perpendicular,
|
|
||||||
FractalKind::Buffalo,
|
|
||||||
FractalKind::Phoenix,
|
|
||||||
FractalKind::Lambda,
|
|
||||||
FractalKind::ComplexMultibrot,
|
|
||||||
];
|
|
||||||
|
|
||||||
pub fn description(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
FractalKind::Mandelbrot => {
|
|
||||||
"The Mandelbrot set is the most famous fractal set, obtained with the simplest escape-time formula. This set represents all Julia fractals: each points of the Mandelbrot set is related to a specific Julia fractal."
|
|
||||||
}
|
|
||||||
FractalKind::BurningShip => {
|
|
||||||
"A variation of the famous Mandelbrot set, using absolute values on the real and imaginary part of each iterations."
|
|
||||||
}
|
|
||||||
FractalKind::Tricorn => {
|
|
||||||
"The Tricorn set is obtained using the same formula as the Mandelbrot set, taking the complex conjugate of the previous iteration."
|
|
||||||
}
|
|
||||||
FractalKind::Multibrot => {
|
|
||||||
"Multibrot use the same formula as the Mandelbrot set, with a bigger exposant."
|
|
||||||
}
|
|
||||||
FractalKind::Celtic => "",
|
|
||||||
FractalKind::Perpendicular => "",
|
|
||||||
FractalKind::Buffalo => "",
|
|
||||||
FractalKind::Phoenix => "",
|
|
||||||
FractalKind::Lambda => "",
|
|
||||||
FractalKind::ComplexMultibrot => {
|
|
||||||
"Like Multibrot, but the exponent itself is a complex number instead of a plain integer, via z^p = exp(p·ln z)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// UI label for this kind (combo box / info panel heading).
|
|
||||||
pub fn label(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
FractalKind::Mandelbrot => "Mandelbrot",
|
|
||||||
FractalKind::BurningShip => "Burning Ship",
|
|
||||||
FractalKind::Tricorn => "Tricorn",
|
|
||||||
FractalKind::Multibrot => "Multibrot",
|
|
||||||
FractalKind::Celtic => "Celtic",
|
|
||||||
FractalKind::Perpendicular => "Perpendicular",
|
|
||||||
FractalKind::Buffalo => "Buffalo",
|
|
||||||
FractalKind::Phoenix => "Phoenix",
|
|
||||||
FractalKind::Lambda => "Lambda",
|
|
||||||
FractalKind::ComplexMultibrot => "Complex Multibrot",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The iteration formula in human-readable notation (mirrors the doc
|
|
||||||
/// comments on the variants above). `power` is only used by Multibrot;
|
|
||||||
/// `complex_power` only by Complex Multibrot.
|
|
||||||
pub fn formula(&self, power: u32, complex_power: (f64, f64)) -> String {
|
|
||||||
match self {
|
|
||||||
FractalKind::Mandelbrot => "z = z² + c".to_string(),
|
|
||||||
FractalKind::BurningShip => "z = (|Re(z)| + i|Im(z)|)² + c".to_string(),
|
|
||||||
FractalKind::Tricorn => "z = conj(z)² + c".to_string(),
|
|
||||||
FractalKind::Multibrot => format!("z = z^{power} + c"),
|
|
||||||
FractalKind::Celtic => "z = |Re(z²)| + i·Im(z²) + c".to_string(),
|
|
||||||
FractalKind::Perpendicular => "z = (x² − y²) − 2x|y|i + c".to_string(),
|
|
||||||
FractalKind::Buffalo => "z = |Re(z²)| − i|Im(z²)| + c".to_string(),
|
|
||||||
FractalKind::Phoenix => "z = z² + c + p·z_prev".to_string(),
|
|
||||||
FractalKind::Lambda => "z = λ·z(1 − z) + c".to_string(),
|
|
||||||
FractalKind::ComplexMultibrot => {
|
|
||||||
format!("z = z^({:.3}{:+.3}i) + c", complex_power.0, complex_power.1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Short tag used to identify this kind in a share-link fragment.
|
|
||||||
pub fn share_tag(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
FractalKind::Mandelbrot => "mandel",
|
|
||||||
FractalKind::BurningShip => "burning",
|
|
||||||
FractalKind::Tricorn => "tricorn",
|
|
||||||
FractalKind::Multibrot => "multi",
|
|
||||||
FractalKind::Celtic => "celtic",
|
|
||||||
FractalKind::Perpendicular => "perp",
|
|
||||||
FractalKind::Buffalo => "buffalo",
|
|
||||||
FractalKind::Phoenix => "phoenix",
|
|
||||||
FractalKind::Lambda => "lambda",
|
|
||||||
FractalKind::ComplexMultibrot => "cmulti",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inverse of `share_tag`; unknown tags fall back to `None` so the caller
|
|
||||||
/// can decide the default (matches historical share-link behavior).
|
|
||||||
pub fn from_share_tag(tag: &str) -> Option<FractalKind> {
|
|
||||||
Some(match tag {
|
|
||||||
"mandel" => FractalKind::Mandelbrot,
|
|
||||||
"burning" => FractalKind::BurningShip,
|
|
||||||
"tricorn" => FractalKind::Tricorn,
|
|
||||||
"multi" => FractalKind::Multibrot,
|
|
||||||
"celtic" => FractalKind::Celtic,
|
|
||||||
"perp" => FractalKind::Perpendicular,
|
|
||||||
"buffalo" => FractalKind::Buffalo,
|
|
||||||
"phoenix" => FractalKind::Phoenix,
|
|
||||||
"lambda" => FractalKind::Lambda,
|
|
||||||
"cmulti" => FractalKind::ComplexMultibrot,
|
|
||||||
_ => return None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default parameter-plane (Mandelbrot-mode) view for this kind, as
|
|
||||||
/// `(center_re, center_im, half_height)`. The Julia (dynamical) plane
|
|
||||||
/// doesn't vary by kind, so it isn't covered here.
|
|
||||||
pub fn default_set_view(&self) -> (f64, f64, f64) {
|
|
||||||
match self {
|
|
||||||
FractalKind::Mandelbrot => (-0.5, 0.0, 1.25),
|
|
||||||
FractalKind::BurningShip => (-0.5, -0.5, 1.3),
|
|
||||||
FractalKind::Tricorn => (-0.25, 0.0, 1.7),
|
|
||||||
FractalKind::Multibrot => (0.0, 0.0, 1.5),
|
|
||||||
FractalKind::Celtic => (-0.5, 0.0, 1.6),
|
|
||||||
FractalKind::Perpendicular => (-0.5, 0.0, 1.5),
|
|
||||||
FractalKind::Buffalo => (-0.5, 0.5, 1.5),
|
|
||||||
FractalKind::Phoenix => (-0.5, 0.0, 1.5),
|
|
||||||
FractalKind::Lambda => (-0.5, 0.0, 2.4),
|
|
||||||
FractalKind::ComplexMultibrot => (0.0, 0.0, 1.5),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-7
@@ -2,19 +2,15 @@
|
|||||||
//! egui paint callback.
|
//! egui paint callback.
|
||||||
|
|
||||||
pub mod buddhabrot;
|
pub mod buddhabrot;
|
||||||
pub mod kind;
|
|
||||||
pub mod reference;
|
pub mod reference;
|
||||||
pub mod renderer;
|
pub mod renderer;
|
||||||
pub mod share;
|
pub mod share;
|
||||||
|
|
||||||
pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms};
|
pub use buddhabrot::{BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms};
|
||||||
pub use kind::FractalKind;
|
pub use reference::{FractalKind, compute_reference, compute_set_reference};
|
||||||
pub use reference::{RefOrbit, compute_reference, compute_set_reference};
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub use renderer::PipelineKey;
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub use renderer::encode_png_with_progress;
|
pub use renderer::encode_png_with_progress;
|
||||||
pub use renderer::{ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms};
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use renderer::{encode_png, export_to_png_blocking, render_readback_blocking, unpad_rgba};
|
pub use renderer::export_to_png_blocking;
|
||||||
|
pub use renderer::{ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms};
|
||||||
pub use share::ShareState;
|
pub use share::ShareState;
|
||||||
|
|||||||
+140
-688
File diff suppressed because it is too large
Load Diff
+212
-1325
File diff suppressed because it is too large
Load Diff
+32
-31
@@ -2,14 +2,13 @@
|
|||||||
//! iterations, Julia constant, coloring) as a compact URL fragment so deep-zoom
|
//! iterations, Julia constant, coloring) as a compact URL fragment so deep-zoom
|
||||||
//! locations can be shared or bookmarked.
|
//! locations can be shared or bookmarked.
|
||||||
//!
|
//!
|
||||||
//! Format: `m=m&f=<str>&re=<dec>&im=<dec>&hh=<sci>&it=<u32>&cs=<f32>&co=<f32>` with
|
//! Format: `m=m&f=<str>&re=<dec>&im=<dec>&hh=<f64>&it=<u32>&cs=<f32>&co=<f32>` with
|
||||||
//! `m=j&jr=<f64>&ji=<f64>` added for Julia. `re`/`im` are full-precision decimal
|
//! `m=j&jr=<f64>&ji=<f64>` added for Julia. `re`/`im` are full-precision decimal
|
||||||
//! strings; `hh` is a `Scale` in scientific notation (any exponent).
|
//! strings.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::fractal::FractalKind;
|
use crate::fractal::FractalKind;
|
||||||
use crate::view::Scale;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ShareState {
|
pub struct ShareState {
|
||||||
@@ -19,15 +18,13 @@ pub struct ShareState {
|
|||||||
pub power: u32,
|
pub power: u32,
|
||||||
pub center_re: String,
|
pub center_re: String,
|
||||||
pub center_im: String,
|
pub center_im: String,
|
||||||
pub half_height: Scale,
|
pub half_height: f64,
|
||||||
pub iterations: u32,
|
pub iterations: u32,
|
||||||
pub julia_c: (f64, f64),
|
pub julia_c: (f64, f64),
|
||||||
/// Distortion constant for the Phoenix kind (ignored by others).
|
/// Distortion constant for the Phoenix kind (ignored by others).
|
||||||
pub phoenix_p: (f64, f64),
|
pub phoenix_p: (f64, f64),
|
||||||
/// Distortion constant for the Lambda kind (ignored by others).
|
/// Distortion constant for the Lambda kind (ignored by others).
|
||||||
pub lambda_l: (f64, f64),
|
pub lambda_l: (f64, f64),
|
||||||
/// Complex exponent for the Complex Multibrot kind (ignored by others).
|
|
||||||
pub complex_power: (f64, f64),
|
|
||||||
pub color_scale: f32,
|
pub color_scale: f32,
|
||||||
pub color_offset: f32,
|
pub color_offset: f32,
|
||||||
/// Palette index (`palette_id` in the shader).
|
/// Palette index (`palette_id` in the shader).
|
||||||
@@ -40,7 +37,20 @@ impl ShareState {
|
|||||||
pub fn encode(&self) -> String {
|
pub fn encode(&self) -> String {
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
s.push_str(if self.julia { "m=j" } else { "m=m" });
|
s.push_str(if self.julia { "m=j" } else { "m=m" });
|
||||||
s.push_str(&format!("&f={}", self.kind.share_tag()));
|
s.push_str(&format!(
|
||||||
|
"&f={}",
|
||||||
|
match self.kind {
|
||||||
|
FractalKind::Mandelbrot => "mandel",
|
||||||
|
FractalKind::BurningShip => "burning",
|
||||||
|
FractalKind::Multibrot => "multi",
|
||||||
|
FractalKind::Tricorn => "tricorn",
|
||||||
|
FractalKind::Celtic => "celtic",
|
||||||
|
FractalKind::Perpendicular => "perp",
|
||||||
|
FractalKind::Buffalo => "buffalo",
|
||||||
|
FractalKind::Phoenix => "phoenix",
|
||||||
|
FractalKind::Lambda => "lambda",
|
||||||
|
}
|
||||||
|
));
|
||||||
s.push_str(&format!("&pw={}", self.power));
|
s.push_str(&format!("&pw={}", self.power));
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
"&re={}&im={}&hh={}&it={}",
|
"&re={}&im={}&hh={}&it={}",
|
||||||
@@ -50,12 +60,8 @@ impl ShareState {
|
|||||||
s.push_str(&format!("&px={}&py={}", self.phoenix_p.0, self.phoenix_p.1));
|
s.push_str(&format!("&px={}&py={}", self.phoenix_p.0, self.phoenix_p.1));
|
||||||
s.push_str(&format!("&lx={}&ly={}", self.lambda_l.0, self.lambda_l.1));
|
s.push_str(&format!("&lx={}&ly={}", self.lambda_l.0, self.lambda_l.1));
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
"&cpr={}&cpi={}",
|
"&cs={}&co={}&pal={}",
|
||||||
self.complex_power.0, self.complex_power.1
|
self.color_scale, self.color_offset, self.palette
|
||||||
));
|
|
||||||
s.push_str(&format!(
|
|
||||||
"&cs={}&co={}&pal={}&spal={}",
|
|
||||||
self.color_scale, self.color_offset, self.palette, self.shadow_palette
|
|
||||||
));
|
));
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
@@ -73,7 +79,18 @@ impl ShareState {
|
|||||||
julia: map.get("m").map(|m| *m == "j").unwrap_or(false),
|
julia: map.get("m").map(|m| *m == "j").unwrap_or(false),
|
||||||
kind: map
|
kind: map
|
||||||
.get("f")
|
.get("f")
|
||||||
.and_then(|f| FractalKind::from_share_tag(f))
|
.map(|f| match *f {
|
||||||
|
"mandel" => FractalKind::Mandelbrot,
|
||||||
|
"multi" => FractalKind::Multibrot,
|
||||||
|
"burning" => FractalKind::BurningShip,
|
||||||
|
"tricorn" => FractalKind::Tricorn,
|
||||||
|
"celtic" => FractalKind::Celtic,
|
||||||
|
"perp" => FractalKind::Perpendicular,
|
||||||
|
"buffalo" => FractalKind::Buffalo,
|
||||||
|
"phoenix" => FractalKind::Phoenix,
|
||||||
|
"lambda" => FractalKind::Lambda,
|
||||||
|
_ => FractalKind::Mandelbrot,
|
||||||
|
})
|
||||||
.unwrap_or(FractalKind::Mandelbrot),
|
.unwrap_or(FractalKind::Mandelbrot),
|
||||||
power: map.get("pw").and_then(|s| s.parse().ok()).unwrap_or(2),
|
power: map.get("pw").and_then(|s| s.parse().ok()).unwrap_or(2),
|
||||||
center_re: (*map.get("re")?).to_string(),
|
center_re: (*map.get("re")?).to_string(),
|
||||||
@@ -92,10 +109,6 @@ impl ShareState {
|
|||||||
map.get("lx").and_then(|s| s.parse().ok()).unwrap_or(-0.5),
|
map.get("lx").and_then(|s| s.parse().ok()).unwrap_or(-0.5),
|
||||||
map.get("ly").and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
map.get("ly").and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||||
),
|
),
|
||||||
complex_power: (
|
|
||||||
map.get("cpr").and_then(|s| s.parse().ok()).unwrap_or(2.0),
|
|
||||||
map.get("cpi").and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
|
||||||
),
|
|
||||||
color_scale: map.get("cs").and_then(|s| s.parse().ok()).unwrap_or(0.02),
|
color_scale: map.get("cs").and_then(|s| s.parse().ok()).unwrap_or(0.02),
|
||||||
color_offset: map.get("co").and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
color_offset: map.get("co").and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||||
palette: map.get("pal").and_then(|s| s.parse().ok()).unwrap_or(0),
|
palette: map.get("pal").and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||||
@@ -116,12 +129,11 @@ mod tests {
|
|||||||
power: 5,
|
power: 5,
|
||||||
center_re: "-0.743643887037158704752191506114774".into(),
|
center_re: "-0.743643887037158704752191506114774".into(),
|
||||||
center_im: "0.131825904205311970493132056385139".into(),
|
center_im: "0.131825904205311970493132056385139".into(),
|
||||||
half_height: Scale::from_f64(1.5e-20),
|
half_height: 1.5e-20,
|
||||||
iterations: 4000,
|
iterations: 4000,
|
||||||
julia_c: (-0.123, 0.745),
|
julia_c: (-0.123, 0.745),
|
||||||
phoenix_p: (-0.5, 0.1),
|
phoenix_p: (-0.5, 0.1),
|
||||||
lambda_l: (-0.5, 0.0),
|
lambda_l: (-0.5, 0.0),
|
||||||
complex_power: (2.5, 0.3),
|
|
||||||
color_scale: 0.02,
|
color_scale: 0.02,
|
||||||
color_offset: 0.25,
|
color_offset: 0.25,
|
||||||
palette: 3,
|
palette: 3,
|
||||||
@@ -137,7 +149,6 @@ mod tests {
|
|||||||
assert_eq!(d.iterations, s.iterations);
|
assert_eq!(d.iterations, s.iterations);
|
||||||
assert_eq!(d.julia_c, s.julia_c);
|
assert_eq!(d.julia_c, s.julia_c);
|
||||||
assert_eq!(d.phoenix_p, s.phoenix_p);
|
assert_eq!(d.phoenix_p, s.phoenix_p);
|
||||||
assert_eq!(d.complex_power, s.complex_power);
|
|
||||||
assert_eq!(d.palette, s.palette);
|
assert_eq!(d.palette, s.palette);
|
||||||
assert_eq!(d.shadow_palette, s.shadow_palette);
|
assert_eq!(d.shadow_palette, s.shadow_palette);
|
||||||
}
|
}
|
||||||
@@ -147,15 +158,5 @@ mod tests {
|
|||||||
let d = ShareState::decode("#m=m&re=0.0&im=0.0&hh=1.25&it=256").unwrap();
|
let d = ShareState::decode("#m=m&re=0.0&im=0.0&hh=1.25&it=256").unwrap();
|
||||||
assert!(!d.julia);
|
assert!(!d.julia);
|
||||||
assert_eq!(d.iterations, 256);
|
assert_eq!(d.iterations, 256);
|
||||||
assert_eq!(d.half_height, Scale::from_f64(1.25));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Zooms past f64's range survive a round trip.
|
|
||||||
#[test]
|
|
||||||
fn round_trip_past_f64_range() {
|
|
||||||
let d = ShareState::decode("#m=m&re=0.0&im=0.0&hh=1.5e-1234&it=256").unwrap();
|
|
||||||
assert_eq!(d.half_height, "1.5e-1234".parse().unwrap());
|
|
||||||
let d2 = ShareState::decode(&d.encode()).unwrap();
|
|
||||||
assert_eq!(d2.half_height, d.half_height);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-521
@@ -3,44 +3,17 @@
|
|||||||
// event loop, no worker-thread debounce (nothing to debounce for a one-shot
|
// event loop, no worker-thread debounce (nothing to debounce for a one-shot
|
||||||
// render); it just creates its own wgpu device, computes the reference orbit
|
// render); it just creates its own wgpu device, computes the reference orbit
|
||||||
// once, and renders through the same `ExportRender` path the "Export PNG"
|
// once, and renders through the same `ExportRender` path the "Export PNG"
|
||||||
// button uses. `--export-path -` writes to stdout instead: the PNG for a
|
// button uses.
|
||||||
// single image, or a raw RGBA8 video stream for an animation (for piping
|
|
||||||
// into ffmpeg).
|
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use eframe::egui_wgpu::wgpu;
|
||||||
use std::io::{IsTerminal, Write};
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
||||||
use std::sync::{Mutex, mpsc};
|
|
||||||
|
|
||||||
use crate::app::{FractalApp, RefJob, parse_complex_pair, unix_timestamp};
|
use crate::app::{FractalApp, unix_timestamp};
|
||||||
use crate::cli::Cli;
|
use crate::cli::Cli;
|
||||||
use crate::fractal::{
|
use crate::fractal::{ExportRender, FractalRenderer, export_to_png_blocking};
|
||||||
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.
|
/// Cap on the output image dimension (px), to stay within GPU texture limits.
|
||||||
const MAX_DIM: u32 = 8192 * 16;
|
const MAX_DIM: u32 = 8192 * 16;
|
||||||
|
|
||||||
/// `--export-path` value meaning "write to stdout".
|
|
||||||
const STDOUT_PATH: &str = "-";
|
|
||||||
|
|
||||||
/// Refuse to dump binary image data onto a terminal.
|
|
||||||
fn check_stdout_piped() -> Result<(), String> {
|
|
||||||
if std::io::stdout().is_terminal() {
|
|
||||||
return Err(
|
|
||||||
"--export-path - writes binary data to stdout; pipe it somewhere \
|
|
||||||
(e.g. `| ffmpeg ...`)"
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run(cli: Cli) -> Result<(), String> {
|
pub fn run(cli: Cli) -> Result<(), String> {
|
||||||
if cli.buddhabrot {
|
if cli.buddhabrot {
|
||||||
return Err("headless mode doesn't support --buddhabrot yet".into());
|
return Err("headless mode doesn't support --buddhabrot yet".into());
|
||||||
@@ -48,47 +21,33 @@ pub fn run(cli: Cli) -> Result<(), String> {
|
|||||||
|
|
||||||
let width = cli.width.clamp(16, MAX_DIM);
|
let width = cli.width.clamp(16, MAX_DIM);
|
||||||
let height = cli.height.clamp(16, MAX_DIM);
|
let height = cli.height.clamp(16, MAX_DIM);
|
||||||
|
let export_path = cli
|
||||||
// These drive the animation path below; grab them before `apply_cli`
|
.export_path
|
||||||
// consumes `cli` to build the start state.
|
.clone()
|
||||||
let targets = AnimTargets::from_cli(&cli)?;
|
.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp()));
|
||||||
let export_path = cli.export_path.clone();
|
|
||||||
if export_path.as_deref() == Some(STDOUT_PATH) {
|
|
||||||
check_stdout_piped()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if targets.shard.is_some() && !targets.any() {
|
|
||||||
return Err("--shard/--shards only apply to animations (give a --to-* target)".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut app = FractalApp::default_state();
|
let mut app = FractalApp::default_state();
|
||||||
app.apply_cli(cli);
|
app.apply_cli(cli);
|
||||||
app.set_output_size(width, height);
|
|
||||||
|
|
||||||
if 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…");
|
eprintln!("computing reference orbit…");
|
||||||
app.compute_reference_blocking();
|
app.compute_reference_blocking();
|
||||||
|
|
||||||
let (device, queue) = pollster::block_on(request_device())?;
|
let (device, queue) = pollster::block_on(request_device())?;
|
||||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
|
||||||
let renderer = FractalRenderer::new(&device, format);
|
let renderer = FractalRenderer::new(&device, format);
|
||||||
let uniforms = app.make_uniforms(width as f64 / height as f64, height as f64);
|
let (pipeline, bind_group_layout, format) = renderer.export_handles();
|
||||||
let handles = renderer.export_handles(&device, &uniforms);
|
|
||||||
|
|
||||||
|
let uniforms = app.make_uniforms(width as f64 / height as f64);
|
||||||
let er = ExportRender::new(
|
let er = ExportRender::new(
|
||||||
&device,
|
&device,
|
||||||
&queue,
|
&queue,
|
||||||
&handles,
|
pipeline,
|
||||||
|
&bind_group_layout,
|
||||||
|
format,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
uniforms,
|
uniforms,
|
||||||
app.reference_points(),
|
app.reference_points(),
|
||||||
app.lights(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
eprintln!("rendering {width}×{height}…");
|
eprintln!("rendering {width}×{height}…");
|
||||||
@@ -97,448 +56,11 @@ pub fn run(cli: Cli) -> Result<(), String> {
|
|||||||
});
|
});
|
||||||
eprintln!();
|
eprintln!();
|
||||||
|
|
||||||
if export_path == STDOUT_PATH {
|
std::fs::write(&export_path, &png).map_err(|e| format!("save failed: {e}"))?;
|
||||||
let mut out = std::io::stdout().lock();
|
println!("saved {export_path} ({width}×{height})");
|
||||||
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(())
|
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
|
/// Set up a wgpu device with no surface/window attached, matching the limits
|
||||||
/// `main::wgpu_options` requests for the windowed app (the fractal fragment
|
/// `main::wgpu_options` requests for the windowed app (the fractal fragment
|
||||||
/// shader needs storage buffers, which downlevel/WebGL-style limits disallow).
|
/// shader needs storage buffers, which downlevel/WebGL-style limits disallow).
|
||||||
@@ -558,30 +80,3 @@ async fn request_device() -> Result<(wgpu::Device, wgpu::Queue), String> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("failed to create device: {e}"))
|
.map_err(|e| format!("failed to create device: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::shard_range;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shards_tile_all_frames() {
|
|
||||||
for frames in [2, 3, 10, 97, 1000, u32::MAX] {
|
|
||||||
for shards in [1, 2, 3, 7, 10] {
|
|
||||||
if shards > frames {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut next = 0;
|
|
||||||
for k in 1..=shards {
|
|
||||||
let r = shard_range(frames, k, shards);
|
|
||||||
assert_eq!(
|
|
||||||
r.start, next,
|
|
||||||
"gap/overlap at shard {k}/{shards} of {frames}"
|
|
||||||
);
|
|
||||||
assert!(!r.is_empty(), "empty shard {k}/{shards} of {frames}");
|
|
||||||
next = r.end;
|
|
||||||
}
|
|
||||||
assert_eq!(next, frames);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-44
@@ -1,9 +1,7 @@
|
|||||||
use std::f32::consts::PI;
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
use ecolor::Color32;
|
use egui::{Color32, Ui};
|
||||||
#[cfg(feature = "gui")]
|
|
||||||
use egui::Ui;
|
|
||||||
|
|
||||||
/// Maximum number of simultaneous lights.
|
/// Maximum number of simultaneous lights.
|
||||||
pub const MAX_LIGHT_COUNT: usize = 16;
|
pub const MAX_LIGHT_COUNT: usize = 16;
|
||||||
@@ -29,14 +27,8 @@ impl Default for Light {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Light {
|
impl Light {
|
||||||
#[cfg(feature = "gui")]
|
|
||||||
pub fn widget(&mut self, ui: &mut Ui) -> bool {
|
pub fn widget(&mut self, ui: &mut Ui) -> bool {
|
||||||
let formater = |v, _| format!("{}°", ((v * 180. / std::f64::consts::PI) as u32));
|
let formater = |v, _| format!("{}°", ((v as f32 * 180. / PI) as u32));
|
||||||
let parser = |s: &str| {
|
|
||||||
s.parse::<u32>()
|
|
||||||
.ok()
|
|
||||||
.map(|x| x as f64 * std::f64::consts::PI / 180.)
|
|
||||||
};
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let del = ui.button("-").clicked();
|
let del = ui.button("-").clicked();
|
||||||
ui.label("color:");
|
ui.label("color:");
|
||||||
@@ -46,7 +38,6 @@ impl Light {
|
|||||||
egui::DragValue::new(&mut self.azimuth)
|
egui::DragValue::new(&mut self.azimuth)
|
||||||
.range(0.0..=PI * 2.)
|
.range(0.0..=PI * 2.)
|
||||||
.custom_formatter(formater)
|
.custom_formatter(formater)
|
||||||
.custom_parser(parser)
|
|
||||||
.speed(0.02),
|
.speed(0.02),
|
||||||
);
|
);
|
||||||
ui.label("φ:");
|
ui.label("φ:");
|
||||||
@@ -54,7 +45,6 @@ impl Light {
|
|||||||
egui::DragValue::new(&mut self.altitude)
|
egui::DragValue::new(&mut self.altitude)
|
||||||
.range(0.0..=PI / 2.)
|
.range(0.0..=PI / 2.)
|
||||||
.custom_formatter(formater)
|
.custom_formatter(formater)
|
||||||
.custom_parser(parser)
|
|
||||||
.speed(0.02),
|
.speed(0.02),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -63,35 +53,3 @@ impl Light {
|
|||||||
.inner
|
.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)
|
|
||||||
}
|
|
||||||
|
|||||||
+4
-24
@@ -1,8 +1,6 @@
|
|||||||
// Without these, rust fails to infer Send/Sync trait impls
|
// Without these, rust fails to infer Send/Sync trait impls
|
||||||
// Probably caused by the new trait solver
|
// Probably caused by the new trait solver
|
||||||
#![recursion_limit = "256"]
|
#![recursion_limit = "256"]
|
||||||
// Without the `gui` feature, UI-only state and helpers go unused.
|
|
||||||
#![cfg_attr(not(feature = "gui"), allow(dead_code, unused_imports))]
|
|
||||||
|
|
||||||
// Fractal Explorer — Rust + wgpu + egui + WGSL deep-zoom Mandelbrot.
|
// Fractal Explorer — Rust + wgpu + egui + WGSL deep-zoom Mandelbrot.
|
||||||
//
|
//
|
||||||
@@ -11,8 +9,6 @@
|
|||||||
// and calls the wasm `main`, which boots eframe onto the page's <canvas>.
|
// and calls the wasm `main`, which boots eframe onto the page's <canvas>.
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod bignum;
|
|
||||||
mod camera;
|
|
||||||
mod fractal;
|
mod fractal;
|
||||||
mod lights;
|
mod lights;
|
||||||
mod view;
|
mod view;
|
||||||
@@ -24,17 +20,8 @@ mod headless;
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
mod worker;
|
mod worker;
|
||||||
|
|
||||||
#[cfg(all(target_arch = "wasm32", not(feature = "gui")))]
|
|
||||||
compile_error!("the web build needs the `gui` feature");
|
|
||||||
|
|
||||||
#[cfg(feature = "gui")]
|
|
||||||
use app::FractalApp;
|
use app::FractalApp;
|
||||||
|
|
||||||
#[cfg(all(feature = "gui", not(target_arch = "wasm32")))]
|
|
||||||
type MainResult = eframe::Result;
|
|
||||||
#[cfg(all(not(feature = "gui"), not(target_arch = "wasm32")))]
|
|
||||||
type MainResult = Result<(), String>;
|
|
||||||
|
|
||||||
/// wgpu configuration for eframe. The fractal fragment shader reads the
|
/// wgpu configuration for eframe. The fractal fragment shader reads the
|
||||||
/// reference orbit from a **storage buffer**, so the device must allow storage
|
/// reference orbit from a **storage buffer**, so the device must allow storage
|
||||||
/// buffers in the fragment stage. eframe's default requests WebGL2-downlevel
|
/// buffers in the fragment stage. eframe's default requests WebGL2-downlevel
|
||||||
@@ -42,7 +29,6 @@ type MainResult = Result<(), String>;
|
|||||||
/// * request the adapter's real limits (which include storage buffers), and
|
/// * request the adapter's real limits (which include storage buffers), and
|
||||||
/// * force the WebGPU backend on the web (WebGL2 can't do storage buffers at
|
/// * force the WebGPU backend on the web (WebGL2 can't do storage buffers at
|
||||||
/// all) — failing cleanly on browsers without WebGPU, per the design.
|
/// all) — failing cleanly on browsers without WebGPU, per the design.
|
||||||
#[cfg(feature = "gui")]
|
|
||||||
fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||||
use eframe::egui_wgpu::{WgpuSetup, wgpu};
|
use eframe::egui_wgpu::{WgpuSetup, wgpu};
|
||||||
|
|
||||||
@@ -64,7 +50,7 @@ fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn main() -> MainResult {
|
fn main() -> eframe::Result {
|
||||||
use clap::Parser as _;
|
use clap::Parser as _;
|
||||||
|
|
||||||
env_logger::builder()
|
env_logger::builder()
|
||||||
@@ -83,13 +69,6 @@ fn main() -> MainResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "gui"))]
|
|
||||||
{
|
|
||||||
eprintln!("error: built without the \"gui\" feature; only --headless is supported");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "gui")]
|
|
||||||
let native_options = eframe::NativeOptions {
|
let native_options = eframe::NativeOptions {
|
||||||
renderer: eframe::Renderer::Wgpu,
|
renderer: eframe::Renderer::Wgpu,
|
||||||
wgpu_options: wgpu_options(),
|
wgpu_options: wgpu_options(),
|
||||||
@@ -100,7 +79,6 @@ fn main() -> MainResult {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(feature = "gui")]
|
|
||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"Fractal Explorer",
|
"Fractal Explorer",
|
||||||
native_options,
|
native_options,
|
||||||
@@ -144,7 +122,9 @@ fn main() {
|
|||||||
match result {
|
match result {
|
||||||
Ok(_) => loading.remove(),
|
Ok(_) => loading.remove(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
loading.set_inner_html(&format!("<p>The app has crashed.</br>{e:?}</p>"));
|
loading.set_inner_html(
|
||||||
|
"<p>The app has crashed. See the developer console for details.</p>",
|
||||||
|
);
|
||||||
log::error!("failed to start eframe: {e:?}");
|
log::error!("failed to start eframe: {e:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ struct VsOut {
|
|||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut {
|
fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut {
|
||||||
let p = fullscreen_triangle_pos(idx);
|
var verts = array<vec2<f32>, 3>(
|
||||||
|
vec2<f32>(-1.0, -1.0),
|
||||||
|
vec2<f32>(3.0, -1.0),
|
||||||
|
vec2<f32>(-1.0, 3.0),
|
||||||
|
);
|
||||||
|
let p = verts[idx];
|
||||||
var out: VsOut;
|
var out: VsOut;
|
||||||
out.pos = vec4<f32>(p, 0.0, 1.0);
|
out.pos = vec4<f32>(p, 0.0, 1.0);
|
||||||
// Map NDC to texture UV. v is flipped so the cache's top row (rendered at
|
// Map NDC to texture UV. v is flipped so the cache's top row (rendered at
|
||||||
|
|||||||
+37
-27
@@ -47,27 +47,30 @@ struct Uniforms {
|
|||||||
// Tonemap colour style: 0 = classic (R/G/B = raw caps), 1 = nebula
|
// Tonemap colour style: 0 = classic (R/G/B = raw caps), 1 = nebula
|
||||||
// (yellow core, blue halo), 2 = grayscale.
|
// (yellow core, blue halo), 2 = grayscale.
|
||||||
palette: u32,
|
palette: u32,
|
||||||
// Padding so `complex_power` (a vec2, 8-byte aligned) starts on an
|
// Padding to a 16-byte multiple. NOT vec3<u32> — that type aligns to 16
|
||||||
// 8-byte boundary. NOT vec3<u32> — that type aligns to 16 bytes in WGSL
|
// bytes in WGSL (unlike Rust's `[u32; 3]`, which aligns to 4), which
|
||||||
// (unlike Rust's `[u32; 3]`, which aligns to 4), which silently added 32
|
// silently added 32 bytes instead of 16 and mismatched the Rust struct's
|
||||||
// bytes instead of 16 and mismatched the Rust struct's size (a wgpu
|
// size (a wgpu validation error at dispatch time: "size 96 where the
|
||||||
// validation error at dispatch time: "size 96 where the shader expects
|
// shader expects 112").
|
||||||
// 112").
|
|
||||||
_pad0: u32,
|
_pad0: u32,
|
||||||
// Complex exponent for the Complex Multibrot kind; unused by other kinds.
|
_pad1: u32,
|
||||||
complex_power: vec2<f32>,
|
_pad2: u32,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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_NEBULA: u32 = 0u;
|
||||||
const PALETTE_YELLOW: u32 = 1u;
|
const PALETTE_YELLOW: u32 = 1u;
|
||||||
const PALETTE_GRAYSCALE: u32 = 2u;
|
const PALETTE_GRAYSCALE: u32 = 2u;
|
||||||
|
|
||||||
|
const KIND_MANDELBROT: u32 = 0u;
|
||||||
|
const KIND_BURNING_SHIP: u32 = 1u;
|
||||||
|
const KIND_TRICORN: u32 = 2u;
|
||||||
|
const KIND_MULTIBROT: u32 = 3u;
|
||||||
|
const KIND_CELTIC: u32 = 4u;
|
||||||
|
const KIND_PERPENDICULAR: u32 = 5u;
|
||||||
|
const KIND_BUFFALO: u32 = 6u;
|
||||||
|
const KIND_PHOENIX: u32 = 7u;
|
||||||
|
const KIND_LAMBDA: u32 = 8u;
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||||
// Compute pass: read-write atomic histogram (3 planes of width*height, R/G/B).
|
// Compute pass: read-write atomic histogram (3 planes of width*height, R/G/B).
|
||||||
@group(0) @binding(1) var<storage, read_write> histogram: array<atomic<u32>>;
|
@group(0) @binding(1) var<storage, read_write> histogram: array<atomic<u32>>;
|
||||||
@@ -88,6 +91,10 @@ fn rand01(seed: u32) -> f32 {
|
|||||||
return f32(hash_u32(seed)) * (1.0 / 4294967295.0);
|
return f32(hash_u32(seed)) * (1.0 / 4294967295.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmul(a: vec2<f32>, b: vec2<f32>) -> vec2<f32> {
|
||||||
|
return vec2<f32>(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x);
|
||||||
|
}
|
||||||
|
|
||||||
fn complex_pow(z: vec2<f32>, p: u32) -> vec2<f32> {
|
fn complex_pow(z: vec2<f32>, p: u32) -> vec2<f32> {
|
||||||
var r = vec2<f32>(1.0, 0.0);
|
var r = vec2<f32>(1.0, 0.0);
|
||||||
for (var i: u32 = 0u; i < p; i = i + 1u) {
|
for (var i: u32 = 0u; i < p; i = i + 1u) {
|
||||||
@@ -101,26 +108,24 @@ fn complex_pow(z: vec2<f32>, p: u32) -> vec2<f32> {
|
|||||||
// Must match `FractalKind` in reference.rs (the direct, non-perturbative form
|
// Must match `FractalKind` in reference.rs (the direct, non-perturbative form
|
||||||
// of the same formulas).
|
// of the same formulas).
|
||||||
fn advance(z: vec2<f32>, zp: vec2<f32>, c: vec2<f32>) -> vec2<f32> {
|
fn advance(z: vec2<f32>, zp: vec2<f32>, c: vec2<f32>) -> vec2<f32> {
|
||||||
if KIND == KIND_BURNING_SHIP {
|
if u.kind == KIND_BURNING_SHIP {
|
||||||
return vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * abs(z.x * z.y)) + c;
|
return vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * abs(z.x * z.y)) + c;
|
||||||
} else if KIND == KIND_TRICORN {
|
} else if u.kind == KIND_TRICORN {
|
||||||
return vec2<f32>(z.x * z.x - z.y * z.y, -2.0 * z.x * z.y) + c;
|
return vec2<f32>(z.x * z.x - z.y * z.y, -2.0 * z.x * z.y) + c;
|
||||||
} else if KIND == KIND_MULTIBROT {
|
} else if u.kind == KIND_MULTIBROT {
|
||||||
return complex_pow(z, clamp(u.power, 2u, MULTIBROT_MAX_POWER)) + c;
|
return complex_pow(z, clamp(u.power, 2u, 8u)) + c;
|
||||||
} else if KIND == KIND_CELTIC {
|
} else if u.kind == KIND_CELTIC {
|
||||||
return vec2<f32>(abs(z.x * z.x - z.y * z.y), 2.0 * z.x * z.y) + c;
|
return vec2<f32>(abs(z.x * z.x - z.y * z.y), 2.0 * z.x * z.y) + c;
|
||||||
} else if KIND == KIND_PERPENDICULAR {
|
} else if u.kind == KIND_PERPENDICULAR {
|
||||||
return vec2<f32>(z.x * z.x - z.y * z.y, -2.0 * z.x * abs(z.y)) + c;
|
return vec2<f32>(z.x * z.x - z.y * z.y, -2.0 * z.x * abs(z.y)) + c;
|
||||||
} else if KIND == KIND_BUFFALO {
|
} else if u.kind == KIND_BUFFALO {
|
||||||
return vec2<f32>(abs(z.x * z.x - z.y * z.y), -abs(2.0 * z.x * z.y)) + c;
|
return vec2<f32>(abs(z.x * z.x - z.y * z.y), -abs(2.0 * z.x * z.y)) + c;
|
||||||
} else if KIND == KIND_PHOENIX {
|
} else if u.kind == KIND_PHOENIX {
|
||||||
let sq = vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y);
|
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);
|
return sq + c + cmul(u.phoenix_p, zp);
|
||||||
} else if KIND == KIND_LAMBDA {
|
} else if u.kind == KIND_LAMBDA {
|
||||||
// l * z * (1 - z); c is unused (see file doc comment above).
|
// l * z * (1 - z); c is unused (see file doc comment above).
|
||||||
return cmul(u.lambda_l, cmul(z, vec2<f32>(1.0 - z.x, -z.y)));
|
return cmul(u.lambda_l, cmul(z, vec2<f32>(1.0 - z.x, -z.y)));
|
||||||
} else if KIND == KIND_COMPLEX_MULTIBROT {
|
|
||||||
return cpow(z, u.complex_power) + c;
|
|
||||||
}
|
}
|
||||||
return vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c; // Mandelbrot
|
return vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c; // Mandelbrot
|
||||||
}
|
}
|
||||||
@@ -182,7 +187,7 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||||||
|
|
||||||
var c = sample;
|
var c = sample;
|
||||||
var z0 = vec2<f32>(0.0, 0.0);
|
var z0 = vec2<f32>(0.0, 0.0);
|
||||||
if KIND == KIND_LAMBDA {
|
if u.kind == KIND_LAMBDA {
|
||||||
c = vec2<f32>(0.0, 0.0); // unused by the Lambda step
|
c = vec2<f32>(0.0, 0.0); // unused by the Lambda step
|
||||||
z0 = sample;
|
z0 = sample;
|
||||||
}
|
}
|
||||||
@@ -227,7 +232,12 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4<f32> {
|
fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4<f32> {
|
||||||
return vec4<f32>(fullscreen_triangle_pos(idx), 0.0, 1.0);
|
var verts = array<vec2<f32>, 3>(
|
||||||
|
vec2<f32>(-1.0, -1.0),
|
||||||
|
vec2<f32>(3.0, -1.0),
|
||||||
|
vec2<f32>(-1.0, 3.0),
|
||||||
|
);
|
||||||
|
return vec4<f32>(verts[idx], 0.0, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
|
|||||||
+134
-163
@@ -10,188 +10,159 @@
|
|||||||
// at the fragment's integer pixel coordinate (nearest — iteration data must not
|
// at the fragment's integer pixel coordinate (nearest — iteration data must not
|
||||||
// be linearly filtered across escape boundaries).
|
// be linearly filtered across escape boundaries).
|
||||||
|
|
||||||
|
// Must match `Uniforms` in mandelbrot.wgsl / the Rust `Uniforms` struct.
|
||||||
|
struct Uniforms {
|
||||||
|
span: vec2<f32>,
|
||||||
|
max_iter: u32,
|
||||||
|
ref_len: u32,
|
||||||
|
color_offset: f32,
|
||||||
|
color_scale: f32,
|
||||||
|
bailout_sq: f32,
|
||||||
|
is_julia: u32,
|
||||||
|
palette_id: u32,
|
||||||
|
shadow_palette_id: u32,
|
||||||
|
aa_level: u32,
|
||||||
|
kind: u32,
|
||||||
|
power: u32,
|
||||||
|
dc_offset: vec2<f32>,
|
||||||
|
phoenix_p: vec2<f32>,
|
||||||
|
lambda_l: vec2<f32>,
|
||||||
|
de_coloring: u32,
|
||||||
|
shadow: u32,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Light {
|
||||||
|
azimuth: f32,
|
||||||
|
altitude: f32,
|
||||||
|
color: u32,
|
||||||
|
_pad: u32
|
||||||
|
};
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||||
@group(0) @binding(1) var data_tex: texture_2d<f32>;
|
@group(0) @binding(1) var data_tex: texture_2d<f32>;
|
||||||
@group(0) @binding(2) var<uniform> lights: array<Light, 16>;
|
@group(0) @binding(2) var<uniform> lights: array<Light, 16>;
|
||||||
|
|
||||||
@vertex
|
// Smooth cyclic palettes (Inigo Quilez cosine palettes). Must match the palette
|
||||||
fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4<f32> {
|
// in mandelbrot.wgsl.
|
||||||
return vec4<f32>(fullscreen_triangle_pos(idx), 0.0, 1.0);
|
fn palette(id: u32, t: f32) -> vec3<f32> {
|
||||||
|
if id == 4u {
|
||||||
|
return vec3<f32>(t, t, t); // grayscale
|
||||||
|
}
|
||||||
|
let a = vec3<f32>(0.5, 0.5, 0.5);
|
||||||
|
let b = vec3<f32>(0.5, 0.5, 0.5);
|
||||||
|
var c = vec3<f32>(1.0, 1.0, 1.0);
|
||||||
|
var d = vec3<f32>(0.00, 0.10, 0.20); // 0: amber / blue
|
||||||
|
if id == 1u {
|
||||||
|
d = vec3<f32>(0.00, 0.33, 0.67); // rainbow
|
||||||
|
} else if id == 2u {
|
||||||
|
d = vec3<f32>(0.30, 0.20, 0.20); // warm ember
|
||||||
|
} else if id == 3u {
|
||||||
|
c = vec3<f32>(1.0, 1.0, 0.5);
|
||||||
|
d = vec3<f32>(0.80, 0.90, 0.30); // lime / magenta
|
||||||
|
}
|
||||||
|
return a + b * cos(6.28318530718 * (c * t + d));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shadow_fragment(pos: vec2<f32>) -> vec4<f32> {
|
@vertex
|
||||||
let x = i32(pos.x);
|
fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4<f32> {
|
||||||
let y = i32(pos.y);
|
var verts = array<vec2<f32>, 3>(
|
||||||
let size = textureDimensions(data_tex);
|
vec2<f32>(-1.0, -1.0),
|
||||||
let here = textureLoad(data_tex, vec2<i32>(x, y), 0);
|
vec2<f32>(3.0, -1.0),
|
||||||
if here.b != 0. {
|
vec2<f32>(-1.0, 3.0),
|
||||||
return vec4<f32>(shadow_interior_color(), 1.0);
|
);
|
||||||
}
|
return vec4<f32>(verts[idx], 0.0, 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
|
fn load(x: i32, y: i32) -> vec3<f32> {
|
||||||
// expects — plugging h[-1] in directly would flip the normal there.
|
let dist = textureLoad(data_tex, vec2<i32>(x, y), 0).g;
|
||||||
let h0 = here.g;
|
return vec3<f32>(f32(x), f32(y), dist);
|
||||||
var h1: f32;
|
}
|
||||||
if x + 1 < i32(size.x) {
|
|
||||||
h1 = textureLoad(data_tex, vec2<i32>(x + 1, y), 0).g;
|
fn compute_light(normal: vec3<f32>, light: vec3<f32>) -> vec3<f32> {
|
||||||
} else {
|
return vec3<f32>(max(0., dot(normal, normalize(light))));
|
||||||
h1 = 2.0 * h0 - textureLoad(data_tex, vec2<i32>(x - 1, y), 0).g;
|
}
|
||||||
}
|
|
||||||
var h2: f32;
|
fn uncharted2tonemap(x: vec3<f32>) -> vec3<f32> {
|
||||||
if y + 1 < i32(size.y) {
|
let A = 0.15; // Shoulder strength
|
||||||
h2 = textureLoad(data_tex, vec2<i32>(x, y + 1), 0).g;
|
let B = 0.50; // Linear strength
|
||||||
} else {
|
let C = 0.10; // Linear angle
|
||||||
h2 = 2.0 * h0 - textureLoad(data_tex, vec2<i32>(x, y - 1), 0).g;
|
let D = 0.20; // Toe strength
|
||||||
}
|
let E = 0.02; // Toe numerator / shoarder angle/etc.
|
||||||
let normal = normal_from_heights(h0, h1, h2);
|
let F = 0.30; // Toe denominator
|
||||||
return vec4<f32>(shadow_color(normal, here.r), 1.0);
|
|
||||||
|
return ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filmic(color: vec3<f32>, white_point: f32) -> vec3<f32> {
|
||||||
|
let exposure_bias = 2.0;
|
||||||
|
let curr = uncharted2tonemap(color * exposure_bias);
|
||||||
|
|
||||||
|
// Valeur blanche maximale de référence
|
||||||
|
let white_scale = vec3(1.0) / uncharted2tonemap(vec3(white_point));
|
||||||
|
return curr * white_scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn s(color: vec3<f32>, k: f32, c: f32) -> vec3<f32> {
|
||||||
|
return 1. / (1. + exp(-k * (color - c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contrast(color: vec3<f32>, k: f32, c: f32) -> vec3<f32> {
|
||||||
|
let color_c = s(color, k, c);
|
||||||
|
|
||||||
|
return (color_c - s(vec3<f32>(0), k, c)) / (s(vec3<f32>(1), k, c) - s(vec3<f32>(0), k, c));
|
||||||
}
|
}
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||||
if u.shadow == 2u {
|
if u.shadow != 0u {
|
||||||
return ray_marching(pos);
|
if textureLoad(data_tex, vec2<i32>(i32(pos.x), i32(pos.y)), 0).b != 0. {
|
||||||
} else if u.shadow == 1u {
|
return vec4<f32>(0.1, 0.1, 0.1, 1.0);
|
||||||
return shadow_fragment(pos.xy);
|
} else {
|
||||||
|
let d = array<vec3<f32>, 3>(load(i32(pos.x), i32(pos.y)), load(i32(pos.x + 1), i32(pos.y)), load(i32(pos.x), i32(pos.y + 1)));
|
||||||
|
|
||||||
|
let normal = normalize(cross(d[1] - d[0], d[2] - d[0]));
|
||||||
|
|
||||||
|
var color: vec3<f32>;
|
||||||
|
if u.shadow_palette_id == 0u {
|
||||||
|
color = compute_light(normal,vec3<f32>(.5, .5, .5)) + vec3<f32>(0.58, 0.85, 1.) * 0.2;
|
||||||
|
|
||||||
|
color = filmic(color, 2.5);
|
||||||
|
color = contrast(color, 4., 0.67);
|
||||||
|
} else if u.shadow_palette_id == 1u {
|
||||||
|
color = compute_light(normal, vec3<f32>(0., .5, .5)) * vec3<f32>(1., 0.5, 0.5) + compute_light(normal, vec3<f32>(0.5, 0., .5)) * vec3<f32>(0.5, 1., 1.);
|
||||||
|
|
||||||
|
color = filmic(color, 4.2);
|
||||||
|
} else {
|
||||||
|
color = vec3<f32>(0);
|
||||||
|
var light_count = 0;
|
||||||
|
for (var i = 0u ; i < 16; i++) {
|
||||||
|
let light_color = unpack4x8unorm(lights[i].color);
|
||||||
|
if any(light_color != vec4<f32>(0)) {
|
||||||
|
light_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
color += compute_light(normal, vec3<f32>(
|
||||||
|
cos(lights[i].azimuth) * cos(lights[i].altitude),
|
||||||
|
sin(lights[i].azimuth) * cos(lights[i].altitude),
|
||||||
|
sin(lights[i].altitude))) * light_color.xyz * light_color.a;
|
||||||
|
}
|
||||||
|
|
||||||
|
color = filmic(color, 1. + f32(light_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
return vec4<f32>(color, 1.0);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let d = textureLoad(data_tex, vec2<i32>(i32(pos.x), i32(pos.y)), 0);
|
let d = textureLoad(data_tex, vec2<i32>(i32(pos.x), i32(pos.y)), 0);
|
||||||
let ci = d.r;
|
let ci = d.r;
|
||||||
let de = d.g;
|
let de = d.g;
|
||||||
let interior_frac = d.b;
|
let interior_frac = d.b;
|
||||||
|
|
||||||
var col = classic_color(ci, de);
|
let t = fract(ci * u.color_scale + u.color_offset);
|
||||||
|
var col = palette(u.palette_id, t) * de;
|
||||||
// Anti-alias the set boundary: fade toward black by the fraction of the
|
// Anti-alias the set boundary: fade toward black by the fraction of the
|
||||||
// pixel's sub-samples that landed in the interior.
|
// pixel's sub-samples that landed in the interior.
|
||||||
col = col * (1.0 - interior_frac);
|
col = col * (1.0 - interior_frac);
|
||||||
return vec4<f32>(col, 1.0);
|
return vec4<f32>(col, 1.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Colour of rays that miss the fractal's footprint.
|
|
||||||
const RAY_MISS: vec4<f32> = vec4<f32>(1.0, 0.0, 0.0, 1.0);
|
|
||||||
|
|
||||||
// Per-frame constants of the raymarch, computed once per pixel in
|
|
||||||
// `ray_marching` rather than on each of the up-to-100 `sdf` steps.
|
|
||||||
struct MarchConsts {
|
|
||||||
size: vec2<f32>,
|
|
||||||
// (size.x / aspect_ratio, size.y): world xy -> texel scale.
|
|
||||||
to_texel: vec2<f32>,
|
|
||||||
size_i: vec2<i32>,
|
|
||||||
inv_size_y: f32,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn sdf(pos: vec3<f32>, k: MarchConsts) -> f32 {
|
|
||||||
let texture_pos_f32 = pos.xy * k.to_texel;
|
|
||||||
let texture_pos = clamp(vec2<i32>(texture_pos_f32), vec2<i32>(0, 0), k.size_i - vec2<i32>(1, 1));
|
|
||||||
|
|
||||||
let to_texture = max(-min(texture_pos_f32, vec2(0.)), max(texture_pos_f32 - k.size, vec2(0.)));
|
|
||||||
let dist_to_texture = length(to_texture) * k.inv_size_y;
|
|
||||||
|
|
||||||
let px = textureLoad(data_tex, texture_pos, 0);
|
|
||||||
let de = (px.g * k.inv_size_y) * 0.5;
|
|
||||||
// Height is measured toward -z, the side the camera sits on (it looks
|
|
||||||
// along +z), so the terrain is solid on +z: interior plateau at z = 0,
|
|
||||||
// exterior sloping away from the camera as `de` grows.
|
|
||||||
let signed_z = -pos.z;
|
|
||||||
let z = max(signed_z, 0.);
|
|
||||||
var d: f32;
|
|
||||||
if px.b != 0. {
|
|
||||||
d = z;
|
|
||||||
} else {
|
|
||||||
d = min(sqrt(z * z + de * de), signed_z + 1. - exp(-de * 5.));
|
|
||||||
}
|
|
||||||
// Outside the texture footprint, `d` is the distance from the clamped
|
|
||||||
// point q on the footprint's edge. The terrain lies over the (convex)
|
|
||||||
// footprint, so |p - x|² ≥ |q - x|² + |p - q|² for every terrain point x:
|
|
||||||
// combine in quadrature (not by adding, which overshoots). p can't be in
|
|
||||||
// the solid out here, so a negative `d` counts as 0.
|
|
||||||
if dist_to_texture > 0. {
|
|
||||||
let d_pos = max(d, 0.);
|
|
||||||
return sqrt(d_pos * d_pos + dist_to_texture * dist_to_texture);
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ray_marching(pos: vec4<f32>) -> vec4<f32> {
|
|
||||||
let size_i = vec2<i32>(textureDimensions(data_tex));
|
|
||||||
let size = vec2<f32>(size_i);
|
|
||||||
let aspect_ratio = u.screen_dim.x / u.screen_dim.y;
|
|
||||||
let k = MarchConsts(size, vec2<f32>(size.x / aspect_ratio, size.y), size_i, 1.0 / size.y);
|
|
||||||
|
|
||||||
let in_texture = vec2<f32>(
|
|
||||||
(pos.x / size.x) * 2. - 1.,
|
|
||||||
(pos.y / size.y) * 2. - 1.,
|
|
||||||
);
|
|
||||||
|
|
||||||
var world_pos = u.camera_inv_proj * vec4<f32>(in_texture, 0., 1.0);
|
|
||||||
|
|
||||||
let ray_origin = world_pos.xyz;
|
|
||||||
let ray_dir = u.camera_direction;
|
|
||||||
|
|
||||||
// Start where the ray crosses z = 0, the topmost possible surface (the
|
|
||||||
// camera pitch is clamped short of ±90°, so ray_dir.z > 0).
|
|
||||||
let start = ray_origin - ray_dir * (ray_origin.z / ray_dir.z);
|
|
||||||
|
|
||||||
// The terrain only exists over the footprint x in [0, aspect],
|
|
||||||
// y in [0, 1]: clip the ray's xy to it up front, so rays that miss it cost
|
|
||||||
// nothing and the rest start marching at its edge. A huge finite 1/d on
|
|
||||||
// an axis the ray doesn't move along (top-down, during the 2D <-> 3D
|
|
||||||
// transition) keeps the slab maths finite.
|
|
||||||
let inv = select(1.0 / ray_dir.xy, vec2<f32>(1e30), abs(ray_dir.xy) < vec2<f32>(1e-20));
|
|
||||||
let ta = -start.xy * inv;
|
|
||||||
let tb = (vec2<f32>(aspect_ratio, 1.0) - start.xy) * inv;
|
|
||||||
let t_leave = min(max(ta.x, tb.x), max(ta.y, tb.y));
|
|
||||||
var t = max(max(min(ta.x, tb.x), min(ta.y, tb.y)), 0.0);
|
|
||||||
if t >= t_leave {
|
|
||||||
return RAY_MISS;
|
|
||||||
}
|
|
||||||
|
|
||||||
// About 1/50 of a texel at typical sizes: tighter only adds steps
|
|
||||||
// without visibly moving the hit.
|
|
||||||
let dist_threshold = 0.00001;
|
|
||||||
// Rays grazing the exponential slope see a tiny `dist` for many steps in
|
|
||||||
// a row and would crawl along it until the step budget runs out. Force a
|
|
||||||
// step of at least half a texel (the height field is nearest-sampled, so
|
|
||||||
// nothing finer exists), and bisect back if that lands inside the solid.
|
|
||||||
let min_step = 0.5 * k.inv_size_y;
|
|
||||||
var hit = false;
|
|
||||||
var t_prev = t;
|
|
||||||
for (var i = 0u; i < 100u; i++) {
|
|
||||||
let dist = sdf(start + t * ray_dir, k);
|
|
||||||
if dist < dist_threshold {
|
|
||||||
hit = true;
|
|
||||||
if dist < 0. {
|
|
||||||
// Overshot: t_prev is outside, t inside. Refine the crossing.
|
|
||||||
var lo = t_prev;
|
|
||||||
var hi = t;
|
|
||||||
for (var j = 0u; j < 8u; j++) {
|
|
||||||
let mid = 0.5 * (lo + hi);
|
|
||||||
if sdf(start + mid * ray_dir, k) < dist_threshold {
|
|
||||||
hi = mid;
|
|
||||||
} else {
|
|
||||||
lo = mid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t = hi;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
t_prev = t;
|
|
||||||
t += max(dist, min_step);
|
|
||||||
// Past the footprint's far edge: nothing left to hit.
|
|
||||||
if t >= t_leave {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Out of steps while still over the footprint: the ray is skimming the
|
|
||||||
// surface, so shade where it got to rather than reporting a miss.
|
|
||||||
if !hit && t < t_leave {
|
|
||||||
hit = true;
|
|
||||||
}
|
|
||||||
// Shade outside the loop, so its registers don't weigh on the march.
|
|
||||||
if !hit {
|
|
||||||
return RAY_MISS;
|
|
||||||
}
|
|
||||||
return shadow_fragment((start.xy + t * ray_dir.xy) * k.to_texel);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
// Shared helpers, concatenated into every shader at build time via
|
|
||||||
// `concat!`/`include_str!` (see renderer.rs / buddhabrot.rs). Keep this file
|
|
||||||
// free of anything that differs between pipelines (e.g. a `Uniforms` struct —
|
|
||||||
// mandelbrot/colorize and buddhabrot each have their own shape) since every
|
|
||||||
// shader gets the whole thing spliced in.
|
|
||||||
|
|
||||||
// Fullscreen triangle vertex position: one triangle that covers the whole
|
|
||||||
// viewport (cheaper than a quad's two), shared by every full-screen vertex
|
|
||||||
// shader in this project.
|
|
||||||
fn fullscreen_triangle_pos(idx: u32) -> vec2<f32> {
|
|
||||||
var verts = array<vec2<f32>, 3>(
|
|
||||||
vec2<f32>(-1.0, -1.0),
|
|
||||||
vec2<f32>(3.0, -1.0),
|
|
||||||
vec2<f32>(-1.0, 3.0),
|
|
||||||
);
|
|
||||||
return verts[idx];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complex multiply.
|
|
||||||
fn cmul(a: vec2<f32>, b: vec2<f32>) -> vec2<f32> {
|
|
||||||
return vec2<f32>(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x);
|
|
||||||
}
|
|
||||||
|
|
||||||
// z^p for a complex exponent p, via the principal branch z^p = exp(p * ln z),
|
|
||||||
// ln z = ln|z| + i*arg(z). z = 0 maps to 0 (the correct limit for the
|
|
||||||
// Re(p) > 0 region the UI exposes; ln(0) would otherwise be -inf).
|
|
||||||
fn cpow(z: vec2<f32>, p: vec2<f32>) -> vec2<f32> {
|
|
||||||
let r2 = dot(z, z);
|
|
||||||
if r2 < 1e-30 {
|
|
||||||
return vec2<f32>(0.0, 0.0);
|
|
||||||
}
|
|
||||||
let ln_r = 0.5 * log(r2);
|
|
||||||
let theta = atan2(z.y, z.x);
|
|
||||||
let mag = exp(p.x * ln_r - p.y * theta);
|
|
||||||
let ang = p.x * theta + p.y * ln_r;
|
|
||||||
return mag * vec2<f32>(cos(ang), sin(ang));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iteration formula selector, shared by the perturbation (mandelbrot.wgsl)
|
|
||||||
// and direct (buddhabrot.wgsl) iteration paths. Must match `FractalKind` in
|
|
||||||
// reference.rs.
|
|
||||||
const KIND_MANDELBROT: u32 = 0u;
|
|
||||||
const KIND_BURNING_SHIP: u32 = 1u;
|
|
||||||
const KIND_TRICORN: u32 = 2u;
|
|
||||||
const KIND_MULTIBROT: u32 = 3u;
|
|
||||||
// Highest Multibrot power (the UI/CLI/share-link clamp in app.rs matches).
|
|
||||||
// `bailout_sq` in app.rs shrinks the bailout with the power so z^p stays a
|
|
||||||
// finite f32.
|
|
||||||
const MULTIBROT_MAX_POWER: u32 = 20u;
|
|
||||||
const KIND_CELTIC: u32 = 4u;
|
|
||||||
const KIND_PERPENDICULAR: u32 = 5u;
|
|
||||||
const KIND_BUFFALO: u32 = 6u;
|
|
||||||
const KIND_PHOENIX: u32 = 7u;
|
|
||||||
const KIND_LAMBDA: u32 = 8u;
|
|
||||||
const KIND_COMPLEX_MULTIBROT: u32 = 9u;
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
// Shared by mandelbrot.wgsl (writes the per-pixel data texture) and
|
|
||||||
// colorize.wgsl (reads it): the iteration pass and the colour remap pass
|
|
||||||
// must agree on both the uniform layout and the palette function.
|
|
||||||
|
|
||||||
// Must match the Rust `Uniforms` struct in renderer.rs field-for-field,
|
|
||||||
// including padding.
|
|
||||||
struct Uniforms {
|
|
||||||
span: vec2<f32>,
|
|
||||||
max_iter: u32,
|
|
||||||
ref_len: u32,
|
|
||||||
color_offset: f32,
|
|
||||||
color_scale: f32,
|
|
||||||
bailout_sq: f32,
|
|
||||||
is_julia: u32,
|
|
||||||
palette_id: u32,
|
|
||||||
shadow_palette_id: u32,
|
|
||||||
aa_level: u32,
|
|
||||||
// Iteration formula (see the KIND_* constants in common.wgsl).
|
|
||||||
kind: u32,
|
|
||||||
// Exponent for the Multibrot kind.
|
|
||||||
power: u32,
|
|
||||||
// Kind-switch morph: the kind blended *from* (see morph_w).
|
|
||||||
morph_from: u32,
|
|
||||||
dc_offset: vec2<f32>,
|
|
||||||
// Distortion constant p for the Phoenix map (z^2 + c + p*z_{n-1}); unused
|
|
||||||
// by other kinds. Placed by dc_offset so both vec2s stay 8-byte aligned.
|
|
||||||
phoenix_p: vec2<f32>,
|
|
||||||
// Distortion constant l for the Lambda map (l*z(1 - z_{n-1})); unused
|
|
||||||
// by other kinds.
|
|
||||||
lambda_l: vec2<f32>,
|
|
||||||
// Complex exponent for the Complex Multibrot kind (z^power + c); unused
|
|
||||||
// by other kinds.
|
|
||||||
complex_power: vec2<f32>,
|
|
||||||
// 0 = escape-time coloring, 1 = distance-estimation shading.
|
|
||||||
de_coloring: u32,
|
|
||||||
// 0 = classic colors, 1 = shadows, 2 = 3D raymarching rendering
|
|
||||||
shadow: u32,
|
|
||||||
// camera direction vector
|
|
||||||
camera_direction: vec3<f32>,
|
|
||||||
// Number of live entries at the start of `lights` (fills the vec3's tail
|
|
||||||
// padding slot).
|
|
||||||
light_count: u32,
|
|
||||||
// inverse of the camera's view-projection matrix, for reconstructing a
|
|
||||||
// world-space ray origin per pixel in the raymarcher
|
|
||||||
camera_inv_proj: mat4x4<f32>,
|
|
||||||
// Screen dimensions
|
|
||||||
screen_dim: vec2<f32>,
|
|
||||||
// Kind-switch morph weight: each step is (1 - w)*f_kind + w*f_morph_from;
|
|
||||||
// 0 = no morph. Only read by MORPH pipelines (see mandelbrot.wgsl).
|
|
||||||
morph_w: f32,
|
|
||||||
// Deep views: binary exponent E of the view scale. `span` and `dc_offset`
|
|
||||||
// are uploaded multiplied by 2^-E so they stay in f32's range; 0 = not
|
|
||||||
// deep (plain f32 values). Only read by DEEP pipelines (mandelbrot.wgsl).
|
|
||||||
scale_exp: i32,
|
|
||||||
// Complex binomial coefficients C(complex_power, k) for k = 1..16, two per
|
|
||||||
// vec4 (k odd in .xy, k even in .zw), for the Complex Multibrot delta
|
|
||||||
// series. Precomputed on the CPU since they only depend on the power.
|
|
||||||
cm_coef: array<vec4<f32>, 8>,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id.
|
|
||||||
fn palette(id: u32, t: f32) -> vec3<f32> {
|
|
||||||
if id == 4u {
|
|
||||||
return vec3<f32>(t, t, t); // grayscale
|
|
||||||
}
|
|
||||||
let a = vec3<f32>(0.5, 0.5, 0.5);
|
|
||||||
let b = vec3<f32>(0.5, 0.5, 0.5);
|
|
||||||
var c = vec3<f32>(1.0, 1.0, 1.0);
|
|
||||||
var d = vec3<f32>(0.00, 0.10, 0.20); // 0: amber / blue
|
|
||||||
if id == 1u {
|
|
||||||
d = vec3<f32>(0.00, 0.33, 0.67); // rainbow
|
|
||||||
} else if id == 2u {
|
|
||||||
d = vec3<f32>(0.30, 0.20, 0.20); // warm ember
|
|
||||||
} else if id == 3u {
|
|
||||||
c = vec3<f32>(1.0, 1.0, 0.5);
|
|
||||||
d = vec3<f32>(0.80, 0.90, 0.30); // lime / magenta
|
|
||||||
}
|
|
||||||
return a + b * cos(6.28318530718 * (c * t + d));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Classic (non-shadow) escape colouring: palette lookup at the smoothed
|
|
||||||
// iteration count `ci`, darkened by the distance-estimate factor `de`
|
|
||||||
// (sqrt-compressed so the darkening falls off more gently near the
|
|
||||||
// boundary). Shared by the colourise pass's classic branch (colorize.wgsl,
|
|
||||||
// applied to an already-averaged data texel) and the PNG-export pass
|
|
||||||
// (mandelbrot.wgsl's `fs_color`, applied per sub-sample pre-AA) — the two
|
|
||||||
// places a fully escaped point is turned into a final pixel colour.
|
|
||||||
fn classic_color(ci: f32, de: f32) -> vec3<f32> {
|
|
||||||
let t = fract(ci * u.color_scale + u.color_offset);
|
|
||||||
return palette(u.palette_id, t) * sqrt(de);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A single directional light, built on the CPU from the UI's light list
|
|
||||||
// (`GpuLight` in lights.rs): `dir` is the unit direction toward the light
|
|
||||||
// (precomputed from azimuth/altitude so the shader does no trig), `color` a
|
|
||||||
// packed RGBA8 whose alpha doubles as intensity. Only the first
|
|
||||||
// `u.light_count` entries are live, all with a non-zero colour. Each shader
|
|
||||||
// that binds a `lights: array<Light, 16>` uniform (colorize.wgsl,
|
|
||||||
// mandelbrot.wgsl's export shadow path) uses this same layout.
|
|
||||||
struct Light {
|
|
||||||
dir: vec3<f32>,
|
|
||||||
color: u32,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Lambertian term for a unit `light` direction.
|
|
||||||
fn compute_light(normal: vec3<f32>, light: vec3<f32>) -> vec3<f32> {
|
|
||||||
return vec3<f32>(max(0., dot(normal, light)));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uncharted2tonemap(x: vec3<f32>) -> vec3<f32> {
|
|
||||||
let A = 0.15; // Shoulder strength
|
|
||||||
let B = 0.50; // Linear strength
|
|
||||||
let C = 0.10; // Linear angle
|
|
||||||
let D = 0.20; // Toe strength
|
|
||||||
let E = 0.02; // Toe numerator / shoarder angle/etc.
|
|
||||||
let F = 0.30; // Toe denominator
|
|
||||||
|
|
||||||
return ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn filmic(color: vec3<f32>, white_point: f32) -> vec3<f32> {
|
|
||||||
let exposure_bias = 2.0;
|
|
||||||
let curr = uncharted2tonemap(color * exposure_bias);
|
|
||||||
|
|
||||||
// Valeur blanche maximale de référence
|
|
||||||
let white_scale = vec3(1.0) / uncharted2tonemap(vec3(white_point));
|
|
||||||
return curr * white_scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn s(color: vec3<f32>, k: f32, c: f32) -> vec3<f32> {
|
|
||||||
return 1. / (1. + exp(-k * (color - c)));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn contrast(color: vec3<f32>, k: f32, c: f32) -> vec3<f32> {
|
|
||||||
let color_c = s(color, k, c);
|
|
||||||
|
|
||||||
return (color_c - s(vec3<f32>(0), k, c)) / (s(vec3<f32>(1), k, c) - s(vec3<f32>(0), k, c));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Surface normal from three height samples (`h0` at the pixel, `h1` one pixel
|
|
||||||
// to the right, `h2` one pixel down), treating DE as a height field. Only the
|
|
||||||
// differences matter, so callers don't need to pass pixel coordinates — a
|
|
||||||
// texture-backed caller (colorize.wgsl) and a live-sampled caller
|
|
||||||
// (mandelbrot.wgsl's export shadow path) can share this.
|
|
||||||
fn normal_from_heights(h0: f32, h1: f32, h2: f32) -> vec3<f32> {
|
|
||||||
let d0 = vec3<f32>(0.0, 0.0, h0);
|
|
||||||
let d1 = vec3<f32>(1.0, 0.0, h1);
|
|
||||||
let d2 = vec3<f32>(0.0, 1.0, h2);
|
|
||||||
return normalize(cross(d1 - d0, d2 - d0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shade a DE-derived surface normal per `u.shadow_palette_id`: 0 = grayscale
|
|
||||||
// key light, 1 = red/blue two-tone, 2 = the user's custom `lights` list,
|
|
||||||
// 3 = the classic escape-time palette at `ci` (the smoothed iteration count),
|
|
||||||
// lit by the grayscale key light.
|
|
||||||
// Shared by the interactive shadow pass (colorize.wgsl) and the PNG-export
|
|
||||||
// shadow path (mandelbrot.wgsl's `fs_color`), which must render identically.
|
|
||||||
fn shadow_color(normal: vec3<f32>, ci: f32) -> vec3<f32> {
|
|
||||||
var color: vec3<f32>;
|
|
||||||
if u.shadow_palette_id == 0u {
|
|
||||||
color = compute_light(normal, vec3<f32>(0.57735027, 0.57735027, 0.57735027)) + vec3<f32>(0.58, 0.85, 1.) * 0.2;
|
|
||||||
|
|
||||||
color = filmic(color, 2.5);
|
|
||||||
color = contrast(color, 4., 0.67);
|
|
||||||
} else if u.shadow_palette_id == 1u {
|
|
||||||
color = compute_light(normal, vec3<f32>(0., 0.70710678, 0.70710678)) * vec3<f32>(1., 0.5, 0.5) + compute_light(normal, vec3<f32>(0.70710678, 0., 0.70710678)) * vec3<f32>(0.5, 1., 1.);
|
|
||||||
|
|
||||||
color = filmic(color, 4.2);
|
|
||||||
} else if u.shadow_palette_id == 3u {
|
|
||||||
// No DE darkening as in `classic_color`: in shadow/3D modes the DE
|
|
||||||
// is a height (unclamped, not capped at 1), and the lighting already
|
|
||||||
// shows the relief.
|
|
||||||
let t = fract(ci * u.color_scale + u.color_offset);
|
|
||||||
let ambient = 0.25;
|
|
||||||
let light = compute_light(normal, vec3<f32>(0.57735027, 0.57735027, 0.57735027));
|
|
||||||
color = palette(u.palette_id, t) * (ambient + (1.0 - ambient) * light);
|
|
||||||
} else {
|
|
||||||
color = vec3<f32>(0);
|
|
||||||
let light_count = min(u.light_count, 16u);
|
|
||||||
for (var i = 0u; i < light_count; i++) {
|
|
||||||
let light_color = unpack4x8unorm(lights[i].color);
|
|
||||||
color += compute_light(normal, lights[i].dir) * light_color.xyz * light_color.a;
|
|
||||||
}
|
|
||||||
|
|
||||||
color = filmic(color, 1. + f32(light_count));
|
|
||||||
}
|
|
||||||
return color;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Colour of an interior (non-escaped) pixel in shadow/3D modes: black under
|
|
||||||
// the classic palette, like classic 2D mode, otherwise a dark gray plateau.
|
|
||||||
fn shadow_interior_color() -> vec3<f32> {
|
|
||||||
if u.shadow_palette_id == 3u {
|
|
||||||
return vec3<f32>(0.0);
|
|
||||||
}
|
|
||||||
return vec3<f32>(0.1);
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
+191
-1098
File diff suppressed because it is too large
Load Diff
+51
-511
@@ -1,304 +1,61 @@
|
|||||||
//! Camera / view state over the complex plane.
|
//! Camera / view state over the complex plane.
|
||||||
//!
|
//!
|
||||||
//! The center is stored in arbitrary precision ([`Big`]) — this is what lets us
|
//! The center is stored in arbitrary precision (`FBig`) — this is what lets us
|
||||||
//! zoom far past f64's ~1e13x limit. The pixel *scale* is a [`Scale`]: an
|
//! zoom far past f64's ~1e13x limit. The pixel *scale* stays `f64`: even at
|
||||||
//! f64 mantissa with its own `i32` binary exponent, so it isn't bound by
|
//! 10^30x zoom the scale is ~1e-33, comfortably inside f64's range. Only the
|
||||||
//! f64's ~1e-308 range either (the floor, `Scale::MIN`, only keeps the GPU's
|
//! center needs the extra digits.
|
||||||
//! i32 exponent arithmetic far from overflow). Once a pixel is smaller than
|
|
||||||
//! `DEEP_PIXEL_SIZE` the GPU switches to rescaled deltas (see `needs_deep`),
|
|
||||||
//! since f32 alone bottoms out near 1e-38.
|
|
||||||
|
|
||||||
use core::str::FromStr;
|
use core::str::FromStr;
|
||||||
|
|
||||||
pub use crate::bignum::Big;
|
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>;
|
||||||
|
|
||||||
/// Half-height (complex units) of the default view; also the zoom-1 reference.
|
/// Half-height (complex units) of the default view; also the zoom-1 reference.
|
||||||
pub const DEFAULT_HALF_HEIGHT: f64 = 1.25;
|
pub const DEFAULT_HALF_HEIGHT: f64 = 1.25;
|
||||||
|
|
||||||
/// Below this pixel size (complex units per pixel) the GPU renders with the
|
|
||||||
/// deep pipeline, whose per-pixel deltas start out as an f32 mantissa times
|
|
||||||
/// `2^scale_exp`. Plain f32 stays exact as long as the smallest per-pixel
|
|
||||||
/// offsets (a quarter pixel, for the AA grid) are normal floats (>= 2^-126),
|
|
||||||
/// i.e. down to 2^-124 per pixel. Measured: pixel-identical to the deep path
|
|
||||||
/// down to 2^-124 (no AA), first errors at 2^-126, all black by 2^-136. The
|
|
||||||
/// deep path is slower, so the switch is as late as that allows, with two
|
|
||||||
/// binades of margin.
|
|
||||||
pub const DEEP_PIXEL_SIZE: f64 = 1.0 / (1u128 << 122) as f64; // 2^-122
|
|
||||||
|
|
||||||
/// Whether a view rendered `height_px` pixels tall needs the deep pipeline
|
|
||||||
/// (see `DEEP_PIXEL_SIZE`).
|
|
||||||
pub fn needs_deep(half_height: Scale, height_px: f64) -> bool {
|
|
||||||
half_height.mul_f64(2.0 / height_px.max(1.0)) < Scale::from_f64(DEEP_PIXEL_SIZE)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Binary exponent `E` of the deep view scale: `floor(log2(half_height))`,
|
|
||||||
/// so the rescaled span is in `[2, 4)`. Never 0, which means "not deep"
|
|
||||||
/// (see `Uniforms::scale_exp`).
|
|
||||||
pub fn deep_scale_exp(half_height: Scale) -> i32 {
|
|
||||||
let e = half_height.exponent();
|
|
||||||
if e == 0 { -1 } else { e }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `x * 2^k` for any `k`, saturating to 0 / infinity like the true value
|
|
||||||
/// would (`powi` alone overflows at 2^±1024 even when the product fits).
|
|
||||||
fn ldexp(mut x: f64, mut k: i32) -> f64 {
|
|
||||||
while k > 1000 {
|
|
||||||
x *= 2f64.powi(1000);
|
|
||||||
k -= 1000;
|
|
||||||
if x.is_infinite() || x == 0.0 {
|
|
||||||
return x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while k < -1000 {
|
|
||||||
x *= 2f64.powi(-1000);
|
|
||||||
k += 1000;
|
|
||||||
if x == 0.0 || x.is_infinite() {
|
|
||||||
return x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
x * 2f64.powi(k)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A positive real with f64 precision and an `i32` binary exponent:
|
|
||||||
/// `m · 2^e`, `m` in `[1, 2)`. The view's half-height (and the pixel size
|
|
||||||
/// derived from it) is one of these, so zoom isn't bound by f64's range.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub struct Scale {
|
|
||||||
m: f64,
|
|
||||||
e: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Scale {
|
|
||||||
/// Deepest scale the view can reach: 2^-(2^20) (about 1e-315653). Far
|
|
||||||
/// past anything a reference orbit can practically be computed for; it
|
|
||||||
/// only keeps the shader's i32 exponent sums (scale × degree) from
|
|
||||||
/// overflowing.
|
|
||||||
pub const MIN: Scale = Scale {
|
|
||||||
m: 1.0,
|
|
||||||
e: -(1 << 20),
|
|
||||||
};
|
|
||||||
|
|
||||||
/// `m · 2^e`, normalized. Non-positive or NaN input gives `MIN`.
|
|
||||||
pub fn from_parts(m: f64, e: i32) -> Self {
|
|
||||||
if m.is_nan() || m <= 0.0 {
|
|
||||||
return Self::MIN;
|
|
||||||
}
|
|
||||||
if m.is_infinite() {
|
|
||||||
return Scale {
|
|
||||||
m: 1.0,
|
|
||||||
e: i32::MAX / 2,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Bring m into [1, 2) through its own binary exponent (exact).
|
|
||||||
let k = m.log2().floor() as i32;
|
|
||||||
let mut m = ldexp(m, -k);
|
|
||||||
let mut e = e.saturating_add(k);
|
|
||||||
// log2 can round across a power of two.
|
|
||||||
if m >= 2.0 {
|
|
||||||
m /= 2.0;
|
|
||||||
e = e.saturating_add(1);
|
|
||||||
} else if m < 1.0 {
|
|
||||||
m *= 2.0;
|
|
||||||
e = e.saturating_sub(1);
|
|
||||||
}
|
|
||||||
Scale { m, e }.max(Self::MIN)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_f64(x: f64) -> Self {
|
|
||||||
Self::from_parts(x, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `2^l`.
|
|
||||||
pub fn from_log2(l: f64) -> Self {
|
|
||||||
let e = l.floor();
|
|
||||||
Self::from_parts((l - e).exp2(), e as i32)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The value as an f64 (0 or infinity outside its range).
|
|
||||||
pub fn to_f64(self) -> f64 {
|
|
||||||
ldexp(self.m, self.e)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `self · 2^k` as an f64: the value in units of `2^-k`.
|
|
||||||
pub fn scaled_f64(self, k: i32) -> f64 {
|
|
||||||
ldexp(self.m, self.e.saturating_add(k))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `floor(log2(self))`.
|
|
||||||
pub fn exponent(self) -> i32 {
|
|
||||||
self.e
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn log2(self) -> f64 {
|
|
||||||
self.m.log2() + self.e as f64
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn log10(self) -> f64 {
|
|
||||||
self.log2() * core::f64::consts::LOG10_2
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `self · f` (`f > 0`).
|
|
||||||
pub fn mul_f64(self, f: f64) -> Self {
|
|
||||||
Self::from_parts(self.m * f, self.e)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `self / other`, as an f64.
|
|
||||||
pub fn ratio(self, other: Scale) -> f64 {
|
|
||||||
ldexp(self.m / other.m, self.e.saturating_sub(other.e))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn max(self, other: Scale) -> Self {
|
|
||||||
if other > self { other } else { self }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn min(self, other: Scale) -> Self {
|
|
||||||
if other < self { other } else { self }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clamp(self, lo: Scale, hi: Scale) -> Self {
|
|
||||||
self.max(lo).min(hi)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `f · self` as an exact `Big` at `bits` of precision (`f` any f64).
|
|
||||||
pub fn big_times(self, f: f64, bits: usize) -> Big {
|
|
||||||
big_from_f64(f * self.m, bits) << self.e as isize
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exact binary value as a `Big`.
|
|
||||||
fn to_big(self) -> Big {
|
|
||||||
big_from_f64(self.m, 53) << self.e as isize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PartialOrd for Scale {
|
|
||||||
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
|
|
||||||
// Normalized and positive: the exponent decides, then the mantissa.
|
|
||||||
Some(self.e.cmp(&other.e).then(self.m.partial_cmp(&other.m)?))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl core::fmt::Display for Scale {
|
|
||||||
/// Scientific notation, `1.5e-20` / `3.7e-4000`. The precision flag
|
|
||||||
/// (`{:.4}`) sets mantissa digits after the point; without it, enough
|
|
||||||
/// digits to parse back to the same value.
|
|
||||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
|
||||||
let x = self.to_f64();
|
|
||||||
if x.is_normal() {
|
|
||||||
return match f.precision() {
|
|
||||||
Some(p) => write!(f, "{x:.p$e}"),
|
|
||||||
None => write!(f, "{x:e}"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Out of f64's range: round the exact decimal expansion instead.
|
|
||||||
let big = self.to_big();
|
|
||||||
let sci = |sig: usize, pad: Option<usize>| {
|
|
||||||
let parts = big.to_decimal_parts(sig);
|
|
||||||
let digits = if parts.digits.is_empty() {
|
|
||||||
"0"
|
|
||||||
} else {
|
|
||||||
&parts.digits
|
|
||||||
};
|
|
||||||
// value = 0.digits · 10^exp10; move the point after the first digit.
|
|
||||||
let exp10 = parts.exp10 - 1;
|
|
||||||
let (head, tail) = digits.split_at(1);
|
|
||||||
let tail = match pad {
|
|
||||||
Some(p) => format!("{tail:0<p$}"),
|
|
||||||
None => tail.to_string(),
|
|
||||||
};
|
|
||||||
if tail.is_empty() {
|
|
||||||
format!("{head}e{exp10}")
|
|
||||||
} else {
|
|
||||||
format!("{head}.{tail}e{exp10}")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match f.precision() {
|
|
||||||
Some(p) => f.write_str(&sci(p + 1, Some(p))),
|
|
||||||
None => {
|
|
||||||
// Like `{:e}` on f64: the shortest string that parses back.
|
|
||||||
let s = (1..17)
|
|
||||||
.map(|sig| sci(sig, None))
|
|
||||||
.find(|s| s.parse::<Scale>() == Ok(*self))
|
|
||||||
.unwrap_or_else(|| sci(17, None));
|
|
||||||
f.write_str(&s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for Scale {
|
|
||||||
type Err = ();
|
|
||||||
|
|
||||||
/// Parses any positive decimal (`1.25`, `1.5e-20`, `3.7e-4000`), rounding
|
|
||||||
/// to the nearest f64 mantissa. Values past `MIN` clamp to it.
|
|
||||||
fn from_str(s: &str) -> Result<Self, ()> {
|
|
||||||
let s = s.trim();
|
|
||||||
if let Ok(x) = s.parse::<f64>()
|
|
||||||
&& x.is_normal()
|
|
||||||
{
|
|
||||||
return if x > 0.0 {
|
|
||||||
Ok(Self::from_f64(x))
|
|
||||||
} else {
|
|
||||||
Err(())
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Too small (or large) for f64: go through an exact decimal.
|
|
||||||
let bin = Big::from_decimal_str(s, 64).ok_or(())?;
|
|
||||||
if bin.is_negative() {
|
|
||||||
return Err(());
|
|
||||||
}
|
|
||||||
let top = bin.log2_floor().ok_or(())?;
|
|
||||||
let m = (bin >> top).to_f64();
|
|
||||||
let e = top.clamp(i32::MIN as isize, i32::MAX as isize) as i32;
|
|
||||||
Ok(Self::from_parts(m, e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Guard bits added on top of the zoom-dictated precision.
|
/// Guard bits added on top of the zoom-dictated precision.
|
||||||
const GUARD_BITS: usize = 48;
|
const GUARD_BITS: usize = 48;
|
||||||
/// Upper bound on center precision: what `Scale::MIN` needs. Only a guard
|
/// Upper bound on center precision (f32 GPU perturbation degrades long before
|
||||||
/// against pathological input; the reference orbit is impractically slow
|
/// this; the cap just prevents pathological allocation).
|
||||||
/// long before this.
|
const MAX_PRECISION_BITS: usize = 2048;
|
||||||
pub const MAX_PRECISION_BITS: usize = (1 << 20) + GUARD_BITS;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ViewState {
|
pub struct ViewState {
|
||||||
pub center_re: Big,
|
pub center_re: Big,
|
||||||
pub center_im: Big,
|
pub center_im: Big,
|
||||||
/// Half the view height in complex-plane units. Zooming in shrinks this.
|
/// Half the view height in complex-plane units. Zooming in shrinks this.
|
||||||
pub half_height: Scale,
|
pub half_height: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ViewState {
|
impl Default for ViewState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let bits = precision_for(Scale::from_f64(DEFAULT_HALF_HEIGHT));
|
let bits = precision_for(DEFAULT_HALF_HEIGHT);
|
||||||
Self {
|
Self {
|
||||||
center_re: big_from_f64(-0.5, bits),
|
center_re: big_from_f64(-0.5, bits),
|
||||||
center_im: big_from_f64(0.0, bits),
|
center_im: big_from_f64(0.0, bits),
|
||||||
half_height: Scale::from_f64(DEFAULT_HALF_HEIGHT),
|
half_height: DEFAULT_HALF_HEIGHT,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ViewState {
|
impl ViewState {
|
||||||
/// Complex-plane span (width, height) for the given pixel aspect ratio.
|
/// Complex-plane span (width, height) for the given pixel aspect ratio.
|
||||||
pub fn span(&self, aspect: f64) -> (Scale, Scale) {
|
pub fn span(&self, aspect: f64) -> (f64, f64) {
|
||||||
let h = self.half_height.mul_f64(2.0);
|
let h = self.half_height * 2.0;
|
||||||
(h.mul_f64(aspect), h)
|
(h * aspect, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Complex-plane units per pixel, given the viewport height in pixels.
|
/// Complex-plane units per pixel, given the viewport height in pixels.
|
||||||
pub fn complex_per_pixel(&self, height_px: f64) -> Scale {
|
pub fn complex_per_pixel(&self, height_px: f64) -> f64 {
|
||||||
self.half_height.mul_f64(2.0 / height_px)
|
(self.half_height * 2.0) / height_px
|
||||||
}
|
}
|
||||||
|
|
||||||
/// log10 of the current magnification relative to the default view.
|
/// Current magnification relative to the default view.
|
||||||
pub fn magnification_log10(&self) -> f64 {
|
pub fn magnification(&self) -> f64 {
|
||||||
DEFAULT_HALF_HEIGHT.log10() - self.half_height.log10()
|
DEFAULT_HALF_HEIGHT / self.half_height
|
||||||
}
|
|
||||||
|
|
||||||
/// Current zoom level.
|
|
||||||
pub fn zoom(&self) -> Scale {
|
|
||||||
self.half_height
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bits of precision the center currently needs for this zoom level.
|
/// Bits of precision the center currently needs for this zoom level.
|
||||||
@@ -311,10 +68,10 @@ impl ViewState {
|
|||||||
pub fn sync_precision(&mut self) {
|
pub fn sync_precision(&mut self) {
|
||||||
let bits = self.precision_bits();
|
let bits = self.precision_bits();
|
||||||
if self.center_re.precision() < bits {
|
if self.center_re.precision() < bits {
|
||||||
self.center_re = self.center_re.clone().with_precision(bits);
|
self.center_re = self.center_re.clone().with_precision(bits).value();
|
||||||
}
|
}
|
||||||
if self.center_im.precision() < bits {
|
if self.center_im.precision() < bits {
|
||||||
self.center_im = self.center_im.clone().with_precision(bits);
|
self.center_im = self.center_im.clone().with_precision(bits).value();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,8 +81,8 @@ impl ViewState {
|
|||||||
let cpp = self.complex_per_pixel(height_px);
|
let cpp = self.complex_per_pixel(height_px);
|
||||||
let bits = self.precision_bits();
|
let bits = self.precision_bits();
|
||||||
// Grab-and-drag: moving the mouse right shows content to the left.
|
// Grab-and-drag: moving the mouse right shows content to the left.
|
||||||
self.center_re = &self.center_re - &cpp.big_times(dx, bits);
|
self.center_re = &self.center_re - &big_from_f64(dx * cpp, bits);
|
||||||
self.center_im = &self.center_im - &cpp.big_times(dy, bits);
|
self.center_im = &self.center_im - &big_from_f64(dy * cpp, bits);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zoom by `factor` (<1 zooms in) keeping the complex point currently under
|
/// Zoom by `factor` (<1 zooms in) keeping the complex point currently under
|
||||||
@@ -338,14 +95,14 @@ impl ViewState {
|
|||||||
// The cursor's complex offset from the center is (off * cpp). Keeping it
|
// The cursor's complex offset from the center is (off * cpp). Keeping it
|
||||||
// fixed while scaling the view by `factor` moves the center by
|
// fixed while scaling the view by `factor` moves the center by
|
||||||
// off * cpp * (1 - factor). (Derivation: new_c = fixed + (c-fixed)*f.)
|
// off * cpp * (1 - factor). (Derivation: new_c = fixed + (c-fixed)*f.)
|
||||||
let k = 1.0 - factor;
|
let k = cpp * (1.0 - factor);
|
||||||
self.center_re = &self.center_re + &cpp.big_times(off_x * k, bits);
|
self.center_re = &self.center_re + &big_from_f64(off_x * k, bits);
|
||||||
self.center_im = &self.center_im + &cpp.big_times(off_y * k, bits);
|
self.center_im = &self.center_im + &big_from_f64(off_y * k, bits);
|
||||||
self.half_height = self.half_height.mul_f64(factor);
|
self.half_height *= factor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a view from full-precision center coordinates and a half-height.
|
/// Build a view from full-precision center coordinates and a half-height.
|
||||||
pub fn with_center(center_re: Big, center_im: Big, half_height: Scale) -> Self {
|
pub fn with_center(center_re: Big, center_im: Big, half_height: f64) -> Self {
|
||||||
let mut v = Self {
|
let mut v = Self {
|
||||||
center_re,
|
center_re,
|
||||||
center_im,
|
center_im,
|
||||||
@@ -359,253 +116,36 @@ impl ViewState {
|
|||||||
/// Parse a decimal string (any number of digits) losslessly into a `Big` with at
|
/// Parse a decimal string (any number of digits) losslessly into a `Big` with at
|
||||||
/// least `bits` of precision. Used for share links and debug view specs.
|
/// least `bits` of precision. Used for share links and debug view specs.
|
||||||
pub fn big_from_decimal_str(s: &str, bits: usize) -> Option<Big> {
|
pub fn big_from_decimal_str(s: &str, bits: usize) -> Option<Big> {
|
||||||
Big::from_decimal_str(s, bits)
|
let dec = DBig::from_str(s.trim()).ok()?;
|
||||||
}
|
Some(dec.with_base_and_precision::<2>(bits.max(53)).value())
|
||||||
|
|
||||||
/// Parse a "re,im,half_height[,iterations]" spec (re/im decimal, parsed at
|
|
||||||
/// full precision) into a view and an optional iteration count. Shared by
|
|
||||||
/// `FractalApp::apply_view_spec` (the `--view` CLI flag) and headless
|
|
||||||
/// animation's `--to-view`.
|
|
||||||
pub fn parse_view_spec(spec: &str) -> Option<(ViewState, Option<u32>)> {
|
|
||||||
let parts: Vec<&str> = spec.split(',').collect();
|
|
||||||
if parts.len() < 3 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let half_height = parse_half_height_spec(parts[2])?;
|
|
||||||
let bits = precision_for(half_height);
|
|
||||||
let re = big_from_decimal_str(parts[0], bits)?;
|
|
||||||
let im = big_from_decimal_str(parts[1], bits)?;
|
|
||||||
let iterations = parts.get(3).and_then(|s| s.trim().parse::<u32>().ok());
|
|
||||||
Some((ViewState::with_center(re, im, half_height), iterations))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a half_height spec. Shared by
|
|
||||||
/// `FractalApp::apply_half_height_spec` (the `--zoom` CLI flag) and headless
|
|
||||||
/// animation's `--to-zoom`.
|
|
||||||
pub fn parse_half_height_spec(spec: &str) -> Option<Scale> {
|
|
||||||
spec.parse::<Scale>().ok()
|
|
||||||
}
|
|
||||||
/// Parse a "re,im" spec (re/im decimal, parsed at
|
|
||||||
/// full precision) into a view. Shared by
|
|
||||||
/// `FractalApp::apply_re_im_spec` (the `--position` CLI flag) and headless
|
|
||||||
/// animation's `--to-position`.
|
|
||||||
pub fn parse_re_im_spec(spec: &str, bits: usize) -> Option<(Big, Big)> {
|
|
||||||
let parts: Vec<&str> = spec.split(',').collect();
|
|
||||||
if parts.len() != 2 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let re = big_from_decimal_str(parts[0], bits)?;
|
|
||||||
let im = big_from_decimal_str(parts[1], bits)?;
|
|
||||||
Some((re, im))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Interpolate between two views for an animation frame, `t` in `[0, 1]`.
|
|
||||||
/// The half-height interpolates geometrically (log-linear), since zoom depth
|
|
||||||
/// spans many decades and a linear sweep would crawl at the start and blow
|
|
||||||
/// past the target at the end. The center has to shrink its offset from the
|
|
||||||
/// target at that *same* geometric rate: blending it linearly in `t` instead
|
|
||||||
/// barely moves it while the view is still huge (early frames), so the
|
|
||||||
/// target stays effectively off-screen — offset/half_height ratio blows up —
|
|
||||||
/// for nearly the whole animation, and only lands on `to`'s center in the
|
|
||||||
/// literal last frame where `t == 1` forces an exact match. `g(t)` below
|
|
||||||
/// tracks the same `q^t` decay used for `half_height` (keeping the
|
|
||||||
/// offset/half_height ratio roughly constant, i.e. the target's on-screen
|
|
||||||
/// position steady) but is shifted so it lands on exactly 1 at `t = 0` and
|
|
||||||
/// exactly 0 at `t = 1`.
|
|
||||||
///
|
|
||||||
/// With `d = log2(q)`, `g = q^t · (1 - q^(1-t)) / (1 - q)`, all in `Scale`
|
|
||||||
/// / `expm1` form: zooming in by more than f64's range, `q` (and `q^t`)
|
|
||||||
/// underflow, yet `g · (from - to)` must keep tracking the half-height.
|
|
||||||
pub fn interpolate_view(from: &ViewState, to: &ViewState, t: f64) -> ViewState {
|
|
||||||
let (l0, l1) = (from.half_height.log2(), to.half_height.log2());
|
|
||||||
let d = l1 - l0;
|
|
||||||
let half_height = if t <= 0.0 {
|
|
||||||
from.half_height
|
|
||||||
} else if t >= 1.0 {
|
|
||||||
to.half_height
|
|
||||||
} else {
|
|
||||||
Scale::from_log2(l0 + t * d)
|
|
||||||
};
|
|
||||||
let bits = precision_for(half_height);
|
|
||||||
let ln2 = core::f64::consts::LN_2;
|
|
||||||
let g_big = if d.abs() < 1e-12 {
|
|
||||||
big_from_f64(1.0 - t, bits)
|
|
||||||
} else if d < 0.0 {
|
|
||||||
// Zooming in: q^t may be far below f64's range, keep it as a Scale.
|
|
||||||
let f = ((1.0 - t) * d * ln2).exp_m1() / (d * ln2).exp_m1();
|
|
||||||
Scale::from_log2(t * d).big_times(f, bits)
|
|
||||||
} else {
|
|
||||||
// Zooming out: g = (1 - q^(t-1)) / (1 - q^-1), every term bounded.
|
|
||||||
big_from_f64(((t - 1.0) * d * ln2).exp_m1() / (-d * ln2).exp_m1(), bits)
|
|
||||||
};
|
|
||||||
let re0 = from.center_re.clone().with_precision(bits);
|
|
||||||
let im0 = from.center_im.clone().with_precision(bits);
|
|
||||||
let re1 = to.center_re.clone().with_precision(bits);
|
|
||||||
let im1 = to.center_im.clone().with_precision(bits);
|
|
||||||
let center_re = &re1 + &(&(&re0 - &re1) * &g_big);
|
|
||||||
let center_im = &im1 + &(&(&im0 - &im1) * &g_big);
|
|
||||||
ViewState::with_center(center_re, center_im, half_height)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub fn interpolate_f64(from: f64, to: f64, t: f64) -> f64 {
|
|
||||||
from + (to - from) * t
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a `Big` as a decimal string with `sig_digits` significant digits.
|
/// Render a `Big` as a decimal string with `sig_digits` significant digits.
|
||||||
pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String {
|
pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String {
|
||||||
x.to_decimal_string(sig_digits)
|
let dec = x
|
||||||
|
.to_decimal()
|
||||||
|
.value()
|
||||||
|
.with_precision(sig_digits.max(1))
|
||||||
|
.value();
|
||||||
|
format!("{dec}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Precision (bits) needed to resolve the center at a given half-height.
|
/// Precision (bits) needed to resolve the center at a given half-height.
|
||||||
pub fn precision_for(half_height: Scale) -> usize {
|
pub fn precision_for(half_height: f64) -> usize {
|
||||||
// We need enough bits to distinguish points a pixel apart, i.e. roughly
|
// We need enough bits to distinguish points a pixel apart, i.e. roughly
|
||||||
// log2(1 / half_height) significant bits, plus a guard margin.
|
// log2(1 / half_height) significant bits, plus a guard margin.
|
||||||
let zoom_bits = (-half_height.log2()).ceil().max(0.0) as usize;
|
let zoom_bits = if half_height > 0.0 && half_height.is_finite() {
|
||||||
|
(-half_height.log2()).ceil().max(0.0) as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
(zoom_bits + GUARD_BITS).clamp(53, MAX_PRECISION_BITS)
|
(zoom_bits + GUARD_BITS).clamp(53, MAX_PRECISION_BITS)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `Big` from an f64 with an explicit precision.
|
/// Build an `FBig` from an f64 with an explicit precision context.
|
||||||
pub fn big_from_f64(x: f64, bits: usize) -> Big {
|
pub fn big_from_f64(x: f64, bits: usize) -> Big {
|
||||||
Big::from_f64(x, bits)
|
Big::try_from(x)
|
||||||
}
|
.unwrap_or_default()
|
||||||
|
.with_precision(bits)
|
||||||
#[cfg(test)]
|
.value()
|
||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-21
@@ -1,7 +1,7 @@
|
|||||||
//! Native background worker for reference-orbit computation.
|
//! Native background worker for reference-orbit computation.
|
||||||
//!
|
//!
|
||||||
//! At deep zoom the high-precision reference can take many milliseconds (tens of
|
//! At deep zoom the high-precision reference can take many milliseconds (tens of
|
||||||
//! thousands of `Big` iterations), which would stutter the UI if done inline.
|
//! thousands of `FBig` iterations), which would stutter the UI if done inline.
|
||||||
//! This runs it on a thread and coalesces bursts of requests (e.g. during a
|
//! This runs it on a thread and coalesces bursts of requests (e.g. during a
|
||||||
//! drag) down to the most recent one. On the web we compute inline instead
|
//! drag) down to the most recent one. On the web we compute inline instead
|
||||||
//! (browsers need a Web Worker for threads); see `app.rs`.
|
//! (browsers need a Web Worker for threads); see `app.rs`.
|
||||||
@@ -9,13 +9,13 @@
|
|||||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
|
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
use crate::fractal::{FractalKind, RefOrbit, compute_reference, compute_set_reference};
|
use crate::fractal::{FractalKind, compute_reference, compute_set_reference};
|
||||||
use crate::view::{Big, Scale, big_from_f64};
|
use crate::view::{Big, big_from_f64};
|
||||||
|
|
||||||
pub struct RefRequest {
|
pub struct RefRequest {
|
||||||
pub center_re: Big,
|
pub center_re: Big,
|
||||||
pub center_im: Big,
|
pub center_im: Big,
|
||||||
pub half_height: Scale,
|
pub half_height: f64,
|
||||||
pub julia: bool,
|
pub julia: bool,
|
||||||
pub julia_c: (f64, f64),
|
pub julia_c: (f64, f64),
|
||||||
pub max_iter: u32,
|
pub max_iter: u32,
|
||||||
@@ -26,21 +26,13 @@ pub struct RefRequest {
|
|||||||
pub phoenix_p: (f64, f64),
|
pub phoenix_p: (f64, f64),
|
||||||
/// Distortion constant for the Lambda map (ignored by other kinds).
|
/// Distortion constant for the Lambda map (ignored by other kinds).
|
||||||
pub lambda_l: (f64, f64),
|
pub lambda_l: (f64, f64),
|
||||||
/// Complex exponent for the Complex Multibrot kind (ignored by other kinds).
|
|
||||||
pub complex_power: (f64, f64),
|
|
||||||
/// Kind-switch morph: `(from_kind, weight)` blended into every step.
|
|
||||||
pub morph: Option<(FractalKind, f32)>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RefResult {
|
pub struct RefResult {
|
||||||
pub center_re: Big,
|
pub center_re: Big,
|
||||||
pub center_im: Big,
|
pub center_im: Big,
|
||||||
pub half_height: Scale,
|
pub half_height: f64,
|
||||||
pub points: RefOrbit,
|
pub points: Vec<[f32; 2]>,
|
||||||
/// The kind and morph `points` was computed with (echoed from the
|
|
||||||
/// request).
|
|
||||||
pub kind: FractalKind,
|
|
||||||
pub morph: Option<(FractalKind, f32)>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RefWorker {
|
pub struct RefWorker {
|
||||||
@@ -74,8 +66,6 @@ impl RefWorker {
|
|||||||
center_im: req.center_im,
|
center_im: req.center_im,
|
||||||
half_height: req.half_height,
|
half_height: req.half_height,
|
||||||
points,
|
points,
|
||||||
kind: req.kind,
|
|
||||||
morph: req.morph,
|
|
||||||
})
|
})
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
@@ -102,7 +92,7 @@ impl RefWorker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compute(req: &RefRequest) -> RefOrbit {
|
fn compute(req: &RefRequest) -> Vec<[f32; 2]> {
|
||||||
if req.julia {
|
if req.julia {
|
||||||
let jr = big_from_f64(req.julia_c.0, req.precision);
|
let jr = big_from_f64(req.julia_c.0, req.precision);
|
||||||
let ji = big_from_f64(req.julia_c.1, req.precision);
|
let ji = big_from_f64(req.julia_c.1, req.precision);
|
||||||
@@ -117,8 +107,6 @@ fn compute(req: &RefRequest) -> RefOrbit {
|
|||||||
req.power,
|
req.power,
|
||||||
req.phoenix_p,
|
req.phoenix_p,
|
||||||
req.lambda_l,
|
req.lambda_l,
|
||||||
req.complex_power,
|
|
||||||
req.morph.map(|(k, w)| (k, w as f64)),
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
compute_set_reference(
|
compute_set_reference(
|
||||||
@@ -130,8 +118,6 @@ fn compute(req: &RefRequest) -> RefOrbit {
|
|||||||
req.power,
|
req.power,
|
||||||
req.phoenix_p,
|
req.phoenix_p,
|
||||||
req.lambda_l,
|
req.lambda_l,
|
||||||
req.complex_power,
|
|
||||||
req.morph.map(|(k, w)| (k, w as f64)),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-140
@@ -3,7 +3,7 @@
|
|||||||
//! shader with the same `naga` version wgpu uses — catching shader errors
|
//! shader with the same `naga` version wgpu uses — catching shader errors
|
||||||
//! without needing a GPU or a display.
|
//! without needing a GPU or a display.
|
||||||
|
|
||||||
fn validate(name: &str, src: &str) -> (naga::Module, naga::valid::ModuleInfo) {
|
fn validate(name: &str, src: &str) {
|
||||||
let module = match naga::front::wgsl::parse_str(src) {
|
let module = match naga::front::wgsl::parse_str(src) {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(e) => panic!("{name}: WGSL parse error:\n{}", e.emit_to_string(src)),
|
Err(e) => panic!("{name}: WGSL parse error:\n{}", e.emit_to_string(src)),
|
||||||
@@ -12,160 +12,33 @@ fn validate(name: &str, src: &str) -> (naga::Module, naga::valid::ModuleInfo) {
|
|||||||
naga::valid::ValidationFlags::all(),
|
naga::valid::ValidationFlags::all(),
|
||||||
naga::valid::Capabilities::all(),
|
naga::valid::Capabilities::all(),
|
||||||
);
|
);
|
||||||
match validator.validate(&module) {
|
if let Err(e) = validator.validate(&module) {
|
||||||
Ok(info) => (module, info),
|
panic!("{name}: WGSL validation error:\n{}", e.emit_to_string(src));
|
||||||
Err(e) => panic!("{name}: WGSL validation error:\n{}", e.emit_to_string(src)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of fractal kinds, i.e. the `const KIND_*` declarations in
|
|
||||||
/// common.wgsl (one per `FractalKind` variant, values 0..N).
|
|
||||||
fn kind_count() -> u32 {
|
|
||||||
let n = include_str!("../src/shaders/common.wgsl")
|
|
||||||
.lines()
|
|
||||||
.filter(|l| l.starts_with("const KIND_"))
|
|
||||||
.count() as u32;
|
|
||||||
assert!(n >= 10, "found only {n} KIND_* constants in common.wgsl");
|
|
||||||
n
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Specialize `module`'s `override`s with `constants` for `entry_point` (as
|
|
||||||
/// wgpu does at pipeline creation) and compile the result to SPIR-V, so a
|
|
||||||
/// shader that only breaks once a particular override value folds a branch
|
|
||||||
/// in or out is still caught.
|
|
||||||
fn specialize(
|
|
||||||
name: &str,
|
|
||||||
module: &naga::Module,
|
|
||||||
info: &naga::valid::ModuleInfo,
|
|
||||||
stage: naga::ShaderStage,
|
|
||||||
entry_point: &str,
|
|
||||||
constants: &[(&str, f64)],
|
|
||||||
) {
|
|
||||||
let mut pc = naga::back::PipelineConstants::default();
|
|
||||||
for (k, v) in constants {
|
|
||||||
pc.insert((*k).to_string(), *v);
|
|
||||||
}
|
|
||||||
let (module, info) = naga::back::pipeline_constants::process_overrides(
|
|
||||||
module,
|
|
||||||
info,
|
|
||||||
Some((stage, entry_point)),
|
|
||||||
&pc,
|
|
||||||
)
|
|
||||||
.unwrap_or_else(|e| panic!("{name} {entry_point} {constants:?}: override error: {e:?}"));
|
|
||||||
let pipeline = naga::back::spv::PipelineOptions {
|
|
||||||
shader_stage: stage,
|
|
||||||
entry_point: entry_point.to_string(),
|
|
||||||
};
|
|
||||||
naga::back::spv::write_vec(
|
|
||||||
&module,
|
|
||||||
&info,
|
|
||||||
&naga::back::spv::Options::default(),
|
|
||||||
Some(&pipeline),
|
|
||||||
)
|
|
||||||
.unwrap_or_else(|e| panic!("{name} {entry_point} {constants:?}: SPIR-V error: {e:?}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const MANDELBROT_SRC: &str = concat!(
|
|
||||||
include_str!("../src/shaders/common.wgsl"),
|
|
||||||
include_str!("../src/shaders/iterate_uniforms.wgsl"),
|
|
||||||
include_str!("../src/shaders/mandelbrot.wgsl"),
|
|
||||||
);
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mandelbrot_shader_is_valid() {
|
fn mandelbrot_shader_is_valid() {
|
||||||
validate("mandelbrot.wgsl", MANDELBROT_SRC);
|
validate(
|
||||||
}
|
"mandelbrot.wgsl",
|
||||||
|
include_str!("../src/shaders/mandelbrot.wgsl"),
|
||||||
/// Every specialization renderer.rs can build (`PipelineKey`: kind × Julia ×
|
);
|
||||||
/// DE × morph × deep), for every fragment entry point.
|
|
||||||
#[test]
|
|
||||||
fn mandelbrot_shader_specializations_compile() {
|
|
||||||
let (module, info) = validate("mandelbrot.wgsl", MANDELBROT_SRC);
|
|
||||||
for kind in 0..kind_count() {
|
|
||||||
for julia in [0.0, 1.0] {
|
|
||||||
for de in [0.0, 1.0] {
|
|
||||||
for morph in [0.0, 1.0] {
|
|
||||||
for deep in [0.0, 1.0] {
|
|
||||||
let constants = [
|
|
||||||
("KIND", kind as f64),
|
|
||||||
("IS_JULIA", julia),
|
|
||||||
("DE", de),
|
|
||||||
("MORPH", morph),
|
|
||||||
("DEEP", deep),
|
|
||||||
];
|
|
||||||
for entry in ["fs_data", "fs_refine", "fs_color"] {
|
|
||||||
specialize(
|
|
||||||
"mandelbrot.wgsl",
|
|
||||||
&module,
|
|
||||||
&info,
|
|
||||||
naga::ShaderStage::Fragment,
|
|
||||||
entry,
|
|
||||||
&constants,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn colorize_shader_is_valid() {
|
fn colorize_shader_is_valid() {
|
||||||
validate(
|
validate("colorize.wgsl", include_str!("../src/shaders/colorize.wgsl"));
|
||||||
"colorize.wgsl",
|
|
||||||
concat!(
|
|
||||||
include_str!("../src/shaders/common.wgsl"),
|
|
||||||
include_str!("../src/shaders/iterate_uniforms.wgsl"),
|
|
||||||
include_str!("../src/shaders/colorize.wgsl"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lipschitz_shader_is_valid() {
|
|
||||||
validate(
|
|
||||||
"lipschitz.wgsl",
|
|
||||||
concat!(
|
|
||||||
include_str!("../src/shaders/common.wgsl"),
|
|
||||||
include_str!("../src/shaders/lipschitz.wgsl"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blit_shader_is_valid() {
|
fn blit_shader_is_valid() {
|
||||||
validate(
|
validate("blit.wgsl", include_str!("../src/shaders/blit.wgsl"));
|
||||||
"blit.wgsl",
|
|
||||||
concat!(
|
|
||||||
include_str!("../src/shaders/common.wgsl"),
|
|
||||||
include_str!("../src/shaders/blit.wgsl"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const BUDDHABROT_SRC: &str = concat!(
|
|
||||||
include_str!("../src/shaders/common.wgsl"),
|
|
||||||
include_str!("../src/shaders/buddhabrot.wgsl"),
|
|
||||||
);
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn buddhabrot_shader_is_valid() {
|
fn buddhabrot_shader_is_valid() {
|
||||||
validate("buddhabrot.wgsl", BUDDHABROT_SRC);
|
validate(
|
||||||
}
|
"buddhabrot.wgsl",
|
||||||
|
include_str!("../src/shaders/buddhabrot.wgsl"),
|
||||||
/// 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)],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
results/
|
|
||||||
.worktrees/
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
# 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`.
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
#!/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:]))
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
clips/
|
|
||||||
work/
|
|
||||||
results/
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/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