Fruxis Break Down
<< Home

Fruxis Break Down

Fruxis by Inigo Quilez is a really, really cool scene. And as I am learning Raymarching (again), this time I will try to break this whole thing down, so to get a little bit advanced-than-before view of raymarching. So, let’s go make fruxis again!

Introduction

There are quite a few objects inside this scene. And as the original author (iq) was using Spanish at that time, let’s learn a few Spanish words first:

  1. suelo (floor)
  2. pared (wall)
  3. melon (duh, melon)
  4. mazana (apple)
  5. uvas (grapes?)
  6. lemon
  7. jarron (vase)
  8. mantelito (doily)
  9. botella (bottle)

And we are going to draw it one by one. K, let’s begin!

Implementation

Well first, there’s gonna be an empty scene. You can create one at here. Then we need to implement all sorts of basic raymarching functions.

Intersect

Raymarching

I don’t know if you could remember this, but here’s the gist of raymarching: you have a camera, and a direction, and a ray shoots out from the camera, towards the direction. And for every iteration, calculate the nearest object’s distance to the ray’s head, and the ray advances said distance. If this makes the ray’s head ends up in an object (negative distance), the raymarching is done. Otherwise if the ray is going too far or there has been too many iterations already, the raymarching is done.

That’s why we need to create the camera first:

float an = 2.0 * sin(0.7 + 0.5 * iTime);
vec3 ro = vec3(0.8 + 0.2 * sin(2.0 + an), 0.4, 1.1 + 0.25 * sin(an));

We are naming it ro because it’s a raymarching tradition. ro stands for Ray Origin and rd stands for Ray Direction. Setting it as the output of the whole graph, and we will see that it sways gently:

void main() {
    float an = 2.0 * sin(0.7 + 0.5 * time);
    vec3 ro = vec3(0.8 + 0.2 * sin(2.0 + an), 0.4, 1.1 + 0.25 * sin(an));
    color = vec4(ro, 1.0);
}
Recompile | Click on the canvas to toggle running. It is paused by default.

And now, onto the next step! Getting a direction:

vec3 center = vec3(-0.2, 0.0, 0.0);
// LookAt matrix
vec3 front = normalize(center - ro);
vec3 right = normalize(cross(front, vec3(0.0, 1.0, 0.0)));
vec3 up = normalize(cross(right, front));

float aspect = iResolution.x / iResolution.y;
vec2 uv = (fragCoord / iResolution.xy) * 2.0 - 1.0;
uv.x *= aspect; // multiples aspect so it doesn't lose 

vec3 rd = normalize(uv.x * right + uv.y * up + 2.0 * front);

(iq himself really optimzed the variable names and stuffs. If you want to see how he works, check out here, around line 650.)

And after getting both ro and rd, it’s time to write the raymarching’s core function: raymarching! (Or intersect as iq calls)

float intersect(vec3 ro, vec3 rd, out vec4 info) {
    float depth = 0.0;
    for (int i = 0; i < 90; i++) {
        float dist = map(ro + rd * depth, info);
        if (dist < 0.001) {
            break;
        }
        depth += dist;
    }
    return depth;
}

The code above says a lot. First, there will be 90 marches; Second, the raymarching here does not concern about a way too long ray, so I retract my statement above. The info vector itself was used to convey object info. What object does the ray bumped into? It will be used for coloring that particular object later. But there’s a new function in the intersect function: the map! It is the SDF function. And now, we are going to start out simple:

float map(vec3 p, out vec4 info) {
    float closest = 1000.0;
    vec4 result = vec4(-1.0);
    vec3 objPos = vec3(0.0);

    float dist = ball(p, objPos);
    if (dist < closest) { closest = dist; result = vec4(1.0, objPos); }

    info = result;
    return closest;
}

Here, we keep track of the closest distance after all objects. The result is a vector4 whose x component is kinda like the material ID, and yzw component is the object’s position, so to say.

float ball(vec3 p, out vec3 objPos) {
    objPos = p;
    return length(p) - 0.5;
}

And now the raymarching part is done! Heading back to the mainImage, we are going to create a crude test to prove it works:

// ...
vec3 rd = normalize(uv.x * right + uv.y * up + 2.0 * front);

vec4 info;
float dist = intersect(ro, rd, info);

fragColor = vec4(info.yzw, 1.0);

Yep, just displaying the info! You should be able to see a ball that looks kinda like it’s breathing:

float ball(vec3 p, out vec3 objPos) {
    objPos = p;
    return length(p) - 0.5;
}

float map(vec3 p, out vec4 info) {
    float closest = 1000.0;
    vec4 result = vec4(-1.0);
    vec3 objPos = vec3(0.0);

    float dist = ball(p, objPos);
    if (dist < closest) { closest = dist; result = vec4(1.0, objPos); }

    info = result;
    return closest;
}

float intersect(vec3 ro, vec3 rd, out vec4 info) {
    float depth = 0.0;

    for (int i = 0; i < 90; i++) {
        float dist = map(ro + rd * depth, info);
        if (dist < 0.001) {
            break;
        }
        depth += dist;
    }
    return depth;
}

