Andrew Cassidy / Blog / Antialiasing For SDF Textures

SDF textures are commonly used for rendering text and simple graphics in both 2D and 3D applications. They allow for smooth and crisp graphics using small raster graphics as an input, and relatively simple shaders. If you’re unfamiliar with them, I recommend reading the Valve whitepaper. Antialiasing when rendering SDFs seems to be a recurring problem, and I set out to find a good method that worked for any input.

As a demo, I’ll be using the following SDF texture. I’m using white to represent negative distances (inside the shape) and black to represent areas outside the shape, but the inverse is also common.

In this article I’ll be using the term “SDF size” to refer to the distance between black and white pixels in the SDF gradient, and “SDF units” to refer to the arbitrary units the textures are expressed in. 2D SDFs can also be generated by a function inside the shader or sampled using multiple channels to get sharper corners. As long as you have some input with a known value where the edge should occur, you can use these methods.

No antialiasing

For reference, lets look at what the shader looks like with no antialiasing. If the distance is negative, make the alpha 1, otherwise, its 0. The simplest possible SDF/cutoff shader:

1
2
3
4
5
6
7
8
9
10
fixed4 frag (v2f i) : SV_Target {
    fixed4 color = _Color;

    // sdf distance from edge (scalar)
    float dist = _Cutoff - tex2D(_MainTex, (i.uv)).r;

    color.a = dist < 0 ? 1 : 0;

    return color;
}

This looks pretty bad:

Adding Smoothness

The Valve whitepaper suggests using a smoothstep function to soften the edges to achieve antialiasing. This works when the image is a fixed size and distance, but when zoomed in or out the image becomes blurry or remains aliased. This method is also relative to the SDF size, so different SDF sizes will result in different smoothnesses when rendered.

1
2
3
4
5
6
7
8
9
10
fixed4 frag (v2f i) : SV_Target {
    fixed4 color = _Color;

    // sdf distance from edge (scalar)
    float dist = _Cutoff - tex2D(_MainTex, (i.uv)).r;

    color.a = smoothstep(_Smoothness, -_Smoothness, dist);

    return color;
}

Antialiasing with a known SDF size

The solution then is to adjust the smoothness value with the size of the texture on screen. We can use the fwidth function and the texture size to determine the texels (texture pixels) per pixel on screen. Instead of using smoothstep, we directly calculate the pixel distance from the edge, as described in this blog post by Edaqa Mortoray. We also need to pass in the size of the SDF so we can convert SDF units to texels:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fixed4 frag (v2f i) : SV_Target {
    fixed4 color = _Color;

    // texel distance from edge (scalar)
    float dist = (_Cutoff - tex2D(_MainTex, (i.uv)).r) * _SDFsize;

    // uv distance per pixel density for texture on screen
    float2 duv = fwidth(i.uv); 

    // texel-per-pixel density for texture on screen (scalar)
    // nb: in unity, z and w of TexelSize are the texture dimensions
    float dtex = length(duv * _MainTex_TexelSize.zw); 

    // distance to edge in pixels (scalar)
    float pixelDist = dist * 2 / dtex; 

    color.a = saturate(0.5 - pixelDist); 

    return color;
}

(I’m actually not sure why the 2 on line 15 is there, but it seems to be necessary)

As long as our SDF is of a known size, this works great! The text looks good at all sizes. The problem comes when we have an unknown SDF size, such as when using user-provided textures, textures that are a mix of multiple scaled SDF textures, or when the SDF generation tool adjusts the sdf size dynamically to best utilize the available space.

To demonstrate, I’ll add some triangles to our test texture with multiple SDF sizes:

Antialiasing with an unknown SDF radius

We need to be able to determine the SDF size dynamically within the shader, so fwidth, or rather, ddx and ddy come into play again (or dFdx and dFdy in glsl, but I’m using Unity’s Shaderlab system). In fact, we dont even need to use the fwidth of the uv anymore, because the gradient of the sdf is already in pixels!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fixed4 frag (v2f i) : SV_Target {
    fixed4 color = _Color;

    // sdf distance from edge (scalar)
    float dist = (_Cutoff - tex2D(_MainTex, (i.uv)).r);

    // sdf distance per pixel (gradient vector)
    float2 ddist = float2(ddx(dist), ddy(dist));

    // distance to edge in pixels (scalar)
    float pixelDist = dist / length(ddist);

    color.a = saturate(0.5 - pixelDist); 

    return color;
}

The only differences in the result between this method and the last, besides the correct antialiasing of the triangles, is that smaller text ends up “tighter”. This seems to be due to mipmapping coming into play and affecting the SDF gradient calculation, though I think the result still looks fine and perfectly readable.