Skip to content

Source: math16.h

math16

The numeric vocabulary every effect and script writes against.

Typedefs

Return Name Description
uint16_t angle16 An angle where a full turn is the type's range, so overflow is the wrap and no modulo is needed.
uint16_t frac16 A fraction of one across the type's range, for interpolation weights and easing.

angle16

using angle16 = uint16_t

An angle where a full turn is the type's range, so overflow is the wrap and no modulo is needed.


frac16

using frac16 = uint16_t

A fraction of one across the type's range, for interpolation weights and easing.

Functions

Return Name Description
constexpr int16_t sin16 constexpr Sine over a 16-bit angle, returning a signed result: the widely ported contract.
constexpr int16_t cos16 constexpr Cosine: a quarter turn ahead of sine. Signed, like sin16.
constexpr int32_t map32 constexpr Map v from [inLo,inHi] to [outLo,outHi] in 64-bit intermediate, clamped to the input range.
constexpr uint64_t isqrt64 constexpr Integer square root, rounded down, by the binary restoring method: no divide and no float.
constexpr uint32_t isqrt constexpr
angle16 atan16 inline Angle of (x, y) as an angle16, measured counter-clockwise from +x. Full 16-bit resolution, so a gradient swept around the circle has no visible steps on a large fixture.
uint32_t dist16 inline True Euclidean distance from the origin to (dx, dy): a real radius, not the octagon dist8 approximates, and it does not saturate at 255.
constexpr frac16 easeInOutQuad constexpr Quadratic ease in-out: accelerate from rest, decelerate to rest. The default choice.
constexpr frac16 easeInOutCubic constexpr Cubic ease in-out: the same shape with a longer, softer settle.
constexpr frac16 easeOutQuad constexpr Quadratic ease out: fast start, gentle settle. The "arrives and rests" curve.
constexpr uint8_t smoothFollow constexpr A one-pole follower: the current value moves a fraction of the way toward the target each frame.
constexpr uint8_t ballistic constexpr A meter's ballistic: rise at one rate, fall at another. smoothFollow with two time constants.
constexpr uint8_t peakHold constexpr The falling-peak meter: rise INSTANTLY to a new high, then decay slowly, which is what catches a transient and leaves it readable. Every VU meter with a floating peak dot is this function.
constexpr uint16_t hashInt constexpr A hash of up to four integers: random-looking, but a pure function of its inputs.
angle16 kaleido inline Fold an angle into mirrored wedges, which gives any field sampled through it that symmetry.
constexpr uint16_t triwave16 constexpr A triangle wave: the fold of a ramp, cheaper and sharper than a sine for a linear sweep.
constexpr uint16_t beat16 constexpr A sawtooth completing a given number of cycles per minute, measured from a timebase.
uint32_t halfLifeKeep inline Half-life decay: the fraction of a value that SURVIVES after dtMs, as a 0..65536 weight, annotated because [draw::decay] calls it every frame.

sin16

constexpr

constexpr constexpr int16_t sin16(angle16 theta)

Sine over a 16-bit angle, returning a signed result: the widely ported contract.


cos16

constexpr

constexpr constexpr int16_t cos16(angle16 theta)

Cosine: a quarter turn ahead of sine. Signed, like sin16.


map32

constexpr

constexpr constexpr int32_t map32(int32_t v, int32_t inLo, int32_t inHi, int32_t outLo, int32_t outHi)

Map v from [inLo,inHi] to [outLo,outHi] in 64-bit intermediate, clamped to the input range.


isqrt64

constexpr

constexpr constexpr uint64_t isqrt64(uint64_t x)

Integer square root, rounded down, by the binary restoring method: no divide and no float.


isqrt

constexpr

constexpr constexpr uint32_t isqrt(uint32_t v)

atan16

inline

inline angle16 atan16(int32_t y, int32_t x)

Angle of (x, y) as an angle16, measured counter-clockwise from +x. Full 16-bit resolution, so a gradient swept around the circle has no visible steps on a large fixture.


