Text: touching glyphs join without a seam (#415)

Cause: each glyph's fill was rasterised and composited on its own, so
where two glyphs share an edge (the crossbars of "ff") two half-covered
pixels composited to 0.75 coverage instead of 1: a thin light seam,
where After Effects draws the line as one outline.

Fix: consecutive fill passes of the same opaque colour, without Blur,
under Normal Inter-Character Blending are rasterised as one coverage
mask (the glyphs' outlines in layer space, wound the same way so
overlaps add up under NonZero). Characters of other colours, translucent
or blurred ones, strokes and the other blending modes are drawn one by
one as before.

Tests: text::tests::touching_glyphs_join_without_a_seam (two glyphs
sharing an edge mid-pixel: 0.75 before, 1.0 now; opposite windings and
overlaps stay filled; other colours and Multiply still composite one by
one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-09 23:17:32 +07:00
1 parent 2a460986ee
commit 88e2e6470a
3 files changed
+104 -15

No files matched your search

+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:?}");
}
+8
View File
@@ -401,6 +401,14 @@ 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.
## 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