curve

tape
import curve from "curve";
import math3d from "math3d";

let c = curve.cubic_Vec2(math3d.vec2(0.0, 0.0), math3d.vec2(0.0, 4.0),
                         math3d.vec2(4.0, 4.0), math3d.vec2(4.0, 0.0));
let mid = c.at(0.5);                       // (2, 3)
let arc = c.arc();                         // distance -> parameter
let p = c.at(arc.param(2.5));              // 2.5 units along the curve

curve has the curves a game moves things along: Bézier segments, Hermite segments, Catmull-Rom splines through points, uniform B-splines, a table that turns distance along a curve into its parameter (so motion keeps an even speed), keyframe tracks, and sprite-sheet frame tracks. Everything is f32, on math3d’s vectors, and every value is plain (no heap): keep it in a var and copy it like a struct.

Each curve comes in four forms: f32, Vec2, Vec3 and Vec4, the type’s name at the end (cubic_f32, cubic_Vec3, Track_Vec4). Below, T stands for any of them.

Bézier segments

Function or methodGives
cubic_T(p0, p1, p2, p3) -> Cubic_TA cubic Bézier from p0 to p3, pulled toward p1 and p2
quadratic_T(p0, p1, p2) -> Quadratic_TA quadratic Bézier from p0 to p2, pulled toward p1
c.at(t) -> TThe point at t, 0 to 1: exactly the first point at 0 and the last at 1
c.tangent(t) -> TThe derivative at t: the direction of travel, and the speed for t going 0 to 1 in unit time
c.split(t)The two halves at t, .first (0 to t) and .second (t to 1), as curves of the same kind
c.arc() -> ArcTableA cubic’s arc-length table (below)
q.to_cubic() -> Cubic_TA quadratic as the same curve in cubic form
tape
let c = curve.cubic_f32(0.0, 0.0, 10.0, 10.0);
let s = c.split(0.25);
// s.first runs 0..0.25 of c, s.second 0.25..1; s.first.p3 == s.second.p0
let q = curve.quadratic_Vec2(math3d.vec2(0.0, 0.0), math3d.vec2(2.0, 4.0), math3d.vec2(4.0, 0.0));
let top = q.at(0.5);                       // (2, 2)
let dir = q.tangent(0.0);                  // (4, 8)

A Cubic_T is the shape every cubic segment ends up in: the other kinds below are made into one, so at, tangent, split and arc work on all of them.

Hermite, Catmull-Rom and B-spline segments

FunctionGives
hermite_T(p0, m0, p1, m1) -> Cubic_TFrom p0 leaving with tangent m0 to p1 arriving with tangent m1
catmull_rom_T(p0, p1, p2, p3, alpha) -> Cubic_TThe Catmull-Rom segment from p1 to p2, with p0 and p3 as neighbours
bspline_T(p0, p1, p2, p3) -> Cubic_TThe uniform cubic B-spline segment of four control points

alpha spaces Catmull-Rom’s knots: curve.UNIFORM (0), curve.CENTRIPETAL (0.5) or curve.CHORDAL (1). Centripetal is the one to use when points are unevenly spaced: it never makes a loop or a cusp inside a segment. Uniform is the classic Catmull-Rom (the tangent at a point is half the chord between its neighbours). Points on top of each other are fine.

A B-spline is smoother than Catmull-Rom but passes near its points, not through them.

tape
let h = curve.hermite_f32(0.0, 3.0, 1.0, 3.0);   // leaves and arrives with slope 3
let seg = curve.catmull_rom_Vec2(a, b, c, d, curve.CENTRIPETAL);   // b to c

Splines through points

The spline functions take the points as a slice, []const T:

FunctionGives
catmull_rom_segment_T(pts, i, alpha) -> Cubic_TSegment i, from pts[i] to pts[i + 1] (i from 0 to n - 2)
catmull_rom_at_T(pts, alpha, u) -> TThe spline at u, 0 to n - 1: point k at u = k
catmull_rom_arc_T(pts, alpha) -> ArcTableIts arc-length table, parameter 0 to n - 1
bspline_segment_T(pts, i) -> Cubic_TSegment i of the B-spline over pts (i from 0 to n - 4)
bspline_at_T(pts, u) -> TThe B-spline at u, 0 to n - 3
bspline_arc_T(pts) -> ArcTableIts arc-length table, parameter 0 to n - 3
tape
var path: [4]math3d.Vec2 = [math3d.vec2(0.0, 0.0), math3d.vec2(10.0, 5.0),
                            math3d.vec2(20.0, 0.0), math3d.vec2(30.0, 5.0)];
let at_second = curve.catmull_rom_at_Vec2(path[..], curve.CENTRIPETAL, 1.0);   // path[1]
let heading = curve.catmull_rom_segment_Vec2(path[..], 1, curve.CENTRIPETAL).tangent(0.5);

A Catmull-Rom spline goes through every point. Its first and last segment have no outer neighbour, so the next point is mirrored to stand in for it. A B-spline over n points starts near pts[1] and ends near pts[n - 2]; write a point three times to start or end exactly on it. A u outside the range is clamped. Too few points (fewer than 2 for Catmull-Rom, 4 for a B-spline) or a segment index out of range stops the program with a trap.

Arc length

A curve’s parameter is not distance: a point at t = 0.5 is not half way along, and stepping t evenly moves at an uneven speed. An ArcTable measures the curve (the lengths of 64 short chords along its whole parameter range) and maps back:

MethodGives
arc.length() -> f32The curve’s whole length
arc.param(d) -> f32The parameter where the curve is d along (d clamped to 0..length)
arc.distance(u) -> f32How far along the curve parameter u is
tape
let arc = c.arc();
var s: f32 = 0.0;
while (s < arc.length()) {
    let p = c.at(arc.param(s));            // equal steps of s: an even speed
    s = s + 0.5;
}

The table is a plain value of 65 numbers: build it once, when the curve is made, and keep it beside the curve. It is a little under the true length (a quarter circle comes out 0.0025% short). For a spline of many segments, 64 chords spread thin: measure each segment’s arc() instead.

Keyframe tracks

A track is a value over time from keys: at each key a time (milliseconds, f32), a value, and how the value moves on to the next key. The keys are yours, as many as you like, in an array, a Vec or a loaded file; the track reads them where they are.

tape
let keys = [curve.key_f32(0.0, 0.0, curve.Interp.Cubic),
            curve.key_f32(250.0, 40.0, curve.Interp.Cubic),
            curve.key_eased_f32(500.0, 10.0, easing.Ease.BounceOut),
            curve.key_f32(800.0, 0.0, curve.Interp.Step)];
let y = curve.track_f32(keys[..], curve.Wrap.Loop);
let now = y.at(clock_ms);                  // any time, in any order
Function or methodDoes
key_T(time, v, how) -> Key_TA key; how is a curve.Interp
key_eased_T(time, v, ease) -> Key_TA key moving on along an easing.Ease curve
track_T(keys, wrap) -> Track_TA track over keys (a []const Key_T)
t.at(time) -> TThe value at time
t.duration() -> f32From the first key to the last, in ms
t.len() -> i64How many keys it has
t.wrapIts curve.Wrap, which you can change

curve.Interp is how a key moves on to the next one:

InterpFrom this key to the next
StepHolds this key’s value, then jumps
LinearA straight mix
EasedA mix shaped by the key’s easing curve (key_eased)
CubicA smooth curve through the keys (Catmull-Rom over time)

curve.Wrap is what a time before the first key or after the last gives:

WrapOutside the keys
ClampThe first key’s value before, the last one’s after
LoopStarts again from the first key; the last key’s time shows the first value
PingPongRuns back to the first key, then forward again

w.fold(t, span) is the same folding on its own: a time t counted from the start, into 0..span (curve.Wrap.PingPong.fold(130.0, 100.0) is 70), for a clock of your own.

Keys go in time order. Two keys at the same time make a jump. A key earlier than the one before it, or at on a track with no keys, stops the program with a trap. The track keeps a view of your keys, not a copy: keep them alive as long as the track, and copying a track copies no keys.

A track is not a tween: a tween is played (you step it, it remembers where it is, it can repeat and retarget), while a track just answers “what is the value at this time” for whatever clock you keep. Many objects can share one track, each at its own time.

Sprite-sheet frames

A frame track says which frame of a sprite sheet to draw:

tape
var walk = curve.frame_strip(8, 6, 80.0, curve.Wrap.Loop);   // frames 8..13, 80 ms each
let frame = walk.at(clock_ms);

let frames = [curve.frame(0, 2000.0),     // open for 2 s
              curve.frame(1, 100.0),      // closed briefly
              curve.frame(0, 100.0)];
let blink = curve.frame_track(frames[..], curve.Wrap.Clamp);
Function or methodDoes
frame_strip(first, count, ms, wrap) -> FrameTrackFrames first to first + count - 1, each ms long (needs no storage)
frame(index, ms) -> FrameA frame of the sheet, shown ms long
frame_track(frames, wrap) -> FrameTrackA frame track over frames (a []const Frame, kept as a view)
f.at(time) -> i64The frame to draw at time ms
f.done(time) -> boolA Clamp track at or past its end (Loop and PingPong never are)
f.duration() -> f32, f.len() -> i64One play forward in ms; how many frames

Clamp holds the last frame, Loop starts again, and PingPong runs back and forth without showing the end frames twice (4 5 6 7 6 5 4 5 …). at with no frames stops the program with a trap.

Your own types

gen_curve makes every function above for a type of your own: a struct with + and - of two values, * and / by an f32, and length() -> f32.

tape
pub struct Pt { x: f32; y: f32; }
pub fn Pt.op_add(self, o: Pt) -> Pt { return Pt { x: self.x + o.x; y: self.y + o.y; }; }
pub fn Pt.op_sub(self, o: Pt) -> Pt { return Pt { x: self.x - o.x; y: self.y - o.y; }; }
pub fn Pt.op_mul(self, s: f32) -> Pt { return Pt { x: self.x * s; y: self.y * s; }; }
pub fn Pt.op_div(self, s: f32) -> Pt { return Pt { x: self.x / s; y: self.y / s; }; }
pub fn Pt.length(self) -> f32 { return @sqrt(self.x * self.x + self.y * self.y); }

@tape { curve.gen_curve(Pt); }             // cubic_Pt, Track_Pt, catmull_rom_at_Pt, ...

The struct must be pub, as for any generator.

Last modified: