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:
parent
94e4afe8de
commit
9d85d4f116
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue