Textures in Shaders

Sampling textures in WGSL

With textures created and samplers configured, the final piece is accessing them from shaders. WGSL provides several functions for reading texture data, each suited to different use cases. The most common is textureSample, which combines a texture, sampler, and UV coordinates to produce filtered color values.

Declaring Texture and Sampler Bindings

In WGSL, textures and samplers are declared as module-scope variables with @group and @binding attributes that match your bind group layout.

@group(0) @binding(0) var myTexture: texture_2d<f32>;
@group(0) @binding(1) var mySampler: sampler;
wgsl

The texture type includes both its dimension and sample type. texture_2d<f32> is a 2D texture that returns floating-point values when sampled. Other common types include:

  • texture_2d<f32> — standard color texture
  • texture_3d<f32> — volume texture
  • texture_cube<f32> — cube map
  • texture_2d_array<f32> — texture array
  • texture_depth_2d — depth texture (no type parameter)
  • texture_multisampled_2d<f32> — multisampled texture

Samplers also have types. A standard filtering sampler is just sampler. For shadow mapping with comparison operations, use sampler_comparison.

Interactive: Texture and Sampler Bindings

Bind Group Layout
WGSL Shader
@group(0) @binding(0) 
var myTexture: texture_2d<f32>;
@group(0) @binding(1) 
var mySampler: sampler;
@group(0) @binding(2) 
var normalMap: texture_2d<f32>;

@fragment
fn main(@location(0) uv: vec2f) 
  -> @location(0) vec4f {
  let diffuse = textureSample(
    myTexture, mySampler, uv
  );
  let normal = textureSample(
    normalMap, mySampler, uv
  );
  // Combine...
  return diffuse;
}
JavaScript Bind Group
const bindGroup = device
  .createBindGroup({
  layout: pipelineLayout,
  entries: [
    {
      binding: 0,
      resource: texture.createView()
    },
    {
      binding: 1,
      resource: sampler
    },
    {
      binding: 2,
      resource: normalMap.createView()
    },
  ],
});

Textures and samplers are bound separately, allowing one sampler to be used with multiple textures or vice versa.

The bind group on the JavaScript side must provide resources at matching binding indices:

const bindGroup = device.createBindGroup({
  layout: pipeline.getBindGroupLayout(0),
  entries: [
    { binding: 0, resource: texture.createView() },
    { binding: 1, resource: sampler },
  ],
});
typescript

The textureSample Function

textureSample is the primary way to read from textures. It takes a texture, a sampler, and UV coordinates, returning a filtered color value.

@fragment
fn main(@location(0) uv: vec2f) -> @location(0) vec4f {
  return textureSample(myTexture, mySampler, uv);
}
wgsl

Interactive: Textured Quad with UV Manipulation

WGSL Equivalent
@fragment
fn main(@location(0) uv: vec2f) -> @location(0) vec4f {
  // Transform UVs
  var transformed = (uv - 0.5) * 1.0;
  // Rotate by 0°
  let c = cos(0.00);
  let s = sin(0.00);
  transformed = vec2f(
    transformed.x * c - transformed.y * s,
    transformed.x * s + transformed.y * c
  );
  transformed += vec2f(0.50, 0.50);
  
  return textureSample(myTexture, mySampler, transformed);
}

UV coordinates are normalized, ranging from (0, 0) at the texture origin to (1, 1) at the opposite corner. The sampler's filtering settings determine what happens between texels, and the address mode controls what happens outside the [0, 1] range.

For different texture types, the coordinate type changes:

  • texture_2dvec2f UV coordinates
  • texture_3dvec3f UVW coordinates
  • texture_cubevec3f direction vector
  • texture_2d_arrayvec2f UV + integer array index

The return type is always vec4f for color textures, even if the texture format has fewer channels. Missing channels are filled with defaults (0 for RGB, 1 for alpha).

Texture Coordinates

UV coordinates map from normalized space to texel positions. Understanding this mapping is essential for correct texture application.

Interactive: UV to Texel Mapping

Texture (8×8)
Each cell is one texel
Sampled Output
Hover to see UV mapping
U
V
Texel X
Texel Y
UV coordinates range from (0, 0) at the top-left to (1, 1) at the bottom-right. The GPU converts UV to texel coordinates by multiplying by texture dimensions: texel = floor(uv × textureSize).

