Art Generator With Javascript and WebGL

0
26
Art Generator With Javascript and WebGL

Modern web design often makes use of great introductory pictures. These large images frame a product or service at the top of the website.

This made me think about how we could use WebGL to create an art effect similar to The Starry Night by Van Gogh. What I made was something similar, which turns nicely into liquid, if you will.

Step 1. Tre.JS

Three.js is a Javascript library that allows you to easily create WebGL 3d objects. To achieve our effect, we will follow a few important steps:

  • 1. Create the geometry (form) with Three.JS
  • 2. Render this on an HTML5 canvas
  • 3. Send this geometry data to shaders
  • 4. Animate this using Javascript
  • 5. Manipulate the geometry with shaders

So with that in mind, let’s take a look at an excerpt of our Javascript, which covers the first 3 points.

    // Please view github or codepen demo for entire code
    const noise = await loader('./shaders/noise.glsl');
    const fragment = await loader('./shaders/fragment.glsl');
    const vertex = await loader('./shaders/vertex.glsl');

    const renderer = new THREE.WebGLRenderer({
        powerPreference: "high-performance",
        antialias: true, 
        alpha: true,
        canvas: canvas // canvas is the Id for our HTML5 canvas. Remove this line and Three will auto create a canvas.
    });
    
    // Get el width and height
    let elWidth = window.innerWidth;
    let elHeight = window.innerHeight
    
    // Set sizes and set scene/camera
    renderer.setSize( elWidth, elHeight );
    document.body.appendChild( renderer.domElement )
    renderer.setPixelRatio( elWidth/elHeight );
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera( 75, elWidth / elHeight, 0.1, 1000 );
    
    let i = 2;
    // Check on colors to use
    let high = config.colors[i].high; 
    let low = config.colors[i].low;

    // Create a plane, and pass that through to our shaders
    let geometry = new THREE.PlaneGeometry(600, 600, 100, 100);
    let material = new THREE.ShaderMaterial({
        uniforms: {
            // All of these variables are passed to our shaders
            // which are then passed to the GPU
            u_lowColor: {type: 'v3', value: low },
            u_highColor: {type: 'v3', value: high },
            u_time: {type: 'f', value: 0},
            u_resolution: {type: 'v2', value: new THREE.Vector2(elWidth, elHeight) },
            u_mouse: {type: 'v2', value: new THREE.Vector2(0, 0) },
            u_height: {type: 'f', value: 1},
            u_manipulate: {type: 'f', value: 1 },
            u_veinDefinition: {type: 'f', value: 20 },
            u_goCrazy: { type: 't', value: 1 },
            u_inputTexture: {type: 't', value: lion},
            u_scale: {type: 'f', value: 0.85 },
            u_clickLength: { type: 'f', value: 1},
            u_rand: { type: 'f', value: randomInteger(0, 10) },
            u_rand: {type: 'f', value: new THREE.Vector2(randomInteger(6, 10), randomInteger(8, 10)) }
        },
        fragmentShader: noise + fragment,
        vertexShader: noise + vertex,
    });
    // Create the mesh and position appropriately
    let mesh = new THREE.Mesh(geometry, material);
    mesh.position.set(0, 0, -300);
    scene.add(mesh);

    // This function when run will animate the renderer
    // Meaning for every animation frame the 3d model
    // will be rerendered onto the canvas.
    const animate = function () {
        requestAnimationFrame( animate );
        renderer.render( scene, camera );
        document.body.appendChild(renderer.domElement);
        mesh.material.uniforms.u_time.value = t;
        if(t < 10 && backtrack == false) {
            t = t + 0.005;
        } else {
            backtrack = true;
            t = t - 0.005;
            if(t < 0) {
                backtrack = false;
            }
        }
    };

I have not included all the details, but here are some of the main points:

  • The uniform variables in uniforms: {} are sent directly to the shadow code we want to write. When we update these, it will allow us to live to update our 3d object.
  • The shader locations are mentioned in the ShaderMaterial () function
  • We then request an animation frame to render the 3D object. Note in the animate () function we increase t (time) and the uniform variable, which creates the animation

Step 2. Shaders

If you are not familiar with what shaders are, they essentially allow you to manipulate the color of the geometry and the location of a geometry. There are two types of shaders, fragments (for colors) and vertex (for positions). These change shape before being reproduced.

For this tutorial, our main focus is fragment shader. We will use noise to generate the liquid effect, and the specific noise we will use is called fractal brownish noise (FBM). An example of the noise created by FBM is shown below:

Don’t worry, you don’t have to know how to create these noise effects, and in fact, one won an Oscar. The full code of these noise features is available online and you can find them in GitHub Repo in shaders/ folder. Our vertex shader is the standard vertex shader, but we’ll see the code below to see how fragment shader works:

// Main function
void main() {
    // We have to adjust the effect to fit our resolution.
    // Heavily modified FBM function from https://thebookofshaders.com/13/
    vec2 res = (gl_FragCoord.xy + 100.) / (u_resolution.xy * u_scale);
    
    // Next lets get our colors
    vec3 highColor = rgb(u_highColor.r, u_highColor.g, u_highColor.b);
    vec3 lowColor = rgb(u_lowColor.r, u_lowColor.g, u_lowColor.b);
    
    // Set a random color
    vec3 color = vec3(23.0);

    // This is a randomised function based on fbm and some other variables
    // that we can adjust in our Javascript
    vec2 fbm1 = vec2(10.);
    fbm1.x = fbm( res + 0.05 * u_time) * snoise(res) * u_goCrazy;
    fbm1.y = fbm( res + vec2(3.0)) / (u_manipulate - snoise(res)) * 9. * u_goCrazy / u_veinDefinition * u_clickLength * 5.;

    // Next we adjust it all based on mouse position, time, and qfbm1
    vec2 r = vec2(0.);
    r.x = fbm( res + fbm1 * u_time * 0.1 ) + -sin(u_mouse.x) + 600.;
    r.y = fbm( res + fbm1 * u_time * 0.5 ) * -u_mouse.y;

    // And create a float of fbm, for use in the final color
    float f = fbm(res+r) * 1.;

    // Then we mix all our colors together
    color = mix(highColor*2., lowColor, f*3.);
    color = mix(color, lowColor, clamp(length(fbm1),0.0,2.0)); // * snoise(st) * 51.9
    color = mix(color, highColor, clamp(length(r.y),0.0,3.0));

    // And output them for render
    gl_FragColor = vec4((f*f*f*0.9*f*f+.5*f)*color,1.);
}
    

The cool thing about this particular fragment shader is that it renders as a cool noise effect to our demo, but if we increase the settings, it can also act as a fairly simple liquid water fragment shader.

Our uniform variables are all defined at the top of the fragment shadow, but not listed above. They are listed in the form uniform float u_variableName;.

Step 3. Event listeners

Finally, we add some typical event listeners to our code, to track the position of the mouse, as well as the range selector positions. This allows us to adjust the effect and animate it as the user moves around.

This is perhaps the most straightforward vanilla JS part. For the most part, we simply update the uniform variables through the mesh we defined before – mesh.material.uniforms.... Once these are updated, ours animate() function reproduces the new object in our HTML5 canvas so that our cursor position and interaction with the canvas have real-time effects on the WebGL code.

    /* event listeners */
    document.getElementById('range').addEventListener('input', function(e) {
        // Update 'range' selector
        mesh.material.uniforms.u_manipulate.value = this.value;
    })
    document.getElementById('veins').addEventListener('input', function(e) {
        // Update 'veins' selector
        mesh.material.uniforms.u_veinDefinition.value = this.value;
    })
    document.getElementById('crazy').addEventListener('input', function(e) {
        // Update 'crazy' selector
        mesh.material.uniforms.u_goCrazy.value = this.value;
    })

    document.querySelectorAll('.color').forEach(function(item) {
        // Selector a color
        item.addEventListener('click', function(e) {
            let i = parseFloat(this.getAttribute('data-id'));
            mesh.material.uniforms.u_highColor.value = config.colors[i].high;
            mesh.material.uniforms.u_lowColor.value = config.colors[i].low;
        });
    });

    // Variables to track settings
    let reduceVector;
    let increasePressure;
    let reducePressure;
    let prevX = 0;
    let prevY = 0;
    let curValueX = 0;
    let curValueY = 0;
    let mouseEnterX = 0;
    let mouseEnterY = 0;

    // On move effect
    document.body.addEventListener('pointermove', function(e) {
        if(typeof reduceVector !== "undefined") {
            clearInterval(reduceVector);
            curValueX = 0;
            curValueY = 0;
        }
        let mouseMoveX = mouseEnterX - e.pageX;
        let mouseMoveY = mouseEnterY - e.pageY;
        mesh.material.uniforms.u_mouse.value = new THREE.Vector2(prevX + (mouseMoveX / elWidth), prevY + (mouseMoveY / elHeight));
    });
    
    // Animations on pointerdown and pointerup, using setInterval 60fps
    document.getElementById('canvas').addEventListener('pointerdown', function(e) {
        if(typeof reducePressure !== "undefined") clearInterval(reducePressure);
        increasePressure = setInterval(function() {
            if(mesh.material.uniforms.u_clickLength.value < 3) {
                mesh.material.uniforms.u_clickLength.value += 0.03;
            }
        },1000/60);
    });
    document.getElementById('canvas').addEventListener('pointerup', function(e) {
        if(typeof increasePressure !== "undefined") clearInterval(increasePressure);
        reducePressure = setInterval(function() {
            if(mesh.material.uniforms.u_clickLength.value > 1) {
                mesh.material.uniforms.u_clickLength.value -= 0.03;
            }
        },1000/60);
    });

Conclusion

And we’re done! Then we have most of the components needed to create the effect. Thanks for reading – you can find the resources below the full code for the effect below:

LEAVE A REPLY

Please enter your comment!
Please enter your name here