Breaking Up Repetition in Tiled Materials
Large surfaces such as terrain, concrete, rock, sand, and walls often rely on repeating textures. This is efficient, but it can introduce visible patterns that reveal the texture tile.
Stochastic texturing reduces this repetition by sampling the same texture several times with controlled variation, then blending the results together.
The goal is not to make every sample completely random. The goal is to introduce enough variation that the eye can no longer easily identify the repeating source pattern.
The Problem
A standard tiled material samples a texture using repeated UV coordinates.
float2 uv = worldPosition.xz * textureScale;
float4 colour = Texture.Sample(Sampler, uv);
This works well at close range, but on large surfaces the same rocks, cracks, and colour patches repeat at regular intervals.
The texture is technically correct, but the material begins to look artificial.
The Solution
Instead of sampling the texture once, stochastic texturing samples it several times using different transformations.
Each sample can use a different:
- UV offset
- Rotation
- Scale
- Colour adjustment
- Blend weight
The shader then combines these samples using noise or spatially generated weights.
The final surface still uses the same source texture, but the repetition becomes much less noticeable.
Basic Shader Structure
A simple implementation starts with three texture samples.
float2 baseUV = worldPosition.xz * textureScale;
float2 uvA = baseUV + offsetA;
float2 uvB = RotateUV(baseUV, rotationB) + offsetB;
float2 uvC = RotateUV(baseUV, rotationC) + offsetC;
float4 sampleA = Texture.Sample(Sampler, uvA);
float4 sampleB = Texture.Sample(Sampler, uvB);
float4 sampleC = Texture.Sample(Sampler, uvC);
These samples are then blended together.
float3 weights = CalculateBlendWeights(worldPosition);
float4 finalColour =
sampleA * weights.x +
sampleB * weights.y +
sampleC * weights.z;
The weights should always add up to one.
weights /= weights.x + weights.y + weights.z;
This keeps the final material brightness stable.
Rotating UV Coordinates
Rotation is one of the simplest ways to reduce visible repetition.
float2 RotateUV(float2 uv, float angle)
{
float sineValue = sin(angle);
float cosineValue = cos(angle);
float2x2 rotationMatrix =
{
cosineValue, -sineValue,
sineValue, cosineValue
};
return mul(rotationMatrix, uv);
}
In practice, the UV coordinates should usually be rotated around the centre of the texture.
float2 RotateAroundCentre(float2 uv, float angle)
{
uv -= 0.5;
uv = RotateUV(uv, angle);
uv += 0.5;
return uv;
}
For textures such as rock, dirt, or concrete, rotations of 90, 180, and 270 degrees often work well.
These fixed rotations are also cheaper than fully arbitrary rotation because they can be implemented with coordinate swaps and sign changes.
Generating Variation
Variation can be generated using world position.
A hash function converts a position into a repeatable random value.
float Hash(float2 position)
{
return frac(
sin(dot(position, float2(12.9898, 78.233)))
* 43758.5453
);
}
The world can then be divided into cells.
float2 cellPosition = floor(worldPosition.xz * cellScale);
float randomValue = Hash(cellPosition);
Each cell receives a stable random value.
This value can control rotation, offset, scale, or colour.
float rotationIndex = floor(randomValue * 4.0);
float angle = rotationIndex * 1.570796;
The important detail is that the value is deterministic. A given world position always produces the same result, so the texture does not flicker between frames.
Blending Between Cells
Changing texture transforms per cell can create visible seams.
To avoid this, neighbouring samples are blended together.
A common method is to sample the texture from the surrounding cells and blend the results using smooth interpolation.
float2 scaledPosition = worldPosition.xz * cellScale;
float2 cell = floor(scaledPosition);
float2 localPosition = frac(scaledPosition);
float2 blend = localPosition * localPosition *
(3.0 - 2.0 * localPosition);
The shader can then evaluate neighbouring cells and blend between them.
float4 colour00 = SampleCell(cell + float2(0, 0));
float4 colour10 = SampleCell(cell + float2(1, 0));
float4 colour01 = SampleCell(cell + float2(0, 1));
float4 colour11 = SampleCell(cell + float2(1, 1));
float4 colourX0 = lerp(colour00, colour10, blend.x);
float4 colourX1 = lerp(colour01, colour11, blend.x);
float4 finalColour = lerp(colourX0, colourX1, blend.y);
This produces smooth variation across the surface without introducing obvious cell boundaries.
Using Noise Based Blending
Another approach is to use a noise texture to control the blend weights.
float noiseA = NoiseTexture.Sample(
NoiseSampler,
worldPosition.xz * noiseScale
).r;
float noiseB = NoiseTexture.Sample(
NoiseSampler,
worldPosition.xz * noiseScale + 0.33
).r;
float noiseC = NoiseTexture.Sample(
NoiseSampler,
worldPosition.xz * noiseScale + 0.67
).r;
The values are normalized before blending.
float3 weights = float3(noiseA, noiseB, noiseC);
weights = max(weights, 0.001);
weights /= dot(weights, 1.0);
Noise based blending is simple and visually effective, but the noise scale must be chosen carefully.
If the scale is too small, the surface looks patchy.
If the scale is too large, the original repetition may still be visible.
Colour Variation
Small colour changes can further break up repetition.
float brightness = lerp(0.9, 1.1, randomValue);
sample.rgb *= brightness;
This should be used conservatively.
Large changes can make the surface look stained or inconsistent.
For most materials, a variation range of approximately five to ten percent is enough.
Normal Maps
Normal maps require special handling.
Normal values cannot always be blended in the same way as colour values because they represent directions rather than standard colour data.
A basic implementation can unpack and normalize each normal before blending.
float3 normalA = UnpackNormal(
NormalTexture.Sample(Sampler, uvA)
);
float3 normalB = UnpackNormal(
NormalTexture.Sample(Sampler, uvB)
);
float3 normalC = UnpackNormal(
NormalTexture.Sample(Sampler, uvC)
);
float3 finalNormal = normalize(
normalA * weights.x +
normalB * weights.y +
normalC * weights.z
);
When rotating a normal map sample, the tangent space normal direction must also be rotated.
Failing to rotate the normal direction can produce incorrect lighting.
Roughness and Material Properties
The same transforms should normally be applied to all related texture maps.
This includes:
- Base colour
- Normal
- Roughness
- Metallic
- Ambient occlusion
- Height
If each map uses a different random transform, the material data will no longer align.
For example, a crack in the base colour may not match the crack in the normal map.
A shared stochastic transform should therefore be calculated once and reused across the entire material.
Performance Considerations
The main cost of stochastic texturing is the additional texture sampling.
A normal material may use one sample per texture.
A stochastic material using three variants may require three samples per texture.
For a material with base colour, normal, roughness, and height, this can become expensive very quickly.
Several optimizations can help.
Use Texture Arrays
Related texture data can be packed into texture arrays or combined maps.
For example, roughness, metallic, and ambient occlusion can be packed into separate colour channels of one texture.
Limit the Number of Samples
Two or three samples are often enough to hide repetition.
More samples increase cost but may provide only a small visual improvement.
Use Fixed Rotations
Rotations of 90 degree increments are cheaper than arbitrary rotation.
They also preserve texture footprint quality more reliably.
Reduce Distance Cost
Full stochastic blending may only be needed close to the camera.
At longer distances, the material can use fewer samples or transition to a macro texture.
Reuse Calculations
World position, cell coordinates, random values, and blend weights should be calculated once and reused.
Avoid recalculating the same values for every texture map.
Example Use Case
I used this technique on a large rock surface where the original material had an obvious repeating formation.
The source texture contained several distinctive bright stones and cracks. When tiled across the environment, these features formed a visible grid.
The stochastic version used three texture samples.
Each sample used:
- A different UV offset
- A 90 degree rotation
- A small colour adjustment
- Noise based blend weights
The result retained the detail of the original rock texture while removing the most obvious repetition.
The material still used the same source asset, so no additional unique texture set was required.
Visual Comparison
The standard material showed:
- Repeating cracks
- Regularly spaced bright stones
- Visible horizontal and vertical patterning
- A clear texture tile size
The stochastic material showed:
- Less predictable feature placement
- Reduced grid patterns
- Better variation across large surfaces
- More natural transitions between repeated regions
When to Use Stochastic Texturing
This technique works well for:
- Terrain
- Rock
- Concrete
- Sand
- Dirt
- Snow
- Bark
- Large walls
- Procedural environments
It is less suitable for textures with a clear direction or strict structure.
Examples include bricks, text, road markings, floorboards, and architectural panels.
These materials may require pattern-aware variation rather than random rotation.
Final Result
Stochastic texturing is a practical way to improve large tiled materials without creating additional texture assets.
It is especially useful in procedural environments, open worlds, and performance-sensitive projects where texture memory must remain controlled.
The key is to balance visual variation against shader cost.
A strong implementation should:
- Use stable world based variation
- Keep all material maps aligned
- Blend between transforms smoothly
- Avoid excessive texture samples
- Scale complexity based on camera distance
When implemented carefully, stochastic texturing can make a small set of textures feel much larger and more natural.