Rendering

Splines, ribbons and lines

Curves sampled by arc length, banked surfaces swept along them with gaps and colliders, the exact ground on a route, and world-space strokes with real width.

Install npm install @driftengine/core

Three tools for anything that follows a path: a Spline says where the path goes, buildRibbon sweeps a surface along it, and RibbonSurface answers where the ground is on it. Separately, a line batch draws strokes with a real width in metres, for trajectories, trails and debug rays.

A spline

examples/snippets/splines.ts
/** Control points carry a bank, in radians, and a width, in metres, as well as a position. */
const points: SplinePoint[] = [
  { x: 0, y: 0, z: 0, bankRad: 0, widthM: 8 },
  { x: 40, y: 4, z: -30, bankRad: 0.35, widthM: 7 },
  { x: 90, y: 2, z: -20, bankRad: -0.2, widthM: 7 },
  { x: 130, y: 10, z: 30, bankRad: 0, widthM: 9 },
];
const route = new Spline(points);
console.info(`${route.lengthM.toFixed(1)} m long`);

Spline is a Catmull-Rom curve sampled by arc length, so samples are evenly spaced in metres whatever the curve does. Sampling by parameter bunches them in tight corners and stretches them on straights, which would give a swept surface huge triangles on the fast sections and a width that pulses through every bend.

Each control point carries a bank, the roll about the direction of travel, and a width. Both follow the points through a smooth curve, so there is no crease at a control point. sampleAt(distanceM, out) gives the position at a distance along the route with its frame: the tangent, the surface normal after banking, and the right-hand direction. The frame is carried along from the previous sample, never rebuilt from world up, so it cannot flip when the route climbs vertically.

A ribbon

examples/snippets/splines.ts
/** A slab swept along the route, with one gap a player has to jump. */
export function buildRoute(renderer: RendererApi) {
  const holes = [{ fromM: 70, toM: 78 }];
  const { mesh, colliders } = buildRibbon(route, {
    color: [0.36, 0.38, 0.44],
    stepM: 1,
    thicknessM: 0.6,
    holes,
  });
  const surface = new RibbonSurface(route, { holes });
  return { mesh: renderer.createMesh(mesh), colliders, surface };
}

buildRibbon sweeps a closed slab along the spline and returns mesh data and colliders built from the same corners, so what you see is what you hit. Holes are stretches where the surface isn't there; geometry and colliders both respect them, because a gap that is drawn but still solid looks like a jump and plays like a floor.

The options build more than a road: edgeBandM and edgeSide build only a strip along one edge, lateralFromM and lateralToM a band between two offsets, liftM raises it, so a kerb or a painted line is another ribbon over the same spline. shade varies the colour per cross-section, and fromM and toM build one stretch.

The ground on a route

examples/snippets/splines.ts
const hit = createSurfaceHit();

/** Where the ground is in a column, and where along the route that is. */
export function groundUnder(
  surface: RibbonSurface,
  x: number,
  z: number,
  y: number,
): number | null {
  if (!surface.sample(x, z, hit, y)) return null; // over a gap, or off the route
  // hit.distanceM is how far along, hit.lateralM how far off the centre line, hit.normal* the face.
  return hit.y;
}

RibbonSurface answers the question a character controller and a vehicle actually ask: in this column, where is the surface, and which way does it face? It answers with the exact banked surface, not the colliders' boxes, whose flat tops sit at the height of the highest corner. It also says how far along the route the point is, how far off the centre line, and how much width is left before the edge, which is most of what a racing game's rules need.

A query costs a grid lookup and a fixed number of Newton steps. The count is fixed so that it is the same on every machine, which determinism needs.

RibbonSurface is one kind of GroundSurface. BoxSurface is another, for flat pads; heightSurface wraps a function of your own, or several stacked, so a road can pass under a road; colliderSurface turns a set of colliders into ground; and CompositeSurface asks several and keeps the best answer. The character controller and the vehicle both take one as their ground.

Lines with real width

Starts examples/lines in this page, on WebGPU where your browser has it.

Open on its own · Every file · Source

WebGL2 draws one-pixel lines on almost every driver, and WebGPU draws no wide lines at all, so the engine expands each segment into a quad that faces the camera.

examples/lines/main.ts
/** One stroke of 220 segments, allocated once. `capacity` is segments, not points. */
const POINTS = 221;
const segments = createLineSegments(POINTS - 1);
const batch = stage.renderer.createLines(POINTS - 1, 'example-trail');
const path = new Float32Array(POINTS * 3);

A segment buffer and a line batch are allocated once, at a fixed capacity. capacity counts segments, one fewer than the points that make them.

examples/lines/main.ts
segments.count = setPolyline(segments, path, POINTS);

stage.renderer.beginFrame([0.05, 0.06, 0.09]);
stage.renderer.bindMeshPass(stage.camera, DAYLIGHT);
stage.renderer.drawMesh(ground, still.worldMatrix);
stage.renderer.drawLines(
  batch,
  segments,
  still.worldMatrix,
  stage.camera,
  DAYLIGHT,
  [1, 0.72, 0.32],
  width,
  1,
  softness,
  /* The floor: at least two millimetres of half-width per metre away, about a pixel. */
  0.002,
);

setPolyline rewrites the buffer in place from a flat list of points and returns how many segments are live. drawLines takes the batch, the segments, a model matrix, the camera, the environment, then a colour, a width in metres, an opacity, an edge softness, and a minimum width per metre of distance, which stops a far stroke from thinning below a pixel and flickering. A last additive argument makes a stroke add light instead of covering what is behind it.

Lines are blended and fogged like anything else in the world. For a stroke that is light itself, an arc or a beam, see Particles and effects, which draws bolts.

This page's source, on GitHub