4 minutes
Transformation Matrix
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>
);
};