Simulating Depth of Field with Particles using the Blurry Library

Publikováno: 1.10.2019

Learn how to create a geometrical scene with a depth effect using the Blurry library.

Simulating Depth of Field with Particles using the Blurry Library was written by Domenico Bruzzese and published on Codrops.

Celý článek

Blurry is a set of scripts that allow you to easily visualize simple geometrical shapes with a bokeh/depth of field effect of an out-of-focus camera. It uses Three.js internally to make it easy to develop the shaders and the WebGL programs required to run it.

The bokeh effect is generated by using millions of particles to draw the primitives supported by the library. These particles are then accumulated in a texture and randomly displaced in a circle depending on how far away they are from the focal plane.

These are some of the scenes I’ve recently created using Blurry:

blurry examples

Since the library itself is very simple and you don’t need to know more than three functions to get started, I’ve decided to write this walk-through of a scene made with Blurry. It will teach you how to use various tricks to create geometrical shapes often found in the works of generative artists. This will also hopefully show you how simple tools can produce interesting and complex looking results.

In this little introduction to Blurry we’ll try to recreate the following scene, by using various techniques borrowed from the world of generative art:

targetscene

Starting out

You can download the repo here and serve index.html from a local server to render the scene that is currently coded inside libs/createScene.js. You can rotate, zoom and pan around the scene as with any Three.js project using OrbitControls.js.

There are also some additional key-bindings to change various parameters of the renderer, such as the focal length, exposure, bokeh strength and more. These are visible at the bottom left of the screen.

All the magic happens inside libs/createScene.js, where you can implement the two functions required to render something with Blurry. All the snippets defined in this article will end up inside createScene.js.

The most important function we’ll need to implement to recreate the scene shown at the beginning of the article is createScene(), which will be called by the other scripts just before the renderer pushes the primitives to the GPU for the actual rendering of the scene.

The other function we’ll define is setGlobals(), which is used to define the parameters of the shaders that will render our scene, such as the strength of the bokeh effect, the exposure, background color, etc.

Let’s head over to createScene.js, remove everything that’s already coded in there, and define setGlobals() as:

function setGlobals() {
    pointsPerFrame = 50000;

    cameraPosition = new THREE.Vector3(0, 0, 115);
    cameraFocalDistance = 100;

    minimumLineSize = 0.005;

    bokehStrength = 0.02;
    focalPowerFunction = 1;
    exposure = 0.009;
    distanceAttenuation = 0.002;

    useBokehTexture = true;
    bokehTexturePath = "assets/bokeh/pentagon2.png";

    backgroundColor[0] *= 0.8;
    backgroundColor[1] *= 0.8;
    backgroundColor[2] *= 0.8;
}

There’s an explanation for each of these parameters in the Readme of the GitHub repo. The important info at the moment is that the camera will start positioned at (x: 0, y: 0, z: 115) and the cameraFocalDistance (the distance from the camera where our primitives will be in focus) will be set at 100, meaning that every point 100 units away from the camera will be in focus.

Another variable to consider is pointsPerFrame, which is used internally to assign a set number of points to all the primitives to render in a single frame. If you find that your GPU is struggling with 50000, lower that value.

Before we start implementing createScene(), let’s first define some initial global variables that will be useful later:

let rand, nrand;
let vec3 = function(x,y,z) { return new THREE.Vector3(x,y,z) };

I’ll explain the usage of each of these variables as we move along; vec3() is just a simple shortcut to create Three.js vectors without having to type THREE.Vector3(…) each time.

Let’s now define createScene():

function createScene() {
    Utils.setRandomSeed("3926153465010");

    rand = function() { return Utils.rand(); };
    nrand = function() { return rand() * 2 - 1; };
}

