BUILD A LASER RING
VFX IN THREE.JS
By Shane Brumback · · Free Three.js tutorial
The original example moves a torus down the Z axis while it grows. This tutorial keeps that motion and adds a bright rim, a soft glow, a laser beam, sparks, and a shockwave when the ring reaches its target.
LIVE DEMO
The layered rings travel into the target in the background. Launch the full-screen scene to fire pulses, cycle palettes, and tune the glow and speed.
All scene controls live in the full-screen demo. Drag to orbit, scroll to zoom, and press Esc to return.
Introduction
The original scene demonstrates a torus that grows while moving down the Z axis. That is a solid starting point, but a single flat yellow ring can read like a rotating prop instead of a weapon firing. Here, the torus remains the hero: we give it a bright inner edge, a softer outer halo, a short beam that connects the muzzle to the pulse, and a hit response at the target.
The added detail is intentionally bounded. A small pool of rings is reused, sparks live in one point buffer, and the impact shockwave is a single reusable mesh. The palette and glow controls change shared materials rather than rebuilding the scene. You can use the same firePulse() entry point from a mouse click, weapon key, or game event.
Code Breakdown
Follow the pulse from birth to impact. Each step includes code from the live scene, a beginner-friendly explanation, and a developer note about the implementation choice. Try firing the scene above between steps and watch for the feature being discussed.
1. Build a reusable pulse pool
const pulsePool = Array.from({ length: 7 }, () => {
const group = new THREE.Group();
const outer = new THREE.Mesh(ringGeometry, ringMaterial.clone());
const halo = new THREE.Mesh(haloGeometry, haloMaterial.clone());
group.add(halo, outer);
group.visible = false;
group.userData = { active: false, age: 0, hit: false, outer, halo };
pulsesRoot.add(group);
return group;
});
Beginner: Each pulse is a pair of torus rings: a sharp bright rim and a wider, softer halo. Seven are prepared up front, but hidden until fired.
Developer note: The pool caps simultaneous pulses and reuses their meshes. That avoids building and destroying Three.js objects during every shot.
2. Fire by activating an available pulse
const group = pulsePool.find(pulse => !pulse.userData.active);
if (!group) return;
const data = group.userData;
data.active = true;
data.age = 0;
data.hit = false;
group.position.copy(muzzle.position);
group.visible = true;
pulses.push(group);
Beginner: Find a resting ring pair, reset its timer, move it to the muzzle, and show it. If all seven are busy, this shot is skipped instead of creating unlimited objects.
Developer note: firePulse() is the weapon-facing API. Call it from a mouse, keyboard, animation, or gameplay event; keep input handling separate from VFX state.
3. Animate from elapsed time and add sparks
data.age += dt * speed;
const life = data.age / 1.12;
pulse.position.z = 4.2 - life * 8.7;
pulse.scale.setScalar(0.28 + life * 1.48);
data.outer.material.opacity = Math.max(0, 1 - life) * intensity;
if (data.sparkTick >= 0.075 && life < 0.82) {
emitSparks(0, 0, pulse.position.z, 3, pulse.scale.x);
data.sparkTick = 0;
}
Beginner: As the timer advances, the ring travels down the beam, grows, and fades. Small sparks peel away while it moves, making its direction and speed easier to read.
Developer note: dt is delta time, so travel is based on seconds rather than frame count. The spark system writes into one fixed 180-point buffer instead of allocating a mesh per spark.
Try it: Change the Pulse speed slider in the live scene. The same lifetime animation speeds up or slows down without changing the target position.
4. Trigger a distinct hit response
if (!data.hit && pulse.position.z <= -4.25) {
data.hit = true;
impactAge = 0;
impact.visible = true;
impact.scale.setScalar(0.18);
impact.material.opacity = 0.9;
impactLight.intensity = 5 * intensity;
emitSparks(0, 0, -4.1, 28);
}
Beginner: When the ring reaches the target, mark the hit once, flash the light, expand a new shockwave, and burst out extra sparks. Those signals make the impact feel stronger than simply stopping the ring.
Developer note: The hit guard prevents repeated impact events while the pulse remains at the target. In a game, replace the fixed Z threshold with your raycast or physics hit point.
1Add the effect to a game
Keep the reusable pulse factory in your weapon or VFX module. Call firePulse() when the weapon fires, and pass the muzzle world position if your gun moves. The tutorial demo uses a fixed emitter so the visual logic is easy to inspect.
Useful next changes
- Use a Raycaster or your physics engine to place impact at the actual hit point.
- Move the muzzle transform to the weapon socket before spawning each pulse.
- Use object pooling if your game can have many simultaneous shots.
- Try a slower, wider ring for a defensive shield pulse.
Frequently Asked Questions
How do you make a laser ring effect in Three.js?
Create a torus facing down the beam, move it forward, and expand its radius over its lifetime. Layer emissive rims, a short beam core, sparks, and a shockwave at the target to make the pulse read clearly.
How do you make a Three.js laser ring glow?
Use additive blending and a few concentric torus meshes with different widths and opacities. A post-processing bloom pass can add a softer halo, but the effect still works without bloom.
How can I trigger the laser effect from a game?
Put the effect in a reusable fire function and call it from your weapon input or game event. The demo exposes a fire method that can be connected to a mouse click, key press, or gameplay event.
How do you keep laser particles performant?
Reuse ring meshes and particle buffers, cap the number of live pulses, and avoid creating a new mesh for every spark. This demo keeps a small bounded set of pulse objects and reuses its spark geometry.
Can I change the laser color?
Yes. Change the shared color palette and apply it to the ring materials, beam, sparks, and impact light. The live demo cycles between cyan, magenta, and amber palettes.
The Full Code
Copy the compact starter below, or open a new full-screen instance of the complete live scene with its beam, pulse pool, sparks, impact shockwave, bloom, and in-scene controls.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
renderer.setClearColor(0x050814);
document.body.style.cssText = 'margin:0;overflow:hidden;background:#050814';
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050814);
scene.fog = new THREE.Fog(0x050814, 9, 25);
const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, 100);
camera.position.set(4, 3, 8);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, -2);
scene.add(new THREE.HemisphereLight(0x8ccfff, 0x111522, 1.5));
const floor = new THREE.GridHelper(24, 40, 0x15334c, 0x101d2d);
floor.position.z = -3;
scene.add(floor);
const beam = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.08, 8, 16, 1, true),
new THREE.MeshBasicMaterial({ color: 0x31f6ff, transparent: true, opacity: 0.34, blending: THREE.AdditiveBlending, depthWrite: false }));
beam.rotation.x = Math.PI / 2;
beam.position.z = 0;
scene.add(beam);
const rings = [];
const palettes = [0x31f6ff, 0xff42d0, 0xffb347];
let palette = 0, glow = 1, nextShot = 0;
function makeRing(radius, tube, opacity) {
const mesh = new THREE.Mesh(new THREE.TorusGeometry(radius, tube, 8, 64),
new THREE.MeshBasicMaterial({ color: palettes[palette], transparent: true, opacity, blending: THREE.AdditiveBlending, depthWrite: false }));
return mesh;
}
function firePulse() {
const group = new THREE.Group();
const rim = makeRing(0.52, 0.035, 1);
const halo = makeRing(0.58, 0.09, 0.24);
group.add(halo, rim);
group.position.z = 3.6;
group.userData = { age: 0, hit: false, rim, halo };
scene.add(group);
rings.push(group);
}
const hitRing = makeRing(0.8, 0.035, 0.9);
hitRing.position.set(0, 0, -4.15);
hitRing.visible = false;
scene.add(hitRing);
const hitLight = new THREE.PointLight(0x31f6ff, 0, 6);
hitLight.position.set(0, 0, -3.8);
scene.add(hitLight);
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
const time = clock.elapsedTime;
if (time > nextShot) { firePulse(); nextShot = time + 1.4; }
beam.material.opacity = (0.26 + Math.sin(time * 32) * 0.08) * glow;
for (let i = rings.length - 1; i >= 0; i--) {
const pulse = rings[i], data = pulse.userData;
data.age += dt;
const life = data.age / 0.95;
pulse.position.z = 3.6 - life * 7.8;
pulse.scale.setScalar(0.35 + life * 1.2);
data.rim.material.opacity = Math.max(0, 1 - life) * glow;
data.halo.material.opacity = Math.max(0, 0.28 * (1 - life)) * glow;
if (!data.hit && pulse.position.z < -3.8) {
data.hit = true;
hitRing.visible = true;
hitRing.scale.setScalar(0.15);
hitLight.intensity = 5 * glow;
}
if (life >= 1) { scene.remove(pulse); rings.splice(i, 1); }
}
if (hitRing.visible) {
hitRing.scale.multiplyScalar(1 + dt * 4);
hitRing.material.opacity = Math.max(0, hitRing.material.opacity - dt * 1.5);
hitLight.intensity = Math.max(0, hitLight.intensity - dt * 8);
if (hitLight.intensity === 0) { hitRing.visible = false; hitRing.material.opacity = 0.9; }
}
controls.update();
renderer.render(scene, camera);
}
addEventListener('resize', () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); });
animate();


