Visualizing 3D Perlin Noise in Three.js

Franky Hung
6 min readDec 20, 2023

With a noise algorithm such as perlin noise, you can create a myriad of 2D textures or 3D shapes such as cellular structure, fire, organic blobs, natural landscape, clouds, and many more. However as a beginner in using perlin noise, I couldn’t resist the idea of simply visualizing it in 3D, to actually get a feel of what it looks like by itself, and that is what this article is about.

Personally, I think the animated visualization itself is already looking pretty nice; I can look at it all day. It is like looking at ever changing clouds, it’s pretty satisfying in a way.

Source code and Live demo

Source code is hosted on https://github.com/franky-adl/perlin-noise-3d.

Live demo here: https://projects.arkon.digital/threejs/perlin-noise-3d/

Get your hands dirty

Let’s get straight into the tutorial!

You can start with your own boilerplate threejs template, or mine at https://github.com/franky-adl/threejs-starter-template.

This is gonna be a really simple scene, with no lighting needed, just a custom BufferGeometry with a custom ShaderMaterial .

Setup for the geometry:

this.geometry = new THREE.BufferGeometry()

let positions = new Float32Array(TOTAL*3)
// for identifying each particle, like an id
let reference = new Float32Array(TOTAL*2)

for (let i = 0; i < TOTAL; i++) {
let x = (i%WIDTH)/WIDTH * 2 - 1
let y = (Math.floor(i/WIDTH)%WIDTH)/WIDTH * 2 - 1
// ~~ is alternative for Math.floor(), provided that the target is already positive number
let z = ~~(i/(WIDTH*WIDTH))/WIDTH * 2 - 1

// think of xx,yy to be particle coordinates of a 2D plane of the same number of particles
// with width and height of 1000 (1000 squared = Math.pow(100, 3))
let xx = (i%1000)/1000
let yy = ~~(i/1000)/1000

positions.set([x,y,z], i*3)
reference.set([xx,yy], i*2)
}

this.geometry.setAttribute('position', new THREE.BufferAttribute(positions,3))
this.geometry.setAttribute('reference', new THREE.BufferAttribute(reference,2))

We need to store 2 BufferAttribute only, position is to store the 3D coordinates of each particle, reference is a 2D coordinate of each particle that acts as a unique identifier for each particle, which is later used to add more noise to each particle position. The above calculations align the particles perfectly into a cube.

Next we have the ShaderMaterial:

this.material = new THREE.ShaderMaterial({
uniforms: {
...uniforms,
perlinFactor: params.perlinFactor,
randomFactor: params.randomFactor
},
blending: THREE.AdditiveBlending,
vertexShader: vertex,
fragmentShader: fragment,
transparent: true,
// it seems that when dealing with Points of transparency, you should turn depthTest to false in order for the points to be displayed correctly
depthTest: false
})

The uniforms in use are just u_time, perlinFactor and randomFactor. I maintain the particles at 0.2 opacity in the fragment shader so I have to turn on transparent to true, depthTest to false. Also using AdditiveBlending here so colors would add up in areas with denser particles, making the render look more natural with our partially transparent particles. We’ll talk about the shaders shortly.

Lastly, it’s just adding the mesh to the scene:

this.particles = new THREE.Points(this.geometry, this.material)
// setting an initial rotation
this.particles.rotation.y = Math.PI / 4
scene.add(this.particles)

Finally, it’s the centerpiece of this simulation, the shaders!

If you want a refresher on perlin noise, I suggest watching this youtube video https://www.youtube.com/watch?v=7fd331zsie0; it explains the idea with pretty nice visuals.

This time, most of the shader work is done in the vertex shader. The idea is simply to set the particle size(i.e. gl_PointSize) according to the perlin noise value calculated for each particle. We also pass the noise value to the fragment shader via a varying float such that we can tweak the colors a bit according to the noise value as well.

The vertex shader:

float PI = 3.141592653589793238;
uniform float u_time;
uniform float perlinFactor;
uniform float randomFactor;
attribute vec2 reference;
varying float perlin;

vec3 mod289(vec3 x)
{
return x - floor(x * (1.0 / 289.0)) * 289.0;
}

vec4 mod289(vec4 x)
{
return x - floor(x * (1.0 / 289.0)) * 289.0;
}

vec4 permute(vec4 x)
{
return mod289(((x*34.0)+10.0)*x);
}

vec4 taylorInvSqrt(vec4 r)
{
return 1.79284291400159 - 0.85373472095314 * r;
}

vec3 fade(vec3 t) {
return t*t*t*(t*(t*6.0-15.0)+10.0);
}

// Classic Perlin noise
float cnoise(vec3 P)
{
vec3 Pi0 = floor(P); // Integer part for indexing
vec3 Pi1 = Pi0 + vec3(1.0); // Integer part + 1
Pi0 = mod289(Pi0);
Pi1 = mod289(Pi1);
vec3 Pf0 = fract(P); // Fractional part for interpolation
vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0
vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
vec4 iy = vec4(Pi0.yy, Pi1.yy);
vec4 iz0 = Pi0.zzzz;
vec4 iz1 = Pi1.zzzz;

vec4 ixy = permute(permute(ix) + iy);
vec4 ixy0 = permute(ixy + iz0);
vec4 ixy1 = permute(ixy + iz1);

vec4 gx0 = ixy0 * (1.0 / 7.0);
vec4 gy0 = fract(floor(gx0) * (1.0 / 7.0)) - 0.5;
gx0 = fract(gx0);
vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
vec4 sz0 = step(gz0, vec4(0.0));
gx0 -= sz0 * (step(0.0, gx0) - 0.5);
gy0 -= sz0 * (step(0.0, gy0) - 0.5);

vec4 gx1 = ixy1 * (1.0 / 7.0);
vec4 gy1 = fract(floor(gx1) * (1.0 / 7.0)) - 0.5;
gx1 = fract(gx1);
vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
vec4 sz1 = step(gz1, vec4(0.0));
gx1 -= sz1 * (step(0.0, gx1) - 0.5);
gy1 -= sz1 * (step(0.0, gy1) - 0.5);

vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);

vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
g000 *= norm0.x;
g010 *= norm0.y;
g100 *= norm0.z;
g110 *= norm0.w;
vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
g001 *= norm1.x;
g011 *= norm1.y;
g101 *= norm1.z;
g111 *= norm1.w;

float n000 = dot(g000, Pf0);
float n100 = dot(g100, vec3(Pf1.x, Pf0.yz));
float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z));
float n110 = dot(g110, vec3(Pf1.xy, Pf0.z));
float n001 = dot(g001, vec3(Pf0.xy, Pf1.z));
float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z));
float n011 = dot(g011, vec3(Pf0.x, Pf1.yz));
float n111 = dot(g111, Pf1);

vec3 fade_xyz = fade(Pf0);
vec4 n_z = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), fade_xyz.z);
vec2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x);
return 2.2 * n_xyz;
}

float random (vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898,78.233)))*43758.5453123);
}

void main() {
float rF = randomFactor;
vec3 tweakedPos = position + vec3(random(reference.xy), random(reference.yx * vec2(3.0)), random(reference.xy * vec2(5.0))) * rF;
vec4 mvPosition = modelViewMatrix * vec4(tweakedPos, 1.);

float fF = 3.;
perlin = cnoise(position * (perlinFactor + sin(u_time/3.0)) + vec3(sin(u_time/2.7), cos(u_time/3.6), sin(u_time/5.5)) * fF) + 1.0;

gl_PointSize = smoothstep(0.6, 2.0, perlin) * 5.;

gl_Position = projectionMatrix * mvPosition;
}

All functions above the float random (vec2 st) function are referenced from https://stegu.github.io/webgl-noise/webdemo/, which are for calculating 3D perlin noise. The 2D random function is copied from https://thebookofshaders.com.

The position is literally the 3D coordinate we define for each particle when we initialize the positions array for the BufferAttribute. The reason it’s being tweaked is that you’d see the straight seams between the particles due to the tidy 3D alignment of the particles in the cube; you can easily see that if you tune randomFactor to 0 in the live demo:

But by adding some randomness to the particles, these seams would disappear. I multiplied the randomness values for each axis differently so as to complete get rid of those seams.

Then for calculating the perlin noise, firstly I multiplied the position with (perlinFactor + sin(u_time/3.0)) . So for example if perlinFactor is 2, then the multiplier would bounce between 1 to 3, speed according to how u_time advances or how much you want to speed/slow it down. The higher this multiplier, the more intricate details you can see of the perlin noise patterns in the cube of particles. The second part that’s added to the input of the perlin noise is vec3(sin(u_time/2.7), cos(u_time/3.6), sin(u_time/5.5)) * fF . This is to animate the motion of the perlin noise pattern in the cube. I assigned different speeds for the trigonometric functions such that the animated path is less predictable. Then, adding 1.0 to the result of cnoise is to move its range from [-1..1] to [0..2], although I think the perlin noise algorithm I use produces values over [-1..1] for a few particles(from previous testing results). Finally, the smoothstep is to crop away areas with noise values lower than 0.6, so as to achieve 3D gaps/holes in the visualization.

Fragment shader:

float PI = 3.141592653589793238;
varying float perlin;

void main() {
gl_FragColor = vec4(perlin/2., 0., 1. - perlin/2., .2);
}

As for the fragment shader, I simply colored the particles with red and blue corresponding to the perlin noise value. You could get pretty fancy with coloring the visualization but for the sake of demonstration, I kept this simple and easy to understand.

And that’s the end of this tutorial, hope you learn something new today! Happy coding!

--

--

Franky Hung

Founder of Arkon Digital. I’m not a code fanatic, but I’m always amazed by what code can do. The endless possibilities in coding is what fascinates me everyday.