Files
mandelbrot/src/fractal/renderer.rs
T
2026-09-18 10:17:34 +02:00

921 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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: the expensive per-pixel perturbation shader renders into
//! an offscreen **cache texture**, and only when the view/coloring/size actually
//! change (tracked by `rendered`). Every egui frame then just blits that cached
//! texture onto egui's surface with a cheap textured fullscreen triangle — so
//! incidental repaints (mouse-move, hover, the worker-pending poll) cost a blit,
//! not a full fractal recompute. The fragment shader iterates each pixel as an
//! f32 perturbation delta from the 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;
/// Format of the intermediate iteration-data texture holding, per pixel,
/// `(ci, DE factor, interior fraction)`. 32-bit float keeps the smooth iteration
/// count precise at deep zoom. Color-renderable and read with nearest sampling
/// (iteration data must never be linearly filtered across escape boundaries), so
/// no `float32-filterable` feature is needed.
const DATA_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
/// True when the two uniforms differ in any field the iteration pass depends on
/// (i.e. anything except the palette / colour scale / offset).
fn geom_differs(a: &Uniforms, b: &Uniforms) -> bool {
a.span != b.span
|| a.max_iter != b.max_iter
|| a.ref_len != b.ref_len
|| a.bailout_sq != b.bailout_sq
|| a.is_julia != b.is_julia
|| a.aa_level != b.aa_level
|| a.kind != b.kind
|| a.power != b.power
|| a.dc_offset != b.dc_offset
|| a.phoenix_p != b.phoenix_p
|| a.de_coloring != b.de_coloring
}
/// True when the two uniforms differ in a colour-only field (remappable by the
/// cheap colourise pass without re-iterating).
fn color_differs(a: &Uniforms, b: &Uniforms) -> bool {
a.color_offset != b.color_offset
|| a.color_scale != b.color_scale
|| a.palette_id != b.palette_id
|| a.shadow != b.shadow
}
/// 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,
/// Supersampling factor per axis: 1 = off, 2 = 2×2 (4 samples).
pub aa_level: u32,
/// Iteration formula (`FractalKind::shader_id`).
pub kind: u32,
/// Exponent for the Multibrot kind.
pub power: 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],
/// Distortion constant `p` for the Phoenix map (`z^2 + c + p·z_{n-1}`);
/// ignored by other kinds. Kept next to `dc_offset` so both `vec2`s land on
/// 8-byte boundaries, matching the shader's layout.
pub phoenix_p: [f32; 2],
/// Distortion constant `l` for the Lambda map (`l·z(1 - z)`);
/// ignored by other kinds.
pub lambda_l: [f32; 2],
/// 0 = escape-time coloring, 1 = distance-estimation shading.
pub de_coloring: u32,
// 0 = classic colors, 1 = shadows
pub shadow: u32,
}
/// Offscreen textures for the two-pass render, recreated whenever the widget's
/// pixel size changes:
/// * `data_view` — the iteration pass's output (see [`DATA_FORMAT`]).
/// * `color_view` — the colourise pass's output; the blit source.
/// plus the bind groups that read them.
struct CacheTarget {
data_view: wgpu::TextureView,
color_view: wgpu::TextureView,
/// Colourise pass input: uniforms + the data texture.
colorize_bind_group: wgpu::BindGroup,
/// Blit pass input: the colour texture + sampler.
blit_bind_group: wgpu::BindGroup,
width: u32,
height: u32,
}
/// What the iteration-data texture was last computed with. If the next frame's
/// geometry inputs match, iteration is skipped and only colour may be redone.
struct IterState {
uniforms: Uniforms,
generation: u64,
width: u32,
height: u32,
}
/// What the colour texture was last computed with. If the next frame's colour
/// inputs (and size) match and iteration did not re-run, colourise is skipped.
struct ColorState {
uniforms: Uniforms,
width: u32,
height: u32,
}
pub struct FractalRenderer {
/// Iteration pass: perturbation iterate → data texture (`fs_data`).
iterate_pipeline: wgpu::RenderPipeline,
/// Combined iterate + colour in one pass (`fs_color`), used only by export.
export_pipeline: wgpu::RenderPipeline,
bind_group_layout: wgpu::BindGroupLayout,
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,
/// Colourise pass: data texture → colour texture (palette mapping).
colorize_pipeline: wgpu::RenderPipeline,
colorize_bind_group_layout: wgpu::BindGroupLayout,
/// Blit pipeline + resources that copy the colour texture to egui's surface.
blit_pipeline: wgpu::RenderPipeline,
blit_bind_group_layout: wgpu::BindGroupLayout,
blit_sampler: wgpu::Sampler,
/// The offscreen textures; `None` until the first frame sizes them.
cache: Option<CacheTarget>,
/// What the data texture holds; `None` forces re-iteration.
iterated: Option<IterState>,
/// What the colour texture holds; `None` forces a recolour.
colored: Option<ColorState>,
}
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,
});
// Iteration pass: perturbation iterate → data texture (color-independent).
let iterate_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("fractal iterate 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_data"),
targets: &[Some(wgpu::ColorTargetState {
format: DATA_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,
});
// Combined iterate + colour in one pass — for PNG export only.
let export_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("fractal export 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_color"),
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,
});
// Colourise pass: data texture + colour uniforms → colour texture.
let colorize_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("colorize"),
source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/colorize.wgsl").into()),
});
let colorize_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("colorize 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::Texture {
// Nearest only: iteration data must not be filtered.
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
});
let colorize_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("colorize pipeline layout"),
bind_group_layouts: &[Some(&colorize_bind_group_layout)],
immediate_size: 0,
});
let colorize_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("colorize pipeline"),
layout: Some(&colorize_pipeline_layout),
vertex: wgpu::VertexState {
module: &colorize_shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &colorize_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,
});
// Blit pipeline: samples the cache texture onto egui's surface.
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("blit"),
source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/blit.wgsl").into()),
});
let blit_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("blit bind group layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let blit_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("blit sampler"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let blit_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("blit pipeline layout"),
bind_group_layouts: &[Some(&blit_bind_group_layout)],
immediate_size: 0,
});
let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("blit pipeline"),
layout: Some(&blit_pipeline_layout),
vertex: wgpu::VertexState {
module: &blit_shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &blit_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 {
iterate_pipeline,
export_pipeline,
bind_group_layout,
uniform_buffer,
ref_buffer,
bind_group,
target_format,
uploaded_generation: u64::MAX,
colorize_pipeline,
colorize_bind_group_layout,
blit_pipeline,
blit_bind_group_layout,
blit_sampler,
cache: None,
iterated: None,
colored: None,
}
}
/// Ensure the cache texture exists at `width`×`height`. Recreates it (and its
/// blit bind group) on a size change, invalidating any previous render.
fn ensure_cache(&mut self, device: &wgpu::Device, width: u32, height: u32) {
if let Some(c) = &self.cache
&& c.width == width
&& c.height == height
{
return;
}
let extent = wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
};
// Iteration-data texture (color-independent escape data).
let data_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("fractal data"),
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DATA_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let data_view = data_texture.create_view(&wgpu::TextureViewDescriptor::default());
// Colour texture (colourise output; blit source).
let color_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("fractal color cache"),
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.target_format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let color_view = color_texture.create_view(&wgpu::TextureViewDescriptor::default());
let colorize_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("colorize bind group"),
layout: &self.colorize_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&data_view),
},
],
});
let blit_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("blit bind group"),
layout: &self.blit_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&color_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.blit_sampler),
},
],
});
self.cache = Some(CacheTarget {
data_view,
color_view,
colorize_bind_group,
blit_bind_group,
width,
height,
});
// New textures → old renders are gone.
self.iterated = None;
self.colored = None;
}
/// Handles needed to build a standalone [`ExportRender`] off the UI thread:
/// the (immutable) pipeline and its bind-group layout, plus the target
/// format. Cloned so the caller can drop the render-state lock before use.
pub fn export_handles(
&self,
) -> (
wgpu::RenderPipeline,
wgpu::BindGroupLayout,
wgpu::TextureFormat,
) {
(
self.export_pipeline.clone(),
self.bind_group_layout.clone(),
self.target_format,
)
}
}
/// A self-contained render of one export image. It owns its own uniform and
/// reference buffers (a snapshot of the view at export time), so it is unaffected
/// by panning/zooming on the main thread, and can run on a background thread.
/// The image is rendered in horizontal tiles so progress can be reported as the
/// GPU works through it.
pub struct ExportRender {
pipeline: wgpu::RenderPipeline,
bind_group: wgpu::BindGroup,
texture: wgpu::Texture,
view: wgpu::TextureView,
readback: wgpu::Buffer,
/// Padded bytes-per-row of the readback buffer.
pub padded_bpr: u32,
pub width: u32,
pub height: u32,
/// Number of horizontal tiles the render is split into.
pub tiles: u32,
pub swap_rb: bool,
}
impl ExportRender {
/// Allocate the export's dedicated GPU resources and upload the snapshot.
#[allow(clippy::too_many_arguments)]
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
pipeline: wgpu::RenderPipeline,
bind_group_layout: &wgpu::BindGroupLayout,
target_format: wgpu::TextureFormat,
width: u32,
height: u32,
uniforms: Uniforms,
reference: &[[f32; 2]],
) -> Self {
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("export uniforms"),
size: std::mem::size_of::<Uniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&uniform_buffer, 0, bytemuck::bytes_of(&uniforms));
let count = reference.len().min(MAX_REF_POINTS);
let ref_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("export reference orbit"),
size: (count.max(1) * std::mem::size_of::<[f32; 2]>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
if count > 0 {
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 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: 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 padded_bpr = (width * 4).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,
});
// ~128px bands, kept to a sane range so progress is smooth without too
// many submissions.
let tiles = (height / 128).clamp(8, 64).min(height.max(1));
let swap_rb = matches!(
target_format,
wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
);
Self {
pipeline,
bind_group,
texture,
view,
readback,
padded_bpr,
width,
height,
tiles,
swap_rb,
}
}
/// Pixel row range `[y0, y1)` covered by tile `t`.
fn tile_rows(&self, t: u32) -> (u32, u32) {
let band = self.height.div_ceil(self.tiles);
let y0 = (t * band).min(self.height);
let y1 = (y0 + band).min(self.height);
(y0, y1)
}
/// Render one horizontal tile into the export texture and submit it. Tile 0
/// clears the whole attachment; later tiles preserve earlier ones.
pub fn render_tile(&self, device: &wgpu::Device, queue: &wgpu::Queue, t: u32) {
let (y0, y1) = self.tile_rows(t);
if y1 <= y0 {
return;
}
let load = if t == 0 {
wgpu::LoadOp::Clear(wgpu::Color::BLACK)
} else {
wgpu::LoadOp::Load
};
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export tile"),
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("export tile pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
// Full-viewport triangle (so pixel→plane mapping matches the whole
// image), scissored to this tile's rows.
pass.set_scissor_rect(0, y0, self.width, y1 - y0);
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.draw(0..3, 0..1);
}
queue.submit(std::iter::once(encoder.finish()));
}
/// Copy the finished texture into the mappable readback buffer and submit.
pub fn copy_to_readback(&self, device: &wgpu::Device, queue: &wgpu::Queue) {
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export copy"),
});
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &self.texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &self.readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(self.padded_bpr),
rows_per_image: Some(self.height),
},
},
wgpu::Extent3d {
width: self.width,
height: self.height,
depth_or_array_layers: 1,
},
);
queue.submit(std::iter::once(encoder.finish()));
}
/// The mappable readback buffer (valid after [`copy_to_readback`]).
pub fn readback(&self) -> &wgpu::Buffer {
&self.readback
}
}
/// Convert a padded BGRA/RGBA readback into tightly-packed RGBA8 and encode it
/// as PNG bytes, reporting progress in `[0, 1]` via `on_progress` as rows are
/// streamed to the compressor (encoding is the slow, subdividable phase).
pub fn encode_png_with_progress(
padded: &[u8],
width: u32,
height: u32,
padded_bpr: u32,
swap_rb: bool,
mut on_progress: impl FnMut(f32),
) -> Vec<u8> {
use std::io::Write as _;
let row = (width * 4) as usize;
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");
let mut stream = writer.stream_writer().expect("png stream");
let mut line = vec![0u8; row];
for y in 0..height as usize {
let src_off = y * padded_bpr as usize;
let src = &padded[src_off..src_off + row];
if swap_rb {
for x in 0..width as usize {
line[x * 4] = src[x * 4 + 2];
line[x * 4 + 1] = src[x * 4 + 1];
line[x * 4 + 2] = src[x * 4];
line[x * 4 + 3] = src[x * 4 + 3];
}
stream.write_all(&line).expect("png data");
} else {
stream.write_all(src).expect("png data");
}
if y % 64 == 0 {
on_progress(y as f32 / height as f32);
}
}
stream.finish().expect("png finish");
}
on_progress(1.0);
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
/// when its `generation` changes; the expensive iteration pass re-runs only when
/// a geometry input changes, and colour-only changes re-run just the cheap
/// colourise pass (see `prepare`).
pub struct FractalCallback {
pub uniforms: Uniforms,
pub reference: Arc<Vec<[f32; 2]>>,
pub generation: u64,
/// Widget size in physical pixels — the cache texture resolution.
pub size_px: [u32; 2],
}
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> {
let Some(renderer) = resources.get_mut::<FractalRenderer>() else {
return Vec::new();
};
let width = self.size_px[0].max(1);
let height = self.size_px[1].max(1);
renderer.ensure_cache(device, width, height);
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;
}
// Iteration (expensive) re-runs only when the geometry inputs change;
// colourise (cheap) re-runs when it did, or when only a colour changed —
// so palette / colour-scale / offset tweaks (e.g. colour cycling) skip
// the perturbation entirely.
let iter_dirty = renderer.iterated.as_ref().is_none_or(|r| {
r.generation != self.generation
|| r.width != width
|| r.height != height
|| geom_differs(&r.uniforms, &self.uniforms)
});
let color_dirty = iter_dirty
|| renderer.colored.as_ref().is_none_or(|c| {
c.width != width || c.height != height || color_differs(&c.uniforms, &self.uniforms)
});
if !color_dirty {
return Vec::new(); // cache still valid; paint() just blits it
}
// Both passes read the uniform buffer; refresh it once.
queue.write_buffer(
&renderer.uniform_buffer,
0,
bytemuck::bytes_of(&self.uniforms),
);
if let Some(cache) = &renderer.cache {
if iter_dirty {
// Iteration pass: perturbation iterate → data texture.
let mut pass = egui_encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("fractal iterate pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &cache.data_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(&renderer.iterate_pipeline);
pass.set_bind_group(0, &renderer.bind_group, &[]);
pass.draw(0..3, 0..1);
}
// Colourise pass: data texture → colour texture.
let mut pass = egui_encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("fractal colorize pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &cache.color_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(&renderer.colorize_pipeline);
pass.set_bind_group(0, &cache.colorize_bind_group, &[]);
pass.draw(0..3, 0..1);
}
if iter_dirty {
renderer.iterated = Some(IterState {
uniforms: self.uniforms,
generation: self.generation,
width,
height,
});
}
renderer.colored = Some(ColorState {
uniforms: self.uniforms,
width,
height,
});
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>()
&& let Some(cache) = &renderer.cache
{
render_pass.set_pipeline(&renderer.blit_pipeline);
render_pass.set_bind_group(0, &cache.blit_bind_group, &[]);
render_pass.draw(0..3, 0..1);
}
}
}