Right now, when we want to know the position and forward direction along a curve, we ask two separate functions. This works, but having two separate functions for the same node on the track is odd and adds more complexity than we actually need.

Earlier we mentioned that there is a better way to describe this kind of information. That better way is a 4x4 transformation matrix.

A transformation matrix can hold position and directions at the same time. This is exactly what we need to describe a node along the track. Once we switch to transformation matrices, the two existing functions forwardDirectionAtArcLength and positionAtArcLength naturally collapse into one. Instead of asking the track for separate pieces of data, we ask for a transformation matrix at a distance along the curve and extract whatever information we need from it.

For that reason, we make a small but important change. From now on, we work with tranformation matrices and remove those two functions entirely.

If it helps, it is worth rereading the chapter on linear roller coaster tracks, where forwardDirectionAtArcLength and positionAtArcLength were introduced. Both are now replaced by a single function called transformationAtArcLength.

For a linear track segment, this is straightforward. We compute the position exactly like before using linear interpolation. For orientation, we let THREE.js build a matrix that lookAts from one control point to the other. This gives us a correct forward direction without any extra work.

Note: At this stage, we only need a valid forward direction. There is no need to complicate things with roll, so we ignore it for now.

const transformationAtArcLength = (
  cp1: Vector3,
  cp2: Vector3,
  at: number,
) => {
  const position = cp1
    .clone()
    .lerp(cp2, at / totalArcLength(cp1, cp2));

  return new Matrix4()
    .lookAt(cp2, cp1, new Vector3(0, 1, 0))
    .setPosition(position);
};

With this change, we also need to adjust evaluateMotion. Instead of receiving a forward direction vector, it now receives a transformation matrix. We then extract the forward direction from that transformation.

This is very straightforward. Here is a small helper to extract the forward direction from a transformation matrix:

const toFrontDirection = (m: Matrix4) => {
  return new Vector3(
    m.elements[8],
    m.elements[9],
    m.elements[10],
  ).normalize();
};

Now we use this helper to extract the forward direction:

const forwardDirection = toFrontDirection(transformation);

With that in place, the full updated evaluateMotion function looks like this:

const evaluateMotion = (
  state: SimulationState,
  transformation: Matrix4,
  friction: number,
  airResistance: number,
  gravity: number,
  deltaTime: number,
): SimulationState => {
  const forwardDirection = toFrontDirection(transformation);
  const velocityDirection = state.velocity < 0 ? -1 : 1;

  let energyLoss = airResistance * state.velocity * state.velocity;
  energyLoss += friction * gravity;
  energyLoss *= velocityDirection;

  let acceleration = forwardDirection.dot(
    new Vector3(0, -gravity, 0),
  );
  acceleration -= energyLoss;

  const velocity = state.velocity + acceleration * deltaTime;
  const distanceTraveled =
    state.distanceTraveled + velocity * deltaTime;

  return { velocity, distanceTraveled, acceleration };
};

What comes next?

These changes are mostly structural. Visually, nothing changes yet. Under the hood, however, the simulation now works entirely with transformation matrices, which will make later changes easier.

In the next chapter, we move away from linear segments and introduce a more general way to describe curves. That will require a new implementation of transformationAtArcLength, but the rest of the simulation can stay as it is.

This is the kind of refactor we want. We change how the track is described without rewriting everything built on top of it.

Interactive demo and code

There is no visible difference in the demo. The train behaves exactly as before. The only change is that the simulation now uses transformationAtArcLength internally.

For completeness, the full demo is shown below.

If you want to run the code locally, clone this repository, run npm install and npm run dev-scripts, then open this link in your browser.

import React, { useEffect, useState } from 'react';
import { Line } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import { MathUtils, Vector3 } from 'three';

import {
  totalArcLength,
  transformationAtArcLength,
} from '../../../../maths/linear';
import { evaluateMotion } from '../../../../helper/physics';
import { useColors } from '../../../../hooks/useColors';
import { useSimulationStateControls } from '../../../../hooks/useSimulationStateControls';

import { DragControlPoints } from '../../../../components/curve/DragControlPoints';
import { PointWithMatrixArrows } from '../../../../components/PointWithMatrixArrows';
import { OrthographicScene } from '../../../../components/scenes/OrthographicScene';

const TransformationMatrixDemo = () => {
  const colors = useColors();

  // control points
  const [points, setPoints] = useState([
    new Vector3(-11.5, 3.2, 0),
    new Vector3(0, -2.8, 0),
  ]);

  const [simulationState, setSimulationState] =
    useSimulationStateControls();

  useFrame((state, deltaTime) => {
    setSimulationState(
      evaluateMotion(
        simulationState,
        transformationAtArcLength(
          points[0],
          points[1],
          simulationState.distanceTraveled,
        ),
        simulationState.friction,
        simulationState.airResistance,
        simulationState.gravity,
        deltaTime * simulationState.simulationSpeed,
      ),
    );
  });

  // reset simulation state if train overshoots track
  useEffect(() => {
    if (
      simulationState.distanceTraveled >
        totalArcLength(points[0], points[1]) ||
      simulationState.distanceTraveled < 0
    ) {
      setSimulationState({
        velocity: 0,
        distanceTraveled: 0,
        acceleration: 0,
      });
    }
  }, [simulationState.distanceTraveled, points, setSimulationState]);

  const motionMatrix = transformationAtArcLength(
    points[0],
    points[1],
    MathUtils.clamp(
      simulationState.distanceTraveled,
      0,
      totalArcLength(points[0], points[1]),
    ),
  );

  return (
    <>
      <DragControlPoints
        axisLock="z"
        points={points}
        setPoints={setPoints}
      />
      <Line points={points} color={colors.secondary} />
      <PointWithMatrixArrows matrix={motionMatrix} />
    </>
  );
};

export const TransformationMatrixDemoScene = () => {
  return (
    <OrthographicScene>
      <TransformationMatrixDemo />
    </OrthographicScene>
  );
};