The follow camera's eye¶
The follow camera's eye eased toward its goal.
Over a frame of dt the gap between the eye and the goal (a body's place and an offset, say) shrinks by exp(-rate dt): eye' = goal + (eye - goal) exp(-rate dt), the exact step of d(eye)/dt = rate (goal - eye) for a goal held over the frame, so two half frames ease as far as one whole and the camera feels the same at any frame rate. A rate of 0 holds the eye; a large one snaps it to the goal. Source: the first-order lag's exact discretisation, e.g. Franklin, Powell and Workman, Digital Control of Dynamic Systems, 3rd ed., section 4.3 (the zero-order-hold equivalent of a first-order system). Checked against a reference in numpy, which has its own tests.
- Source
- the first-order lag's zero-order-hold equivalent, Franklin, Powell and Workman, Digital Control of Dynamic Systems, 3rd ed., section 4.3.
- Proof
- reference, invariant, limit.
follow.trait
namespace lusine.camera;
vector<float<m>, 3> follow(vector<float<m>, 3> current, vector<float<m>, 3> goal, float<s^-1> rate, float<s> dt)
{
return goal + (current - goal) * exp(-(rate * dt));
}
The eye eased toward its goal by exp(-rate dt) of the gap a frame, the same at any frame rate. One of the reference camera's kinds: a camera is a game's own state, the eye and target lusine.render.table's view reads, and a kind is a kernel its procedure calls to move it.
import os, sys
root = os.getcwd()
while not os.path.isfile(os.path.join(root, 'docsite.json')):
root = os.path.dirname(root)
sys.path.insert(0, os.path.join(root, 'tools'))
import numpy as np
from lusine import trait_run
F32 = np.float32
U = float(np.finfo(F32).eps) / 2 # binary32's unit roundoff, 2^-24
rng = np.random.default_rng(2026)
The kernel¶
The law as the library states it.
print(trait_run.source('camera/follow.trait'))
// lusine.camera.follow: the follow camera's eye eased toward its goal. Over a frame of dt the gap
// between the eye and the goal (a body's place and an offset, say) shrinks by exp(-rate dt):
// eye' = goal + (eye - goal) exp(-rate dt),
// the exact step of d(eye)/dt = rate (goal - eye) for a goal held over the frame, so two half frames ease as far as
// one whole and the camera feels the same at any frame rate. A rate of 0 holds the eye; a large one snaps it to the
// goal.
// Source: the first-order lag's exact discretisation, e.g. Franklin, Powell and Workman, Digital Control of Dynamic
// Systems, 3rd ed., section 4.3 (the zero-order-hold equivalent of a first-order system).
// Checked against a reference in numpy, which has its own tests.
namespace lusine.camera;
vector<float<m>, 3> follow(vector<float<m>, 3> current, vector<float<m>, 3> goal, float<s^-1> rate, float<s> dt)
{
return goal + (current - goal) * exp(-(rate * dt));
}
The program¶
Each section runs the kernel over a lattice of items, one a case: the host sets every parameter, and main() binds the kernel over the items.
print(trait_run.law_program('camera/follow.trait', 'follow'))
from lusine.kernels.camera.follow import follow as reference
def law(**kw):
return trait_run.law('camera/follow.trait', 'follow', **kw)
namespace probe;
lusine.version version = { { number = 1 } };
struct item {
host vector<float<m>, 3> current;
host vector<float<m>, 3> goal;
host float<s^-1> rate;
host float<s> dt;
vector<float<m>, 3> result_0;
};
lattice<item, 1> items(spacing = 1 m, boundary = wall);
void main() { items.result_0 = lusine.camera.follow(items.current, items.goal, items.rate, items.dt); }
Reference: eases of every pace¶
Eyes, goals, rates and frames: the kernel within binary32's roundings of the reference, measured against the gap.
n = 300
c, g = rng.uniform(-5, 5, (n, 3)), rng.uniform(-5, 5, (n, 3))
rate, dt = rng.uniform(0.0, 20.0, n), rng.uniform(1 / 240, 1 / 15, n)
got, want = law(current=c, goal=g, rate=rate, dt=dt), reference(c, g, rate, dt)
err = np.max(np.abs(got - want)) / np.max(np.abs(c - g))
assert err <= 32 * U, err / U
print(f'within {err / U:.1f} x 2^-24 of the gap over {n} frames')
within 1.5 x 2^-24 of the gap over 300 frames
Invariant: the same at any frame rate¶
Two frames at 60 Hz ease as far as one at 30 Hz, to binary32's roundings.
one = law(current=c, goal=g, rate=rate, dt=np.full(n, 1 / 30))
two = law(current=law(current=c, goal=g, rate=rate, dt=np.full(n, 1 / 60)), goal=g, rate=rate, dt=np.full(n, 1 / 60))
assert np.max(np.abs(one - two)) <= 1e-5
print(f'two half frames against one: largest difference {np.max(np.abs(one - two)):.2e} m')
two half frames against one: largest difference 9.54e-07 m
Limit: no ease, a snap, and half way¶
A rate of 0 holds the eye, a fast one snaps it to the goal, and after ln 2 / rate it is half way.
e = law(current=c[:3], goal=g[:3], rate=np.array([0.0, 1e4, 4.0]), dt=np.array([1.0, 1.0, np.log(2.0) / 4.0]))
assert np.allclose(e[0], c[0], atol=1e-6) and np.allclose(e[1], g[1], atol=1e-6)
assert np.allclose(e[2], (c[2] + g[2]) / 2, atol=1e-5)
print('held; snapped; half way at ln 2 / rate')
held; snapped; half way at ln 2 / rate