Creating 3d Animated Gradient Effects with Javascript and WebGL
📣 Sponsor
I’ve been really interested lately in engaging gradient backgrounds. Most websites selling products are relatively static, and creating an animated background gradient effect in Javascript can help improve user engagement. Recently I was trying to create a compelling background gradient effect for a project website I am working on. The effect I was after should be a) simple, b) random and c) subtle. The final results are shown below:
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.
background: rgba(0,0,0,1);
background: linear-gradient(315deg, rgba(0,0,0,1) 0%, rgba(255,255,255,1),1) 100%);
This looks fine, but it's not really the final effect I was after. So next I decided to try going for a Javascript animated gradient effect.
To create the Javascript animated gradient background effect I used 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: https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83
- 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:
<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.
// 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().
// 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
More Tips and Tricks for Javascript
- How Generator Functions work in Javascript
- Import, Export and Require in Javascript
- What is Nullish Coalescing (or ??) in Javascript
- Javascript Array Slice Method
- Javascript Records and Tuples
- Javascript on Click Confetti Effect
- Truthy and Falsy Values in Javascript
- Instagram Style Video Preload Static Effect
- Demystifying how 'this' works in Javascript
- Making a Morphing 3D Sphere in Javascript with Three.js