8.7 KiB
Crafter.Math
The Crafter engine's math library: a C++ module with no implementation units and no dependencies, so everything is a template or an inline body compiled into your translation units with your build's --target and --march. Source and issues: https://forgejo.catcrafts.net/Catcrafts/Crafter.Math. Namespace Crafter. The README there documents the whole surface; these notes are what an agent working in a consumer needs.
Which type
Vector<T, Len, Alignment = 0> is plain data, for vertex and uniform structs, events, anything stored or passed around. .x .y .z .w (and .r .g .b .a) alias v[]; constructors exist for Len 2, 3 and 4 (Vector<float, 2>{x, y}); + - * / with vectors and scalars, += and friends, ==, unary -; statics Dot(a, b), Cross(a, b) (Len == 3), Normalize(a); members Length(), LengthSq(), Normalize(), Invert(). std::format("{}", v) prints {x,y,z}.
Alignmentis the number of stored elements.0meansLen, naturally aligned:Vector<float, 3>is 12 bytes aligned 4. AnyN >= LenstoresT v[N]and marks the struct__attribute__((packed)), alignment 1:Vector<float, 3, 3>is 12 bytes aligned 1 (the vertex type Crafter.Graphics uses),Vector<float, 3, 4>is 16 bytes, avec4slot. Conversion between alignments is implicit.- Arithmetic between two lengths covers the shorter one and leaves the other lanes of the result uninitialised, and
==compares the shorter. The memberNormalize()divides without a zero check; the staticNormalize(a)checks.
VectorF32<Len, Packing> is one SIMD register holding Packing vectors of Len floats, for math-heavy loops. Construct from a const float* or broadcast one float; Load(ptr), Store(ptr), and Store<float>() returning a std::array. Operators as above; statics Cross (Len == 3), MulitplyAdd(a, b, c) (a*b + c), MulitplySub(a, b, c) (a*b - c), Rotate(v, q) and RotatePivot(v, q, pivot) by a VectorF32<4, Packing> quaternion, QuanternionFromEuler(halfAngles), Blend<{{bools}}>(a, b); members Sin(), Cos(), SinCos(), Shuffle<{{i, j, k}}>(), Negate<{{bools}}>(), ExtractLo<N>(). Inside a template write v.template Store<float>().
Load,Storeand the pointer constructor move the whole register,AlignmentElementfloats (4, 8 or 16), notLen * Packing. Give them a buffer that big (alignas(16) float buf[4] = { x, y, z, 0 };is the idiom in the tests), zero the padding lanes on the way in and ignore them on the way out. The pointer need not be aligned.Dot,Length,LengthSqandNormalizeare batched with the arity fixed at compile time:Normalize,LengthandLengthSqtake exactlyBatchSizevectors,Dottakes2 * BatchSize(a0, b0, a1, b1, ...). They returnVectorF32<1, Packing * BatchSize>with one lane per input vector (Normalizereturnsstd::array<VectorF32<Len, Packing>, BatchSize>). There is no two-argumentDot: for one product pass the same pairBatchSizetimes and read lane 0, asMatrixRowMajor::operator*does, or useVector<float, 3>::Dot.BatchSizeisAlignmentElement / Packing, 4 for everyVectorF32<Len, 1>.OptimalPackingis how manyLen-vectors fill one register (table below). Shapes convert implicitly, copying the lanes they share.- The x86 backend lacks
conston the scalar operators,Sin,Cos,SinCosandShuffle: call them on non-const objects.
VectorF16<Len, Packing> is the same surface for _Float16 where the target has native half arithmetic: x86 with AVX512-FP16, AArch64 with ARMv8.2-FP16. Everywhere else, including every x86 tier below sapphirerapids and all of RISC-V, it is using VectorF16 = VectorF32, converting on Load and Store from _Float16*. Never overload a function on VectorF16 and VectorF32 both, and never assume its lanes are halves.
MatrixRowMajor<float, 4, RowSize, 1> is four-column and row-major, each row a VectorF32<4, 1> in the public rows[]; constructors take the 12 or 16 floats row by row. Only T = float, CollumSize = 4, Repeats = 1 exist.
<float, 4, 3, 1>(48 bytes) is the column-vector shape:M * VectorF32<3, 1>applies it with an implicitw = 1,TransformNormal(v)withw = 0; translation sits in column 3.Store(float*)writes the 12 floats row-major, which isVkTransformMatrixKHR:m.Store(reinterpret_cast<float*>(instance.transform.matrix)).<float, 4, 4, 1>(64 bytes) is the row-vector shape for the GPU side (v * M, translation in row 3). There is no matrix-times-vector operator for it in C++.operator*between two 4x4s,Transpose(),LookTo(eye, direction, up)andLookAt(eye, focus, up)are 4x4 only.- Both shapes have
Identity(),Scaling(x, y, z),Translation(x, y, z)andRotation(pitch, yaw, roll)in radians (each also from aVectorF32<3, 1>),Rotation(Vector<float, 4> quaternion),TRS(t, q, s)(scale, then rotate, then translate: a glTF node),InverseAffine()(any rotation, scale and shear plus a translation, never a projection) andDecompose()returning(t, q, s), with a mirroring expressed as a negative X scale. Each means the same transform in both shapes.std::formatprints both.
Quaternions are Vector<T, 4> in (x, y, z, w) order, unit, w the scalar part, applied by Vector<T, 3>::Rotate(v, q) and RotatePivot(v, q, pivot); identity is (0, 0, 0, 1). The functions are statics of Vector<T, 4> and are spelled Quanternion: QuanternionMultiply(a, b) is the Hamilton product, b first then a (Rotate(v, a*b) == Rotate(Rotate(v, b), a)); QuanternionConjugate(q) is the inverse rotation; QuanternionFromAxisAngle(unitAxis, radians) is right-handed (+90° about Z takes X to Y); QuanternionFromUnitVectors(from, to) is the shortest arc, defined for opposite directions too; QuanternionFromBasis(right, up, forward); QuanternionFromEuler(rollHalf, pitchHalf, yawHalf) takes half angles; QuanternionNlerp(a, b, t) and QuanternionSlerp(a, b, t) take the shorter arc and return unit quaternions.
Intersection tests (IntersectionTestRayTriangle, IntersectionTestRaySphere, IntersectionTestRayOrientedBox, IntersectionTestSphereOrientedBox, IntersectionTestOrientedBoxOrientedBox, with PackedOBBs<Packing> and GetOBBCorners) are batched over Packing * BatchSize primitives per call; Packing defaults to VectorF32<3, 1>::OptimalPacking (the ray-box test to the smaller of the 3- and 4-vector packings). A vertex argument is std::array<VectorF32<3, Packing>, BatchSize>: element b, lanes s*3 .. s*3+2, holds primitive b * Packing + s. The result is a VectorF32<1, Total> with lane i for primitive i: the ray parameter t, or std::numeric_limits<float>::max() for a miss (the two box-overlap tests return 0 for a hit). The triangle test culls back faces; the sphere test returns the near root, so a ray starting inside misses. tests/Intersection/main.cpp in the Crafter.Math repository has PackVec3, PackVec4 and PackScalars helpers worth copying.
Targets and SIMD
| Backend | Register | MaxElement (floats) |
VectorF32<3,1>::OptimalPacking |
BatchSize of VectorF32<3, Opt> |
|---|---|---|---|---|
x86 AVX-512 (x86-64-v4, sapphirerapids) |
64 B | 16 | 5 | 3 (15 lanes) |
x86 AVX2 (x86-64-v3), and the scalar fallback |
32 B | 8 | 2 | 4 (8 lanes) |
| AArch64 NEON, WebAssembly SIMD128 | 16 B | 4 | 1 | 4 |
| RISC-V RVV | 16, 32 or 64 B from the Zvl* in the march |
4, 8 or 16 |
- The backend and register width are fixed when the module is compiled, from predefined macros, so from the
--marchyour build uses. crafter-build's default isnative: fine for a developer build, wrong for a binary that ships.--march=x86-64-v3is the lowest x86 tier the library is tested on (its SSE paths use FMA); thesapphirerapidstier runs only on CPUs with AVX512-FP16. - Tested as of 2026-10-31: all three x86 tiers on Linux and on Windows (
x86_64-w64-mingw32under wine); AArch64armv8-aandarmv8.2-a+fp16under qemu, except Crafter.Math's own Matrix test, which does not compile for AArch64 with clang 23 (arm_neon.hwrapper functions defined twice across module units; it bites a unit that emitsVectorF32<3, 1>arithmetic before aVectorF32<3, 1>Sin,CosorSinCos, as that test does throughMatrixRowMajor::Rotation(pitch, yaw, roll)). If an AArch64 build of yours fails withdefinition with same mangled nameinarm_neon.h, it is that issue. RISC-V has never been built. WebAssembly builds (crafter-build adds-msimd128; flags you add to your own units must match) and has no tests.
Spellings
Quanternion*,MulitplyAdd,MulitplySub,CollumSize,Aligmentare the real identifiers. Don't correct them in a consumer; a rename is an upstream change that updates every consumer at once.