dist16

inline

inline uint32_t dist16(int32_t dx, int32_t dy)

True Euclidean distance from the origin to (dx, dy): a real radius, not the octagon dist8 approximates, and it does not saturate at 255.


easeInOutQuad

constexpr

constexpr constexpr frac16 easeInOutQuad(frac16 t)

Quadratic ease in-out: accelerate from rest, decelerate to rest. The default choice.


easeInOutCubic

constexpr

constexpr constexpr frac16 easeInOutCubic(frac16 t)

Cubic ease in-out: the same shape with a longer, softer settle.


easeOutQuad

constexpr

constexpr constexpr frac16 easeOutQuad(frac16 t)

Quadratic ease out: fast start, gentle settle. The "arrives and rests" curve.


smoothFollow

constexpr

constexpr constexpr uint8_t smoothFollow(uint8_t current, uint8_t target, uint8_t rate)

A one-pole follower: the current value moves a fraction of the way toward the target each frame.


ballistic

constexpr

constexpr constexpr uint8_t ballistic(uint8_t current, uint8_t target, uint8_t rise, uint8_t fall)

A meter's ballistic: rise at one rate, fall at another. smoothFollow with two time constants.


peakHold

constexpr

constexpr constexpr uint8_t peakHold(uint8_t peak, uint8_t value, uint8_t decay)

The falling-peak meter: rise INSTANTLY to a new high, then decay slowly, which is what catches a transient and leaves it readable. Every VU meter with a floating peak dot is this function.


hashInt

constexpr

constexpr constexpr uint16_t hashInt(uint32_t a, uint32_t b = 0, uint32_t c = 0, uint32_t seed = 0)

A hash of up to four integers: random-looking, but a pure function of its inputs.


kaleido

inline

inline angle16 kaleido(angle16 a, uint8_t segments)

Fold an angle into mirrored wedges, which gives any field sampled through it that symmetry.


triwave16

constexpr

constexpr constexpr uint16_t triwave16(uint16_t i)

A triangle wave: the fold of a ramp, cheaper and sharper than a sine for a linear sweep.


beat16

constexpr

constexpr constexpr uint16_t beat16(uint8_t bpm, uint32_t ms, uint32_t timebase = 0)

A sawtooth completing a given number of cycles per minute, measured from a timebase.


halfLifeKeep

inline

inline uint32_t halfLifeKeep(uint32_t dtMs, uint32_t halfLifeMs)

Half-life decay: the fraction of a value that SURVIVES after dtMs, as a 0..65536 weight, annotated because [draw::decay] calls it every frame.

Variables

Return Name Description
constexpr int16_t sin16_quarter constexpr Quarter-wave sine table: sin(pi/2 * i/64) * 32767, 65 entries (0..64 inclusive, so the last segment has both endpoints). 130 bytes in flash; the other three quadrants come from symmetry.
constexpr int16_t atan16_octant constexpr The arctangent over one octant, the other seven coming from symmetry.

sin16_quarter

constexpr

constexpr int16_t sin16_quarter = {
        0,   804,  1608,  2410,  3212,  4011,  4808,  5602,
     6393,  7179,  7962,  8739,  9512, 10278, 11039, 11793,
    12539, 13279, 14010, 14732, 15446, 16151, 16846, 17530,
    18204, 18868, 19519, 20159, 20787, 21403, 22005, 22594,
    23170, 23731, 24279, 24811, 25329, 25832, 26319, 26790,
    27245, 27683, 28105, 28510, 28898, 29268, 29621, 29956,
    30273, 30571, 30852, 31113, 31356, 31580, 31785, 31971,
    32137, 32285, 32412, 32521, 32609, 32678, 32728, 32757,
    32767
}

Quarter-wave sine table: sin(pi/2 * i/64) * 32767, 65 entries (0..64 inclusive, so the last segment has both endpoints). 130 bytes in flash; the other three quadrants come from symmetry.


atan16_octant

constexpr

constexpr int16_t atan16_octant = {
        0,   326,   651,   975,  1297,  1617,  1933,  2246,
     2555,  2860,  3159,  3453,  3742,  4025,  4302,  4572,
     4836,  5094,  5344,  5589,  5826,  6058,  6282,  6500,
     6712,  6917,  7117,  7310,  7498,  7679,  7856,  8026,
     8192
}

The arctangent over one octant, the other seven coming from symmetry.

BeatPhase

class BeatPhase
src/core/util/math16.h:196

A resolution- and framerate-independent BPM phase accumulator.

Nine effects hand-rolled this identically, and the shape is subtle enough to be worth owning once. The per-tick product dt * bpm * scale / 60000 rounds to ZERO when dt is under a millisecond (every desktop frame. An ESP32 running a small fixture fast), so the animation silently freezes. The fix all nine converged on is to accumulate the RAW numerator in 64 bits and divide only at the read: which is what this does.

Usage: one member per animated quantity; call advanceTo(nowMs, rate) once per frame, then read as often as needed. rate is BPM-like: the caller's speed control, whatever its units.

Public Methods

inline void advanceTo(uint32_t nowMs, uint32_t rate) : Accumulate this frame's contribution. Safe to call with a rate of 0 (the phase holds).

inline uint32_t phase(uint32_t scale) const : The phase scaled by scale and divided late: phase(256) is the uint8 angle form.

inline uint64_t numerator() const : The undivided numerator, for a caller that scales differently (NoiseEffect multiplies the.

inline void advanceScaled(uint32_t elapsedMs, uint64_t scaledRate) : Feed a pre-scaled product directly: for the callers whose rate already carries a factor.

inline void reset() : Forget the accumulated phase, restarting from zero.

More info

Why a 16-bit tier alongside the 8-bit one

An 8-bit result positions to 256 levels. Visibly steps on a large fixture: a wall of twelve thousand lights shows the staircase in a slow gradient or a drifting blob. Effects must look smooth at every size, so the contract is 16-bit.

The 8-bit tier stays for genuinely 8-bit domains, a palette index and a hue being modulo 256 by design, and as the internal fast path.

The cost was measured, not assumed

Interpolating the existing 8-bit table was tried first, since it adds no bytes, and rejected. Rounding the endpoints to 8 bits distorts the segments the interpolation runs between, giving around one percent of amplitude, which is worse than the classic 16-bit implementations manage.

A small quarter-wave 16-bit table with the same linear interpolation measures two orders of magnitude better, at a cost that rounds to nothing. An exact implementation exists but spends eight times the table and two wide multiplies per call. This is the middle that keeps large-fixture gradients smooth without either price.

Why the sine is signed

The unsigned form, with the midpoint at the zero crossing, was tried first to match the 8-bit sine. It is the wrong call: this is the most-ported symbol in the LED world, so any snippet doing arithmetic on it breaks silently, everything offset by half scale. Eleven of our own call sites were already subtracting that midpoint to undo the convention, which is the same signal from the inside.

The contract was verified against the two codebases users port from, at their main branches rather than a release. The contract we bind to should be the one users will have. One declares the signed return in its own header comment. The other's effects add the midpoint back when they want an unsigned value, which is precisely the arithmetic that broke against our old return.

A standard name must carry the standard contract. The 8-bit sine keeps its own, because palette and hue domains are genuinely unsigned.

How the wave is reconstructed

It is a quarter-wave lookup with linear interpolation. The top bits of the angle pick the quadrant, the next six the table entry, and the low byte the position between entries. Mirroring the index in odd quadrants and negating in the upper half reconstructs the full wave from a quarter of the table.

What the helpers exist to stop being hand-rolled

The range mapper puts the fencepost in one place. Six effects hand-rolled it, each carrying its own comment about mapping to one less than the extent when the output is a grid. Callers map to the extent and the pixel writer clamps, which is the form that does not lose the last column.

The narrow integer root is a binary restoring method, with no divide and no float. Matters because the chip has no fast divide and effects call it per pixel for a true distance. Where a comparison against a threshold will do, comparing squared values instead skips it entirely: measured on hardware that is roughly an eighth of the cost. The wide form was promoted here from the audio path so both roots have one home.

The hash addresses randomness by position, which a stream generator cannot do. A stream advances per call, so a device rendering one extra frame or holding a different light count diverges forever. Addressing by position keeps a dissolve, a starfield or a per-pixel offset identical across devices, which is what supersync requires.

Why a follower needs two rates

A follower with a single rate is the wrong instrument for anything reactive. It makes the attack as sluggish as the decay and rounds off the transient the effect exists to show. Every meter standard separates the two, and a broadcast peak programme meter is the shape wanted here. Rise almost at once, fall over a comfortable time so the eye can read the peak. Equal values reduce to the single-rate form exactly, so the two-rate one is a superset rather than a second mechanism.

The falling-peak meter takes that further, rising instantly and decaying slowly. A peak that eased upward would miss transients, and one that dropped instantly would show nothing to read.

The simple follower is deliberately frame-rate dependent, which is what every LED codebase uses; a caller needing true time independence scales its rate by its own step.

Why decay is stated as a half-life

The caller states the thing they actually mean: that half of a value is gone after so many milliseconds. Frame-rate independence is then a property of the formula rather than of the caller's arithmetic, because the exponent carries the elapsed time. Two short steps and one long one reach the same place.

The per-frame form does not survive a fast device. A fade that keeps a fixed fraction each frame has a half-life that moves with the frame rate. One setting is a long tail at sixty frames a second and an instant clear at twenty times that. The effects that hit this carry a remainder by hand to stop the fade truncating to nothing.

Why the polar pair is 16-bit too

The 8-bit forms use an octant arctangent and an octagonal distance, taken as the larger axis plus half the smaller. That is up to a tenth off a true radius and saturates at the byte's top. On a small panel neither shows; on a large one the octagon reads as visible corners on what should be a circle. The saturation flattens everything past that distance from the center.

The 16-bit forms address a grid in polar coordinates, which is the vocabulary behind every radial look: rings, spirals, rotation, kaleidoscopes, tunnels, radial wipes, a spectrum bent around a circle. The angle feeds the sine and the palette directly, and the distance is a true Euclidean radius. Both are general grid arithmetic rather than helpers for the few effects that happen to call them first.

Why easings are here

These are Robert Penner's curves under their industry-standard names. An easing takes a progress value and bends it, so motion accelerates and settles instead of running at a constant speed. Anything that travels, grows, fades or transitions reads better through one, and a linear ramp is what makes an animation look mechanical.

Three places the obvious arithmetic overflows

The range mapper widens every operand before the arithmetic. A full-width span does not fit in a same-width subtraction, so computing the spans narrow overflows at the extremes. The product of two such spans fits in the wider type for every range this maps between, an extent, a byte, a band count or a frequency. Only mapping a full range onto another full range would exceed it, which no caller does.

The integer root starts from a power-of-two bound rather than the input itself. The Newton step overflows when it starts at the input, which at the type's maximum made the first iteration wrap and the function return zero. Halving the bit width gives a start above the true root and inside the range, so every iteration stays representable.

The distance squares in the wider unsigned type and roots there. The narrow form is wrong far inside the normal range rather than only at the extremes: two coordinates of seventy thousand already sum past the narrow maximum. It saturated and reported the type's top for that distance.

Two casts the arctangent needs

It folds into the first octant on unsigned magnitudes. Negating the signed minimum has no representation, so the obvious conditional negation is undefined at exactly one input per axis; widening first keeps the fold exact across the whole range.

It subtracts from zero rather than applying unary minus. The unary form is well defined on an unsigned value, being modular arithmetic. Is exactly what is wanted, but one major compiler reports it and that build treats warnings as errors. Subtracting from zero is the same operation with no diagnostic anywhere. A quadratic core can swap in behind the same name if a field-heavy effect ever needs it.