7 C
New York
Monday, March 10, 2025

Rendering a Procedural Vortex Inside a Glass Sphere with Three.js and TSL


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 )
The wireframe will not be seen right here as a result of excessive vertex depend (512×512), making particular person edges troublesome to tell apart.

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)
} )()
The colour crimson is displayed as a result of we specified 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);
} )()
Within the picture, you’ll be able to see that the middle of the UV coordinates is within the lower-left nook.

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);
The blue coloration clearly signifies the third part we added. Nevertheless, it rapidly disappears as a result of the part decreases infinitely, and the display can not show a coloration worth under 0.
...
const angle = float( log2( size( _uv ) ).negate() ).toVar();
coloration.assign( rotateZ( coloration, angle ) );

return vec4(coloration, 1.0);
Now the UV coordinates type a whirlpool. To regulate the impact, you’ll be able to modify the 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 );
The picture seems in black and white as a result of we’re solely displaying the 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);
Within the picture, you’ll be able to see that the outer edges look like cropped by a sq.. By multiplying _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 ) );
The central half is now illuminated.

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 );
Ultimate 2D implementation of the fragment shader.

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;
Within the picture, you’ll be able to see that the airplane has been remodeled into sq. particles. Nevertheless, some pointless particles stay and must be eliminated.

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;
} )();
Particle geometry after eradicating pointless particles.

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 );
Glass sphere

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

  1. Cut back the variety of particles and their dimension to attenuate overlaps.
  2. Use Storage (WebGPU solely) for improved efficiency.
  3. Exchange the FBM perform with a precomputed noise texture.
  4. 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.
  5. 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! 🙂



Supply hyperlink

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles