Whereas experimenting with particle techniques, I challenged myself to create particles with tails, much like snakes shifting via house. At first, I didn’t have entry to TSL, so I examined fundamental concepts, like utilizing noise derivatives and calculating earlier steps for every particle, however none of them labored as anticipated.
I spent a very long time pondering easy methods to make it work, however all my options concerned heavy testing with WebGL and GPGPU, which appeared like it could require an excessive amount of code for a easy proof of idea. That’s when TSL (Three.js Shader Language) got here into play. With its Compute Shaders, I used to be in a position to compute arrays and feed the outcomes into supplies, making it simpler to check concepts rapidly and effectively. This allowed me to perform the duty with out a lot time misplaced.
Now, let’s dive into the step-by-step strategy of constructing the particle system, from organising the surroundings to creating the paths and attaining that fluid motion.
Step 1: Set Up the Particle System
First, we’ll outline the required uniforms that will probably be used to create and management the particles within the system.
uniforms = {
coloration: uniform( new THREE.Coloration( 0xffffff ).setRGB( 1, 1, 1 ) ),
measurement: uniform( 0.489 ),
uFlowFieldInfluence: uniform( 0.5 ),
uFlowFieldStrength: uniform( 3.043 ),
uFlowFieldFrequency: uniform( 0.207 ),
}
Subsequent, create the variables that may outline the parameters of the particle system. The “tails_count” variable determines what number of segments every snake could have, whereas the “particles_count” defines the overall variety of segments within the scene. The “story_count” variable represents the variety of frames used to retailer the place knowledge for every section. Rising this worth will enhance the space between segments, as we’ll retailer the place historical past of every one. The “story_snake” variable holds the historical past of 1 snake, whereas “full_story_length” shops the historical past for all snakes. These variables will probably be sufficient to carry the idea to life.
tails_count = 7 // n-1 level tails
particles_count = this.tails_count * 200 // want % tails_count
story_count = 5 // story for 1 place
story_snake = this.tails_count * this.story_count
full_story_length = ( this.particles_count / this.tails_count ) * this.story_snake
Subsequent, we have to create the buffers required for the computational shaders. A very powerful buffer to deal with is the “positionStoryBuffer,” which is able to retailer the place historical past of all segments. To grasp the way it works, think about a practice: the top of the practice units the course, and the automobiles comply with in the identical path. By saving the place historical past of the top, we will use that knowledge to find out the place of every automobile by referencing its place within the historical past.
const positionsArray = new Float32Array( this.particles_count * 3 )
const lifeArray = new Float32Array( this.particles_count )
const positionInitBuffer = instancedArray( positionsArray, 'vec3' );
const positionBuffer = instancedArray( positionsArray, 'vec3' );
// Tails
const positionStoryBuffer = instancedArray( new Float32Array( this.particles_count * this.tails_count * this.story_count ), 'vec3' );
const lifeBuffer = instancedArray( lifeArray, 'float' );
Now, let’s create the particle system with a cloth. I selected an ordinary materials as a result of it permits us to make use of an emissiveNode, which is able to work together with Bloom results. For every section, we’ll use a sphere and disable frustum culling to make sure the particles don’t by accident disappear off the display.
const particlesMaterial = new THREE.MeshStandardNodeMaterial( {
metalness: 1.0,
roughness: 0
} );
particlesMaterial.emissiveNode = coloration(0x00ff00)
const sphereGeometry = new THREE.SphereGeometry( 0.1, 32, 32 );
const particlesMesh = this.particlesMesh = new THREE.InstancedMesh( sphereGeometry, particlesMaterial, this.particles_count );
particlesMesh.instanceMatrix.setUsage( THREE.DynamicDrawUsage );
particlesMesh.frustumCulled = false;
this.scene.add( this.particlesMesh )
Step 2: Initialize Particle Positions
To initialize the positions of the particles, we’ll use a computational shader to scale back CPU utilization and pace up web page loading. We randomly generate the particle positions, which kind a pseudo-cube form. To maintain the particles at all times seen on display, we assign them a lifetime after which they disappear and gained’t reappear from their beginning positions. The “cycleStep” helps us assign every snake its personal random positions, guaranteeing the tails are generated in the identical location as the top. Lastly, we ship this knowledge to the computation course of.
const computeInit = this.computeInit = Fn( () => {
const place = positionBuffer.aspect( instanceIndex )
const positionInit = positionInitBuffer.aspect( instanceIndex );
const life = lifeBuffer.aspect( instanceIndex )
// Place
place.xyz = vec3(
hash( instanceIndex.add( uint( Math.random() * 0xffffff ) ) ),
hash( instanceIndex.add( uint( Math.random() * 0xffffff ) ) ),
hash( instanceIndex.add( uint( Math.random() * 0xffffff ) ) )
).sub( 0.5 ).mul( vec3( 5, 5, 5 ) );
// Copy Init
positionInit.assign( place )
const cycleStep = uint( float( instanceIndex ).div( this.tails_count ).ground() )
// Life
const lifeRandom = hash( cycleStep.add( uint( Math.random() * 0xffffff ) ) )
life.assign( lifeRandom )
} )().compute( this.particles_count );
this.renderer.computeAsync( this.computeInit ).then( () => {
this.initialCompute = true
} )

