Nephos
- https://www.youtube.com/watch?v=Qj_tK_mdRcA
- https://www.youtube.com/watch?v=8OrvIQUFptA
- Absorbe light using beer's law
- make constant consenty
- https://progmdong.github.io/2019-03-04/Volumetric_Rendering/
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 cloud
After setuping a default project with a simple cloud (cube) and those default shader we got this:
Setup 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.
// ...
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 . 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.
// ...
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.
// ...
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.
// ...
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:
Which, in our case, translates to:
is the unknown in our formula. To isolate it, we simply do the following for each axis:
In GLSL, we can compute this for all three axes simultaneously. The result for is a vector containing the distances:
t.x: distance to hit the right/left wallt.y: distance to hit the top/bottom wallt.z: distance to hit the front/back wall
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).
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:
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.
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 > tExitto avoid calculating steps outside our geometry. -
Early exit: If the accumulated
totalDensityreaches1.0, the volume has become completely opaque. We can stop marching early to save processing power.