Tileable Procedural Textures

Tiling techniques

This article will present some simple techniques that can be used to create tileable versions of classic noises (Value, Perlin, Worley), FBMs and other procedural textures.
It will also show a nested hashing technique that is fast and more reliable than float variants, which can be used for generating random cell values for any classic noise.

Classic noises

Making the classic noises (i.e. Value, Perlin, Worley) tileable is trivial, due to their grid structure you only need to use modulo with the domain scale on the cell's corners:

 1    
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// value noise
pos *= scale;
vec4 i = floor(pos).xyxy + vec2(0, 1).xxyy;
vec2 f = pos - vec2(i.xy);
i = mod(i, scale).xyxy + seed;
// cell corners: 00 10 01 11
vec4 hash = vec4(Hash(i.xy), Hash(i.zy), Hash(i.xw), Hash(i.zw));

// voronoi
for (int y=-1; y<=1; y++)
{
    for (int x=-1; x<=1; x++)
    {
       vec2 n = vec2(float(x), float(y));
       vec2 cPos = hash2D(mod(i + n, scale) + seed) * jitter;

Note that it's best to use vector operations to make use of the SIMD lanes.

However, due to floating point precision errors it won't work on all GPUs for certain values (usually the prime numbers: 7, 13), integer math can be used to solve that:

1    
2
3
4
5
6
pos *= scale;
ivec4 i = ivec2(pos).xyxy + ivec2(0, 1).xxyy;
vec2 f = pos - vec2(i.xy);
i = i % ivec2(scale).xyxy + int(seed);
// cell corners: 00 10 01 11
vec4 hash = vec4(Hash(i.xy), Hash(i.zy), Hash(i.xw), Hash(i.zw));

Improved Perlin improved version by Brian Sharpe6 that doesn't have grid artifacts.

FBMs

Making FBMs tileable is also quite simple, however it comes with some limitations: the frequency (of each octave) must be an integer and we cannot apply certain transformations (i.e. rotations except straight angles, skew, etc.) to the domain of the octaves.
We can rely on other tricks to add more chaos to the octaves, such as: applying a random direction to the shift, a random phase or seed and also a straight angle rotation (and 45° rotations if the noise function is tileable):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
    vec2 sinCos = vec2(sin(shift), cos(shift));
    mat2 rotate = mat2(sinCos.y, sinCos.x, sinCos.x, sinCos.y);

    float value = 0.0;
    for (int i = 0; i < octaves; i++) 
    {
        // can also use: seed + float(i), and skip the domain shift
        float n = noise(p / frequency, frequency, time, seed);
        value += amplitude * n;
        
        p = p * lacunarity + offset * float(1 + i);
        frequency *= lacunarity;
        amplitude = pow(amplitude * gain, octaveFactor);
        // apply a random phase and domain shift
        time += timeShift;
        offset *= rotate;
    }

In the above case we divide by the frequency as the noise function will multiply the position with it, also we need to use the frequency of the octave as the scale of the noise.

You can also create tileable textures using the same approach as long as the image is tileable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
    vec2 p = uv * frequency;
    for (uint i = 0u; i < octaves; i++)
    {
        vec3 color = texture(tex, p).rgb;
        vec2 grad = sobel(tex, p, spread);
        derivative += grad;
        value += amplitude * color / (1.0 + mix(0.0, dot(derivative, derivative), slopeness));

        amplitude = pow(amplitude * gain, octaveFactor);
        p = (p * lacunarity + offset) * rotate;
        frequency *= lacunarity;
        angle += axialShift;
        offset *= rotate;
    }

The Sobel function returns the gradient of the grayscale version of the image (or a normal map could be used), this should be precomputed in advance and with the spread you can control the radius of the slope. Note that the gradient can be used to compute the normals and also guide the look of the FBM2. However, one of the disadvantages is that for large textures (i.e. 4k) performance will degrade heavily due to the large amount of cache misses caused by the domain shifting and rotation.

Patterns

Most tileable patterns (like waves or 45° rotation) will simply require to find the period (i.e. Π or √2, some can only be tileable on one axis) and multiply the coordinates by it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    // diagonal checkerboard
    vec2 p = pos * numTiles * 2.0;
    const mat2 rotate45 = mat2(0.70710678119, 0.70710678119, -0.70710678119, 0.70710678119);
    p *= 1.0 / sqrtOfTwo;
    p.x += sqrtOfTwo * 0.5;
    p = p * rotate45;

    // sine wave
    const float pi = 3.141592;
    vec2 p;
    p.x = pos.x * pi * scale.x;
    p.y = pos.y * scale.y;

For grid tiling textures you need to take into account divergence issues (i.e. the UV gradient won't be continuous at cell boundaries), to fix that you simply have to compute the screen space derivatives (or compute them manually) before the domain repetition (i.e. fract or mod):

1
2
3
4
5
    uv *= scale;

    vec4 dxdy = vec4(dFdx(uv), dFdy(uv));
    uv = fract(uv);
    vec4 col = textureGrad(iChannel, uv, dxdy.xy, dxdy.zw);

Hash

For performance and also quality reasons a good hash is important for any noise function, Mark Jarzynski has made an excellent overview of the most common used hash functions and conversion techniques 7, considering the properties of 1D hash functions and that they can be nested together, this can be useful to make better use of the SIMD lanes and compute multiple hash values:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    // conversion of a 2D value to 1 random value using nesting
    uvec2 q = uvec2(x);
    uint h0 = ihash1D(ihash1D(q.x) + q.y);
    ...

    // compute the position of the cell's 4 corners, i.e. 00, 01, 10, 11
    vec4 cell = floor(pos).xyxy + vec2(0.0, 1.0).xxyy;
    cell  = mod(cell , scale.xyxy) + seed;

    // generate a random value for each of the 4 cell corners
    uvec4 i = uvec4(cell);
    uvec4 hash = ihash1D(ihash1D(i.xzxz) + i.yyww);
    return vec4(hash) * (1.0 / float(0xffffffffu));

This is similar to the permutation polynomial used in the 2x2 Worley noise implementation by Stefan Gustavson5, however, any 1D hash function can be used like PCG. In the above case I used a variation of Integer Hash - I by Hugo Elias, since it provides a good balance between performance and quality7, and also most modern GPUs have support for integers ops that can be executed in parallel with float ops (e.g. Turing architecture).

Next, we can use this to optimize noise functions by creating multi hash variants, such as Gradient noise (i.e. generates two random values for each corner):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  float gradientNoise(vec2 pos, vec2 scale, float seed) 
  {
      pos *= scale;
      vec4 i = floor(pos).xyxy + vec2(0.0, 1.0).xxyy;
      vec4 f = (pos.xyxy - i.xyxy) - vec2(0.0, 1.0).xxyy;
      i = mod(i, scale.xyxy) + seed;

      vec4 hashX, hashY;
      smultiHash2D(i, hashX, hashY);

      vec4 gradients = hashX * f.xzxz + hashY * f.yyww;
      vec2 u = noiseInterpolate(f.xy);
      vec2 g = mix(gradients.xz, gradients.yw, u.x);
      return 1.4142135623730950 * mix(g.x, g.y, u.y);

The same technique can also be used to optimize a 3x3 Worley noise (i.e. generates two random values for each 2D position):

1
2
3
4
5
6
    const vec3 offset = vec3(-1.0, 0.0, 1.0);
    vec4 cells = mod(i.xyxy + offset.xxzz, scale.xyxy) + seed;
    i = mod(i, scale) + seed;
    vec4 dx0, dy0, dx1, dy1;
    multiHash2D(vec4(cells.xy, vec2(i.x, cells.y)), vec4(cells.zyx, i.y), dx0, dy0);
    multiHash2D(vec4(cells.zwz, i.y), vec4(cells.xw, vec2(i.x, cells.w)), dx1, dy1);