feat: don't draw the fractal each frames
This commit is contained in:
+15
-4
@@ -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,
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
+221
-16
@@ -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,28 +457,32 @@ 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),
|
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() {
|
if renderer.uploaded_generation != self.generation && !self.reference.is_empty() {
|
||||||
let count = self.reference.len().min(MAX_REF_POINTS);
|
let count = self.reference.len().min(MAX_REF_POINTS);
|
||||||
@@ -335,7 +493,52 @@ impl egui_wgpu::CallbackTrait for FractalCallback {
|
|||||||
);
|
);
|
||||||
renderer.uploaded_generation = self.generation;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user