Merge pull request #444 from storytold/fix/415-416-417-text-fidelity

Text and viewer fidelity: glyph seams, overlay fringes, text raster scale, viewer minification (#415, #416, #390, #417)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-09 23:56:18 +07:00
commit 176cddcdad
19 files changed
+559 -122

No files matched your search

+6 -2
View File
@@ -75,9 +75,13 @@ fn kinetic_typography() {
qa.exec("layer.setSwitch", json!({"layers": [pre["layer"].clone()], "switch": "motionBlur", "value": true}));
qa.exec("layer.enableTimeRemap", json!({"layers": [pre["layer"].clone()], "value": true}));
qa.tool("add_keyframe", json!({"layer": pre["layer"].clone(), "path": "timeRemap", "keys": [{"time": 0, "value": 1.9}, {"time": 1.9, "value": 0}]}));
let fwd = qa.frame(Some(pre["comp"].clone()), 1.6);
// The 1.9 s key sits on frame 23 (1.917 s), so 0.3 s maps to 1.6026 s: compared at that time,
// not 1.6 s, where the moving title (and text drawn at its on-screen scale) differs.
let at = qa.tool("get_property", json!({"layer": pre["layer"].clone(), "path": "timeRemap", "time": 0.3}))["value"].as_f64().unwrap();
assert!((at - 1.6).abs() < 0.01, "time remap 0.3 s → 1.6 s of the precomp: {at}");
let fwd = qa.frame(Some(pre["comp"].clone()), at);
let rev = qa.frame(Some(comp.clone()), 0.3);
assert!(mean_diff(&fwd, &rev) < 1.0, "time remap 0.3 s → 1.6 s of the precomp");
assert!(mean_diff(&fwd, &rev) < 1.0, "the remapped precomp shows its frame at {at} s: {}", mean_diff(&fwd, &rev));
// Render: H.264 and a PNG sequence through the render queue.
let mp4 = qa.path("kinetic.mp4");
+42 -24
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()
}
+3 -2
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,
+17
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);
}
+46
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
+15 -9
View File
@@ -681,18 +681,21 @@ impl<'a> Renderer<'a> {
Region::Full
}
/// Rasterisation scale of a layer's source: the output scale, or — for Continuously
/// Rasterize text and shape layers — the output scale times the layer's on-screen scale
/// (quarter-octave steps, rounded up, so vector content is drawn at its final size and
/// stays sharp when scaled up).
/// Rasterisation scale of a layer's source: the output scale, or — for text layers, which are
/// always continuously rasterised, and Continuously Rasterize shape layers and vector footage —
/// the output scale times the layer's on-screen scale (quarter-octave steps, rounded up, so
/// vector content is drawn at its final size and stays sharp when scaled up).
pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 {
let s = self.opts.scale;
let vector = match layer.source {
LayerSource::Text | LayerSource::Shape => true,
LayerSource::Footage { item } => matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f)),
let continuous = match layer.source {
LayerSource::Text => true,
LayerSource::Shape => layer.switches.collapse,
LayerSource::Footage { item } => {
layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f))
}
_ => false,
};
if !layer.switches.collapse || !vector {
if !continuous {
return s;
}
let (l2c, _) = ctx.layer_to_comp(layer);
@@ -705,7 +708,10 @@ impl<'a> Renderer<'a> {
if !k.is_finite() || k <= 1e-6 {
return s;
}
let mut k = 2f64.powf((k.log2() * 4.0).ceil() / 4.0).clamp(1.0 / 16.0, 64.0);
// The step is rounded up past rounding noise only: an unscaled layer whose position isn't
// a whole number maps the unit vectors to lengths a hair over 1, which must stay 1 (#390:
// rounded up to 2^¼, unscaled text was drawn larger and resampled, softer).
let mut k = 2f64.powf((k.log2() * 4.0 - 1e-6).ceil() / 4.0).clamp(1.0 / 16.0, 64.0);
// Keep the buffer within about 16 million pixels.
if let Some(b) = content_bounds(ctx, layer) {
let area = ((b[2] - b[0]) * (b[3] - b[1])).max(1.0) * s * s;
+42 -18
View File
@@ -460,18 +460,41 @@ fn noisy(a: f32, noise: f32, x: usize, y: usize, seed: u32) -> f32 {
if noise <= 0.0 { a } else { a * (1.0 - noise * hash_noise(x as u32, y as u32, seed)) }
}
/// `dst` with the straight colour `c` at coverage `a` blended onto it at pixel (`x`, `y`).
#[inline]
fn blend_style(mode: BlendMode, seed: u32, x: usize, y: usize, dst: [f32; 4], c: [f32; 3], a: f32) -> [f32; 4] {
let n = if mode == BlendMode::Dissolve { hash_noise(x as u32, y as u32, seed) } else { 0.5 };
blend_pixel(mode, dst, [c[0] * a, c[1] * a, c[2] * a, a], n)
}
/// Blend a straight colour with coverage `a` onto every pixel of `img` (`f(x, y)` → (colour, a)).
fn blend_into(img: &mut Image, mode: BlendMode, seed: u32, f: impl Fn(usize, usize) -> ([f32; 3], f32) + Sync) {
let w = img.width as usize;
img.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, d) in row.iter_mut().enumerate() {
let (c, a) = f(x, y);
if a <= 0.0 {
continue;
if a > 0.0 {
*d = blend_style(mode, seed, x, y, *d, c, a);
}
}
});
}
/// Blend an interior style clipped to the content: `f(x, y)` → (straight colour, opacity) is
/// blended onto the layer *inside* its shape, whose coverage is `clip`, so the body keeps its
/// alpha and an anti-aliased edge pixel mixes the style with the layer's colour in the
/// proportion the style covers it, instead of letting the layer's own colour show through
/// around a blended edge (#416).
fn blend_clipped(img: &mut Image, clip: &Plane, mode: BlendMode, seed: u32, f: impl Fn(usize, usize) -> ([f32; 3], f32) + Sync) {
let w = img.width as usize;
img.data.par_chunks_mut(w.max(1)).zip(clip.data.par_chunks(w.max(1))).enumerate().for_each(|(y, (row, cov))| {
for (x, (d, &k)) in row.iter_mut().zip(cov).enumerate() {
if k > 0.0
&& let (c, a) = f(x, y)
&& a > 0.0
{
*d = blend_style(mode, seed, x, y, d.map(|v| v / k), c, a).map(|v| v * k);
}
let src = [c[0] * a, c[1] * a, c[2] * a, a];
let n = if mode == BlendMode::Dissolve { hash_noise(x as u32, y as u32, seed) } else { 0.5 };
*d = blend_pixel(mode, *d, src, n);
}
});
}
@@ -568,13 +591,12 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, content: &Buf, layer_size: [f64; 2])
if !bl.interior_group && bl.fill < 1.0 {
body.scale_alpha(bl.fill);
}
let cov = |i: usize| alpha.data[i];
if let Some(go) = &styles.gradient {
let (cx, cy, hw, hh) = gradient_frame(go, &buf, &alpha, layer_size);
let (ux, uy) = (go.angle.to_radians().cos(), -go.angle.to_radians().sin());
let ext = ((hw * ux).abs() + (hh * uy).abs()).max(1e-3) * go.scale;
let radius = hw.max(hh).max(1e-3) * go.scale;
blend_into(&mut body, go.mode, seed, |x, y| {
blend_clipped(&mut body, &alpha, go.mode, seed, |x, y| {
let (px, py) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy);
let along = px * ux + py * uy;
let across = -px * uy + py * ux;
@@ -592,35 +614,35 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, content: &Buf, layer_size: [f64; 2])
t = 1.0 - t;
}
let c = go.gradient.sample(t.clamp(0.0, 1.0));
([c[0], c[1], c[2]], c[3] * go.opacity * cov(y * w + x))
([c[0], c[1], c[2]], c[3] * go.opacity)
});
}
if let Some(co) = &styles.color {
blend_into(&mut body, co.mode, seed, |x, y| (co.color, co.opacity * cov(y * w + x)));
blend_clipped(&mut body, &alpha, co.mode, seed, |_, _| (co.color, co.opacity));
}
if let Some(sa) = &styles.satin {
let b = gauss_plane(&alpha, sa.size * s / 2.0, sa.size * s / 2.0);
let off = [sa.offset[0] * s, sa.offset[1] * s];
blend_into(&mut body, sa.mode, seed, |x, y| {
blend_clipped(&mut body, &alpha, sa.mode, seed, |x, y| {
let v = (shifted(&b, x, y, off) - shifted(&b, x, y, [-off[0], -off[1]])).abs().min(1.0);
let v = if sa.invert { 1.0 - v } else { v };
(sa.color, v * sa.opacity * cov(y * w + x))
(sa.color, v * sa.opacity)
});
}
if let Some(gl) = &styles.inner_glow {
let v = glow_intensity(&alpha, &d, gl, s, true);
blend_into(&mut body, gl.mode, seed, |x, y| {
blend_clipped(&mut body, &alpha, gl.mode, seed, |x, y| {
let i = y * w + x;
let e = if gl.center { 1.0 - v.data[i] } else { v.data[i] };
let (c, a) = glow_color(gl, e, x, y);
(c, noisy(a * gl.opacity * cov(i), gl.noise, x, y, seed ^ 0x3c3c))
(c, noisy(a * gl.opacity, gl.noise, x, y, seed ^ 0x3c3c))
});
}
if let Some(is) = &styles.inner_shadow {
let shape = soft_shape(&alpha, &d, is.size * s, is.spread, true);
let off = [is.offset[0] * s, is.offset[1] * s];
blend_into(&mut body, is.mode, seed, |x, y| {
let a = shifted(&shape, x, y, off).min(1.0) * is.opacity * cov(y * w + x);
blend_clipped(&mut body, &alpha, is.mode, seed, |x, y| {
let a = shifted(&shape, x, y, off).min(1.0) * is.opacity;
(is.color, noisy(a, is.noise, x, y, seed ^ 0x1234))
});
}
@@ -635,11 +657,13 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, content: &Buf, layer_size: [f64; 2])
2 => (size / 2.0, size / 2.0),
_ => (size, 0.0),
};
// Inside part: blended over the body within the content.
// Inside part: blended over the body within the content, over the share of each
// pixel's coverage the ring takes.
if r_in > 0.0 {
blend_into(&mut body, sk.mode, seed, |x, y| {
blend_clipped(&mut body, &alpha, sk.mode, seed, |x, y| {
let i = y * w + x;
let ring = (alpha.data[i] - grown(sd.data[i], -r_in)).max(0.0);
let a = alpha.data[i];
let ring = ((a - grown(sd.data[i], -r_in)) / a.max(1e-6)).clamp(0.0, 1.0);
(sk.color, ring * sk.opacity)
});
}
+53 -2
View File
@@ -1,12 +1,12 @@
//! Collapse Transformations, Continuously Rasterize and the Quality switch.
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::Value;
use effectcraft_keyframe::{TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{Image, render_frame};
use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer, render_frame};
fn project() -> Project {
let mut p = Project::default();
@@ -234,3 +234,54 @@ fn collapsed_precomp_of_another_size_renders_through_the_parent_camera() {
assert!(img.get(100, 100)[3] < 0.01, "only the 40 px solid is drawn");
}
}
/// Text layers are always continuously rasterised, as text is vector in After Effects (#390, #194):
/// text scaled up with the layer's Scale is drawn at its on-screen size, as sharp as text set at
/// that font size, without the switch.
#[test]
fn text_scaled_up_stays_sharp_without_the_switch() {
let run = |size: f64, scale: f64| {
let mut p = project();
let comp = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (240, 160), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HE".into(), size, ..Default::default() }));
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
set(&mut l, "transform/position", Value::Vec3([40.0, 120.0, 0.0]));
set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0]));
assert!(!l.switches.collapse);
let mut c = comp;
c.layers = vec![l];
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
render_frame(&p, cid, Tick::ZERO, 1.0)
};
let partial = |img: &Image| img.data.iter().filter(|px| (0.02..0.98).contains(&px[3])).count();
let scaled = run(10.0, 800.0);
let native = run(80.0, 100.0);
let (ps, pn) = (partial(&scaled), partial(&native));
let d = max_diff(&scaled, &native);
eprintln!("partially covered pixels: scaled {ps}, native {pn}; max difference {d}");
assert!(native.data.iter().filter(|px| px[3] > 0.98).count() > 1000, "the text renders");
assert!(ps <= pn + pn / 2, "scaled text is blurred: {ps} soft pixels against {pn}");
assert!(d < 0.05, "scaled text differs from text set at that size by {d}");
}
/// Unscaled text is drawn at the output scale wherever it sits: the lengths a position that isn't a
/// whole number maps the unit vectors to, a rounding error over 1, don't raise its raster scale a
/// quarter octave (#390: that softened it, and drew it differently at nearby times).
#[test]
fn unscaled_text_rasterises_at_the_output_scale() {
let mut p = project();
let mut comp = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (240, 160), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HE".into(), size: 30.0, ..Default::default() }));
comp.layers = vec![l];
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
for i in 0..500 {
let pos = [3.1 + i as f64 * 0.37, 7.3 + i as f64 * 0.29, 0.0];
set(&mut p.comp_mut(cid).unwrap().layers[0], "transform/position", Value::Vec3(pos));
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let c = p.comp(cid).unwrap();
let k = r.raster_scale(&EvalCtx::new(&p, cid, c, Tick::ZERO), &c.layers[0]);
assert_eq!(k, 1.0, "text at {pos:?}");
}
}
+63 -2
View File
@@ -1,10 +1,10 @@
//! Layer Styles pixel tests.
use effectcraft_color::Label;
use effectcraft_keyframe::{Gradient, Keyframe, Value};
use effectcraft_keyframe::{Gradient, Keyframe, ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::styles::{self as st, GROUP};
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid};
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, MaskMode, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{Image, NoFootage, RenderOpts, Renderer, render_frame};
@@ -182,6 +182,67 @@ fn gradient_overlay_follows_angle() {
assert!(img.get(81, 50)[0] > 0.9 && img.get(118, 50)[0] < 0.1);
}
/// Overlays replace the layer's colour inside its alpha: an anti-aliased edge pixel mixes the
/// background with the overlay colour only, exactly as a layer of that colour would, with no
/// fringe of the layer's own colour (#416).
#[test]
fn overlays_leave_antialiased_edges_clean() {
let (blue, bg) = ([0.2, 0.533, 1.0], [0.25, 0.22, 0.45]);
// A mask half a pixel inside the square's left and right sides: in layer space, where the
// styles run, columns 80 and 119 are half covered.
let edge = |color: [f32; 3], f: &dyn Fn(&mut Project, &Comp, &mut Layer)| {
let (p, cid) = scene(color, Some(bg), |p, c, l| {
let mut next = p.next_id;
let pts = vec![[0.5, 0.0], [39.5, 0.0], [39.5, 40.0], [0.5, 40.0]];
let sp = ShapePath { in_tangents: vec![[0.0; 2]; 4], out_tangents: vec![[0.0; 2]; 4], vertices: pts, closed: true, feather: Vec::new() };
let m = build::mask(&mut Ids(&mut next), "Mask 1", sp, MaskMode::Add, [255, 255, 0]);
p.next_id = next;
l.props.group_mut("masks").unwrap().children.push(m.into());
f(p, c, l);
});
let img = render(&p, cid);
[img.get(80, 50), img.get(119, 50), img.get(100, 50)]
};
let plain = edge(blue, &|_, _, _| {});
assert!(near(plain[0][0], 0.5 * (blue[0] + bg[0]), 0.01), "half covered: {:?}", plain[0]);
let color = Value::Color([blue[0] as f64, blue[1] as f64, blue[2] as f64, 1.0]);
let flat =
Gradient { colors: vec![(0.0, [blue[0], blue[1], blue[2], 1.0]), (1.0, [blue[0], blue[1], blue[2], 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };
let styled: [(&str, Box<dyn Fn(&mut Project, &Comp, &mut Layer)>); 3] = [
(
"Color Overlay",
Box::new(|p, c, l| {
add(p, c, l, "colorOverlay");
set(l, "colorOverlay/color", color.clone());
}),
),
(
"Gradient Overlay",
Box::new(|p, c, l| {
add(p, c, l, "gradientOverlay");
set(l, "gradientOverlay/colors", Value::Gradient(flat.clone()));
}),
),
(
"Stroke (inside)",
Box::new(|p, c, l| {
add(p, c, l, "stroke");
set(l, "stroke/position", Value::Enum(1));
set(l, "stroke/size", Value::Scalar(25.0));
set(l, "stroke/color", color.clone());
}),
),
];
for (name, f) in &styled {
let px = edge([1.0; 3], f.as_ref());
for (a, b) in px.iter().zip(&plain) {
for ch in 0..4 {
assert!(near(a[ch], b[ch], 0.01), "{name}: {a:?} against the plain layer {b:?}");
}
}
}
}
fn bevel_scene(angle: f64) -> Image {
let (p, cid) = scene([0.5; 3], None, |p, c, l| {
add(p, c, l, "bevelEmboss");
+55 -15
View File
@@ -4,6 +4,8 @@
//! inter-character blending). Per-character 3D characters are handed to the 3D compositor as
//! separate planes (see [`per_char_planes`]).
use std::borrow::Cow;
use effectcraft_color::BlendMode;
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, Mat4, Vec3, vec2, vec3};
@@ -790,16 +792,17 @@ fn passes(geom: &TextGeom) -> Vec<Pass> {
v
}
/// Rasterize one pass of a glyph (path already in target pixels via `m`) into a premultiplied
/// patch over the target rect, blurred when the character has Blur.
fn raster_pass(g: &PlacedGlyph, stroke: bool, path: &BezPath, m: &Mat3, rect: [i64; 4], blur_px: [f64; 2]) -> Image {
/// Rasterize one pass of a glyph — or of a run of glyphs painted alike, as one outline — (paths
/// mapped into target pixels by `m`) into a premultiplied patch over the target rect, blurred
/// when the character has Blur.
fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2]) -> Image {
let (w, h) = ((rect[2] - rect[0]) as u32, (rect[3] - rect[1]) as u32);
let mm = Mat3::translate(vec2(-rect[0] as f64, -rect[1] as f64)) * *m;
let cov = if stroke {
let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
effectcraft_path::stroke_coverage(std::slice::from_ref(path), &st, &mm, w, h)
effectcraft_path::stroke_coverage(paths, &st, &mm, w, h)
} else {
effectcraft_path::fill_coverage(std::slice::from_ref(path), &mm, w, h, FillRule::NonZero)
effectcraft_path::fill_coverage(paths, &mm, w, h, FillRule::NonZero)
};
let col = if stroke { g.stroke } else { g.fill };
let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32;
@@ -838,20 +841,57 @@ fn blit(dst: &mut Image, src: &Image, at: [i64; 2], mode: BlendMode) {
}
}
/// Draw glyphs (paths mapped by `to_px` from layer space into `img`).
fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Option<usize>) {
let (w, h) = (img.width as i64, img.height as i64);
/// An opaque, unblurred fill pass: where such fills of one colour touch or overlap, compositing
/// them one by one would let their anti-aliased edges show the background through.
fn solid_fill(g: &PlacedGlyph, stroke: bool) -> bool {
!stroke && g.xf.blur == [0.0; 2] && g.fill[3] >= 1.0 && g.xf.opacity >= 100.0
}
/// The passes in drawing order, consecutive opaque fills of one colour gathered into a run that
/// is rasterised as one outline, as After Effects draws a line of text: glyphs that touch (the
/// crossbars of "ff") or overlap join without a seam (#415). Under an Inter-Character Blending
/// mode other than Normal every character is still composited onto the others on its own.
fn runs(geom: &TextGeom) -> Vec<(Vec<usize>, bool)> {
let mut out: Vec<(Vec<usize>, bool)> = vec![];
for (gi, stroke) in passes(geom) {
if only.is_some_and(|o| o != gi) {
continue;
}
let g = &geom.glyphs[gi];
if g.xf.opacity <= 0.0 {
continue;
}
let m = *to_px * if only.is_some() { Mat3::IDENTITY } else { g.m2() };
let path = &g.local;
let Some(b) = effectcraft_path::bounds(std::slice::from_ref(path)) else { continue };
if geom.blend == BlendMode::Normal
&& solid_fill(g, stroke)
&& let Some((run, false)) = out.last_mut()
&& run.last().is_some_and(|&p| solid_fill(&geom.glyphs[p], false) && geom.glyphs[p].fill == g.fill)
{
run.push(gi);
continue;
}
out.push((vec![gi], stroke));
}
out
}
/// Draw glyphs (paths mapped by `to_px` from layer space into `img`).
fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Option<usize>) {
let (w, h) = (img.width as i64, img.height as i64);
let runs = match only {
Some(o) => passes(geom).into_iter().filter(|(gi, _)| *gi == o && geom.glyphs[*gi].xf.opacity > 0.0).map(|(gi, st)| (vec![gi], st)).collect(),
None => runs(geom),
};
for (run, stroke) in runs {
let Some(g) = run.first().map(|&gi| &geom.glyphs[gi]) else { continue };
// One glyph is drawn from its own outline; a run from its glyphs' outlines in layer
// space, all wound the same way so overlaps add up instead of cancelling (NonZero).
let local = Cow::Borrowed(std::slice::from_ref(&g.local));
let (paths, m): (Cow<[BezPath]>, Mat3) = match (only, run.as_slice()) {
(Some(_), _) => (local, *to_px),
(None, [_]) => (local, *to_px * g.m2()),
(None, _) => {
let wound = |p: BezPath| if kurbo::Shape::area(&p) < 0.0 { p.reverse_subpaths() } else { p };
(Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px)
}
};
let Some(b) = effectcraft_path::bounds(&paths) else { continue };
let blur = [g.xf.blur[0] * s, g.xf.blur[1] * s];
let pad = g.stroke_width * m.mean_scale() + 2.0 + blur[0].max(blur[1]) * 1.5;
let r = m.map_rect(&effectcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1));
@@ -864,7 +904,7 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt
if rect[2] <= rect[0] || rect[3] <= rect[1] {
continue;
}
let patch = raster_pass(g, stroke, path, &m, rect, blur);
let patch = raster_pass(g, stroke, &paths, &m, rect, blur);
blit(img, &patch, [rect[0], rect[1]], if only.is_some() { BlendMode::Normal } else { geom.blend });
}
}
+41
View File
@@ -485,3 +485,44 @@ fn caret_follows_animators_and_path_text() {
let glyph_c = glyph_paths(&c, ly).iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().y).collect::<Vec<_>>();
assert!(qt.y > glyph_c[1] && qt.y < glyph_c[2], "between the 2nd and 3rd characters: {} in {glyph_c:?}", qt.y);
}
/// Glyphs that touch or overlap are drawn as one outline, as After Effects draws a line of text:
/// no light seam where their anti-aliased edges meet (the crossbars of "ff", #415).
#[test]
fn touching_glyphs_join_without_a_seam() {
use kurbo::Shape;
let glyph = |x0: f64, x1: f64, fill: [f32; 4], reversed: bool| {
let path = kurbo::Rect::new(x0, 4.0, x1, 16.0).to_path(0.1);
PlacedGlyph {
local: if reversed { path.reverse_subpaths() } else { path },
m: Mat4::IDENTITY,
xf: CharXf::default(),
fill,
stroke: [0.0; 4],
stroke_width: 0.0,
apply_fill: true,
char_index: 0,
rest: [0.0; 2],
}
};
let draw = |glyphs: Vec<PlacedGlyph>, blend: BlendMode| {
let geom = TextGeom { doc: TextDoc::default(), glyphs, fill_stroke: 0, blend };
let mut img = crate::Image::new(24, 20);
draw_glyphs(&geom, &mut img, &Mat3::IDENTITY, 1.0, None);
img
};
let (white, red) = ([1.0; 4], [1.0, 0.0, 0.0, 1.0]);
// The shared edge runs through the middle of pixel column 10.
let seam = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, white, false)], BlendMode::Normal).get(10, 10);
assert!(seam[3] > 0.999 && seam[0] > 0.999, "touching glyphs leave no seam: {seam:?}");
// Outlines wound the other way (another font, a mirrored character) still add up.
let seam = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, white, true)], BlendMode::Normal).get(10, 10);
assert!(seam[3] > 0.999, "opposite windings join too: {seam:?}");
let overlap = draw(vec![glyph(4.0, 12.0, white, false), glyph(8.0, 17.0, white, true)], BlendMode::Normal).get(10, 10);
assert!(overlap[3] > 0.999, "and their overlap stays filled: {overlap:?}");
// Characters of other colours, or blended with each other, are still drawn one by one.
let two = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, red, false)], BlendMode::Normal).get(10, 10);
assert!((two[3] - 0.75).abs() < 0.02, "{two:?}");
let multiply = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, white, false)], BlendMode::Multiply).get(10, 10);
assert!(multiply[3] < 0.9, "{multiply:?}");
}
+28 -8
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;
}
+5 -4
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
+45 -14
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));
+60 -5
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");
}
}
+3 -3
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)
}
+5 -10
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,
+11 -4
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
@@ -207,8 +212,9 @@ switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers: `Switches:
**Layer styles** (Layer ▸ Layer Styles; `crates/render/src/styles.rs`) render in layer space and
may grow the layer's bounds. Drop Shadow and Outer Glow become separate passes composited below the
layer with their own blend modes; the interior styles, Stroke and Bevel and Emboss are baked into
the layer body. Layer opacity fades the whole stack; Knockout and the R/G/B channel switches apply
at composite time. Styled pixels are cached separately from the layer content, so editing a style
the layer body. Interior styles are clipped to the layer: they blend onto its colour inside its
shape and keep its alpha, so an anti-aliased edge mixes the style and the background only. Layer
opacity fades the whole stack; Knockout and the R/G/B channel switches apply at composite time. Styled pixels are cached separately from the layer content, so editing a style
reuses the cached source/masks/effects. Global Light is one setting per comp, mirrored into every
layer's Blending Options and kept in step after each edit.
@@ -354,8 +360,9 @@ hierarchical block-matching optical flow and a bidirectional warp (Pixel Motion,
its nested layers straight into the parent with concatenated transforms (one resample), so
nested blend modes and adjustment layers act on the parent's layers; nested 3D layers use the
parent's camera and lights and, when the precomp layer is 3D, are depth-sorted with the parent's
3D run. Masks, effects or styles force a flattened render. On text and shape layers the switch is
Continuously Rasterize: the source is rasterised at its on-screen scale. Quality: Draft samples
3D run. Masks, effects or styles force a flattened render. Text layers are always rasterised at
their on-screen scale, as text is vector in After Effects; on shape layers and vector footage the
switch is Continuously Rasterize and does the same. Quality: Draft samples
nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down,
dragging the source bar in the timeline) moves the source under fixed in/out points.
+19
View File
@@ -401,6 +401,25 @@ Browser in the web app (landed in M13.10: browser storage and File System Access
wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script
`Socket` validates ports, and ScriptUI resource strings accept trailing array commas.
## Update: text and viewer fidelity (G1)
Fixes from measured reports:
- Glyphs that touch or overlap ("ff") are drawn as one outline, so no light seam shows where
their anti-aliased edges meet (#415). Characters of other colours, with Blur, or under an
Inter-Character Blending mode are still composited one by one.
- Interior layer styles (Gradient / Color Overlay, Satin, Inner Glow, Inner Shadow, the inside of
Stroke) blend onto the layer's colour inside its alpha, so an anti-aliased edge no longer shows
a rim of the layer's own fill (#416). Not yet: Inner Bevel still shades an anti-aliased edge
over the layer's own colour.
- 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
EffectCraft's core keeps After Effects parity; what After Effects users get from third parties is