Javascript

Creating 3d Gradients with WebGL

I’ve been really interested lately in engaging gradient backgrounds. Most websites selling products are relatively static, and bringing something living can help improve conversions. Recently I was trying to create a compelling gradient effect as a background to a project website I am working on. The effect I was after should be a) simple, b) random and c) subtle. The final results can be found here.

So I started out, like anyone would with a basic CSS gradient. The CSS gradient used to be pretty new technology, but now is broadly supported by the major browsers, but it’s always good to have a background tag as a backup for anyone still using Internet Explorer.

css Copy
background: rgba(0,0,0,1); background: linear-gradient(315deg, rgba(0,0,0,1) 0%, rgba(255,255,255,1),1) 100%);

Easy then — I thought — I would write a simple Javascript function to alternate the rgba numbers above. This is easy to do — but was such a disaster that I won’t share the outputs here. My second attempt involved using moving circles on an HTML canvas with a blend mode. This was more promising, but again, it wasn’t exactly what I was after.

Finally, enough was enough, I decided to move into the third dimension, and use three.js

Getting Started —three.js

I’m using three.js for this experiment because it makes WebGL very easy. To start I created a new file called index.html and another called script.js in the same folder. In index.html, paste the following hollow HTML structure.

Concept

WebGL is a pretty overwhelming concept if you don’t know anything about it, as I didn’t before I started on my gradient fill journey. As I learned, there are essentially three parts to a successful WebGL — two shaders and a bit of Javascript to manipulate the shapes produced.

What is a shader?

Shaders, as we describe here, are essentially functions which adjust the output of a 3D rendering. They’re part of a pipeline of tasks that happen when something is rendered in 3D on your screen.

One of these shaders is called the vertex shader — this will adjust every ‘vertex’ point on the page. It basically iterates over every point, and adjusts it based on what you have in the function. The other is the fragment shader. Think of this as adjusting the colour of each point on the page.

The shader is not in Javascript, it is written GLSL, a C-like language which is passed straight to your GPU by three.js. You can paste these straight into the <body /> of your HTML page. As someone used to writing in Javascript, these shaders look very foreign, but it basically breaks down into a few key pieces.

  • snoise() or simplex noise is a function within both of our shaders for generating noise or random vertices. You’ll see how this works, but in our vertex shader, we adjust the x and z positions of each point to create a cloth like effect. How did I come up with this? As it turns out, many have tried this before. See here for all your shader noise functions.
  • uniform variables are variables we can manipulate straight from Javascript in real time with three.js. We define them in our code below, and simply by updating these in Javascript, we will update the 3D shape on our page (this was the coolest discovery from this experiment).
  • gl_Position and gl_FragColor are reserved variables which control the final output of our shaders.
  • vUv and uv are variables which carry information on the vertex we are currently adjusting.

The best way to learn about these more is to download the files in this article and try changing a few things in the shaders and Javascript. Below is the final output of the shaders which you can paste straight into the body of your HTML:

