From fa42de44a74d4f5e8c0bba3d2bf0877cf0488d93 Mon Sep 17 00:00:00 2001 From: Rowan Merewood Date: Mon, 28 Sep 2026 16:19:35 +0100 Subject: [PATCH] perf: pack reference orbit to 16 bytes/point, fast-extend periodic orbits, and scale deep-zoom slice ceiling --- AGENTS.md | 8 +-- src/Renderer.js | 11 +-- src/Renderer.test.js | 4 ++ src/renderer/shader.wgsl | 78 ++++++++++---------- wasm/src/lib.rs | 151 +++++++++++++++++++++++++-------------- 5 files changed, 149 insertions(+), 103 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d3e2491..a15291a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,9 +115,9 @@ In `shader.wgsl`, the pixel's total iteration count `i` (`0..uniforms.iter`) is - Byte `76`: `ref_iter` (`u32`, valid pre-escape length of `reference_orbit`) - Bytes `80..95`: `sa_params` (`vec4`: `[bitcast(skip_iter), inv_rmax_hi, inv_rmax_lo, 0.0]`) - Bytes `96..223`: `sa_coeffs` (`array, 8>`: Double-Single normalized Series Approximation coefficients $a_1, \dots, a_8$ on the disk $|\Delta c| \le R_{\text{max}}$) -- **`reference_orbit` Storage Buffer (`(calcIter + 1) * 32 bytes`)**: - - Each orbit step `m` occupies `2 × vec4` (`32 bytes`): `[zx0, zx1, zx2, zx3]` at byte offset `m * 32` (`m * 2u`) and `[zy0, zy1, zy2, zy3]` at byte offset `m * 32 + 16` (`m * 2u + 1u`). - - The orbit ends at the anchor's escape point (or `calcIter`), so `ref_iter` is simply `orbit.byteLength / 32 - 1`. +- **`reference_orbit` Storage Buffer (`(calcIter + 1) * 16 bytes`)**: + - Each orbit step `m` occupies a single `vec4` (`16 bytes`): `[zx_hi, zx_lo, zy_hi, zy_lo]` at byte offset `m * 16`. + - The orbit ends at the anchor's escape point (or `calcIter`), so `ref_iter` is simply `orbit.byteLength / 16 - 1`. ### 3.4 Dual-Canvas Zero-Latency Interaction (`#fractal-bg` + `#fractal`) @@ -126,7 +126,7 @@ While dragging/zooming or waiting for the WASM worker to compute a new reference 1. `#fractal-bg` (a 2D canvas behind `#fractal`) is updated via `_updateBackgroundCanvas()` (`bgCtx.drawImage(this.canvas, 0, 0)`) whenever a render completes (`state.nextRow >= canvas.height`), including single-slice low-resolution interactive previews. 2. When the viewport transitions from a completed low-res preview to a multi-slice progressive high-res render (`this.canvas.width` / `height` resize), `#fractal-bg` retains the low-res preview underneath `#fractal` so there is never a black flash or stale-frame jump while progressive slices fill in. 3. Progressive high-res slices in `Fractious.frame()` are gated on `device.queue.onSubmittedWorkDone()` and a monotonically increasing `_renderGeneration` token so GPU submissions never saturate the browser compositor queue and user input can preempt in-flight progressive passes within a single frame. -4. Progressive slice height is adaptive: `Renderer.recordSliceTime()` learns per-tier GPU throughput (in worst-case ops per ms) from each slice's submit-to-done time, and `_sliceRows()` sizes the next slice (`Math.ceil`) to about `TARGET_SLICE_MS` (32ms, above a 16.7ms 60Hz VSync frame on mobile). Slower measurements apply immediately, faster ones at most double the estimate, and slices are floored at `MIN_SLICE_ROWS` (32 rows, one mobile TBDR hardware tile strip) and capped at `MAX_SLICE_BUDGETS` × the fixed worst-case budget so high-iteration serial latency never collapses slices to 1 row and exterior-to-interior transitions stay bounded. Each full-res render records a `fractious:full-res` User Timing measure for traces. +4. Progressive slice height is adaptive: `Renderer.recordSliceTime()` learns per-tier GPU throughput (in worst-case ops per ms) from each slice's submit-to-done time, and `_sliceRows()` sizes the next slice (`Math.ceil`) to about `TARGET_SLICE_MS` (32ms, above a 16.7ms 60Hz VSync frame on mobile). Slower measurements apply immediately, faster ones at most double the estimate, and slices are floored at `MIN_SLICE_ROWS` (32 rows, one mobile TBDR hardware tile strip) and capped at `Math.max(budget * MAX_SLICE_BUDGETS, MIN_SLICE_ROWS * 2 * rowOps)` so high-iteration serial latency never collapses slices to 1 row, ultra-deep exterior slices can still scale up to `2 × MIN_SLICE_ROWS`, and exterior-to-interior transitions stay bounded. Each full-res render records a `fractious:full-res` User Timing measure for traces. --- diff --git a/src/Renderer.js b/src/Renderer.js index 3e91875..04f473d 100644 --- a/src/Renderer.js +++ b/src/Renderer.js @@ -93,7 +93,7 @@ export class Renderer { }); // initial minimal size, will be updated when orbit arrives - this.referenceOrbitSize = (200 + 1) * 8 * 4; + this.referenceOrbitSize = (200 + 1) * 4 * 4; this.referenceOrbitBuffer = this.device.createBuffer({ size: Math.max(this.referenceOrbitSize, 16), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, @@ -188,7 +188,7 @@ export class Renderer { const requiredSize = orbitArrayBuffer.byteLength; // The worker truncates the orbit at its escape point, so the last stored // point index is the valid reference length. - this.referenceOrbitMaxIter = Math.max(0, Math.floor(requiredSize / 32) - 1); + this.referenceOrbitMaxIter = Math.max(0, Math.floor(requiredSize / 16) - 1); if (sa && sa.length === 36) { this.saUniformView.set(sa); this.skipIter = Math.floor(this.uniformDataView.getFloat32(80, true)); @@ -300,10 +300,11 @@ export class Renderer { const rowOps = this.canvas.width * this._effectiveIter(config); const budget = PROGRESSIVE_MAX_OPS * tier.opsMultiplier; const opsPerMs = this.throughput.get(tier.name) || budget / TARGET_SLICE_MS; - const ops = Math.min( - opsPerMs * TARGET_SLICE_MS, + const maxOps = Math.max( budget * MAX_SLICE_BUDGETS, + MIN_SLICE_ROWS * 2 * rowOps, ); + const ops = Math.min(opsPerMs * TARGET_SLICE_MS, maxOps); return Math.min( remaining, Math.max(MIN_SLICE_ROWS, Math.ceil(ops / rowOps)), @@ -384,7 +385,7 @@ export class Renderer { const maxBufferIter = Math.max( 0, - Math.floor(this.referenceOrbitSize / 32) - 1, + Math.floor(this.referenceOrbitSize / 16) - 1, ); const refIter = Math.max( 1, diff --git a/src/Renderer.test.js b/src/Renderer.test.js index dfd950e..86347a9 100644 --- a/src/Renderer.test.js +++ b/src/Renderer.test.js @@ -55,6 +55,10 @@ describe('Renderer adaptive progressive slices', () => { renderer.throughput.set('QS', 1e12); // capped at 16 * 200M ops = 640 rows expect(renderer._sliceRows(config, progressiveState(), tier)).toBe(640); + // At ultra-high iterations where 16 * budget < 32 rows, fast slices can still scale up to 2 * MIN_SLICE_ROWS (64) + expect( + renderer._sliceRows({ iter: 150000 }, progressiveState(), tier), + ).toBe(64); expect(renderer._sliceRows(config, progressiveState(990), tier)).toBe(10); }); diff --git a/src/renderer/shader.wgsl b/src/renderer/shader.wgsl index a852477..6171711 100644 --- a/src/renderer/shader.wgsl +++ b/src/renderer/shader.wgsl @@ -232,13 +232,8 @@ struct Uniforms { sa_coeffs: array, 8>, }; -struct OrbitPoint { - re: vec4, - im: vec4, -}; - @group(0) @binding(0) var uniforms: Uniforms; -@group(0) @binding(1) var reference_orbit: array; +@group(0) @binding(1) var reference_orbit: array>; struct VertexOutput { @builtin(position) position: vec4, @@ -276,7 +271,7 @@ fn ds_sub(a: vec2, b: vec2) -> vec2 { fn ds_mul(a: vec2, b: vec2) -> vec2 { let p = a.x * b.x; let e1 = fma(a.x, b.x, -p); - let e2 = a.y * b.x + a.x * b.y; + let e2 = fma(a.y, b.x, a.x * b.y); let hi = p + e2; let lo = e1 + e2 - (hi - p); return vec2(hi, lo); @@ -397,16 +392,19 @@ fn fs_main_f32(@location(0) uv: vec2) -> @location(0) vec4 { let check_interior = uniforms.zoom.x > INTERIOR_CHECK_MIN_ZOOM; let c_ref = reference_orbit[1]; if (check_interior && - in_main_bulbs(vec2(c_ref.re.x + c_delta_re, c_ref.im.x + c_delta_im))) { + in_main_bulbs(vec2(c_ref.x + c_delta_re, c_ref.z + c_delta_im))) { return compute_color(uniforms.iter, 0.0, vec2(0.0), uv); } - // Brent-style periodicity check: an orbit that returns (within a small fraction of - // a pixel, floored above f32 rounding noise) to a saved point is periodic, so the - // pixel is interior. Cap the checkpoint window so slowly converging boundary orbits - // refresh their saved point regularly. - let period_tol = clamp(uniforms.zoom.x * 2.0e-4, 2.5e-7, 5.0e-5); + // Brent-style periodicity check: at shallow zooms compare absolute Z_n; at deeper + // zooms compare perturbations delta_n when next_m == period_m (so X_m cancels out). + let period_tol = select( + uniforms.zoom.x * 2.0e-4, + clamp(uniforms.zoom.x * 2.0e-4, 2.5e-7, 5.0e-5), + check_interior + ); let period_tol_sq = period_tol * period_tol; var period_z = vec2(0.0, 0.0); + var period_m: u32 = 0u; var period_len: u32 = 8u; var period_step: u32 = 0u; @@ -430,8 +428,8 @@ fn fs_main_f32(@location(0) uv: vec2) -> @location(0) vec4 { i = skip_iter; m = skip_iter; let init_xm = reference_orbit[skip_iter]; - x_re = init_xm.re.x; - x_im = init_xm.im.x; + x_re = init_xm.x; + x_im = init_xm.z; } loop { @@ -447,8 +445,8 @@ fn fs_main_f32(@location(0) uv: vec2) -> @location(0) vec4 { let next_m = m + 1u; let raw_xm_next = reference_orbit[next_m]; - let nx_re = raw_xm_next.re.x; - let nx_im = raw_xm_next.im.x; + let nx_re = raw_xm_next.x; + let nx_im = raw_xm_next.z; // Compute zn in single precision let zn_re = nx_re + delta_re; @@ -462,18 +460,20 @@ fn fs_main_f32(@location(0) uv: vec2) -> @location(0) vec4 { break; } - if (check_interior) { - let dz = vec2(zn_re, zn_im) - period_z; + let cur_z = select(vec2(delta_re, delta_im), vec2(zn_re, zn_im), check_interior); + if (check_interior || next_m == period_m) { + let dz = cur_z - period_z; if (dot(dz, dz) < period_tol_sq) { i = uniforms.iter; break; } - period_step = period_step + 1u; - if (period_step == period_len) { - period_step = 0u; - period_len = min(period_len * 2u, 128u); - period_z = vec2(zn_re, zn_im); - } + } + period_step = period_step + 1u; + if (period_step == period_len) { + period_step = 0u; + period_len = min(period_len * 2u, 128u); + period_z = cur_z; + period_m = next_m; } let delta_norm_sq = fma(delta_re, delta_re, delta_im * delta_im); @@ -526,10 +526,7 @@ fn fs_main_ds(@location(0) uv: vec2) -> @location(0) vec4 { i = skip_iter; m = skip_iter; let init_xm = reference_orbit[skip_iter]; - xm = ds_complex( - vec2(init_xm.re.x, init_xm.re.y), - vec2(init_xm.im.x, init_xm.im.y) - ); + xm = ds_complex(init_xm.xy, init_xm.zw); } loop { @@ -541,10 +538,7 @@ fn fs_main_ds(@location(0) uv: vec2) -> @location(0) vec4 { let next_m = m + 1u; let raw_xm_next = reference_orbit[next_m]; - let xm_next = ds_complex( - vec2(raw_xm_next.re.x, raw_xm_next.re.y), - vec2(raw_xm_next.im.x, raw_xm_next.im.y) - ); + let xm_next = ds_complex(raw_xm_next.xy, raw_xm_next.zw); // Compute zn in single precision let zn_re = xm_next.re.x + delta.re.x; @@ -595,7 +589,7 @@ fn fs_main_qs(@location(0) uv: vec2) -> @location(0) vec4 { var zn_sq: f32 = 0.0; var zn_sp = vec2(0.0, 0.0); // X_m, carried over from the previous iteration's X_{m+1} (X_0 = 0). - var raw_xm = OrbitPoint(vec4(0.0), vec4(0.0)); + var raw_xm = vec4(0.0); let skip_iter = u32(uniforms.sa_params.x); let u_norm = vec2(c_delta_re.x, c_delta_im.x) * uniforms.sa_params.y; @@ -614,15 +608,18 @@ fn fs_main_qs(@location(0) uv: vec2) -> @location(0) vec4 { if (i >= uniforms.iter) { break; } // delta_{n+1} = (2 * X_m + delta_n) * delta_n + delta_0 - let w = qs_complex(qs_add(raw_xm.re * 2.0, delta.re), qs_add(raw_xm.im * 2.0, delta.im)); + let w = qs_complex( + qs_add(vec4(raw_xm.xy * 2.0, 0.0, 0.0), delta.re), + qs_add(vec4(raw_xm.zw * 2.0, 0.0, 0.0), delta.im) + ); delta = qc_add(qc_mul(w, delta), c_delta_qs); let next_m = m + 1u; let raw_xm_next = reference_orbit[next_m]; // Compute zn in single precision for escape check & coloring - let zn_re = raw_xm_next.re.x + delta.re.x; - let zn_im = raw_xm_next.im.x + delta.im.x; + let zn_re = raw_xm_next.x + delta.re.x; + let zn_im = raw_xm_next.z + delta.im.x; zn_sq = fma(zn_re, zn_re, zn_im * zn_im); i = i + 1u; @@ -634,10 +631,13 @@ fn fs_main_qs(@location(0) uv: vec2) -> @location(0) vec4 { let delta_norm_sq = fma(delta.re.x, delta.re.x, delta.im.x * delta.im.x); if (zn_sq < delta_norm_sq || next_m >= uniforms.ref_iter) { - let xm_next = qs_complex(raw_xm_next.re, raw_xm_next.im); + let xm_next = qs_complex( + vec4(raw_xm_next.xy, 0.0, 0.0), + vec4(raw_xm_next.zw, 0.0, 0.0) + ); delta = qc_add(xm_next, delta); m = 0u; - raw_xm = OrbitPoint(vec4(0.0), vec4(0.0)); + raw_xm = vec4(0.0); } else { m = next_m; raw_xm = raw_xm_next; diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index d89ff74..c5c1e05 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -5,7 +5,7 @@ use std::convert::TryFrom; use std::str::FromStr; use wasm_bindgen::prelude::*; -/// Hard cap on reference orbit length (GPU buffer is 32 bytes per point). +/// Hard cap on reference orbit length (GPU buffer is 16 bytes per point). const MAX_REFERENCE_ITER: u64 = 2_500_000; #[wasm_bindgen] @@ -44,16 +44,6 @@ fn split_f64_to_2_f32(val: f64) -> (f32, f32) { (hi, lo) } -fn split_f64_to_4_f32(val: f64) -> (f32, f32, f32, f32) { - let part0 = val as f32; - let r1 = val - f64::from(part0); - let part1 = r1 as f32; - let r2 = r1 - f64::from(part1); - let part2 = r2 as f32; - let part3 = (r2 - f64::from(part2)) as f32; - (part0, part1, part2, part3) -} - /// Fixed inputs for iterating z -> z^2 + c at a given binary precision. struct IterCtx<'a> { cx: FBig, @@ -121,14 +111,16 @@ impl CycleDetector { } /// A Mandelbrot orbit z_0 = 0, z_{n+1} = z_n^2 + c in arbitrary precision. It can be -/// advanced in stages, optionally recording each point as quad-f32 (the GPU reference -/// layout: 8 floats per point) and/or f64 (for the perturbation probes). +/// advanced in stages, optionally recording each point as double-single f32 (the GPU +/// reference layout: 4 floats per point) and/or f64 (for the perturbation probes). struct Orbit { zx: FBig, zy: FBig, /// Index of the current point z_n, which has not been recorded yet. n: u32, escaped: bool, + period: Option, + period_locked: bool, qs: Option>, f64s: Option>, } @@ -141,11 +133,33 @@ impl Orbit { zy: zero, n: 0, escaped: false, + period: None, + period_locked: false, qs: record_qs.then(Vec::new), f64s: record_f64.then(Vec::new), } } + fn extend_locked_cycle(&mut self, limit: u32, p: usize) { + if self.n > limit || p == 0 { + return; + } + let rem = (limit - self.n + 1) as usize; + if let Some(qs) = self.qs.as_mut() { + let start = qs.len() - p * 4; + for idx in 0..(rem * 4) { + qs.push(qs[start + idx]); + } + } + if let Some(f64s) = self.f64s.as_mut() { + let start = f64s.len() - p; + for idx in 0..rem { + f64s.push(f64s[start + idx]); + } + } + self.n = limit + 1; + } + /// Records and iterates points up to and including z_limit, stopping early on escape /// (the escape point is recorded) or when a cycle is detected (the repeating point is /// not recorded, so a later `advance` resumes exactly where this one stopped). @@ -158,9 +172,16 @@ impl Orbit { ) -> bool { let prec = ctx.prec; if let Some(qs) = self.qs.as_mut() { - qs.reserve((limit.saturating_sub(self.n) as usize + 1) * 8); + qs.reserve((limit.saturating_sub(self.n) as usize + 1) * 4); + } + if self.period_locked { + if let Some(p) = self.period.map(|p| p as usize) { + self.extend_locked_cycle(limit, p); + return true; + } } + let mut cycle_matches = 0_usize; while !self.escaped && self.n <= limit { if self.n.is_multiple_of(1000) && is_aborted(ctx.abort) { return false; @@ -169,13 +190,22 @@ impl Orbit { let z64 = (self.zx.to_f64().value(), self.zy.to_f64().value()); if let Some(detector) = cycles.as_deref_mut() { if detector.repeats(&self.zx, &self.zy, z64, self.n, prec) { + self.period = Some(detector.lam); return true; } } + let (x0, x1) = split_f64_to_2_f32(z64.0); + let (y0, y1) = split_f64_to_2_f32(z64.1); + let pt = [x0, x1, y0, y1]; + let matches_cycle = cycles.is_none() + && self.period.is_some_and(|p| { + let p = p as usize; + self.qs + .as_ref() + .is_some_and(|qs| qs.len() >= p * 4 && qs[qs.len() - p * 4..][..4] == pt) + }); if let Some(qs) = self.qs.as_mut() { - let (x0, x1, x2, x3) = split_f64_to_4_f32(z64.0); - let (y0, y1, y2, y3) = split_f64_to_4_f32(z64.1); - qs.extend_from_slice(&[x0, x1, x2, x3, y0, y1, y2, y3]); + qs.extend_from_slice(&pt); } if let Some(f64s) = self.f64s.as_mut() { f64s.push(z64); @@ -185,6 +215,19 @@ impl Orbit { self.escaped = true; break; } + if matches_cycle { + cycle_matches += 1; + if let Some(p) = self.period.map(|p| p as usize) { + if cycle_matches >= p { + self.n += 1; + self.period_locked = true; + self.extend_locked_cycle(limit, p); + break; + } + } + } else { + cycle_matches = 0; + } let zx2 = (&self.zx * &self.zx).with_precision(prec).value(); let zy2 = (&self.zy * &self.zy).with_precision(prec).value(); @@ -491,7 +534,7 @@ const SA_FLOATS: usize = 4 + SA_ORDER * 4; /// around the reference anchor, validated by 8 boundary probes on the circle `|u| = 1`. fn compute_sa(qs: &[f32], max_dc: f64) -> Vec { let mut out = vec![0.0_f32; SA_FLOATS]; - let num_points = qs.len() / 8; + let num_points = qs.len() / 4; if num_points <= 2 || !(1e-37..1e-3).contains(&max_dc) { return out; } @@ -511,9 +554,9 @@ fn compute_sa(qs: &[f32], max_dc: f64) -> Vec { let mut skip_iter = 0_u32; for m in 0..ref_iter { - let base = m * 8; - let xr = f64::from(qs[base]) + f64::from(qs[base + 1]) + f64::from(qs[base + 2]); - let xi = f64::from(qs[base + 4]) + f64::from(qs[base + 5]) + f64::from(qs[base + 6]); + let base = m * 4; + let xr = f64::from(qs[base]) + f64::from(qs[base + 1]); + let xi = f64::from(qs[base + 2]) + f64::from(qs[base + 3]); let tx = 2.0 * xr; let ty = 2.0 * xi; @@ -534,12 +577,9 @@ fn compute_sa(qs: &[f32], max_dc: f64) -> Vec { ai[k] = (tx * ak_i + ty * ak_r) + si; } - let next_base = (m + 1) * 8; - let xnr = - f64::from(qs[next_base]) + f64::from(qs[next_base + 1]) + f64::from(qs[next_base + 2]); - let xni = f64::from(qs[next_base + 4]) - + f64::from(qs[next_base + 5]) - + f64::from(qs[next_base + 6]); + let next_base = (m + 1) * 4; + let xnr = f64::from(qs[next_base]) + f64::from(qs[next_base + 1]); + let xni = f64::from(qs[next_base + 2]) + f64::from(qs[next_base + 3]); let mut valid = true; for (ux, uy, dr, di) in &mut probes { let dcx = *ux * max_dc; @@ -631,7 +671,7 @@ pub struct Reference { #[wasm_bindgen] impl Reference { - /// Moves the quad-f32 orbit out (8 floats per point, ending at the escape point if + /// Moves the double-single f32 orbit out (4 floats per point, ending at the escape point if /// the anchor escapes) without copying it through a getter. pub fn take_orbit(&mut self) -> Vec { std::mem::take(&mut self.orbit) @@ -728,10 +768,10 @@ mod tests { fn test_split_fbig() { let d = DBig::from_str("-0.743643887037158704752191506114774").unwrap(); let f = d.to_binary().value().with_precision(256).value(); - let (p0, p1, p2, p3) = split_f64_to_4_f32(f.to_f64().value()); - let reconstructed = (p0 as f64) + (p1 as f64) + (p2 as f64) + (p3 as f64); + let (p0, p1) = split_f64_to_2_f32(f.to_f64().value()); + let reconstructed = f64::from(p0) + f64::from(p1); let diff = (reconstructed - -0.7436438870371587_f64).abs(); - assert!(diff < 1e-15); + assert!(diff < 1e-14); } #[wasm_bindgen_test] @@ -772,51 +812,52 @@ mod tests { #[wasm_bindgen_test] fn test_orbit_origin() { - // max_iter = 2 -> points z_0..z_2 -> (2 + 1) * 8 = 24 floats - assert_eq!(reference_orbit("0.0", "0.0", 2, 53), vec![0.0; 24]); + // max_iter = 2 -> points z_0..z_2 -> (2 + 1) * 4 = 12 floats + assert_eq!(reference_orbit("0.0", "0.0", 2, 53), vec![0.0; 12]); } #[wasm_bindgen_test] fn test_orbit_diverge() { // Iter 0: z=0,0 -> stored. New z = 3,0 // Iter 1: z=3,0 -> stored. (3^2 + 0^2 > 4) -> escapes - // The orbit ends at the escape point (2 points = 16 floats) - let mut expected = vec![0.0; 16]; - expected[8] = 3.0; // zx0 at iter 1 + // The orbit ends at the escape point (2 points = 8 floats) + let mut expected = vec![0.0; 8]; + expected[4] = 3.0; // zx0 at iter 1 assert_eq!(reference_orbit("3.0", "0.0", 2, 53), expected); } #[wasm_bindgen_test] fn test_orbit_oscillate() { // c = (-1, 0): z alternates 0, -1, 0, -1 - // Vec structure for each iteration: [zx0..zx3, zy0..zy3] - let mut expected = vec![0.0; 32]; - expected[8] = -1.0; // Iter 1: z=(-1, 0) -> zx0 = -1.0 - expected[24] = -1.0; // Iter 3: z=(-1, 0) -> zx0 = -1.0 + // Vec structure for each iteration: [zx0, zx1, zy0, zy1] + let mut expected = vec![0.0; 16]; + expected[4] = -1.0; // Iter 1: z=(-1, 0) -> zx0 = -1.0 + expected[12] = -1.0; // Iter 3: z=(-1, 0) -> zx0 = -1.0 assert_eq!(reference_orbit("-1.0", "0.0", 3, 53), expected); } #[wasm_bindgen_test] fn test_orbit_invalid_input_falls_back_to_zero() { - assert_eq!(reference_orbit("invalid", "invalid", 2, 53), vec![0.0; 24]); + assert_eq!(reference_orbit("invalid", "invalid", 2, 53), vec![0.0; 12]); } #[wasm_bindgen_test] fn test_staged_advance_matches_single_pass() { - // Stop at a cycle, then resume: must be bit-identical to one uninterrupted pass. - let cx = DBig::from_str("-0.1").unwrap(); - let cy = DBig::from_str("0.1").unwrap(); - let ctx = IterCtx::new(&cx, &cy, 128, &None); - let mut staged = Orbit::new(128, true, false); - let mut cycles = CycleDetector::new(128); - assert!(staged.advance(&ctx, 5000, Some(&mut cycles))); - assert!(!staged.escaped && staged.n < 5000, "expected a cycle stop"); - assert!(staged.advance(&ctx, 6000, None)); - - assert_eq!( - staged.qs.unwrap(), - reference_orbit("-0.1", "0.1", 6000, 128) - ); + // Stop at a cycle, then resume across multiple stages: must be bit-identical to one uninterrupted pass. + for (c_re, c_im) in [("-0.1", "0.1"), ("-0.95", "0.05")] { + let cx = DBig::from_str(c_re).unwrap(); + let cy = DBig::from_str(c_im).unwrap(); + let ctx = IterCtx::new(&cx, &cy, 128, &None); + let mut staged = Orbit::new(128, true, false); + let mut cycles = CycleDetector::new(128); + assert!(staged.advance(&ctx, 5000, Some(&mut cycles))); + assert!(!staged.escaped && staged.n < 5000, "expected a cycle stop"); + assert!(staged.advance(&ctx, 5500, None)); + assert!(staged.period_locked, "expected cycle lock"); + assert!(staged.advance(&ctx, 6000, None)); + + assert_eq!(staged.qs.unwrap(), reference_orbit(c_re, c_im, 6000, 128)); + } } #[wasm_bindgen_test]