feat: don't draw the fractal each frames

This commit is contained in:
2026-09-15 09:22:38 +02:00
parent 343ddfc4b7
commit b2c29504bc
3 changed files with 278 additions and 29 deletions
+15 -4
View File
@@ -570,16 +570,26 @@ impl FractalApp {
self.ensure_reference(); self.ensure_reference();
// Nothing to draw until the first reference orbit is ready. // Poll the worker roughly every 30 ms while a reference is computing,
// instead of spinning a full-speed repaint. Once ready, changed inputs
// (or the initial draw) drive repaints on their own.
let poll = std::time::Duration::from_millis(30);
if self.reference.is_empty() { if self.reference.is_empty() {
ui.ctx().request_repaint(); // Nothing to draw until the first reference orbit is ready.
ui.ctx().request_repaint_after(poll);
return; return;
} }
// Keep polling the worker while a newer reference is computing.
if self.pending { if self.pending {
ui.ctx().request_repaint(); ui.ctx().request_repaint_after(poll);
} }
// Cache-texture resolution: the widget size in physical pixels.
let ppp = ui.ctx().pixels_per_point();
let size_px = [
((rect.width() * ppp).round() as u32).max(1),
((rect.height() * ppp).round() as u32).max(1),
];
let uniforms = self.make_uniforms(aspect); let uniforms = self.make_uniforms(aspect);
ui.painter().add(egui_wgpu::Callback::new_paint_callback( ui.painter().add(egui_wgpu::Callback::new_paint_callback(
rect, rect,
@@ -587,6 +597,7 @@ impl FractalApp {
uniforms, uniforms,
reference: Arc::clone(&self.reference), reference: Arc::clone(&self.reference),
generation: self.generation, generation: self.generation,
size_px,
}, },
)); ));
} }
+230 -25
View File
@@ -1,10 +1,13 @@
//! wgpu resources for the fractal: the render pipeline, the uniform buffer, the //! wgpu resources for the fractal: the render pipeline, the uniform buffer, the
//! reference-orbit storage buffer, and the egui paint callback that drives them. //! reference-orbit storage buffer, and the egui paint callback that drives them.
//! //!
//! Rendering strategy: a single fullscreen triangle is drawn into the rectangle //! Rendering strategy: the expensive per-pixel perturbation shader renders into
//! egui allocates for the fractal widget (egui presets the render pass viewport //! an offscreen **cache texture**, and only when the view/coloring/size actually
//! for us). The fragment shader iterates each pixel as an f32 perturbation delta //! change (tracked by `rendered`). Every egui frame then just blits that cached
//! from the high-precision reference orbit stored in `ref_buffer`. //! 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 std::sync::Arc;
@@ -38,6 +41,24 @@ pub struct Uniforms {
pub dc_offset: [f32; 2], pub dc_offset: [f32; 2],
} }
/// Offscreen texture the fractal is rendered into, plus the bind group used to
/// blit it. Recreated whenever the widget's pixel size changes.
struct CacheTarget {
view: wgpu::TextureView,
blit_bind_group: wgpu::BindGroup,
width: u32,
height: u32,
}
/// State the cache texture was last rendered with. If the next frame's inputs
/// match this, the cache is still valid and the fractal shader is skipped.
struct RenderedState {
uniforms: Uniforms,
generation: u64,
width: u32,
height: u32,
}
pub struct FractalRenderer { pub struct FractalRenderer {
pipeline: wgpu::RenderPipeline, pipeline: wgpu::RenderPipeline,
uniform_buffer: wgpu::Buffer, uniform_buffer: wgpu::Buffer,
@@ -46,6 +67,15 @@ pub struct FractalRenderer {
target_format: wgpu::TextureFormat, target_format: wgpu::TextureFormat,
/// Generation of the reference orbit currently uploaded to `ref_buffer`. /// Generation of the reference orbit currently uploaded to `ref_buffer`.
uploaded_generation: u64, uploaded_generation: u64,
/// Blit pipeline + resources that copy the cache texture to egui's surface.
blit_pipeline: wgpu::RenderPipeline,
blit_bind_group_layout: wgpu::BindGroupLayout,
blit_sampler: wgpu::Sampler,
/// The offscreen cache; `None` until the first frame sizes it.
cache: Option<CacheTarget>,
/// What the cache currently holds; `None` forces a re-render.
rendered: Option<RenderedState>,
} }
impl FractalRenderer { impl FractalRenderer {
@@ -142,6 +172,74 @@ impl FractalRenderer {
cache: 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 { Self {
pipeline, pipeline,
uniform_buffer, uniform_buffer,
@@ -149,9 +247,65 @@ impl FractalRenderer {
bind_group, bind_group,
target_format, target_format,
uploaded_generation: u64::MAX, uploaded_generation: u64::MAX,
blit_pipeline,
blit_bind_group_layout,
blit_sampler,
cache: None,
rendered: 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 texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("fractal cache"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.target_format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
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(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.blit_sampler),
},
],
});
self.cache = Some(CacheTarget {
view,
blit_bind_group,
width,
height,
});
// New texture → old render is gone.
self.rendered = None;
}
/// Upload a reference orbit to the storage buffer (used by PNG export to /// Upload a reference orbit to the storage buffer (used by PNG export to
/// guarantee the buffer is current before an offscreen render). /// guarantee the buffer is current before an offscreen render).
pub fn upload_reference(&self, queue: &wgpu::Queue, points: &[[f32; 2]]) { pub fn upload_reference(&self, queue: &wgpu::Queue, points: &[[f32; 2]]) {
@@ -303,39 +457,88 @@ pub fn encode_png(
/// A per-frame paint callback. Carries this frame's uniforms plus a reference to /// 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 /// the current reference orbit (cheap `Arc` clone). The orbit is only re-uploaded
/// to the GPU when its `generation` changes. /// to the GPU when its `generation` changes, and the fractal is only re-rendered
/// into the cache when the uniforms, generation, or `size_px` change.
pub struct FractalCallback { pub struct FractalCallback {
pub uniforms: Uniforms, pub uniforms: Uniforms,
pub reference: Arc<Vec<[f32; 2]>>, pub reference: Arc<Vec<[f32; 2]>>,
pub generation: u64, pub generation: u64,
/// Widget size in physical pixels — the cache texture resolution.
pub size_px: [u32; 2],
} }
impl egui_wgpu::CallbackTrait for FractalCallback { impl egui_wgpu::CallbackTrait for FractalCallback {
fn prepare( fn prepare(
&self, &self,
_device: &wgpu::Device, device: &wgpu::Device,
queue: &wgpu::Queue, queue: &wgpu::Queue,
_screen_descriptor: &egui_wgpu::ScreenDescriptor, _screen_descriptor: &egui_wgpu::ScreenDescriptor,
_egui_encoder: &mut wgpu::CommandEncoder, egui_encoder: &mut wgpu::CommandEncoder,
resources: &mut egui_wgpu::CallbackResources, resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> { ) -> Vec<wgpu::CommandBuffer> {
if let Some(renderer) = resources.get_mut::<FractalRenderer>() { let Some(renderer) = resources.get_mut::<FractalRenderer>() else {
queue.write_buffer( return Vec::new();
&renderer.uniform_buffer, };
0,
bytemuck::bytes_of(&self.uniforms),
);
if renderer.uploaded_generation != self.generation && !self.reference.is_empty() { let width = self.size_px[0].max(1);
let count = self.reference.len().min(MAX_REF_POINTS); let height = self.size_px[1].max(1);
queue.write_buffer( renderer.ensure_cache(device, width, height);
&renderer.ref_buffer,
0, if renderer.uploaded_generation != self.generation && !self.reference.is_empty() {
bytemuck::cast_slice(&self.reference[..count]), let count = self.reference.len().min(MAX_REF_POINTS);
); queue.write_buffer(
renderer.uploaded_generation = self.generation; &renderer.ref_buffer,
} 0,
bytemuck::cast_slice(&self.reference[..count]),
);
renderer.uploaded_generation = self.generation;
} }
// Re-render the cache only when what it depends on changed.
let dirty = renderer.rendered.as_ref().is_none_or(|r| {
r.generation != self.generation
|| r.width != width
|| r.height != height
|| bytemuck::bytes_of(&r.uniforms) != bytemuck::bytes_of(&self.uniforms)
});
if !dirty {
return Vec::new();
}
queue.write_buffer(
&renderer.uniform_buffer,
0,
bytemuck::bytes_of(&self.uniforms),
);
if let Some(cache) = &renderer.cache {
let mut pass = egui_encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("fractal cache pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &cache.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.pipeline);
pass.set_bind_group(0, &renderer.bind_group, &[]);
pass.draw(0..3, 0..1);
}
renderer.rendered = Some(RenderedState {
uniforms: self.uniforms,
generation: self.generation,
width,
height,
});
Vec::new() Vec::new()
} }
@@ -345,9 +548,11 @@ impl egui_wgpu::CallbackTrait for FractalCallback {
render_pass: &mut wgpu::RenderPass<'static>, render_pass: &mut wgpu::RenderPass<'static>,
resources: &egui_wgpu::CallbackResources, resources: &egui_wgpu::CallbackResources,
) { ) {
if let Some(renderer) = resources.get::<FractalRenderer>() { if let Some(renderer) = resources.get::<FractalRenderer>()
render_pass.set_pipeline(&renderer.pipeline); && let Some(cache) = &renderer.cache
render_pass.set_bind_group(0, &renderer.bind_group, &[]); {
render_pass.set_pipeline(&renderer.blit_pipeline);
render_pass.set_bind_group(0, &cache.blit_bind_group, &[]);
render_pass.draw(0..3, 0..1); render_pass.draw(0..3, 0..1);
} }
} }
+33
View File
@@ -0,0 +1,33 @@
// Passthrough blit: sample the cached fractal texture and write it to egui's
// target. Kept separate from the fractal shader so the expensive per-pixel
// iteration runs only when the cache is (re)rendered, while every egui frame
// pays just this cheap textured fullscreen triangle.
@group(0) @binding(0) var cache_tex: texture_2d<f32>;
@group(0) @binding(1) var cache_samp: sampler;
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut {
var verts = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
let p = verts[idx];
var out: VsOut;
out.pos = vec4<f32>(p, 0.0, 1.0);
// Map NDC to texture UV. v is flipped so the cache's top row (rendered at
// NDC y = +1) shows at the top of the screen.
out.uv = vec2<f32>((p.x + 1.0) * 0.5, (1.0 - p.y) * 0.5);
return out;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
return textureSampleLevel(cache_tex, cache_samp, in.uv, 0.0);
}