Before we can create a 3D cloud. We’re going to need a 3D texture. ThreeJS has a Data3DTexture class and the WebGPU version has a VolumeNodeMaterial which is no doubt ideal for my purposes. However, Data3DTexture expects the texture to be created CPU side not GPU and there is virtual no documentation for using VolumeNodeMaterial other than the cloud example which is nice but the code is a bit opaque with something called a testNode, it all sounds a bit work in progress, so after a few blind alleys I decided to roll my own version. For the 3D texture I would use the common approach of creating a texture containing multiple versions of the texture in slices in a grid. To access a different z position is then simply a matter of finding the cell in the grid.
Take a look at the CodePen example below. z = 0 is the cell in the bottom left and z = 1 the cell at the top right. As z increases from 0 to 1 the cell moves horizontally until it is at the 16th cell at the right then moves up to the next row at the left. In row, col notation, [ 1, 0 ] follows [ 0, 15 ]. I’ve added a little code to this example to draw grid lines to emphasise the cells. Notice there are 16 cells across and 8 down. A total of 8 x 16 = 128 cells.
So how do we create this texture? As ever you can see all the code by clicking the JS button of the CodePen above. We’ll focus on the createNoiseTexture function. I’ve added two new parameters, cellsX and cellsY. These are the number of columns and rows of texture blocks to create. This means that the StorageTexture instance now has pixel dimensions size * cellsX, size * cellsY. When setting posX it is the remainder after instanceIndex is divided by size * cellsX, the total width of the texture and posY is instanceIndex divided width, size * cellsX. The total number of z slices is cellsX * cellsY. The row is posY/size and the column is posX/size. Making the index value of the slice row * cellsX + col. Now we have an index value for the slice and the total number of slices we can set a point position, pt, that will work with our tileable noise code only this time the z value will vary between 0 and 1 when calling perlinFbm and worleyFbm. pt.xy is [ (posX-(col * size)) /size, (posY-(row*size))] and pt.z is slice/slices. When using TSL be careful with unsigned integers, uint, integers, int when using division if you want a floating point result. slice/slices will always be 0 for example, because slice is a uint, so it is actually floor( slice/slices ). By converting slice to a float you get the correct result, a floating point value between 0 and 1.
To draw lines to better illustrate the cells I added a conditional, If pt.x or pt.y is less than 0.03, the beginning of a new column or row in other words, then set r, g, b to 0 and a to 1 to give a black pixel.
When computing the computeTexture function we now need to run it size * size * cellsX * cellsY times.
async function createNoiseTexture(size = 256, cellsX = 16, cellsY = 8) {
const storageTexture = new THREE.StorageTexture(size * cellsX, size * cellsY);
const computeTexture = Fn(({ storageTexture }) => {
const posX = instanceIndex.modInt(size * cellsX).toVar();
const posY = instanceIndex.div(size * cellsX).toVar();
const indexUV = uvec2(posX, posY);
const slices = cellsX * cellsY;
const row = uint(posY.div(size)).toVar();
const col = uint(posX.div(size)).toVar();
const slice = row.mul(cellsX).add(col).toVar();
const pt = vec3(posX.sub(col.mul(size)), posY.sub(row.mul(size)), 0).div(
size
);
pt.z = float(slice).div(slices);
const freq = float(4);
const pfbm = mix(1, perlinFbm(pt, 4, 7), 0.5).toVar();
pfbm.assign(abs(pfbm.mul(2).sub(1))); // billowy perlin noise
const g = worleyFbm(pt, freq).toVar();
const b = worleyFbm(pt, freq.mul(2)).toVar();
const a = worleyFbm(pt, freq.mul(4)).toVar();
const r = remap(pfbm, 0, 1, g, 1).toVar(); // perlin-worley
//Draw grid to emphasize the cells
//Don't do this for the texture used in steps 5 or 6!!
If ( ( pt.x.lessThan(0.03).or( pt.y.lessThan(0.03) ) ), () => {
r.assign(0);
g.assign(0);
b.assign(0);
a.assign(1);
} );
textureStore(storageTexture, indexUV, vec4(r, g, b, a)).toWriteOnly();
})({ storageTexture }).compute(size * size * cellsY * cellsX);
await renderer.computeAsync(computeTexture);
return storageTexture;
}
We’re definitely making progress. In the next instalment we’ll look at displaying each slice.
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.