Two laser hits, two independent bursts in Three.js
Two laser hits arrive a tenth of a second apart. The first burst is still alive when the second hits a differently angled wall. This is where a single reusable effect object becomes awkward: restarting it removes the first burst, and changing a shared transform couples two impacts that should be independent.
This walkthrough uses one short-lived instance per impact, with a small concurrency cap. It focuses on overlapping hits and lifecycle ownership. Disclosure: it was prepared with AI assistance for a NixieFX marketing assignment. The screenshot and source come from the local example described below, not a production game or performance benchmark.
Run the two-wall example
Download the complete example source (ZIP)
Unzip the source into an empty directory. With Node.js 22 installed, run:
npm install
npm test
npm run build
python3 -m http.server 8080
Open http://localhost:8080. Two quick hits creates the first burst, advances ten 0.01-second steps, creates the second, then advances ten more steps. The paused view therefore shows the first at 0.20 seconds and the second at 0.10 seconds. Play resumes time; Finish advances another 1.50 seconds; Dispose releases this example’s resources. Reload to run again after disposal.
The package pins nixie-fx 0.1.20, Three.js 0.185.1, and esbuild 0.28.2. The supplied check ran with Node.js 22.23.2, and the visual example ran in Chrome 155 on a Mac. Install access to npm and a browser with WebGL are prerequisites. No external texture files or account are needed for the fixture.
Mistake one: treating every shot as the same burst
Keep a set of live instances. Each confirmed wall hit creates a new instance with its own position, rotation, and seed. Update the renderer once per frame in seconds, then remove instances that have completed. Do not also update each renderer-owned instance separately.
The source’s Impacts class caps concurrent instances at four. A fifth hit is ignored until one finishes. That explicit policy prevents a rapid-fire input from creating an unbounded collection; a game could choose another measured budget. This cap is a gameplay integration choice, not an automatic runtime limit.
The tiny effect emits twelve procedural triangle shards once, with a 0.55-second lifetime and a 2.2-unit-per-second initial speed. Continuous emission is zero. The cone points along local positive Y, with a 65-degree angle and a small initial radius. Mesh size is 0.095 units with its size module enabled. Those values are deliberately easy to inspect, not a universal recipe for sparks.
If you are choosing a three js particle editor, the NixieFX Three.js runtime guide explains the exported bundle, backend validation, and instance lifecycle used here. The downloadable fixture constructs and compiles its small authoring definition in code, then verifies it with loadVfxExportBundle before spawning. An editor-authored project should export the runtime bundle and load that bundle through the same validation boundary.
Mistake two: using a local normal as a world direction
A raycast face normal belongs to the mesh’s local coordinates. Convert it with the normal matrix after updating the object’s world matrix. This is especially important when the wall has nonuniform scale. Orient the outward burst away from the incoming ray:
const normal = hit.face.normal.clone().applyNormalMatrix(
new THREE.Matrix3().getNormalMatrix(hit.object.matrixWorld)
);
if (normal.dot(incomingDirection) > 0) normal.negate();
normal.normalize();
const q = new THREE.Quaternion().setFromUnitVectors(
new THREE.Vector3(0, 1, 0), normal
);
const euler = new THREE.Euler().setFromQuaternion(q, 'XYZ');
const position = hit.point.clone().addScaledVector(normal, 0.025);
const burst = vfx.createEffect(effect, {
position: position.toArray(),
rotation: [euler.x, euler.y, euler.z],
seed: nextSeed++
});
Instance rotations use radians. The small position offset moves the initial shard centers in front of the wall. This mesh-particle example rotates each complete instance once and leaves that transform fixed for its lifetime. It does not use camera-facing billboards or move a live burst between impacts.
The beam lines and green arrows are ordinary Three.js debugging helpers. The raycast chooses a surface hit; there is no collision simulation, wall decal, damage system, or dynamic light in this example.
Mistake three: confusing appearance with support
The effect uses unlit additive rendering with depth testing enabled and depth writing disabled. Mesh particles follow the per-particle rendering path here, so twelve shards are not one instanced draw call. Do not extrapolate the small fixture into a mobile frame-rate claim. Measure your scene and target devices before increasing particle counts.
Current adapter limitations also matter: the constructor’s clockSpace option is reserved and unused, and texture offset/repeat are not applied on the instanced billboard path. Neither is used here. Preview bloom is disabled; additive color does not create a postprocessing bloom pass. Inspect the export support report and runtime unsupportedFeatures rather than assuming every authored setting carries across unchanged.
Mistake four: leaving completed effects attached
The local assertions observed two instances and 24 active particles during overlap, with the first instance’s origin unchanged. Both transformed-wall bursts had particle centers on the outward side. The fifth concurrent hit was rejected at the configured cap. After another 1.50 seconds, active particles and instance count were both zero. These are correctness checks for this fixture, not performance results.
Scene disposal stops the animation loop, removes the example’s handlers, clears its live set, destroys the VFX renderer, and disposes its own wall, beam, helper, and renderer resources. Shared provider-owned assets would need separate ownership rules; this fixture creates none.
Before adapting it, check both bursts remain visible at their original hits, test the cap under rapid input, verify completion reaches zero, and unload the scene while particles are alive. That sequence catches the integration mistakes a single attractive impact screenshot cannot show.