diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 93283e78..e994dd1b 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -246,6 +246,8 @@ pub struct GpuContext { pipelines: HashMap<&'static str, wgpu::ComputePipeline>, display_bgl: wgpu::BindGroupLayout, display: wgpu::ComputePipeline, + /// The display texture's next mip level from the one above (`display.wgsl` `mip`). + display_mip: wgpu::ComputePipeline, dummy: wgpu::Texture, dummy_tex: wgpu::TextureView, dummy_buf: wgpu::Buffer, @@ -506,16 +508,20 @@ impl GpuContext { immediate_size: 0, }) })?; - let display = init_resource(&device, "display compute pipeline", || { - device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("display"), - layout: Some(&dlayout), - module: &dmodule, - entry_point: Some("display"), - compilation_options: Default::default(), - cache: None, + let display_pipeline = |entry: &'static str| { + init_resource(&device, "display compute pipeline", || { + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(entry), + layout: Some(&dlayout), + module: &dmodule, + entry_point: Some(entry), + compilation_options: Default::default(), + cache: None, + }) }) - })?; + }; + let display = display_pipeline("display")?; + let display_mip = display_pipeline("mip")?; let dummy = init_resource(&device, "dummy texture", || { device.create_texture(&wgpu::TextureDescriptor { label: Some("dummy"), @@ -543,6 +549,7 @@ impl GpuContext { pipelines, display_bgl, display, + display_mip, dummy_tex, dummy, dummy_buf, @@ -1186,36 +1193,47 @@ impl<'g> Enc<'g> { self.g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("data"), contents: &bytes, usage: wgpu::BufferUsages::STORAGE }) } - /// Convert to the viewer's premultiplied RGBA8 texture (egui-wgpu's native texture format). + /// Convert to the viewer's premultiplied RGBA8 texture (egui-wgpu's native texture format), + /// with a full mip chain so the viewer minifies it without aliasing (#417). pub fn display(&mut self, img: &GpuImage) -> wgpu::Texture { let g = self.g; + let levels = mip_levels(img.width, img.height); let tex = g.device.create_texture(&wgpu::TextureDescriptor { label: Some("viewer frame"), size: wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 }, - mip_level_count: 1, + mip_level_count: levels, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8Unorm, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); - let sv = img.texture.create_view(&Default::default()); - let ov = tex.create_view(&Default::default()); - let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: None, - layout: &g.display_bgl, - entries: &[ - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&sv) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&ov) }, - ], - }); + let level = |l: u32| tex.create_view(&wgpu::TextureViewDescriptor { base_mip_level: l, mip_level_count: Some(1), ..Default::default() }); + // Level 0 from the frame, then each level the 2 × 2 mean of the one above. + let mut steps = vec![(&g.display, img.texture.create_view(&Default::default()), level(0), img.width, img.height)]; + for l in 1..levels { + steps.push((&g.display_mip, level(l - 1), level(l), (img.width >> l).max(1), (img.height >> l).max(1))); + } let enc = self.encoder(); - { + for (pipeline, src, dst, w, h) in &steps { + let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &g.display_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(src) }, + wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(dst) }, + ], + }); let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("display"), timestamp_writes: None }); - pass.set_pipeline(&g.display); + pass.set_pipeline(pipeline); pass.set_bind_group(0, &bg, &[]); - pass.dispatch_workgroups(img.width.div_ceil(16), img.height.div_ceil(16), 1); + pass.dispatch_workgroups(w.div_ceil(16), h.div_ceil(16), 1); } tex } } + +/// Mip levels of a full chain for a `w × h` texture (down to 1 × 1). +pub(crate) fn mip_levels(w: u32, h: u32) -> u32 { + u32::BITS - w.max(h).max(1).leading_zeros() +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 7d124303..8879ab86 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -104,8 +104,9 @@ pub struct Gpu { auto: Arc, } -/// A viewer frame left on the GPU: premultiplied RGBA8 (`wgpu::TextureFormat::Rgba8Unorm`), -/// ready for `egui_wgpu::Renderer::register_native_texture`. +/// A viewer frame left on the GPU: premultiplied RGBA8 (`wgpu::TextureFormat::Rgba8Unorm`) with +/// a full mip chain (for minified views), ready for +/// `egui_wgpu::Renderer::register_native_texture_with_sampler_options`. #[derive(Clone, Debug)] pub struct DisplayFrame { pub texture: wgpu::Texture, diff --git a/crates/gpu/src/shaders/display.wgsl b/crates/gpu/src/shaders/display.wgsl index 68261875..1b7da8a1 100644 --- a/crates/gpu/src/shaders/display.wgsl +++ b/crates/gpu/src/shaders/display.wgsl @@ -17,3 +17,20 @@ fn display(@builtin(global_invocation_id) gid: vec3) { let q = floor(vec4(clamp(s.xyz, vec3(0.0), vec3(a)), a) * 255.0 + 0.5) / 255.0; textureStore(dout, p, q); } + +// The display texture's next mip level (`dsrc` = the level above, `dout` = this one): each texel +// the mean of the 2 × 2 premultiplied texels it covers, as ui-egui's fitted CPU textures average +// (#417). A level one texel wide or high repeats its edge. +@compute @workgroup_size(16, 16) +fn mip(@builtin(global_invocation_id) gid: vec3) { + let dims = vec2(textureDimensions(dout)); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let last = vec2(textureDimensions(dsrc)) - vec2(1); + let q = p * 2; + let s = textureLoad(dsrc, min(q, last), 0) + textureLoad(dsrc, min(q + vec2(1, 0), last), 0) + + textureLoad(dsrc, min(q + vec2(0, 1), last), 0) + textureLoad(dsrc, min(q + vec2(1, 1), last), 0); + textureStore(dout, p, s * 0.25); +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 93ea89b0..39be29a9 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -635,6 +635,52 @@ fn backend_selection_and_display_frames() { } } +/// The display texture carries a full mip chain, each level the 2 × 2 mean of the one above, so the +/// viewer minifies a frame without aliasing (#417). +#[test] +fn display_frames_are_mipmapped() { + let Some(g) = gpu() else { return }; + let s = blend_scene(BitDepth::Bpc8, BlendMode::Screen); + let mut r = Renderer::new(&s.p, &Pattern, opts()); + r.accel = Some(g); + let f = g.render_display(&r, s.cid, Tick::ZERO).expect("display frame"); + assert_eq!(f.texture.mip_level_count(), crate::context::mip_levels(f.width, f.height)); + assert_eq!(f.texture.mip_level_count(), u32::BITS - f.width.max(f.height).leading_zeros()); + let base = g.read_display(&f).expect("readback"); + // Level 1, copied out to read it back. + let (w1, h1) = (f.width / 2, f.height / 2); + let tex = g.device().create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { width: w1, height: h1, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let mut e = crate::context::Enc::new(&g.ctx); + e.encoder().copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { texture: &f.texture, mip_level: 1, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::Extent3d { width: w1, height: h1, depth_or_array_layers: 1 }, + ); + let level1 = e.read_texture(&tex, w1, h1, 4).expect("level 1"); + let at = |x: u32, y: u32, c: usize| base[((y * f.width + x) * 4) as usize + c] as f32; + let mut varied = false; + for y in 0..h1 { + for x in 0..w1 { + for c in 0..4 { + let want = (at(2 * x, 2 * y, c) + at(2 * x + 1, 2 * y, c) + at(2 * x, 2 * y + 1, c) + at(2 * x + 1, 2 * y + 1, c)) / 4.0; + let got = level1[((y * w1 + x) * 4) as usize + c] as f32; + assert!((want - got).abs() <= 1.0, "({x}, {y}) channel {c}: {got} against the mean {want}"); + varied |= want != at(2 * x, 2 * y, c); + } + } + } + assert!(varied, "the scene has edges to average"); +} + /// Browsers' WGSL compilers reject an f32 literal whose exact decimal value lies outside the f32 /// range (`3.40282347e38` rounds to f32::MAX in Rust but exceeds it), which invalidates the whole /// module (every kernel); naga accepts it, so native runs can't catch it. Every float literal diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index a157359f..e1784090 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -425,33 +425,53 @@ pub fn to_color_image(img: &effectcraft_engine::render::Image) -> egui::ColorIma egui::ColorImage::new([img.width as usize, img.height as usize], px) } -/// The size `size` is shown at in a texture whose sides can't exceed `max_side`: divided by the -/// smallest whole factor that fits (frames of comps wider than the GPU's texture limit, #201). +/// The whole factor an image of `size` is averaged down by for a texture: at least `min_k`, and +/// the smallest that fits its sides in `max_side` (frames of comps wider than the GPU's texture +/// limit, #201). +pub fn fit_factor(size: [usize; 2], max_side: usize, min_k: usize) -> usize { + size[0].max(size[1]).div_ceil(max_side.max(1)).max(min_k).max(1) +} + +/// The size `size` is shown at in a texture whose sides can't exceed `max_side`. pub fn fitted_size(size: [usize; 2], max_side: usize) -> [usize; 2] { - let k = size[0].max(size[1]).div_ceil(max_side.max(1)).max(1); + let k = fit_factor(size, max_side, 1); [size[0].div_ceil(k), size[1].div_ceil(k)] } +/// The texture coordinates of the image within a texture averaged down by `k`: its last texels +/// cover fewer pixels than the factor, so drawn over the image's area with these the texels sit +/// exactly where their pixels are. +pub fn fitted_uv(size: [usize; 2], k: usize) -> egui::Rect { + let k = k.max(1); + let f = |n: usize| (n as f64 / (n.div_ceil(k).max(1) * k) as f64) as f32; + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(f(size[0]), f(size[1]))) +} + /// `img` as a texture the renderer accepts: unchanged when it fits in `max_side`, else averaged /// down by a whole factor (premultiplied pixels average correctly). The viewer draws it over the /// same area, so only detail beyond the GPU's texture limit is lost, instead of the upload /// failing (#201). pub fn fit_texture(img: egui::ColorImage, max_side: usize) -> egui::ColorImage { + fit_texture_by(img, max_side, 1) +} + +/// [`fit_texture`] averaging down by at least `min_k` (see [`fit_factor`]). +pub fn fit_texture_by(img: egui::ColorImage, max_side: usize, min_k: usize) -> egui::ColorImage { let [w, h] = img.size; - let [fw, fh] = fitted_size(img.size, max_side); - if [fw, fh] == [w, h] { + let k = fit_factor(img.size, max_side, min_k); + if k == 1 { return img; } - let k = w.max(h).div_ceil(max_side.max(1)).max(1); + let (fw, fh) = (w.div_ceil(k), h.div_ceil(k)); let px: Vec = (0..fw * fh) .into_par_iter() .map(|i| { let (x0, y0) = ((i % fw) * k, (i / fw) * k); - let (mut sum, mut n) = ([0u32; 4], 0u32); + let (mut sum, mut n) = ([0u64; 4], 0u64); for y in y0..(y0 + k).min(h) { for c in img.pixels.get(y * w + x0..y * w + (x0 + k).min(w)).unwrap_or_default() { for (s, v) in sum.iter_mut().zip(c.to_array()) { - *s += v as u32; + *s += v as u64; } n += 1; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 01a02881..82be8a32 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -231,8 +231,9 @@ pub struct EffectcraftApp { /// When the project last changed, and its revision then (see [`Self::editing`]). last_edit: (f64, u64), pub(crate) toast: Option<(String, f64)>, - /// Texture of the last CPU frame shown in the viewer and the key it came from. - pub(crate) viewer_tex: Option<(egui::TextureHandle, FrameKey)>, + /// Texture of the last CPU frame shown in the viewer, the key it came from and the factor it + /// was averaged down by for the magnification (`panels::viewer::minify_factor`). + pub(crate) viewer_tex: Option<(egui::TextureHandle, FrameKey, usize)>, /// The frames the other (passive) Composition viewers show, by viewer id. pub(crate) passive_tex: std::collections::HashMap, /// The last GPU frame shown: its egui texture id (registered with egui-wgpu), key and texture. @@ -550,7 +551,7 @@ impl EffectcraftApp { } self.viewer_native = None; self.passive_tex.retain(|_, (_, texture)| matches!(texture, panels::viewers::PassiveTexture::Cpu(_))); - self.viewer_shown = self.viewer_tex.as_ref().map(|(texture, key)| (texture.id(), *key)); + self.viewer_shown = self.viewer_tex.as_ref().map(|(texture, key, _)| (texture.id(), *key)); self.viewer_image = None; // Old completion closures retain only their detached slot, not the replacement. self.viewer_readback = Default::default(); @@ -615,7 +616,7 @@ impl EffectcraftApp { /// The size of the viewer frame's texture (CPU frames; fitted to the GPU's texture limit). pub fn viewer_texture_size(&self) -> Option<[usize; 2]> { - self.viewer_tex.as_ref().map(|(t, _)| t.size()) + self.viewer_tex.as_ref().map(|(t, _, _)| t.size()) } /// The viewer's current pixels as 8-bit premultiplied RGBA, reading a GPU frame back the diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 66045ce2..9bc70cb1 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -669,8 +669,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.request_frame_urgent(cid, frame, scale); if let Some(img) = app.frames.get(&key) { let stale = app.viewer_shown.as_ref().is_none_or(|(_, k)| *k != key); - if stale { - show_frame(app, &ctx, key, img); + // A CPU frame goes up again when the magnification calls for another factor. + let minify = minify_factor(app.session.prefs.viewer_zoom_smooth(), scale, (zoom * ppp) as f64); + let refit = !app.viewer_on_gpu() && app.viewer_tex.as_ref().is_some_and(|(_, _, m)| *m != minify); + if stale || refit { + show_frame(app, &ctx, key, img, minify); } else if let Some((_, k)) = app.viewer_shown.as_mut() { // The same frame at a later revision (an edit elsewhere, an undo): it shows that one. k.revision = key.revision; @@ -1989,6 +1992,34 @@ pub(crate) fn zoom_texture_options(smooth: bool) -> egui::TextureOptions { if smooth { egui::TextureOptions::LINEAR } else { egui::TextureOptions::NEAREST } } +/// The sampler GPU frames (mipmapped display textures) are drawn with: More Accurate filters +/// bilinearly within and between mip levels, so a frame shown below 100 % is averaged instead of +/// skipping pixels (#417); Faster takes the nearest pixel of the full-size frame. +pub(crate) fn zoom_sampler(smooth: bool) -> eframe::wgpu::SamplerDescriptor<'static> { + use eframe::wgpu::{FilterMode, MipmapFilterMode, SamplerDescriptor}; + let label = Some("viewer frame"); + if smooth { + SamplerDescriptor { + label, + mag_filter: FilterMode::Linear, + min_filter: FilterMode::Linear, + mipmap_filter: MipmapFilterMode::Linear, + ..Default::default() + } + } else { + SamplerDescriptor { label, lod_max_clamp: 0.0, ..Default::default() } + } +} + +/// The whole factor a CPU frame is averaged down by for its texture when `pixels` of it are drawn +/// over `screen` screen pixels: about one texel per screen pixel, so bilinear filtering never +/// skips frame pixels — the CPU frames' counterpart of the GPU frames' mip levels (#417). Faster +/// keeps every pixel. +pub(crate) fn minify_factor(smooth: bool, pixels: f64, screen: f64) -> usize { + let k = (pixels / screen + 1e-3).floor(); + if smooth && k >= 1.0 { k.min(256.0) as usize } else { 1 } +} + pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> { let h = s.trim().trim_start_matches('#'); if h.len() != 6 || !h.is_ascii() { @@ -2026,26 +2057,27 @@ fn rect_of(map: &ViewerMap, r: Option<[f64; 4]>) -> Rect { Rect::from_min_max(map.to_screen([x, y]), map.to_screen([x + w, y + h])) } -/// Put a rendered frame on screen: CPU pixels go into an egui texture; GPU frames are drawn -/// straight from their wgpu texture (registered with egui-wgpu, no readback). -fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames::FrameKey, img: crate::frames::FrameImage) { +/// Put a rendered frame on screen: CPU pixels go into an egui texture, averaged down by `minify` +/// (see [`minify_factor`]); GPU frames are drawn straight from their wgpu texture (registered with +/// egui-wgpu, no readback). +fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames::FrameKey, img: crate::frames::FrameImage, minify: usize) { use crate::frames::FrameImage; // Settings ▸ Previews ▸ Viewer Zoom Quality. let smooth = app.session.prefs.viewer_zoom_smooth(); let opts = zoom_texture_options(smooth); match img { FrameImage::Cpu(img) => { - // Frames wider or taller than the GPU's texture limit go up averaged down (#201). + // Frames wider or taller than the GPU's texture limit go up averaged down too (#201). let max = crate::frames::max_texture_side(ctx); - let fitted = crate::frames::fit_texture((*img).clone(), max); + let fitted = crate::frames::fit_texture_by((*img).clone(), max, minify); match &mut app.viewer_tex { - Some((tex, k)) if tex.size() == fitted.size => { + Some((tex, k, m)) if tex.size() == fitted.size => { tex.set(fitted, opts); - *k = key; + (*k, *m) = (key, minify); } - _ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", fitted, opts), key)), + _ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", fitted, opts), key, minify)), } - app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k)| (t.id(), *k)); + app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k, _)| (t.id(), *k)); app.viewer_image = Some(img); vt::set_texture_roi(ctx, app.session.state.region_of_interest.or(app.ui.viewer.extended)); } @@ -2053,13 +2085,12 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames: let Some(rs) = &app.wgpu else { return }; let view = f.texture.create_view(&Default::default()); let mut ren = rs.renderer.write(); - let filter = if smooth { eframe::wgpu::FilterMode::Linear } else { eframe::wgpu::FilterMode::Nearest }; let id = match &app.viewer_native { Some((id, _, _)) => { - ren.update_egui_texture_from_wgpu_texture(&rs.device, &view, filter, *id); + ren.update_egui_texture_from_wgpu_texture_with_sampler_options(&rs.device, &view, zoom_sampler(smooth), *id); *id } - None => ren.register_native_texture(&rs.device, &view, filter), + None => ren.register_native_texture_with_sampler_options(&rs.device, &view, zoom_sampler(smooth)), }; drop(ren); app.viewer_native = Some((id, key, f)); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 36b64b48..8d41eb73 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -421,7 +421,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: } comp_rect } else { - let Some((tex, k)) = app.viewer_tex.clone() else { return }; + let Some((tex, k, minify)) = app.viewer_tex.clone() else { return }; if k.comp != cid.0 { return; } @@ -431,29 +431,40 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: None => comp_rect, }; if plain { + let uv = app.viewer_image.as_ref().map_or(uv, |src| fitted_uv(ctx, src.size, minify)); painter.image(tex.id(), rect, uv, Color32::WHITE); return; } rect }; let Some(src) = app.viewer_image.clone() else { return }; - let zoom_opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); + let smooth = app.session.prefs.viewer_zoom_smooth(); + // Averaged down for the magnification, as the frame's own texture is (#417). + let minify = super::viewer::minify_factor(smooth, src.size[0] as f64, (rect.width() * ctx.pixels_per_point()) as f64); let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - (std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key).hash(&mut h); + (std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key, minify).hash(&mut h); h.finish() }; let id = egui::Id::new("viewer-display-tex"); let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) { Some((k, tex)) if k == key => tex, _ => { - let tex = crate::frames::load_fitted(ctx, "viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), zoom_opts); + let img = + crate::frames::fit_texture_by(transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), crate::frames::max_texture_side(ctx), minify); + let tex = ctx.load_texture("viewer-display", img, super::viewer::zoom_texture_options(smooth)); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); tex } }; - painter.image(tex.id(), rect, uv, Color32::WHITE); + painter.image(tex.id(), rect, fitted_uv(ctx, src.size, minify), Color32::WHITE); +} + +/// The texture coordinates of a frame of `size` in its texture, fitted with at least `minify` +/// (see [`crate::frames::fitted_uv`]). +fn fitted_uv(ctx: &egui::Context, size: [usize; 2], minify: usize) -> Rect { + crate::frames::fitted_uv(size, crate::frames::fit_factor(size, crate::frames::max_texture_side(ctx), minify)) } // ---------------------------------------------------------------- bottom bar @@ -757,4 +768,48 @@ mod tests { assert_eq!(resolution_label(Resolution::Quarter, 0.25), "Quarter"); assert_eq!(resolution_label(Resolution::Custom(5), 0.2), "Custom (5)"); } + + /// Auto renders the pixels the magnification needs, as in After Effects: Full above 50 %, + /// Half down to 33.3 %, Third down to 25 %, Quarter below — never fewer pixels than the + /// screen shows (#417: 60 % rendered at Half). + #[test] + fn auto_resolution_follows_the_magnification() { + let auto = |zoom: f32, ppp: f32| resolution_label(Resolution::Auto, Resolution::Auto.scale(zoom, ppp)); + for (zoom, want) in [(2.0, "(Full)"), (1.0, "(Full)"), (0.6, "(Full)"), (0.51, "(Full)"), (0.5, "(Half)"), (0.46, "(Half)"), (0.34, "(Half)")] { + assert_eq!(auto(zoom, 1.0), want, "{zoom}"); + } + for (zoom, want) in [(1.0 / 3.0, "(Third)"), (0.26, "(Third)"), (0.25, "(Quarter)"), (0.1, "(Quarter)"), (0.0, "(Quarter)")] { + assert_eq!(auto(zoom, 1.0), want, "{zoom}"); + } + // Magnification in points on a 150 % display: 40 % shows 60 % of the comp's pixels. + assert_eq!(auto(0.4, 1.5), "(Full)"); + assert_eq!(auto(f32::NAN, 1.0), "(Full)"); + assert_eq!(Resolution::Full.scale(0.1, 1.0), 1.0); + } + + /// CPU frames go up averaged down to about one texel per screen pixel (More Accurate only), + /// drawn with texture coordinates that keep each texel over its pixels (#417). + #[test] + fn minified_frames_keep_their_texels_in_place() { + use crate::panels::viewer::minify_factor; + assert_eq!(minify_factor(true, 1.0, 0.46), 2); + assert_eq!(minify_factor(true, 1.0, 0.5), 2); + assert_eq!(minify_factor(true, 1.0, 0.51), 1); + assert_eq!(minify_factor(true, 0.5, 0.46), 1, "a Half frame at 46 %"); + assert_eq!(minify_factor(true, 1.0, 1.0 / 3.0), 3); + assert_eq!(minify_factor(true, 1.0, 2.0), 1); + assert_eq!(minify_factor(false, 1.0, 0.1), 1, "Faster keeps every pixel"); + assert_eq!(minify_factor(true, 1.0, 0.0), 256); + assert_eq!(minify_factor(true, f64::NAN, 1.0), 1); + // 2560 px by 3: 854 texels hold 2562 px, the image is 2560 / 2562 of them. + let uv = crate::frames::fitted_uv([2560, 1440], 3); + assert!((uv.max.x - 2560.0 / 2562.0).abs() < 1e-6 && uv.max.y == 1.0, "{uv:?}"); + assert_eq!(crate::frames::fitted_uv([7, 5], 1).max, egui::pos2(1.0, 1.0)); + let img = + egui::ColorImage::new([5, 1], vec![egui::Color32::WHITE, egui::Color32::BLACK, egui::Color32::WHITE, egui::Color32::BLACK, egui::Color32::WHITE]); + let out = crate::frames::fit_texture_by(img, 4096, 2); + assert_eq!(out.size, [3, 1]); + assert_eq!(out.pixels[1], egui::Color32::from_gray(128), "texels average their pixels"); + assert_eq!(out.pixels[2], egui::Color32::WHITE, "the last one only the pixels it holds"); + } } diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index 57a3511f..c0c4a454 100644 --- a/crates/ui-egui/src/panels/viewers.rs +++ b/crates/ui-egui/src/panels/viewers.rs @@ -145,14 +145,14 @@ fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: Fram (FrameImage::Gpu(f), old) => { let Some(rs) = &app.wgpu else { return }; let view = f.texture.create_view(&Default::default()); - let filter = if app.session.prefs.viewer_zoom_smooth() { eframe::wgpu::FilterMode::Linear } else { eframe::wgpu::FilterMode::Nearest }; + let sampler = || super::viewer::zoom_sampler(app.session.prefs.viewer_zoom_smooth()); let mut ren = rs.renderer.write(); let tid = match old { Some((_, PassiveTexture::Gpu(tid, _))) => { - ren.update_egui_texture_from_wgpu_texture(&rs.device, &view, filter, tid); + ren.update_egui_texture_from_wgpu_texture_with_sampler_options(&rs.device, &view, sampler(), tid); tid } - _ => ren.register_native_texture(&rs.device, &view, filter), + _ => ren.register_native_texture_with_sampler_options(&rs.device, &view, sampler()), }; PassiveTexture::Gpu(tid, f) } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index a7069c2b..0cce2c11 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -252,17 +252,12 @@ impl Resolution { /// Render scale for a viewer magnification (and display pixel density). pub fn scale(self, zoom: f32, ppp: f32) -> f64 { match self { + // As in After Effects, Auto renders the pixels the magnification needs: the coarsest + // whole factor that still gives every screen pixel a rendered one (Full above 50 %, + // Half down to 33.3 %, Third down to 25 %, Quarter below), never fewer (#417). Resolution::Auto => { - let z = (zoom * ppp) as f64; - if z >= 0.75 { - 1.0 - } else if z >= 0.45 { - 0.5 - } else if z >= 0.3 { - 1.0 / 3.0 - } else { - 0.25 - } + let n = (1.0 / (zoom * ppp) as f64 + 1e-3).floor(); + if n >= 1.0 { 1.0 / n.min(4.0) } else { 1.0 } } Resolution::Full => 1.0, Resolution::Half => 0.5, diff --git a/docs/architecture.md b/docs/architecture.md index 7835e395..33e38215 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -171,6 +171,11 @@ options (Draft / Fast Previews, shadows, nested switches), so frames rendered un options are never shown. When the budget (Settings ▸ Memory & CPU) is full the least recently shown frames go first, and frames of an older revision are dropped as soon as the project changes. The timeline's green bar counts the frames of exactly what the viewer shows. +Resolution Auto renders the pixels the magnification needs, as in After Effects (Full above +50 %, Half down to 33.3 %, Third down to 25 %, then Quarter). Below 100 % the viewer averages +instead of skipping pixels (Viewer Zoom Quality More Accurate): GPU frames carry a mip chain +sampled trilinearly, CPU frames go up averaged down by the whole factor that leaves about one +texel per screen pixel. A persistent **disk cache** (`render::disk_cache`, Settings ▸ Media & Disk Cache) backs both the layer cache and the viewer's RAM preview: layer buffers that were slow to render and every diff --git a/docs/parity.md b/docs/parity.md index 5c4ae138..c89bee97 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -415,6 +415,10 @@ Fixes from measured reports: - Text layers are always rasterised at their on-screen scale, sharp at any Scale as in After Effects, and an unscaled layer at a fractional position is no longer rasterised a quarter octave too large (#390). +- The viewer's Auto resolution renders the pixels the magnification needs (Full above 50 %), and + frames shown below 100 % are averaged (GPU frames through mip levels, CPU frames by a whole + factor) instead of minified bilinearly (#417). Not yet: the viewer resolution and magnification + are not remembered between launches. ## Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel