In the event you’ve been following the WebGL/Three.js neighborhood, likelihood is you’ve come throughout the good work of @Mister_Prada. We’re thrilled to have him share his experience on Codrops, the place he walks us by way of the method of constructing a mesmerizing procedural vortex!
Impressed by the work of cmzw, I began by making a easy fragment shader and needed to take it additional by turning it right into a volumetric impact inside a glass sphere utilizing TSL (Three.js Shader Language). As I labored on this, I spotted how a lot composition and stability matter, particularly in 3D. At EgorovAgency, I’m at all times studying, and collaborating with 3D artists has helped me perceive learn how to create visuals that really feel proper.
This tutorial walks by way of the entire course of from the fundamental 2D shader to a swirling vortex inside a glass sphere. Let’s begin by establishing the bottom geometry.
Step 1: Making a Airplane for 2D Show
To start, we’d like a primary 2D airplane that may function the inspiration for our procedural vortex. This step includes making a airplane geometry with a excessive vertex depend (512×512) to make sure easy deformations later. We then rotate it to lie flat alongside the XZ airplane and apply a primary materials with a wireframe mode for visualization. Lastly, the airplane is added to the scene, setting the stage for additional transformations.
const planeGeometry = this.planeGeometry = new THREE.PlaneGeometry(
this.uniforms.uResolution.worth.x,
this.uniforms.uResolution.worth.y,
512,
512
)
planeGeometry.rotateX( -Math.PI * 0.5 )
const materials = new THREE.MeshBasicNodeMaterial( {
wireframe: true,
clear: true,
} )
this.planeMesh = new THREE.Mesh( planeGeometry, materials )
this.scene.add( this.planeMesh )

Step 2: Making a Fragment Shader for the Airplane
First, we have to add all the required imports to work with TSL.
import {
sin, positionLocal, time, vec2, vec3, vec4, uv, uniform, coloration, fog, rangeFogFactor,
texture, If, min, vary, instanceIndex, timerDelta, step, timerGlobal,
combine, max, uint, cond, various, varyingProperty, Fn, struct, output, emissive, diffuseColor, PI, PI2,
oneMinus, cos, atan, float, cross, mrt, assign, normalize, mul, log2, size, pow, smoothstep,
screenUV, distance, instancedArray, instancedBufferAttribute, attribute, attributeArray, pointUV,
choose, equals
} from 'three/tsl'
Subsequent, we have to create a perform for colorNode
to start out outputting coloration to the airplane utilizing TSL.
materials.colorNode = Fn( () => {
return vec4( 1, 0, 0, 1)
} )()

vec4( 1, 0, 0, 1)
→ Pink, Inexperienced, Blue, Alpha.Now, we have to show the UV coordinates.
materials.colorNode = Fn( () => {
const _uv = uv();
return vec4(uv.xy, 0, 1);
} )()

Subsequent, we have to transfer the UV coordinate middle to the center of the airplane, making it simpler to govern within the fragment shader. For a sq. airplane, multiplying by 2 and subtracting 1 is adequate. Nevertheless, if you happen to’re working with an oblong airplane—similar to a typical display—you additionally have to multiply uv.y
by the facet ratio.
materials.colorNode = Fn( () => {
const uResolution = this.uniforms.uResolution;
const facet = uResolution.x.div( uResolution.y );
const _uv = uv().mul( 2 ).sub( 1 );
_uv.y.mulAssign( facet );
return vec4(_uv.xy, 0, 1);
} )()
Now, we have to create a vec3()
that features the UV coordinates and a 3rd part, which we’ll use for infinite vector motion. This permits our vortex to maneuver inward alongside the UV coordinates, a way generally seen in Blender Nodes.
...
const coloration = vec3( _uv, 0.0 ).toVar();
coloration.z.addAssign( 0.5 );
coloration.assign( normalize( coloration ) );
coloration.subAssign( mul( this.uniforms.velocity, vec3( 0.0, 0.0, time ) ) );
return vec4(coloration, 1.0);

...
const angle = float( log2( size( _uv ) ).negate() ).toVar();
coloration.assign( rotateZ( coloration, angle ) );
return vec4(coloration, 1.0);

angle
variable.Subsequent, we have to add Fractal Brownian Movement (FBM) noise to the noiseColor
variable.
...
const frequency = this.uniforms.frequency;
const distortion = this.uniforms.distortion;
coloration.x.assign( fbm3d( coloration.mul( frequency ).add( 0.0 ), 5 ).add( distortion ) );
coloration.y.assign( fbm3d( coloration.mul( frequency ).add( 1.0 ), 5 ).add( distortion ) );
coloration.z.assign( fbm3d( coloration.mul( frequency ).add( 2.0 ), 5 ).add( distortion ) );
const noiseColor = coloration.toVar();
return vec4(coloration, 1.0);
Now, let’s isolate the middle and improve it with an emission impact.
...
noiseColor.mulAssign( 2 );
noiseColor.subAssign( 0.1 );
noiseColor.mulAssign( 0.188 );
noiseColor.addAssign( vec3(_uv.xy, 0 ) );
const noiseColorLength = size( noiseColor );
noiseColorLength.assign( float( 0.770 ).sub( noiseColorLength ) );
noiseColorLength.mulAssign( 4.2 );
noiseColorLength.assign( pow( noiseColorLength, 1.0 ) );
return vec4( vec3(noiseColorLength), 1 );

noiseColorLength
float part.Now, let’s spotlight the outer edges.
...
const fac = size( _uv ).sub( facture( coloration.add( 0.32 ) ) );
fac.addAssign( 0.1 );
fac.mulAssign( 3.0 );
return vec4( vec3(fac), 1);

_uv
by a selected worth, your complete picture will be shrunk towards the middle, with the clear half eradicating any extra.Now, let’s create a glow impact within the middle.
const emissionColor = emission( this.uniforms.emissionColor, noiseColorLength.mul( this.uniforms.emissionMultiplier ) );

Subsequent, we mix the whole lot right into a single coloration.
...
coloration.assign( combine( emissionColor, vec3( fac ), fac.add( 1.2 ) ) );
return vec4( coloration, 1 );

Lastly, we add an alpha worth to take away pointless elements.
const alpha = float( 1 ).sub( fac );
return vec4( coloration, alpha );

Step 3: Altering the Geometry Place Primarily based on the Texture
We separate the feel code right into a devoted perform that accepts uv
as an enter parameter. It’s additionally vital to outline a various
variable, since we’ll name the feel code contained in the vertex shader. By passing this variable to the fragment shader, we keep away from redundant texture rendering and may entry its coloration immediately.
// Varyings
varyings = {
vSwirl: various( vec4( 0 ), 'vSwirl' )
}
this.swirlTexture = Fn( ( params ) => {
const _uv = params.uv.mul( 1 );
...
// Assign to various
this.varyings.vSwirl.assign( coloration );
return vec4( noiseColor, alpha );
} )
Since we’re utilizing FBM noise, which features a Z part, we are able to combine our texture with the geometry’s place. We add the feel information to positionLocal
, whereas the remaining changes are for refining the looks. Be certain to orient the geometry horizontally in order that the Y-axis behaves appropriately contained in the shader.
...
planeGeometry.rotateX( -Math.PI * 0.5 ); // Align to ground floor
materials.positionNode = Fn( () => {
const uResolution = this.uniforms.uResolution;
const facet = uResolution.x.div( uResolution.y );
const _uv = uv().mul( 2 ).sub( 1 );
_uv.y.mulAssign( facet );
_uv.mulAssign( 1.1 );
const swirl = this.swirlTexture( { uv: _uv } );
const finalPosition = positionLocal;
finalPosition.y.addAssign( swirl.g.mul( 0.9 ) );
return finalPosition;
} )();
Step 4: Changing the Airplane to Particles
Now, we are able to take away the airplane from the scene and substitute it with particles. We create two buffers for place and UV coordinates, extracted from planeGeometry
. Then, we outline a brand new perform for positionNode
, which can make the most of the feel we created earlier and cross the uvA
coordinates into it.
const positionAttribute = new THREE.InstancedBufferAttribute( new Float32Array( this.planeGeometry.attributes.place.array ), 3 );
const pos = instancedBufferAttribute( positionAttribute );
const uvAttribute = new THREE.InstancedBufferAttribute( new Float32Array( this.planeGeometry.attributes.uv.array ), 2 );
const uvA = instancedBufferAttribute( uvAttribute );
const particleMaterial = new THREE.SpriteNodeMaterial( {} );
particleMaterial.positionNode = Fn( () => {
const uResolution = this.uniforms.uResolution;
const facet = uResolution.x.div( uResolution.y );
const _uv = uvA.mul( 2 ).sub( 1 );
_uv.y.mulAssign( facet );
const swirl = this.swirlTexture( { uv: _uv } );
const finalPosition = pos.toVar();
finalPosition.y.addAssign( swirl.g );
return finalPosition;
} )();
particleMaterial.scaleNode = this.uniforms.dimension;
const particlesMesh = this.particlesMesh = new THREE.Mesh( new THREE.PlaneGeometry( 1, 1 ), particleMaterial );
particlesMesh.depend = this.planeGeometry.attributes.place.depend;
particlesMesh.frustumCulled = false;

