nothings / stb

stb single-file public domain libraries for C/C++
https://twitter.com/nothings
Other
25.99k stars 7.67k forks source link

SDF incorrect with intersecting glyph segments #1524

Open jamesthomasgriffin opened 10 months ago

jamesthomasgriffin commented 10 months ago

This bug occurs when a glyph is defined by two or more intersecting simple closed curves. Take for example the letter f from a Noto font, (eg "NotoSansJP-Regular.ttf"), then the glyph is defined by a vertical piece and a horizontal piece. The signed distance field in the interior of the glyph near the intersection is incorrect, see attached images. This leads to artefacts around the intersection, they are just visible in the image, however can be more pronounced for smaller text and would be very pronounced for effects using sdf's, eg rendering edges.

The code used to reproduce is something like this (font loading and globals taken from sdf_test.c)

    char ch = 'f';
    float sdf_size = 256.0;
    float scale = stbtt_ScaleForPixelHeight(&font, sdf_size);

    int xoff, yoff, w, h, advance;
    unsigned char* sdf_data = stbtt_GetCodepointSDF(&font, scale, ch, padding, onedge_value, pixel_dist_scale, &w, &h, &xoff, &yoff);

    stbi_write_png("character_sdf.png", w, h, 1, sdf_data, 0);

    unsigned char* d = malloc(w * h);
    int x, y;
    for (x = 0; x < w; x++) {
        for (y = 0; y < h; y++) {
            int ix = x + w * y;
            float sdf_dist = stb_linear_remap(sdf_data[ix], onedge_value, onedge_value + pixel_dist_scale, 0, 1);
            float alpha = stb_linear_remap(sdf_dist, -0.5, 0.5, 0, 1);
            if (alpha > 1) alpha = 1;
            if (alpha < 0) alpha = 0;
            d[ix] = (unsigned char)(255 * (1 - alpha));                
        }
    }

    stbi_write_png("character_render.png", w, h, 1, d, 0);

The sdf in the interior of the 'f' should be the minimal distance to the outline, but the algorithm is including the distance to lines that have crossed to the interior.

character_sdf character_render character_render_edges

jamesthomasgriffin commented 10 months ago

I believe that the only way to fix this properly is to recompute the outline of the character. However I propose a partial fix:

Edit my apologies, I was mistaken, disregard the below. When computing min_dist in stbtt_GetGlyphSDF, instead compute the minimal distance for a single connected component, min_dist_component say. Then only update min_dist when a component is finished, either at the end of the loop, or when a STBTT_vmove is encountered. When the winding number indicates that we have an interior point then set min_dist = max(min_dist, min_dist_component).

This would not compute the Euclidean distance, however I believe it would remove artefacts and be good enough for edge rendering.

nothings commented 10 months ago

All of this makes sense, but FYI, traditionally TrueType fonts have non-self-intersecting glyphs. I don't know if it's part of the spec, but it was a de facto part of the spec, at least originally, although maybe fonts that break this assumption have become more common.

The only exception is when they're compound glyphs constructed by combining glyphs (e.g. accents created by simply drawing both the original letter and the accent, each of which will be non-self-intersecting, but once positioned, might overlap), in which case you do have a set of separate connected components and computing the min of each is correct, whereas we might be combining them into one big list, I don't remember.

Just to give some context, it doesn't use truetype fonts, but the Postscript print language has two commands, "stroke" and "fill". Fill will fill a path, and "stroke" will outline it. And of course it was a useful effect to be able to "stroke" a character and get an outline. But if you define the character with self-intersections, like your NotoSansJP "f", obviously that produces output that is NOT what you want. So it was, AFAICT, that's one of the reasons why it was traditional to use non-intersecting paths in fonts.

jamesthomasgriffin commented 9 months ago

That does make sense. However according to this page from Apple (see section Intersecting Contours), intersections are allowed with a non-zero winding number to determine what gets included and what doesn't (so technically allowing for the symmetric difference of two components).

I suspect that this particular font has intersections because it's 'strokes' were generated by thickening a weighted path, while Apple cite adding a component to an O to form a Q as a reason for allowing intersections.

Would you mind leaving the issue open while I investigate some ideas for fixing it? I have an idea that Dijkstra's algorithm, or a variant of it, can be used on the resulting bitmap to approximate the correct result. This would only be applied when an intersection is detected (by using the winding number) so no impact on performance for non-intersecting contours.