void main() {
    float an = 2.0 * sin(0.7 + 0.5 * time);
    vec3 ro = vec3(0.8 + 0.2 * sin(2.0 + an), 0.4, 1.1 + 0.25 * sin(an));
    vec3 center = vec3(-0.2, 0.0, 0.0);
    // LookAt matrix
    vec3 front = normalize(center - ro);
    vec3 right = normalize(cross(front, vec3(0.0, 1.0, 0.0)));
    vec3 up = normalize(cross(right, front));

    // This thing is a square, man! There's no need for aspect.
    vec3 rd = normalize(uv.x * right + uv.y * up + 2.0 * front);
    vec4 info;
    float dist = intersect(ro, rd, info);

    color = vec4(info.yzw, 1.0);
}
Recompile | Click on the canvas to toggle running. It is paused by default.

It’s not breathing though. The ball looks the same no matter how the camera rotates. But that also means we are ready to go for the next step!

Suelo (Floor)

The important stuff in Fruxis is the floor. Without floor, all the good imaginations will be gone:

Busted!

And it is of utmost importance that we make the floor first.

float suelo(vec3 p, out vec3 objPos) {
    objPos = p;
    return p.y;
}

After adding it, the scene should look kinda like this:

Scene 1

Ugh. Ugly. Don’t sweat it though, because we are going to add

Pared (Wall)

In fruxis, the camera points to the corner of the wall. Ever wondered what’s in the opposite direction of those fruits? Well, nothing!

Nothing

And I guess that’s the importance of the walls, right? It blocks your view.

float pared(vec3 p, out vec3 objPos) {
    objPos = 4.0 * p;

    float d1 = 0.6 + pos.z;
    float d2 = 0.6 + pos.x;
    d1 = min(d1, d2);
    // d1 = min(d1, sdBox(p - vec3(0.0,2.0,0.0), vec3(1.5, 0.05, 1.5)));
    return d1;
}

And now, the scene code should probably look like this:

float map(vec3 p, out vec4 info) {
    float closest = 1000.0;
    vec4 result = vec4(-1.0);
    vec3 objPos = vec3(0.0);

    float dist = suelo(p, objPos);
    if (dist < closest) { closest = dist; result = vec4(1.0, objPos); }
    
    dist = pared(p, objPos);
    if (dist < closest) { closest = dist; result = vec4(2.0, objPos); }
    
    dist = ball(p, objPos);
    if (dist < closest) { closest = dist; result = vec4(3.0, objPos); }

    info = result;
    return closest;
}

Scene 2

Well, it’s getting there! After adding some crude walls & floors, it’s time for the crucial part of the scene: lighting!

Lighting

A scene is nothing but pure ugliness if the lighting is dumb (just like above). So now, let’s dive into magnificent iq’s code, and check out how his lighting works! But for every lighting, we will be needing their normals. That’s why we need this estimate normal function:

vec3 calcNormal(vec3 p) {
    vec4 trash; // as map() takes 2 arguments
    const float epsilon = 0.001;
    return normalize(vec3(
        map(p, trash) - map(vec3(p.x - epsilon, p.yz), trash),
        map(p, trash) - map(vec3(p.x, p.y - epsilon, p.z), trash),
        map(p, trash) - map(vec3(p.xy, p.z - epsilon), trash)
    ));
}

Then we can get the normal by doing:

vec3 n = calcNormal(ro + dist * rd);

After getting this, we are going to calculate the basic lighting (phong) first. And we are going to do this separately. First, we define a variable for final lighting calculation:

vec3 lighting = vec3(0.0);

The scene itself contains a large amount of lighting calculations. And here they are:

  1. occ - Ambient Occlusion
  2. bfl - Light coming from floor
  3. amb - Ambient Lighting
  4. bce - Light coming from sky
  5. dif - Diffuse Lighting
  6. bak - Back Light
  7. sha - Direct Lighting (shadow)
  8. fre - Front Light? (probably frente?)
  9. spe - Specular Lighting

In all of those variables, bfl, bce, bak, and fre seems to be GI hacks simulating lights reflecting from all kinds of directions. amb, dif and spe are standard phong lighting variables, occ and sha are real stuffs. Let’s begin from Phong, anyway:

Ambient

Following iq, we are going to set our ambient lighting contributor to 1.0. Don’t sweat it, because the ambient color itself is pretty dark.

float ambient = 1.0;
lighting += ambient * vec3(0.18, 0.10, 0.12) * occulusion * attenuation;

There’s another two variables here: occlusion and attenuation. For now, let’s just assume it to be all vec3(1.0)s. We will get back later.

Diffuse

Once there are diffuse lighting, the light position will be expected. And here it is:

const vec3 lightPos = vec3(3.62, 2.99, 0.71 );
vec3 lightDir = normalize(rlight);

It’s pretty clear that this is a directional light. And here’s the diffuse lighting:

float diffuse = max(dot(n, lightDir), 0.0);
lighting += diffuse * vec3(2.5, 1.8, 1.3);

Specular

And finally, the last member of the Phong family, Specular lighting:

vec3 headToLight =