Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved anti-aliasing. #22

Closed
paulhoux opened this issue Jul 23, 2016 · 15 comments
Closed

Improved anti-aliasing. #22

paulhoux opened this issue Jul 23, 2016 · 15 comments

Comments

@paulhoux
Copy link

paulhoux commented Jul 23, 2016

Hi Chlumsky,

I noticed some aliasing artifacts when rendering fonts using msdfgen. By changing the shader a bit, I was able to improve the rendering quality:

float sigDist = median( sample.r, sample.g, sample.b );
float w = fwidth( sigDist );
float opacity = smoothstep( 0.5 - w, 0.5 + w, sigDist );

Before:
2016-07-23_130831

After:
2016-07-23_130756

Especially note the capitals "A" and "W".

Thanks for sharing your hard work on SDF text rendering with the open source community!

@Chlumsky
Copy link
Owner

That shader was just an example that can be used as a quick demonstration. If you're doing something serious you definitely shouldn't use the derivative of the signed distance but of the coordinate instead. The only problem is that then you need to keep track of the range of the distance field to correctly convert it to a 1 pixel thick smoothing. This is the same as with single channel distance fields, so you might be able to find more information about this elsewhere.

@paulhoux
Copy link
Author

Actually, I think you misunderstood, if I may be so bold. Taking the derivative of the signed distance is actually correct, as it tells you about the rate of change over the span of 1 pixel. In combination with the smoothstep function, this results in proper anti-aliasing. You can tell by the difference in quality in the second image. Anyway, I was merely suggesting to update the README.md to improve text rendering quality.

@Chlumsky
Copy link
Owner

I'm not saying it is completely incorrect, just not exact. I'm pretty sure it's what causes the weird dots in the corners in your screenshots, but as you wish. Also, unless you're drawing the text in a 3D perspective, I would avoid derivatives completely.

@paulhoux
Copy link
Author

I believe the dots in the corners are caused by a relatively low size for the glyphs on the texture. The implementation I use (this one) reserves 32x32 texels by default, which is usually not enough to encode all font details properly. The dots are also visible in the top screenshot, that uses the default shader from the README.md file. Thanks for your reply and thanks again for sharing your code.

@Chlumsky
Copy link
Owner

Still try it though, I bet you there won't be any dots if you get rid of fwidth (e.g. replace it with a constant).

@rougier
Copy link

rougier commented Jul 24, 2016

@paulhoux The L letter seems to be better in your version but there are still dots in the corner of the I just before. Also, the hole in the H (third line) seems to have been accentuated.

@Chlumsky Why is there a hole in the H on third line but not on fourth line ?

@paulhoux
Copy link
Author

paulhoux commented Jul 25, 2016

I stand corrected. I tried with an arbitrary constant (in my case 0.15) and the little dots disappeared. This means that taking the fwidth of the signed distance is wrong in a theoretical sense. Still, I do believe the minor changes I made to the shader code as presented in the README.md do result in better visual quality, even though they technically are incorrect.

From this article, I understand that the right thing to do would be to calculate the distance from the fragment to the glyph outline as measured in pixels. Then use float opacity = clamp( 0.5 - distanceInPixels, 0.0, 1.0 ); to calculate coverage. The problem is that we only know the distance in texels and we need to convert this value.

For what it's worth: the following shader code gave me higher quality results, even at small scales (zoomed out), but requires an arbitrary multiplication factor that I would like to get rid of:

vec3 sample = texture( uTex0, TexCoord ).rgb;
ivec2 sz = textureSize( uTex0, 0 );
float dx = dFdx( TexCoord.x ) * sz.x;
float dy = dFdy( TexCoord.y ) * sz.y;
float toPixels = 8.0 * inversesqrt( dx * dx + dy * dy );
float sigDist = median( sample.r, sample.g, sample.b ) - 0.5;
float opacity = clamp( sigDist * toPixels + 0.5, 0.0, 1.0 );

Also, when zooming out too far, texture sampling artifacts occur that render the text unreadable. I believe this could be solved by using more padding between glyphs or clamping texture coordinates.

