Compare commits

...
1 Commits
Author SHA1 Message Date
surv ea2a23999d feat: BLA iteration-skipping for deep-zoom Mandelbrot 2026-09-15 15:50:57 +02:00
6 changed files with 546 additions and 69 deletions
+67 -4
View File
@@ -5,7 +5,7 @@ use eframe::egui_wgpu;
use eframe::egui_wgpu::wgpu;
use crate::fractal::{
ExportRender, FractalCallback, FractalKind, FractalRenderer, MAX_REF_POINTS, ShareState,
Bla, ExportRender, FractalCallback, FractalKind, FractalRenderer, MAX_REF_POINTS, ShareState,
Uniforms,
};
#[cfg(target_arch = "wasm32")]
@@ -139,6 +139,11 @@ pub struct FractalApp {
/// Reference orbit (`Z_n` as f32 pairs) for the current view.
reference: Arc<Vec<[f32; 2]>>,
/// BLA iteration-skip table for `reference` (empty unless BLA is on and the
/// kind supports it). Uploaded to the GPU alongside the orbit.
bla: Arc<Vec<Bla>>,
/// Enable BLA iteration-skipping (square map only). Big speedup at deep zoom.
use_bla: bool,
/// Bumped whenever `reference` is replaced, so the GPU re-uploads it.
generation: u64,
/// Center + zoom the current `reference` was computed at (may differ
@@ -236,6 +241,8 @@ impl FractalApp {
antialias: false,
de_coloring: false,
reference: Arc::new(Vec::new()),
bla: Arc::new(Vec::new()),
use_bla: true,
generation: 0,
ref_center_re,
ref_center_im,
@@ -300,6 +307,9 @@ impl FractalApp {
if let Ok(spec) = std::env::var("MANDEL_VIEW") {
app.apply_view_spec(&spec);
}
if std::env::var("MANDEL_NO_BLA").is_ok() {
app.use_bla = false; // for BLA-on vs BLA-off comparison
}
if std::env::var("MANDEL_DE").is_ok() {
app.de_coloring = true;
}
@@ -491,14 +501,34 @@ impl FractalApp {
[dre, dim]
}
fn apply_reference(&mut self, points: Vec<[f32; 2]>, cre: Big, cim: Big, hh: f64) {
fn apply_reference(
&mut self,
points: Vec<[f32; 2]>,
bla: Vec<Bla>,
cre: Big,
cim: Big,
hh: f64,
) {
self.reference = Arc::new(points);
self.bla = Arc::new(bla);
self.ref_center_re = cre;
self.ref_center_im = cim;
self.ref_half_height = hh;
self.generation = self.generation.wrapping_add(1);
}
/// Upper bound on any pixel's `|dc|` for the current view (center→corner
/// distance), with margin so a reference reused after slight drift/zoom stays
/// valid. Used to size BLA merge radii.
fn bla_dc_max(&self, half_height: f64) -> f64 {
let aspect = if self.last_size_px.y > 0.0 {
(self.last_size_px.x / self.last_size_px.y) as f64
} else {
1.0
};
half_height * (1.0 + aspect * aspect).sqrt() * 2.0
}
/// Recompute the reference orbit when needed. Native: dispatch to a worker
/// thread and pick up completed results. Web: compute inline.
fn ensure_reference(&mut self) {
@@ -506,6 +536,9 @@ impl FractalApp {
let key = self.current_key();
let precision = self.view.precision_bits();
let max_iter = key.iter.min(MAX_REF_POINTS as u32 - 1);
// BLA applies to the holomorphic square map (Mandelbrot set + Julia).
let want_bla = self.use_bla && key.kind == FractalKind::Mandelbrot;
let dc_max = self.bla_dc_max(key.half_height);
#[cfg(not(target_arch = "wasm32"))]
{
@@ -519,6 +552,8 @@ impl FractalApp {
precision,
kind: key.kind,
power: key.power,
build_bla: want_bla,
dc_max,
});
self.pending = true;
}
@@ -547,8 +582,14 @@ impl FractalApp {
key.power,
)
};
let bla = if want_bla {
crate::fractal::build_bla_table(&points, dc_max)
} else {
Vec::new()
};
self.apply_reference(
points,
bla,
key.center_re.clone(),
key.center_im.clone(),
key.half_height,
@@ -560,7 +601,13 @@ impl FractalApp {
#[cfg(not(target_arch = "wasm32"))]
if let Some(res) = self.worker.try_take_latest() {
self.apply_reference(res.points, res.center_re, res.center_im, res.half_height);
self.apply_reference(
res.points,
res.bla,
res.center_re,
res.center_im,
res.half_height,
);
self.pending = false;
}
}
@@ -581,7 +628,11 @@ impl FractalApp {
power: self.power,
dc_offset: self.dc_offset(),
de_coloring: self.de_coloring as u32,
_pad: 0,
// Only when a table for the current reference is actually present, so
// a stale/empty buffer is never traversed during a kind switch.
use_bla: (self.use_bla
&& self.kind == FractalKind::Mandelbrot
&& !self.bla.is_empty()) as u32,
}
}
@@ -618,6 +669,7 @@ impl FractalApp {
renderer.export_handles()
};
let reference = Arc::clone(&self.reference);
let bla = Arc::clone(&self.bla);
let shared = Arc::new(Mutex::new(ExportShared {
fraction: 0.0,
@@ -645,6 +697,7 @@ impl FractalApp {
h,
uniforms,
reference.as_slice(),
bla.as_slice(),
);
// Render the image tile by tile, waiting for each so progress
@@ -710,6 +763,7 @@ impl FractalApp {
h,
uniforms,
reference.as_slice(),
bla.as_slice(),
);
// Render tile by tile, awaiting each submission so the browser
@@ -871,6 +925,14 @@ impl FractalApp {
for crisp filaments at deep zoom. Exact for Mandelbrot/Multibrot, \
approximate for Burning Ship/Tricorn.",
);
ui.add_enabled(
self.kind == FractalKind::Mandelbrot,
egui::Checkbox::new(&mut self.use_bla, "BLA skipping"),
)
.on_hover_text(
"Bivariate Linear Approximation: skip runs of iterations at deep zoom \
for a large speedup. Mandelbrot/Julia only.",
);
ui.separator();
// Editable center coordinates. Shown at full precision; parsed
@@ -1104,6 +1166,7 @@ impl FractalApp {
FractalCallback {
uniforms,
reference: Arc::clone(&self.reference),
bla: Arc::clone(&self.bla),
generation: self.generation,
size_px,
},
+1 -1
View File
@@ -5,7 +5,7 @@ pub mod reference;
pub mod renderer;
pub mod share;
pub use reference::{FractalKind, compute_reference, compute_set_reference};
pub use reference::{Bla, FractalKind, build_bla_table, compute_reference, compute_set_reference};
pub use renderer::{
ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms,
encode_png_with_progress,
+266
View File
@@ -146,6 +146,150 @@ pub fn compute_set_reference(
)
}
// ---------------------------------------------------------------------------
// Bivariate Linear Approximation (BLA)
//
// Deep in a zoom the per-pixel delta stays far smaller than the reference, so
// the nonlinear `e^2` term of the perturbation step is negligible and the step
// is effectively linear: `e -> A e + B dc`. BLA precomputes, for runs of
// iterations, the composed linear coefficients `(A, B)` plus a validity radius
// `r` (the largest `|e|` for which dropping `e^2` stays within tolerance). A
// pixel can then skip a whole run in one multiply whenever `|e| < r`.
//
// Runs are merged pairwise into a binary tree of levels: level `k` holds BLAs of
// length `2^k` starting at multiples of `2^k`. The GPU walks levels high→low to
// take the longest valid skip at the current index. Both sides recompute the
// per-level counts from `ref_len` (count[0] = ref_len-1, count[k] = count[k-1]/2)
// so no offset table needs to travel to the GPU — only this flat array does.
//
// Only the holomorphic square map (Mandelbrot/Julia, `A = 2 Z`, `B = 1`) is
// supported; other kinds fall back to per-iteration stepping on the GPU.
// ---------------------------------------------------------------------------
/// One merged linear step: `e_{n+l} = A e_n + B dc`, valid while `|e_n| < r`.
/// Laid out to match the WGSL `Bla` struct (two `vec2<f32>`, then `f32`, `u32`;
/// 24-byte std430 stride).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Bla {
pub a: [f32; 2],
pub b: [f32; 2],
pub r: f32,
pub l: u32,
}
/// Relative budget for the dropped nonlinear term, chosen near the f32 orbit
/// storage noise floor so BLA adds no visible error over plain perturbation.
const BLA_EPS: f64 = 1.0e-6;
/// f64 working form of a BLA (merges accumulate in f64, stored as f32).
#[derive(Clone, Copy)]
struct BlaF {
ar: f64,
ai: f64,
br: f64,
bi: f64,
r: f64,
l: u32,
}
impl BlaF {
fn to_bla(self) -> Bla {
Bla {
a: [self.ar as f32, self.ai as f32],
b: [self.br as f32, self.bi as f32],
r: self.r as f32,
l: self.l,
}
}
}
/// Merge two consecutive BLAs (`x` then `y`) into one covering both runs.
fn merge_bla(x: BlaF, y: BlaF, dc_max: f64) -> BlaF {
// A = Ay Ax ; B = Ay Bx + By (complex).
let ar = y.ar * x.ar - y.ai * x.ai;
let ai = y.ar * x.ai + y.ai * x.ar;
let br = y.ar * x.br - y.ai * x.bi + y.br;
let bi = y.ar * x.bi + y.ai * x.br + y.bi;
// Valid if |e| < rx (so x holds) and |Ax e + Bx dc| < ry (so y holds):
// |e| < (ry - |Bx| dc_max) / |Ax|.
let ax_mag = (x.ar * x.ar + x.ai * x.ai).sqrt();
let bx_mag = (x.br * x.br + x.bi * x.bi).sqrt();
let r_y = if ax_mag > 0.0 {
((y.r - bx_mag * dc_max) / ax_mag).max(0.0)
} else {
x.r
};
BlaF {
ar,
ai,
br,
bi,
r: x.r.min(r_y),
l: x.l + y.l,
}
}
/// Build the BLA table for a square-map reference orbit. `dc_max` is an upper
/// bound on any pixel's `|dc|` in the current view (used to size merge radii).
/// Returns a flat array with levels concatenated (level 0 first). Empty if the
/// orbit is too short to skip.
pub fn build_bla_table(points: &[[f32; 2]], dc_max: f64) -> Vec<Bla> {
let m = points.len();
if m < 2 {
return Vec::new();
}
// Level 0: one single step from each index i (uses Z_i). A = 2 Z, B = 1.
// Dropping e^2 is within tolerance while |e| < BLA_EPS |Z| (since the kept
// linear term is |2 Z e|).
let mut level0: Vec<BlaF> = Vec::with_capacity(m - 1);
for z in &points[..m - 1] {
let (zr, zi) = (z[0] as f64, z[1] as f64);
let zmag = (zr * zr + zi * zi).sqrt();
level0.push(BlaF {
ar: 2.0 * zr,
ai: 2.0 * zi,
br: 1.0,
bi: 0.0,
r: BLA_EPS * zmag,
l: 1,
});
}
let mut levels: Vec<Vec<BlaF>> = vec![level0];
while levels.last().unwrap().len() >= 2 {
let prev = levels.last().unwrap();
let mut next = Vec::with_capacity(prev.len() / 2);
let mut i = 0;
while i + 1 < prev.len() {
next.push(merge_bla(prev[i], prev[i + 1], dc_max));
i += 2;
}
levels.push(next);
}
let mut flat = Vec::with_capacity(levels.iter().map(|l| l.len()).sum());
for level in &levels {
flat.extend(level.iter().map(|b| b.to_bla()));
}
flat
}
/// Start offset of BLA level `lv` within the flat table, computed from the orbit
/// length exactly as the shader does (`count[0] = ref_len-1`, halving each
/// level). Kept here so the traversal test mirrors the GPU indexing.
#[cfg(test)]
fn bla_level_start(ref_len: usize, lv: u32) -> (usize, usize) {
let mut start = 0usize;
let mut count = ref_len - 1;
for _ in 0..lv {
start += count;
count /= 2;
}
(start, count)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -256,4 +400,126 @@ mod tests {
zi = nzi;
}
}
/// Step-by-step perturbation (drops nothing): `e_{n+1} = 2 Z_n e_n + e_n^2 + dc`,
/// using the stored f32 orbit as `Z_n`. Returns `e` after `target_n` steps.
fn advance_naive(points: &[[f32; 2]], dc: (f64, f64), target_n: usize) -> (f64, f64) {
let (mut er, mut ei) = (0.0f64, 0.0f64);
for z in &points[..target_n] {
let (zr, zi) = (z[0] as f64, z[1] as f64);
let tr = 2.0 * (zr * er - zi * ei);
let ti = 2.0 * (zr * ei + zi * er);
let sr = er * er - ei * ei;
let si = 2.0 * er * ei;
er = tr + sr + dc.0;
ei = ti + si + dc.1;
}
(er, ei)
}
/// Advance `e` to exactly `target_n` steps using the BLA table — the same
/// walk the shader performs (longest valid skip first, else a full step),
/// but never skipping past `target_n`. Returns `(e, took_a_multi_step_skip)`.
fn advance_bla(
points: &[[f32; 2]],
table: &[Bla],
dc: (f64, f64),
target_n: usize,
) -> ((f64, f64), bool) {
let ref_len = points.len();
let (mut er, mut ei) = (0.0f64, 0.0f64);
let mut n = 0usize;
let mut skipped = false;
while n < target_n {
let emag2 = er * er + ei * ei;
let mut applied = false;
// Highest level worth trying is bounded by how far we may advance.
let span = target_n - n;
let max_lv = (usize::BITS - 1 - span.leading_zeros()) as i64; // floor(log2(span))
let mut lv = max_lv;
while lv >= 0 {
let lvu = lv as u32;
let step = 1usize << lvu;
if n % step == 0 && n + step <= target_n {
let (start, count) = bla_level_start(ref_len, lvu);
let idx = n >> lvu;
if idx < count {
let b = table[start + idx];
let r = b.r as f64;
if emag2 < r * r {
let (ar, ai) = (b.a[0] as f64, b.a[1] as f64);
let (br, bi) = (b.b[0] as f64, b.b[1] as f64);
let ner = ar * er - ai * ei + br * dc.0 - bi * dc.1;
let nei = ar * ei + ai * er + br * dc.1 + bi * dc.0;
er = ner;
ei = nei;
n += step;
skipped |= step > 1;
applied = true;
break;
}
}
}
lv -= 1;
}
if !applied {
let (zr, zi) = (points[n][0] as f64, points[n][1] as f64);
let tr = 2.0 * (zr * er - zi * ei);
let ti = 2.0 * (zr * ei + zi * er);
let sr = er * er - ei * ei;
let si = 2.0 * er * ei;
er = tr + sr + dc.0;
ei = ti + si + dc.1;
n += 1;
}
}
((er, ei), skipped)
}
/// The BLA walk must reproduce step-by-step perturbation at deep zoom (where
/// the delta is tiny and skips actually fire).
#[test]
fn bla_matches_step_by_step() {
// An interior center (never escapes), so `e` stays bounded and we can
// iterate the full orbit; zoomed so |dc| ~ 1e-9 (deep enough for big
// skips). Correctness of the walk is independent of which orbit we pick.
let cr = Big::try_from(-0.5_f64).unwrap();
let ci = Big::try_from(0.0_f64).unwrap();
let points = compute_set_reference(&cr, &ci, 800, 160, FractalKind::Mandelbrot, 2);
assert!(points.len() > 64, "need a long orbit to exercise BLA levels");
let half_height = 1.0e-9_f64;
let aspect = 1.5_f64;
let dc_max = half_height * (1.0 + aspect * aspect).sqrt();
let table = build_bla_table(&points, dc_max);
assert!(!table.is_empty());
let target_n = points.len() - 1;
// A few pixel offsets across the view (all within dc_max).
let offsets = [
(0.0, 0.0),
(0.6e-9, -0.4e-9),
(-0.9e-9, 0.3e-9),
(0.2e-9, 0.8e-9),
];
let mut any_skip = false;
for dc in offsets {
let naive = advance_naive(&points, dc, target_n);
let (bla, skipped) = advance_bla(&points, &table, dc, target_n);
any_skip |= skipped;
let ref_mag = (naive.0 * naive.0 + naive.1 * naive.1).sqrt();
let err = ((bla.0 - naive.0).powi(2) + (bla.1 - naive.1).powi(2)).sqrt();
// Only the dropped e^2 differs; must stay near the BLA_EPS budget.
let tol = 1e-4 * ref_mag + 1e-15;
assert!(
err <= tol,
"dc={dc:?}: BLA {bla:?} vs naive {naive:?} (err {err:e} > tol {tol:e})"
);
}
assert!(any_skip, "BLA never took a multi-step skip — test is not exercising it");
}
}
+120 -54
View File
@@ -13,10 +13,81 @@ use std::sync::Arc;
use eframe::egui_wgpu::{self, wgpu};
use super::reference::Bla;
/// Maximum reference-orbit length (points) the storage buffer can hold. Also
/// bounds the iteration count. 128k points * 8 bytes = 1 MiB.
pub const MAX_REF_POINTS: usize = 1 << 17;
/// Maximum BLA-table entries the storage buffer can hold. A full table has
/// ~2× the orbit length (all levels summed); ~256k entries * 24 bytes = 6 MiB.
pub const MAX_BLA_ENTRIES: usize = 2 * MAX_REF_POINTS;
/// The fractal pipeline's bind group layout: uniforms (0), reference orbit (1),
/// BLA table (2). Shared by the live renderer and [`ExportRender`].
fn fractal_bind_group_layout_desc<'a>() -> wgpu::BindGroupLayoutDescriptor<'a> {
const STORAGE: wgpu::BindingType = wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
};
wgpu::BindGroupLayoutDescriptor {
label: Some("fractal bind group layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: STORAGE,
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: STORAGE,
count: None,
},
],
}
}
/// Build the fractal bind group from its three buffers.
fn fractal_bind_group(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
uniform_buffer: &wgpu::Buffer,
ref_buffer: &wgpu::Buffer,
bla_buffer: &wgpu::Buffer,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("fractal bind group"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: ref_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: bla_buffer.as_entire_binding(),
},
],
})
}
/// GPU-side view + coloring parameters. Layout must match `Uniforms` in the
/// WGSL shader; total size is a multiple of 16 bytes for uniform-buffer rules.
#[repr(C)]
@@ -45,8 +116,9 @@ pub struct Uniforms {
pub dc_offset: [f32; 2],
/// 0 = escape-time coloring, 1 = distance-estimation shading.
pub de_coloring: u32,
/// Padding to a 16-byte multiple (uniform buffer requirement).
pub _pad: u32,
/// 0 = per-iteration stepping, 1 = BLA iteration-skipping (square map only).
/// This also fills the struct out to a 16-byte multiple (uniform requirement).
pub use_bla: u32,
}
/// Offscreen texture the fractal is rendered into, plus the bind group used to
@@ -72,9 +144,10 @@ pub struct FractalRenderer {
bind_group_layout: wgpu::BindGroupLayout,
uniform_buffer: wgpu::Buffer,
ref_buffer: wgpu::Buffer,
bla_buffer: wgpu::Buffer,
bind_group: wgpu::BindGroup,
target_format: wgpu::TextureFormat,
/// Generation of the reference orbit currently uploaded to `ref_buffer`.
/// Generation of the reference orbit + BLA table currently uploaded.
uploaded_generation: u64,
/// Blit pipeline + resources that copy the cache texture to egui's surface.
@@ -108,46 +181,23 @@ impl FractalRenderer {
mapped_at_creation: false,
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("fractal bind group layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
let bla_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("bla table"),
size: (MAX_BLA_ENTRIES * std::mem::size_of::<Bla>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("fractal bind group"),
layout: &bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: ref_buffer.as_entire_binding(),
},
],
});
let bind_group_layout =
device.create_bind_group_layout(&fractal_bind_group_layout_desc());
let bind_group = fractal_bind_group(
device,
&bind_group_layout,
&uniform_buffer,
&ref_buffer,
&bla_buffer,
);
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("fractal pipeline layout"),
@@ -254,6 +304,7 @@ impl FractalRenderer {
bind_group_layout,
uniform_buffer,
ref_buffer,
bla_buffer,
bind_group,
target_format,
uploaded_generation: u64::MAX,
@@ -361,6 +412,7 @@ impl ExportRender {
height: u32,
uniforms: Uniforms,
reference: &[[f32; 2]],
bla: &[Bla],
) -> Self {
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("export uniforms"),
@@ -381,20 +433,24 @@ impl ExportRender {
queue.write_buffer(&ref_buffer, 0, bytemuck::cast_slice(&reference[..count]));
}
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("export bind group"),
layout: bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: ref_buffer.as_entire_binding(),
},
],
let bla_count = bla.len().min(MAX_BLA_ENTRIES);
let bla_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("export bla table"),
size: (bla_count.max(1) * std::mem::size_of::<Bla>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
if bla_count > 0 {
queue.write_buffer(&bla_buffer, 0, bytemuck::cast_slice(&bla[..bla_count]));
}
let bind_group = fractal_bind_group(
device,
bind_group_layout,
&uniform_buffer,
&ref_buffer,
&bla_buffer,
);
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("export target"),
@@ -583,6 +639,8 @@ pub fn encode_png_with_progress(
pub struct FractalCallback {
pub uniforms: Uniforms,
pub reference: Arc<Vec<[f32; 2]>>,
/// BLA table for the current reference (empty when BLA is off/unsupported).
pub bla: Arc<Vec<Bla>>,
pub generation: u64,
/// Widget size in physical pixels — the cache texture resolution.
pub size_px: [u32; 2],
@@ -612,6 +670,14 @@ impl egui_wgpu::CallbackTrait for FractalCallback {
0,
bytemuck::cast_slice(&self.reference[..count]),
);
let bla_count = self.bla.len().min(MAX_BLA_ENTRIES);
if bla_count > 0 {
queue.write_buffer(
&renderer.bla_buffer,
0,
bytemuck::cast_slice(&self.bla[..bla_count]),
);
}
renderer.uploaded_generation = self.generation;
}
+72 -1
View File
@@ -29,6 +29,17 @@ struct Uniforms {
dc_offset: vec2<f32>,
// 0 = escape-time coloring, 1 = distance-estimation shading.
de_coloring: u32,
// 0 = per-iteration stepping, 1 = BLA iteration-skipping (square map only).
use_bla: u32,
};
// One merged linear step from the BLA table: e_{n+l} = A e + B dc, valid while
// |e| < r. Matches the Rust `Bla` struct (24-byte std430 stride).
struct Bla {
a: vec2<f32>,
b: vec2<f32>,
r: f32,
l: u32,
};
const KIND_MANDELBROT: u32 = 0u;
@@ -38,6 +49,9 @@ const KIND_MULTIBROT: u32 = 3u;
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var<storage, read> ref_orbit: array<vec2<f32>>;
// BLA table, levels concatenated (level 0 first). Per-level counts are recomputed
// from `ref_len` exactly as the CPU builder laid them out.
@group(0) @binding(2) var<storage, read> bla_table: array<Bla>;
struct VsOut {
@builtin(position) pos: vec4<f32>,
@@ -169,6 +183,45 @@ fn palette(id: u32, t: f32) -> vec3<f32> {
return a + b * cos(6.28318530718 * (c * t + d));
}
// Result of a BLA lookup at an orbit index.
struct Hop {
found: bool,
a: vec2<f32>,
b: vec2<f32>,
l: u32,
};
// Longest valid BLA skip starting at orbit index `n` for a delta of squared
// magnitude `emag2`. Walks levels low->high, recomputing each level's flat-array
// start and count from `ref_len` (count[0] = ref_len-1, halving each level).
// Radii shrink with level and alignment is monotonic, so the valid levels form a
// prefix: we keep the last valid one and stop at the first that fails.
fn bla_find(n: u32, emag2: f32) -> Hop {
var res: Hop;
res.found = false;
var start: u32 = 0u;
var count: u32 = u.ref_len - 1u;
var lv: u32 = 0u;
loop {
if (count == 0u) { break; }
let step = 1u << lv;
if ((n & (step - 1u)) != 0u) { break; } // n not aligned to this level
let idx = n >> lv;
if (idx >= count) { break; } // run would exceed the orbit
let b = bla_table[start + idx];
if (emag2 >= b.r * b.r) { break; } // delta too large: not valid
res.found = true;
res.a = b.a;
res.b = b.b;
res.l = b.l;
start = start + count;
count = count / 2u;
lv = lv + 1u;
}
return res;
}
// Perturbation iterate + color a single sample. `offset` is the per-pixel
// offset in complex units. For Mandelbrot it is the c-plane offset added every
// step (delta starts at 0); for Julia it is the z-plane offset that seeds the
@@ -208,16 +261,34 @@ fn shade(offset: vec2<f32>, px: f32) -> vec3<f32> {
break; // interior
}
// Advance one step, or skip a whole run via BLA when the delta is small
// enough (square map only; other kinds keep `use_bla == 0`). The orbit
// derivative for DE follows the same linear map: over a run it advances
// by the run's own (A, B) coefficients, matching the per-step recurrence.
var did_skip = false;
if (u.use_bla != 0u) {
let hop = bla_find(m, dot(e, e));
if (hop.found) {
if (u.de_coloring != 0u) {
dz = cmul(hop.a, dz) + cmul(hop.b, dz_seed);
}
e = cmul(hop.a, e) + cmul(hop.b, step_add);
m = m + hop.l;
n = n + hop.l;
did_skip = true;
}
}
if (!did_skip) {
// Propagate the derivative of the full orbit (unaffected by rebasing,
// which only re-expresses the same value). Only when DE is enabled.
if (u.de_coloring != 0u) {
dz = cmul(fprime(z), dz) + dz_seed;
}
// Advance the delta by this fractal's formula (+ dc for the set plane).
e = advance_delta(xm, e) + step_add;
m = m + 1u;
n = n + 1u;
}
// Keep the reference index valid and the delta small.
if (m >= u.ref_len) {
+12 -1
View File
@@ -9,7 +9,7 @@
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use std::thread;
use crate::fractal::{FractalKind, compute_reference, compute_set_reference};
use crate::fractal::{Bla, FractalKind, build_bla_table, compute_reference, compute_set_reference};
use crate::view::{Big, big_from_f64};
pub struct RefRequest {
@@ -22,6 +22,10 @@ pub struct RefRequest {
pub precision: usize,
pub kind: FractalKind,
pub power: u32,
/// Build the BLA iteration-skip table for this orbit (square map only).
pub build_bla: bool,
/// Upper bound on any pixel's `|dc|`, sizing the BLA merge radii.
pub dc_max: f64,
}
pub struct RefResult {
@@ -29,6 +33,7 @@ pub struct RefResult {
pub center_im: Big,
pub half_height: f64,
pub points: Vec<[f32; 2]>,
pub bla: Vec<Bla>,
}
pub struct RefWorker {
@@ -56,12 +61,18 @@ impl RefWorker {
}
let points = compute(&req);
let bla = if req.build_bla {
build_bla_table(&points, req.dc_max)
} else {
Vec::new()
};
if res_tx
.send(RefResult {
center_re: req.center_re,
center_im: req.center_im,
half_height: req.half_height,
points,
bla,
})
.is_err()
{