As some may have guessed, my enthusiasm for Oolite is at the low end of the cycle at the moment. As usual, this means I’ve been playing. Today’s toy project is an implementation of distance mapping, a useful technique for decals. If you’ve played Half-Life 2 and derivatives, or Fallout 3, you may have seen it used for in-game text on signs and similar.
Distance mapping is a technique that allows low-resolution textures to represent sharp-edged shapes which can be magnified almost without limit. The tradeoff is that it only represents shapes, with no colour, shading or transparency (although you could of course have one shape per channel).
Here is an example shape and its corresponding distance map:
This looks a lot like any old blur, but it isn’t exactly. Each pixel of the distance map texture represents the distance to the nearest edge of the shape. Values below 0.5 indicate a point outside the shape, while values above 0.5 indicate a point inside the shape. You’ll also note that the distance map is 1/256th of the size of the original, which is not to be sneezed at. (My code for generating distance maps is
in the Oolite source tree, and a pre-built Mac version is available
here).
To use the distance map in a shader, you read the texture map in the usual way. If the value is greater than or equal to 0.5, the current fragment is inside the shape, otherwise it’s outside:
Code: Select all
float dmap = texture2D(tex, texCoords).r;
float mask = step(dmap, 0.5);
vec4 color = mix(kWhite, kBlack, mask);
So what does this win you? The clever part is that the bilinear filtering used to scale textures up interpolates the distance map much better than it does hard edges in a normal texture. Additionally, the trilinear mipmap filter used to scale textures down does a decent job of simplifying the shape. Here’s the result of the above snippet:
It’s not magic; if you look closely you can see that it’s a piecewise linear approximation of the original shape, and the tip of the tail is clipped off in a funny way. Still, not bad for a 64 × 64 px texture, apart from the unacceptable aliasing.
To fix the aliasing, we’re going to cheat and blur the edge. This is easily done using a distance field: instead of using the step() function to introduce a hard edge at the 0.5 threshold, we use two slightly different threshold values and the smoothstep() function, whose hermite interpolation happens to produce a good blur profile.
Code: Select all
float dmap = texture2D(tex, texCoords).r;
float mask = smoothstep(0.5 - aaFactor, 0.5 + aaFactor, dmap);
The next question is how to select aaFactor. Ideally, it would be a value such that the blur covers about one screen pixel, but that distance in texture space varies depending on camera distance and so forth. Fortunately, GLSL provides us with the mysterious fragment processing approximate derivative functions, dFdx(), dFdy() and fwidth(). Without going into the details, we want to use fwidth() on the texture coordinates, average the x and y coordinates of the result, and multiply by a fudge factor (which is 3 in my example). This gives us:
Code: Select all
vec2 fw = fwidth(texCoords);
float aaFactor = (fw.x + fw.y) * 1.5;
Unfortunately, not all implementations give useful results. The broken hardware I’ve encountered always produces 0, so we can recognise this case and substitute a fixed blur factor instead:
Code: Select all
aaFactor = (aaFactor == 0.0) ? 0.03 : aaFactor;
The result (with a working fwidth()) looks like this:
Apart from anti-aliasing, varying the threshold can be used to find boundaries at different distances from the shape – in other words, to create outlines, like this (inner threshold: 0.52, outer threshold: 0.49):
I have a variation in mind that would allow randomized damage/paint chipping to be applied without the outline following the contours of the chipping.
Here’s my test shader:
Code: Select all
uniform sampler2D tex;
const float kThreshold = 0.5;
const float kFallbackAAFactor = 0.03;
const vec4 kBlack = vec4(0.0, 0.0, 0.0, 1.0);
const vec4 kWhite = vec4(1.0);
const vec4 kRed = vec4(1.0, 0.0, 0.0, 1.0);
const vec4 kBlue = vec4(0.0, 0.0, 1.0, 1.0);
float DistanceMap(sampler2D texture, vec2 texCoords, float threshold)
{
float dmap = texture2D(tex, texCoords).r;
// Fake anti-aliasing with a hermite blur.
// The fwidth() term lets us scale this appropriately for the screen.
vec2 fw = fwidth(texCoords);
float aaFactor = (fw.x + fw.y) * 1.5;
// If fwidth() doesn't provide useful data, use a fixed blur instead.
// Setting kFallbackAAFactor to zero gives you aliased output in the fallback case.
aaFactor = (aaFactor == 0.0) ? kFallbackAAFactor : aaFactor;
return smoothstep(threshold - aaFactor, threshold + aaFactor, dmap);
}
void main()
{
#if 1
// NOTE: in real code, reuse the dmap value instead of sampling twice.
float inner = DistanceMap(tex, gl_TexCoord[0].xy, kThreshold + 0.01);
float outer = DistanceMap(tex, gl_TexCoord[0].xy, kThreshold - 0.02);
vec4 decalColor = mix(kBlack, kRed, inner);
decalColor = mix(kWhite, decalColor, outer);
#else
float mask = DistanceMap(tex, gl_TexCoord[0].xy, kThreshold);
vec4 decalColor = mix(kWhite, kBlack, mask);
#endif
gl_FragColor = decalColor;
}