On the left the shader using fwidth( sigDist ), on the right the shader mentioned in this post:
sdf_render_comparison
(Text rendered at 25%. Please disregard the layout of the text, it's a work in progress.)

@behdad
Copy link

behdad commented Aug 2, 2016

Indeed, fwidth() of signed-distance is wrong. In GLyphy I use:

  /* isotropic antialiasing */ 
  vec2 dpdx = dFdx (p); 
  vec2 dpdy = dFdy (p); 
  float m = length (vec2 (length (dpdx), length (dpdy))) * SQRT2_2; 

  vec4 color = vec4 (0,0,0,1); 

  float gsdist = glyphy_sdf (p, gi.nominal_size GLYPHY_DEMO_EXTRA_ARGS); 
  float sdist = gsdist / m * u_contrast; 

where p is the texel coordinate.

@Michaelangel007
Copy link

fwidth() of signed-distance is wrong.

I respectively disagree -- it adds a bit of sharpening. I put together this (shadertoy) demo that clearly shows this:

https://www.shadertoy.com/view/llK3Wm#

I couldn't get the isotropic antialiasing variation to work. :-/

@paulhoux
Copy link
Author

paulhoux commented Sep 28, 2016

I don't think your implementation is correct, @Michaelangel007 . The smoothstep version should not use the range [0.33, 0.66], because this is much too blurry - it should be way tighter. How much tighter is calculated by the fwidth() function, but as discussed in this issue it should be converted to pixel coordinates first, otherwise the results are clearly inferior. See the image I posted for a comparison.

FYI: the shader I ended up with differs from the one I posted. It is a bit more complicated, similar to:
http://www.essentialmath.com/blog/?p=151&cpage=1

@Michaelangel007
Copy link

I agree the default smoothstep() needs tweaking. You can hold down the mouse button and drag it left/right. At the far left it is equivalent to a = smoothstep( 0.5, 0.5, d ); which is sharp but then the edges aren't anti-alised.

Thanks for the link. I've added that to the references.

@Michaelangel007
Copy link

@paulhoux I've added your shader via SMOOTH_2.

Here is a picture of your shader: float w = fwidth( d ); a = smoothstep( 0.5 - w, 0.5 + w, d );
paul_fwidth_smoothstep

And here is the one using 1/fwidth() & clamp: float v = s / fwidth( s ); a = clamp( v + 0.5, 0.0, 1.0 );
michael_recip_fwidth_clamp

If you do a Photoshop Difference on the two images are almost identical. It is almost impossible to visually see any differences between the two.

@paulhoux
Copy link
Author

Yes, but remember that this is still incorrect and actually has nothing to do with the discussion in this issue. Taking the fwidth from the texture sample is only part of the solution, as it does not take into account the scale of your glyph in pixels. You're not calculating the distance to the glyph's edge in pixels, but in texels, leading to poor quality rendering when you downscale the glyph. See the link in my previous reply for a proper solution.

@Chlumsky : I'd also recommend to close this issue, as it is no longer contributing anything to msdfgen.

@Michaelangel007
Copy link

Michaelangel007 commented Sep 28, 2016

I don't think anyone is saying the fwidth( texel ) is mathematically correct. What I am saying that it is certainly visually "good enough" -- the results speak for themselves compared to the proper partial derivatives of the texture coordinates.

In my day job I deal with OpenGL ES on smart TV's and shader complexity is a real ongoing issue -- any wins in optimizations here are VERY noticeable.

In practice there most certainly is a difference between:

  • mathematically correct but slow, and
  • close enough but very fast.

Truth be told, most of graphics is "one big hack", or I should say "approximations" anyways.

leading to poor quality rendering when you downscale the glyph.

In practice I haven't found that to be an issue at all. What size is your original SDF font and how small are you down sampling that you found this to be problematic?

I used to use 2 shaders:

  • one for downscaling, and
  • one for upscaling.

Using the shader I presented above lets me unify them and avoid unnecessary shader loading without general loss of quality for both upscaling and downscaling. Not everyone is running SDF fonts on a discrete GPU that have spare horsepower to burn. :-/

Granted I'm using a SDF font of 42 px so that I can have both downsampling and upsampling looking beautiful -- maybe this is overkill and I could get away with 32 px? I do know that upsampling to 224 px looks great, and downsampling to 12px looks great (which is sufficient for our needs.)

Are there any heuristics for the ratio of the SDF glyphs to actual rendered font height that people are aware of?

@paulhoux
Copy link
Author

paulhoux commented Sep 30, 2016

For most fonts I use 64x64 or sometimes 72x72 texels per glyph. This captures the details nicely, unless the font has very thin lines (like e.g. Parchment). When creating the texture, I use bin-packing to tightly pack the glyphs, so in practice they almost never need the full 72x72 texels.

I then render text by creating a mesh for the specified font size. This means that for a font size of 12pt, the mesh is actually smaller than 72x72 pixels per glyph and therefor scaling will occur. I want to be able to render 6pt text as well as 480pt text using the same font texture. Here's what it looks like when using a proper shader:

2016-09-30_205834

This is Calibri Light, stored on the texture as 72x72 texels (max) per glyph, then rendered at 15pts. I am not using multi-sampling, all anti-aliasing is done in the shader. Here's what it looks like when using float w = fwidth( sigDist ); float opacity = smoothstep( 0.5 - w, 0.5 + w, sigDist ) with float sigDist = median( sample.r, sample.g, sample.b ); as explained in the MSDFGEN docs:

2016-09-30_210113

When zooming in on the text, differences are less pronounced, but you can still see artifacts when using the simple shader:

2016-09-30_211009

These are gone when using the proper shader:

2016-09-30_211114

While frame rates are never a great way to compare performance, it's worth pointing out that both shaders run at 318 fps (NVIDIA GTX980M). I have also tested on iOS devices and performance is great there, too. So I'd really recommend using a slightly more complicated shader, because of the much higher quality.

Edit: just profiled using OpenGL queries and both shaders have very similar performance at roughly 1.7ms for the top-most image and 0.85ms for the zoomed-in image. It's worth noting that the mesh is recreated every frame and this is reflected in the timings as well. An optimized implementation might shave off a significant number of microseconds, I am sure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants