
I'm revisiting my WebGL series with another article, this time focusing on creating a dynamic, parallaxing background using Perlin noise. This piece builds upon the foundation set by a previous article, leveraging its established infrastructure. Our primary focus here will be the work with shaders to bring this animated backdrop to life.
The core concept behind this effect involves utilizing multiple Perlin noise generators, each operating at varying scales.
Perlin noise is a type of gradient noise developed by Ken Perlin in 1983. It has many uses, including but not limited to: procedurally generating terrain, applying pseudo-random changes to a variable, and assisting in the creation of image textures. It is most commonly implemented in two, three, or four dimensions, but can be defined for any number of dimensions.
For our purposes, we'll employ this implementation of the noise:
vec4 mod289(vec4 x)
{
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec4 permute(vec4 x)
{
return mod289(((x*34.0)+1.0)*x);
}
vec4 taylorInvSqrt(vec4 r)
{
return 1.79284291400159 - 0.85373472095314 * r;
}
vec2 fade(vec2 t) {
return t*t*t*(t*(t*6.0-15.0)+10.0);
}
// Classic Perlin noise
float cnoise(vec2 P)
{
vec4 Pi = floor(P.xyxy) + vec4(0.0, 0.0, 1.0, 1.0);
vec4 Pf = fract(P.xyxy) - vec4(0.0, 0.0, 1.0, 1.0);
Pi = mod289(Pi); // To avoid truncation effects in permutation
vec4 ix = Pi.xzxz;
vec4 iy = Pi.yyww;
vec4 fx = Pf.xzxz;
vec4 fy = Pf.yyww;
vec4 i = permute(permute(ix) + iy);
vec4 gx = fract(i * (1.0 / 41.0)) * 2.0 - 1.0 ;
vec4 gy = abs(gx) - 0.5 ;
vec4 tx = floor(gx + 0.5);
gx = gx - tx;
vec2 g00 = vec2(gx.x,gy.x);
vec2 g10 = vec2(gx.y,gy.y);
vec2 g01 = vec2(gx.z,gy.z);
vec2 g11 = vec2(gx.w,gy.w);
vec4 norm = taylorInvSqrt(vec4(dot(g00, g00), dot(g01, g01), dot(g10, g10), dot(g11, g11)));
g00 *= norm.x;
g01 *= norm.y;
g10 *= norm.z;
g11 *= norm.w;
float n00 = dot(g00, vec2(fx.x, fy.x));
float n10 = dot(g10, vec2(fx.y, fy.y));
float n01 = dot(g01, vec2(fx.z, fy.z));
float n11 = dot(g11, vec2(fx.w, fy.w));
vec2 fade_xy = fade(Pf.xy);
vec2 n_x = mix(vec2(n00, n01), vec2(n10, n11), fade_xy.x);
float n_xy = mix(n_x.x, n_x.y, fade_xy.y);
return 2.3 * n_xy;
}
The initial implementation of the noise yields a value within the [-1; 1] range. To adapt this, we require a straightforward wrapper function that normalizes these values to fall between [0; 1]:
float perlin_noise(vec2 x) {
return cnoise(x * 10.0) * 0.5 + 0.5;
}
In the shader, we incorporate time and mouse position as essential elements. These are passed through using a uniform and require some precomputations:
uniform vec2 uResolution;
uniform vec2 uMouse;
uniform float uTime;
//...
void main() {
vec2 uv = tPos;
vec2 kMouse = uMouse / uResolution - 0.5;
float yRatio = uResolution.y / uResolution.x;
kMouse.y *= yRatio;
uv.y *= yRatio;
//...
}
Given that Perlin Noise necessitates a position input, we can introduce an offset based on the mouse position. By applying a distinct offset coefficient to each mask, we effectively achieve the parallax effect. Additionally, the smoothstep function is utilized for antialiasing purposes.
float n1 = smoothstep(0.405, 0.4, perlin_noise((uv + kMouse * 0.8) * 0.5));
float n2 = smoothstep(0.31, 0.3, perlin_noise((uv + kMouse * 0.4) * 1.0));
float n3 = smoothstep(0.22, 0.2, perlin_noise((uv + kMouse * 0.2) * 2.0));
float n4 = smoothstep(0.13, 0.1, perlin_noise((uv + kMouse * 0.1) * 3.0));
Simply utilizing raw masks can be uninteresting, so let's enhance the visual appeal by integrating a gradient that evolves over time.
vec3 time_color(vec2 uv, float time) {
return vec3(0.1 + 0.9 * abs(sin(cos(time + 3.0 * uv.y) * 2.0 * uv.x + time)),
0.1 + 0.9 * abs(cos(sin(time + 2.0 * uv.x) * 3.0 * uv.y + time)),
0.5);
}
In the shader's main function, I have crafted a parallax effect specifically for colors, where the sequence of operations plays a crucial role:
vec3 col = vec3(0.0);
col = mix(col, time_color(uv + kMouse * 0.1, uTime), n4);
col = mix(col, time_color(uv + kMouse * 0.2, uTime), n3);
col = mix(col, time_color(uv + kMouse * 0.4, uTime), n2);
col = mix(col, time_color(uv + kMouse * 0.8, uTime), n1);
We also need to establish the JavaScript component. The following is the essential JS code required to track the mouse position. As an added feature, it simulates changes in the mouse position in response to changes in a mobile device's orientation. However, it's important to note that this functionality ceases to work on iOS devices without the appropriate permissions.
var mouseX = 0, mouseY = 0;
var time = 0;
document.onmousemove = function (event) {
mouseX = event.clientX;
mouseY = event.clientY;
}
function handleOrientation(event) {
var absolute = event.absolute;
var alpha = event.alpha;
var beta = event.beta;
var gamma = event.gamma;
mouseY= (beta / 90.0 * 0.5 + 0.5) * glCanvas.height;
mouseX = (gamma / 90.0 * 0.5 + 0.5) * glCanvas.width;
}
window.addEventListener("deviceorientation", handleOrientation, true);
//...
function animateScene() {
//...
time += 0.01;
gl.useProgram(shaderProgram.program);
gl.uniform2fv(shaderProgram.uniforms["uResolution"], [glCanvas.width, glCanvas.height]);
gl.uniform2fv(shaderProgram.uniforms["uMouse"], [mouseX, mouseY]);
gl.uniform1f(shaderProgram.uniforms["uTime"], time);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, vertexCount);
//...
}
Just like before, the page's background serves as a visible outcome of this implementation. Below is the complete code for the fragment shader:
#ifdef GL_ES
precision highp float;
#endif
uniform vec2 uResolution;
uniform vec2 uMouse;
uniform float uTime;
varying vec2 tPos;
vec4 mod289(vec4 x)
{
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec4 permute(vec4 x)
{
return mod289(((x*34.0)+1.0)*x);
}
vec4 taylorInvSqrt(vec4 r)
{
return 1.79284291400159 - 0.85373472095314 * r;
}
vec2 fade(vec2 t) {
return t*t*t*(t*(t*6.0-15.0)+10.0);
}
// Classic Perlin noise
float cnoise(vec2 P)
{
vec4 Pi = floor(P.xyxy) + vec4(0.0, 0.0, 1.0, 1.0);
vec4 Pf = fract(P.xyxy) - vec4(0.0, 0.0, 1.0, 1.0);
Pi = mod289(Pi); // To avoid truncation effects in permutation
vec4 ix = Pi.xzxz;
vec4 iy = Pi.yyww;
vec4 fx = Pf.xzxz;
vec4 fy = Pf.yyww;
vec4 i = permute(permute(ix) + iy);
vec4 gx = fract(i * (1.0 / 41.0)) * 2.0 - 1.0 ;
vec4 gy = abs(gx) - 0.5 ;
vec4 tx = floor(gx + 0.5);
gx = gx - tx;
vec2 g00 = vec2(gx.x,gy.x);
vec2 g10 = vec2(gx.y,gy.y);
vec2 g01 = vec2(gx.z,gy.z);
vec2 g11 = vec2(gx.w,gy.w);
vec4 norm = taylorInvSqrt(vec4(dot(g00, g00), dot(g01, g01), dot(g10, g10), dot(g11, g11)));
g00 *= norm.x;
g01 *= norm.y;
g10 *= norm.z;
g11 *= norm.w;
float n00 = dot(g00, vec2(fx.x, fy.x));
float n10 = dot(g10, vec2(fx.y, fy.y));
float n01 = dot(g01, vec2(fx.z, fy.z));
float n11 = dot(g11, vec2(fx.w, fy.w));
vec2 fade_xy = fade(Pf.xy);
vec2 n_x = mix(vec2(n00, n01), vec2(n10, n11), fade_xy.x);
float n_xy = mix(n_x.x, n_x.y, fade_xy.y);
return 2.3 * n_xy;
}
float perlin_noise(vec2 x) {
return cnoise(x * 10.0) * 0.5 + 0.5;
}
vec3 time_color(vec2 uv, float time) {
return vec3(0.1 + 0.9 * abs(sin(cos(time + 3.0 * uv.y) * 2.0 * uv.x + time)),
0.1 + 0.9 * abs(cos(sin(time + 2.0 * uv.x) * 3.0 * uv.y + time)),
0.5);
}
void main() {
vec2 uv = tPos;
vec2 kMouse = uMouse / uResolution - 0.5;
float yRatio = uResolution.y / uResolution.x;
kMouse.y *= yRatio;
uv.y *= yRatio;
float n1 = smoothstep(0.405, 0.4, perlin_noise((uv + kMouse * 0.8) * 0.5));
float n2 = smoothstep(0.31, 0.3, perlin_noise((uv + kMouse * 0.4) * 1.0));
float n3 = smoothstep(0.22, 0.2, perlin_noise((uv + kMouse * 0.2) * 2.0));
float n4 = smoothstep(0.13, 0.1, perlin_noise((uv + kMouse * 0.1) * 3.0));
vec3 col = vec3(0.0);
col = mix(col, time_color(uv + kMouse * 0.1, uTime), n4);
col = mix(col, time_color(uv + kMouse * 0.2, uTime), n3);
col = mix(col, time_color(uv + kMouse * 0.4, uTime), n2);
col = mix(col, time_color(uv + kMouse * 0.8, uTime), n1);
gl_FragColor = vec4(col, 1.0);
}
George Ostrobrod, 2019 (edited 2024)