Trails
Trail3D and Trail2D — ECS components that draw motion trails behind moving entities, as world-space tubes or screen-facing ribbons.
A trail records where an entity has been and draws a fading strip of geometry along that path. Meep ships two trail components. Trail3D extrudes a volumetric tube around the path in world space — it has real thickness from every viewing angle and is the right default for most effects. Trail2D extrudes a screen-facing ribbon in the shader — flat geometry that always turns toward the camera, cheaper per segment and fine for thin streaks. Both are plain ECS components: attach one to an entity that has a Transform, add the matching system, and the engine handles spawning, aging, and rendering.
Trail3D
Trail3D (from src/engine/graphics/ecs/trail3d/Trail3D.js) lays down a tube of knots behind the entity. Each knot carries a parallel-transported tangent frame, so the tube’s cross-section ring stays smoothly oriented along the centre-line even through tight curves.
import Trail3D from "@woosh/meep-engine/src/engine/graphics/ecs/trail3d/Trail3D.js";
import Trail3DSystem from "@woosh/meep-engine/src/engine/graphics/ecs/trail3d/Trail3DSystem.js";
// Register the system once (at bootstrap)
config.addSystem(new Trail3DSystem(engine));
// Attaching at runtime? Register the component type first.
ecd.registerComponentType(Trail3D);
const trail = new Trail3D();
trail.maxAge = 0.5; // knots live half a second
trail.width = 0.4; // tube diameter, world units
trail.color.set(1.0, 0.55, 0.15, 1);
ecd.addComponentToEntity(entity, trail);
Trail3D properties (all also settable through fromJSON):
| Property | Type | Default | Notes |
|---|---|---|---|
maxAge | number | 5 | Seconds before a knot fades out and disappears |
width | number | 1 | Tube diameter. Written into each new head knot, so animating it tapers the tube |
radialSegments | number | 8 | Subdivisions around the tube. Build-time only — changing it after the trail is built has no effect |
spawnMode | TrailSpawnMode | Time | When a new knot is committed (see below) |
spawnDistance | number | 1 | Distance between knots when spawnMode is Distance |
color | Color | white | RGB is baked into the knots at build time; alpha only seeds the build |
offset | Vector3 | (0,0,0) | Offset from the entity to the trail head, in the entity’s local frame — it rides the entity’s rotation and scales with it |
textureURL | string|null | null | Diffuse texture; null renders a vertex-colour tube |
depthWrite | boolean | false | true makes the tube write depth — it occludes and self-sorts, for thick solid-looking trails |
lightingEnabled | boolean | false | true shades the tube as a matte surface under the scene’s lights instead of rendering unlit (see below) |
The system sizes the tube automatically: 60 knots per second of maxAge, capped at 1024. Materials are cached and shared across trails through TubeXPlugin (acquired lazily — no registration needed); trails that agree on the material-defining properties (textureURL, depthWrite, lightingEnabled) share one material.
Lit trails
By default a trail renders unlit — the knot colour is what you see, which is right for tracers, energy ribbons, and anything that reads as an emitter. Set lightingEnabled = true and the tube instead shades as a matte (Lambert) surface under the scene’s ambient, hemisphere, light-probe, directional, point, and spot lights, and receives shadows (it never casts them). Under the Forward+ pipeline the renderer’s material manager rewrites the material as well, so clustered point lights and decals apply too. Use it for trails that are meant to be matter — dust, smoke ribbons, a snow plume behind a skier — that should sit in the scene’s lighting rather than glow through it.
It’s a build-time option: it selects the compiled material and the mesh’s shadow settings, so set it before the trail is registered with the system. Setting it on a built trail flips the flag but not the compiled material, so nothing changes on screen — in a development build the assertion catches it; in a production build with assertions stripped it passes silently.
Spawn modes
TrailSpawnMode (from src/engine/graphics/trail/TrailSpawnMode.js) controls when the head commits a new knot:
| Mode | Knots are spaced evenly in | Behaviour |
|---|---|---|
Time (default) | time | A fast emitter draws a long trail, a slow one a short trail; both last maxAge seconds |
Distance | space | A knot every spawnDistance world units, regardless of speed — uniform geometry with no stretching under acceleration |
Fading a trail in and out
Knot alpha is owned by the tube simulator: every knot fades linearly to zero as it approaches maxAge. Setting trail.color.a after the trail is built has no effect — gate visibility by animating width instead. A zero-diameter tube is invisible, and since width is written per head knot, a lerp gives a smooth taper:
// e.g. show the trail only at speed
const target = speed > 17 ? 0.55 : 0;
trail.width += (target - trail.width) * Math.min(1, dt * 8);
Teleporting the emitter
Moving an entity discontinuously (respawn, kickoff reset) would otherwise draw a streak from the old position to the new one. Call trail.clear() on teleport: it hides all current knots and collapses the tube onto the entity’s next position before spawning resumes.
Live example
A sphere flying a figure-eight with a Trail3D behind it — drag to orbit, scroll to zoom:
Source
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js";
import Trail3D from "@woosh/meep-engine/src/engine/graphics/ecs/trail3d/Trail3D.js";
import Trail3DSystem from "@woosh/meep-engine/src/engine/graphics/ecs/trail3d/Trail3DSystem.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
config.addSystem(new ShadedGeometrySystem(engine));
config.addSystem(new Trail3DSystem(engine));
},
});
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: true,
enableShadows: false,
focus: new Vector3(0, 0, 0),
distance: 7,
pitch: 0.5,
yaw: 0.3,
showFps: false,
});
const ecd = engine.entityManager.dataset;
ecd.registerComponentType(Trail3D);
// the emitter: a small glowing sphere
const mesh = ShadedGeometry.from(
new THREE.SphereGeometry(0.22, 24, 16),
new THREE.MeshStandardMaterial({ color: 0xffb02e, emissive: 0xff7043, emissiveIntensity: 0.7 }),
);
// the trail: a 1.2 s orange tube, ~0.3 units thick
const trail = new Trail3D();
trail.maxAge = 1.2;
trail.width = 0.3;
trail.color.set(1.0, 0.55, 0.15, 1);
const t = new Transform();
t.position.set(2.2, 0, 0);
new Entity()
.add(t)
.add(mesh)
.add(trail)
.build(ecd);
// fly a figure-eight; Trail3DSystem reads the Transform every frame and lays
// the tube down behind it
const start = performance.now();
engine.graphics.on.preRender.add(() => {
const s = (performance.now() - start) / 1000;
t.position.set(
Math.sin(s * 1.7) * 2.2,
Math.sin(s * 3.4) * 0.8,
Math.cos(s * 1.7) * 2.2,
);
});Trail2D
Trail2D (from src/engine/graphics/ecs/trail2d/Trail2D.js) is the flat counterpart: a camera-facing ribbon built on RibbonX. Same attach-a-component workflow with Trail2DSystem, and a near-identical surface — maxAge, width, color, textureURL, offset, clear(). Use it when the trail should always face the screen (thin light streaks, projectile tracers) or when you need many trails cheaply; use Trail3D when the trail must read as a solid object in the world.
For the low-level geometry underneath both — RibbonX ring buffers, tube attribute layouts, manual material specs — see Effects → Ribbons and trails.