Trails
Trail3D - the ECS component that lays a fading world-space tube behind a moving entity, extruded on the CPU and drawn on Shade's dynamic-mesh path.
A trail records where an entity has been and draws a fading strip of geometry along that path. The engine has one trail component: Trail3D, which extrudes a volumetric tube around the path in world space, so it has real thickness from every viewing angle. It is a plain ECS component - attach it to an entity that has a Transform64, register Trail3DSystem, and the engine handles spawning, aging and drawing. Trail2D ships as a component but nothing renders it; see Trail2D renders nothing at the end of this page.
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/graphics3/Trail3DSystem.js";
// Register the system once (at bootstrap)
config.addSystem(new Trail3DSystem(engine.graphics));
// Attaching to an entity that already exists? 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);
Two import details: the system is a named export under src/engine/graphics3/ and its constructor takes the graphics facade; the component is a default export. Trail3DSystem3 is a deprecated alias of the same class - write the unsuffixed name.
Trail3D properties (all also settable through fromJSON):
| Property | Type | Default | Notes |
|---|---|---|---|
maxAge | number | 5 | Seconds before a knot has faded out completely 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 when the trail is built; alpha is read every tick as the trail’s base opacity |
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 |
Three properties that do nothing
textureURL, depthWrite and lightingEnabled exist on the component. They read, they write, they serialize, and they have no effect on what you see.
Shade draws trails on the dynamic-mesh path, which has one pipeline, one fixed vertex layout carrying position and colour, and one blend mode. There is no per-trail material to hang a texture on, no per-trail depth-write switch, and no lighting variant of the shader. All three are accessors over a TubeXMaterialSpec the component carries as trail.material; Trail3DFlags.Lit and the build-time assertion that guards lightingEnabled are there too. Nothing in the render path reads any of it: the spec is inert bookkeeping that keeps scene JSON round-tripping.
A trail that needs to read as lit matter rather than as an emitter is not something this component can do. Either accept the flat colour, or build the geometry yourself on the dynamic-mesh path.
How a trail reaches the screen
Trail3DSystem registers a render extension that draws at FramePhase.AfterTransparency, straight alpha over the finished scene, depth tested and never depth written. That phase means trails are concealed by the fog of war along with particles, path arcs and highlights.
Every trail is rewritten every frame it exists, moving or not. The simulator ages every knot on every tick and the age is what the vertex alpha carries, so there is no such thing as a quiet trail - a trail that has stopped is a trail fading out. Removing the component is what stops the work.
The ring extrusion runs on the CPU. trail_tube_write_geometry(geometry, tube) (from src/engine/graphics3/trail/trail_tube_write_geometry.js) places each ring vertex at cos(angle) * normal + sin(angle) * binormal, half a thickness out from its knot, once per knot ring per frame. The dynamic path’s fixed vertex layout has nowhere to put a tangent frame, so a shader cannot do it. This is what makes radialSegments and maxAge worth keeping honest: the per-frame cost of a trail is knots x radialSegments vertices written from JavaScript.
The geometry is written into rather than replaced, because GPU residency on this path is keyed on the geometry object - a trail that allocated a fresh Geometry each frame would strand a vertex buffer per frame.
Knot count
The system sizes the tube from maxAge when the trail is built: 60 knots per second of life, clamped to between 2 and 1024. A 0.5 s trail gets 30 knots, a 3 s trail 180. Past roughly 17 s the cap bites, and a longer maxAge buys duration rather than detail - the same 1024 knots are simply spread over more time.
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 knot every maxAge / knotCount seconds. 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 |
Either way a knot is only committed when the head has actually moved, so a stationary emitter does not burn through its knots.
Colour, opacity and fading
RGB is written into the knots when the trail is built and never revisited, so recolouring color.r/g/b on a live trail does not change the knots already laid down.
Alpha is different: color.a is handed to the simulator every tick as the base opacity that the age fade scales, so setting it on a live trail fades the whole tube from the next frame. It is a working master opacity, not a build-time seed.
The per-knot fade itself is the simulator’s and cannot be overridden - every knot fades linearly to zero as its age approaches maxAge.
To gate a trail on a condition, animating width usually reads better than dropping opacity, because width is written per head knot: the tube already on screen keeps its thickness and the new end tapers away to nothing, instead of the whole streak dimming at once.
// 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 ages out every knot and raises a reseed flag, and the next head update collapses the whole tube onto the entity’s new position before spawning resumes, so no segment can bridge the two.
Beams: a trail that is born whole
A normal trail draws itself by travelling. A beam does not travel - it leaves its source and arrives at its target inside one frame - so there is nothing to lay it down with. make_gradient_stroke builds a Trail3D that already has its whole shape: a stroke between two world points that only ages.
import { make_gradient_stroke } from "@woosh/meep-engine/src/engine/graphics/ecs/trail3d/make_gradient_stroke.js";
const beam = make_gradient_stroke({
from: muzzlePosition, // Vector3, world space
to: targetPosition, // Vector3, must not coincide with `from`
color: 0xffb02e, // packed RGB
thickness: 0.15, // world units
duration: 0.35, // seconds to fade out completely
age_from: 0.6, // the source end is born 60% through its life
age_to: 0,
texture: "" // asserted to be a string, but inert - see above
});
ecd.addComponentToEntity(entity, beam);
The gradient is nothing more than the two ends’ starting ages: the end seeded older thins out first, so a stroke whose source end is born most of the way through its life reads as retracting towards its target. age_from and age_to are fractions of duration and are asserted to be in [0,1].
make_gradient_stroke clears Trail3DFlags.Spawning for you, which is what stops the per-frame update dragging the head onto the entity and growing a tail towards it. The entity still needs a Transform64 - that is Trail3DSystem’s dependency - but the stroke does not follow it.
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 { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { t64_announce_change } from "@woosh/meep-engine/src/engine/ecs/transform/t64_announce_change.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
import Trail3D from "@woosh/meep-engine/src/engine/graphics/ecs/trail3d/Trail3D.js";
import { Trail3DSystem } from "@woosh/meep-engine/src/engine/graphics3/Trail3DSystem.js";
import { make_octahedron_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_octahedron_geometry.js";
import { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
import { StandardShadeMaterial } from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";
import { Color } from "@woosh/meep-engine/src/core/color/Color.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.graphics, EngineHarness.shadeScene(engine)));
config.addSystem(new Trail3DSystem(engine.graphics));
},
});
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;
// The emitter: a small bright sphere. Shade has no sphere primitive - a
// subdivided octahedron is one, every vertex of it landing on the radius.
const sphereGeometry = meshlet_geometry_build_from_geometry(make_octahedron_geometry(0.22, 3));
const sphereMaterial = new StandardShadeMaterial();
sphereMaterial.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse("#ffb02e")));
const mesh = ShadedGeometry.from(sphereGeometry, sphereMaterial);
// The trail: a 1.2 s orange tube, ~0.3 units thick. 1.2 s of life is 72 knots.
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 Transform64();
t.setTranslation(2.2, 0, 0);
// Entity.build registers the component types it carries, so Trail3D needs no
// explicit registerComponentType here.
const entity = new Entity()
.add(t)
.add(mesh)
.add(trail)
.build(ecd);
// Fly a figure-eight; Trail3DSystem reads the Transform64 every tick and lays
// the tube down behind it. The sphere is placed on announcement rather than on a
// poll, so the write ends with one - without it the tube flies and the ball does
// not.
const start = performance.now();
engine.graphics.on.preRender.add(() => {
const s = (performance.now() - start) / 1000;
t.setTranslation(
Math.sin(s * 1.7) * 2.2,
Math.sin(s * 3.4) * 0.8,
Math.cos(s * 1.7) * 2.2,
);
t64_announce_change(ecd, entity);
});Trail2D renders nothing
Trail2D (src/engine/graphics/ecs/trail2d/Trail2D.js) ships as a component and serializes, and a few utilities reference it. Nothing draws it. There is no Trail2DSystem and no screen-facing ribbon renderer. A Trail2D attached to an entity is inert data.
What to use, depending on what the ribbon is for:
- A beam or a streak:
make_gradient_stroke, above, which produces aTrail3D. - A trail behind a moving thing:
Trail3D, the only trail the engine draws. - A camera-facing ribbon: build it yourself on the dynamic-mesh path. That is the same machinery
Trail3Druns on - aGeometryyou rewrite each frame inside aDynamicMesh, drawn by a render extension of yours - and the screen-facing expansion is yours to write, since no shader does it.
See also
- Effects - the dynamic-mesh path in full, plus decals, outlines and camera shake
- Particles & VFX - GPU simulations authored as node graphs with ParticleEffect
- Rendering overview - what Shade is and how ECS systems bind to it