Very often I find the need to “repeat” the sequence of randomly generated numbers I had in a bugged scene. If I had to rely on the standard Math.random() function, each page-refresh would give me different random numbers, which is why I’ve included a seeded random number generator in the project. Utils.setRandomSeed(…) will take a string as a parameter and use that as the seed of the random numbers that will be generated by Utils.rand(), the seeded generator that is used in place of Math.random() (though you can still use that if you want).

The functions rand & nrand will be used to generate random values in the interval [0 … 1] for rand, and [-1 … +1] for nrand.

Let’s draw some lines

At the moment you can only draw two simple primitives in Blurry: lines and quads. We’ll focus on lines in this article. Here’s the code that generates 10 consecutive straight lines:

function createScene() {
    Utils.setRandomSeed("3926153465010");

    rand = function() { return Utils.rand(); };
    nrand = function() { return rand() * 2 - 1; };

    for(let i = 0; i < 10; i++) {
        lines.push(
            new Line({
                v1: vec3(i, 0, 15),
                v2: vec3(i, 10, 15),
                
                c1: vec3(5, 5, 5),
                c2: vec3(5, 5, 5),
            })
        );
    }
}

lines is simply a global array used to store the lines to render. Every line we .push() into the array will be rendered.

v1 and v2 are the two vertices of the line. c1 and c2 are the colors associated to each vertex as an RGB triplet. Note that Blurry is not restricted to the [0…1] range for each component of the RGB color. In this case using 5 for each component will give us a white line.

If you did everything correctly up until now, you’ll see 10 straight lines in the screen as soon as you launch index.html from a local server.

Here’s the code we have so far.

Since we’re not here to just draw straight lines, we’ll now make more interesting shapes with the help of these two new functions:

function createScene() {
    Utils.setRandomSeed("3926153465010");
    
    rand = function() { return Utils.rand(); };
    nrand = function() { return rand() * 2 - 1; };
    
    computeWeb();
    computeSparkles();
}

function computeWeb() { }
function computeSparkles() { }

Let’s start by defining computeWeb() as:

function computeWeb() {
    // how many curved lines to draw
    let r2 = 17;
    // how many "straight pieces" to assign to each of these curved lines
    let r1 = 35;
    for(let j = 0; j < r2; j++) {
        for(let i = 0; i < r1; i++) {
            // definining the spherical coordinates of the two vertices of the line we're drawing
            let phi1 = j / r2 * Math.PI * 2;
            let theta1 = i / r1 * Math.PI - Math.PI * 0.5;

            let phi2 = j / r2 * Math.PI * 2;
            let theta2 = (i+1) / r1 * Math.PI - Math.PI * 0.5;

            // converting spherical coordinates to cartesian
            let x1 = Math.sin(phi1) * Math.cos(theta1);
            let y1 = Math.sin(theta1);
            let z1 = Math.cos(phi1) * Math.cos(theta1);

            let x2 = Math.sin(phi2) * Math.cos(theta2);
            let y2 = Math.sin(theta2);
            let z2 = Math.cos(phi2) * Math.cos(theta2);

            lines.push(
                new Line({
                    v1: vec3(x1,y1,z1).multiplyScalar(15),
                    v2: vec3(x2,y2,z2).multiplyScalar(15),
                    c1: vec3(5,5,5),
                    c2: vec3(5,5,5),
                })
            );
        }
    }
}

The goal here is to create a bunch of vertical lines that follow the shape of a sphere. Since we can’t make curved lines, we’ll break each line along this sphere in tiny straight pieces. (x1,y1,z1) and (x2,y2,z2) will be the endpoints of the line we’ll draw in each iteration of the loop. r2 is used to decide how many vertical lines in the surface of the sphere we’ll be drawing, whereas r1 is the amount of tiny straight pieces that we’re going to use for each one of the curved lines we’ll draw.

The phi and theta variables represent the spherical coordinates of both points, which are then converted to Cartesian coordinates before pushing the new line into the lines array.

