Texture sampling tips
27 March 2022
Shader texture sampling is a really interesting area of graphics technology, and one of the areas where there are some nicely hidden gotchas in terms of performance. This blog explores some of the issues …
Taxonomy of texture sampling
Conceptually the basic operation of texture filtering is very simple.
The shader provides a texture coordinate. The texture call returns the data at that coordinate. Optionally, linear filtering is applied to blend between the nearest 4 texels if the sample coordinate falls in the space between the ideal point samples that texels represent.
Reality is messier, and this occurs because of mipmapping.
Mipmap chains store downscaled versions of an image, providing efficient pre-filtering of data so the GPU can apply “the right size” of texel to a pixel at runtime. This avoids renders getting under-sampling shimmer artifacts when applying a too-large texture to a distant object. The problem is, how does the GPU determine “the right size”?
The hardware needs to pick a mipmap level where the area covered by a texel is approximately the same size as a pixel. The problem is that this needs area, and a single fragment shader thread only has an area-less point sample. To work out the covered area the hardware needs more information. It gets this by sampling four fragment threads in parallel. These four threads are arranged as 2x2 fragment quad, and the sampling unit uses the dx/dy derivatives between the 4 coordinates to estimate the size of the sample area.
Helper threads
OK, so we need four threads in a 2x2 fragment quad to compute mipmaps. What happens if some of those threads have no sample coverage, either because they never hit a triangle or were culled by early-zs testing?
Even though these threads have no visible output, as there is no fragment sample active, we still need to run enough of the shader for all fragments in the quad to compute any value used as an input to a texture coordinate. The API specs call these threads “helper threads”, and they must stay alive until the last mipmapped texture operation has completed.
Each thread slot in a quad can therefore spawn in one of three modes:
- A “real” thread which produces fragment output.
- A “helper” thread which produces texture coords to help “real” threads.
- A “idle” slot which is simply disabled because no helper is needed.
The use of fragment quads for mipmap derivatives this is one reason why small triangles are so expensive. As triangles shrink, a higher and higher percentage of spawned quads will span edges and have fragments with no coverage. For these locations you effectively get a new form of overdraw. Multiple quads are needed at the same location to complete the necessary coverage to completely color the screen, which comes with the linear slowdown caused by rendering multiple layers of quads.
The first “tip” is therefore unrelated to texturing. Keep your triangles as big as possible, and your fragment shading will go faster as you need to shade fewer quads to complete the render.
Filtering modes
The basic filtering mode in the hardware is a linear filter within a single
mipmap level (GL_LINEAR_MIP_NEAREST
). This is also known as a bilinear
filter, as you are filtering in two dimensions in the image. For this filter
the sample performs a weighted average based on distance to the nearest 4
texels. For Mali, this filter gives best performance.
The next filtering mode in the hardware is linear filter between two
mipmap level (GL_LINEAR_MIP_LINEAR
), also known as trilinear filtering. This
filter makes two bilinear filters, one from each mipmap level, and then blends
those two together. For Mali, this filter runs at half the performance of
bilinear filtering.
The final filter mode in the hardware is anisotropic filtering. This is a
high quality filter that effectively builds a patch-based integral, assembling
the texel coverage for fragment from up to MAX_ANISOTROPY
sub-samples, each
of which may be bilinear or trilinear filtered. This can be up to
MAX_ANISOTROPY
times slower and MAX_ANISOTROPY^2
higher texture bandwidth,
although the number of samples needed depends on the orientation of the
primitive projection relative to the view plane so this is the worst case.
Tips and tricks
So, how does this distill into performance advice.
Tip 1: Use textureLod when you can
The textureLod()
function uses explicit mipmap selection, which tells the
driver up-front that your shader doesn’t need to computed cross-quad
derivatives.
If you know you have a texture without mipmaps then use textureLod(0)
in
preference to a simple texture()
call. This will minimize the number and
duration of any helper threads, which improves energy efficiency.
The textureLod()
call is fastest when the selected lod level is uniform
across the warp.
Tip 2: Use texelFetch when you can
The texelFetch()
function uses integer coordinate lookup. If you only have a
single mipmap level and you want nearest filtering then this is a more
efficient lookup than a texture()
call. No helper thread is needed, no
sampler descriptor is needed, and no coordinate calculation is needed, all of
which improve energy efficiency.
Tip 3: Use textureGather for 1 channel lookups
If you are downsampling a single channel texture use textureGather()
to
return 4 samples in a single cycle, rather than 4 separate texture()
calls.
The textureGatherOffset()
function is only full-throughput when returning
samples within a single 2x2 texel block footprint. Other offset patterns are
likely to be much slower.
Tip 4: Beware of textureGrad performance
On current Mali textureGrad()
performance is slow, so it is best avoided.
If you are not doing anisotropic filtering then you can manually compute mipmap
level-of-detail, instead of using textureGrad()
. This is normally more
efficient.
dTdx = dPdx * tex_dim;
dTdy = dPdy * tex_dim;
lod = 0.5 * log2(max(dot(dTdx, dTdx), dot(dTdy, dTdy)));
Tip 5: Use fp16 sampler precision
Current Mali can return 64 bits of filtered data per fragment per clock. Full speed bilinear filtering of 3 or 4 component textures is only possible for 16-bit samplers. Also moving more data around the GPU is more energy intensive, even if it is not your performance bottleneck.
Use mediump
samplers as much as you can. In nearly all cases your input data
is probably only 8-bit unorm or 16-bit float anyway, so you really don’t need
a highp
filtered value.
Tip 6: Use 32-bpp ASTC decode modes
Current Mali can only sustain full throughput filtering for formats that are 32-bit per texel after decompression. This is the default for ETC1/2 textures, which decompress into RGBA8. However it can be a problem for ASTC textures because the default decompression precision is fp16, unless using the sRGB format type.
To hit peak throughput for ASTC textures you must use either the sRGB type, or use the ASTC decode mode extension to lower the decompressed precision for linear textures (to RGBA8 for LDR, or RGB9e5 for HDR). Doing this also improves texture cache capacity.
Tip 7: Anisotropic filter with care
Anisotropic filtering can be very expensive in terms of both filtering cycles
and memory bandwidth. For mobile you want to limit the worst case behavior, but
the good news is that the visual benefit rolls off with LOG2(MAX_ANISOTROPY)
so you get most of the benefit for low sub-sample counts.
For Mali it is definitely worth trying bilinear samples with MAX_ANISOTROPY
of 2. This is ofter faster and nicer looking than simple trilinear filtering,
as the hardware gets latitude to drop samples when they are not needed. If that
isn’t enough then try trilinear samples with MAX_ANISOTROPY
of 2.
The other tip is to remember that MAX_ANISOTROPY
doesn’t need to be a power
of two; try 3 to see if it is good enough before trying 4.