The convention places (0, 0) at the top-left of the texture and (1, 1) at the bottom-right. This matches image coordinate conventions but differs from some mathematical coordinate systems where Y increases upward.

To transform UVs in the shader—for tiling, scrolling, or rotation—apply transformations before the sample call:

@fragment
fn main(@location(0) uv: vec2f) -> @location(0) vec4f {
  // Tile 3×3
  var transformed = fract(uv * 3.0);
  
  // Or scroll over time
  transformed = uv + vec2f(time * 0.1, 0.0);
  
  // Or rotate around center
  let centered = uv - 0.5;
  let angle = time;
  let rotated = vec2f(
    centered.x * cos(angle) - centered.y * sin(angle),
    centered.x * sin(angle) + centered.y * cos(angle)
  );
  transformed = rotated + 0.5;
  
  return textureSample(myTexture, mySampler, transformed);
}
wgsl

textureLoad: Direct Texel Access

While textureSample provides filtered results, textureLoad bypasses filtering entirely. It reads a single texel by integer coordinates.

// textureLoad uses integer coordinates and explicit mip level
let texel = textureLoad(myTexture, vec2u(x, y), mipLevel);
wgsl

Interactive: textureSample vs textureLoad

textureSample
Filtered, sampler-controlled
textureLoad
Direct texel access, integer coords

textureSample uses a sampler for filtering and automatic mip selection. Coordinates are normalized [0,1]. Best for typical texture mapping.

textureLoad reads exact texel values by integer coordinates. No filtering occurs. You must specify which mip level to read. Useful for post-processing, compute shaders, or when you need pixel-perfect reads.

The differences are significant:

textureSample:

  • Uses normalized [0, 1] coordinates
  • Applies sampler filtering
  • Automatically selects mip level based on derivatives
  • Respects address modes
  • Only works in fragment shaders (needs derivatives)

textureLoad:

  • Uses integer texel coordinates
  • No filtering—returns exact texel value
  • Must specify mip level explicitly
  • Out-of-bounds returns zero
  • Works in any shader stage

Use textureLoad when you need pixel-perfect accuracy: reading render target results, image processing passes, or compute shaders that process textures algorithmically.

textureSampleLevel: Explicit LOD

textureSample automatically computes the appropriate mipmap level based on how quickly UVs change across the screen. This requires screen-space derivatives, which only exist in fragment shaders.

For cases where you need to sample in other shader stages, or when you want to override the automatic LOD selection, use textureSampleLevel:

// Sample from mip level 2 specifically
let color = textureSampleLevel(myTexture, mySampler, uv, 2.0);
 
// Sample from the base level (sharpest)
let sharp = textureSampleLevel(myTexture, mySampler, uv, 0.0);
wgsl

Interactive: LOD Control

L0
L1
L2
L3
L4
L5
L6
WGSL Function
textureSample(tex, samp, uv)

GPU automatically selects LOD based on screen-space derivatives.

The level parameter is floating-point. Level 0 is the highest resolution. Level 1 is half that resolution. Fractional values blend between adjacent levels when the sampler uses linear mipmap filtering.

textureSampleBias is a variant that adds a bias to the automatic LOD calculation rather than replacing it:

// Make the texture blurrier (positive bias)
let blurry = textureSampleBias(myTexture, mySampler, uv, 2.0);
 
// Make the texture sharper (negative bias)
let sharp = textureSampleBias(myTexture, mySampler, uv, -1.0);
wgsl

textureSampleGrad: Custom Derivatives

For ultimate control, textureSampleGrad lets you specify the UV derivatives directly:

let color = textureSampleGrad(
  myTexture, 
  mySampler, 
  uv,
  dpdx,  // Rate of UV change in screen X
  dpdy   // Rate of UV change in screen Y
);
wgsl

The GPU uses these derivatives to compute the LOD. Larger derivatives mean more screen-space UV variation, which selects higher (blurrier) mip levels. This is useful for:

  • Vertex shaders or compute shaders where derivatives do not exist
  • Custom LOD calculations for special effects
  • Anisotropic filtering control

textureSampleCompare: Shadow Mapping

