DEV Community

Sam Novak
Sam Novak

Posted on Fully Autonomous

A Three.js sword-slash trail on a running character: mount it on the scene or on the hero?

A sword-slash trail that looks right with the character standing still can turn into a long smear once the character runs and the camera follows. I measured why, with NixieFX 0.1.20 and three.js r185, and tried two places to put the trail. One fixed the smear and caused a new problem when the character turns. Both are below, with numbers.

The setup

  • Effect: a billboard particle trail with no velocity. simulationSpace: "world", rateOverDistance: 60 per unit, lifetime 0.2 to 0.3 s, shrink and fade over life. It's a NixieFX project exported to runtime JSON. NixieFX is a web game VFX editor, but nothing here depends on the editor UI, only on the exported file and the Three.js runtime.
  • Swing: the blade tip sweeps 150° to 30° on a 1.2-unit radius in 0.25 s. Each 1/60 s frame is split into 4 sub-steps so the arc doesn't clump.
  • Hero: a box with the hilt at (0.2, 1.1, 0) in hero space, running along +X at 6 units/s.
  • Camera: rigid follow at a fixed offset from the hero, so the hero stays in the same spot on screen and the world slides past.
  • End: allowCompletion() when the swing ends, while the hero keeps running.

The only difference between the runs is what the renderer is mounted on, and which space the tip position is passed in:

const vfx = new ThreeVfxRenderer({ scene, camera });
vfx.mount(mountMode === "hero" ? hero : scene);

// setTransform positions are in the space of the
// object the renderer is mounted on.
function tipForTrail() {
  const w = hilt.localToWorld(TIP_LOCAL.clone());
  return (mountMode === "hero" ? hero.worldToLocal(w) : w)
    .toArray();
}

// every sub-step during the swing:
placeHero(t);
setAngle(deg);
trail.setTransform({ position: tipForTrail() });
vfx.update(DT / SUBSTEPS);
Enter fullscreen mode Exit fullscreen mode

What happened

Four frames from the demo. A: standing. B: running with the renderer on the scene, a long flat smear. C: running with the renderer on the hero, same arc as A. D: as C, but the hero turns 90 degrees after the swing and the trail turns edge-on.

Measured at the end of the swing. "Behind hilt" is how far the oldest particle sits behind the hilt, measured in hero space. "On screen" is the horizontal span of the particles on the 720 × 405 canvas.

Run Live particles Behind hilt Arc around hilt On screen
A: standing, renderer on scene 141 0.97 u 114° 184 px
B: running, renderer on scene 214 2.44 u 134.7° 306 px
C: running, renderer on hero 141 0.97 u 114° 184 px

B is the smear. In world space each particle stays where it was born, and the hero has moved by the time it dies. The oldest particles end up 2.44 units behind the hilt instead of 0.97. The follow camera makes it look worse: the hero stays still on screen, the posts in the background slide left, and the trail slides left with them. It reads as part of the background, not as part of the swing.

B also has 52% more particles. rateOverDistance counts the distance the emitter moved in world space, and in B that includes the 1.5 units the hero ran during the swing. Same effect, same seed: 214 particles instead of 141. If you budget particles from a standing preview, a running attack costs more than the preview showed.

C matches A exactly. With the renderer mounted on the hero root and the tip passed in hero space, the run speed drops out. Every number in C is identical to the standing swing. The tip still moves in hero space during the swing, so distance-based emission keeps working; only the run is taken out of it.

The catch: the trail turns with the hero

Mounting on the hero means the trail inherits everything the hero does, including rotation. In run D the hero turns 90° (yaw) over 0.1 s right after the swing, which is a normal thing for a player to do.

0.1 s after the swing Live On screen
B: renderer on scene 139 232 px
C: renderer on hero, no turn 90 152 px
D: renderer on hero, 90° turn 90 41 px
B with the same 90° turn 139 232 px

The hero-mounted trail swings around with the body and ends up edge-on to the camera, so it nearly disappears. The scene-mounted trail ignores the turn because it was never attached to the hero.

There isn't one right mount. If the attack is about reading the arc relative to the character (a combo, a parry window), C is easier to read and costs fewer particles. If the character turns sharply mid-fade, or the trail is long-lived, B can look more natural. With a 0.3 s lifetime the turn problem lasts 0.3 s at most. I didn't test a hybrid, such as switching the mount mid-trail, so I'm not recommending one.

Inspecting attachment and lifetime

The demo prints a version of this every frame (trimmed here). getParticleDebugTransforms() returns positions in the renderer's root space, so they go to world space through the root's matrix, then to hero space:

function inspect() {
  const root = mountMode === "hero" ? hero : scene;
  root.updateMatrixWorld(true);
  const toHero = hero.matrixWorld.clone().invert();
  const tip = hilt.localToWorld(TIP_LOCAL.clone());
  const ps = trail.getParticleDebugTransforms().map((p) => {
    const world = new THREE.Vector3(...p.position)
      .applyMatrix4(root.matrixWorld);
    return { world, hero: world.clone().applyMatrix4(toHero) };
  });
  return {
    live: ps.length,
    behindHilt: Math.min(...ps.map((p) => p.hero.x - HILT.x)),
    nearestToTip: Math.min(...ps.map((p) => p.world.distanceTo(tip))),
  };
}
Enter fullscreen mode Exit fullscreen mode

Two checks came out of it:

  • Attachment: at the end of the swing the nearest particle to the blade tip is 0.000 units away in all three runs, so the newest particle is where the blade is. After the swing, B's nearest particle drifts away from the tip (0.30 units after 0.05 s, 0.60 after 0.1 s) because the hero keeps running and the trail doesn't. C's stays at 0.
  • Lifetime: after allowCompletion() the trail is empty 0.30 s later in A and C, and 0.283 s later in B. Both fit the 0.2 to 0.3 s lifetime, which is picked at random per particle.

What's supported, and what I didn't test

The renderer's getSupport() reports this effect as supported on Three.js, with no warnings. When I switched other modules on in a copy of the effect, it reported lights and subEmitters as blocked on the Three.js renderer in 0.1.20, so a trail that spawns sparks on death or lights the blade won't work in this setup. This trail uses only spawn, color and size. I didn't use the ribbon trails module.

The NixieFX Three.js runtime guide covers mount(), setTransform(), allowCompletion() and the support report used here.

Limits

  • One desktop Chromium browser, fixed 1/60 s frames with 4 sub-steps, seed 7. Particle counts will differ slightly with another seed.
  • The camera is a rigid follow. A smoothed or lagging camera changes B's on-screen numbers, though not the hero-space ones.
  • The hero is a box with a rotating blade, not an animated character. Root motion from a real animation clip would need the same "which space" decision for the root bone.
  • No mobile testing, and no measurement of frame time.

Drafted with AI assistance. The demo was built and measured in a Claude Code session, and the numbers above are from those runs.

Top comments (0)