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 transformation 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 toForwardDirection = (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 = toForwardDirection(transformation);

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

const evaluateMotion = (
  state: SimulationState,
  transformation: Matrix4,
  activeAcceleration: number,
  friction: number,
  airResistance: number,
  gravity: number,
  deltaTime: number,
): SimulationState => {
  const forwardDirection = toForwardDirection(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 += activeAcceleration;
  acceleration -= energyLoss;

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

  return { velocity, distanceTraveled, acceleration };
};

Note: Do not worry about the activeAcceleration parameter, which seems to come from nowhere. This is just us preparing for later chapters, when we introduce actual roller coaster sections that actively add or remove acceleration, for example, launch or brake sections. It is not used yet and is always zero for now.

Interactive demo

The demo shows the same train motion as before. What changes is how position and forward direction are obtained internally: both now come from a single transformation matrix evaluated along the track.

What comes next?

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.

Demo code

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 { Ground } from '../../../../components/Ground';
import { PointWithMatrixArrows } from '../../../../components/PointWithMatrixArrows';
import { EditorScene } from '../../../../components/scenes/EditorScene';

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,
        ),
        0,
        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} />
      <Ground position={[0, -5, 0]} />
      <PointWithMatrixArrows matrix={motionMatrix} />
    </>
  );
};

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