[Solved] Signed-distance-field fonts look crappy at small pt sizes

Hi all, I’ve successfully implemented signed-distance-fields as described by the Valve paper:
http://www.valvesoftware.com/publications/2007/SIGGRAPH2007_AlphaTestedMagnification.pdf

As advertised it has created excellent looking characters at very large pt sizes. However at small pt sizes (<12 or so) they look like crap. Normal 64x64 glyph simply downscaled on the left, SDF on the right.

My distance fields fonts are 64x64 pixels per character calculated from a 1024x1024 pixel original using a brute-force exhaustive distance function. Thus I figure that they encapsulate enough information to technically be able to render a good looking font event at small sizes.

(^ alpha SDF font is hard to see but provided to show that they are of high quality)

According to the creator of TextMesh Pro it is possible:

[quote]A big misconception about SDF Text Rendering is that large text looks great but small text looks bad. Well, that is not the case with TextMesh Pro’s Advanced Signed Distance Field shaders. TextMesh Pro’s SDF text looks awesome at large sizes and looks as good or better than bitmap text at small sizes.
[/quote]
http://forum.unity3d.com/threads/textmesh-pro-advanced-text-rendering-for-unity-beta-now-available-in-asset-store.227790/

Here’s the source for my fragment shader which does the basic implementation:


// GLSL 1.2 to correspond with OpenGL 2.0
#version 120

// predefined variables
uniform sampler2D tex0;

void main() {
    // retrieve distance from texture
    float dist = texture2D(tex0, gl_TexCoord[0].xy).a;

    // fwidth helps keep outlines a constant width irrespective of scaling
    float width = fwidth(dist);

    float alpha = smoothstep(0.5 - width, 0.5 + width, dist);

    // antialiased
    gl_FragColor = vec4(gl_Color.rgb, alpha);
}

Anyway have any of you worked in this area and have suggestions towards a custom fragment shader that may accomplish this?

libgdx I think has SDF. Forget that fwidth exists. It was a bad idea, try doing the correct computation.

I think this has a survey:
http://jcgt.org/published/0002/01/04/

Do you have mipmaps?

It seems that fwidth() returns a number that maps to one pixel or less. Trying it versus Stefan Gustavson’s:

float aa = 0.75 * length(vec2(dFdx(d), dFdy(d)));  // antialias

doesn’t appear to make a perceptible difference. Can you explain how calculating fwidth() properly will give other benefits?

Anyways an update on tests so far. I’ve applied two improvements. The first is a supersampling technique and the second is using a “thicker” original SDF texture.

Details about the second technique. My original calculation used to create the alpha-channel texture was the canonical:

val alpha: Double = 0.5 + 0.5 * (signedDistance / spread)

Where signedDistance = distance to an opposite bit and will be +ve if inside the glyph, -ve if outside; spread = 4

And in an attempt to make the original texture “thicker” I tried the following:

val alpha: Double = 0.6 + (signedDistance / spread)

Original SDF (texture simply scaled on left, SDF shader w/ supersampling applied on right):

Versus thicker:

As you can see this gives me a much more solid-looking distance field glyph that also gives me better results with the shader esp. at small pt sizes. However, knowing that any alpha of > 0.5 (which becomes “distance” within the shader) is inside the glyph I figure I could using a more abrupt transition in the shader instead of recreating the alpha images.

I’m tending towards using the simple thicker texture because that means I can use the same texture for both SDF or simple scaling (when shaders aren’t available).

Thought about it but putting in different code and different shaders depending on the font size is too unwieldy for my tastes. Dynamically switching could affect performance too as I would have to split up all my VBOs by pt size.

Using a single shader + source texture for all text is the most elegant.

So, are you using mipmaps or not? You should. You’d still have the same code, a single shader and a single texture.

If you mean by using the GL commands and the work it does behind the scenes I am:


// Using MipMaps instead of glTextImage2D() and GL_NEAREST
// gives us much better scaled down textures.
//glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST)
//glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 16*64, 16*64, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels)

gluBuild2DMipmaps(GL_TEXTURE_2D, 4, sheet.width, sheet.height, GL_RGBA, GL_UNSIGNED_BYTE, pixels)
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR_MIPMAP_LINEAR)

But I don’t actually have pre-rendered SDF textures for multiple point sizes.

I’m also using a supersampling technique suggested by “glacialthinker” that also gives improvement in the small pixel details. My current shader source:

// GLSL 1.2 to correspond with OpenGL 2.0
#version 120

// predefined variables
uniform sampler2D tex0;

float contour(in float d, in float w) {
    // smoothstep(lower edge0, upper edge1, x)
    return smoothstep(0.5 - w, 0.5 + w, d);
}

float samp(in vec2 uv, float w) {
    return contour(texture2D(tex0, uv).a, w);
}

void main(void) {

    // retrieve distance from texture
    vec2 uv = gl_TexCoord[0].xy;
    float dist = texture2D(tex0, uv).a;

    // fwidth helps keep outlines a constant width irrespective of scaling
    // GLSL's fwidth = abs(dFdx(uv)) + abs(dFdy(uv))
    float width = fwidth(dist);
    // Stefan Gustavson's fwidth
    //float width = 0.7 * length(vec2(dFdx(dist), dFdy(dist)));

// basic version
    //float alpha = smoothstep(0.5 - width, 0.5 + width, dist);

// supersampled version

    float alpha = contour( dist, width );
    //float alpha = aastep( 0.5, dist );

    // ------- (comment this block out to get your original behavior)
    // Supersample, 4 extra points
    float dscale = 0.354; // half of 1/sqrt2; you can play with this
    vec2 duv = dscale * (dFdx(uv) + dFdy(uv));
    vec4 box = vec4(uv-duv, uv+duv);

    float asum = samp( box.xy, width )
               + samp( box.zw, width )
               + samp( box.xw, width )
               + samp( box.zy, width );

    // weighted average, with 4 extra points having 0.5 weight each,
    // so 1 + 0.5*4 = 3 is the divisor
    alpha = (alpha + 0.5 * asum) / 3.0;

    // -------

    gl_FragColor = vec4(gl_Color.rgb, alpha);
}

http://www.opengl.org/wiki/Common_Mistakes#Legacy_Generation
http://www.opengl.org/wiki/Common_Mistakes#gluBuild2DMipmaps
(box filter = shit)

[icode]glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR_MIPMAP_LINEAR)[/icode] is illegal. [icode]GL_LINEAR[/icode]

One interesting note that I learned from the person (thanks glacialthinker@reddit!) who showed me supersampling. If using the supersampling approach in the shader you actually don’t want to use mipmapping as you’ll lose some of the detail that the supersampler would otherwise pick up on.

Supersampling shader without mipmaps (left is downscaling and right is SDF):

And going back to the original non-thick SDF font:

To conclude this thread SDF fonts can look good at small pt sizes if you use supersampling and no mipmapping.

Reminder of how crappy the SDF looked when this saga began: