Angle Class Using Unsigned Integer
An angle class in C++ can be written with an underlying floating-point value with conversion methods and constructors for various units. This is similar to how one would write time-point and time-span classes: a function may take a TimeSpan
and the implementation then retrieve the needed duration in whatever unit it needs.
I have decided to write and analyze an angle class using a 32-bit unsigned integer instead of a floating-point value to remove the need of spherically wrapping the values via fmod
.
Choice of integral type
With signed integers, the obvious range of values for an angle class would be -π to +π. With unsigned integers, the range would be 0 to 2π.
We rely on arithmetic overflow to take advantage of the hardware modulo. In the year of our Lord 2024, for signed integers, arithmetic overflow and underflow is still undefined behavior1.
For this reason, I have chosen to use unsigned integers, which have defined overflow semantics1. In this case, it happens to be just what we need:
Unsigned integer arithmetic is always performed modulo 2^n where n is the number of bits in that particular integer. E.g. for
unsigned int
, adding one toUINT_MAX
gives0
, and subtracting one from0
givesUINT_MAX
.
The next step is to decide on a width. The largest we have is 64 bits, but I decided against it as I will need to represent values outside the possible range (using promotion). 32 bits is the next best thing, and what I went with for the remainder of this analysis.
More shallow widths vary in feasibility. There is certainly an argument to be made for using 16 bits if a lookup table is used for sine/cosine calculation, as the extra precision for 32 bits is wasted anyway. However, 8 bits is a tad limiting as it cannot represent every integer degree value and even fewer radian values.
Floating-point modulo
A typical fmod
implementation will handle some edge cases and either utilize hand-rolled long division algorithms or intrinsics such as fprem
. This will need to be done when the angle is modified, calculated, or compared. The angle class could be written to support explicit wrapping APIs to allow uses for wrapped and non-wrapped values, or have it done implicitly when the underlying value is get or set.
On the other hand, unsigned integer modulo is provided for free by the hardware at no cost and is intrinsic to all arithmetic operations performed on the underlying integer value. If we imagine for unsigned integer type T
the value T(0)
being 0° and T(-1) + 1
being 360°, the modulo provided by the hardware works in our favor. Unfortunately, it is more difficult and more limited to support (if at all) values that are not wrapped.
Sine and cosine
An extremely common operation for angles is to calculate a sine and/or cosine value. To make this as fast as possible, it is common to make the underlying floating-point value be represented in radians, rather than degrees, so that its value can be passed directly to std::sin
and std::cos
which expect radians.
If the angle class uses an integer, this becomes a little more complicated. At the very least, we have two options:
- Shift-right and mask the integer to some desired amount of width (or "precision") and use the value as-is to some lookup table. This is what older games like Super Mario Sunshine does.
- Cast the unsigned integer to a floating-point number, multiply it by some constant to convert the normalized integer value to radians, and then pass it to the math library function.
Both options have its benefits and caveats. You lose precision and memory with option #1, while at the same time, gaining some network synchronization benefits. With option #2, it can have more precise results but requires a cvtsi2sd
instruction (or equivalent) to efficiently convert from unsigned-integer to floating-point.
I briefly looked into GCC's output with various cases of option #2. For simple sine or cosine, it generated something to the effect of:
pxor xmm0, xmm0
cvtsi2sd xmm0, rax # convert to floating-point
mulsd xmm0, QWORD PTR .LC0[rip] # convert to radians
I initially suspected passing the same angle to std::sin
and std::cos
sequentially would either convert twice or reuse the results from the first for the second. I was pleasantly surprised when I found that GCC is smart enough to do even better:
pxor xmm0, xmm0
sub rsp, 24
cvtsi2sd xmm0, rdi
mulsd xmm0, QWORD PTR .LC0[rip]
lea rax, [rsp+8]
mov rsi, rsp
mov rdi, rax
call sincos
It converts only once and still generates a single call to an optimized sincos
function, so no more worries there. If the sine and cosine calls are more separated, then it will take explicit work on part of the programmer to reduce the number of conversions.
Unit conversion
For the magic constant above, I used 0.0000000014629180792671596
, which in my case, was calculated via 2³² ÷ 2π. Having this division in one constant will guide the compiler to generate only one multiply instruction instead of two.
To calculate your own constant, raise 2 to the power of the width of the unsigned-integer type and divide that by either 2π for radians or 360° for degrees:
Width | Radians | Degrees |
---|---|---|
8 | 2⁸ ÷ 2π | 2⁸ ÷ 360° |
16 | 2¹⁶ ÷ 2π | 2¹⁶ ÷ 360° |
32 | 2³² ÷ 2π | 2³² ÷ 360° |
Comparison
If equivalent angles need to supported (e.g. 0° = 360°), floating-point angles will need to be wrapped on both operands. If every instance of the angle class is kept in the range via fmod
, or if this is not needed at all, then comparisons are the same as any other floating-point comparison.
For an angle class using a normalized unsigned integer, all comparisons by definition are equivalency and the same as any other unsigned-integral comparisons.
Spherical interpolation
The solutions for floating-point spherical interpolation are well observed. For an unsigned-integer angle class, similar solutions can be employed.
One example assuming the underlying integers are 32-bits wide:
u32 slerp(u32 a0, u32 a1, f64 t) {
// promote to next widest type
u64 current = a0;
u64 target = a1;
// avoid arithmetic underflow and
// bypass the need for std::abs()
u64 difference =
std::max(a0, a1) -
std::min(a0, a1)
;
u64 const PI = 0x0'8000'0000ULL;
u64 const TWO_PI = 0x1'0000'0000ULL;
// fidget the operands so that the
// difference is never larger than π
if (difference > PI) {
if (current > target) {
target += TWO_PI;
} else {
current += TWO_PI;
}
}
return lerp(current, target, t);
}
Perhaps the most costly aspect of this algorithm is the inevitable linear interpolation of an unsigned integer needing a roundtrip conversion to floating-point.
Overall thoughts
I have compared both options and ended up switching from floating-point to unsigned-integer as my angle class's underlying representation. I have found it more convenient to use at the cost of losing support native for equivalent angles.
One possible workaround would be using 16.16 fixed-point format, masking the low-order 16 bits for comparisons and leaving the high-order bits for some degree of overflow. I didn't bother with this solution. An example of its implementation in production is GZDOOM's fixed-point angles2 in ACS.
References
-
According to cppreference.com⧉. ↩ ↩2