fix: remove zoom limits
This commit is contained in:
@@ -11,8 +11,9 @@ 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. Plain `f32` deltas run out of exponent range
|
||||||
once a pixel is ~2^-124 wide (~10³⁴× at 1080p), so from 2^-122 per pixel
|
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
|
(`view::DEEP_PIXEL_SIZE`) a `DEEP` shader variant starts each pixel with
|
||||||
rescaled deltas (f32 mantissa × 2^i32), reaching ~10³⁰⁰× (the `f64` limit of
|
rescaled deltas (f32 mantissa × 2^i32). There's no practical depth limit:
|
||||||
`half_height`, `view::MIN_HALF_HEIGHT`). Runs
|
`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).
|
||||||
|
|
||||||
@@ -94,8 +95,13 @@ what makes deep zoom cheap — one expensive high-precision orbit, then every
|
|||||||
pixel is a handful of `f32` complex multiplies.
|
pixel is a handful of `f32` complex multiplies.
|
||||||
|
|
||||||
- `src/view.rs` — `ViewState`; center is arbitrary-precision `FBig` (`Big`
|
- `src/view.rs` — `ViewState`; center is arbitrary-precision `FBig` (`Big`
|
||||||
type alias), pixel scale stays `f64` (so zoom is clamped at
|
type alias). The pixel scale (`half_height`) is a `Scale`, an f64
|
||||||
`MIN_HALF_HEIGHT` = 1e-300). Precision (bits) scales with zoom depth
|
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
|
(`precision_for`). `needs_deep` switches rendering to the deep pipeline
|
||||||
once a pixel of the full-resolution render is below `DEEP_PIXEL_SIZE`
|
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
|
(2^-122; the f32 path is exact down to 2^-124 with AA's quarter-pixel
|
||||||
|
|||||||
+43
-36
@@ -19,7 +19,7 @@ use crate::lights::{Light, gpu_lights};
|
|||||||
use crate::view::parse_half_height_spec;
|
use crate::view::parse_half_height_spec;
|
||||||
use crate::view::parse_re_im_spec;
|
use crate::view::parse_re_im_spec;
|
||||||
use crate::view::{
|
use crate::view::{
|
||||||
Big, DEFAULT_HALF_HEIGHT, MIN_HALF_HEIGHT, ViewState, big_from_decimal_str, big_from_f64,
|
Big, DEFAULT_HALF_HEIGHT, MAX_PRECISION_BITS, Scale, ViewState, big_from_decimal_str, big_from_f64,
|
||||||
big_to_decimal_str, deep_scale_exp, interpolate_view, needs_deep, parse_view_spec,
|
big_to_decimal_str, deep_scale_exp, interpolate_view, needs_deep, parse_view_spec,
|
||||||
precision_for,
|
precision_for,
|
||||||
};
|
};
|
||||||
@@ -206,7 +206,7 @@ impl RefJob {
|
|||||||
struct RequestKey {
|
struct RequestKey {
|
||||||
center_re: Big,
|
center_re: Big,
|
||||||
center_im: Big,
|
center_im: Big,
|
||||||
half_height: f64,
|
half_height: Scale,
|
||||||
julia: bool,
|
julia: bool,
|
||||||
julia_c: (f64, f64),
|
julia_c: (f64, f64),
|
||||||
phoenix_p: (f64, f64),
|
phoenix_p: (f64, f64),
|
||||||
@@ -556,7 +556,7 @@ pub struct FractalApp {
|
|||||||
/// slightly from the live view; the shader compensates via `dc_offset`).
|
/// slightly from the live view; the shader compensates via `dc_offset`).
|
||||||
ref_center_re: Big,
|
ref_center_re: Big,
|
||||||
ref_center_im: Big,
|
ref_center_im: Big,
|
||||||
ref_half_height: f64,
|
ref_half_height: Scale,
|
||||||
/// Kind and kind-switch morph the current `reference` was computed with.
|
/// Kind and kind-switch morph the current `reference` was computed with.
|
||||||
/// The shader iterates with these (not the live kind/morph) so its delta
|
/// The shader iterates with these (not the live kind/morph) so its delta
|
||||||
/// formula always matches the orbit, even while the worker lags a frame
|
/// formula always matches the orbit, even while the worker lags a frame
|
||||||
@@ -614,8 +614,8 @@ fn sig_digits_for(bits: usize) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Format a magnification for the editable field (compact scientific).
|
/// Format a magnification for the editable field (compact scientific).
|
||||||
fn format_zoom(m: f64) -> String {
|
fn format_zoom(m: Scale) -> String {
|
||||||
format!("{m:.4e}")
|
format!("{m:.4}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Precision (bits) to parse a typed center at: at least what the current zoom
|
/// Precision (bits) to parse a typed center at: at least what the current zoom
|
||||||
@@ -624,7 +624,7 @@ fn format_zoom(m: f64) -> String {
|
|||||||
fn parse_bits_for(s: &str, min_bits: usize) -> usize {
|
fn parse_bits_for(s: &str, min_bits: usize) -> usize {
|
||||||
let digits = s.chars().filter(char::is_ascii_digit).count();
|
let digits = s.chars().filter(char::is_ascii_digit).count();
|
||||||
let from_input = (digits as f64 * std::f64::consts::LOG2_10).ceil() as usize + 16;
|
let from_input = (digits as f64 * std::f64::consts::LOG2_10).ceil() as usize + 16;
|
||||||
min_bits.max(from_input).min(2048)
|
min_bits.max(from_input).min(MAX_PRECISION_BITS)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FractalApp {
|
impl FractalApp {
|
||||||
@@ -746,7 +746,7 @@ impl FractalApp {
|
|||||||
if let Some(k) = cli.kind {
|
if let Some(k) = cli.kind {
|
||||||
self.kind = k.into();
|
self.kind = k.into();
|
||||||
if let Some(p) = cli.power {
|
if let Some(p) = cli.power {
|
||||||
self.power = p.clamp(2, 8);
|
self.power = p.clamp(2, 20);
|
||||||
}
|
}
|
||||||
self.view = Self::default_view_for(self.mode, self.kind);
|
self.view = Self::default_view_for(self.mode, self.kind);
|
||||||
}
|
}
|
||||||
@@ -979,6 +979,7 @@ impl FractalApp {
|
|||||||
/// Jump to a preset Mandelbrot location: decimal center (parsed at the
|
/// Jump to a preset Mandelbrot location: decimal center (parsed at the
|
||||||
/// precision the zoom needs), half-height, and a fitting iteration count.
|
/// precision the zoom needs), half-height, and a fitting iteration count.
|
||||||
fn go_to_place(&mut self, re: &str, im: &str, half_height: f64, iterations: u32) {
|
fn go_to_place(&mut self, re: &str, im: &str, half_height: f64, iterations: u32) {
|
||||||
|
let half_height = Scale::from_f64(half_height);
|
||||||
let bits = precision_for(half_height);
|
let bits = precision_for(half_height);
|
||||||
if let (Some(cre), Some(cim)) = (
|
if let (Some(cre), Some(cim)) = (
|
||||||
big_from_decimal_str(re, bits),
|
big_from_decimal_str(re, bits),
|
||||||
@@ -997,7 +998,7 @@ impl FractalApp {
|
|||||||
/// `auto_iterations` is on. Grows roughly linearly with zoom decades so deep
|
/// `auto_iterations` is on. Grows roughly linearly with zoom decades so deep
|
||||||
/// zooms keep enough iterations to stay sharp instead of banding.
|
/// zooms keep enough iterations to stay sharp instead of banding.
|
||||||
fn auto_iteration_count(&self) -> u32 {
|
fn auto_iteration_count(&self) -> u32 {
|
||||||
let decades = self.view.magnification().log10().max(0.0);
|
let decades = self.view.magnification_log10().max(0.0);
|
||||||
let iters = 400.0 + 900.0 * decades;
|
let iters = 400.0 + 900.0 * decades;
|
||||||
(iters.round() as u32).clamp(200, MAX_REF_POINTS as u32 - 1)
|
(iters.round() as u32).clamp(200, MAX_REF_POINTS as u32 - 1)
|
||||||
}
|
}
|
||||||
@@ -1033,7 +1034,7 @@ impl FractalApp {
|
|||||||
FractalMode::Mandelbrot
|
FractalMode::Mandelbrot
|
||||||
};
|
};
|
||||||
self.kind = s.kind;
|
self.kind = s.kind;
|
||||||
self.power = s.power.clamp(2, 8);
|
self.power = s.power.clamp(2, 200);
|
||||||
self.julia_c = s.julia_c;
|
self.julia_c = s.julia_c;
|
||||||
self.phoenix_p = s.phoenix_p;
|
self.phoenix_p = s.phoenix_p;
|
||||||
self.lambda_l = s.lambda_l;
|
self.lambda_l = s.lambda_l;
|
||||||
@@ -1077,10 +1078,10 @@ impl FractalApp {
|
|||||||
/// each kind's interesting region.
|
/// each kind's interesting region.
|
||||||
fn default_view_for(mode: FractalMode, kind: FractalKind) -> ViewState {
|
fn default_view_for(mode: FractalMode, kind: FractalKind) -> ViewState {
|
||||||
if mode == FractalMode::Julia {
|
if mode == FractalMode::Julia {
|
||||||
return ViewState::with_center(big_from_f64(0.0, 53), big_from_f64(0.0, 53), 1.5);
|
return ViewState::with_center(big_from_f64(0.0, 53), big_from_f64(0.0, 53), Scale::from_f64(1.5));
|
||||||
}
|
}
|
||||||
let (cr, ci, hh) = kind.default_set_view();
|
let (cr, ci, hh) = kind.default_set_view();
|
||||||
ViewState::with_center(big_from_f64(cr, 53), big_from_f64(ci, 53), hh)
|
ViewState::with_center(big_from_f64(cr, 53), big_from_f64(ci, 53), Scale::from_f64(hh))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The request key for the current state. Its `iter` is the reference
|
/// The request key for the current state. Its `iter` is the reference
|
||||||
@@ -1104,10 +1105,14 @@ impl FractalApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Distance (complex units) the live view center has drifted from `key`.
|
/// Distance (complex units) the live view center has drifted from `key`.
|
||||||
|
/// Distance of the live center from `key`'s, in units of the live
|
||||||
|
/// half-height (measured at that scale, so it works past f64's range).
|
||||||
fn drift_from(&self, key: &RequestKey) -> f64 {
|
fn drift_from(&self, key: &RequestKey) -> f64 {
|
||||||
let dre = (&self.view.center_re - &key.center_re).to_f64().value();
|
let hh = self.view.half_height;
|
||||||
let dim = (&self.view.center_im - &key.center_im).to_f64().value();
|
let k = -hh.exponent() as isize;
|
||||||
(dre * dre + dim * dim).sqrt()
|
let dre = ((&self.view.center_re - &key.center_re) << k).to_f64().value();
|
||||||
|
let dim = ((&self.view.center_im - &key.center_im) << k).to_f64().value();
|
||||||
|
(dre * dre + dim * dim).sqrt() / hh.scaled_f64(-hh.exponent())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the reference should be (re)computed: parameters changed, or the
|
/// Whether the reference should be (re)computed: parameters changed, or the
|
||||||
@@ -1143,19 +1148,22 @@ impl FractalApp {
|
|||||||
&& self.morph.is_none()
|
&& self.morph.is_none()
|
||||||
{
|
{
|
||||||
// But still recompute on significant zoom changes for precision
|
// But still recompute on significant zoom changes for precision
|
||||||
let ratio = self.view.half_height / key.half_height;
|
let ratio = self.view.half_height.ratio(key.half_height);
|
||||||
return !(0.5..=2.0).contains(&ratio);
|
return !(0.5..=2.0).contains(&ratio);
|
||||||
}
|
}
|
||||||
let ratio = self.view.half_height / key.half_height;
|
let ratio = self.view.half_height.ratio(key.half_height);
|
||||||
self.drift_from(key) > 0.5 * self.view.half_height || !(0.5..=2.0).contains(&ratio)
|
self.drift_from(key) > 0.5 || !(0.5..=2.0).contains(&ratio)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Complex offset of the live view center from the reference center.
|
/// Complex offset of the live view center from the reference center, in
|
||||||
fn dc_offset(&self) -> (f64, f64) {
|
/// units of `2^scale_exp` (shifted exactly in `Big`, so it doesn't
|
||||||
let dre = (&self.view.center_re - &self.ref_center_re)
|
/// underflow f64 at deep zooms).
|
||||||
|
fn dc_offset(&self, scale_exp: i32) -> (f64, f64) {
|
||||||
|
let k = -scale_exp as isize;
|
||||||
|
let dre = ((&self.view.center_re - &self.ref_center_re) << k)
|
||||||
.to_f64()
|
.to_f64()
|
||||||
.value();
|
.value();
|
||||||
let dim = (&self.view.center_im - &self.ref_center_im)
|
let dim = ((&self.view.center_im - &self.ref_center_im) << k)
|
||||||
.to_f64()
|
.to_f64()
|
||||||
.value();
|
.value();
|
||||||
(dre, dim)
|
(dre, dim)
|
||||||
@@ -1178,7 +1186,7 @@ impl FractalApp {
|
|||||||
points: RefOrbit,
|
points: RefOrbit,
|
||||||
cre: Big,
|
cre: Big,
|
||||||
cim: Big,
|
cim: Big,
|
||||||
hh: f64,
|
hh: Scale,
|
||||||
kind: FractalKind,
|
kind: FractalKind,
|
||||||
morph: Option<(FractalKind, f32)>,
|
morph: Option<(FractalKind, f32)>,
|
||||||
) {
|
) {
|
||||||
@@ -1404,10 +1412,12 @@ impl FractalApp {
|
|||||||
let mode = self.effective_rendering_mode();
|
let mode = self.effective_rendering_mode();
|
||||||
// Deep views upload the geometry pre-multiplied by 2^-E (exact).
|
// Deep views upload the geometry pre-multiplied by 2^-E (exact).
|
||||||
let scale_exp = self.scale_exp(height_px);
|
let scale_exp = self.scale_exp(height_px);
|
||||||
let inv_scale = 2f64.powi(-scale_exp);
|
let (dc_re, dc_im) = self.dc_offset(scale_exp);
|
||||||
let (dc_re, dc_im) = self.dc_offset();
|
|
||||||
Uniforms {
|
Uniforms {
|
||||||
span: [(span_x * inv_scale) as f32, (span_y * inv_scale) as f32],
|
span: [
|
||||||
|
span_x.scaled_f64(-scale_exp) as f32,
|
||||||
|
span_y.scaled_f64(-scale_exp) as f32,
|
||||||
|
],
|
||||||
max_iter: self.max_iterations.min(MAX_REF_POINTS as u32 - 1),
|
max_iter: self.max_iterations.min(MAX_REF_POINTS as u32 - 1),
|
||||||
ref_len: self.reference.len() as u32,
|
ref_len: self.reference.len() as u32,
|
||||||
color_offset: self.color_offset,
|
color_offset: self.color_offset,
|
||||||
@@ -1420,7 +1430,7 @@ impl FractalApp {
|
|||||||
kind: self.ref_kind.unwrap_or(self.kind) as u32,
|
kind: self.ref_kind.unwrap_or(self.kind) as u32,
|
||||||
power: self.power,
|
power: self.power,
|
||||||
morph_from: self.ref_morph.map_or(0, |(k, _)| k as u32),
|
morph_from: self.ref_morph.map_or(0, |(k, _)| k as u32),
|
||||||
dc_offset: [(dc_re * inv_scale) as f32, (dc_im * inv_scale) as f32],
|
dc_offset: [dc_re as f32, dc_im as f32],
|
||||||
phoenix_p: [self.phoenix_p.0 as f32, self.phoenix_p.1 as f32],
|
phoenix_p: [self.phoenix_p.0 as f32, self.phoenix_p.1 as f32],
|
||||||
lambda_l: [self.lambda_l.0 as f32, self.lambda_l.1 as f32],
|
lambda_l: [self.lambda_l.0 as f32, self.lambda_l.1 as f32],
|
||||||
complex_power: [self.complex_power.0 as f32, self.complex_power.1 as f32],
|
complex_power: [self.complex_power.0 as f32, self.complex_power.1 as f32],
|
||||||
@@ -1450,7 +1460,7 @@ impl FractalApp {
|
|||||||
];
|
];
|
||||||
BuddhabrotUniforms {
|
BuddhabrotUniforms {
|
||||||
center,
|
center,
|
||||||
half_height: self.view.half_height as f32,
|
half_height: self.view.half_height.to_f64() as f32,
|
||||||
aspect: aspect as f32,
|
aspect: aspect as f32,
|
||||||
phoenix_p: [self.phoenix_p.0 as f32, self.phoenix_p.1 as f32],
|
phoenix_p: [self.phoenix_p.0 as f32, self.phoenix_p.1 as f32],
|
||||||
lambda_l: [self.lambda_l.0 as f32, self.lambda_l.1 as f32],
|
lambda_l: [self.lambda_l.0 as f32, self.lambda_l.1 as f32],
|
||||||
@@ -2016,11 +2026,10 @@ impl FractalApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.anim.zoom && self.anim.zoom_speed != 0.0 {
|
if self.anim.zoom && self.anim.zoom_speed != 0.0 {
|
||||||
let min_hh = DEFAULT_HALF_HEIGHT * 1.0e-26; // practical f32-perturbation depth
|
let max_hh = Scale::from_f64(DEFAULT_HALF_HEIGHT * 4.0);
|
||||||
let max_hh = DEFAULT_HALF_HEIGHT * 4.0;
|
|
||||||
let factor = (-(self.anim.zoom_speed as f64) * dt).exp();
|
let factor = (-(self.anim.zoom_speed as f64) * dt).exp();
|
||||||
let target = (self.view.half_height * factor).clamp(min_hh, max_hh);
|
let target = self.view.half_height.mul_f64(factor).clamp(Scale::MIN, max_hh);
|
||||||
let f = target / self.view.half_height;
|
let f = target.ratio(self.view.half_height);
|
||||||
if (f - 1.0).abs() > 1.0e-9 {
|
if (f - 1.0).abs() > 1.0e-9 {
|
||||||
self.view
|
self.view
|
||||||
.zoom_at_pixel(0.0, 0.0, self.last_size_px.y.max(1.0) as f64, f);
|
.zoom_at_pixel(0.0, 0.0, self.last_size_px.y.max(1.0) as f64, f);
|
||||||
@@ -2453,11 +2462,9 @@ impl FractalApp {
|
|||||||
}
|
}
|
||||||
if zoom_resp.lost_focus() {
|
if zoom_resp.lost_focus() {
|
||||||
if self.zoom_edited
|
if self.zoom_edited
|
||||||
&& let Ok(hh) = self.zoom_edit.trim().parse::<f64>()
|
&& let Ok(hh) = self.zoom_edit.parse::<Scale>()
|
||||||
&& hh > 0.0
|
|
||||||
&& hh.is_finite()
|
|
||||||
{
|
{
|
||||||
self.view.half_height = hh.max(MIN_HALF_HEIGHT);
|
self.view.half_height = hh;
|
||||||
self.view.sync_precision();
|
self.view.sync_precision();
|
||||||
}
|
}
|
||||||
self.zoom_edited = false;
|
self.zoom_edited = false;
|
||||||
@@ -2560,7 +2567,7 @@ impl FractalApp {
|
|||||||
});
|
});
|
||||||
ui.checkbox(&mut self.buddha_accumulate, "Keep sampling")
|
ui.checkbox(&mut self.buddha_accumulate, "Keep sampling")
|
||||||
.on_hover_text("Dispatch a fresh batch of random samples every frame.");
|
.on_hover_text("Dispatch a fresh batch of random samples every frame.");
|
||||||
if self.view.magnification() > 1.0e5 {
|
if self.view.magnification_log10() > 5.0 {
|
||||||
ui.colored_label(
|
ui.colored_label(
|
||||||
egui::Color32::LIGHT_YELLOW,
|
egui::Color32::LIGHT_YELLOW,
|
||||||
"deep zoom isn't supported here (f32 precision only)",
|
"deep zoom isn't supported here (f32 precision only)",
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ fn step(
|
|||||||
FractalKind::Perpendicular => {
|
FractalKind::Perpendicular => {
|
||||||
// (x^2 - y^2) - 2·x·|y| i: abs the imaginary input.
|
// (x^2 - y^2) - 2·x·|y| i: abs the imaginary input.
|
||||||
let re = &zr.sqr() - &zi.sqr() + cr;
|
let re = &zr.sqr() - &zi.sqr() + cr;
|
||||||
let im = if zi.to_f64().value() < 0.0 {
|
let im = if *zi < Big::ZERO {
|
||||||
ci + ((zr * zi) << 1)
|
ci + ((zr * zi) << 1)
|
||||||
} else {
|
} else {
|
||||||
ci - ((zr * zi) << 1)
|
ci - ((zr * zi) << 1)
|
||||||
@@ -422,10 +422,10 @@ fn big_zero(precision: usize) -> Big {
|
|||||||
Big::from(0i32).with_precision(precision).value()
|
Big::from(0i32).with_precision(precision).value()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Absolute value of a `Big`. The sign check via f64 is exact except for values
|
/// Absolute value of a `Big`. The sign comes from the `Big` itself: through
|
||||||
/// so tiny that |x| ≈ x either way — negligible against the f32 orbit storage.
|
/// f64, anything below ~1e-308 reads as ±0 and would keep its sign.
|
||||||
fn big_abs(x: Big) -> Big {
|
fn big_abs(x: Big) -> Big {
|
||||||
if x.to_f64().value() < 0.0 { -x } else { x }
|
if x < Big::ZERO { -x } else { x }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `(zr + i zi)^power` by repeated complex multiply at `precision` bits.
|
/// `(zr + i zi)^power` by repeated complex multiply at `precision` bits.
|
||||||
@@ -442,10 +442,10 @@ fn complex_pow(zr: &Big, zi: &Big, power: u32, precision: usize) -> (Big, Big) {
|
|||||||
(rr, ri)
|
(rr, ri)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `true` if `x` is (numerically) zero. The f64 check is exact for a true
|
/// `true` if `x` is exactly zero (special-cases `ln(0)`). Not via f64,
|
||||||
/// zero; only matters here to special-case `ln(0)`.
|
/// which flushes values below ~1e-308 to zero.
|
||||||
fn is_big_zero(x: &Big) -> bool {
|
fn is_big_zero(x: &Big) -> bool {
|
||||||
x.to_f64().value() == 0.0
|
x.repr().significand().is_zero()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `(zr + i zi)^(pr + i pi)` for a complex exponent, via the principal branch
|
/// `(zr + i zi)^(pr + i pi)` for a complex exponent, via the principal branch
|
||||||
@@ -504,6 +504,17 @@ pub fn compute_set_reference(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Sign and zero tests must hold far below f64's range, where
|
||||||
|
/// `to_f64` reads as ±0.
|
||||||
|
#[test]
|
||||||
|
fn sign_and_zero_below_f64_range() {
|
||||||
|
let tiny = Big::try_from(1.0_f64).unwrap().with_precision(64).value() >> 5000;
|
||||||
|
assert!(!is_big_zero(&tiny));
|
||||||
|
assert!(is_big_zero(&big_zero(64)));
|
||||||
|
assert_eq!(big_abs(-tiny.clone()), tiny);
|
||||||
|
assert_eq!(big_abs(tiny.clone()), tiny);
|
||||||
|
}
|
||||||
|
|
||||||
/// The high-precision reference must agree with a plain f64 iteration for a
|
/// The high-precision reference must agree with a plain f64 iteration for a
|
||||||
/// shallow point (where f64 is accurate).
|
/// shallow point (where f64 is accurate).
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+15
-4
@@ -2,13 +2,14 @@
|
|||||||
//! iterations, Julia constant, coloring) as a compact URL fragment so deep-zoom
|
//! iterations, Julia constant, coloring) as a compact URL fragment so deep-zoom
|
||||||
//! locations can be shared or bookmarked.
|
//! locations can be shared or bookmarked.
|
||||||
//!
|
//!
|
||||||
//! Format: `m=m&f=<str>&re=<dec>&im=<dec>&hh=<f64>&it=<u32>&cs=<f32>&co=<f32>` with
|
//! Format: `m=m&f=<str>&re=<dec>&im=<dec>&hh=<sci>&it=<u32>&cs=<f32>&co=<f32>` with
|
||||||
//! `m=j&jr=<f64>&ji=<f64>` added for Julia. `re`/`im` are full-precision decimal
|
//! `m=j&jr=<f64>&ji=<f64>` added for Julia. `re`/`im` are full-precision decimal
|
||||||
//! strings.
|
//! strings; `hh` is a `Scale` in scientific notation (any exponent).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::fractal::FractalKind;
|
use crate::fractal::FractalKind;
|
||||||
|
use crate::view::Scale;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ShareState {
|
pub struct ShareState {
|
||||||
@@ -18,7 +19,7 @@ pub struct ShareState {
|
|||||||
pub power: u32,
|
pub power: u32,
|
||||||
pub center_re: String,
|
pub center_re: String,
|
||||||
pub center_im: String,
|
pub center_im: String,
|
||||||
pub half_height: f64,
|
pub half_height: Scale,
|
||||||
pub iterations: u32,
|
pub iterations: u32,
|
||||||
pub julia_c: (f64, f64),
|
pub julia_c: (f64, f64),
|
||||||
/// Distortion constant for the Phoenix kind (ignored by others).
|
/// Distortion constant for the Phoenix kind (ignored by others).
|
||||||
@@ -115,7 +116,7 @@ mod tests {
|
|||||||
power: 5,
|
power: 5,
|
||||||
center_re: "-0.743643887037158704752191506114774".into(),
|
center_re: "-0.743643887037158704752191506114774".into(),
|
||||||
center_im: "0.131825904205311970493132056385139".into(),
|
center_im: "0.131825904205311970493132056385139".into(),
|
||||||
half_height: 1.5e-20,
|
half_height: Scale::from_f64(1.5e-20),
|
||||||
iterations: 4000,
|
iterations: 4000,
|
||||||
julia_c: (-0.123, 0.745),
|
julia_c: (-0.123, 0.745),
|
||||||
phoenix_p: (-0.5, 0.1),
|
phoenix_p: (-0.5, 0.1),
|
||||||
@@ -146,5 +147,15 @@ mod tests {
|
|||||||
let d = ShareState::decode("#m=m&re=0.0&im=0.0&hh=1.25&it=256").unwrap();
|
let d = ShareState::decode("#m=m&re=0.0&im=0.0&hh=1.25&it=256").unwrap();
|
||||||
assert!(!d.julia);
|
assert!(!d.julia);
|
||||||
assert_eq!(d.iterations, 256);
|
assert_eq!(d.iterations, 256);
|
||||||
|
assert_eq!(d.half_height, Scale::from_f64(1.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zooms past f64's range survive a round trip.
|
||||||
|
#[test]
|
||||||
|
fn round_trip_past_f64_range() {
|
||||||
|
let d = ShareState::decode("#m=m&re=0.0&im=0.0&hh=1.5e-1234&it=256").unwrap();
|
||||||
|
assert_eq!(d.half_height, "1.5e-1234".parse().unwrap());
|
||||||
|
let d2 = ShareState::decode(&d.encode()).unwrap();
|
||||||
|
assert_eq!(d2.half_height, d.half_height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+350
-70
@@ -1,11 +1,12 @@
|
|||||||
//! Camera / view state over the complex plane.
|
//! Camera / view state over the complex plane.
|
||||||
//!
|
//!
|
||||||
//! The center is stored in arbitrary precision (`FBig`) — this is what lets us
|
//! The center is stored in arbitrary precision (`FBig`) — this is what lets us
|
||||||
//! zoom far past f64's ~1e13x limit. The pixel *scale* stays `f64`, which
|
//! zoom far past f64's ~1e13x limit. The pixel *scale* is a [`Scale`]: an
|
||||||
//! bounds zoom at ~10^300x (`MIN_HALF_HEIGHT`). Only the center needs the
|
//! f64 mantissa with its own `i32` binary exponent, so it isn't bound by
|
||||||
//! extra digits. Once a pixel is smaller than `DEEP_PIXEL_SIZE` the GPU
|
//! f64's ~1e-308 range either (the floor, `Scale::MIN`, only keeps the GPU's
|
||||||
//! switches to rescaled deltas (see `needs_deep`), since f32 alone bottoms
|
//! i32 exponent arithmetic far from overflow). Once a pixel is smaller than
|
||||||
//! out near 1e-38.
|
//! `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;
|
||||||
|
|
||||||
@@ -18,9 +19,6 @@ 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;
|
||||||
|
|
||||||
/// Smallest half-height the view can zoom to: f64's range (the pixel scale,
|
|
||||||
/// and the rescaled GPU uniforms, are computed in f64).
|
|
||||||
pub const MIN_HALF_HEIGHT: f64 = 1e-300;
|
|
||||||
|
|
||||||
/// Below this pixel size (complex units per pixel) the GPU renders with the
|
/// 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
|
/// deep pipeline, whose per-pixel deltas start out as an f32 mantissa times
|
||||||
@@ -34,62 +32,266 @@ 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
|
/// Whether a view rendered `height_px` pixels tall needs the deep pipeline
|
||||||
/// (see `DEEP_PIXEL_SIZE`).
|
/// (see `DEEP_PIXEL_SIZE`).
|
||||||
pub fn needs_deep(half_height: f64, height_px: f64) -> bool {
|
pub fn needs_deep(half_height: Scale, height_px: f64) -> bool {
|
||||||
2.0 * half_height / height_px.max(1.0) < DEEP_PIXEL_SIZE
|
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))`,
|
/// 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"
|
/// so the rescaled span is in `[2, 4)`. Never 0, which means "not deep"
|
||||||
/// (see `Uniforms::scale_exp`).
|
/// (see `Uniforms::scale_exp`).
|
||||||
pub fn deep_scale_exp(half_height: f64) -> i32 {
|
pub fn deep_scale_exp(half_height: Scale) -> i32 {
|
||||||
let e = half_height.max(MIN_HALF_HEIGHT).log2().floor() as i32;
|
let e = half_height.exponent();
|
||||||
if e == 0 { -1 } else { e }
|
if e == 0 { -1 } else { e }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `x * 2^k` for any `k`, saturating to 0 / infinity like the true value
|
||||||
|
/// would (`powi` alone overflows at 2^±1024 even when the product fits).
|
||||||
|
fn ldexp(mut x: f64, mut k: i32) -> f64 {
|
||||||
|
while k > 1000 {
|
||||||
|
x *= 2f64.powi(1000);
|
||||||
|
k -= 1000;
|
||||||
|
if x.is_infinite() || x == 0.0 {
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while k < -1000 {
|
||||||
|
x *= 2f64.powi(-1000);
|
||||||
|
k += 1000;
|
||||||
|
if x == 0.0 || x.is_infinite() {
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
x * 2f64.powi(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A positive real with f64 precision and an `i32` binary exponent:
|
||||||
|
/// `m · 2^e`, `m` in `[1, 2)`. The view's half-height (and the pixel size
|
||||||
|
/// derived from it) is one of these, so zoom isn't bound by f64's range.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct Scale {
|
||||||
|
m: f64,
|
||||||
|
e: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scale {
|
||||||
|
/// Deepest scale the view can reach: 2^-(2^20) (about 1e-315653). Far
|
||||||
|
/// past anything a reference orbit can practically be computed for; it
|
||||||
|
/// only keeps the shader's i32 exponent sums (scale × degree) from
|
||||||
|
/// overflowing.
|
||||||
|
pub const MIN: Scale = Scale {
|
||||||
|
m: 1.0,
|
||||||
|
e: -(1 << 20),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `m · 2^e`, normalized. Non-positive or NaN input gives `MIN`.
|
||||||
|
pub fn from_parts(m: f64, e: i32) -> Self {
|
||||||
|
if m.is_nan() || m <= 0.0 {
|
||||||
|
return Self::MIN;
|
||||||
|
}
|
||||||
|
if m.is_infinite() {
|
||||||
|
return Scale { m: 1.0, e: i32::MAX / 2 };
|
||||||
|
}
|
||||||
|
// Bring m into [1, 2) through its own binary exponent (exact).
|
||||||
|
let k = m.log2().floor() as i32;
|
||||||
|
let mut m = ldexp(m, -k);
|
||||||
|
let mut e = e.saturating_add(k);
|
||||||
|
// log2 can round across a power of two.
|
||||||
|
if m >= 2.0 {
|
||||||
|
m /= 2.0;
|
||||||
|
e = e.saturating_add(1);
|
||||||
|
} else if m < 1.0 {
|
||||||
|
m *= 2.0;
|
||||||
|
e = e.saturating_sub(1);
|
||||||
|
}
|
||||||
|
Scale { m, e }.max(Self::MIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_f64(x: f64) -> Self {
|
||||||
|
Self::from_parts(x, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `2^l`.
|
||||||
|
pub fn from_log2(l: f64) -> Self {
|
||||||
|
let e = l.floor();
|
||||||
|
Self::from_parts((l - e).exp2(), e as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value as an f64 (0 or infinity outside its range).
|
||||||
|
pub fn to_f64(self) -> f64 {
|
||||||
|
ldexp(self.m, self.e)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `self · 2^k` as an f64: the value in units of `2^-k`.
|
||||||
|
pub fn scaled_f64(self, k: i32) -> f64 {
|
||||||
|
ldexp(self.m, self.e.saturating_add(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `floor(log2(self))`.
|
||||||
|
pub fn exponent(self) -> i32 {
|
||||||
|
self.e
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn log2(self) -> f64 {
|
||||||
|
self.m.log2() + self.e as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn log10(self) -> f64 {
|
||||||
|
self.log2() * core::f64::consts::LOG10_2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `self · f` (`f > 0`).
|
||||||
|
pub fn mul_f64(self, f: f64) -> Self {
|
||||||
|
Self::from_parts(self.m * f, self.e)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `self / other`, as an f64.
|
||||||
|
pub fn ratio(self, other: Scale) -> f64 {
|
||||||
|
ldexp(self.m / other.m, self.e.saturating_sub(other.e))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max(self, other: Scale) -> Self {
|
||||||
|
if other > self { other } else { self }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn min(self, other: Scale) -> Self {
|
||||||
|
if other < self { other } else { self }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clamp(self, lo: Scale, hi: Scale) -> Self {
|
||||||
|
self.max(lo).min(hi)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `f · self` as an exact `Big` at `bits` of precision (`f` any f64).
|
||||||
|
pub fn big_times(self, f: f64, bits: usize) -> Big {
|
||||||
|
big_from_f64(f * self.m, bits) << self.e as isize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact binary value as a `Big`.
|
||||||
|
fn to_big(self) -> Big {
|
||||||
|
big_from_f64(self.m, 53) << self.e as isize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for Scale {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
|
||||||
|
// Normalized and positive: the exponent decides, then the mantissa.
|
||||||
|
Some(
|
||||||
|
self.e
|
||||||
|
.cmp(&other.e)
|
||||||
|
.then(self.m.partial_cmp(&other.m)?),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for Scale {
|
||||||
|
/// Scientific notation, `1.5e-20` / `3.7e-4000`. The precision flag
|
||||||
|
/// (`{:.4}`) sets mantissa digits after the point; without it, enough
|
||||||
|
/// digits to parse back to the same value.
|
||||||
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
let x = self.to_f64();
|
||||||
|
if x.is_normal() {
|
||||||
|
return match f.precision() {
|
||||||
|
Some(p) => write!(f, "{x:.p$e}"),
|
||||||
|
None => write!(f, "{x:e}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Out of f64's range: round the exact decimal expansion instead.
|
||||||
|
let sig = f.precision().map_or(17, |p| p + 1);
|
||||||
|
let dec = self.to_big().to_decimal().value().with_precision(sig).value();
|
||||||
|
let repr = dec.repr();
|
||||||
|
let digits = repr.significand().to_string();
|
||||||
|
let digits = digits.trim_end_matches('0');
|
||||||
|
let digits = if digits.is_empty() { "0" } else { digits };
|
||||||
|
// value = significand · 10^exponent; move the point after the first digit.
|
||||||
|
let exp10 = repr.exponent() + repr.significand().to_string().len() as isize - 1;
|
||||||
|
let (head, tail) = digits.split_at(1);
|
||||||
|
let tail = match f.precision() {
|
||||||
|
Some(p) => format!("{tail:0<p$}"),
|
||||||
|
None => tail.to_string(),
|
||||||
|
};
|
||||||
|
if tail.is_empty() {
|
||||||
|
write!(f, "{head}e{exp10}")
|
||||||
|
} else {
|
||||||
|
write!(f, "{head}.{tail}e{exp10}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Scale {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
/// Parses any positive decimal (`1.25`, `1.5e-20`, `3.7e-4000`), rounding
|
||||||
|
/// to the nearest f64 mantissa. Values past `MIN` clamp to it.
|
||||||
|
fn from_str(s: &str) -> Result<Self, ()> {
|
||||||
|
let s = s.trim();
|
||||||
|
if let Ok(x) = s.parse::<f64>()
|
||||||
|
&& x.is_normal()
|
||||||
|
{
|
||||||
|
return if x > 0.0 { Ok(Self::from_f64(x)) } else { Err(()) };
|
||||||
|
}
|
||||||
|
// Too small (or large) for f64: go through an exact decimal.
|
||||||
|
let dec = DBig::from_str(s).map_err(|_| ())?;
|
||||||
|
let bin: Big = dec.with_base_and_precision::<2>(64).value();
|
||||||
|
if bin < Big::ZERO {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
let repr = bin.repr();
|
||||||
|
let digits = repr.digits();
|
||||||
|
if digits == 0 {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
let top = repr.exponent() + digits as isize - 1;
|
||||||
|
let m = (bin.clone() >> top).to_f64().value();
|
||||||
|
let e = top.clamp(i32::MIN as isize, i32::MAX as isize) as i32;
|
||||||
|
Ok(Self::from_parts(m, e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Guard bits added on top of the zoom-dictated precision.
|
/// Guard bits added on top of the zoom-dictated precision.
|
||||||
const GUARD_BITS: usize = 48;
|
const GUARD_BITS: usize = 48;
|
||||||
/// Upper bound on center precision (f32 GPU perturbation degrades long before
|
/// Upper bound on center precision: what `Scale::MIN` needs. Only a guard
|
||||||
/// this; the cap just prevents pathological allocation).
|
/// against pathological input; the reference orbit is impractically slow
|
||||||
const MAX_PRECISION_BITS: usize = 2048;
|
/// long before this.
|
||||||
|
pub const MAX_PRECISION_BITS: usize = (1 << 20) + GUARD_BITS;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ViewState {
|
pub struct ViewState {
|
||||||
pub center_re: Big,
|
pub center_re: Big,
|
||||||
pub center_im: Big,
|
pub center_im: Big,
|
||||||
/// Half the view height in complex-plane units. Zooming in shrinks this.
|
/// Half the view height in complex-plane units. Zooming in shrinks this.
|
||||||
pub half_height: f64,
|
pub half_height: Scale,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ViewState {
|
impl Default for ViewState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let bits = precision_for(DEFAULT_HALF_HEIGHT);
|
let bits = precision_for(Scale::from_f64(DEFAULT_HALF_HEIGHT));
|
||||||
Self {
|
Self {
|
||||||
center_re: big_from_f64(-0.5, bits),
|
center_re: big_from_f64(-0.5, bits),
|
||||||
center_im: big_from_f64(0.0, bits),
|
center_im: big_from_f64(0.0, bits),
|
||||||
half_height: DEFAULT_HALF_HEIGHT,
|
half_height: Scale::from_f64(DEFAULT_HALF_HEIGHT),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ViewState {
|
impl ViewState {
|
||||||
/// Complex-plane span (width, height) for the given pixel aspect ratio.
|
/// Complex-plane span (width, height) for the given pixel aspect ratio.
|
||||||
pub fn span(&self, aspect: f64) -> (f64, f64) {
|
pub fn span(&self, aspect: f64) -> (Scale, Scale) {
|
||||||
let h = self.half_height * 2.0;
|
let h = self.half_height.mul_f64(2.0);
|
||||||
(h * aspect, h)
|
(h.mul_f64(aspect), h)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Complex-plane units per pixel, given the viewport height in pixels.
|
/// Complex-plane units per pixel, given the viewport height in pixels.
|
||||||
pub fn complex_per_pixel(&self, height_px: f64) -> f64 {
|
pub fn complex_per_pixel(&self, height_px: f64) -> Scale {
|
||||||
(self.half_height * 2.0) / height_px
|
self.half_height.mul_f64(2.0 / height_px)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Current magnification relative to the default view.
|
/// log10 of the current magnification relative to the default view.
|
||||||
pub fn magnification(&self) -> f64 {
|
pub fn magnification_log10(&self) -> f64 {
|
||||||
DEFAULT_HALF_HEIGHT / self.half_height
|
DEFAULT_HALF_HEIGHT.log10() - self.half_height.log10()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Current zoom level.
|
/// Current zoom level.
|
||||||
pub fn zoom(&self) -> f64 {
|
pub fn zoom(&self) -> Scale {
|
||||||
self.half_height
|
self.half_height
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,8 +318,8 @@ impl ViewState {
|
|||||||
let cpp = self.complex_per_pixel(height_px);
|
let cpp = self.complex_per_pixel(height_px);
|
||||||
let bits = self.precision_bits();
|
let bits = self.precision_bits();
|
||||||
// Grab-and-drag: moving the mouse right shows content to the left.
|
// Grab-and-drag: moving the mouse right shows content to the left.
|
||||||
self.center_re = &self.center_re - &big_from_f64(dx * cpp, bits);
|
self.center_re = &self.center_re - &cpp.big_times(dx, bits);
|
||||||
self.center_im = &self.center_im - &big_from_f64(dy * cpp, bits);
|
self.center_im = &self.center_im - &cpp.big_times(dy, bits);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zoom by `factor` (<1 zooms in) keeping the complex point currently under
|
/// Zoom by `factor` (<1 zooms in) keeping the complex point currently under
|
||||||
@@ -130,18 +332,18 @@ impl ViewState {
|
|||||||
// The cursor's complex offset from the center is (off * cpp). Keeping it
|
// The cursor's complex offset from the center is (off * cpp). Keeping it
|
||||||
// fixed while scaling the view by `factor` moves the center by
|
// fixed while scaling the view by `factor` moves the center by
|
||||||
// off * cpp * (1 - factor). (Derivation: new_c = fixed + (c-fixed)*f.)
|
// off * cpp * (1 - factor). (Derivation: new_c = fixed + (c-fixed)*f.)
|
||||||
let k = cpp * (1.0 - factor);
|
let k = 1.0 - factor;
|
||||||
self.center_re = &self.center_re + &big_from_f64(off_x * k, bits);
|
self.center_re = &self.center_re + &cpp.big_times(off_x * k, bits);
|
||||||
self.center_im = &self.center_im + &big_from_f64(off_y * k, bits);
|
self.center_im = &self.center_im + &cpp.big_times(off_y * k, bits);
|
||||||
self.half_height = (self.half_height * factor).max(MIN_HALF_HEIGHT);
|
self.half_height = self.half_height.mul_f64(factor);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a view from full-precision center coordinates and a half-height.
|
/// Build a view from full-precision center coordinates and a half-height.
|
||||||
pub fn with_center(center_re: Big, center_im: Big, half_height: f64) -> Self {
|
pub fn with_center(center_re: Big, center_im: Big, half_height: Scale) -> Self {
|
||||||
let mut v = Self {
|
let mut v = Self {
|
||||||
center_re,
|
center_re,
|
||||||
center_im,
|
center_im,
|
||||||
half_height: half_height.max(MIN_HALF_HEIGHT),
|
half_height,
|
||||||
};
|
};
|
||||||
v.sync_precision();
|
v.sync_precision();
|
||||||
v
|
v
|
||||||
@@ -164,11 +366,7 @@ pub fn parse_view_spec(spec: &str) -> Option<(ViewState, Option<u32>)> {
|
|||||||
if parts.len() < 3 {
|
if parts.len() < 3 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let half_height = parts[2].trim().parse::<f64>().ok()?;
|
let half_height = parse_half_height_spec(parts[2])?;
|
||||||
if !(half_height > 0.0 && half_height.is_finite()) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let half_height = half_height.max(MIN_HALF_HEIGHT);
|
|
||||||
let bits = precision_for(half_height);
|
let bits = precision_for(half_height);
|
||||||
let re = big_from_decimal_str(parts[0], bits)?;
|
let re = big_from_decimal_str(parts[0], bits)?;
|
||||||
let im = big_from_decimal_str(parts[1], bits)?;
|
let im = big_from_decimal_str(parts[1], bits)?;
|
||||||
@@ -179,12 +377,8 @@ pub fn parse_view_spec(spec: &str) -> Option<(ViewState, Option<u32>)> {
|
|||||||
/// Parse a half_height spec. Shared by
|
/// Parse a half_height spec. Shared by
|
||||||
/// `FractalApp::apply_half_height_spec` (the `--zoom` CLI flag) and headless
|
/// `FractalApp::apply_half_height_spec` (the `--zoom` CLI flag) and headless
|
||||||
/// animation's `--to-zoom`.
|
/// animation's `--to-zoom`.
|
||||||
pub fn parse_half_height_spec(spec: &str) -> Option<f64> {
|
pub fn parse_half_height_spec(spec: &str) -> Option<Scale> {
|
||||||
let half_height = spec.trim().parse::<f64>().ok()?;
|
spec.parse::<Scale>().ok()
|
||||||
if !(half_height > 0.0 && half_height.is_finite()) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(half_height.max(MIN_HALF_HEIGHT))
|
|
||||||
}
|
}
|
||||||
/// Parse a "re,im" spec (re/im decimal, parsed at
|
/// Parse a "re,im" spec (re/im decimal, parsed at
|
||||||
/// full precision) into a view. Shared by
|
/// full precision) into a view. Shared by
|
||||||
@@ -213,16 +407,32 @@ pub fn parse_re_im_spec(spec: &str, bits: usize) -> Option<(Big, Big)> {
|
|||||||
/// offset/half_height ratio roughly constant, i.e. the target's on-screen
|
/// offset/half_height ratio roughly constant, i.e. the target's on-screen
|
||||||
/// position steady) but is shifted so it lands on exactly 1 at `t = 0` and
|
/// position steady) but is shifted so it lands on exactly 1 at `t = 0` and
|
||||||
/// exactly 0 at `t = 1`.
|
/// exactly 0 at `t = 1`.
|
||||||
|
///
|
||||||
|
/// With `d = log2(q)`, `g = q^t · (1 - q^(1-t)) / (1 - q)`, all in `Scale`
|
||||||
|
/// / `expm1` form: zooming in by more than f64's range, `q` (and `q^t`)
|
||||||
|
/// underflow, yet `g · (from - to)` must keep tracking the half-height.
|
||||||
pub fn interpolate_view(from: &ViewState, to: &ViewState, t: f64) -> ViewState {
|
pub fn interpolate_view(from: &ViewState, to: &ViewState, t: f64) -> ViewState {
|
||||||
let q = to.half_height / from.half_height;
|
let (l0, l1) = (from.half_height.log2(), to.half_height.log2());
|
||||||
let half_height = from.half_height * q.powf(t);
|
let d = l1 - l0;
|
||||||
let bits = precision_for(half_height);
|
let half_height = if t <= 0.0 {
|
||||||
let g = if (q - 1.0).abs() < 1e-12 {
|
from.half_height
|
||||||
1.0 - t
|
} else if t >= 1.0 {
|
||||||
|
to.half_height
|
||||||
} else {
|
} else {
|
||||||
(q.powf(t) - q) / (1.0 - q)
|
Scale::from_log2(l0 + t * d)
|
||||||
|
};
|
||||||
|
let 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 g_big = big_from_f64(g, bits);
|
|
||||||
let re0 = from.center_re.clone().with_precision(bits).value();
|
let re0 = from.center_re.clone().with_precision(bits).value();
|
||||||
let im0 = from.center_im.clone().with_precision(bits).value();
|
let im0 = from.center_im.clone().with_precision(bits).value();
|
||||||
let re1 = to.center_re.clone().with_precision(bits).value();
|
let re1 = to.center_re.clone().with_precision(bits).value();
|
||||||
@@ -248,14 +458,10 @@ pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Precision (bits) needed to resolve the center at a given half-height.
|
/// Precision (bits) needed to resolve the center at a given half-height.
|
||||||
pub fn precision_for(half_height: f64) -> usize {
|
pub fn precision_for(half_height: Scale) -> usize {
|
||||||
// We need enough bits to distinguish points a pixel apart, i.e. roughly
|
// We need enough bits to distinguish points a pixel apart, i.e. roughly
|
||||||
// log2(1 / half_height) significant bits, plus a guard margin.
|
// log2(1 / half_height) significant bits, plus a guard margin.
|
||||||
let zoom_bits = if half_height > 0.0 && half_height.is_finite() {
|
let zoom_bits = (-half_height.log2()).ceil().max(0.0) as usize;
|
||||||
(-half_height.log2()).ceil().max(0.0) as usize
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
(zoom_bits + GUARD_BITS).clamp(53, MAX_PRECISION_BITS)
|
(zoom_bits + GUARD_BITS).clamp(53, MAX_PRECISION_BITS)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +477,10 @@ pub fn big_from_f64(x: f64, bits: usize) -> Big {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn sc(x: f64) -> Scale {
|
||||||
|
Scale::from_f64(x)
|
||||||
|
}
|
||||||
|
|
||||||
fn re_im_f64(v: &ViewState) -> (f64, f64) {
|
fn re_im_f64(v: &ViewState) -> (f64, f64) {
|
||||||
let re: f64 = v.center_re.to_decimal().value().to_f64().value();
|
let re: f64 = v.center_re.to_decimal().value().to_f64().value();
|
||||||
let im: f64 = v.center_im.to_decimal().value().to_f64().value();
|
let im: f64 = v.center_im.to_decimal().value().to_f64().value();
|
||||||
@@ -279,12 +489,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interpolate_view_hits_exact_endpoints() {
|
fn interpolate_view_hits_exact_endpoints() {
|
||||||
let bits = precision_for(1.0);
|
let bits = precision_for(sc(1.0));
|
||||||
let from = ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), 1.5);
|
let from = ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), sc(1.5));
|
||||||
let to = ViewState::with_center(
|
let to = ViewState::with_center(
|
||||||
big_from_f64(-0.7515, precision_for(1e-20)),
|
big_from_f64(-0.7515, precision_for(sc(1e-20))),
|
||||||
big_from_f64(0.1013, precision_for(1e-20)),
|
big_from_f64(0.1013, precision_for(sc(1e-20))),
|
||||||
1e-20,
|
sc(1e-20),
|
||||||
);
|
);
|
||||||
|
|
||||||
let start = interpolate_view(&from, &to, 0.0);
|
let start = interpolate_view(&from, &to, 0.0);
|
||||||
@@ -304,12 +514,12 @@ mod tests {
|
|||||||
/// instead stay roughly bounded throughout.
|
/// instead stay roughly bounded throughout.
|
||||||
#[test]
|
#[test]
|
||||||
fn interpolate_view_keeps_target_offset_bounded() {
|
fn interpolate_view_keeps_target_offset_bounded() {
|
||||||
let bits = precision_for(1.0);
|
let bits = precision_for(sc(1.0));
|
||||||
let from = ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), 1.5);
|
let from = ViewState::with_center(big_from_f64(-0.5, bits), big_from_f64(0.0, bits), sc(1.5));
|
||||||
let to = ViewState::with_center(
|
let to = ViewState::with_center(
|
||||||
big_from_f64(-0.7515, precision_for(1e-20)),
|
big_from_f64(-0.7515, precision_for(sc(1e-20))),
|
||||||
big_from_f64(0.1013, precision_for(1e-20)),
|
big_from_f64(0.1013, precision_for(sc(1e-20))),
|
||||||
1e-20,
|
sc(1e-20),
|
||||||
);
|
);
|
||||||
let (to_re, to_im) = re_im_f64(&to);
|
let (to_re, to_im) = re_im_f64(&to);
|
||||||
|
|
||||||
@@ -318,7 +528,7 @@ mod tests {
|
|||||||
let mid = interpolate_view(&from, &to, t);
|
let mid = interpolate_view(&from, &to, t);
|
||||||
let (re, im) = re_im_f64(&mid);
|
let (re, im) = re_im_f64(&mid);
|
||||||
let offset = ((re - to_re).powi(2) + (im - to_im).powi(2)).sqrt();
|
let offset = ((re - to_re).powi(2) + (im - to_im).powi(2)).sqrt();
|
||||||
let ratio = offset / mid.half_height;
|
let ratio = offset / mid.half_height.to_f64();
|
||||||
assert!(
|
assert!(
|
||||||
ratio < 10.0,
|
ratio < 10.0,
|
||||||
"t={t}: offset/half_height ratio {ratio} blew up (offset={offset}, half_height={})",
|
"t={t}: offset/half_height ratio {ratio} blew up (offset={offset}, half_height={})",
|
||||||
@@ -326,4 +536,74 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scale_parse_display_round_trip() {
|
||||||
|
for s in ["1.25", "1e-20", "1.5e-20", "3.7e-4000", "1e-400", "9.99999e-310"] {
|
||||||
|
let a: Scale = s.parse().unwrap();
|
||||||
|
let b: Scale = a.to_string().parse().unwrap();
|
||||||
|
assert_eq!(a, b, "{s} -> {a}");
|
||||||
|
}
|
||||||
|
assert_eq!("1.25".parse::<Scale>().unwrap().to_f64(), 1.25);
|
||||||
|
assert_eq!(sc(1.5e-20).to_string(), "1.5e-20");
|
||||||
|
let deep: Scale = "3.7e-4000".parse().unwrap();
|
||||||
|
assert_eq!(deep.to_string(), "3.7e-4000");
|
||||||
|
assert_eq!(format!("{deep:.2}"), "3.70e-4000");
|
||||||
|
assert!((deep.log10() - (3.7f64.log10() - 4000.0)).abs() < 1e-9);
|
||||||
|
assert!("0".parse::<Scale>().is_err());
|
||||||
|
assert!("-1e-500".parse::<Scale>().is_err());
|
||||||
|
assert!("abc".parse::<Scale>().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scale_arithmetic() {
|
||||||
|
let a: Scale = "1e-1000".parse().unwrap();
|
||||||
|
let b = a.mul_f64(0.25);
|
||||||
|
assert!((b.ratio(a) - 0.25).abs() < 1e-15);
|
||||||
|
assert!(b < a && a > b);
|
||||||
|
assert_eq!(a.mul_f64(3.0).mul_f64(1.0 / 3.0).exponent(), a.exponent());
|
||||||
|
assert_eq!(sc(1.0).exponent(), 0);
|
||||||
|
assert_eq!(sc(0.75).exponent(), -1);
|
||||||
|
assert_eq!(sc(4.0).scaled_f64(-2), 1.0);
|
||||||
|
assert_eq!(a.scaled_f64(-a.exponent()), a.mul_f64(1.0).scaled_f64(-a.exponent()));
|
||||||
|
assert!((1.0..2.0).contains(&a.scaled_f64(-a.exponent())));
|
||||||
|
assert_eq!(a.to_f64(), 0.0);
|
||||||
|
assert_eq!(Scale::MIN.mul_f64(0.5), Scale::MIN);
|
||||||
|
let p = precision_for(a);
|
||||||
|
assert!((3322 + 48..=3323 + 48).contains(&p), "{p}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Past f64's range, the center must still land on the target at the
|
||||||
|
/// same geometric pace as the half-height.
|
||||||
|
#[test]
|
||||||
|
fn interpolate_view_past_f64_range() {
|
||||||
|
let from = ViewState::with_center(
|
||||||
|
big_from_f64(-0.5, 64),
|
||||||
|
big_from_f64(0.0, 64),
|
||||||
|
sc(1.5),
|
||||||
|
);
|
||||||
|
let hh: Scale = "1e-1000".parse().unwrap();
|
||||||
|
let bits = precision_for(hh);
|
||||||
|
let to = ViewState::with_center(
|
||||||
|
big_from_f64(-0.7515, bits),
|
||||||
|
big_from_f64(0.1013, bits),
|
||||||
|
hh,
|
||||||
|
);
|
||||||
|
let end = interpolate_view(&from, &to, 1.0);
|
||||||
|
assert_eq!(end.half_height, hh);
|
||||||
|
assert_eq!(re_im_f64(&end), re_im_f64(&to));
|
||||||
|
let mut prev = from.half_height;
|
||||||
|
for i in 1..20 {
|
||||||
|
let t = i as f64 / 20.0;
|
||||||
|
let mid = interpolate_view(&from, &to, t);
|
||||||
|
assert!(mid.half_height < prev);
|
||||||
|
prev = mid.half_height;
|
||||||
|
// Offset from the target, in units of the view's half-height.
|
||||||
|
let k = -mid.half_height.exponent() as isize;
|
||||||
|
let dre = ((&mid.center_re - &to.center_re) << k).to_f64().value();
|
||||||
|
let dim = ((&mid.center_im - &to.center_im) << k).to_f64().value();
|
||||||
|
let ratio = (dre * dre + dim * dim).sqrt() / mid.half_height.scaled_f64(k as i32);
|
||||||
|
assert!(ratio > 0.01 && ratio < 10.0, "t={t}: ratio {ratio}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -10,12 +10,12 @@ 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, RefOrbit, compute_reference, compute_set_reference};
|
||||||
use crate::view::{Big, big_from_f64};
|
use crate::view::{Big, Scale, big_from_f64};
|
||||||
|
|
||||||
pub struct RefRequest {
|
pub struct RefRequest {
|
||||||
pub center_re: Big,
|
pub center_re: Big,
|
||||||
pub center_im: Big,
|
pub center_im: Big,
|
||||||
pub half_height: f64,
|
pub half_height: Scale,
|
||||||
pub julia: bool,
|
pub julia: bool,
|
||||||
pub julia_c: (f64, f64),
|
pub julia_c: (f64, f64),
|
||||||
pub max_iter: u32,
|
pub max_iter: u32,
|
||||||
@@ -35,7 +35,7 @@ pub struct RefRequest {
|
|||||||
pub struct RefResult {
|
pub struct RefResult {
|
||||||
pub center_re: Big,
|
pub center_re: Big,
|
||||||
pub center_im: Big,
|
pub center_im: Big,
|
||||||
pub half_height: f64,
|
pub half_height: Scale,
|
||||||
pub points: RefOrbit,
|
pub points: RefOrbit,
|
||||||
/// The kind and morph `points` was computed with (echoed from the
|
/// The kind and morph `points` was computed with (echoed from the
|
||||||
/// request).
|
/// request).
|
||||||
|
|||||||
Reference in New Issue
Block a user