Viewer: Auto resolution follows the magnification; minified frames are averaged (#417)

Cause: Auto dropped to Half below 75 % magnification, so text between
50 % and 75 % was rendered with fewer pixels than the screen shows and
looked soft. Below 100 % the frame texture was minified with a plain
bilinear sampler and no mip levels (Full at 46 % skipped most frame
pixels), so edges stair-stepped.

Fix:
- Auto renders the pixels the magnification needs, as in After Effects:
  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).
- GPU frames: the display texture carries a full mip chain, each level
  the 2 x 2 mean of the one above (display.wgsl `mip`, same bind group
  layout as `display`), sampled trilinearly with Viewer Zoom Quality
  More Accurate (Faster keeps the nearest pixel of the full-size frame).
- CPU frames (egui-wgpu textures have no mips): uploaded averaged down
  by the whole factor that leaves about one texel per screen pixel, the
  same box average as the texture-limit fit (#201), re-uploaded when the
  magnification calls for another factor; drawn with texture coordinates
  that keep each texel over its pixels when the size isn't a multiple.
  Show Channel / exposure textures follow.

Tests: gpu tests::display_frames_are_mipmapped (level count and level 1
= 2 x 2 means of level 0), viewer_tools
auto_resolution_follows_the_magnification (60 % was Half) and
minified_frames_keep_their_texels_in_place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-09 22:52:48 +07:00
parent 94e4afe8de
commit 9d85d4f116
12 changed files with 263 additions and 70 deletions

View File

@ -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()
}

View File

@ -104,8 +104,9 @@ pub struct Gpu {
auto: Arc<effectcraft_render::AutoPick>,
}
/// 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,

View File

@ -17,3 +17,20 @@ fn display(@builtin(global_invocation_id) gid: vec3<u32>) {
let q = floor(vec4<f32>(clamp(s.xyz, vec3<f32>(0.0), vec3<f32>(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<u32>) {
let dims = vec2<i32>(textureDimensions(dout));
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let last = vec2<i32>(textureDimensions(dsrc)) - vec2<i32>(1);
let q = p * 2;
let s = textureLoad(dsrc, min(q, last), 0) + textureLoad(dsrc, min(q + vec2<i32>(1, 0), last), 0)
+ textureLoad(dsrc, min(q + vec2<i32>(0, 1), last), 0) + textureLoad(dsrc, min(q + vec2<i32>(1, 1), last), 0);
textureStore(dout, p, s * 0.25);
}

View File

@ -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

View File

@ -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<egui::Color32> = (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;
}

View File

@ -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<u32, (FrameKey, panels::viewers::PassiveTexture)>,
/// 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

View File

@ -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));

View File

@ -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");
}
}

View File

@ -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)
}

View File

@ -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,

View File

@ -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

View File

@ -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