Each time the outer loop (j) is entered, phi1 and phi2 will decide at which angle the vertical line will start (for the moment, they’ll hold the same exact value). Every iteration inside the inner loop (i) will construct the tiny pieces creating the vertical line, by slightly incrementing the theta angle at each iteration.

After the conversion, the resulting Cartesian coordinates will be multiplied by 15 world units with .multiplyScalar(15), thus the curved lines that we’re drawing are placed on the surface of a sphere which has a radius of exactly 15.

To make things a bit more interesting, let’s twist these vertical lines a bit with this simple change:

let phi1 = (j + i * 0.075) / r2 * Math.PI * 2;
...
let phi2 = (j + (i+1) * 0.075) / r2 * Math.PI * 2;

If we twist the phi angles a bit as we move up the line while we’re constructing it, we’ll end up with:

t1

And as a last change, let’s swap the z-axis of both points with the y-axis:

...
lines.push(
    new Line({
        v1: vec3(x1,z1,y1).multiplyScalar(15),
        v2: vec3(x2,z2,y2).multiplyScalar(15),
        c1: vec3(5,5,5),
        c2: vec3(5,5,5),
    })
);
...

Here’s the full source of createScene.js up to this point.

Segment-Plane intersections

Now the fun part begins. To recreate these type of intersections between the lines we just did

t2

…we’ll need to play a bit with ray-plane intersections. Here’s an overview of what we’ll do:

Given the lines we made in our 3D scene, we’re going to create an infinite plane with a random direction and we’ll intersect this plane with all the lines we have in the scene. Then we’ll pick one of these lines intersecting the plane (chosen at random) and we’ll find the closest line to it that is also intersected by the plane.

Let’s use a figure to make the example a bit easier to digest:

t6

Let’s assume all the segments in the picture are the lines of our scene that intersected the random plane. The red line was chosen randomly out of all the intersected lines. Every line intersects the plane at a specific point in 3D space. Let’s call “x” the point of contact of the red line with the random plane.

The next step is to find the closest point to “x”, from all the other contact points of the other lines that were intersected by the plane. In the figure the green point “y” is the closest.

As soon as we have these two points “x” and “y”, we’ll simply create another line connecting them.

If we run this process several times (creating a random plane, intersecting our lines, finding the closest point, making a new line) we’ll end up with the result we want. To make it possible, let’s define findIntersectingEdges() as:

function findIntersectingEdges(center, dir) {

    let contactPoints = [];
    for(line of lines) {
        let ires = intersectsPlane(
            center, dir,
            line.v1, line.v2
        );

        if(ires === false) continue;

        contactPoints.push(ires);
    }

    if(contactPoints.length < 2) return;
}

The two parameters of findIntersectingEdges() are the center of the 3D plane and the direction that the plane is facing towards. contactPoints will store all the points of intersection between the lines of our scene and the plane, intersectsPlane() will tell us if a given line intersects a plane. If the returned value ires isn’t undefined, which means there’s a point of intersection stored inside the ires variable, we’ll save the ires variable in the contactPoints array.

intersectsPlane() is defined as:

function intersectsPlane(planePoint, planeNormal, linePoint, linePoint2) {

    let lineDirection = new THREE.Vector3(linePoint2.x - linePoint.x, linePoint2.y - linePoint.y, linePoint2.z - linePoint.z);
    let lineLength = lineDirection.length();
    lineDirection.normalize();

    if (planeNormal.dot(lineDirection) === 0) {
        return false;
    }

    let t = (planeNormal.dot(planePoint) - planeNormal.dot(linePoint)) / planeNormal.dot(lineDirection);
    if (t > lineLength) return false;
    if (t < 0) return false;

    let px = linePoint.x + lineDirection.x * t;
    let py = linePoint.y + lineDirection.y * t;
    let pz = linePoint.z + lineDirection.z * t;
    
    let planeSize = Infinity;
    if(vec3(planePoint.x - px, planePoint.y - py, planePoint.z - pz).length() > planeSize) return false;

    return vec3(px, py, pz);
}

I won’t go over the details of how this function works, if you want to know more check the original version of the function here.

Let’s now go to step 2: Picking a random contact point (we’ll call it randCp) and finding its closest neighbor contact point. Append this snippet at the end of findIntersectingEdges():

function findIntersectingEdges(center, dir) {
    ...
    ...

    let randCpIndex = Math.floor(rand() * contactPoints.length);
    let randCp = contactPoints[randCpIndex];

    // let's search the closest contact point from randCp
    let minl = Infinity;
    let minI = -1;
    
    // iterate all contact points
    for(let i = 0; i < contactPoints.length; i++) {
        // skip randCp otherwise the closest contact point to randCp will end up being... randCp!
        if(i === randCpIndex) continue;

        let cp2 = contactPoints[i];

        // 3d point in space of randCp
        let v1 = vec3(randCp.x, randCp.y, randCp.z);
        // 3d point in space of the contact point we're testing for proximity
        let v2 = vec3(cp2.x, cp2.y, cp2.z);

        let sv = vec3(v2.x - v1.x, v2.y - v1.y, v2.z - v1.z);
        // "l" holds the euclidean distance between the two contact points
        let l = sv.length();

        // if "l" is smaller than the minimum distance we've registered so far, store this contact point's index as minI
        if(l < minl) {
            minl = l;
            minI = i;
        }
    }

    let cp1 = contactPoints[randCpIndex];
    let cp2 = contactPoints[minI];

    // let's create a new line out of these two contact points
    lines.push(
        new Line({
            v1: vec3(cp1.x, cp1.y, cp1.z),
            v2: vec3(cp2.x, cp2.y, cp2.z),
            c1: vec3(2,2,2),
            c2: vec3(2,2,2),
        })
    );
}

Now that we have our routine to test intersections against a 3D plane, let’s use it repeatedly against the lines that we already made in the surface of the sphere. Append the following code at the end of computeWeb():

function computeWeb() {

    ...
    ...

    // intersect many 3d planes against all the lines we made so far
    for(let i = 0; i < 4500; i++) {
        let x0 = nrand() * 15;
        let y0 = nrand() * 15;
        let z0 = nrand() * 15;
        
        // dir will be a random direction in the unit sphere
        let dir = vec3(nrand(), nrand(), nrand()).normalize();
        findIntersectingEdges(vec3(x0, y0, z0), dir);
    }
}

If you followed along, you should get this result

t3

Click here to see the source up to this point.

Adding sparkles

We’re almost done! To make the depth of field effect more prominent we’re going to fill the scene with little sparkles. So, it’s now time to define the last function we were missing:

function computeSparkles() {
    for(let i = 0; i < 5500; i++) {
        let v0 = vec3(nrand(), nrand(), nrand()).normalize().multiplyScalar(18 + rand() * 65);

        let c = 1.325 * (0.3 + rand() * 0.7);
        let s = 0.125;

        if(rand() > 0.9) {
            c *= 4;
        }

        lines.push(new Line({
            v1: vec3(v0.x - s, v0.y, v0.z),
            v2: vec3(v0.x + s, v0.y, v0.z),

            c1: vec3(c, c, c),
            c2: vec3(c, c, c),
        }));

        lines.push(new Line({
            v1: vec3(v0.x, v0.y - s, v0.z),
            v2: vec3(v0.x, v0.y + s, v0.z),
    
            c1: vec3(c, c, c),
            c2: vec3(c, c, c),
        }));
    }
}

Let’s start by explaining this line:

let v0 = vec3(nrand(), nrand(), nrand()).normalize().multiplyScalar(18 + rand() * 65);

Here we’re creating a 3D vector with three random values between -1 and +1. Then, by doing .normalize() we’re making it a “unit vector”, which is a vector whose length is exactly 1.

