Rendering

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):

PropertyTypeDefaultNotes
maxAgenumber5Seconds before a knot fades out and disappears
widthnumber1Tube diameter. Written into each new head knot, so animating it tapers the tube
radialSegmentsnumber8Subdivisions around the tube. Build-time only — changing it after the trail is built has no effect
spawnModeTrailSpawnModeTimeWhen a new knot is committed (see below)
spawnDistancenumber1Distance between knots when spawnMode is Distance
colorColorwhiteRGB is baked into the knots at build time; alpha only seeds the build
offsetVector3(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
textureURLstring|nullnullDiffuse texture; null renders a vertex-colour tube
depthWritebooleanfalsetrue makes the tube write depth — it occludes and self-sorts, for thick solid-looking trails
lightingEnabledbooleanfalsetrue 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:

ModeKnots are spaced evenly inBehaviour
Time (default)timeA fast emitter draws a long trail, a slow one a short trail; both last maxAge seconds
DistancespaceA 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:

live
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.