Step 3: Compute Place Historical past
For every body, we compute the place historical past for every section. The important thing side of the “computePositionStory” operate is that new positions are recorded solely from the top of the snake, and all positions are shifted one step ahead utilizing a queue algorithm.
const computePositionStory = this.computePositionStory = Fn( () => {
const positionStory = positionStoryBuffer.aspect( instanceIndex )
const cycleStep = instanceIndex.mod( uint( this.story_snake ) )
const lastPosition = positionBuffer.aspect( uint( float( instanceIndex.div( this.story_snake ) ).ground().mul( this.tails_count ) ) )
If( cycleStep.equal( 0 ), () => { // Head
positionStory.assign( lastPosition )
} )
positionStoryBuffer.aspect( instanceIndex.add( 1 ) ).assign( positionStoryBuffer.aspect( instanceIndex ) )
} )().compute( this.full_story_length );
Step 4: Replace Particle Positions
Subsequent, we replace the positions of all particles, making an allowance for the recorded historical past of their positions. First, we use simplex noise to generate the brand new positions of the particles, permitting our snakes to maneuver easily via house. Every particle additionally has its personal lifetime, throughout which it strikes and finally resets to its unique place. The important thing a part of this operate is figuring out which particle is the top and which is the tail. For the top, we generate a brand new place primarily based on simplex noise, whereas for the tail, we use positions from the saved historical past.
const computeUpdate = this.computeUpdate = Fn( () => {
const place = positionBuffer.aspect( instanceIndex )
const positionInit = positionInitBuffer.aspect( instanceIndex )
const life = lifeBuffer.aspect( instanceIndex );
const _time = time.mul( 0.2 )
const uFlowFieldInfluence = this.uniforms.uFlowFieldInfluence
const uFlowFieldStrength = this.uniforms.uFlowFieldStrength
const uFlowFieldFrequency = this.uniforms.uFlowFieldFrequency
If( life.greaterThanEqual( 1 ), () => {
life.assign( life.mod( 1 ) )
place.assign( positionInit )
} ).Else( () => {
life.addAssign( deltaTime.mul( 0.2 ) )
} )
// Power
const power = simplexNoise4d( vec4( place.mul( 0.2 ), _time.add( 1 ) ) ).toVar()
const affect = uFlowFieldInfluence.sub( 0.5 ).mul( -2.0 ).toVar()
power.assign( smoothstep( affect, 1.0, power ) )
// Stream subject
const flowField = vec3(
simplexNoise4d( vec4( place.mul( uFlowFieldFrequency ).add( 0 ), _time ) ),
simplexNoise4d( vec4( place.mul( uFlowFieldFrequency ).add( 1.0 ), _time ) ),
simplexNoise4d( vec4( place.mul( uFlowFieldFrequency ).add( 2.0 ), _time ) )
).normalize()
const cycleStep = instanceIndex.mod( uint( this.tails_count ) )
If( cycleStep.equal( 0 ), () => { // Head
const newPos = place.add( flowField.mul( deltaTime ).mul( uFlowFieldStrength ) /* * power */ )
place.assign( newPos )
} ).Else( () => { // Tail
const prevTail = positionStoryBuffer.aspect( instanceIndex.mul( this.story_count ) )
place.assign( prevTail )
} )
} )().compute( this.particles_count );
To show the particle positions, we’ll create a easy operate referred to as “positionNode.” This operate won’t solely output the positions but additionally apply a slight magnification impact to the top of the snake.
particlesMaterial.positionNode = Fn( () => {
const place = positionBuffer.aspect( instanceIndex );
const cycleStep = instanceIndex.mod( uint( this.tails_count ) )
const finalSize = this.uniforms.measurement.toVar()
If( cycleStep.equal( 0 ), () => {
finalSize.addAssign( 0.5 )
} )
return positionLocal.mul( finalSize ).add( place )
} )()
The ultimate aspect will probably be to replace the calculations on every body.
async replace( deltaTime ) {
// Compute replace
if( this.initialCompute) {
await this.renderer.computeAsync( this.computePositionStory )
await this.renderer.computeAsync( this.computeUpdate )
}
}
Conclusion
Now, you need to be capable of simply create place historical past buffers for different problem-solving duties, and with TSL, this course of turns into fast and environment friendly. I imagine this challenge has potential for additional growth, corresponding to transferring place knowledge to mannequin bones. This might allow the creation of gorgeous, flying dragons or comparable results in 3D house. For this, a customized bone construction tailor-made to the challenge could be wanted.