Mesh & Animation
src/mesh/, src/animation/, and src/quaternion.hh — loading .MESHBIN files (see the MESHBIN format spec), skeletal posing, and .ANIMBIN playback.
MeshManager
src/mesh/mesh_manager.hh
Loads and caches up to MAX_LOADED_MESHES (250) meshes by name, each with up to MAX_FACES_PER_MESH (1000) faces.
class MeshManager {
public:
psyqo::Coroutine<> LoadMesh(const eastl::string_view &meshName, MeshBin **meshOut);
void GetMeshFromName(const eastl::string_view &meshName, MeshBin **meshOut);
void UnloadMesh(const eastl::string_view &mesh_name);
// dump all meshes in memory and start fresh. Used when switching to a loading screen.
// dangerous — doesn't check if anything is still in use.
void Dump(void);
};
Non-static member of MadnightEngine (g_madnightEngine.m_meshManager).
MeshBin
The in-memory representation of a loaded .MESHBIN file (see the file format spec for the on-disk layout):
struct MeshBin {
uint8_t type; // 1 = quads, 2 = tris (unused)
// sub header
uint32_t vertexCount, indicesCount, facesCount, normalsCount, uvCount;
uint8_t hasSkeleton;
uint8_t numBones;
RendererFace *facesData;
// skeleton info
Skeleton *skeleton;
uint8_t *boneForVertex; // vertex index -> bone index
// basic min/max collision box
AABBCollision collisionBox;
BoundingSphere bsphere;
// for easier sub-division
RendererQuad *quads;
RendererTri *tris;
};
The loader does not keep the raw vertex, normal or UV arrays or their index lists. It bakes them into per-face RendererQuad / RendererTri entries (positions, vertex indices, colours, normal, UVs) and frees the raw arrays once loading finishes. facesData holds one RendererFace per face in file order (isQuad plus rendererFaceIx, the face's index into quads or tris).
GameObject::mesh() returns a pointer into a manager-owned MeshBin — meshes are shared across every GameObject that references the same name, not duplicated per-instance.
Usage
MeshBin *crateMesh;
co_await g_madnightEngine.m_meshManager.LoadMesh("crate", &crateMesh); // must be co_awaited inside a coroutine
crateGameObject->SetMesh("crate"); // looks it up by name once loaded
Internals
LoadMeshchecksIsMeshLoadedfirst, so calling it again with an already-loaded name is cheap — it just hands back the cached pointer instead of re-reading the file.
Skeleton & SkeletonController
src/mesh/skeleton/skeleton.hh
A fixed-size (MAX_BONES = 15) bone hierarchy embedded in a skinned MeshBin, posed via quaternion rotations.
struct SkeletonBoneMatrix {
psyqo::Matrix33 rotationMatrix;
psyqo::Vec3 translation;
};
struct SkeletonBone {
int8_t id;
int8_t parent; // -1 = root
psyqo::Vec3 localPos; // relative to parent
Quaternion localRotation; // relative to parent
Quaternion initialLocalRotation;
SkeletonBoneMatrix localMatrix; // computed by GameObject::GenerateRotationMatrix
SkeletonBoneMatrix worldMatrix; // computed from parent, likewise
SkeletonBoneMatrix bindPose; // pose when the skeleton was loaded in
SkeletonBoneMatrix bindPoseInverse;
bool isDirty = true; // set by the animation system when it needs recomputing
bool hasDoneBindPose = false;
};
struct Skeleton {
uint8_t numBones;
SkeletonBone bones[MAX_BONES];
Animation *animation;
uint16_t animationCurrentFrame = 0;
};
class SkeletonController {
public:
void UpdateSkeletonBoneMatrices(Skeleton *skeleton);
void MarkBonesClean(Skeleton *skeleton);
void SetAnimation(Skeleton *skeleton, Animation *animation);
void PlayAnimation(Skeleton *skeleton, uint32_t deltaTime);
};
Non-static member of MadnightEngine (g_madnightEngine.m_skeletonController).
PlayAnimationadvancesanimationCurrentFrameand marks affected bones dirty;UpdateSkeletonBoneMatricesthen recomputes each dirty bone's local/world matrices (parent-relative, walking up the hierarchy viaparent).bindPose/bindPoseInverseare captured once when the skeleton is first loaded and used to skin vertices back into their animated position each frame.
Usage
Animation *walkAnim = g_madnightEngine.m_animationManager.GetAnimationFromName("walk");
g_madnightEngine.m_skeletonController.SetAnimation(mesh->skeleton, walkAnim);
// per-frame:
g_madnightEngine.m_skeletonController.PlayAnimation(mesh->skeleton, deltaTime);
g_madnightEngine.m_skeletonController.UpdateSkeletonBoneMatrices(mesh->skeleton);
g_madnightEngine.m_skeletonController.MarkBonesClean(mesh->skeleton); // once you're done reading this frame's matrices
Internals
SetAnimationjust overwrites the current animation and resets to frame 0 — there's no blending between the old and new animation.- Only
ROTATIONkeys are actually applied —TRANSLATIONtracks are read but not yet wired up (markedTODOin-source). - Dirty state propagates down the hierarchy: a bone is recomputed if it changed or its parent did, so posing the hips also quietly re-dirties everything below it.
PlayAnimation'sdeltaTimeis GPU vsync ticks, not a fixed rate or milliseconds, with no refresh-rate normalization: the same clip plays about 20% faster on NTSC than on PAL. See the ANIMBIN format spec for the full breakdown.
AnimationManager
src/animation/animation_manager.hh
class AnimationManager final {
public:
psyqo::Coroutine<> LoadAnimation(const eastl::string_view &animationsFile);
Animation *GetAnimationFromName(const eastl::fixed_string<char, MAX_ANIMATION_NAME_LENGTH> &animationName);
};
Non-static member of MadnightEngine (g_madnightEngine.m_animationManager).
Loads a whole .ANIMBIN file (see the file format spec) at once — a single .ANIMBIN can contain up to MAX_ANIMATIONS (5) named animations, retrieved individually afterwards by name.
Like ColbinManager, this holds one loaded .ANIMBIN at a time (a lone AnimationBin member, not a pool) — loading a new file replaces whatever was loaded before.
Animation data types
src/animation/animation.hh
enum KeyType : uint8_t { ROTATION, TRANSLATION };
struct Key {
union { Quaternion rotation; psyqo::Vec3 translation; };
uint16_t frame;
KeyType keyType;
};
struct Track { // "for this bone, do this"
uint8_t type;
uint8_t jointId;
uint16_t numKeys;
Key keys[MAX_KEYS]; // MAX_KEYS = 30
};
struct Marker { // e.g. "play a footstep sound at this frame"
eastl::fixed_string<char, MAX_MARKER_NAME_LENGTH> name;
uint16_t frame;
};
struct Animation {
eastl::fixed_string<char, MAX_ANIMATION_NAME_LENGTH> name;
uint32_t flags; // bitfield — looped = 1
uint16_t length; // frame count
uint16_t numTracks;
Track tracks[MAX_TRACKS]; // MAX_TRACKS = 50
uint16_t numMarkers;
Marker markers[MAX_MARKERS]; // MAX_MARKERS = 5
};
struct AnimationBin {
uint8_t numAnimations;
Animation animations[MAX_ANIMATIONS]; // MAX_ANIMATIONS = 5
};
Quaternion
src/quaternion.hh
Quaternion type used throughout skeletal animation. As of v0.0.1, its components are full psyqo::FixedPoint<> ("full fat int32_t") rather than psyqo::GTE::Short — more precision headroom at the cost of some memory per component.
struct Quaternion {
psyqo::FixedPoint<> w{1.0}, x{0.0}, y{0.0}, z{0.0};
auto operator<=>(const Quaternion &) const = default;
psyqo::Matrix33 ToRotationMatrix() const;
void Normalize();
};
Quaternion operator*(const Quaternion &q1, const Quaternion &q2);
Quaternion operator-(const Quaternion &q);
psyqo::FixedPoint<> DotProduct(const Quaternion &a, const Quaternion &b);
// Small-rotation only for now — fine for animation interpolation.
Quaternion Slerp(const Quaternion &q1, const Quaternion &q2, psyqo::FixedPoint<> factor);
Quaternion FromEulerAngles(psyqo::Angle pitch, psyqo::Angle yaw, const psyqo::Trig<> &trig);
Quaternion FromEulerAngles(psyqo::Angle pitch, psyqo::Angle yaw, psyqo::Angle roll, const psyqo::Trig<> &trig);
Slerp isn't slerp. The implementation is a component-wise linear blend of the two quaternions followed by a normalize, with no acos/sin anywhere in it: it's nlerp. The in-source comment calling it "small rotations only" is describing the accuracy limits of that nlerp approximation, not a spherical interpolation with a reduced range. It's fine for interpolating between adjacent animation keyframes and not fine for arbitrary large-angle rotation blending.
FromEulerAngles builds a quaternion from the given pitch/yaw (or pitch/yaw/roll) angles. FindRotationQuat — the rotation quaternion that takes one vector to another — is commented out in-source as of v0.0.1 and isn't currently available; don't rely on it.