Rether / docs
engine/nephos/Nephos
hubVolumetric clouds in React Three Fiber, and the notes that follow them

Nephos

updated 2026.08.17created 2026.08.17~6 min

Final result

Let's try to draw a big cloud, like the one in Castle in the Sky, that hides the castle.

Castle in the sky cloudCastle in the sky cloud

After setuping a default project with a simple cloud (cube) and those default shader we got this:

Setup projectSetup project

To make our cube a cloud we gonna use the beer's law.

Explain the beer's law

  • Step 1 — Build a ray for each pixel: its origin (the camera in the cube's local space) and its normalized direction.
  • Step 2 — Intersect that ray with the cube to get the entry and exit distances, tEnter and tExit.
  • Step 2.5 — Apply Beer's law to the thickness tExit − tEnter.

Step 1

For the first step, we calculate the ray origin and its normalized direction for each pixel.

The origin is the same for every pixel in the frame: it is the camera position in the cube's local space. We calculate this local camera position in the vertex shader and pass it to the fragment shader via a varying.

// glsl
// ...

varying vec3 vCameraPositionLocal;

void main() {
    // ...

    vCameraPositionLocal = (inverse(modelMatrix) * vec4(cameraPosition, 1.0)).xyz;

    // ...
}

In the code above, we invert the modelMatrix to transform coordinates from world space back into local object space. We then multiply it by the cameraPosition to get the camera's coordinates in the object space of our cube.

The formula to obtain a directional vector pointing from A to B is BAB - A. We already have A (our local camera position). B is the position of the 3D surface exactly underneath the current pixel. To get B, we pass the geometry's coordinates from the vertex shader to the fragment shader using a varying.

// glsl
// ...

varying vec3 vPositionSurfaceLocal;

void main() {
    // ...

    // 'position' is a built-in attribute representing the current vertex
    vPositionSurfaceLocal = position;

    // ...
}

Next, in the fragment shader, we calculate the ray's direction by subtracting vCameraPositionLocal from vPositionSurfaceLocal and normalizing the result.

// glsl
// ...

varying vec3 vCameraPositionLocal;
varying vec3 vPositionSurfaceLocal;

void main() {
    vec3 direction = normalize(vPositionSurfaceLocal - vCameraPositionLocal);

    // ...
}

The following schema illustrates what we've done in our shaders.

To check if the direction is calculated correctly, we can output this variable as our pixel color. If the colors change smoothly as we rotate around the cube, it is working as expected.

// glsl
// ...

void main() {
    vec3 direction = // ...
    gl_FragColor = vec4(direction * 0.5 + 0.5, 1.0);
}

This gives us the following result.

Now that we have the direction of our rays, we can move on to Step 2: finding the tEnter and tExit distances.

Step 2

To find the tEnter and tExit distances, we use the slab method, an algorithm used to solve the ray-box intersection problem (AABB). The fundamental ray equation is the following:

Position=Origin+(Distance×Direction)Position = Origin + (Distance \times Direction)

Which, in our case, translates to:

Pointwall=Cameralocal+(t×Direction)Point_{wall} = Camera_{local} + (t \times Direction)

tt is the unknown in our formula. To isolate it, we simply do the following for each axis:

  1. Wallx=Camerax+(t×Directionx)Wall_x = Camera_x + (t \times Direction_x)
  2. WallxCamerax=t×DirectionxWall_x - Camera_x = t \times Direction_x
  3. t=(WallxCamerax)/Directionxt = (Wall_x - Camera_x) / Direction_x

In GLSL, we can compute this for all three axes simultaneously. The result for tt is a vector containing the distances:

  • t.x: distance to hit the right/left wall
  • t.y: distance to hit the top/bottom wall
  • t.z: distance to hit the front/back wall
// glsl
vec3 t1 = (boxGeometrySize - vCameraPositionLocal) / direction;
vec3 t2 = (-boxGeometrySize - vCameraPositionLocal) / direction;

Then, we need to determine which point is the entry (tEnter) and which is the exit (tExit).

// glsl
vec3 tNear = min(t1, t2);
vec3 tFar = max(t1, t2);

float tEnter = max(tNear.x, max(tNear.y, tNear.z));
tEnter = max(tEnter, 0.0);
float tExit = min(tFar.x, min(tFar.y, tFar.z));

Finally, we can apply Beer's Law to calculate the light transmission and use it for our output color:

// glsl
float T = exp(-0.2 * (tExit - tEnter));
gl_FragColor = vec4(cloudColor, 1.0 - T);

This gives us the foundation of a cloud, which currently looks like a transparent cube:

Now we can move to the step 3: implement the raymarching

Step 3

In the previous step, our ray jumped directly from tEnter to tExit. To render a volumetric shape like a cloud, we cannot just evaluate the start and end boundaries. We need to divide the ray segment into smaller, discrete steps to evaluate the space inside the volume. This technique is called raymarching.

Starting from tEnter, we move forward along the ray's direction until we reach tExit. At each step, we calculate our exact 3D position and sample the volume's density at that specific location. For this test, we use a simple mathematical sphere: the density is highest at the center of the local space and fades to zero at the specified radius.

The following schema illustrates the ray taking one step at a time. Each dot represents a single step, and its opacity reflects the density sampled at that position, the last two dots remain pale because they sit inside the cube but outside the density sphere.

// glsl
int MAX_STEPS = 100;
float stepSize = 0.05;
float tCurrent = tEnter;
float totalDensity = 0.0;
float radius = 5.0;

vec3 position;
float distance;

for(int i = 0; i < MAX_STEPS; ++i) {
    if(tCurrent > tExit) {
        break;
    }

    position = vCameraPositionLocal + (tCurrent * direction);
    distance = length(position);
    float localDensity = max(0.0, 1.0 - (distance / radius));

    totalDensity += localDensity * stepSize;
    tCurrent += stepSize;

    if(totalDensity >= 1.0) {
        totalDensity = 1.0;
        break;
    }
}

gl_FragColor = vec4(cloudColor, totalDensity);

To ensure this loop runs efficiently on the GPU, we include two essential optimizations:

  • Bounds checking: We break the loop immediately if tCurrent > tExit to avoid calculating steps outside our geometry.

  • Early exit: If the accumulated totalDensity reaches 1.0, the volume has become completely opaque. We can stop marching early to save processing power.

Keyboard

search this vaultK / /
step the sidebarjk
open selected
switch browse mode123
switch vaultgv
toggle themet
close / clearesc
this help?
esc to close