That idea became my “Magic Wand – Energy Bolt” demo. I built it as a single HTML document using JavaScript, Three.js r128, WebGL, custom GLSL shaders, and Bloom post-processing. Clicking fires a beam of energy. Holding the pointer down sustains the stream, while releasing it lets the tail catch up with the leading edge until the effect disappears. I also implemented a second firing mode: shooting directly from the cursor in the direction of its most recent movement.
In this tutorial, I'll break the project down into its individual systems. I'll show how I prepared the point and line buffers, calculated the stream's backbone, wrapped three colored helices around it, and generated a network of miniature lightning bolts resembling electrical impulses traveling between neurons. This isn't a prerecorded video or a sprite sheet: almost the entire effect is generated procedurally and changes in real time.
See video:
1. What Can My Animation Do?
I started by designing several visual layers that would work together as a single cohesive energy bolt. I deform the central axis using moving control points. Three ribbons—blue, green, and pink—spiral around that axis. Smaller particles race along their paths, leaving short luminous trails. Independent sparks and brief branching lightning bolts surround the entire structure. Additional connections between the tips of those branches create the distinctive glowing network.
I also added two ways to select the firing origin, controls for the intensity of the glow and the speed of the moving particles, subtle screen shake while the beam is being held, and support for multiple simultaneous discharges. The code reserves capacity for five active bolts, 600 independent embers, and 100 short-lived electrical branches per bolt. These are system limits and buffer capacities, not a guarantee that every element will be visible at once.
My main priority was to make sure these layers didn't behave like unrelated effects. When the beam's central axis changes shape, the helices, fast-moving particles, and lightning branches all adjust their positions to follow the same trajectory.
2. HTML Structure, CSS, and the Control Panel
I packaged the entire demonstration into one HTML file. Instead of building a complicated interface, I kept a full-screen canvas and a small panel in the upper-right corner. The panel contains two mode buttons and two sliders.
<div class="vig"></div>
<div id="ui">
<h1>Magic Wand Energy Bolt</h1>
<div class="seg" role="group" aria-label="Bolt origin">
<button data-m="point" class="on">From Point</button>
<button data-m="cursor">From Cursor</button>
</div>
<label>Glow <input type="range" id="bl" min="0" max="3" step=".05" value="1.4"></label>
<label>Particle Speed <input type="range" id="ps" min=".4" max="2.2" step=".05" value="1"></label>
<p id="hint"></p>
</div>
The .vig element adds a subtle vignette, darkening the edges of the scene. In CSS, I positioned the canvas so it fills the entire window. The touch-action:none rule prevents page scrolling from taking over touch gestures, while #ui uses a translucent background and a backdrop-filter blur.
The buttons switch between “From Point” and “From Cursor”. The “Glow” slider adjusts Bloom intensity from 0 to 3, while the “Particle Speed” slider changes the particles' movement speed from 0.4 to 2.2. This lets me immediately see how those parameters affect the overall animation.
3. Libraries and Post-Processing — Where Does the Glow Come From?
I used Three.js r128 and its classic post-processing scripts. Alongside the core library, I load EffectComposer, RenderPass, and UnrealBloomPass, together with their dependencies. The original demo uses CDN URLs, so this HTML version requires an internet connection when loading those libraries.
<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/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/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/UnrealBloomPass.js"></script>
I create the renderer with powerPreference:'high-performance' and cap the device pixel ratio at 2. I use the very dark #03020a background and a perspective camera with a 60-degree field of view. I deliberately disabled standard antialiasing in the renderer constructor: the visual style relies mainly on soft points, additive blending, and glow.
// ---------- renderer ----------
const renderer=new THREE.WebGLRenderer({antialias:false,powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(window.devicePixelRatio||1,2));
document.body.prepend(renderer.domElement);
const scene=new THREE.Scene();scene.background=new THREE.Color(0x03020a);
const camera=new THREE.PerspectiveCamera(60,1,.1,300);camera.position.set(0,0,30);
let composer=null,bloom=null;
if(THREE.EffectComposer&&THREE.UnrealBloomPass){
composer=new THREE.EffectComposer(renderer);
composer.addPass(new THREE.RenderPass(scene,camera));
bloom=new THREE.UnrealBloomPass(new THREE.Vector2(256,256),1.4,.7,.08);
composer.addPass(bloom);
}
I first render the scene through a RenderPass inside EffectComposer, then process the result with UnrealBloomPass. The values 1.4, 0.7, and 0.08 specify the initial Bloom strength, radius, and threshold. I also included a fallback rendering path: if the required post-processing classes are unavailable, the scene can render without the composer. That fallback does not, however, repair libraries that failed to load correctly.
4. Animation Parameters — Range, Point Counts, and Color Palette
Before building any geometry, I gathered the most important settings in one place.
const TAU=Math.PI*2,R=70,SPEED=85,NS=300,NP=90,NSP=100,MAXB=5,DL=2.6,NC=32;
const PAL=[[.16,.62,1],[.12,1,.5],[1,.22,.72]]; // blue, green, pink
const clamp=(x,a,b)=>x<a?a:x>b?b:x,rnd=(a,b)=>a+Math.random()*(b-a);
const sm=x=>{x=clamp(x,0,1);return x*x*(3-2*x)};
const V=THREE.Vector3,DYN=35048;
const state={mode:'point',pspeed:1};
| Parameter | Value | Purpose |
|---|---|---|
R |
70 |
Maximum distance reached by the bolt's leading edge, in scene units |
SPEED |
85 |
Speed at which the leading edge advances |
NS |
300 |
Samples along each of the three main ribbons |
NP |
90 |
Fast-moving particles per ribbon |
NSP |
100 |
Pool of electrical branches per bolt |
MAXB |
5 |
Maximum number of bolt objects |
DL |
2.6 |
Spacing between control points along the bent axis |
NC |
32 |
Number of axis control points |
EN |
600 |
Capacity of the shared independent-ember pool |
I define the colors in PAL as RGB triplets ranging from 0 to 1: blue, green, and pink. I also prepared clamp(), the rnd() random-range helper, and a smoothing function, sm(), which removes abrupt changes at the beginning and end of transitions. Later, I use it both at the ends of the main energy stream and during the growth of miniature electrical discharges.
5. Custom ShaderMaterial — Soft, Glowing Points
If I had rendered ordinary points without a custom material, I would have ended up with sharp, geometric dots. Instead, I created one shared ShaderMaterial for several particle systems. In the vertex shader, I pass the color and calculate a point size that takes perspective into account. The fragment shader turns the square area of each point into a soft patch of light.
const ptMat=new THREE.ShaderMaterial({
uniforms:{uS:{value:1}},transparent:true,depthWrite:false,blending:THREE.AdditiveBlending,
vertexShader:`attribute float aSize;attribute vec3 aColor;varying vec3 vC;uniform float uS;
void main(){vC=aColor;vec4 mv=modelViewMatrix*vec4(position,1.);gl_PointSize=aSize*uS/(-mv.z);gl_Position=projectionMatrix*mv;}`,
fragmentShader:`varying vec3 vC;void main(){float d=length(gl_PointCoord-.5)*2.;float a=exp(-d*d*3.5)*step(d,1.);gl_FragColor=vec4(vC*a,1.);}`
});
The key pieces here are gl_PointCoord and exp(-d*d*3.5). The first gives me coordinates inside an individual point; the second reduces its brightness from the center toward its edges. I also use THREE.AdditiveBlending: overlapping particles intensify the light instead of obscuring each other like ordinary opaque objects.
I set the point size according to depth by dividing by -mv.z. The uS uniform lets me adjust the size to the rendering resolution and the camera's field of view.
6. Point Buffers — How I Avoid Creating Thousands of Mesh Objects
I wanted to keep the number of Three.js objects under control. That's why I wrote mkPoints(), which takes a point count and prepares three arrays: positions, colors, and sizes. A single THREE.Points object can render a large number of particles at once.
function mkPoints(n){
const g=new THREE.BufferGeometry(),p=new Float32Array(n*3),c=new Float32Array(n*3),s=new Float32Array(n);
g.setAttribute('position',new THREE.BufferAttribute(p,3).setUsage(DYN));
g.setAttribute('aColor',new THREE.BufferAttribute(c,3).setUsage(DYN));
g.setAttribute('aSize',new THREE.BufferAttribute(s,1).setUsage(DYN));
const o=new THREE.Points(g,ptMat);o.frustumCulled=false;
return{o,g,p,c,s,flush(){const a=g.attributes;a.position.needsUpdate=a.aColor.needsUpdate=a.aSize.needsUpdate=true}};
}
The position attribute stores three coordinates per point. Likewise, aColor contains three RGB components, while aSize holds a single value. All arrays use Float32Array.
Because I modify these buffers during the animation, I mark them as dynamic with setUsage(DYN), where DYN=35048 corresponds to Three.js's DynamicDrawUsage. The flush() method sets needsUpdate=true, notifying the renderer that it needs to upload the updated data to the GPU.
I reuse this helper for the main ribbons, traveling particles, flashes, free-flying sparks, and the aiming marker. I don't create separate 3D models for every speck of light; I update shared buffers from JavaScript instead.
7. Sparks and Embers — A Separate Pool of 600 Particles
I didn't want the energy bolt to look like an excessively clean line. On each frame, I emit extra sparks from different parts of the stream. They break away, slow down, and fade. To avoid creating new objects for every emission, I reserved a fixed pool of 600 points.
// ---------- sparks (embers) ----------
const EN=600,E=mkPoints(EN),ev=new Float32Array(EN*3),ec=new Float32Array(EN*3),el=new Float32Array(EN),em=new Float32Array(EN);let ei=0;
function emit(x,y,z,vx,vy,vz,col){
const i=ei++%EN;E.p.set([x,y,z],i*3);ev.set([vx,vy,vz],i*3);ec.set(col,i*3);el[i]=em[i]=rnd(.5,1.1);
}
function updEmbers(dt){
for(let i=0;i<EN;i++){
if(el[i]>0){
el[i]-=dt;const f=Math.max(0,el[i]/em[i]),k=1-1.8*dt;
for(let a=0;a<3;a++){E.p[i*3+a]+=ev[i*3+a]*dt;ev[i*3+a]*=k;E.c[i*3+a]=ec[i*3+a]*f*f}
E.s[i]=.05+.2*f;
}else{E.c[i*3]=E.c[i*3+1]=E.c[i*3+2]=0;E.s[i]=0}
}
E.flush();
}
scene.add(E.o);
The emit() function records a spark's position, velocity, color, and lifetime. The ei variable serves as a circular pointer: after using the last slot, I return to the beginning of the array.
In updEmbers(), I move active sparks by velocity × dt, gradually damp their velocity, and decrease their brightness in proportion to the square of their remaining lifetime. Once they expire, I set their color and size to zero. Each spark lives for a randomly selected period between 0.5 and 1.1 seconds.
8. The Bolt Class — Each Energy Shot as a Self-Contained System
I encapsulated the main discharge in the Bolt class. Each instance stores its origin, direction, head and tail progress, axis-deformation arrays, and its own point and line buffers. This lets me manage several bolts without mixing up their geometries.
class Bolt{
constructor(){
this.o=new V();this.d=new V(1,0,0);this.e1=new V(0,0,1);this.e2=new V(0,-1,0);
this.head=0;this.tail=0;this.held=false;this.active=false;this.age=0;
this.cv=new Float32Array(NC*2);this.ct=new Float32Array(NC*2);this.cn=new Float32Array(NC);
this.P=mkPoints(NS*3+NP*9+2+NSP*2);
const segs=NSP*9+40;
this.lp=new Float32Array(segs*6);this.lc=new Float32Array(segs*6);
const g=new THREE.BufferGeometry();
g.setAttribute('position',new THREE.BufferAttribute(this.lp,3).setUsage(DYN));
g.setAttribute('color',new THREE.BufferAttribute(this.lc,3).setUsage(DYN));
this.L=new THREE.LineSegments(g,new THREE.LineBasicMaterial({vertexColors:true,transparent:true,blending:THREE.AdditiveBlending,depthWrite:false}));
this.L.frustumCulled=false;
this.g=new THREE.Group();this.g.add(this.P.o,this.L);this.g.visible=false;scene.add(this.g);
this.sp=Array.from({length:NSP},()=>({born:-9,ttl:0,s:0,i:0,len:1,col:0,tick:-1,bi:2,fade:0,on:false,
jt:new Float32Array(9),jw:new Float32Array(9),bd:[0,0],tip:[0,0,0]}));
}
In the constructor, I reserve a buffer of 1,912 points for each bolt. That comes directly from NS*3 + NP*9 + 2 + NSP*2: 900 points for the three ribbons, 810 for the fast particles and their trails, two endpoint flashes, and 200 points marking the ends of small lightning branches. Separately, I allocate a LineSegments buffer for up to 940 segments per bolt (NSP*9+40).
I don't allocate these values again on each frame. I create the arrays in the constructor and overwrite their contents during animation. Everything sits inside a THREE.Group, so I can hide an inactive bolt with one visibility change.
Starting and Fading an Electrical Discharge
The fire() method stores the origin and direction, resets the bolt's age, and activates it with held=true.
fire(o,d){
this.o.copy(o);this.d.copy(d);this.e2.set(d.y,-d.x,0);
this.head=.01;this.tail=0;this.held=true;this.age=0;this.active=true;this.g.visible=true;
this.sp.forEach(s=>{s.born=-9;s.ttl=0});
for(let m=0;m<NC;m++)this.rr(m,true);
}
On each frame, I advance head at the rate defined by SPEED, without allowing it to pass R=70. Once the pointer is released, I advance tail too—at a higher speed of SPEED*1.35. When the distance between them approaches zero, I hide the group and deactivate the bolt. The age>.3 condition creates a short initial period before the tail is allowed to follow the head.
update(dt,T){
if(!this.active)return;
this.age+=dt;this.wob(dt);
this.head=Math.min(R,this.head+SPEED*dt);
if(!this.held&&this.age>.3){
this.tail=Math.min(this.head,this.tail+SPEED*1.35*dt);
if(this.head-this.tail<.05){this.active=false;this.g.visible=false;return}
}
For me, splitting the stream into a leading edge and a trailing edge is the foundation of the entire effect. I don't need to delete particles individually. I simply render every path only between the current tail and head values.
9. The Bent Beam Axis — Control Points and Interpolation
A regular beam traveling along a perfectly straight line looks too sterile. I therefore created 32 control points spaced 2.6 units apart. Each point receives two random perpendicular offsets, which periodically move toward new target values.
rr(m,now){
const A=2*clamp(m/2.5,0,1)*(Math.random()<.2?1.5:1);
this.ct[m*2]=m?rnd(-1,1)*A:0;this.ct[m*2+1]=m?rnd(-1,1)*A*.5:0;
this.cn[m]=rnd(.1,.3);
if(now){this.cv[m*2]=this.ct[m*2];this.cv[m*2+1]=this.ct[m*2+1]}
}
wob(dt){
const k=1-Math.exp(-dt*14),cv=this.cv,ct=this.ct;
for(let m=1;m<NC;m++){
this.cn[m]-=dt;if(this.cn[m]<=0)this.rr(m,false);
cv[m*2]+=(ct[m*2]-cv[m*2])*k;cv[m*2+1]+=(ct[m*2+1]-cv[m*2+1])*k;
}
}
cl(s){
const f0=clamp(s/DL,0,NC-1.001),m=Math.floor(f0),f=f0-m,cv=this.cv,o=this.o,d=this.d,e1=this.e1,e2=this.e2;
const a=cv[m*2]*(1-f)+cv[m*2+2]*f,z=cv[m*2+1]*(1-f)+cv[m*2+3]*f;
CL.x=o.x+d.x*s+e2.x*a+e1.x*z;CL.y=o.y+d.y*s+e2.y*a+e1.y*z;CL.z=o.z+d.z*s+e2.z*a+e1.z*z;
}
The rr() function chooses a new offset target and a time until the next change. Rather than instantly snapping points to their new coordinates, wob() smoothly moves the current values in cv toward their targets in ct using the factor 1-exp(-dt*14). This creates irregular motion without ugly, sudden jumps.
The most important calculations happen in cl(s). For a distance s from the bolt's origin, I locate two neighboring control points and interpolate their offsets. I add those offsets to the base line described by the origin o and direction d. This gives me a point on the bent axis at any location, regardless of how many particles I actually render.
10. Three Rotating Helices — The Geometry of the Energy Stream
Once I had the discharge's irregular backbone, I could wrap three ribbons around it. Inside hx(), I calculate the position of a point on a helix at distance s, for ribbon i and time T.
hx(s,i,T,rm){
const e=sm((this.head-s)/2.6)*sm((s-this.tail)/3.2);
const r=rm*.6*e*(1+.2*Math.sin(s*.45-T*3+i*2.1));
const ph=s*.85-T*8+i*TAU/3,cp=Math.cos(ph),sn=Math.sin(ph);
const o=this.o,d=this.d,a=this.e1,b=this.e2;
H.ux=a.x*cp+b.x*sn;H.uy=a.y*cp+b.y*sn;H.uz=a.z*cp+b.z*sn;
this.cl(s);H.x=CL.x+H.ux*r;H.y=CL.y+H.uy*r;H.z=CL.z+H.uz*r;
H.ph=ph;H.e=e;
}
I calculate the helix phase using s*.85 - T*8 + i*TAU/3. The s*.85 term rotates the ribbon around the axis as the distance increases, -T*8 animates that rotation over time, and i*TAU/3 offsets the three strands evenly by 120 degrees.
The radius isn't constant. I multiply it by two sm() values so the helix fades smoothly near both the leading and trailing edges of the beam. I also introduce a little sinusoidal pulsing. I store the result in the helper structure H, which the next visual layers reuse.
Inside update(), I sample each of the three paths at 300 positions between tail and head. At the same time, I blend neighboring palette colors and reduce the brightness at the ends of the stream.
// three intertwined strands
for(let i=0;i<3;i++){
const A=PAL[i],B=PAL[(i+1)%3];
for(let k=0;k<NS;k++){
const s=tail+k/(NS-1)*Ln;this.hx(s,i,T,.8);
const m=.4*(.5+.5*Math.sin(s*.5-T*3)),br=.5*dens*(.5+.5*H.e);
put(H.x,H.y,H.z,(A[0]+(B[0]-A[0])*m)*br,(A[1]+(B[1]-A[1])*m)*br,(A[2]+(B[2]-A[2])*m)*br,.85*(.35+.65*H.e));
}
}
This creates the bolt's visual core: three intertwined paths that not only rotate around their shared axis but also bend whenever that axis changes shape.
11. Fast Particles and Luminous Trails
The triple helix alone didn't fully convey the feeling of flowing energy. So I added 90 fast-moving particles to each ribbon. I represent each particle with three points of decreasing brightness and size. This creates a short trail without needing separate image textures.
// fast particles with trails
const pv=130*state.pspeed;
for(let i=0;i<3;i++){
const A=PAL[i];
for(let k=0;k<NP;k++){
const q=PR[i*NP+k],u=(q.o+T*pv*q.v/Math.max(Ln,8))%1,s0=tail+u*Ln;
for(let t=0;t<3;t++){
const s=s0-t*.55;
if(s<tail+.3){put(0,0,0,0,0,0,0);continue}
this.hx(s,i,T,.8);
const br=1.5*TB[t]*(.3+.7*H.e);
put(H.x,H.y,H.z,(A[0]*.5+.5)*br,(A[1]*.5+.5)*br,(A[2]*.5+.5)*br,.75*q.z*(.4+.6*H.e)*TS[t]);
}
}
}
I calculate the particle's position using u=(q.o+T*pv*q.v/Math.max(Ln,8))%1. The q.o parameter defines a random starting offset, q.v gives each particle its own speed multiplier, and %1 wraps the motion back to the beginning of the active beam section. The value pv is 130*state.pspeed, so it responds to the speed slider in the interface.
For each particle, I draw three positions separated by 0.55 units along its path. The TB and TS arrays control the brightness and size of those points. On screen, the particle looks like a glowing streak rather than a stationary dot.
12. Flashes at the Origin and the Leading Edge
I wanted to emphasize both the emission point and the front of the beam, so I use the two previously reserved points to create separate flashes. The first fades as the tail moves away from the origin; the second remains active until the leading edge reaches its maximum range.
// flashes: origin and leading edge
const og=tail<.5?1:Math.max(0,1-tail/6);
this.cl(tail);put(CL.x,CL.y,CL.z,.55*og,.8*og,1*og,2.6*(.85+.15*Math.sin(T*47))*og+.001);
const hh=(T*1.3)%3,hi=Math.floor(hh),hm=hh-hi,HA=PAL[hi],HB=PAL[(hi+1)%3],hv=head<R?1:0;
this.cl(head);put(CL.x,CL.y,CL.z,((HA[0]+(HB[0]-HA[0])*hm)*.8+.3)*hv,((HA[1]+(HB[1]-HA[1])*hm)*.8+.3)*hv,((HA[2]+(HB[2]-HA[2])*hm)*.8+.3)*hv,1.9*(.85+.15*Math.sin(T*61))*hv+.001);
I continuously change the color of the leading flash, blending between the three colors in my palette. A slight variation in point size, driven by sin(T*47) and sin(T*61), makes these flashes feel like unstable bursts of energy.
13. Branching Lightning — A Miniature Electrical Network
Local electrical discharges are the most elaborate layer of my effect. Rather than drawing a single traditional bolt from the source to the target, I created a pool of short branches. They appear at random positions around one of the three ribbons, change shape, fade away, and are replaced by new ones.
Each branch has its own sp object, in which I store its birth time, lifetime, attachment point, length, color, current tip, and arrays of small random deviations. I prepared 100 such objects for each bolt.
Branch Lifetime and Geometry
// small lightning bolts – neural network
const lp=this.lp,lc=this.lc;
const seg=(idx,ax,ay,az,bx,by,bz,r,g,b,k2)=>{const q=idx*6;lp[q]=ax;lp[q+1]=ay;lp[q+2]=az;lp[q+3]=bx;lp[q+4]=by;lp[q+5]=bz;lc[q]=r;lc[q+1]=g;lc[q+2]=b;lc[q+3]=r*k2;lc[q+4]=g*k2;lc[q+5]=b*k2};
const clr=idx=>{lp.fill(0,idx*6,idx*6+6);lc.fill(0,idx*6,idx*6+6)};
for(let j=0;j<NSP;j++){
const sp=this.sp[j];let age=T-sp.born;
if(age>sp.ttl){
if(Ln>4){
sp.s=rnd(tail+1.2,head-1.2);sp.i=Math.floor(rnd(0,3));sp.len=rnd(.7,1.8);sp.col=Math.floor(rnd(0,3));
sp.bi=2+Math.floor(rnd(0,2));sp.bd=[rnd(-1,1),rnd(-1,1)];sp.born=T+rnd(0,.2);sp.ttl=rnd(.15,.4);
}else{sp.born=T+.1;sp.ttl=.1}
age=T-sp.born;
}
if(age<0||sp.s<tail+.5||sp.s>head-.5){
if(sp.s<tail+.5)sp.ttl=0;
for(let m=0;m<8;m++)clr(j*8+m);
put(0,0,0,0,0,0,0);put(0,0,0,0,0,0,0);sp.on=false;continue;
}
const tick=Math.floor(T*20)+j;
if(tick!==sp.tick){sp.tick=tick;for(let m=0;m<9;m++){sp.jt[m]=rnd(-1,1);sp.jw[m]=rnd(-1,1)}}
this.hx(sp.s,sp.i,T,.8);
const ax=H.x,ay=H.y,az=H.z,ux=H.ux,uy=H.uy,uz=H.uz,dx=d.x,dy=d.y,dz=d.z;
const wx=uy*dz-uz*dy,wy=uz*dx-ux*dz,wz=ux*dy-uy*dx;
const g=sm(age/.07),fade=(1-(age/sp.ttl)**2)*(.7+.3*Math.random()),col=PAL[sp.col],len=sp.len*g,jj=len*.13;
let px=ax,py=ay,pz=az,bx=0,by=0,bz=0;
for(let k=1;k<=5;k++){
const r=len*k/5,jt=sp.jt[k]*jj,jw=sp.jw[k]*jj;
const cx=ax+ux*r+dx*jt+wx*jw,cy=ay+uy*r+dy*jt+wy*jw,cz=az+uz*r+dz*jt+wz*jw;
seg(j*8+k-1,px,py,pz,cx,cy,cz,col[0]*fade*1.1,col[1]*fade*1.1,col[2]*fade*1.1,.85);
if(k===sp.bi){bx=cx;by=cy;bz=cz}
px=cx;py=cy;pz=cz;
}
sp.tip=[px,py,pz];sp.on=true;sp.fade=fade;
put(px,py,pz,(col[0]*.6+.5)*fade,(col[1]*.6+.5)*fade,(col[2]*.6+.5)*fade,.34*g+.001);
// branch
const kx=ux*.5+dx*sp.bd[0]+wx*sp.bd[1],ky=uy*.5+dy*sp.bd[0]+wy*sp.bd[1],kz=uz*.5+dz*sp.bd[0]+wz*sp.bd[1];
const st=len*.16;
for(let m=1;m<=3;m++){
const cx=bx+kx*st+(dx*sp.jt[5+m]+wx*sp.jw[5+m])*len*.07,cy=by+ky*st+(dy*sp.jt[5+m]+wy*sp.jw[5+m])*len*.07,cz=bz+kz*st+(dz*sp.jt[5+m]+wz*sp.jw[5+m])*len*.07;
seg(j*8+4+m,bx,by,bz,cx,cy,cz,col[0]*fade*.9,col[1]*fade*.9,col[2]*fade*.9,.7);
bx=cx;by=cy;bz=cz;
}
put(bx,by,bz,col[0]*fade*1.1,col[1]*fade*1.1,col[2]*fade*1.1,.26*g+.001);
}
When sp.ttl expires, I choose a new attachment position along the active section of the beam, select one of the three helices, and assign a new color. The discharge starts with zero length, grows over roughly 0.07 seconds, and then fades.
I build the main branch from five segments. I move each subsequent point away from the helix, adding random displacement along the bolt's direction and along a perpendicular direction. From one of these points, I draw an additional branch of three segments. I place small glowing points at their tips.
I refresh the random jt and jw values about 20 times per second, making the tiny lightning bolts flicker and jitter. Each discharge remains visible for a random 0.15–0.4 seconds. This produces brief electrical impulses rather than wires that glow continuously.
Connecting the Tips — A Neural-Network-Like Effect
// connections between tips (neurons)
for(let j=0;j<NSP;j++){
const a=this.sp[j];let best=-1,bd2=3.6;
if(a.on)for(let k=0;k<NSP;k++){
if(k===j||!this.sp[k].on)continue;
const b=this.sp[k].tip,x=a.tip[0]-b[0],y=a.tip[1]-b[1],z=a.tip[2]-b[2],d2=x*x+y*y+z*z;
if(d2<bd2){bd2=d2;best=k}
}
if(best>=0){const b=this.sp[best].tip,col=PAL[a.sp_col===undefined?this.sp[j].col:0],f=a.fade*.7;
seg(NSP*8+j,a.tip[0],a.tip[1],a.tip[2],b[0],b[1],b[2],col[0]*f,col[1]*f,col[2]*f,1)}
else clr(NSP*8+j);
}
{let ps=tail;this.cl(ps);let qx=CL.x,qy=CL.y,qz=CL.z;
for(let q=0;q<40;q++){
const idx=NSP*9+q;
if(ps>=head-1e-4){clr(idx);continue}
const ns=Math.min(head,(Math.floor(ps/DL+1e-4)+1)*DL);this.cl(ns);
seg(idx,qx,qy,qz,CL.x,CL.y,CL.z,.4,.4,.46,1);qx=CL.x;qy=CL.y;qz=CL.z;ps=ns;
}}
Here I find the nearest other active tip for each active branch. If the squared distance is less than 3.6, I draw another connection. The result is a collection of short bridges of light between nearby branches, which is why the structure reminds me of a neural network.
This is a deliberate trade-off between appearance and performance. With NSP=100, I search potential neighbors using a straightforward loop. If I were to increase the number of branches significantly, I'd consider a spatial grid or another neighborhood-search structure, because the cost of this approach grows quadratically.
I also draw up to 40 segments of a subtle central connection along the bent axis and emit free-flying embers at random points along the stream. This prevents the smaller discharges from appearing to float independently: visually, they remain part of a single energy beam.
14. Two Firing Modes — Fixed Origin and Cursor
I wanted my demonstration to show two ways of controlling the same system. The first is closer to a traditional magic wand: the bolt always begins at a fixed point in the lower-left part of the screen and travels toward the position being clicked. The second fires directly from the cursor, in the direction of its most recent movement.
First, I convert the cursor position from screen pixels into 3D world coordinates. My toWorld() function uses unproject(camera) and finds where the resulting ray intersects the z=0 plane.
// ---------- aiming marker ----------
const M=mkPoints(4);scene.add(M.o);
const origin=new V(),tmp=new V();
function toWorld(cx,cy,out){
out.set(cx/innerWidth*2-1,-(cy/innerHeight*2-1),.5).unproject(camera);
out.sub(camera.position).normalize();
return out.multiplyScalar(-camera.position.z/out.z).add(camera.position);
}
Next, I store a smoothed vector representing the mouse movement direction. Each time the pointer moves, I update ptr.dx and ptr.dy, normalizing the vector so that the aiming direction doesn't depend on how quickly the mouse is moving.
// ---------- input ----------
const ptr={x:innerWidth/2,y:innerHeight/2,dx:1,dy:0};let cur=null;
const ui=document.getElementById('ui');
addEventListener('pointermove',e=>{
const mx=e.clientX-ptr.x,my=e.clientY-ptr.y,m=Math.hypot(mx,my);
if(m>2){const l=.35;ptr.dx+=(mx/m-ptr.dx)*l;ptr.dy+=(my/m-ptr.dy)*l;const k=Math.hypot(ptr.dx,ptr.dy)||1;ptr.dx/=k;ptr.dy/=k}
ptr.x=e.clientX;ptr.y=e.clientY;
});
function aim(){
const w=toWorld(ptr.x,ptr.y,new V()),d=new V(),o=new V();
if(state.mode==='point'){o.copy(origin);d.subVectors(w,o);d.z=0;if(d.length()<.5)d.set(1,0,0)}
else{o.copy(w);d.set(ptr.dx,-ptr.dy,0)}
return{o,d:d.normalize()};
}
addEventListener('pointerdown',e=>{
if(e.target.closest('#ui'))return;
ptr.x=e.clientX;ptr.y=e.clientY;
const{o,d}=aim();
let b=bolts.find(x=>!x.active)||bolts.reduce((a,x)=>x.age>a.age?x:a);
b.fire(o,d);cur=b;
});
const rel=()=>{if(cur){cur.held=false;cur=null}};
addEventListener('pointerup',rel);addEventListener('pointercancel',rel);
addEventListener('contextmenu',e=>e.preventDefault());
In point mode, I use the fixed origin as the beginning of the bolt and calculate the direction from that point toward the cursor. In cursor mode, the cursor itself becomes the origin, and the direction comes from the most recent mouse-movement vector. On pointerdown, I find the first inactive Bolt; if all instances are busy, I use the reuse logic already implemented in the script.
Neither pointerup nor pointercancel removes the bolt immediately. They only change held to false, allowing the tail to catch up with the head naturally. I find that far more convincing than making the entire effect suddenly disappear.
15. UI Controls, Scene Resizing, and the Animation Loop
I switch modes using the buttons' data-m attributes. I update their on class and the on-screen instructions. The Bloom slider changes bloom.strength directly, while the other slider writes the selected speed to state.pspeed, which I use for the fast-moving particles.
// ---------- UI ----------
const hint=document.getElementById('hint');
const hints={point:'Click to fire from the lower-left point toward the cursor. Hold to sustain the stream.',
cursor:'Click to fire from the cursor in the direction of its last movement (the dots show the target).'};
hint.textContent=hints.point;
document.querySelectorAll('.seg button').forEach(b=>b.onclick=()=>{
document.querySelectorAll('.seg button').forEach(x=>x.classList.toggle('on',x===b));
state.mode=b.dataset.m;hint.textContent=hints[state.mode];
});
document.getElementById('bl').oninput=e=>{if(bloom)bloom.strength=+e.target.value};
document.getElementById('ps').oninput=e=>{state.pspeed=+e.target.value};
When the window size changes, I resize the renderer, camera, and composer. I also recalculate uS using the actual drawing-buffer height and the camera's field of view. In the same function, I update the fixed firing origin to a position at approximately 10% of the window width and 78% of its height.
// ---------- resize and animation loop ----------
function resize(){
const w=innerWidth,h=innerHeight;
renderer.setSize(w,h);camera.aspect=w/h;camera.updateProjectionMatrix();camera.updateMatrixWorld();
if(composer)composer.setSize(w,h);
const b=renderer.getDrawingBufferSize(new THREE.Vector2());
ptMat.uniforms.uS.value=b.y/(2*Math.tan(camera.fov*Math.PI/360));
origin.copy(toWorld(w*.1,h*.78,tmp));
}
addEventListener('resize',resize);resize();
let last=performance.now(),T=0,shake=0;
function frame(now){
const dt=Math.min(.05,(now-last)/1000);last=now;T+=dt;
bolts.forEach(b=>b.update(dt,T));
updEmbers(dt);
// crosshair
const a=aim(),pulse=.85+.15*Math.sin(T*3);
const bp=state.mode==='point'?a.o:a.o;
M.p.set([bp.x,bp.y,bp.z],0);M.c.set([.35*pulse,.65*pulse,.9*pulse],0);M.s[0]=.55*pulse;
for(let k=1;k<4;k++){
M.p.set([bp.x+a.d.x*1.5*k,bp.y+a.d.y*1.5*k,0],k*3);
const f=.5/k;M.c.set([.5*f,.6*f,.9*f],k*3);M.s[k]=.2;
}
M.flush();
const any=bolts.some(b=>b.active&&b.held);
shake+=((any?1:0)-shake)*Math.min(1,dt*10);
scene.position.set((Math.random()-.5)*.1*shake,(Math.random()-.5)*.1*shake,0);
if(composer)composer.render();else renderer.render(scene,camera);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
Inside frame(now), I cap dt at 0.05 seconds. I then update all Bolt instances, the ember pool, the aiming marker, and the subtle camera-scene shake. I build the marker from four luminous points arranged along the aiming direction. If at least one discharge is still being held, I gradually increase shake and offset the entire scene by a very small random amount.
Finally, I call composer.render() or the regular renderer.render(), and schedule the next frame through requestAnimationFrame(frame). This completes the cycle: user events change the state, JavaScript updates the buffers, and WebGL draws the current energy stream.
16. Performance and Areas for Further Improvement
My most important optimization decision was to allocate the buffers in advance. I don't create hundreds of new Three.js objects every time I fire. I reserve fixed capacities for bolts, sparks, points, and line segments, then update their contents. Additive blending and small points are well suited to building layers of light, but drawing overlapping transparent elements still has a rendering cost.
It's worth remembering that, in this particular project, I update many point positions on the CPU and mark the buffers with needsUpdate. This isn't an animation where all motion has been moved into the shaders. The vertex shader mainly handles perspective-aware point sizing, while the fragment shader determines how the points look. If I wanted to push the particle count much further, I could move parts of the helical motion or glow calculations onto the GPU.
I also see room to optimize by skipping updates for invisible elements, uploading only modified buffer ranges, and replacing the quadratic nearest-tip search with a spatial approach. On mobile devices, I'd start by reducing NS, NSP, MAXB, Bloom intensity, and devicePixelRatio.
17. What Can I Customize in My Effect?
I designed this project so that many changes wouldn't require rebuilding its architecture. Increasing R extends the maximum bolt range, while changing SPEED controls how quickly the leading edge grows. DL and NC determine the spacing and reach of the control points on the bent axis, so I'd adjust them together to make sure they cover the entire beam.
By modifying PAL, I can create not only blue-green-pink energy but also fiery orange, cool purple, or a monochromatic ray. Inside hx(), I can control the helix radius and rotational speed; in updEmbers(), I can adjust spark lifetime, drag, and spread. Meanwhile, NSP and sp.ttl affect the density and rhythm of the electrical branches. If I need to reduce the rendering cost, I can lower the number of active bolts through MAXB.
I can also replace the two firing modes with other interactions, such as a keyboard shortcut, touching an object in the scene, or triggering a game event. The code doesn't depend on a physical wand model: in my demo, the “wand” is simply the energy emission point.
18. Where Would I Use an Effect Like This?
I see the greatest potential in interactive websites and creative projects: a fantasy game's intro, a science-fiction hero section, a visual-effects artist's portfolio, or an experimental interface. The bolt doesn't have to represent magic. By changing the colors, intensity, and shape of its axis, I can make it look like a plasma pulse, a data stream, a neural connection, or an energy beam between two devices.
Unlike a prerecorded video, this system lets me change the origin, direction, length, number of active bolts, and fading behavior in real time. Interactivity and parameter-driven customization were my main reasons for creating this effect procedurally.
19. Download
20. Final Thoughts
While developing this animation, I combined several simpler techniques into a more complex system. I first created an irregular central path driven by moving control points. Then I wrapped three colored helices around it, added fast-moving particles with luminous trails, endpoint flashes, and an independent ember pool. The most distinctive element became the network of tiny procedural lightning branches, whose tips can form additional connections.
The most important mechanism, however, is controlling the leading and trailing edges of the stream. It lets me sustain the beam while the pointer is held down and fade it naturally after release. Every visual layer follows the same trajectory, so even when the central axis bends, the entire effect still looks like one living bolt of energy.
I didn't need Blender, 3D models, or prerecorded animations to build it. Three.js, dynamic buffers, basic vector geometry, custom GLSL shaders, and a little controlled chaos were enough. ⚡