If you drew many points by using this method (choosing three random components between [-1, +1] and then normalizing the vector) you’d notice that all the points you draw end up on the surface of a sphere (which have a radius of exactly one).

Since the sphere we’re drawing in computeWeb() has a radius of exactly 15 units, we want to make sure that all our sparkles don’t end up inside the sphere generated in computeWeb().

We can make sure that all points are far enough from the sphere by multiplying each vector component by a scalar that is bigger than the sphere radius with .multiplyScalar(18 … and then adding some randomness to it by adding + rand() * 65.

let c = 1.325 * (0.3 + rand() * 0.7);

c is a multiplier for the color intensity of the sparkle we’re computing. At a minimum, it will be 1.325 * (0.3), if rand() ends up at the highest possible value, c will be 1.325 * (1).

The line if(rand() > 0.9) c *= 4; can be read as “every 10 sparkles, make one whose color intensity is four times bigger than the others”.

The two calls to lines.push() are drawing a horizontal line of size s, and center v0, and a vertical line of size s, and center v0. All the sparkles are in fact little “plus signs”.

And here’s what we have up to this point:

t4

… and the code for createScene.js

Adding lights and colors

The final step to our small journey with Blurry is to change the color of our lines to match the colors of the finished scene.

Before we do so, I’ll give a very simplistic explanation of the algebraic operation called “dot product”. If we plot two unit vectors in 3D space, we can measure how “similar” the direction they’re pointing to is.

Two parallel unit vectors will have a dot product of 1 while orthogonal unit vectors will instead have a dot product of 0. Opposite unit vectors will result in a dot product of -1.

Take this picture as a reference for the value of the dot product depending on the two input unit vectors:

t5

We can use this operation to calculate “how close” two directions are to each other, and we’ll use it to fake diffuse lighting and create the effect that two light sources are lighting up the scene.

Here’s a drawing which will hopefully make it easier to understand what we’ll do:

lighting

The red and white dot on the surface of the sphere has the red unit vector direction associated with it. Now let’s imagine that the violet vectors represent light emitted from a directional light source, and the green vector is the opposite vector of the violet vector (in algebraic terms the green vector is the negation of the violet vector). If we take the dot product between the red and the green vector, we’ll get an estimate of how much the two vectors point to the same direction. The bigger the value is, the bigger the amount of light received at that point will be. The intuitive reasoning behind this process is essentially to imagine each of the points in our lines as if they were very small planes. If these little planes are facing toward the light source, they’ll absorb and reflect more light from it.

Remember though that the dot operation can also return negative values. We’ll catch that by making sure that the minimum value returned by that function is greater or equal than 0.

Let’s now code what we said so far with words and define two new global variables just before the definition of createScene():

let lightDir0 = vec3(1, 1, 0.2).normalize();
let lightDir1 = vec3(-1, 1, 0.2).normalize();

You can think about both variables as two green vectors in the picture above, pointing to two different directional light sources.

We’ll also create a normal1 variable which will be used as our “red vector” in the picture above and calculate the dot products between normal1 and the two light directions we just added. Each light direction will have a color associated to it. After we calculate with the dot products how much light is reflected from both light directions, we’ll just sum the two colors together (we’ll sum the RGB triplets) and use that as the new color of the line we’ll create.

Lets finally append a new snippet to the end of computeWeb() which will change the color of the lines we computed in the previous steps:

function computeWeb() {
    ...

    // recolor edges
    for(line of lines) {
        let v1 = line.v1;
        
        // these will be used as the "red vectors" of the previous example
        let normal1 = v1.clone().normalize();
        
        // lets calculate how much light normal1
        // will get from the "lightDir0" light direction (the white light)
        // we need Math.max( ... , 0.1) to make sure the dot product doesn't get lower than
        // 0.1, this will ensure each point is at least partially lit by a light source and
        // doesn't end up being completely black
        let diffuse0 = Math.max(lightDir0.dot(normal1), 0.1);
        // lets calculate how much light normal1
        // will get from the "lightDir1" light direction (the reddish light)
        let diffuse1 = Math.max(lightDir1.dot(normal1), 0.1);
        
        let firstColor = [diffuse0, diffuse0, diffuse0];
        let secondColor = [2 * diffuse1, 0.2 * diffuse1, 0];
        
        // the two colors will represent how much light is received from both light directions,
        // so we'll need to sum them togheter to create the effect that our scene is being lit by two light sources
        let r1 = firstColor[0] + secondColor[0];
        let g1 = firstColor[1] + secondColor[1];
        let b1 = firstColor[2] + secondColor[2];
        
        let r2 = firstColor[0] + secondColor[0];
        let g2 = firstColor[1] + secondColor[1];
        let b2 = firstColor[2] + secondColor[2];
        
        line.c1 = vec3(r1, g1, b1);
        line.c2 = vec3(r2, g2, b2);
    }
}

Keep in mind what we’re doing is a very, very simple way to recreate diffuse lighting, and it’s incorrect for many reasons, starting from the fact we’re only considering the first vertex of each line, and assigning the calculated light contribution to both, the first and second vertex of the line, without considering the fact that the second vertex might be very far away from the first vertex, thus ending up with a different normal vector and consequently different light contributions. But we’ll live with this simplification for the purpose of this article.

Let’s also update the lines created with computeSparkles() to reflect these changes as well:

function computeSparkles() {
    for(let i = 0; i < 5500; i++) {
        let v0 = vec3(nrand(), nrand(), nrand()).normalize().multiplyScalar(18 + rand() * 65);

        let c = 1.325 * (0.3 + rand() * 0.7);
        let s = 0.125;

        if(rand() > 0.9) {
            c *= 4;
        }

        let normal1 = v0.clone().normalize();

        let diffuse0 = Math.max(lightDir0.dot(normal1), 0.1);
        let diffuse1 = Math.max(lightDir1.dot(normal1), 0.1);

        let r = diffuse0 + 2 * diffuse1;
        let g = diffuse0 + 0.2 * diffuse1;
        let b = diffuse0;

        lines.push(new Line({
            v1: vec3(v0.x - s, v0.y, v0.z),
            v2: vec3(v0.x + s, v0.y, v0.z),

            c1: vec3(r * c, g * c, b * c),
            c2: vec3(r * c, g * c, b * c),
        }));

        lines.push(new Line({
            v1: vec3(v0.x, v0.y - s, v0.z),
            v2: vec3(v0.x, v0.y + s, v0.z),
    
            c1: vec3(r * c, g * c, b * c),
            c2: vec3(r * c, g * c, b * c),
        }));
    }
}

And that’s it!

The scene you’ll end up seeing will be very similar to the one we wanted to recreate at the beginning of the article. The only difference will be that I’m calculating the light contribution for both computeWeb() and computeSparkles() as:

let diffuse0 = Math.max(lightDir0.dot(normal1) * 3, 0.15);
let diffuse1 = Math.max(lightDir1.dot(normal1) * 2, 0.2 );

Check the full source here or take a look at the live demo.

Final words

If you made it this far, you’ll now know how this very simple library works and hopefully you learned a few tricks for your future generative art projects!

This little project only used lines as primitives, but you can also use textured quads, motion blur, and a custom shader pass that I’ve used recently to recreate volumetric light shafts. Look through the examples in libs/scenes/ if you’re curious to see those features in action.

If you have any question about the library or if you’d like to suggest a feature/change feel free to open an issue in the github repo. I’d love to hear your suggestions!

Simulating Depth of Field with Particles using the Blurry Library was written by Domenico Bruzzese and published on Codrops.

Nahoru
Tento web používá k poskytování služeb a analýze návštěvnosti soubory cookie. Používáním tohoto webu s tímto souhlasíte. Další informace