In the next few lessons we’ll create this raymarched clouds example. Along the way you’ll develop your TSL skills.

This series of lessons is a refactoring of this classic example on ShaderToy.

https://www.shadertoy.com/view/3dVXDc

We’re going to take this in small steps. In this step you’ll find out how you can use a Compute Shader to create a texture that is created and stored on the GPU. Here is the example we’ll create in this lesson. If you click and drag you can rotate your view of the quad. If you click on the JS button you’ll see the JavaScript used.

As before there is a tsl function. Notice this is an asynchronous function. As a async function we can use await in the function body. The tsl function is super simple. We create an instances of PlaneGeometry and a MeshBasicNodeMaterial set to transparent. The new feature is the way that a texture is created. Notice we await for the returned data from the function createNoiseTexture. Having got the texture we set the materials colorNode to the value returned from the TSL texture method. The default behaviour of the texture method is to return a vec4 value which is the sampled filtered pixel based on the returned value of the TSL function uv. This will be an interpolated value of the uv values at each vertex of a triangle based on the pixel position in the triangle currently being rendered. If you’re new to GLSL shaders let me recommend The Book of Shaders, or if you prefer a video based course then my GLSL Udemy course.

async function tsl() {
  const geometry = new THREE.PlaneGeometry();
  const material = new THREE.MeshBasicNodeMaterial({ transparent: true });
  mesh = new THREE.Mesh(geometry, material);

  const storageTexture = await createNoiseTexture();
  material.colorNode = texture(storageTexture);

  scene.add(mesh);
}

Now let’s look at the createNoiseTexture function. It is another asynchronous function. The function has a single parameter, size, this is the pixel width and height. It’s a square texture.

First we create an instance of a ThreeJS StorageTexture. This is only available in the WebGPU version of the library. The class takes two parameters:

  • width: pixel width.
  • height: pixel height.

Next we create a TSL function, passing a single parameter, in this case it will be the storageTexture just created. Since this function is a Compute Shader it will receive an instanceIndex. This will have a value that ranges from 0 to one less than width times height. Since the texture we’re creating is square, this is simply size times size. As a GPU function it will run in multiple copies simultaneously. But for each copy, instanceIndex will have a unique value. We cannot assume that the texel we set for instanceIndex 0 has been calculated when calculating the one at instanceIndex 1. The processing order can be entirely arbitary.

We use the instanceIndex to set an x, y value for the texture. x is instanceIndex mod size, or width if you have a non square texture. This value is the remainder after division by size/width. y is simply the integer value of instanceIndex divided by size/width. We set an unsigned integer two component vector, uvec2, to these calculated values.

Then we set a scene world position based on these posX and posY values, setting the z value to 0. We use a scale value, try updating this scale value to see the effect. TSL includes a noise library. mx_noise_float takes a vec3 position and returns the Perlin float value. If you’re new to noise functions then try following the link to find out about how useful parametric gradient noise functions can be. To actually set the data in the storageTexture data, we use the TSL function, textureStore, this takes 3 parameters:

  • texture: a StorageTexture instance.
  • uv: integer values for the x and y position to store the pixel.
  • texel: a vec4 value.

For our example each channel, red, green, blue and alpha receive the same value. Having created the function we need to call it, size times size times. We use the renderer method computeAsync. We pass the function we’ve created as the only parameter. The function is passed an object containing the StorageTexture and then the compute method with the number of copies of the function that should run.

async function createNoiseTexture(size = 512) {
  const storageTexture = new THREE.StorageTexture(size, size);

  // create function

  const computeTexture = Fn(({ texture }) => {
    const posX = instanceIndex.modInt(size);
    const posY = instanceIndex.div(size);
    const indexUV = uvec2(posX, posY);

    const scale = vec3(20);
    const pt = vec3(posX, posY, 0).div(size).mul(scale);

    const val = mx_noise_float(pt);

    textureStore(
      texture,
      indexUV,
      vec4(val, val, val, val)
    ).toWriteOnly();
  });

  await renderer.computeAsync(computeTexture( { texture: storageTexture }).compute( size * size ) );

  return storageTexture;
}

That will give a very simple noise texture. In the next lesson we create a more complex tileable texture with different versions of a noise texture in each channel.

If you find these articles useful, perhaps you can buy me a coffee by clicking the button below. It might encourage me to write more.

Signup to my mailing list.