Skip to content
Fundamental · SamplingBeginner

Texture sampling

Read an image in a shader with texture(), choose how it wraps at the edges, and scroll, tile or flip through it.

How it works

A texture is an image the GPU can read from a shader: a grid of texels, each holding up to four values. It can be a photo or a sprite, but also data: a noise pattern, a mask, a gradient strip, the depth of the last frame. Reading one is called sampling, and it is the main way a shader gets information that isn’t math.

texture()

In GLSL ES 3.00 a texture arrives as a sampler2D uniform, and texture(sampler, coord) reads it. The coordinate is normalized: 0 to 1 across the whole image on both axes, whatever its size in pixels. That is the same range as a UV, so the simplest textured surface is one line:

// at file scope:
uniform sampler2D uImage; // bound to a texture unit by the host code
// inside main():
vec4 color = texture(uImage, uv); // rgba; an 8-bit image gives 0..1 per channel

The result is always a vec4. A single-channel texture returns its value in r, with g and b at 0 and a at 1, so a mask stored in the red channel is texture(uMask, uv).r. The cards on this site declare one sampler per texture, named t_<name>.

Texel centers sit at half-texel positions: in an image 256 texels wide, the first column’s center is at 0.5 / 256.0. textureSize(sampler, 0) returns the size in texels as an ivec2, which gives the step from one texel to the next:

vec2 texel = 1.0 / vec2(textureSize(uImage, 0)); // one texel, in UV units
vec4 right = texture(uImage, uv + vec2(texel.x, 0.0)); // the neighbor to the right

When you want one exact texel with no blending at all, texelFetch(sampler, ivec2(x, y), 0) takes integer texel coordinates instead, ignores wrapping and filtering, and returns undefined values outside the image. It is the right tool for lookup tables and data textures.

Which way is up

Texture coordinate (0, 0) is the first row of texels in memory. Images on the web store their top row first, so a WebGL texture uploaded as-is appears upside down under a UV whose origin is at the bottom left. Either flip on upload (gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)) or flip in the shader with vec2(uv.x, 1.0 - uv.y). Engines handle this for you; hand-written WebGL is where it bites.

Wrap modes

A coordinate outside 0–1 is legal. What it reads is decided by the texture’s wrap mode, which is set on the texture or sampler from the host code, not in GLSL:

  • Repeat tiles the image: 1.25 reads the same as 0.25.
  • Clamp to edge repeats the outermost texels forever: anything past 1 reads the last column.
  • Mirrored repeat tiles, flipping every other copy, which hides the seam of an image that doesn’t tile.

Tiling textures use repeat; sprites, screen copies and lookup strips use clamp to edge, or their borders pick up texels from the opposite side when filtered. WebGL2 allows repeat on any texture size, unlike WebGL 1, which required powers of two.

Tiling, offset and scroll

With repeat wrapping, moving and scaling the coordinate is all it takes:

vec2 tiled    = uv * 4.0;                     // four copies across, four up
vec2 offset   = tiled + vec2(0.3, 0.0);       // shifted by 0.3 of a copy
vec2 scrolled = tiled + vec2(0.1, 0.0) * time; // moves at 0.1 copies per second
vec4 c = texture(uImage, scrolled);

Multiplying the coordinate makes the image smaller on screen, because each copy covers less of the surface. Prefer the wrap mode over fract() for tiling: fract jumps from 1 back to 0 between copies, and the GPU sees that jump when it chooses a mipmap level, which shows up as a thin line at every seam (the filtering-and-mipmaps page explains why).

Flipbooks

A flipbook packs the frames of an animation into one texture, in a grid of columns and rows. Each frame shows one cell: divide the UV by the grid size and add the cell’s corner.

const vec2 grid = vec2(4.0, 4.0);                // 4 columns, 4 rows: 16 frames
float frame = mod(floor(time * 12.0), 16.0);      // 12 frames per second, looping
vec2 cell = vec2(mod(frame, grid.x), grid.y - 1.0 - floor(frame / grid.x));
vec4 c = texture(uImage, (cell + uv) / grid);

The grid.y - 1.0 - puts frame 0 in the top row, where sheets usually start, when the texture’s origin is at the bottom left. Leave a few texels of padding around each frame in the sheet: filtering and mipmaps read a little past a cell’s border, and without padding the neighboring frame bleeds in.

Coordinates you compute

The coordinate can be any expression: a scrolled UV, a UV bent by a sine wave, or a value read from another texture. A read whose coordinate depends on an earlier read is called a dependent read. It is fine in GLSL ES 3.00, though each one waits for the previous fetch, so long chains cost time. One caution: inside an if or a loop whose condition varies from pixel to pixel, texture() can return wrong values at the edges of the branch, because the GPU picks the mipmap level by comparing neighboring pixels and some of them didn’t run the call. Sample before the branch, or use textureLod with an explicit level there.

Further reading