So far we’ve created a 128 tileable textures, laid out on as single textures, cells, in a 16 columns by 8 rows grid. We’ve seen how to grab a single cell using uv remapping. In this, the final instalment of the raymarch clouds series, we look at a technique to display the slices in 3d.

Raymarching involves casting a ray from the camera to the current rendered pixel world position. See the green dot. At this point we get the texel from the 3d texture. But rather than using this to render the pixel. Instead we save this texel as a density and then continue along the ray getting the texel at the next position along the ray, the blue dots. At each sample point we accumulate the density. Once we get to the other side of the box we divide the density by the number of samples and use this accumulated value to render the pixel. This technique is repeated for every visible pixel of the mesh.

Here is the final result.

Let’s take a look at the Raymarching code. First we set the rayDirection which is simply the vector, positionWorld minus cameraPosition, normalized (line 2). The first samplePosition is positionWorld (line 3). The increment for each step is rayDirection multiplied by the uniform stepSize (line 4). The total number of slices is cellsX times cellsY.

Now we initialise some values we use in the loop (lines 7-11). The loop first uses a function from TSL-common.js, pointInAABB. This function returns true or false if the first parameter, is inside an Axis-Aligned Bounding Box defined by parameters two and three. Parameter two is the minimum value for the x, y and z axes and parameter three the maximum values.

If the samplePosition is inside this box the we set the sample point using the function samplePositionToUV (lines 16-18). More about this function shortly. Then we assign the floats s1 and s2 using the function getDensityTSL, we’ll study this function below. The aim is to sample the texture and return the density at the point defined by pt. pt.z will point at a position between two slices. slice is pt.z * slices a float. If next is false then the slice used will be floor( slice ), if next is true then ceil( slice ). We add the blend of s1 and s2 to density. The blend based on the fractional part of slice (line 24). We increment count (line 25).

Outside the loop we divide the accumulated density value by count giving an average of all the densities sampled during the loop.

const raymarchTSL = /*@__PURE__*/ Fn(({ storageTexture }) => {
    const rayDirection = positionWorld.sub(cameraPosition).normalize().toVar();
    const samplePosition = positionWorld.toVar();
    const stepVec = rayDirection.mul(stepSize);
    const slices = cellsX * cellsY;

    const density = float().toVar();
    const count = uint(0).toVar();
    const pt = vec3().toVar();
    const s1 = float().toVar();
    const s2 = float().toVar();

    Loop(
      { start: uint(0), end: stepCount, type: "uint", condition: "<" },
      ({ i }) => {
        If(pointInAABB(samplePosition, bbmin, bbmax), () => {
          pt.assign(
            samplePositionToUV({ pos: samplePosition, bbmin, bbmax })
          );

          s1.assign(getDensityTSL({ storageTexture, pt, next: false }));
          s2.assign(getDensityTSL({ storageTexture, pt, next: true }));

          density.addAssign(mix(s1, s2, fract(pt.z.mul(slices))));
          count.addAssign(1);
        });

        samplePosition.addAssign(stepVec);
      }
    );
    
    density.divAssign(count);

    return vec4(1, 1, 1, saturate(density.mul(intensity)));
  });

When sampling the density we need to convert a world position to a value that is 0,0,0 at bounding box minimum and 1,1,1 at bounding box maximum. The function samplePositionToUV does just this. It also adds a little cloud motion using the time value multiplied by 0.02. We take the fractional value so we stay in the range 0 – 1.

const samplePositionToUV = Fn(({ pos, bbmin, bbmax }) => {
    const uv = pos.sub(bbmin).div(bbmax.sub(bbmin)).toVar();
    uv.x.subAssign(time.mul(0.02));
    uv.y.subAssign(time.mul(0.02));
    uv.x = fract(uv.x);
    uv.y = fract(uv.y);
    return uv;
  });

getDensityTSL is the effectively the function we used to display a slice in the previous instalment. When calculated the slice to use we check the value of next. We use the ternary function, select, if the first parameter is true the second parameter is returned, if false the third parameter is returned. When taking the ceil value could result in a value of slice greater than slices. Lines 4-6, check for this and subtract slices from slice if this is the case. Then we calculate the uv to use and then sample the storageTexture. Having got the texel we blend the four channels to generate the density to return.

const getDensityTSL = Fn(({ storageTexture, pt, next }) => {
    const slices = cellsX * cellsY;
    const slice = select(next, ceil(pt.z.mul(slices)), floor(pt.z.mul(slices)));
    If(slice.greaterThanEqual(slices), () => {
      slice.subAssign(slices);
    });

    const col = slice.modInt(cellsX).toVar();
    const row = uint(slice.div(cellsX)).toVar();
    const origin = vec2(float(col).div(cellsX), float(row).div(cellsY));

    const uv1 = vec2(pt.xy).div(vec2(cellsX, cellsY)).toVar();
    uv1.addAssign(origin);

    const texel = texture(storageTexture, uv1, 0).toVar();
    const perlinWorley = texel.x.toVar();
    const worley = texel.yzw.toVar();
    
    // worley fbms with different frequencies
    const wfbm = worley.x
      .mul(0.625)
      .add(worley.y.mul(0.125))
      .add(worley.z.mul(0.25))
      .toVar();

    // cloud shape modeled after the GPU Pro 7 chapter
    const cloud = remap(perlinWorley, wfbm.sub(wfbmctrl), 1, 0, 1).toVar();
    cloud.assign(saturate(remap(cloud, pwctrl, 1, 0, 1))); // fake cloud coverage

    return cloud;
  });

There you have it. That completes the series. I may come back with more later this year. I hope you’ve found them useful.

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.