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;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 texturetexture_3d<f32>— volume texturetexture_cube<f32>— cube maptexture_2d_array<f32>— texture arraytexture_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
@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;
}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 },
],
});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);
}Interactive: Textured Quad with UV Manipulation
@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_2d→vec2fUV coordinatestexture_3d→vec3fUVW coordinatestexture_cube→vec3fdirection vectortexture_2d_array→vec2fUV + 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
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);
}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);Interactive: textureSample vs textureLoad
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);Interactive: LOD Control
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);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
);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);
}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); // u32These 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);
}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);
}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 assamplerorsampler_comparison textureSamplecombines texture, sampler, and UVs to produce filtered resultstextureLoadbypasses filtering for direct texel access by integer coordinatestextureSampleLevelandtextureSampleBiasprovide explicit LOD controltextureSampleCompareimplements 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 fortextureLoad