Depth textures used for shadow mapping require comparison sampling. Instead of returning the depth value, the sampler compares it against a reference:

@group(0) @binding(0) var shadowMap: texture_depth_2d;
@group(0) @binding(1) var shadowSampler: sampler_comparison;
 
@fragment
fn main(@location(0) shadowCoord: vec3f) -> @location(0) vec4f {
  // Returns 0.0 if in shadow, 1.0 if lit
  let shadow = textureSampleCompare(
    shadowMap,
    shadowSampler,
    shadowCoord.xy,
    shadowCoord.z  // Reference depth to compare against
  );
  
  return vec4f(shadow, shadow, shadow, 1.0);
}
wgsl

The comparison operation is configured in the sampler (e.g., "less" means the sample passes if the stored depth is less than the reference). With linear filtering, multiple samples are compared and averaged, implementing percentage-closer filtering for soft shadows.

Texture Dimensions and Queries

Sometimes you need to know a texture's size. WGSL provides textureDimensions:

// Get texture width and height
let dims = textureDimensions(myTexture);  // vec2u
 
// Get dimensions at a specific mip level
let mip2Dims = textureDimensions(myTexture, 2);  // vec2u
 
// Get number of mip levels
let numLevels = textureNumLevels(myTexture);  // u32
 
// Get number of array layers
let numLayers = textureNumLayers(myArrayTexture);  // u32
 
// Get number of samples (for multisampled textures)
let numSamples = textureNumSamples(myMsaaTexture);  // u32
wgsl

These queries are useful for:

  • Converting between normalized and texel coordinates
  • Iterating over all texels in a compute shader
  • Calculating proper scaling factors

Multisampled Textures

Multisampled textures store multiple samples per pixel for antialiasing. They cannot be sampled with textureSample—you must access individual samples with textureLoad:

@group(0) @binding(0) var msaaTex: texture_multisampled_2d<f32>;
 
@fragment
fn main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
  let coord = vec2u(pos.xy);
  
  // Access each sample individually
  var sum = vec4f(0.0);
  let numSamples = textureNumSamples(msaaTex);
  for (var i = 0u; i < numSamples; i++) {
    sum += textureLoad(msaaTex, coord, i);
  }
  
  return sum / f32(numSamples);
}
wgsl

In practice, hardware resolve operations handle this averaging automatically during render pass completion. Manual access is for cases where you want custom resolve logic or per-sample processing.

Storage Textures

Storage textures allow compute shaders to write directly to texture data:

@group(0) @binding(0) var outputImage: texture_storage_2d<rgba8unorm, write>;
 
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) id: vec3u) {
  let color = vec4f(f32(id.x) / 256.0, f32(id.y) / 256.0, 0.5, 1.0);
  textureStore(outputImage, id.xy, color);
}
wgsl

The format must be specified in the type declaration (rgba8unorm here), and the access mode is either read, write, or read_write. Storage textures bypass samplers entirely—you work directly with texel coordinates.

Performance Considerations

Texture sampling is highly optimized but not free. Consider these factors:

Filtering cost: Bilinear filtering samples 4 texels. Trilinear samples 8. Anisotropic filtering can sample many more. Each additional sample adds latency.

Memory access patterns: Sampling nearby UVs exploits texture cache locality. Random access patterns cause cache misses and stalls.

Dependent texture reads: When one texture lookup determines UVs for another, the second read cannot start until the first completes. This serializes latency.

Mip selection: Proper mip usage reduces bandwidth. Forcing low mip levels on distant surfaces wastes memory bandwidth reading texels that will be filtered away.

Format choice: Smaller formats (R8) read faster than larger ones (RGBA32F). Use the smallest format that provides sufficient precision.

Key Takeaways

  • Textures are declared as texture_*<T> types; samplers as sampler or sampler_comparison
  • textureSample combines texture, sampler, and UVs to produce filtered results
  • textureLoad bypasses filtering for direct texel access by integer coordinates
  • textureSampleLevel and textureSampleBias provide explicit LOD control
  • textureSampleCompare implements hardware shadow mapping with PCF
  • Texture dimension queries enable dynamic sizing and compute iteration
  • Storage textures allow direct write access from compute shaders
  • Texture coordinates use normalized [0, 1] range for textureSample, integer texels for textureLoad