Repository navigation
A rotation type whose values are always rotations - #148
Merged
Merged
Conversation
Quaternion is freely constructible and mutable while most of its API
assumes unit length: rotate, slerp, toMat4, toEulerXYZ and
Mat3::fromQuaternion all produce nonsense from a non-unit value,
quietly. fromAxisAngle({0,0,0}, θ) is the clearest case — nothing about
it describes a rotation, and what comes back is not one either.
MEASURED BEFORE DESIGNED. A temporary probe at all five of those sites
recorded about 24 million calls across the full suite and three scenes,
including a 900-frame ragdoll run: ZERO values off unit by more than
1e-4, worst deviation anywhere 7.15e-07 (roughly six ulps), not growing.
So this type is not fixing a live bug. It removes a representable
invalid state, which is a longer-lived kind of value: the reason nothing
violates the invariant today is that a handful of scattered normalise()
calls happen to sit in the right places, and nothing makes that true
tomorrow.
That measurement also says what it does NOT say. Quaternion::integrate
normalises its result and slerp normalises its near-linear path, so the
bounded drift is evidence that THOSE repairs suffice — not that a
rotation type could skip them. Every operation here that does
floating-point algebra therefore maintains the invariant itself, and no
caller is asked to remember a re-normalisation cadence.
The invariant is maintained DEFINITIVELY. All algebra funnels through
one private choke point which cannot complete with a non-unit or
non-finite value: it logs and aborts instead. Quaternion::normalise
answers NaNs for non-finite input — correct for it, fatal for a type
whose whole claim is that it holds a rotation — and storing those would
leave the supposedly unrepresentable state reachable through integrate()
or slerp() with a corrupt angular velocity. The fallible boundaries
(tryFrom*) filter such cases into nullopt before they ever reach it.
EXACTLY ONE NORMALISATION per operation. integrate and slerp carry their
own formulas rather than delegating to Quaternion's already-normalising
versions, tryFromQuaternion hands its raw value straight to the choke
point, and tryFromVectors builds the half-angle form itself. Phase 1
showed what an extra ulp per component costs a solver (a settling box
stack went from step 169 to 425); it also matters for attribution, since
a golden that moves in the next commit should be the conversion
authority changing and nothing else.
TWO TOLERANCES, because they answer different questions at different
scales, both measured on |‖q‖² − 1| — stated explicitly, since the
linear and squared spellings differ by a factor of two.
Admission (0.05) is how far raw input may sit from unit and still be
normalised rather than refused. Derived from the coarsest encoding glTF
permits for rotation outputs, normalised signed bytes: a component's
error is at most 0.5/127, so |‖q‖² − 1| ≤ 2·Σ|cᵢ|·(0.5/127) +
4·(0.5/127)², and Σ|cᵢ| peaks at 2. The worst case is real —
{0.5,0.5,0.5,0.5} encodes as 64/127 per component and decodes to
1.015810 — and an earlier 1e-2 bound would have rejected that conforming
asset.
Invariant (1e-4) is how far an accepted value may drift before something
is wrong with this type. Sized from the reconnaissance: two decades
above the worst deviation the engine produces.
Imprecise input is normalised, degenerate input is refused. A drifted
orientation or a quantised keyframe is a rotation that needs tidying; a
zero axis, a non-finite component or a scaled quaternion is a different
kind of value, and answering the identity for it would launder a
producer bug into something that rotates nothing.
Equality is about rotations, not representations: q == -q is true,
because they rotate every vector identically, and a rotation type whose
equality said otherwise would make every caller learn about the double
cover. sameComponents() is there for the rare caller that means the
storage. Approximate comparison is an ANGLE rather than four component
tolerances, and angleTo reads it off the relative rotation with atan2 in
double — the 2·acos(|dot|) form loses everything below about 5e-4
radians, which is coarser than this type's own default tolerance.
There is deliberately no unary operator-. Negation does not produce a
different rotation, so it would read as an inverse and silently be a
no-op; hemisphere selection is alignedTo(reference), which says what it
does. Raw four-component algebra stays on Quaternion for the places that
genuinely need it — swing-twist decomposition in the joint solver, glTF
CUBICSPLINE tangents, which are derivatives rather than orientations.
The normalisation test proves its own sensitivity before asserting
anything: normalising an already-unit value is usually idempotent, so a
fixture chosen at random would pass whether the implementation rounds
once or twice. Each section REQUIREs that its raw value changes under a
second normalisation first. The linear slerp branch has no such fixture
— a search over 60,000 inputs found none, because that blend is already
unit to the bit
— so it says so and asserts only what it can, rather than carrying a
comparison that cannot fail.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Quaternion is freely constructible and mutable while most of its API assumes unit length: rotate, slerp, toMat4, toEulerXYZ and Mat3::fromQuaternion all produce nonsense from a non-unit value, quietly. fromAxisAngle({0,0,0}, θ) is the clearest case — nothing about it describes a rotation, and what comes back is not one either.
MEASURED BEFORE DESIGNED. A temporary probe at all five of those sites recorded about 24 million calls across the full suite and three scenes, including a 900-frame ragdoll run: ZERO values off unit by more than 1e-4, worst deviation anywhere 7.15e-07 (roughly six ulps), not growing. So this type is not fixing a live bug. It removes a representable invalid state, which is a longer-lived kind of value: the reason nothing violates the invariant today is that a handful of scattered normalise() calls happen to sit in the right places, and nothing makes that true tomorrow.
That measurement also says what it does NOT say. Quaternion::integrate normalises its result and slerp normalises its near-linear path, so the bounded drift is evidence that THOSE repairs suffice — not that a rotation type could skip them. Every operation here that does floating-point algebra therefore maintains the invariant itself, and no caller is asked to remember a re-normalisation cadence.
The invariant is maintained DEFINITIVELY. All algebra funnels through one private choke point which cannot complete with a non-unit or non-finite value: it logs and aborts instead. Quaternion::normalise answers NaNs for non-finite input — correct for it, fatal for a type whose whole claim is that it holds a rotation — and storing those would leave the supposedly unrepresentable state reachable through integrate() or slerp() with a corrupt angular velocity. The fallible boundaries (tryFrom*) filter such cases into nullopt before they ever reach it.
EXACTLY ONE NORMALISATION per operation. integrate and slerp carry their own formulas rather than delegating to Quaternion's already-normalising versions, tryFromQuaternion hands its raw value straight to the choke point, and tryFromVectors builds the half-angle form itself. Phase 1 showed what an extra ulp per component costs a solver (a settling box stack went from step 169 to 425); it also matters for attribution, since a golden that moves in the next commit should be the conversion authority changing and nothing else.
TWO TOLERANCES, because they answer different questions at different scales, both measured on |‖q‖² − 1| — stated explicitly, since the linear and squared spellings differ by a factor of two.
Admission (0.05) is how far raw input may sit from unit and still be normalised rather than refused. Derived from the coarsest encoding glTF permits for rotation outputs, normalised signed bytes: a component's error is at most 0.5/127, so |‖q‖² − 1| ≤ 2·Σ|cᵢ|·(0.5/127) + 4·(0.5/127)², and Σ|cᵢ| peaks at 2. The worst case is real — {0.5,0.5,0.5,0.5} encodes as 64/127 per component and decodes to 1.015810 — and an earlier 1e-2 bound would have rejected that conforming asset.
Invariant (1e-4) is how far an accepted value may drift before something is wrong with this type. Sized from the reconnaissance: two decades above the worst deviation the engine produces.
Imprecise input is normalised, degenerate input is refused. A drifted orientation or a quantised keyframe is a rotation that needs tidying; a zero axis, a non-finite component or a scaled quaternion is a different kind of value, and answering the identity for it would launder a producer bug into something that rotates nothing.
Equality is about rotations, not representations: q == -q is true, because they rotate every vector identically, and a rotation type whose equality said otherwise would make every caller learn about the double cover. sameComponents() is there for the rare caller that means the storage. Approximate comparison is an ANGLE rather than four component tolerances, and angleTo reads it off the relative rotation with atan2 in double — the 2·acos(|dot|) form loses everything below about 5e-4 radians, which is coarser than this type's own default tolerance.
There is deliberately no unary operator-. Negation does not produce a different rotation, so it would read as an inverse and silently be a no-op; hemisphere selection is alignedTo(reference), which says what it does. Raw four-component algebra stays on Quaternion for the places that genuinely need it — swing-twist decomposition in the joint solver, glTF CUBICSPLINE tangents, which are derivatives rather than orientations.
The normalisation test proves its own sensitivity before asserting anything: normalising an already-unit value is usually idempotent, so a fixture chosen at random would pass whether the implementation rounds once or twice. Each section REQUIREs that its raw value changes under a second normalisation first. The linear slerp branch has no such fixture — a search over 60,000 inputs found none, because that blend is already unit to the bit
— so it says so and asserts only what it can, rather than carrying a comparison that cannot fail.