csharp Copy
<script id="snoise-function" type="x-shader/x-vertex"> vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); } float snoise(vec2 v) { const vec4 C = vec4(0.211324865405187, // (3.0-sqrt(3.0))/6.0 0.366025403784439, // 0.5*(sqrt(3.0)-1.0) -0.577350269189626, // -1.0 + 2.0 * C.x 0.024390243902439); // 1.0 / 41.0 vec2 i = floor(v + dot(v, C.yy) ); vec2 x0 = v - i + dot(i, C.xx); vec2 i1; i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); vec4 x12 = x0.xyxy + C.xxzz; x12.xy -= i1; i = mod289(i); // Avoid truncation effects in permutation vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 )) + i.x + vec3(0.0, i1.x, 1.0 )); vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0); m = m*m ; m = m*m ; vec3 x = 2.0 * fract(p * C.www) - 1.0; vec3 h = abs(x) - 0.5; vec3 ox = floor(x + 0.5); vec3 a0 = x - ox; m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h ); vec3 g; g.x = a0.x * x0.x + h.x * x0.y; g.yz = a0.yz * x12.xz + h.yz * x12.yw; return 130.0 * dot(m, g); } </script> <script id="vertex-shader" type="x-shader/x-vertex"> uniform float u_time; uniform vec2 u_randomisePosition; varying float vDistortion; varying float xDistortion; varying vec2 vUv; void main() { vUv = uv; vDistortion = snoise(vUv.xx * 3. - u_randomisePosition * 0.15); xDistortion = snoise(vUv.yy * 1. - u_randomisePosition * 0.05); vec3 pos = position; pos.z += (vDistortion * 35.); pos.x += (xDistortion * 25.); gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } </script> <script id="fragment-shader" type="x-shader/x-fragment"> vec3 rgb(float r, float g, float b) { return vec3(r / 255., g / 255., b / 255.); } vec3 rgb(float c) { return vec3(c / 255., c / 255., c / 255.); } uniform vec3 u_bg; uniform vec3 u_bgMain; uniform vec3 u_color1; uniform vec3 u_color2; uniform float u_time; varying vec2 vUv; varying float vDistortion; void main() { vec3 bg = rgb(u_bg.r, u_bg.g, u_bg.b); vec3 c1 = rgb(u_color1.r, u_color1.g, u_color1.b); vec3 c2 = rgb(u_color2.r, u_color2.g, u_color2.b); vec3 bgMain = rgb(u_bgMain.r, u_bgMain.g, u_bgMain.b); float noise1 = snoise(vUv + u_time * 0.08); float noise2 = snoise(vUv * 2. + u_time * 0.1); vec3 color = bg; color = mix(color, c1, noise1 * 0.6); color = mix(color, c2, noise2 * .4); color = mix(color, mix(c1, c2, vUv.x), vDistortion); float border = smoothstep(0.1, 0.6, vUv.x); color = mix(color, bgMain, 1. -border); gl_FragColor = vec4(color, 1.0); } </script>

The shader code is in the HTML, although can be pulled into Javascript through any means (for example, a variable containing the code within the Javascript itself)

Javascript

I’ve imported the Three.js file in the codepen at the top, just remember, if you want to do that you must have your code on a server. You can’t just open the file in your browser. You can use your own computer’s localhost for this as well.

To understand a bit more about three, you can check out the three documentation. Essentially, we need to create a camera (for viewing), a renderer (for putting it all on the screen), and a scene (for placing the objects). We will then place a sheet or rectangle on the scene.

This is where uniform variables come in. We will define these in Javascript, and we can then update them to alter the rendering process with Javascript. We have a few other utility functions here, so please see the full code in the codepen or in the git at the end for more information.

javascript Copy
// Lets create a rendering process const renderer = new THREE.WebGLRenderer(); // And make it full screen renderer.setSize( window.innerWidth, window.innerHeight ); // And append it to the body. This is appending a <canvas /> tag document.body.appendChild( renderer.domElement ) // Then lets create the scene and camera const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 ); camera.position.z = 5; let vCheck = false; var randomisePosition = new THREE.Vector2(1, 2); // This is the shader from earlier let sNoise = document.querySelector('#snoise-function').textContent // Lets make a rectangle let geometry = new THREE.PlaneGeometry(400, 400, 100, 100); // And define its material using the shaders. let material = new THREE.ShaderMaterial({ // These are the uniform variables. If we alter these // They will update the rendering process, and the shape will change // in real time uniforms: { u_bg: {type: 'v3', value: rgb(162, 138, 241)}, u_bgMain: {type: 'v3', value: rgb(162, 138, 241)}, u_color1: {type: 'v3', value: rgb(162, 138, 241)}, u_color2: {type: 'v3', value: rgb(82, 31, 241)}, u_time: {type: 'f', value: 0}, u_randomisePosition: { type: 'v2', value: randomisePosition } }, fragmentShader: sNoise + document.querySelector('#fragment-shader').textContent, vertexShader: sNoise + document.querySelector('#vertex-shader').textContent, }); // Now we have the shape and its material, we combine to make what is displayed on the screen let mesh = new THREE.Mesh(geometry, material); // We poisition it in our scene mesh.position.set(0, 140, -280); // And we scale it (so it is bigger or smaller) mesh.scale.multiplyScalar(5); // Lets rotate it a little bit too mesh.rotationX = -1.0; mesh.rotationY = 0.0; mesh.rotationZ = 0.1; // When we're done manipulating, we add it to the scene scene.add(mesh); // Finally we can render using both the scene and camera renderer.render( scene, camera );

If you adjust line 42, multiplyScalar(5) to multiplyScalar(1), you can see the object fully without the massive zoom in. This will give you an idea about how this is working, since you’ll see it is just a sheet being warped, and the sharp lines produced in the gradient are actually just parts of the sheet overlapping with itself.

Alright — now comes the animation. We will use Javascript animation frames as this process can become quite CPU/GPU intensive. Again, the code is minimal here, we can re-render the scene and update its values for every frame produced by requestAnimationFrame().

javascript Copy
// we have two variables that we will use to generate the warp of the sheet let t = 0; let j = 0; // We will set x and y as random integers let x = randomInteger(0, 32); let y = randomInteger(0, 32); const animate = function () { // This function is the animation, so lets request a frame requestAnimationFrame( animate ); // And lets re-render the image renderer.render( scene, camera ); // Remember the uniform variables from earlier? Now we will update the randomisePosition // variable with the j variable, producing a random z and x position as shown in the shader mesh.material.uniforms.u_randomisePosition.value = new THREE.Vector2(j, j); // We will also generate a random R, G, and B value using R(), G(), and B(). The full code // can be found in the codepen or on the git. mesh.material.uniforms.u_color1.value = new THREE.Vector3(R(x,y,t/2), G(x,y,t/2), B(x,y,t/2)); // And since we have t representing time, we will update time. Again, this will produce another // random input for adjusting the animation of the 3D object. mesh.material.uniforms.u_time.value = t; // Every 2 ticks of t, we will adjust x, so it never goes below 0 or above 32. if(t % 0.1 == 0) { if(vCheck == false) { x -= 1; if(x <= 0) { vCheck = true; } } else { x += 1; if(x >= 32) { vCheck = false; } } } // Increase t by a certain value every frame j = j + 0.01; t = t + 0.05; }; // Call the animation function animate();

Conclusion

When I started this, I knew the effect I wanted to produce, but had no idea how to do it. The applications and adjustments you can make to this effect give you a lot of flexibility for backgrounds to websites, header elements, or anything like that.

Relevant Links

Last Updated Wednesday, 16 December 2020
Click to Subscribe Subscribed

Subscribe

Subscribe to stay up to date with our latest posts via email. You can opt out at any time.

Not a valid email