Skip to main content

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​

  • LoadMesh checks IsMeshLoaded first, 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).

  • PlayAnimation advances animationCurrentFrame and marks affected bones dirty; UpdateSkeletonBoneMatrices then recomputes each dirty bone's local/world matrices (parent-relative, walking up the hierarchy via parent).
  • bindPose/bindPoseInverse are 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​

  • SetAnimation just overwrites the current animation and resets to frame 0 — there's no blending between the old and new animation.
  • Only ROTATION keys are actually applied — TRANSLATION tracks are read but not yet wired up (marked TODO in-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's deltaTime is 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);
caution

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.