mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
19 files changed
+559
-122
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+11
-4
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user