955 lines
37 KiB
Rust
955 lines
37 KiB
Rust
//! 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};
|
||
|
||
use crate::lights::{Light, MAX_LIGHT_COUNT};
|
||
|
||
/// 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_palette_id != b.shadow_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,
|
||
pub shadow_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,
|
||
pub _pad: [u32; 1],
|
||
/// 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,
|
||
lights_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 lights_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||
label: Some("lights parameters"),
|
||
size: (MAX_LIGHT_COUNT * std::mem::size_of::<Light>()) as u64,
|
||
usage: wgpu::BufferUsages::UNIFORM | 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,
|
||
},
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 2,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Buffer {
|
||
ty: wgpu::BufferBindingType::Uniform,
|
||
has_dynamic_offset: false,
|
||
min_binding_size: None,
|
||
},
|
||
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,
|
||
lights_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),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 2,
|
||
resource: self.lights_buffer.as_entire_binding(),
|
||
},
|
||
],
|
||
});
|
||
|
||
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 lights: Vec<Light>,
|
||
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)
|
||
})
|
||
|| true;
|
||
|
||
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),
|
||
);
|
||
let mut bytes = [0; size_of::<Light>() * MAX_LIGHT_COUNT];
|
||
bytes[..self.lights.len() * size_of::<Light>()]
|
||
.copy_from_slice(bytemuck::cast_slice(&self.lights));
|
||
queue.write_buffer(&renderer.lights_buffer, 0, &bytes);
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|