//! 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::collections::HashMap; use std::sync::Arc; use eframe::egui_wgpu::{self, wgpu}; use wgpu::util::DeviceExt as _; use super::kind::FractalKind; use super::reference::RefOrbit; use crate::lights::{GpuLight, Light, MAX_LIGHT_COUNT, gpu_lights}; /// 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; /// Cap on the interactive cache's pixel count (the texture is scaled down, /// aspect kept, above it). Each pixel costs 36 bytes across the data, AA and /// colour textures, and the 3D view renders at a configurable multiple per axis /// (default 2×), so a HiDPI screen /// in 3D would otherwise want 0.5 GB+. Browsers cap WebGPU memory well below /// what native gets, so the web budget is ~4K (≈300 MB); native, ~8K. #[cfg(target_arch = "wasm32")] const MAX_CACHE_PIXELS: u32 = 3840 * 2160; #[cfg(not(target_arch = "wasm32"))] const MAX_CACHE_PIXELS: u32 = 7680 * 4320; /// True when the two uniforms differ in any field the iteration pass depends on /// (i.e. anything except the palette / colour scale / offset / camera). 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.complex_power != b.complex_power || a.dc_offset != b.dc_offset || a.scale_exp != b.scale_exp || a.phoenix_p != b.phoenix_p || a.lambda_l != b.lambda_l || a.morph_from != b.morph_from || a.morph_w != b.morph_w || a.de_coloring != b.de_coloring // The iterate pass's DE clamp (`max_de`) depends on whether any // shadow-style mode is on. || (a.rendering_mode != 0) != (b.rendering_mode != 0) } /// True when the two uniforms differ in a field only the colourise pass reads /// (remappable without re-iterating): palette / colour scale / offset, the /// shadow style and light count, and the 3D raymarch camera. 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.rendering_mode != b.rendering_mode || a.light_count != b.light_count || a.camera_direction != b.camera_direction || a.camera_inv_proj != b.camera_inv_proj || a.screen_dim != b.screen_dim } /// Specialization of the iteration shader (`mandelbrot.wgsl`'s `override` /// constants). Everything the per-iteration loop branches on is baked into /// the pipeline instead of tested per step; one pipeline set per key is built /// lazily on first use (a new `FractalKind` needs nothing here). #[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)] pub struct PipelineKey { kind: u32, julia: bool, de: bool, /// A kind-switch morph is in progress (`morph_w > 0`). morph: bool, /// Deep view: the delta starts out in rescaled (mantissa + exponent) /// form (`scale_exp != 0`). deep: bool, } impl PipelineKey { pub fn from_uniforms(u: &Uniforms) -> Self { Self { kind: u.kind, julia: u.is_julia != 0, de: u.de_coloring != 0, morph: u.morph_w > 0.0, deep: u.scale_exp != 0, } } fn constants(&self) -> [(&'static str, f64); 5] { [ ("KIND", self.kind as f64), ("IS_JULIA", self.julia as u32 as f64), ("DE", self.de as u32 as f64), ("MORPH", self.morph as u32 as f64), ("DEEP", self.deep as u32 as f64), ] } } /// Build a fullscreen-triangle render pipeline (`vs_main` + `fs_entry`) /// writing a single `format` target, with `constants` for the shader's /// `override`s. fn fullscreen_pipeline( device: &wgpu::Device, label: &str, module: &wgpu::ShaderModule, layout: &wgpu::PipelineLayout, fs_entry: &str, format: wgpu::TextureFormat, constants: &[(&str, f64)], ) -> wgpu::RenderPipeline { let compilation_options = wgpu::PipelineCompilationOptions { constants, ..Default::default() }; device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(label), layout: Some(layout), vertex: wgpu::VertexState { module, entry_point: Some("vs_main"), buffers: &[], compilation_options: compilation_options.clone(), }, fragment: Some(wgpu::FragmentState { module, entry_point: Some(fs_entry), targets: &[Some(wgpu::ColorTargetState { format, blend: None, write_mask: wgpu::ColorWrites::ALL, })], compilation_options, }), primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview_mask: None, cache: None, }) } /// Stride between the per-pass step uniforms in [`Lipschitz::steps`] /// (WebGPU's minimum uniform-buffer offset alignment). const LIPSCHITZ_STRIDE: u32 = 256; /// Step uniforms held: slot k holds step 2^k, enough for any texture size. const LIPSCHITZ_SLOTS: u32 = 32; /// Whether the 3D view of `u` rebuilds its height field as a distance field /// (see `lipschitz.wgsl`): only Complex Multibrot, whose branch cut makes the /// DE jump into walls. Every other kind keeps its DE as is. fn wants_envelope(u: &Uniforms) -> bool { let cm = FractalKind::ComplexMultibrot as u32; u.rendering_mode == 2 && (u.kind == cm || (u.morph_w > 0.0 && u.morph_from == cm)) } /// The distance-field passes (`lipschitz.wgsl`): seed, jump-flood and /// compose pipelines, their shared input layout (data texture, seed texture, /// step uniform at a dynamic offset) and the buffer of every pass's step. #[derive(Clone)] struct Lipschitz { seed: wgpu::RenderPipeline, jump: wgpu::RenderPipeline, compose: wgpu::RenderPipeline, layout: wgpu::BindGroupLayout, steps: wgpu::Buffer, } impl Lipschitz { fn new(device: &wgpu::Device) -> Self { let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("lipschitz"), source: wgpu::ShaderSource::Wgsl( concat!( include_str!("../shaders/common.wgsl"), include_str!("../shaders/lipschitz.wgsl"), ) .into(), ), }); let texture_entry = |binding| wgpu::BindGroupLayoutEntry { binding, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }; let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("lipschitz bind group layout"), entries: &[ texture_entry(0), texture_entry(1), wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: true, min_binding_size: wgpu::BufferSize::new(16), }, count: None, }, ], }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("lipschitz pipeline layout"), bind_group_layouts: &[Some(&layout)], immediate_size: 0, }); let pipeline = |label, entry| { fullscreen_pipeline(device, label, &module, &pipeline_layout, entry, DATA_FORMAT, &[]) }; let mut contents = vec![0u8; (LIPSCHITZ_STRIDE * LIPSCHITZ_SLOTS) as usize]; for k in 0..LIPSCHITZ_SLOTS { let at = (k * LIPSCHITZ_STRIDE) as usize; contents[at..at + 4].copy_from_slice(&(1i32 << k.min(30)).to_ne_bytes()); } let steps = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("lipschitz steps"), contents: &contents, usage: wgpu::BufferUsages::UNIFORM, }); Self { seed: pipeline("lipschitz seed pipeline", "fs_seed"), jump: pipeline("lipschitz jump pipeline", "fs_jump"), compose: pipeline("lipschitz compose pipeline", "fs_compose"), layout, steps, } } /// A pass input reading the data texture `data` and seed texture `seeds`. fn bind_group( &self, device: &wgpu::Device, data: &wgpu::TextureView, seeds: &wgpu::TextureView, ) -> wgpu::BindGroup { device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("lipschitz bind group"), layout: &self.layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(data), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(seeds), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &self.steps, offset: 0, size: wgpu::BufferSize::new(16), }), }, ], }) } } /// Targets for the distance field of one data texture: two ping-pong seed /// textures for jump flooding, and the output (data with the rebuilt DE). struct Envelope { /// `[seeds A, seeds B, output]`, kept so they can be `destroy()`ed with /// the rest of the cache. textures: [wgpu::Texture; 3], views: [wgpu::TextureView; 3], /// Pass inputs: the data texture with seeds A, and with seeds B. inputs: [wgpu::BindGroup; 2], /// Step slot of each jump pass, in order (see [`lipschitz_slots`]). slots: Vec, } impl Envelope { fn new( device: &wgpu::Device, lp: &Lipschitz, data: &wgpu::TextureView, width: u32, height: u32, ) -> Self { let make = |label| { device.create_texture(&wgpu::TextureDescriptor { label: Some(label), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, 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 textures = [ make("DE distance seeds A"), make("DE distance seeds B"), make("DE distance field"), ]; let views = textures .each_ref() .map(|t| t.create_view(&wgpu::TextureViewDescriptor::default())); let inputs = [ lp.bind_group(device, data, &views[0]), lp.bind_group(device, data, &views[1]), ]; Self { textures, views, inputs, slots: lipschitz_slots(width, height), } } /// The data texture with the rebuilt DE. fn output(&self) -> &wgpu::TextureView { &self.views[2] } /// Record seed → jump passes → compose into [`Self::output`]. fn record(&self, encoder: &mut wgpu::CommandEncoder, lp: &Lipschitz) { let pass = |encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, pipeline: &wgpu::RenderPipeline, input: &wgpu::BindGroup, slot: u32| { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("lipschitz pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, 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(pipeline); pass.set_bind_group(0, input, &[slot * LIPSCHITZ_STRIDE]); pass.draw(0..3, 0..1); }; // Seeds into A (the bound seed texture, B, is unread). pass(encoder, &self.views[0], &lp.seed, &self.inputs[1], 0); // Pass i reads seeds i % 2 and writes the other. for (i, &slot) in self.slots.iter().enumerate() { pass(encoder, &self.views[(i + 1) % 2], &lp.jump, &self.inputs[i % 2], slot); } let last = self.slots.len() % 2; pass(encoder, &self.views[2], &lp.compose, &self.inputs[last], 0); } } /// Jump-flooding step slots (step = 2^slot) for a `width`×`height` texture: /// from about half its larger side down to 1, then one more step-1 pass, /// which fixes most of jump flooding's residual errors. fn lipschitz_slots(width: u32, height: u32) -> Vec { let top = (width.max(height) / 2).max(1).ilog2(); (0..=top).rev().chain(std::iter::once(0)).collect() } /// The interactive iteration pipelines for one [`PipelineKey`]. struct IteratePipelines { /// 1-spp perturbation iterate → data texture (`fs_data`). iterate: wgpu::RenderPipeline, /// Adaptive AA: data texture → AA data texture (`fs_refine`). refine: wgpu::RenderPipeline, } /// 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, /// Kind-switch morph: the kind being blended *from* (a `FractalKind` /// discriminant); only read when `morph_w > 0`. pub morph_from: 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], /// Complex exponent for the Complex Multibrot kind (`z^power + c`); /// ignored by other kinds. pub complex_power: [f32; 2], /// 0 = escape-time coloring, 1 = distance-estimation shading. pub de_coloring: u32, // 0 = classic colors, 1 = shadows, 2 = 3D raymarching rendering pub rendering_mode: u32, // camera direction vector pub camera_direction: [f32; 3], /// Number of live entries in the lights buffer (see `gpu_lights`). pub light_count: u32, /// Inverse of the camera's view-projection matrix (column-major), for /// reconstructing a world-space ray origin per pixel in the raymarcher. pub camera_inv_proj: [f32; 16], /// Screen dimension pub screen_dim: [f32; 2], /// Kind-switch morph weight: each step is `(1 - w)·f_kind + w·f_from`. /// 0 = no morph (and the iteration pipeline is then specialized without /// the morph path, see [`PipelineKey`]). pub morph_w: f32, /// Binary exponent `E` of the deep (rescaled) view scale: `span` and /// `dc_offset` are uploaded multiplied by `2^-E`, so they stay inside /// f32's exponent range at any depth. Non-zero exactly when the deep /// pipeline is used (see [`PipelineKey`] and `mandelbrot.wgsl`'s `DEEP`). pub scale_exp: i32, /// Complex binomial coefficients `C(complex_power, k)`, k = 1..16, two per /// row (odd k in `[0..2]`, even k in `[2..4]`), for the Complex Multibrot /// delta series. Derived from `complex_power` alone. pub cm_coef: [[f32; 4]; 8], } /// Offscreen textures for the two-pass render, recreated whenever the widget's /// pixel size changes: /// * `data_view` — the 1-spp iteration pass's output (see [`DATA_FORMAT`]). /// * `aa` — the adaptive-AA refine pass's output (only when AA is on). /// * `color_view` — the colourise pass's output; the blit source. /// plus the bind groups that read them. struct CacheTarget { /// Kept so they can be `destroy()`ed on resize (see `ensure_cache`). textures: Vec, data_view: wgpu::TextureView, color_view: wgpu::TextureView, /// Refine pass input (group 1): the 1-spp data texture. refine_bind_group: wgpu::BindGroup, /// Colourise pass input: uniforms + the 1-spp data texture. colorize_bind_group: wgpu::BindGroup, /// Refine pass output + the colourise bind group reading it. Only /// allocated while AA is on: it's a second full-size `Rgba32Float`. aa: Option<(wgpu::TextureView, wgpu::BindGroup)>, /// The distance-field rebuild of the refined (or 1-spp) data + the /// colourise bind group reading it. Allocated on first use, only for /// 3D Complex Multibrot (see [`wants_envelope`]). envelope: Option<(Envelope, 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, lights: [GpuLight; MAX_LIGHT_COUNT], width: u32, height: u32, } pub struct FractalRenderer { /// `mandelbrot.wgsl`, specialized per [`PipelineKey`] at pipeline creation. shader: wgpu::ShaderModule, /// Layout of the iterate + export pipelines (group 0 only). pipeline_layout: wgpu::PipelineLayout, /// Layout of the refine pipeline (group 0 + the 1-spp texture in group 1). refine_pipeline_layout: wgpu::PipelineLayout, refine_bind_group_layout: wgpu::BindGroupLayout, /// Lazily built interactive pipelines, per shader specialization. pipelines: HashMap, bind_group_layout: wgpu::BindGroupLayout, uniform_buffer: wgpu::Buffer, ref_buffer: wgpu::Buffer, ref_exp_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, /// Contents of `lights_buffer`, so it's only re-uploaded on change. uploaded_lights: Option<[GpuLight; MAX_LIGHT_COUNT]>, /// Colourise pass: data texture → colour texture (palette mapping). colorize_pipeline: wgpu::RenderPipeline, colorize_bind_group_layout: wgpu::BindGroupLayout, /// Distance-field passes for 3D Complex Multibrot (see [`wants_envelope`]). lipschitz: Lipschitz, /// Whether the cache's envelope matches the current data texture. Not /// implied by iteration: switching shadow → 3D doesn't re-iterate. envelope_valid: bool, /// 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, /// What the data texture holds; `None` forces re-iteration. iterated: Option, /// What the colour texture holds; `None` forces a recolour. colored: Option, } 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( concat!( include_str!("../shaders/common.wgsl"), include_str!("../shaders/iterate_uniforms.wgsl"), include_str!("../shaders/mandelbrot.wgsl"), ) .into(), ), }); let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("fractal uniforms"), size: std::mem::size_of::() 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, }); // Per-point exponents of the reference orbit (`RefOrbit::exps`), only // read by deep pipelines. let ref_exp_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("reference orbit exponents"), size: (MAX_REF_POINTS * std::mem::size_of::()) 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: std::mem::size_of::<[GpuLight; MAX_LIGHT_COUNT]>() 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, }, // Only read by the export pipeline's shadow branch (`fs_color` // with the custom-lights palette); the iterate pipeline // (`fs_data`) ignores it, but both pipelines share this layout. 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, }, wgpu::BindGroupLayoutEntry { binding: 3, 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(), }, wgpu::BindGroupEntry { binding: 2, resource: lights_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 3, resource: ref_exp_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, }); // The iterate/refine/export pipelines are specialized per fractal // kind (see `PipelineKey`) and built lazily; only their layouts are // fixed. Refine additionally reads the 1-spp data texture (group 1). let refine_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("refine bind group layout"), entries: &[wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }], }); let refine_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("refine pipeline layout"), bind_group_layouts: &[Some(&bind_group_layout), Some(&refine_bind_group_layout)], immediate_size: 0, }); // Colourise pass: data texture + colour uniforms → colour texture. let colorize_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("colorize"), source: wgpu::ShaderSource::Wgsl( concat!( include_str!("../shaders/common.wgsl"), include_str!("../shaders/iterate_uniforms.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( concat!( include_str!("../shaders/common.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 { shader, pipeline_layout, refine_pipeline_layout, refine_bind_group_layout, pipelines: HashMap::new(), bind_group_layout, uniform_buffer, ref_buffer, ref_exp_buffer, lights_buffer, bind_group, target_format, uploaded_generation: u64::MAX, uploaded_lights: None, colorize_pipeline, colorize_bind_group_layout, lipschitz: Lipschitz::new(device), envelope_valid: false, blit_pipeline, blit_bind_group_layout, blit_sampler, cache: None, iterated: None, colored: None, } } /// Ensure the cache textures exist at `width`×`height` (plus the AA refine /// target iff `aa`). Recreates them (and their bind groups) on a change, /// invalidating any previous render. fn ensure_cache(&mut self, device: &wgpu::Device, width: u32, height: u32, aa: bool) { if let Some(c) = &self.cache && c.width == width && c.height == height && c.aa.is_some() == aa { return; } // Free the old textures explicitly. On the web backend dropping a // `wgpu::Texture` does not release its GPU memory — that waits for the // JS garbage collector — and this runs on every size change (including // each switch in/out of the downscaled interactive resolution), so the // stale Rgba32Float textures piled up until WebGPU ran out of memory. // Any commands using them were submitted on earlier frames, and // `destroy()` defers the actual free until those finish. if let Some(old) = self.cache.take() { let envelope = old.envelope.iter().flat_map(|e| &e.0.textures); for t in old.textures.iter().chain(envelope) { t.destroy(); } } 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()); // Adaptive-AA output: same format, written by the refine pass. let data_aa_texture = aa.then(|| { device.create_texture(&wgpu::TextureDescriptor { label: Some("fractal data (AA)"), 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: &[], }) }); // 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_for = |data: &wgpu::TextureView| { colorize_bind_group( device, &self.colorize_bind_group_layout, &self.uniform_buffer, &self.lights_buffer, data, ) }; let colorize_bind_group = colorize_bind_group_for(&data_view); let aa_target = data_aa_texture.as_ref().map(|t| { let view = t.create_view(&wgpu::TextureViewDescriptor::default()); let bind_group = colorize_bind_group_for(&view); (view, bind_group) }); let refine_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("refine bind group"), layout: &self.refine_bind_group_layout, entries: &[wgpu::BindGroupEntry { binding: 0, 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 { textures: [Some(data_texture), data_aa_texture, Some(color_texture)] .into_iter() .flatten() .collect(), data_view, color_view, refine_bind_group, colorize_bind_group, aa: aa_target, envelope: None, blit_bind_group, width, height, }); // New textures → old renders are gone. self.iterated = None; self.colored = None; } /// Build (on first use) and cache the interactive pipelines for `key`. fn ensure_pipelines(&mut self, device: &wgpu::Device, key: PipelineKey) { if self.pipelines.contains_key(&key) { return; } let constants = key.constants(); let iterate = fullscreen_pipeline( device, "fractal iterate pipeline", &self.shader, &self.pipeline_layout, "fs_data", DATA_FORMAT, &constants, ); let refine = fullscreen_pipeline( device, "fractal AA refine pipeline", &self.shader, &self.refine_pipeline_layout, "fs_refine", DATA_FORMAT, &constants, ); self.pipelines .insert(key, IteratePipelines { iterate, refine }); } /// Handles needed to build a standalone [`ExportRender`] off the UI thread: /// a combined iterate + colour pipeline (`fs_color`) specialized for /// `uniforms` (built fresh — exports are rare, and this only needs a read /// lock on the renderer), its bind-group layout, and the target format. /// In 3D mode (`rendering_mode == 2`) also the interactive iterate → /// refine → colourise chain, since the raymarcher needs a whole data /// texture to march over and `fs_color` has no 3D path. pub fn export_handles(&self, device: &wgpu::Device, uniforms: &Uniforms) -> ExportHandles { let constants = PipelineKey::from_uniforms(uniforms).constants(); let pipeline = fullscreen_pipeline( device, "fractal export pipeline", &self.shader, &self.pipeline_layout, "fs_color", self.target_format, &constants, ); let raymarch = (uniforms.rendering_mode == 2).then(|| RaymarchHandles { iterate: fullscreen_pipeline( device, "fractal export iterate pipeline", &self.shader, &self.pipeline_layout, "fs_data", DATA_FORMAT, &constants, ), refine: fullscreen_pipeline( device, "fractal export AA refine pipeline", &self.shader, &self.refine_pipeline_layout, "fs_refine", DATA_FORMAT, &constants, ), colorize: self.colorize_pipeline.clone(), refine_bind_group_layout: self.refine_bind_group_layout.clone(), colorize_bind_group_layout: self.colorize_bind_group_layout.clone(), lipschitz: wants_envelope(uniforms).then(|| self.lipschitz.clone()), }); ExportHandles { pipeline, bind_group_layout: self.bind_group_layout.clone(), format: self.target_format, raymarch, } } } /// Everything an [`ExportRender`] needs from the [`FractalRenderer`], cloned /// out so the export can run off the UI thread (see `export_handles`). #[derive(Clone)] pub struct ExportHandles { /// Combined iterate + colour pipeline (`fs_color`), for 2D modes. pipeline: wgpu::RenderPipeline, bind_group_layout: wgpu::BindGroupLayout, format: wgpu::TextureFormat, /// The two-pass chain, for 3D mode only. raymarch: Option, } /// The interactive two-pass pipelines, for a 3D export. #[derive(Clone)] struct RaymarchHandles { iterate: wgpu::RenderPipeline, refine: wgpu::RenderPipeline, colorize: wgpu::RenderPipeline, refine_bind_group_layout: wgpu::BindGroupLayout, colorize_bind_group_layout: wgpu::BindGroupLayout, /// The distance-field passes, when [`wants_envelope`]. lipschitz: Option, } /// A 3D export's own data textures and the passes that fill them: the tiles /// iterate into `data_view`, then one refine (if AA), the distance-field /// rebuild (if wanted) and a colourise pass raymarches the finished height /// field into the export target. struct RaymarchExport { iterate: wgpu::RenderPipeline, /// Refine pipeline, output view and input bind group, when AA is on. refine: Option<(wgpu::RenderPipeline, wgpu::TextureView, wgpu::BindGroup)>, envelope: Option<(Lipschitz, Envelope)>, colorize: wgpu::RenderPipeline, colorize_bind_group: wgpu::BindGroup, data_view: wgpu::TextureView, } /// 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, /// 3D mode: tiles fill a data texture instead of the target. raymarch: Option, } 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, handles: &ExportHandles, width: u32, height: u32, uniforms: Uniforms, reference: &RefOrbit, lights: &[Light], ) -> Self { let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("export uniforms"), size: std::mem::size_of::() 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, }); let ref_exp_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("export reference orbit exponents"), size: (count.max(1) * std::mem::size_of::()) 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])); queue.write_buffer( &ref_exp_buffer, 0, bytemuck::cast_slice(&reference.exps[..count]), ); } // Only read by the shadow branch's custom-lights palette; harmless // (zeroed) for every other coloring mode. let lights_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("export lights"), size: std::mem::size_of::<[GpuLight; MAX_LIGHT_COUNT]>() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let (gpu_lights, _) = gpu_lights(lights); queue.write_buffer(&lights_buffer, 0, bytemuck::cast_slice(&gpu_lights)); let target_format = handles.format; let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("export bind group"), layout: &handles.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: lights_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 3, resource: ref_exp_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 ); let raymarch = handles.raymarch.as_ref().map(|rm| { let data_texture = |label| { device .create_texture(&wgpu::TextureDescriptor { label: Some(label), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, 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: &[], }) .create_view(&wgpu::TextureViewDescriptor::default()) }; let data_view = data_texture("export data"); let refine = (uniforms.aa_level > 1).then(|| { let refine_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("export refine bind group"), layout: &rm.refine_bind_group_layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&data_view), }], }); ( rm.refine.clone(), data_texture("export data (AA)"), refine_bind_group, ) }); // The distance field reads the refined texture when AA is on, // and colourise reads the distance field, else the same texture. let refined = refine.as_ref().map_or(&data_view, |(_, v, _)| v); let envelope = rm.lipschitz.as_ref().map(|lp| { let env = Envelope::new(device, lp, refined, width, height); (lp.clone(), env) }); let colorize_input = envelope.as_ref().map_or(refined, |(_, e)| e.output()); let colorize_bind_group = colorize_bind_group( device, &rm.colorize_bind_group_layout, &uniform_buffer, &lights_buffer, colorize_input, ); RaymarchExport { iterate: rm.iterate.clone(), refine, envelope, colorize: rm.colorize.clone(), colorize_bind_group, data_view, } }); Self { pipeline: handles.pipeline.clone(), bind_group, texture, view, readback, padded_bpr, width, height, tiles, swap_rb, raymarch, } } /// 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. In 3D /// mode the tiles iterate into the data texture instead, and the last one /// also runs the (whole-image) refine + raymarching colourise passes. 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 (target, pipeline) = match &self.raymarch { Some(rm) => (&rm.data_view, &rm.iterate), None => (&self.view, &self.pipeline), }; { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("export tile pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, 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(pipeline); pass.set_bind_group(0, &self.bind_group, &[]); pass.draw(0..3, 0..1); } if let Some(rm) = &self.raymarch && y1 == self.height { if let Some((refine, aa_view, refine_bind_group)) = &rm.refine { data_pass( &mut encoder, "export AA refine pass", aa_view, refine, &[&self.bind_group, refine_bind_group], ); } if let Some((lp, env)) = &rm.envelope { env.record(&mut encoder, lp); } data_pass( &mut encoder, "export colorize pass", &self.view, &rm.colorize, &[&rm.colorize_bind_group], ); } 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 } } /// Render `er` tile by tile (blocking on the GPU after each tile so progress /// reflects real work), read it back, and encode the result as PNG bytes. /// Blocks the calling thread throughout, so it's only for native targets: /// the UI export path runs it on a background thread, headless rendering /// runs it directly since it has no frame loop to share a thread with. #[cfg(not(target_arch = "wasm32"))] pub fn export_to_png_blocking( device: &wgpu::Device, queue: &wgpu::Queue, er: &ExportRender, mut on_progress: impl FnMut(&'static str, f32), ) -> Vec { // Progress budget: rendering fills [0, RENDER_END], encoding the rest. const RENDER_END: f32 = 0.6; for t in 0..er.tiles { er.render_tile(device, queue, t); let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None, }); let done = (t + 1) as f32 / er.tiles as f32; on_progress("Rendering", RENDER_END * done); } er.copy_to_readback(device, queue); let (tx, rx) = std::sync::mpsc::channel(); er.readback() .slice(..) .map_async(wgpu::MapMode::Read, move |res| { let _ = tx.send(res); }); let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None, }); let _ = rx.recv(); on_progress("Encoding", RENDER_END); let png = { let data = er .readback() .slice(..) .get_mapped_range() .expect("map readback buffer"); encode_png_with_progress(&data, er.width, er.height, er.padded_bpr, er.swap_rb, |f| { on_progress("Encoding", RENDER_END + (0.97 - RENDER_END) * f) }) }; er.readback().unmap(); png } /// Render every tile of `er` in one go (no per-tile GPU stall, unlike /// [`export_to_png_blocking`]), read it back, and return a copy of the padded /// readback bytes (`er.padded_bpr` per row) for [`encode_png`]. Used by the /// headless animation pipeline, which encodes on other threads. #[cfg(not(target_arch = "wasm32"))] pub fn render_readback_blocking( device: &wgpu::Device, queue: &wgpu::Queue, er: &ExportRender, ) -> Vec { for t in 0..er.tiles { er.render_tile(device, queue, t); } er.copy_to_readback(device, queue); let (tx, rx) = std::sync::mpsc::channel(); er.readback() .slice(..) .map_async(wgpu::MapMode::Read, move |res| { let _ = tx.send(res); }); let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None, }); let _ = rx.recv(); let bytes = er .readback() .slice(..) .get_mapped_range() .expect("map readback buffer") .to_vec(); er.readback().unmap(); bytes } /// Like [`encode_png_with_progress`], but encodes the whole image at once /// (no progress) at the given compression level. Non-streaming, so the fast /// `fdeflate` levels don't pay the streaming-mode size penalty. #[cfg(not(target_arch = "wasm32"))] pub fn encode_png( padded: &[u8], width: u32, height: u32, padded_bpr: u32, swap_rb: bool, compression: png::Compression, ) -> Vec { let row = (width * 4) as usize; let mut pixels = Vec::with_capacity(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]; if swap_rb { pixels.extend( src.as_chunks::<4>() .0 .iter() .flat_map(|&[b, g, r, a]| [r, g, b, a]), ); } else { pixels.extend_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); encoder.set_compression(compression); let mut writer = encoder.write_header().expect("png header"); writer.write_image_data(&pixels).expect("png data"); } out } /// 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 { 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 } /// Colourise pass input: the uniforms, the data texture `data` to colour and /// the lights. fn colorize_bind_group( device: &wgpu::Device, layout: &wgpu::BindGroupLayout, uniforms: &wgpu::Buffer, lights: &wgpu::Buffer, data: &wgpu::TextureView, ) -> wgpu::BindGroup { device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("colorize bind group"), layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: uniforms.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(data), }, wgpu::BindGroupEntry { binding: 2, resource: lights.as_entire_binding(), }, ], }) } /// Record one fullscreen-triangle pass drawing `pipeline` into `target` /// (cleared first), with `bind_groups` bound to groups 0, 1, ... fn data_pass( encoder: &mut wgpu::CommandEncoder, label: &str, target: &wgpu::TextureView, pipeline: &wgpu::RenderPipeline, bind_groups: &[&wgpu::BindGroup], ) { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some(label), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, 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(pipeline); for (i, bg) in bind_groups.iter().enumerate() { pass.set_bind_group(i as u32, *bg, &[]); } pass.draw(0..3, 0..1); } /// 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, colour-only changes re-run just the cheap /// colourise pass, and a frame where nothing changed (e.g. a hover repaint) /// uploads and renders nothing — `paint` just blits the cache (see `prepare`). pub struct FractalCallback { pub uniforms: Uniforms, /// Lights buffer contents, from [`gpu_lights`] (its count is in /// `uniforms.light_count`). pub lights: [GpuLight; MAX_LIGHT_COUNT], pub reference: Arc, 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 { let Some(renderer) = resources.get_mut::() else { return Vec::new(); }; // Clamp to the device's texture-size limit, keeping the aspect ratio // (the iterate pass maps pixels through NDC, so the view is unchanged; // the blit just upsamples). The 3D supersample (default 2×) on a large/HiDPI // screen can otherwise exceed it. // Also cap the total pixel count (`MAX_CACHE_PIXELS`), same way. let max_dim = device.limits().max_texture_dimension_2d; let [w, h] = self.size_px.map(|v| v.max(1)); let scale = (max_dim as f64 / w.max(h) as f64) .min((MAX_CACHE_PIXELS as f64 / (w as f64 * h as f64)).sqrt()) .min(1.0); let width = ((w as f64 * scale) as u32).clamp(1, max_dim); let height = ((h as f64 * scale) as u32).clamp(1, max_dim); let aa = self.uniforms.aa_level > 1; renderer.ensure_cache(device, width, height, aa); // Iteration (expensive) re-runs only when the geometry inputs change; // colourise (cheap) re-runs when it did, or when only a colour/camera/ // light input changed — so palette tweaks, colour cycling, and 3D // camera moves 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 || c.lights != self.lights || color_differs(&c.uniforms, &self.uniforms) }); if !color_dirty { return Vec::new(); // cache still valid; paint() just blits it } if iter_dirty && 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]), ); queue.write_buffer( &renderer.ref_exp_buffer, 0, bytemuck::cast_slice(&self.reference.exps[..count]), ); renderer.uploaded_generation = self.generation; } // Every pass reads the uniform buffer; refresh it once. queue.write_buffer( &renderer.uniform_buffer, 0, bytemuck::bytes_of(&self.uniforms), ); if renderer.uploaded_lights.as_ref() != Some(&self.lights) { queue.write_buffer( &renderer.lights_buffer, 0, bytemuck::cast_slice(&self.lights), ); renderer.uploaded_lights = Some(self.lights); } if iter_dirty { renderer.ensure_pipelines(device, PipelineKey::from_uniforms(&self.uniforms)); } let envelope = wants_envelope(&self.uniforms); if envelope && let Some(cache) = &renderer.cache && cache.envelope.is_none() { let src = cache.aa.as_ref().map_or(&cache.data_view, |(v, _)| v); let env = Envelope::new(device, &renderer.lipschitz, src, cache.width, cache.height); let bind_group = colorize_bind_group( device, &renderer.colorize_bind_group_layout, &renderer.uniform_buffer, &renderer.lights_buffer, env.output(), ); if let Some(cache) = &mut renderer.cache { cache.envelope = Some((env, bind_group)); } } let mut envelope_ran = false; let pipelines = &renderer.pipelines[&PipelineKey::from_uniforms(&self.uniforms)]; if let Some(cache) = &renderer.cache { if iter_dirty { // Iteration pass: 1-spp perturbation iterate → data texture. data_pass( egui_encoder, "fractal iterate pass", &cache.data_view, &pipelines.iterate, &[&renderer.bind_group], ); if let Some((data_aa_view, _)) = &cache.aa { // Adaptive AA: supersample only the non-smooth pixels. data_pass( egui_encoder, "fractal AA refine pass", data_aa_view, &pipelines.refine, &[&renderer.bind_group, &cache.refine_bind_group], ); } } // 3D Complex Multibrot: rebuild the DE as a distance field. let env = cache.envelope.as_ref().filter(|_| envelope); if let Some((env, _)) = env && (iter_dirty || !renderer.envelope_valid) { env.record(egui_encoder, &renderer.lipschitz); envelope_ran = true; } // Colourise pass: data texture → colour texture. let colorize_bind_group = match env { Some((_, bg)) => bg, None => cache .aa .as_ref() .map_or(&cache.colorize_bind_group, |(_, bg)| bg), }; data_pass( egui_encoder, "fractal colorize pass", &cache.color_view, &renderer.colorize_pipeline, &[colorize_bind_group], ); } renderer.envelope_valid = envelope_ran || (renderer.envelope_valid && !iter_dirty); if iter_dirty { renderer.iterated = Some(IterState { uniforms: self.uniforms, generation: self.generation, width, height, }); } renderer.colored = Some(ColorState { uniforms: self.uniforms, lights: self.lights, 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::() && 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); } } }