PN
Paweł Nosko
Dev Blog · since 2009
← Back to JS & Frontend Tools

How to Create an Interactive Energy Sphere with Three.js – Complete JavaScript, WebGL & GLSL Tutorial

In this tutorial, we will build an interactive energy sphere featuring animated plasma, branching lightning bolts, glowing particles, and advanced lighting effects. The entire animation runs directly in the browser usin…

We will not use pre-built 3D models, videos, or external texture files. The plasma is generated procedurally by the GPU, while the lightning geometry is created dynamically using JavaScript.

The complete project fits into a single file named energy_sphere.html , containing approximately 266 lines of code.

We will examine the project from beginning to end, explaining how each component works and how the different systems interact. By combining the main code blocks in the order presented, you will have a complete, working animation.

 

See demo

 

1. Project Overview

Our energy sphere consists of several interconnected systems:

  • Plasma core – an animated GLSL shader generating colorful, irregular energy patterns.

  • 30 standard lightning bolts – randomly generated electrical discharges with multiple branches.

  • 10 additional lightning bolts – electrical discharges that respond to the mouse cursor.

  • 1,400 particles – tiny sparks emitted from the sphere's surface.

  • Procedural nebula – a subtle animated background.

  • Halo and Bloom – lighting effects that enhance the sphere's glow and brighten its most intense areas.

  • User interaction – the sphere can be rotated using the mouse or touch gestures.

Everything is animated in real time. Each newly generated lightning bolt can have a different shape, while the plasma surface continuously changes its appearance.

The interesting part is that we are not playing a predefined animation. We are generating the visual elements using algorithms.

 

2. Preparing the HTML Document and Libraries

Let's begin by creating a basic HTML document.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Energy Sphere</title>
<style>
  :root { box-sizing: border-box; --bg:#000; --fg:#7fb8ff; }
  html, body { margin:0; height:100%; background:#000; overflow:hidden; }
  canvas { display:block; touch-action:none; }
  #hint { position:fixed; left:0; right:0; bottom:calc(14px + env(safe-area-inset-bottom,0px)); text-align:center;
    font:12px/1.2 system-ui,sans-serif; color:var(--fg); opacity:.55; pointer-events:none; transition:opacity 1.5s; }
</style>
</head>
<body>
<div id="hint">Drag to rotate · lightning strikes the cursor</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/This email address is being protected from spambots. You need JavaScript enabled to view it..0/examples/js/shaders/CopyShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/This email address is being protected from spambots. You need JavaScript enabled to view it..0/examples/js/shaders/LuminosityHighPassShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/This email address is being protected from spambots. You need JavaScript enabled to view it..0/examples/js/postprocessing/EffectComposer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/This email address is being protected from spambots. You need JavaScript enabled to view it..0/examples/js/postprocessing/MaskPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/This email address is being protected from spambots. You need JavaScript enabled to view it..0/examples/js/postprocessing/RenderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/This email address is being protected from spambots. You need JavaScript enabled to view it..0/examples/js/postprocessing/ShaderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/This email address is being protected from spambots. You need JavaScript enabled to view it..0/examples/js/postprocessing/UnrealBloomPass.js"></script>
<script>

 

How it works

The CSS sets a black background and prevents the page from scrolling. The canvas element, which Three.js will create shortly, will occupy the entire browser window.

The touch-action: none property allows us to handle touch gestures without triggering the browser's default scrolling behavior.

The #hint element displays a short instruction explaining how to interact with the sphere. It will automatically disappear after a few seconds.

We are using Three.js r128 (0.128.0) . The additional scripts provide the postprocessing functionality, particularly UnrealBloomPass .

Notice that we are loading Three.js through traditional <script> tags. This approach does not require ES Modules, Node.js, npm, or any build tools.

You can create the project in a simple text editor and run it directly in your browser.

 

3. Configuring the Animation and Creating the Scene

The next section defines our basic settings and initializes the WebGL renderer.

const R = 2.2;
const CURSOR_ACTION = true; // true = lightning strikes the mouse cursor
const NC = CURSOR_ACTION ? 10 : 0; // number of lightning bolts targeting the cursor
let cursorT = null;
const PAL = [new THREE.Color(0.15,0.55,1), new THREE.Color(0.62,0.28,1), new THREE.Color(0.12,1,0.58)];
const WHITE = new THREE.Color(1,1,1);
const rnd = (a,b)=>a+Math.random()*(b-a);
const rv = ()=>{ const u=Math.random()*2-1, th=Math.random()*6.2832, s=Math.sqrt(1-u*u); return new THREE.Vector3(s*Math.cos(th),u,s*Math.sin(th)); };

const renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(55, innerWidth/innerHeight, .1, 200);
camera.position.z = 13;
const group = new THREE.Group(); scene.add(group);

 

How it works

The most important settings are:

Parameter Description
R = 2.2 Radius of the sphere
CURSOR_ACTION Enables lightning interaction with the cursor
NC = 10 Number of additional cursor-targeting lightning bolts
PAL Three colors used by lightning, sparks, and halo
camera.position.z = 13 Camera distance from the center of the scene

The rnd(a,b) function returns a random number within the specified range.

The rv() function is more interesting. It generates a random unit vector in three-dimensional space.

We will use it when creating lightning bolts and calculating the movement directions of particles.

WebGLRenderer handles rendering the scene. We limit devicePixelRatio to 2 to avoid placing unnecessary pressure on the GPU, especially on devices with extremely high pixel densities.

Finally, we create a THREE.Group .

This group acts as a container for our 3D objects, allowing us to rotate the plasma core, particles, and standard lightning bolts together.

 

4. Procedural Noise – The Foundation of Our Plasma Animation

Now we move to GLSL.

Instead of loading a texture, we will generate our own procedural noise. The same functions will be used by both the sphere's surface and its background.

const NOISE = `
float hash(vec3 p){p=fract(p*0.3183099+.1);p*=17.0;return fract(p.x*p.y*p.z*(p.x+p.y+p.z));}
float noise(vec3 x){vec3 i=floor(x),f=fract(x);f=f*f*(3.-2.*f);
 return mix(mix(mix(hash(i),hash(i+vec3(1,0,0)),f.x),mix(hash(i+vec3(0,1,0)),hash(i+vec3(1,1,0)),f.x),f.y),
            mix(mix(hash(i+vec3(0,0,1)),hash(i+vec3(1,0,1)),f.x),mix(hash(i+vec3(0,1,1)),hash(i+vec3(1,1,1)),f.x),f.y),f.z);}
float fbm(vec3 p){float a=.5,s=0.;for(int i=0;i<5;i++){s+=a*noise(p);p=p*2.03+vec3(1.7,9.2,3.1);a*=.5;}return s;}
`;

 

How it works

We have three functions.

hash() converts spatial coordinates into a pseudorandom value.

noise() interpolates these values between neighboring points of a 3D grid. This produces smooth transitions rather than abrupt jumps between random values.

fbm() stands for Fractional Brownian Motion . It combines five layers of noise with increasing frequencies and decreasing amplitudes.

During each iteration, we:

  • Increase the noise frequency by multiplying the coordinates by 2.03 .

  • Offset the sampling position using an additional vector.

  • Reduce the contribution of the next layer by applying a *= .5 .

The result is an irregular, multi-layered structure.

This is the foundation of our plasma effect. Instead of displaying a flat image, the shader will calculate organic patterns that continuously change over time.

 

5. The Plasma Core Shader – Generating Living Energy

Next, we create a THREE.ShaderMaterial  , allowing us to define exactly how the surface of our sphere should look.

// Core: fractal plasma
const coreMat = new THREE.ShaderMaterial({
  uniforms:{t:{value:0}},
  vertexShader:`varying vec3 vN,vP; void main(){vN=normalize(normalMatrix*normal);vP=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}`,
  fragmentShader:`varying vec3 vN,vP; uniform float t;`+NOISE+`
  vec3 pal(float x){x=fract(x)*3.;vec3 b=vec3(.15,.6,1.),pu=vec3(.7,.3,1.),g=vec3(.15,1.,.6);
    vec3 c=mix(b,pu,smoothstep(0.,1.,x));c=mix(c,g,smoothstep(1.,2.,x));return mix(c,b,smoothstep(2.,3.,x));}
  void main(){
    vec3 p=normalize(vP)*2.4;
    float n1=fbm(p+vec3(0.,t*.35,t*.2)), n2=fbm(p*1.7-vec3(t*.3,0.,t*.25)+5.);
    float v=pow(1.-abs(n1*2.-1.),5.), v2=pow(1.-abs(n2*2.-1.),6.);
    float h=fbm(p*.9+vec3(t*.12,-t*.1,t*.08)+11.);
    vec3 col=pal(h*1.6+n2*.6+t*.06);
    vec3 col2=pal(h*1.6+n2*.6+t*.06+.5);
    float f=1.-abs(vN.z);
    float sh=.5+.5*sin(t*2.+n1*9.);
    vec3 body=col*(.25+.6*n1)*(.8+.4*sh);
    vec3 veins=(v+v2)*1.1*col2;
    vec3 rim=pow(f,2.2)*col*1.2;
    vec3 c=body+veins+rim;
    c*=.75; c=max(vec3(0.),mix(vec3(dot(c,vec3(.333))),c,1.6)); c/=max(1.,max(c.r,max(c.g,c.b)));
    gl_FragColor=vec4(c,1.);
  }`
});
group.add(new THREE.Mesh(new THREE.SphereGeometry(R,64,48), coreMat));

 

How it works

The material uses two types of shaders.

The vertex shader processes the geometry's vertices. It also passes the surface coordinates ( vP ) and normals ( vN ) to the fragment shader.

The fragment shader calculates the surface color based on spatial coordinates and the current animation time.

The most important variable is t , passed through uniforms . We will update its value during every animation frame.

Let's examine the two main noise fields:

float n1=fbm(p+vec3(0.,t*.35,t*.2)),
      n2=fbm(p*1.7-vec3(t*.3,0.,t*.25)+5.);

Both change in different directions and at different speeds.

This creates overlapping plasma structures instead of a single, repetitive pattern.

Another mechanism produces the bright energy veins:

float v=pow(1.-abs(n1*2.-1.),5.);
float v2=pow(1.-abs(n2*2.-1.),6.);

 

The power functions emphasize specific regions of the noise field, producing narrower and more visible energy bands.

The pal() function handles smooth transitions between blue, purple, and green.

Meanwhile:

vec3 rim=pow(f,2.2)*col*1.2;

 

creates additional illumination around the sphere's edges.

Finally, we combine three components:

body + veins + rim

These represent the base plasma, the bright energy veins, and the illuminated rim.

The material is applied to a sphere with radius R , using 64 horizontal and 48 vertical segments.

 

6. Procedural Background – An Animated Nebula

The background is not an image either. We generate it using another shader.

// Background: nebula
const bgMat = new THREE.ShaderMaterial({
  side:THREE.BackSide, depthWrite:false, uniforms:{t:{value:0}},
  vertexShader:`varying vec3 vP; void main(){vP=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}`,
  fragmentShader:`varying vec3 vP; uniform float t;`+NOISE+`
  void main(){
    vec3 d=normalize(vP);
    float m=smoothstep(.4,.85,fbm(d*3.+t*.03));
    vec3 c=mix(vec3(0.,.3,.3),vec3(.15,0.,.4),fbm(d*2.+7.));
    gl_FragColor=vec4(c*m*.35,1.);
  }`
});
scene.add(new THREE.Mesh(new THREE.SphereGeometry(80,32,24), bgMat));

How it works

We create a large sphere with a radius of 80 units, surrounding the camera.

Setting THREE.BackSide makes its interior surface visible.

Inside the fragment shader, we use fbm() again, but this time with a much slower animation:

t*.03

The variable m controls the nebula's intensity.

The mix() function blends two colors: turquoise and purple.

Finally, we multiply the result by 0.35 , keeping the background subtle enough that it does not distract from the main sphere.

 

7. Generating the Glow Texture

Our lightning bolts and particles should resemble actual sources of light.

To achieve this, we generate a soft radial texture.

// Soft glow texture
function glowTex(){
  const c=document.createElement('canvas'); c.width=c.height=128; const x=c.getContext('2d');
  const g=x.createRadialGradient(64,64,0,64,64,64);
  g.addColorStop(0,'rgba(255,255,255,1)'); g.addColorStop(.3,'rgba(255,255,255,.35)'); g.addColorStop(1,'rgba(255,255,255,0)');
  x.fillStyle=g; x.fillRect(0,0,128,128); return new THREE.CanvasTexture(c);
}
const GT = glowTex();
const halo = new THREE.Sprite(new THREE.SpriteMaterial({map:GT,blending:THREE.AdditiveBlending,transparent:true,depthWrite:false,opacity:.6}));
halo.scale.set(10,10,1); scene.add(halo);
const curGlow = new THREE.Sprite(new THREE.SpriteMaterial({map:GT,blending:THREE.AdditiveBlending,transparent:true,depthWrite:false,opacity:0}));
curGlow.scale.set(1.6,1.6,1); scene.add(curGlow);

How it works

The glowTex() function creates an auxiliary canvas measuring 128 × 128 pixels.

Using createRadialGradient() , we generate a gradient with a bright white center that gradually fades into complete transparency.

The canvas is then converted into a THREE.CanvasTexture .

No external PNG file is required.

Next, we create two sprites.

halo is the large glow surrounding the sphere.

curGlow is smaller and initially invisible. It appears near the point targeted by the cursor-directed lightning bolts.

Both use THREE.AdditiveBlending .

Additive blending makes overlapping light sources appear brighter, helping us create an energetic, luminous effect.

 

8. Building the Lightning System

Now we reach one of the most important parts of the project.

First, we need to prepare the geometry and materials used by the electrical discharges.

// Lightning bolts (fractal branching)
const MAXSEG=1200, MAXNODE=120, NB=30;
const bolts=[];
for(let i=0;i<NB+NC;i++){
  const g=new THREE.BufferGeometry();
  g.setAttribute('position',new THREE.BufferAttribute(new Float32Array(MAXSEG*6),3));
  g.setAttribute('color',new THREE.BufferAttribute(new Float32Array(MAXSEG*6),3));
  g.setDrawRange(0,0);
  const m=new THREE.LineBasicMaterial({vertexColors:true,transparent:true,blending:THREE.AdditiveBlending,depthWrite:false});
  const line=new THREE.LineSegments(g,m); line.frustumCulled=false;
  const gp=new THREE.BufferGeometry();
  gp.setAttribute('position',new THREE.BufferAttribute(new Float32Array(MAXNODE*3),3));
  gp.setAttribute('color',new THREE.BufferAttribute(new Float32Array(MAXNODE*3),3));
  gp.setDrawRange(0,0);
  const pm=new THREE.PointsMaterial({size:.22,map:GT,vertexColors:true,transparent:true,blending:THREE.AdditiveBlending,depthWrite:false});
  const pts=new THREE.Points(gp,pm); pts.frustumCulled=false;
  (i<NB?group:scene).add(line,pts);
  bolts.push({g,m,gp,pm,count:0,nc:0,age:-rnd(0,1.5),life:1,fresh:true,cur:i>=NB});
}

How it works

Our project uses two types of lightning.

Standard lightning bolts are generated around the sphere and rotate together with it.

Additional lightning bolts are placed directly in the scene and can be directed toward the cursor.

For each lightning bolt, we create two objects.

THREE.LineSegments contains the actual lightning geometry, consisting of multiple line segments.

THREE.Points renders glowing points at selected locations, including branching points.

An important role is played by BufferGeometry .

Instead of repeatedly creating new graphical objects, we allocate buffers into which we can write updated lightning coordinates.

MAXSEG = 1200 specifies that each lightning buffer can accommodate up to 1,200 segments.

Each segment consists of two points in 3D space. Since each point has three coordinates, we allocate MAXSEG * 6 numeric values.

Using setDrawRange() , we can control how many segments are currently visible.

We will use this feature to animate the lightning bolts as they grow outward from the sphere.

 

9. The Lightning Generation Algorithm – Recursive Branching

Now we come to one of the most interesting pieces of JavaScript in the entire project.

The gen() function creates a new lightning bolt, while its internal recursive trace()  function generates individual segments and branches.

function gen(b){
  const segs=[], nodes=[];
  const tgt=(b.cur&&cursorT)?cursorT.clone():null;
  const d0=tgt?tgt.clone().normalize().add(rv().multiplyScalar(rnd(.1,.55))).normalize():rv(), c1=PAL[(Math.random()*3)|0], c2=PAL[(Math.random()*3)|0];
  const total=tgt?tgt.length()*1.6+3:rnd(4,9);
  function trace(p,d,len,depth,dist){
    let pos=p.clone(), dir=d.clone(), L=0;
    const dr=rv().multiplyScalar(.5);
    while(L<len && segs.length<MAXSEG){
      dir.addScaledVector(dr,.08).add(rv().multiplyScalar(.32-depth*.05)).normalize();
      if(tgt&&depth===0){
        const to=tgt.clone().sub(pos), dl=to.length();
        if(dl<.25){
          segs.push({a:pos,b:tgt.clone(),t0:(dist+L)/total,t1:(dist+L+dl)/total,d:dist+L,dep:0});
          if(nodes.length<MAXNODE)nodes.push({p:tgt.clone(),t:1});
          for(let k=0;k<3;k++)trace(tgt.clone(),rv(),rnd(.5,1.2),2,dist+L);
          break;
        }
        dir.addScaledVector(to.normalize(),.45).normalize();
      }
      const sl=rnd(.12,.26), nx=pos.clone().addScaledVector(dir,sl);
      segs.push({a:pos,b:nx,t0:(dist+L)/total,t1:(dist+L+sl)/total,d:dist+L,dep:depth});
      L+=sl;
      if(depth<4 && Math.random()<.15-depth*.02){
        if(nodes.length<MAXNODE) nodes.push({p:nx,t:(dist+L)/total});
        trace(nx,dir.clone().addScaledVector(rv(),1.0).normalize(),rnd(.8,2.6)/(1+depth*.5),depth+1,dist+L);
      }
      pos=nx;
    }
  }
  trace(d0.clone().multiplyScalar(R*.97),d0,total,0,0);
  segs.sort((x,y)=>x.d-y.d);
  const P=b.g.attributes.position.array, C=b.g.attributes.color.array, tmp=new THREE.Color();
  const col=(t,dep,out,o)=>{
    t=Math.min(1,t);
    tmp.copy(c1).lerp(c2,t).lerp(WHITE,.12*(1-t));
    const k=(1-t*.55)*(dep?.85/(1+dep*.4):1.3);
    out[o]=tmp.r*k; out[o+1]=tmp.g*k; out[o+2]=tmp.b*k;
  };
  segs.forEach((s,i)=>{
    const o=i*6;
    P[o]=s.a.x;P[o+1]=s.a.y;P[o+2]=s.a.z;P[o+3]=s.b.x;P[o+4]=s.b.y;P[o+5]=s.b.z;
    col(s.t0,s.dep,C,o); col(s.t1,s.dep,C,o+3);
  });
  const NP=b.gp.attributes.position.array, NC=b.gp.attributes.color.array;
  nodes.forEach((n,i)=>{
    NP[i*3]=n.p.x;NP[i*3+1]=n.p.y;NP[i*3+2]=n.p.z;
    col(n.t,0,NC,i*3);
  });
  b.g.attributes.position.needsUpdate=b.g.attributes.color.needsUpdate=true;
  b.gp.attributes.position.needsUpdate=b.gp.attributes.color.needsUpdate=true;
  b.count=segs.length; b.nc=nodes.length;
  b.life=b.cur?rnd(.25,.6):rnd(.5,1.4); b.age=b.fresh?b.age:-rnd(0,.3); b.fresh=false;
}

 

How it works

The algorithm begins by selecting a starting position on the sphere's surface and calculating an initial direction.

For standard lightning bolts, the direction is completely random.

For cursor-targeting lightning bolts, we also take the direction toward the target into account.

How is a lightning bolt generated?

The trace() function starts with a position p , a direction d , and the desired length len .

It then enters a loop, moving the current position forward in short steps.

const sl=rnd(.12,.26),
      nx=pos.clone().addScaledVector(dir,sl);

 

Each step has a random length between 0.12 and 0.26 units.

The direction is also modified randomly, preventing the lightning from forming a perfectly straight line.

How are branches created?

Branches are generated through recursive calls to trace() :

trace(
  nx,
  dir.clone().addScaledVector(rv(),1.0).normalize(),
  rnd(.8,2.6)/(1+depth*.5),
  depth+1,
  dist+L
);

A new branch starts at the current point, receives a modified direction, and is assigned its own length.

The depth parameter describes the branching level.

The maximum recursion depth is limited to 4.

The probability of creating another branch decreases with depth. This helps keep the main lightning structure visible while preventing smaller branches from overwhelming the entire effect.

How does lightning target the cursor?

For lightning bolts with a target position stored in tgt , the main branch gradually adjusts its direction.

dir.addScaledVector(to.normalize(),.45).normalize();

This is an important detail.

We do not draw a rigid, straight line between the sphere and the mouse cursor.

The algorithm continues introducing random deviations, creating a more natural electrical discharge while still moving toward the target.

When the lightning gets sufficiently close, a final segment connects it to the target position.

Coloring the lightning

Each bolt receives two random colors selected from the PAL array.

The col() function interpolates between them along the lightning's length, while secondary branches receive reduced brightness.

Finally, the calculated positions and colors are written into BufferGeometry .

We do not create a new LineSegments object every time a lightning bolt is generated. Instead, we update the existing geometry.

 

10. Particle System – Sparks Emitted from the Sphere

Our next component is the particle system.

// Sparks
const NPART=1400, pPos=new Float32Array(NPART*3), pCol=new Float32Array(NPART*3), pBase=new Float32Array(NPART*3), pVel=new Float32Array(NPART*3), pLife=new Float32Array(NPART);
function resetP(i){
  const d=rv(), v=d.clone().multiplyScalar(rnd(.3,1.6)).add(rv().multiplyScalar(.3)), c=PAL[(Math.random()*3)|0];
  pPos.set([d.x*R,d.y*R,d.z*R],i*3); pVel.set([v.x,v.y,v.z],i*3);
  const k=rnd(.5,1.2); pBase.set([c.r*k,c.g*k,c.b*k],i*3); pLife[i]=rnd(.8,3);
}
for(let i=0;i<NPART;i++){ resetP(i); pLife[i]*=Math.random(); }
const pg=new THREE.BufferGeometry();
pg.setAttribute('position',new THREE.BufferAttribute(pPos,3));
pg.setAttribute('color',new THREE.BufferAttribute(pCol,3));
const parts=new THREE.Points(pg,new THREE.PointsMaterial({size:.07,map:GT,vertexColors:true,transparent:true,blending:THREE.AdditiveBlending,depthWrite:false}));
parts.frustumCulled=false; group.add(parts);

How it works

We create 1,400 particles, each with its own properties.

  • pPos stores the particle's position.

  • pCol stores its current color.

  • pBase stores its initial color.

  • pVel stores its velocity.

  • pLife stores its remaining lifetime.

The resetP() function generates a random starting position on the sphere's surface, a movement direction, a color, and a lifetime.

Each particle normally receives a lifetime between 0.8 and 3 seconds, although the initial lifetimes are randomized further to distribute the particles across different stages of their animation.

All particles are rendered through a single THREE.Points object.

This is much more practical than creating 1,400 separate 3D meshes.

 

11. Bloom – Enhancing the Lighting

We now have plasma, lightning bolts, and particles.

Let's add postprocessing.

// Bloom
let composer=null;
try{
  composer=new THREE.EffectComposer(renderer);
  composer.addPass(new THREE.RenderPass(scene,camera));
  composer.addPass(new THREE.UnrealBloomPass(new THREE.Vector2(innerWidth,innerHeight),1.1,.7,.35));
}catch(e){ composer=null; }

How it works

EffectComposer allows us to apply additional processing operations to the rendered image.

The first pass, RenderPass , renders the regular scene.

The second pass, UnrealBloomPass , adds a glowing appearance to its brightest areas.

The parameters used in our project are:

Parameter Value
Strength 1.1
Radius 0.7
Threshold 0.35

This gives the lightning bolts additional brightness and makes the sphere appear more energetic.

The initialization is wrapped inside a try...catch block.

If the composer cannot be initialized, the application can fall back to rendering the scene without Bloom.

 

12. Mouse Controls and Calculating the Cursor Position in 3D

Next, we need to make our sphere interactive.

// Rotation using mouse / touch
let rx=0, ry=0, drag=false, px=0, py=0;
addEventListener('pointerdown',e=>{setCursor(e);drag=true;px=e.clientX;py=e.clientY;document.getElementById('hint').style.opacity=0;});
addEventListener('pointerup',e=>{drag=false;if(e.pointerType==='touch')cursorT=null;});
addEventListener('pointermove',setCursor);
document.documentElement.addEventListener('mouseleave',()=>{cursorT=null;});
const ndcV=new THREE.Vector3();
function setCursor(e){
  if(!CURSOR_ACTION)return;
  camera.updateMatrixWorld();
  ndcV.set(e.clientX/innerWidth*2-1,-(e.clientY/innerHeight)*2+1,.5).unproject(camera);
  const d=ndcV.sub(camera.position).normalize(), o=camera.position;
  const bb=o.dot(d), cc=o.dot(o)-R*R, disc=bb*bb-cc;
  cursorT = disc>0 ? o.clone().addScaledVector(d,-bb-Math.sqrt(disc)).multiplyScalar(1.04)
                   : o.clone().addScaledVector(d,-o.z/d.z);
}
addEventListener('pointermove',e=>{ if(!drag)return; ry+=(e.clientX-px)*.006; rx=Math.max(-1.2,Math.min(1.2,rx+(e.clientY-py)*.006)); px=e.clientX; py=e.clientY; });
addEventListener('resize',()=>{
  camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix();
  renderer.setSize(innerWidth,innerHeight); if(composer) composer.setSize(innerWidth,innerHeight);
});

How it works

We use the pointerdown , pointermove , and pointerup events, which support both mouse and touch input.

When the user drags, we update two rotation angles: rx and ry .

The vertical rotation is limited to the range from -1.2 to 1.2 radians.

The more interesting part is the setCursor() function.

The mouse cursor exists in a two-dimensional coordinate system, but our lightning bolts move through three-dimensional space.

We therefore need to convert the cursor's screen position into a point in our 3D scene.

We accomplish this using:

ndcV.set(
  e.clientX/innerWidth*2-1,
  -(e.clientY/innerHeight)*2+1,
  .5
).unproject(camera);

The unproject() method converts normalized device coordinates back into a position in 3D space using the camera's projection.

Next, we calculate a ray originating from the camera and check whether it intersects the mathematical surface of the sphere.

If an intersection exists, we obtain the point where the ray meets the sphere.

Otherwise, we calculate a target point on the z = 0 plane.

The result is stored in cursorT .

This variable is later used by the lightning generation algorithm.

Finally, we also handle browser resizing by updating the camera's aspect ratio and the renderer's dimensions.

 

13. The Main Animation Loop

We have reached the final major JavaScript section.

This function connects all previously created systems and updates them during every animation frame.

let time=0, last=performance.now();
const hc=new THREE.Color();
function frame(now){
  requestAnimationFrame(frame);
  const dt=Math.min(.05,(now-last)/1000); last=now; time+=dt;
  coreMat.uniforms.t.value=time; bgMat.uniforms.t.value=time;
  group.rotation.y=ry+time*.08; group.rotation.x=rx;

  for(const b of bolts){
    if(b.cur&&!cursorT){b.g.setDrawRange(0,0);b.gp.setDrawRange(0,0);b.age=-rnd(0,.2);b.fresh=true;continue;}
    b.age+=dt;
    if(b.age<0){ b.g.setDrawRange(0,0); b.gp.setDrawRange(0,0); continue; }
    if(b.age>=b.life || b.fresh){ gen(b); if(b.age>=0&&b.age>=b.life)b.age=0; }
    const t=b.age/b.life, grow=Math.min(1,t/.25), e=1-Math.pow(1-grow,2);
    const fade=t<.25?1:1-(t-.25)/.75, fl=.7+Math.random()*.3;
    b.m.opacity=b.pm.opacity=fade*fl;
    b.g.setDrawRange(0,Math.floor(b.count*e)*2);
    b.gp.setDrawRange(0,Math.floor(b.nc*e));
  }

  for(let i=0;i<NPART;i++){
    const o=i*3; pLife[i]-=dt;
    if(pLife[i]<=0){ resetP(i); }
    pPos[o]+=pVel[o]*dt; pPos[o+1]+=pVel[o+1]*dt; pPos[o+2]+=pVel[o+2]*dt;
    const k=Math.min(1,pLife[i]);
    pCol[o]=pBase[o]*k; pCol[o+1]=pBase[o+1]*k; pCol[o+2]=pBase[o+2]*k;
  }
  pg.attributes.position.needsUpdate=pg.attributes.color.needsUpdate=true;

  const ph=time*.4;
  hc.copy(PAL[0]).lerp(PAL[1],(Math.sin(ph)+1)/2).lerp(PAL[2],(Math.sin(ph*.7+2)+1)/4);
  halo.material.color.copy(hc);
  if(CURSOR_ACTION){ if(cursorT){curGlow.position.copy(cursorT);curGlow.material.color.copy(hc);curGlow.material.opacity=.55+.35*Math.random();} else curGlow.material.opacity=0; }
  const pulse=1+Math.sin(time*3)*.05; halo.scale.set(10*pulse,10*pulse,1);

  if(composer) composer.render(); else renderer.render(scene,camera);
}
requestAnimationFrame(frame);
setTimeout(()=>{ const h=document.getElementById('hint'); if(h) h.style.opacity=0; },6000);

How it works

The frame() function is executed through requestAnimationFrame() .

At the beginning of each frame, we calculate how much time has passed since the previous update:

const dt=Math.min(.05,(now-last)/1000);

Limiting the delta time to 0.05 seconds prevents excessively large simulation steps.

We then update the shaders' uniforms:

coreMat.uniforms.t.value=time;
bgMat.uniforms.t.value=time;

This allows the plasma and nebula to change continuously.

The sphere also receives automatic rotation:

group.rotation.y=ry+time*.08;
group.rotation.x=rx;

Animating the lightning bolts

For each lightning bolt, we check its current age.

If its lifetime has ended, the gen() function prepares a new discharge.

Otherwise, we update the number of visible segments and the material's opacity.

One of the most important instructions is:

b.g.setDrawRange(0,Math.floor(b.count*e)*2);

Instead of displaying the entire lightning bolt immediately, we gradually increase the number of visible segments.

This makes the electrical discharge appear to grow outward from the sphere.

The glowing nodes are updated in a similar way.

Updating the particles

In the next loop, we decrease each particle's remaining lifetime, move its position according to its velocity, and gradually reduce its color intensity.

Once a particle's lifetime reaches zero, we call resetP() .

The particle returns to the sphere's surface and begins a new cycle.

Animating the halo

Finally, we update the halo's color and make its size pulse slightly.

const pulse=1+Math.sin(time*3)*.05;
halo.scale.set(10*pulse,10*pulse,1);

The sine function smoothly increases and decreases the halo's scale.

When a cursor target is active, we also display an additional glow near the lightning's target position.

The final instruction renders the scene through the composer or directly through the renderer.

 

14. Closing the HTML Document

The last step is to close our HTML document.

</script>
</body>
</html>

That's it.

After combining the main code blocks in the correct order, we have a complete energy_sphere.html file.

The code above follows the original implementation, with the on-screen instructions and code comments translated into English.

 

15. Customizing the Energy Sphere

One of the advantages of this project is how easily we can modify its appearance and behavior.

The most important settings are defined as JavaScript constants.

Parameter Default Description
R 2.2 Sphere radius
NB 30 Number of standard lightning bolts
NC 10 Number of cursor-targeting lightning bolts
NPART 1400 Number of particles
MAXSEG 1200 Maximum segment capacity per lightning bolt
MAXNODE 120 Maximum glowing node capacity
CURSOR_ACTION true Enables lightning interaction with the cursor

Changing the colors

The lightning, particles, and halo use colors defined in the PAL array.

You can replace the existing blue, purple, and green values with your own color combinations.

However, the plasma core uses a separate color palette defined inside the fragment shader's pal() function.

The background nebula also has its own colors.

If you want a completely consistent color scheme, you should adjust all three areas.

Changing the animation speed

The sphere's automatic rotation is controlled by:

time*.08

Increasing this multiplier will make the sphere rotate faster.

The plasma animation speed is controlled by the time multipliers inside the fragment shader's fbm() calculations.

This means you can change the speed of the plasma independently from the sphere's rotation.

You can also modify the lightning lifetime ranges and the Bloom effect's intensity.

Disabling cursor interaction

If you want the energy sphere to work purely as a decorative animation, change:

const CURSOR_ACTION = false;

This disables the additional cursor-targeting lightning system while preserving the standard discharges and other visual effects.

Improving performance

The animation uses procedural shaders, dynamically generated lightning geometry, and a system containing 1,400 particles.

On less powerful devices, you can experiment with reducing NPART and NB .

You can also lower the renderer's pixel ratio or disable Bloom.

These modifications can reduce rendering and JavaScript processing costs, although actual performance will depend on the device and browser.

 

16. Practical Applications

An interactive energy sphere can be used for much more than demonstrating the capabilities of Three.js.

Technology and AI websites

The sphere could become the central visual element of a website's hero section, reacting to cursor movement while presenting a futuristic appearance.

Developer portfolios and creative studios

This project demonstrates practical applications of procedural graphics, GLSL shaders, recursive algorithms, and dynamically generated 3D geometry.

It could work particularly well as part of an interactive developer portfolio.

Games and science-fiction projects

The same techniques could be adapted to represent an energy reactor, magical artifact, force field, or futuristic device.

Changing the shader colors and lightning behavior would allow us to create many variations of the same concept.

Interactive interfaces

The lightning bolts could be connected to application events.

For example, electrical discharges might react to clicks, data transfers, or changes in the application's state.

The current project already provides the basic mechanisms required to build such interactions.

Music visualizers

By extending the project with the Web Audio API, we could make the plasma, lightning, and halo react to sound intensity and frequency.

The current implementation does not include audio analysis, but the shader uniforms and animation parameters provide a convenient foundation for this type of extension.

 

17. Download

Download Download from Github

 

18. Conclusion

In a single HTML document, we combined several different techniques used in computer graphics.

Three.js handles the 3D scene and rendering. GLSL shaders generate procedural plasma and the animated nebula. JavaScript creates branching lightning bolts, animates particles, and manages user interaction.

The visual result is enhanced through additive blending, glowing sprites, and postprocessing.

The greatest advantage of this approach is the amount of control we have over the animation.

We can modify its appearance, behavior, colors, speed, and intensity without creating new graphic assets.

And most importantly, we are not simply replaying a previously recorded sequence.

We are generating the animation through our own algorithms, directly inside the user's browser.