Let’s add a situation that removes pointless particles from the digital camera’s view primarily based on the alpha channel of the feel.
particleMaterial.positionNode = Fn( () => {
...
If( swirl.a.lessThan( this.uniforms.radius ), () => {
finalPosition.xyz.assign( vec3( 99999999 ) );
} );
return finalPosition;
} )();

Now, let’s add coloration to our vortex. We’ll retailer the colour individually in a texture, because it differs barely from the one used for the particle vertices.
this.swirlTexture = Fn( ( params ) => {
...
// Assign coloration to various
this.varyings.vSwirl.assign( coloration );
...
} );
particleMaterial.colorNode = Fn( () => {
return this.varyings.vSwirl;
} )();
Step 4: Creating the Glass Sphere
We begin by creating a regular sphere and making use of MeshPhysicalNodeMaterial
to it. This materials permits us to create a sensible glass impact in Three.js. The required parameters have already been predefined and added to the uniforms.
uniforms = {
coloration: uniform( new THREE.Colour( 0xffffff ) ),
metalness: uniform( 0.0 ),
roughness: uniform( 0 ),
ior: uniform( 1.5 ),
thickness: uniform( 0.3 ),
clearcoat: uniform( 0.73 ),
dispersion: uniform( 5.0 ),
attenuationColor: uniform( new THREE.Colour( 0xffffff ) ),
attenuationDistance: uniform( 1 ),
//alphaMap: texture,
//envMap: hdrEquirect,
envMapIntensity: uniform( 1 ),
transmission: uniform( 1 ),
specularIntensity: uniform( 1 ),
specularColor: uniform( new THREE.Colour( 0xffffff ) ),
opacity: uniform( 1 ),
facet: THREE.DoubleSide,
clear: true
};
const sphereGeometry = new THREE.SphereGeometry( 2.3, 32, 32 );
const sphereMaterial = this.sphereMaterial = new THREE.MeshPhysicalNodeMaterial( {
coloration: this.uniforms.coloration.worth,
metalness: this.uniforms.metalness.worth,
roughness: this.uniforms.roughness.worth,
ior: this.uniforms.ior.worth,
dispersion: this.uniforms.dispersion.worth,
thickness: this.uniforms.thickness.worth,
clearcoat: this.uniforms.clearcoat.worth,
//alphaMap: texture,
//envMap: hdrEquirect,
envMapIntensity: this.uniforms.envMapIntensity.worth,
transmission: this.uniforms.transmission.worth,
specularIntensity: this.uniforms.specularIntensity.worth,
specularColor: this.uniforms.specularColor.worth,
opacity: this.uniforms.opacity.worth,
facet: THREE.DoubleSide,
clear: false,
});
const sphereMesh = new THREE.Mesh( sphereGeometry, sphereMaterial );

You’ll have seen that the sphere nonetheless appears considerably incomplete. To boost its look, we’ll add an EnvironmentMap
—ideally one that includes stars ⭐—to present it a extra immersive and life like look.
const hdriTexture = this.sources.objects.hdriTexture;
hdriTexture.mapping = THREE.EquirectangularReflectionMapping;
this.scene.setting = hdriTexture;
Step 5: Ultimate Changes
Now, let’s add the vortex inside our scene and fine-tune the parameters to attain the specified impact.
Suggestions for Optimization
- Cut back the variety of particles and their dimension to attenuate overlaps.
- Use Storage (WebGPU solely) for improved efficiency.
- Exchange the FBM perform with a precomputed noise texture.
- Think about using a lower-polygon form, like a dice, as an alternative of the glass sphere, and apply normals to create attention-grabbing inside distortions.
- Pre-render the vortex texture and easily rotate the geometry inside, which might considerably enhance efficiency.
In the event you’re feeling experimental, you possibly can strive making a sphere with cutouts, including god rays inside, and surrounding it with fog. I haven’t tried this myself, but it surely sounds prefer it may look actually cool! 🙂