First commit

This commit is contained in:
2026-09-15 08:55:18 +02:00
commit 09d7976ebd
15 changed files with 1989 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
//! GPU fractal rendering: wgpu pipeline, uniforms, reference orbit, and the
//! egui paint callback.
pub mod reference;
pub mod renderer;
pub mod share;
pub use reference::{compute_mandelbrot_reference, compute_reference};
pub use renderer::{FractalCallback, FractalRenderer, Uniforms, MAX_REF_POINTS, encode_png};
pub use share::ShareState;
+136
View File
@@ -0,0 +1,136 @@
//! High-precision reference-orbit computation for perturbation rendering.
//!
//! We iterate `Z_{n+1} = Z_n^2 + C` at high precision (`dashu-float`), storing
//! each `Z_n` as an `f32` pair. Every pixel is then rendered on the GPU as a
//! small `f32` delta from this orbit — that is what makes deep zoom cheap. See
//! `shaders/mandelbrot.wgsl` for the delta side.
//!
//! The `(z0, c)` form serves both fractals:
//! * Mandelbrot: `z0 = 0`, `c = view center` (the c-plane point per pixel).
//! * Julia: `z0 = view center`, `c = julia constant` (fixed for all pixels).
use crate::view::Big;
/// Reference orbit escapes once |Z|^2 exceeds this. Kept larger than the pixel
/// bailout so pixels escaping alongside the reference can still reach their
/// bailout before the stored orbit runs out.
const REFERENCE_ESCAPE_SQ: f64 = 1.0e10;
/// Compute the reference orbit `Z_0..Z_{len-1}` where `Z_0 = z0` and
/// `Z_{n+1} = Z_n^2 + c`, up to `max_iter` steps at `precision` bits. Each entry
/// is `[re, im]` in f32.
pub fn compute_reference(
z0_re: &Big,
z0_im: &Big,
c_re: &Big,
c_im: &Big,
max_iter: u32,
precision: usize,
) -> Vec<[f32; 2]> {
let cr = c_re.clone().with_precision(precision).value();
let ci = c_im.clone().with_precision(precision).value();
let mut zr = z0_re.clone().with_precision(precision).value();
let mut zi = z0_im.clone().with_precision(precision).value();
let mut points: Vec<[f32; 2]> = Vec::with_capacity(max_iter as usize + 1);
for _ in 0..=max_iter {
let fr = zr.to_f64().value() as f32;
let fi = zi.to_f64().value() as f32;
points.push([fr, fi]);
let mag = (fr as f64) * (fr as f64) + (fi as f64) * (fi as f64);
if mag > REFERENCE_ESCAPE_SQ {
break;
}
// Z = Z^2 + C, with Z^2 = (zr^2 - zi^2) + (2 zr zi) i.
let zr2 = zr.sqr();
let zi2 = zi.sqr();
let new_zr = ((&zr2 - &zi2) + &cr).with_precision(precision).value();
let two_zr_zi = (&zr * &zi) << 1; // exact multiply-by-2 in base 2
let new_zi = (two_zr_zi + &ci).with_precision(precision).value();
zr = new_zr;
zi = new_zi;
}
points
}
fn big_zero(precision: usize) -> Big {
Big::from(0i32).with_precision(precision).value()
}
/// Convenience: Mandelbrot reference (`z0 = 0`, `c = center`).
pub fn compute_mandelbrot_reference(
center_re: &Big,
center_im: &Big,
max_iter: u32,
precision: usize,
) -> Vec<[f32; 2]> {
let zero = big_zero(precision);
compute_reference(&zero, &zero, center_re, center_im, max_iter, precision)
}
#[cfg(test)]
mod tests {
use super::*;
/// The high-precision reference must agree with a plain f64 iteration for a
/// shallow point (where f64 is accurate).
#[test]
fn reference_matches_naive_f64() {
let cr = Big::try_from(-0.75_f64).unwrap();
let ci = Big::try_from(0.1_f64).unwrap();
let points = compute_mandelbrot_reference(&cr, &ci, 60, 200);
// Independent naive f64 orbit.
let (c_re, c_im) = (-0.75_f64, 0.1_f64);
let (mut zr, mut zi) = (0.0_f64, 0.0_f64);
for point in &points {
// Tolerance is relative to magnitude: f32 storage only keeps ~7
// significant figures.
let tol_re = 1e-4 * (1.0 + zr.abs());
let tol_im = 1e-4 * (1.0 + zi.abs());
assert!((point[0] as f64 - zr).abs() < tol_re, "re mismatch: {point:?} vs {zr}");
assert!((point[1] as f64 - zi).abs() < tol_im, "im mismatch: {point:?} vs {zi}");
let nzr = zr * zr - zi * zi + c_re;
let nzi = 2.0 * zr * zi + c_im;
zr = nzr;
zi = nzi;
}
}
/// A point inside the main cardioid never escapes: full-length orbit.
#[test]
fn interior_orbit_runs_full_length() {
let cr = Big::try_from(-0.2_f64).unwrap();
let ci = Big::try_from(0.0_f64).unwrap();
let points = compute_mandelbrot_reference(&cr, &ci, 500, 120);
assert_eq!(points.len(), 501, "interior orbit should not escape");
}
/// Julia orbit (fixed c, z0 = center) matches a naive f64 iteration.
#[test]
fn julia_reference_matches_naive_f64() {
let z0_re = Big::try_from(0.15_f64).unwrap();
let z0_im = Big::try_from(-0.1_f64).unwrap();
let c_re = Big::try_from(-0.8_f64).unwrap();
let c_im = Big::try_from(0.156_f64).unwrap();
let points = compute_reference(&z0_re, &z0_im, &c_re, &c_im, 60, 200);
let (mut zr, mut zi) = (0.15_f64, -0.1_f64);
let (cr, ci) = (-0.8_f64, 0.156_f64);
for point in &points {
let tol = 1e-4 * (1.0 + zr.abs().max(zi.abs()));
assert!((point[0] as f64 - zr).abs() < tol);
assert!((point[1] as f64 - zi).abs() < tol);
let nzr = zr * zr - zi * zi + cr;
let nzi = 2.0 * zr * zi + ci;
zr = nzr;
zi = nzi;
}
}
}
+353
View File
@@ -0,0 +1,353 @@
//! wgpu resources for the fractal: the render pipeline, the uniform buffer, the
//! reference-orbit storage buffer, and the egui paint callback that drives them.
//!
//! Rendering strategy: a single fullscreen triangle is drawn into the rectangle
//! egui allocates for the fractal widget (egui presets the render pass viewport
//! for us). The fragment shader iterates each pixel as an f32 perturbation delta
//! from the high-precision reference orbit stored in `ref_buffer`.
use std::sync::Arc;
use eframe::egui_wgpu::{self, wgpu};
/// 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;
/// 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)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Uniforms {
/// Complex-plane span (width, height) covered by the view. Per-pixel `dc`
/// is `centered * span`, where `centered` is in [-0.5, 0.5].
pub span: [f32; 2],
pub max_iter: u32,
pub ref_len: u32,
pub color_offset: f32,
pub color_scale: f32,
pub bailout_sq: f32,
/// 0 = Mandelbrot, 1 = Julia.
pub is_julia: u32,
pub palette_id: u32,
pub _pad0: u32,
/// Complex offset of the view center from the reference center, so a stale
/// or reused reference (computed at a slightly different center) still maps
/// correctly. Added to every pixel's per-pixel offset.
pub dc_offset: [f32; 2],
}
pub struct FractalRenderer {
pipeline: wgpu::RenderPipeline,
uniform_buffer: wgpu::Buffer,
ref_buffer: wgpu::Buffer,
bind_group: wgpu::BindGroup,
target_format: wgpu::TextureFormat,
/// Generation of the reference orbit currently uploaded to `ref_buffer`.
uploaded_generation: u64,
}
impl FractalRenderer {
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("mandelbrot"),
source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/mandelbrot.wgsl").into()),
});
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("fractal uniforms"),
size: std::mem::size_of::<Uniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let ref_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("reference orbit"),
size: (MAX_REF_POINTS * std::mem::size_of::<[f32; 2]>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
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 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 pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("fractal pipeline layout"),
bind_group_layouts: &[Some(&bind_group_layout)],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("fractal pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: target_format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
Self {
pipeline,
uniform_buffer,
ref_buffer,
bind_group,
target_format,
uploaded_generation: u64::MAX,
}
}
/// Upload a reference orbit to the storage buffer (used by PNG export to
/// guarantee the buffer is current before an offscreen render).
pub fn upload_reference(&self, queue: &wgpu::Queue, points: &[[f32; 2]]) {
let count = points.len().min(MAX_REF_POINTS);
if count > 0 {
queue.write_buffer(&self.ref_buffer, 0, bytemuck::cast_slice(&points[..count]));
}
}
/// True if the render target stores bytes as BGRA (so a PNG needs R/B
/// swapped). Surfaces are usually `Bgra8UnormSrgb`.
pub fn needs_rb_swap(&self) -> bool {
matches!(
self.target_format,
wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
)
}
/// Render the current fractal (using `uniforms` and the already-uploaded
/// reference orbit) into an offscreen texture at `width`x`height`, then copy
/// it into a mappable buffer. Returns the buffer and its padded row stride.
/// The caller maps the buffer (blocking on native, async on web).
pub fn render_to_readback(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
width: u32,
height: u32,
uniforms: Uniforms,
) -> (wgpu::Buffer, u32) {
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniforms));
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("export target"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.target_format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let unpadded_bpr = width * 4;
let padded_bpr = unpadded_bpr.div_ceil(align) * align;
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("export readback"),
size: (padded_bpr * height) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export"),
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("export pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded_bpr),
rows_per_image: Some(height),
},
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
queue.submit(std::iter::once(encoder.finish()));
(readback, padded_bpr)
}
}
/// Convert a padded BGRA/RGBA readback into tightly-packed RGBA8 and encode it
/// as PNG bytes.
pub fn encode_png(
padded: &[u8],
width: u32,
height: u32,
padded_bpr: u32,
swap_rb: bool,
) -> Vec<u8> {
let row = (width * 4) as usize;
let mut rgba = vec![0u8; row * height as usize];
for y in 0..height as usize {
let src_off = y * padded_bpr as usize;
let src = &padded[src_off..src_off + row];
let dst = &mut rgba[y * row..y * row + row];
if swap_rb {
for x in 0..width as usize {
dst[x * 4] = src[x * 4 + 2];
dst[x * 4 + 1] = src[x * 4 + 1];
dst[x * 4 + 2] = src[x * 4];
dst[x * 4 + 3] = src[x * 4 + 3];
}
} else {
dst.copy_from_slice(src);
}
}
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().expect("png header");
writer.write_image_data(&rgba).expect("png data");
}
out
}
/// A per-frame paint callback. Carries this frame's uniforms plus a reference to
/// the current reference orbit (cheap `Arc` clone). The orbit is only re-uploaded
/// to the GPU when its `generation` changes.
pub struct FractalCallback {
pub uniforms: Uniforms,
pub reference: Arc<Vec<[f32; 2]>>,
pub generation: u64,
}
impl egui_wgpu::CallbackTrait for FractalCallback {
fn prepare(
&self,
_device: &wgpu::Device,
queue: &wgpu::Queue,
_screen_descriptor: &egui_wgpu::ScreenDescriptor,
_egui_encoder: &mut wgpu::CommandEncoder,
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
if let Some(renderer) = resources.get_mut::<FractalRenderer>() {
queue.write_buffer(
&renderer.uniform_buffer,
0,
bytemuck::bytes_of(&self.uniforms),
);
if renderer.uploaded_generation != self.generation && !self.reference.is_empty() {
let count = self.reference.len().min(MAX_REF_POINTS);
queue.write_buffer(
&renderer.ref_buffer,
0,
bytemuck::cast_slice(&self.reference[..count]),
);
renderer.uploaded_generation = self.generation;
}
}
Vec::new()
}
fn paint(
&self,
_info: egui::PaintCallbackInfo,
render_pass: &mut wgpu::RenderPass<'static>,
resources: &egui_wgpu::CallbackResources,
) {
if let Some(renderer) = resources.get::<FractalRenderer>() {
render_pass.set_pipeline(&renderer.pipeline);
render_pass.set_bind_group(0, &renderer.bind_group, &[]);
render_pass.draw(0..3, 0..1);
}
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Encode/decode a full view (fractal mode, high-precision center, zoom,
//! iterations, Julia constant, coloring) as a compact URL fragment so deep-zoom
//! locations can be shared or bookmarked.
//!
//! Format: `m=m&re=<dec>&im=<dec>&hh=<f64>&it=<u32>&cs=<f32>&co=<f32>` with
//! `m=j&jr=<f64>&ji=<f64>` added for Julia. `re`/`im` are full-precision decimal
//! strings.
use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct ShareState {
pub julia: bool,
pub center_re: String,
pub center_im: String,
pub half_height: f64,
pub iterations: u32,
pub julia_c: (f64, f64),
pub color_scale: f32,
pub color_offset: f32,
}
impl ShareState {
pub fn encode(&self) -> String {
let mut s = String::new();
s.push_str(if self.julia { "m=j" } else { "m=m" });
s.push_str(&format!(
"&re={}&im={}&hh={}&it={}",
self.center_re, self.center_im, self.half_height, self.iterations
));
if self.julia {
s.push_str(&format!("&jr={}&ji={}", self.julia_c.0, self.julia_c.1));
}
s.push_str(&format!("&cs={}&co={}", self.color_scale, self.color_offset));
s
}
pub fn decode(fragment: &str) -> Option<ShareState> {
let fragment = fragment.trim_start_matches(['#', '?']);
let mut map: HashMap<&str, &str> = HashMap::new();
for kv in fragment.split('&') {
if let Some((k, v)) = kv.split_once('=') {
map.insert(k, v);
}
}
Some(ShareState {
julia: map.get("m").map(|m| *m == "j").unwrap_or(false),
center_re: (*map.get("re")?).to_string(),
center_im: (*map.get("im")?).to_string(),
half_height: map.get("hh")?.parse().ok()?,
iterations: map.get("it").and_then(|s| s.parse().ok()).unwrap_or(512),
julia_c: (
map.get("jr").and_then(|s| s.parse().ok()).unwrap_or(-0.8),
map.get("ji").and_then(|s| s.parse().ok()).unwrap_or(0.156),
),
color_scale: map.get("cs").and_then(|s| s.parse().ok()).unwrap_or(0.02),
color_offset: map.get("co").and_then(|s| s.parse().ok()).unwrap_or(0.0),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip() {
let s = ShareState {
julia: true,
center_re: "-0.743643887037158704752191506114774".into(),
center_im: "0.131825904205311970493132056385139".into(),
half_height: 1.5e-20,
iterations: 4000,
julia_c: (-0.123, 0.745),
color_scale: 0.02,
color_offset: 0.25,
};
let d = ShareState::decode(&s.encode()).unwrap();
assert_eq!(d.julia, s.julia);
assert_eq!(d.center_re, s.center_re);
assert_eq!(d.center_im, s.center_im);
assert_eq!(d.half_height, s.half_height);
assert_eq!(d.iterations, s.iterations);
assert_eq!(d.julia_c, s.julia_c);
}
#[test]
fn decode_with_leading_hash() {
let d = ShareState::decode("#m=m&re=0.0&im=0.0&hh=1.25&it=256").unwrap();
assert!(!d.julia);
assert_eq!(d.iterations, 256);
}
}