rigging and animation

This commit is contained in:
John McCardle 2026-02-04 23:19:03 -05:00
commit cc027a2517
11 changed files with 2120 additions and 38 deletions

View file

@ -56,6 +56,10 @@ Entity3D::~Entity3D()
{
// Cleanup cube geometry when last entity is destroyed?
// For now, leave it - it's shared static data
// Clean up Python animation callback
Py_XDECREF(py_anim_callback_);
py_anim_callback_ = nullptr;
}
// =============================================================================
@ -283,23 +287,27 @@ void Entity3D::processNextMove()
void Entity3D::update(float dt)
{
if (!is_animating_) return;
// Update movement animation
if (is_animating_) {
move_progress_ += dt * move_speed_;
move_progress_ += dt * move_speed_;
if (move_progress_ >= 1.0f) {
// Animation complete
world_pos_ = target_world_pos_;
is_animating_ = false;
if (move_progress_ >= 1.0f) {
// Animation complete
world_pos_ = target_world_pos_;
is_animating_ = false;
// Process next move in queue
if (!move_queue_.empty()) {
processNextMove();
// Process next move in queue
if (!move_queue_.empty()) {
processNextMove();
}
} else {
// Interpolate position
world_pos_ = vec3::lerp(move_start_pos_, target_world_pos_, move_progress_);
}
} else {
// Interpolate position
world_pos_ = vec3::lerp(move_start_pos_, target_world_pos_, move_progress_);
}
// Update skeletal animation
updateAnimation(dt);
}
bool Entity3D::setProperty(const std::string& name, float value)
@ -386,6 +394,111 @@ bool Entity3D::hasProperty(const std::string& name) const
name == "sprite_index" || name == "visible";
}
// =============================================================================
// Skeletal Animation
// =============================================================================
void Entity3D::setAnimClip(const std::string& name)
{
if (anim_clip_ == name) return;
anim_clip_ = name;
anim_time_ = 0.0f;
anim_paused_ = false;
// Initialize bone matrices if model has skeleton
if (model_ && model_->hasSkeleton()) {
size_t bone_count = model_->getBoneCount();
bone_matrices_.resize(bone_count);
for (auto& m : bone_matrices_) {
m = mat4::identity();
}
}
}
void Entity3D::updateAnimation(float dt)
{
// Handle auto-animate (play walk/idle based on movement state)
if (auto_animate_ && model_ && model_->hasSkeleton()) {
bool currently_moving = isMoving();
if (currently_moving != was_moving_) {
was_moving_ = currently_moving;
if (currently_moving) {
// Started moving - play walk clip
if (model_->findClip(walk_clip_)) {
setAnimClip(walk_clip_);
}
} else {
// Stopped moving - play idle clip
if (model_->findClip(idle_clip_)) {
setAnimClip(idle_clip_);
}
}
}
}
// Early out if no model, no skeleton, or no animation
if (!model_ || !model_->hasSkeleton()) return;
if (anim_clip_.empty() || anim_paused_) return;
const AnimationClip* clip = model_->findClip(anim_clip_);
if (!clip) return;
// Advance time
anim_time_ += dt * anim_speed_;
// Handle loop/completion
if (anim_time_ >= clip->duration) {
if (anim_loop_) {
anim_time_ = std::fmod(anim_time_, clip->duration);
} else {
anim_time_ = clip->duration;
anim_paused_ = true;
// Fire callback
if (on_anim_complete_) {
on_anim_complete_(this, anim_clip_);
}
// Fire Python callback
if (py_anim_callback_) {
PyObject* result = PyObject_CallFunction(py_anim_callback_, "(Os)",
self, anim_clip_.c_str());
if (result) {
Py_DECREF(result);
} else {
PyErr_Print();
}
}
}
}
// Sample animation
const Skeleton& skeleton = model_->getSkeleton();
const std::vector<mat4>& default_transforms = model_->getDefaultBoneTransforms();
std::vector<mat4> local_transforms;
clip->sample(anim_time_, skeleton.bones.size(), default_transforms, local_transforms);
// Compute global transforms
std::vector<mat4> global_transforms;
skeleton.computeGlobalTransforms(local_transforms, global_transforms);
// Compute final bone matrices (global * inverse_bind)
skeleton.computeBoneMatrices(global_transforms, bone_matrices_);
}
int Entity3D::getAnimFrame() const
{
if (!model_ || !model_->hasSkeleton()) return 0;
const AnimationClip* clip = model_->findClip(anim_clip_);
if (!clip || clip->duration <= 0) return 0;
// Approximate frame at 30fps
return static_cast<int>(anim_time_ * 30.0f);
}
// =============================================================================
// Rendering
// =============================================================================
@ -482,7 +595,13 @@ void Entity3D::render(const mat4& view, const mat4& proj, unsigned int shader)
// If we have a model, use it
if (model_) {
mat4 model = getModelMatrix();
model_->render(shader, model, view, proj);
// Use skinned rendering if model has skeleton and we have bone matrices
if (model_->hasSkeleton() && !bone_matrices_.empty()) {
model_->renderSkinned(shader, model, view, proj, bone_matrices_);
} else {
model_->render(shader, model, view, proj);
}
return;
}
@ -762,6 +881,147 @@ int Entity3D::set_model(PyEntity3DObject* self, PyObject* value, void* closure)
return 0;
}
// Animation property getters/setters
PyObject* Entity3D::get_anim_clip(PyEntity3DObject* self, void* closure)
{
return PyUnicode_FromString(self->data->getAnimClip().c_str());
}
int Entity3D::set_anim_clip(PyEntity3DObject* self, PyObject* value, void* closure)
{
if (!PyUnicode_Check(value)) {
PyErr_SetString(PyExc_TypeError, "anim_clip must be a string");
return -1;
}
self->data->setAnimClip(PyUnicode_AsUTF8(value));
return 0;
}
PyObject* Entity3D::get_anim_time(PyEntity3DObject* self, void* closure)
{
return PyFloat_FromDouble(self->data->getAnimTime());
}
int Entity3D::set_anim_time(PyEntity3DObject* self, PyObject* value, void* closure)
{
if (!PyNumber_Check(value)) {
PyErr_SetString(PyExc_TypeError, "anim_time must be a number");
return -1;
}
self->data->setAnimTime((float)PyFloat_AsDouble(value));
return 0;
}
PyObject* Entity3D::get_anim_speed(PyEntity3DObject* self, void* closure)
{
return PyFloat_FromDouble(self->data->getAnimSpeed());
}
int Entity3D::set_anim_speed(PyEntity3DObject* self, PyObject* value, void* closure)
{
if (!PyNumber_Check(value)) {
PyErr_SetString(PyExc_TypeError, "anim_speed must be a number");
return -1;
}
self->data->setAnimSpeed((float)PyFloat_AsDouble(value));
return 0;
}
PyObject* Entity3D::get_anim_loop(PyEntity3DObject* self, void* closure)
{
return PyBool_FromLong(self->data->getAnimLoop() ? 1 : 0);
}
int Entity3D::set_anim_loop(PyEntity3DObject* self, PyObject* value, void* closure)
{
self->data->setAnimLoop(PyObject_IsTrue(value));
return 0;
}
PyObject* Entity3D::get_anim_paused(PyEntity3DObject* self, void* closure)
{
return PyBool_FromLong(self->data->getAnimPaused() ? 1 : 0);
}
int Entity3D::set_anim_paused(PyEntity3DObject* self, PyObject* value, void* closure)
{
self->data->setAnimPaused(PyObject_IsTrue(value));
return 0;
}
PyObject* Entity3D::get_anim_frame(PyEntity3DObject* self, void* closure)
{
return PyLong_FromLong(self->data->getAnimFrame());
}
PyObject* Entity3D::get_on_anim_complete(PyEntity3DObject* self, void* closure)
{
if (self->data->py_anim_callback_) {
Py_INCREF(self->data->py_anim_callback_);
return self->data->py_anim_callback_;
}
Py_RETURN_NONE;
}
int Entity3D::set_on_anim_complete(PyEntity3DObject* self, PyObject* value, void* closure)
{
// Clear existing callback
Py_XDECREF(self->data->py_anim_callback_);
if (value == Py_None) {
self->data->py_anim_callback_ = nullptr;
} else if (PyCallable_Check(value)) {
Py_INCREF(value);
self->data->py_anim_callback_ = value;
} else {
PyErr_SetString(PyExc_TypeError, "on_anim_complete must be callable or None");
return -1;
}
return 0;
}
PyObject* Entity3D::get_auto_animate(PyEntity3DObject* self, void* closure)
{
return PyBool_FromLong(self->data->getAutoAnimate() ? 1 : 0);
}
int Entity3D::set_auto_animate(PyEntity3DObject* self, PyObject* value, void* closure)
{
self->data->setAutoAnimate(PyObject_IsTrue(value));
return 0;
}
PyObject* Entity3D::get_walk_clip(PyEntity3DObject* self, void* closure)
{
return PyUnicode_FromString(self->data->getWalkClip().c_str());
}
int Entity3D::set_walk_clip(PyEntity3DObject* self, PyObject* value, void* closure)
{
if (!PyUnicode_Check(value)) {
PyErr_SetString(PyExc_TypeError, "walk_clip must be a string");
return -1;
}
self->data->setWalkClip(PyUnicode_AsUTF8(value));
return 0;
}
PyObject* Entity3D::get_idle_clip(PyEntity3DObject* self, void* closure)
{
return PyUnicode_FromString(self->data->getIdleClip().c_str());
}
int Entity3D::set_idle_clip(PyEntity3DObject* self, PyObject* value, void* closure)
{
if (!PyUnicode_Check(value)) {
PyErr_SetString(PyExc_TypeError, "idle_clip must be a string");
return -1;
}
self->data->setIdleClip(PyUnicode_AsUTF8(value));
return 0;
}
// Methods
PyObject* Entity3D::py_path_to(PyEntity3DObject* self, PyObject* args, PyObject* kwds)
@ -903,6 +1163,29 @@ PyGetSetDef Entity3D::getsetters[] = {
"Owning Viewport3D (read-only).", NULL},
{"model", (getter)Entity3D::get_model, (setter)Entity3D::set_model,
"3D model (Model3D). If None, uses placeholder cube.", NULL},
// Animation properties
{"anim_clip", (getter)Entity3D::get_anim_clip, (setter)Entity3D::set_anim_clip,
"Current animation clip name. Set to play an animation.", NULL},
{"anim_time", (getter)Entity3D::get_anim_time, (setter)Entity3D::set_anim_time,
"Current time position in animation (seconds).", NULL},
{"anim_speed", (getter)Entity3D::get_anim_speed, (setter)Entity3D::set_anim_speed,
"Animation playback speed multiplier. 1.0 = normal speed.", NULL},
{"anim_loop", (getter)Entity3D::get_anim_loop, (setter)Entity3D::set_anim_loop,
"Whether animation loops when it reaches the end.", NULL},
{"anim_paused", (getter)Entity3D::get_anim_paused, (setter)Entity3D::set_anim_paused,
"Whether animation playback is paused.", NULL},
{"anim_frame", (getter)Entity3D::get_anim_frame, NULL,
"Current animation frame number (read-only, approximate at 30fps).", NULL},
{"on_anim_complete", (getter)Entity3D::get_on_anim_complete, (setter)Entity3D::set_on_anim_complete,
"Callback(entity, clip_name) when non-looping animation ends.", NULL},
{"auto_animate", (getter)Entity3D::get_auto_animate, (setter)Entity3D::set_auto_animate,
"Enable auto-play of walk/idle clips based on movement.", NULL},
{"walk_clip", (getter)Entity3D::get_walk_clip, (setter)Entity3D::set_walk_clip,
"Animation clip to play when entity is moving.", NULL},
{"idle_clip", (getter)Entity3D::get_idle_clip, (setter)Entity3D::set_idle_clip,
"Animation clip to play when entity is stationary.", NULL},
{NULL} // Sentinel
};

View file

@ -11,6 +11,7 @@
#include <queue>
#include <vector>
#include <string>
#include <functional>
namespace mcrf {
@ -158,6 +159,57 @@ public:
bool getProperty(const std::string& name, float& value) const;
bool hasProperty(const std::string& name) const;
// =========================================================================
// Skeletal Animation
// =========================================================================
/// Get current animation clip name
const std::string& getAnimClip() const { return anim_clip_; }
/// Set animation clip by name (starts playing)
void setAnimClip(const std::string& name);
/// Get/set animation time (position in clip)
float getAnimTime() const { return anim_time_; }
void setAnimTime(float t) { anim_time_ = t; }
/// Get/set playback speed (1.0 = normal)
float getAnimSpeed() const { return anim_speed_; }
void setAnimSpeed(float s) { anim_speed_ = s; }
/// Get/set looping state
bool getAnimLoop() const { return anim_loop_; }
void setAnimLoop(bool l) { anim_loop_ = l; }
/// Get/set pause state
bool getAnimPaused() const { return anim_paused_; }
void setAnimPaused(bool p) { anim_paused_ = p; }
/// Get current animation frame (approximate)
int getAnimFrame() const;
/// Update skeletal animation (call before render)
void updateAnimation(float dt);
/// Get computed bone matrices for shader
const std::vector<mat4>& getBoneMatrices() const { return bone_matrices_; }
/// Animation complete callback type
using AnimCompleteCallback = std::function<void(Entity3D*, const std::string&)>;
/// Set animation complete callback
void setOnAnimComplete(AnimCompleteCallback cb) { on_anim_complete_ = cb; }
/// Auto-animate settings (play walk/idle based on movement)
bool getAutoAnimate() const { return auto_animate_; }
void setAutoAnimate(bool a) { auto_animate_ = a; }
const std::string& getWalkClip() const { return walk_clip_; }
void setWalkClip(const std::string& c) { walk_clip_ = c; }
const std::string& getIdleClip() const { return idle_clip_; }
void setIdleClip(const std::string& c) { idle_clip_ = c; }
// =========================================================================
// Rendering
// =========================================================================
@ -193,6 +245,27 @@ public:
static PyObject* get_model(PyEntity3DObject* self, void* closure);
static int set_model(PyEntity3DObject* self, PyObject* value, void* closure);
// Animation property getters/setters
static PyObject* get_anim_clip(PyEntity3DObject* self, void* closure);
static int set_anim_clip(PyEntity3DObject* self, PyObject* value, void* closure);
static PyObject* get_anim_time(PyEntity3DObject* self, void* closure);
static int set_anim_time(PyEntity3DObject* self, PyObject* value, void* closure);
static PyObject* get_anim_speed(PyEntity3DObject* self, void* closure);
static int set_anim_speed(PyEntity3DObject* self, PyObject* value, void* closure);
static PyObject* get_anim_loop(PyEntity3DObject* self, void* closure);
static int set_anim_loop(PyEntity3DObject* self, PyObject* value, void* closure);
static PyObject* get_anim_paused(PyEntity3DObject* self, void* closure);
static int set_anim_paused(PyEntity3DObject* self, PyObject* value, void* closure);
static PyObject* get_anim_frame(PyEntity3DObject* self, void* closure);
static PyObject* get_on_anim_complete(PyEntity3DObject* self, void* closure);
static int set_on_anim_complete(PyEntity3DObject* self, PyObject* value, void* closure);
static PyObject* get_auto_animate(PyEntity3DObject* self, void* closure);
static int set_auto_animate(PyEntity3DObject* self, PyObject* value, void* closure);
static PyObject* get_walk_clip(PyEntity3DObject* self, void* closure);
static int set_walk_clip(PyEntity3DObject* self, PyObject* value, void* closure);
static PyObject* get_idle_clip(PyEntity3DObject* self, void* closure);
static int set_idle_clip(PyEntity3DObject* self, PyObject* value, void* closure);
// Methods
static PyObject* py_path_to(PyEntity3DObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_teleport(PyEntity3DObject* self, PyObject* args, PyObject* kwds);
@ -240,6 +313,24 @@ private:
float move_speed_ = 5.0f; // Cells per second
vec3 move_start_pos_;
// Skeletal animation state
std::string anim_clip_; // Current animation clip name
float anim_time_ = 0.0f; // Current time in animation
float anim_speed_ = 1.0f; // Playback speed multiplier
bool anim_loop_ = true; // Loop animation
bool anim_paused_ = false; // Pause playback
std::vector<mat4> bone_matrices_; // Computed bone matrices for shader
AnimCompleteCallback on_anim_complete_; // Callback when animation ends
// Auto-animate state
bool auto_animate_ = true; // Auto-play walk/idle based on movement
std::string walk_clip_ = "walk"; // Clip to play when moving
std::string idle_clip_ = "idle"; // Clip to play when stopped
bool was_moving_ = false; // Track movement state for auto-animate
// Python callback for animation complete
PyObject* py_anim_callback_ = nullptr;
// Helper to initialize voxel state
void initVoxelState() const;

View file

@ -60,6 +60,176 @@ ModelMesh& ModelMesh::operator=(ModelMesh&& other) noexcept
return *this;
}
// =============================================================================
// SkinnedMesh Implementation
// =============================================================================
SkinnedMesh::SkinnedMesh(SkinnedMesh&& other) noexcept
: vbo(other.vbo)
, ebo(other.ebo)
, vertex_count(other.vertex_count)
, index_count(other.index_count)
, material_index(other.material_index)
, is_skinned(other.is_skinned)
{
other.vbo = 0;
other.ebo = 0;
other.vertex_count = 0;
other.index_count = 0;
}
SkinnedMesh& SkinnedMesh::operator=(SkinnedMesh&& other) noexcept
{
if (this != &other) {
vbo = other.vbo;
ebo = other.ebo;
vertex_count = other.vertex_count;
index_count = other.index_count;
material_index = other.material_index;
is_skinned = other.is_skinned;
other.vbo = 0;
other.ebo = 0;
other.vertex_count = 0;
other.index_count = 0;
}
return *this;
}
// =============================================================================
// AnimationChannel Implementation
// =============================================================================
void AnimationChannel::sample(float time, vec3& trans_out, quat& rot_out, vec3& scale_out) const
{
if (times.empty()) return;
// Clamp time to animation range
float t = std::max(times.front(), std::min(time, times.back()));
// Find surrounding keyframes
size_t k0 = 0, k1 = 0;
float blend = 0.0f;
for (size_t i = 0; i < times.size() - 1; i++) {
if (t >= times[i] && t <= times[i + 1]) {
k0 = i;
k1 = i + 1;
float dt = times[k1] - times[k0];
blend = (dt > 0.0001f) ? (t - times[k0]) / dt : 0.0f;
break;
}
}
// If time is past the last keyframe, use last keyframe
if (t >= times.back()) {
k0 = k1 = times.size() - 1;
blend = 0.0f;
}
// Interpolate based on path type
switch (path) {
case Path::Translation:
if (!translations.empty()) {
trans_out = vec3::lerp(translations[k0], translations[k1], blend);
}
break;
case Path::Rotation:
if (!rotations.empty()) {
rot_out = quat::slerp(rotations[k0], rotations[k1], blend);
}
break;
case Path::Scale:
if (!scales.empty()) {
scale_out = vec3::lerp(scales[k0], scales[k1], blend);
}
break;
}
}
// =============================================================================
// AnimationClip Implementation
// =============================================================================
void AnimationClip::sample(float time, size_t num_bones,
const std::vector<mat4>& default_transforms,
std::vector<mat4>& local_out) const
{
// Initialize with default transforms
local_out.resize(num_bones);
for (size_t i = 0; i < num_bones && i < default_transforms.size(); i++) {
local_out[i] = default_transforms[i];
}
// Track which components have been animated per bone
struct BoneAnimState {
vec3 translation = vec3(0, 0, 0);
quat rotation;
vec3 scale = vec3(1, 1, 1);
bool has_translation = false;
bool has_rotation = false;
bool has_scale = false;
};
std::vector<BoneAnimState> bone_states(num_bones);
// Sample all channels
for (const auto& channel : channels) {
if (channel.bone_index < 0 || channel.bone_index >= static_cast<int>(num_bones)) {
continue;
}
auto& state = bone_states[channel.bone_index];
vec3 trans_dummy, scale_dummy;
quat rot_dummy;
channel.sample(time, trans_dummy, rot_dummy, scale_dummy);
switch (channel.path) {
case AnimationChannel::Path::Translation:
state.translation = trans_dummy;
state.has_translation = true;
break;
case AnimationChannel::Path::Rotation:
state.rotation = rot_dummy;
state.has_rotation = true;
break;
case AnimationChannel::Path::Scale:
state.scale = scale_dummy;
state.has_scale = true;
break;
}
}
// Build final local transforms for animated bones
for (size_t i = 0; i < num_bones; i++) {
const auto& state = bone_states[i];
// Only rebuild if at least one component was animated
if (state.has_translation || state.has_rotation || state.has_scale) {
// Extract default values from default transform if not animated
// (simplified: assume default is identity or use stored values)
vec3 t = state.has_translation ? state.translation : vec3(0, 0, 0);
quat r = state.has_rotation ? state.rotation : quat();
vec3 s = state.has_scale ? state.scale : vec3(1, 1, 1);
// If not fully animated, try to extract from default transform
if (!state.has_translation || !state.has_rotation || !state.has_scale) {
// For now, assume default transform contains the rest pose
// A more complete implementation would decompose default_transforms[i]
if (!state.has_translation) {
t = vec3(default_transforms[i].at(3, 0),
default_transforms[i].at(3, 1),
default_transforms[i].at(3, 2));
}
}
// Compose: T * R * S
local_out[i] = mat4::translate(t) * r.toMatrix() * mat4::scale(s);
}
}
}
// =============================================================================
// Model3D Implementation
// =============================================================================
@ -77,9 +247,13 @@ Model3D::~Model3D()
Model3D::Model3D(Model3D&& other) noexcept
: name_(std::move(other.name_))
, meshes_(std::move(other.meshes_))
, skinned_meshes_(std::move(other.skinned_meshes_))
, bounds_min_(other.bounds_min_)
, bounds_max_(other.bounds_max_)
, has_skeleton_(other.has_skeleton_)
, skeleton_(std::move(other.skeleton_))
, animation_clips_(std::move(other.animation_clips_))
, default_bone_transforms_(std::move(other.default_bone_transforms_))
{
}
@ -89,9 +263,13 @@ Model3D& Model3D::operator=(Model3D&& other) noexcept
cleanupGPU();
name_ = std::move(other.name_);
meshes_ = std::move(other.meshes_);
skinned_meshes_ = std::move(other.skinned_meshes_);
bounds_min_ = other.bounds_min_;
bounds_max_ = other.bounds_max_;
has_skeleton_ = other.has_skeleton_;
skeleton_ = std::move(other.skeleton_);
animation_clips_ = std::move(other.animation_clips_);
default_bone_transforms_ = std::move(other.default_bone_transforms_);
}
return *this;
}
@ -110,9 +288,20 @@ void Model3D::cleanupGPU()
mesh.ebo = 0;
}
}
for (auto& mesh : skinned_meshes_) {
if (mesh.vbo) {
glDeleteBuffers(1, &mesh.vbo);
mesh.vbo = 0;
}
if (mesh.ebo) {
glDeleteBuffers(1, &mesh.ebo);
mesh.ebo = 0;
}
}
}
#endif
meshes_.clear();
skinned_meshes_.clear();
}
void Model3D::computeBounds(const std::vector<MeshVertex>& vertices)
@ -184,6 +373,9 @@ int Model3D::getVertexCount() const
for (const auto& mesh : meshes_) {
total += mesh.vertex_count;
}
for (const auto& mesh : skinned_meshes_) {
total += mesh.vertex_count;
}
return total;
}
@ -197,6 +389,13 @@ int Model3D::getTriangleCount() const
total += mesh.vertex_count / 3;
}
}
for (const auto& mesh : skinned_meshes_) {
if (mesh.index_count > 0) {
total += mesh.index_count / 3;
} else {
total += mesh.vertex_count / 3;
}
}
return total;
}
@ -512,6 +711,8 @@ std::shared_ptr<Model3D> Model3D::load(const std::string& path)
std::vector<vec3> normals;
std::vector<vec2> texcoords;
std::vector<vec4> colors;
std::vector<vec4> joints; // Bone indices (as floats for shader compatibility)
std::vector<vec4> weights; // Bone weights
// Extract attributes
for (size_t k = 0; k < prim->attributes_count; ++k) {
@ -548,6 +749,26 @@ std::shared_ptr<Model3D> Model3D::load(const std::string& path)
}
}
}
else if (attr->type == cgltf_attribute_type_joints && attr->index == 0) {
// Bone indices - can be unsigned byte or unsigned short
joints.resize(accessor->count);
for (size_t v = 0; v < accessor->count; ++v) {
// Read as uint then convert to float for shader compatibility
cgltf_uint indices[4] = {0, 0, 0, 0};
cgltf_accessor_read_uint(accessor, v, indices, 4);
joints[v].x = static_cast<float>(indices[0]);
joints[v].y = static_cast<float>(indices[1]);
joints[v].z = static_cast<float>(indices[2]);
joints[v].w = static_cast<float>(indices[3]);
}
}
else if (attr->type == cgltf_attribute_type_weights && attr->index == 0) {
// Bone weights
weights.resize(accessor->count);
for (size_t v = 0; v < accessor->count; ++v) {
cgltf_accessor_read_float(accessor, v, &weights[v].x, 4);
}
}
}
// Skip if no positions
@ -567,19 +788,6 @@ std::shared_ptr<Model3D> Model3D::load(const std::string& path)
colors.resize(vertCount, vec4(1, 1, 1, 1));
}
// Interleave vertex data
std::vector<MeshVertex> vertices;
vertices.reserve(vertCount);
for (size_t v = 0; v < vertCount; ++v) {
MeshVertex mv;
mv.position = positions[v];
mv.texcoord = texcoords[v];
mv.normal = normals[v];
mv.color = colors[v];
vertices.push_back(mv);
allVertices.push_back(mv);
}
// Extract indices
std::vector<uint32_t> indices;
if (prim->indices) {
@ -590,18 +798,422 @@ std::shared_ptr<Model3D> Model3D::load(const std::string& path)
}
}
// Create mesh
model->meshes_.push_back(createMesh(vertices, indices));
// Check if this is a skinned mesh (has joints and weights)
bool isSkinned = !joints.empty() && !weights.empty() && model->has_skeleton_;
if (isSkinned) {
// Create skinned mesh with bone data
std::vector<SkinnedVertex> skinnedVertices;
skinnedVertices.reserve(vertCount);
for (size_t v = 0; v < vertCount; ++v) {
SkinnedVertex sv;
sv.position = positions[v];
sv.texcoord = texcoords[v];
sv.normal = normals[v];
sv.color = colors[v];
sv.bone_ids = joints[v];
sv.bone_weights = weights[v];
skinnedVertices.push_back(sv);
// Also track for bounds calculation
MeshVertex mv;
mv.position = positions[v];
mv.texcoord = texcoords[v];
mv.normal = normals[v];
mv.color = colors[v];
allVertices.push_back(mv);
}
model->skinned_meshes_.push_back(model->createSkinnedMesh(skinnedVertices, indices));
} else {
// Interleave vertex data for regular mesh
std::vector<MeshVertex> vertices;
vertices.reserve(vertCount);
for (size_t v = 0; v < vertCount; ++v) {
MeshVertex mv;
mv.position = positions[v];
mv.texcoord = texcoords[v];
mv.normal = normals[v];
mv.color = colors[v];
vertices.push_back(mv);
allVertices.push_back(mv);
}
model->meshes_.push_back(createMesh(vertices, indices));
}
}
}
// Compute bounds from all vertices
model->computeBounds(allVertices);
// Load skeleton and animations if present
if (model->has_skeleton_) {
model->loadSkeleton(data);
model->loadAnimations(data);
}
cgltf_free(data);
return model;
}
// =============================================================================
// Skeleton Loading from glTF
// =============================================================================
int Model3D::findJointIndex(void* cgltf_skin_ptr, void* node_ptr)
{
cgltf_skin* skin = static_cast<cgltf_skin*>(cgltf_skin_ptr);
cgltf_node* node = static_cast<cgltf_node*>(node_ptr);
if (!skin || !node) return -1;
for (size_t i = 0; i < skin->joints_count; i++) {
if (skin->joints[i] == node) {
return static_cast<int>(i);
}
}
return -1;
}
void Model3D::loadSkeleton(void* cgltf_data_ptr)
{
cgltf_data* data = static_cast<cgltf_data*>(cgltf_data_ptr);
if (!data || data->skins_count == 0) {
has_skeleton_ = false;
return;
}
cgltf_skin* skin = &data->skins[0]; // Use first skin
// Resize skeleton
skeleton_.bones.resize(skin->joints_count);
default_bone_transforms_.resize(skin->joints_count);
// Load inverse bind matrices
if (skin->inverse_bind_matrices) {
cgltf_accessor* ibm = skin->inverse_bind_matrices;
for (size_t i = 0; i < skin->joints_count && i < ibm->count; i++) {
float mat_data[16];
cgltf_accessor_read_float(ibm, i, mat_data, 16);
// cgltf gives us column-major matrices (same as our mat4)
for (int j = 0; j < 16; j++) {
skeleton_.bones[i].inverse_bind_matrix.m[j] = mat_data[j];
}
}
}
// Load bone hierarchy
for (size_t i = 0; i < skin->joints_count; i++) {
cgltf_node* joint = skin->joints[i];
Bone& bone = skeleton_.bones[i];
// Name
bone.name = joint->name ? joint->name : ("bone_" + std::to_string(i));
// Find parent index
bone.parent_index = findJointIndex(skin, joint->parent);
// Track root bones
if (bone.parent_index < 0) {
skeleton_.root_bones.push_back(static_cast<int>(i));
}
// Local transform
if (joint->has_matrix) {
for (int j = 0; j < 16; j++) {
bone.local_transform.m[j] = joint->matrix[j];
}
} else {
// Compose from TRS
vec3 t(0, 0, 0);
quat r;
vec3 s(1, 1, 1);
if (joint->has_translation) {
t = vec3(joint->translation[0], joint->translation[1], joint->translation[2]);
}
if (joint->has_rotation) {
r = quat(joint->rotation[0], joint->rotation[1],
joint->rotation[2], joint->rotation[3]);
}
if (joint->has_scale) {
s = vec3(joint->scale[0], joint->scale[1], joint->scale[2]);
}
bone.local_transform = mat4::translate(t) * r.toMatrix() * mat4::scale(s);
}
default_bone_transforms_[i] = bone.local_transform;
}
has_skeleton_ = true;
}
// =============================================================================
// Animation Loading from glTF
// =============================================================================
void Model3D::loadAnimations(void* cgltf_data_ptr)
{
cgltf_data* data = static_cast<cgltf_data*>(cgltf_data_ptr);
if (!data || data->skins_count == 0) return;
cgltf_skin* skin = &data->skins[0];
for (size_t i = 0; i < data->animations_count; i++) {
cgltf_animation* anim = &data->animations[i];
AnimationClip clip;
clip.name = anim->name ? anim->name : ("animation_" + std::to_string(i));
clip.duration = 0.0f;
for (size_t j = 0; j < anim->channels_count; j++) {
cgltf_animation_channel* chan = &anim->channels[j];
cgltf_animation_sampler* sampler = chan->sampler;
if (!sampler || !chan->target_node) continue;
AnimationChannel channel;
channel.bone_index = findJointIndex(skin, chan->target_node);
if (channel.bone_index < 0) continue; // Not a bone we're tracking
// Determine path type
switch (chan->target_path) {
case cgltf_animation_path_type_translation:
channel.path = AnimationChannel::Path::Translation;
break;
case cgltf_animation_path_type_rotation:
channel.path = AnimationChannel::Path::Rotation;
break;
case cgltf_animation_path_type_scale:
channel.path = AnimationChannel::Path::Scale;
break;
default:
continue; // Skip unsupported paths (weights, etc.)
}
// Load keyframe times
cgltf_accessor* input = sampler->input;
if (input) {
channel.times.resize(input->count);
for (size_t k = 0; k < input->count; k++) {
cgltf_accessor_read_float(input, k, &channel.times[k], 1);
}
// Update clip duration
if (!channel.times.empty() && channel.times.back() > clip.duration) {
clip.duration = channel.times.back();
}
}
// Load keyframe values
cgltf_accessor* output = sampler->output;
if (output) {
switch (channel.path) {
case AnimationChannel::Path::Translation:
case AnimationChannel::Path::Scale:
{
std::vector<vec3>& target = (channel.path == AnimationChannel::Path::Translation)
? channel.translations : channel.scales;
target.resize(output->count);
for (size_t k = 0; k < output->count; k++) {
float v[3];
cgltf_accessor_read_float(output, k, v, 3);
target[k] = vec3(v[0], v[1], v[2]);
}
break;
}
case AnimationChannel::Path::Rotation:
{
channel.rotations.resize(output->count);
for (size_t k = 0; k < output->count; k++) {
float v[4];
cgltf_accessor_read_float(output, k, v, 4);
// glTF stores quaternions as (x, y, z, w)
channel.rotations[k] = quat(v[0], v[1], v[2], v[3]);
}
break;
}
}
}
clip.channels.push_back(std::move(channel));
}
if (!clip.channels.empty()) {
animation_clips_.push_back(std::move(clip));
}
}
}
// =============================================================================
// Skinned Mesh Creation
// =============================================================================
SkinnedMesh Model3D::createSkinnedMesh(const std::vector<SkinnedVertex>& vertices,
const std::vector<uint32_t>& indices)
{
SkinnedMesh mesh;
mesh.vertex_count = static_cast<int>(vertices.size());
mesh.index_count = static_cast<int>(indices.size());
mesh.is_skinned = true;
#ifdef MCRF_HAS_GL
if (!gl::isGLReady()) {
return mesh;
}
// Create VBO
glGenBuffers(1, &mesh.vbo);
glBindBuffer(GL_ARRAY_BUFFER, mesh.vbo);
glBufferData(GL_ARRAY_BUFFER,
vertices.size() * sizeof(SkinnedVertex),
vertices.data(),
GL_STATIC_DRAW);
// Create EBO if indexed
if (!indices.empty()) {
glGenBuffers(1, &mesh.ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, mesh.ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER,
indices.size() * sizeof(uint32_t),
indices.data(),
GL_STATIC_DRAW);
}
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
#endif
return mesh;
}
// =============================================================================
// Skinned Rendering
// =============================================================================
void Model3D::renderSkinned(unsigned int shader, const mat4& model,
const mat4& view, const mat4& projection,
const std::vector<mat4>& bone_matrices)
{
#ifdef MCRF_HAS_GL
if (!gl::isGLReady()) return;
// Calculate MVP
mat4 mvp = projection * view * model;
// Set uniforms
int mvpLoc = glGetUniformLocation(shader, "u_mvp");
int modelLoc = glGetUniformLocation(shader, "u_model");
int bonesLoc = glGetUniformLocation(shader, "u_bones");
if (mvpLoc >= 0) glUniformMatrix4fv(mvpLoc, 1, GL_FALSE, mvp.data());
if (modelLoc >= 0) glUniformMatrix4fv(modelLoc, 1, GL_FALSE, model.data());
// Upload bone matrices (max 64 bones)
if (bonesLoc >= 0 && !bone_matrices.empty()) {
int count = std::min(static_cast<int>(bone_matrices.size()), 64);
glUniformMatrix4fv(bonesLoc, count, GL_FALSE, bone_matrices[0].data());
}
// For now, fall back to regular rendering for non-skinned meshes
// TODO: Add skinned mesh rendering with bone weight attributes
// Render skinned meshes
for (const auto& mesh : skinned_meshes_) {
if (mesh.vertex_count == 0) continue;
glBindBuffer(GL_ARRAY_BUFFER, mesh.vbo);
// Position (location 0)
glEnableVertexAttribArray(Shader3D::ATTRIB_POSITION);
glVertexAttribPointer(Shader3D::ATTRIB_POSITION, 3, GL_FLOAT, GL_FALSE,
sizeof(SkinnedVertex), (void*)offsetof(SkinnedVertex, position));
// Texcoord (location 1)
glEnableVertexAttribArray(Shader3D::ATTRIB_TEXCOORD);
glVertexAttribPointer(Shader3D::ATTRIB_TEXCOORD, 2, GL_FLOAT, GL_FALSE,
sizeof(SkinnedVertex), (void*)offsetof(SkinnedVertex, texcoord));
// Normal (location 2)
glEnableVertexAttribArray(Shader3D::ATTRIB_NORMAL);
glVertexAttribPointer(Shader3D::ATTRIB_NORMAL, 3, GL_FLOAT, GL_FALSE,
sizeof(SkinnedVertex), (void*)offsetof(SkinnedVertex, normal));
// Color (location 3)
glEnableVertexAttribArray(Shader3D::ATTRIB_COLOR);
glVertexAttribPointer(Shader3D::ATTRIB_COLOR, 4, GL_FLOAT, GL_FALSE,
sizeof(SkinnedVertex), (void*)offsetof(SkinnedVertex, color));
// Bone IDs (location 4) - as vec4 float for GLES2 compatibility
glEnableVertexAttribArray(4);
glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE,
sizeof(SkinnedVertex), (void*)offsetof(SkinnedVertex, bone_ids));
// Bone Weights (location 5)
glEnableVertexAttribArray(5);
glVertexAttribPointer(5, 4, GL_FLOAT, GL_FALSE,
sizeof(SkinnedVertex), (void*)offsetof(SkinnedVertex, bone_weights));
// Draw
if (mesh.index_count > 0) {
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, mesh.ebo);
glDrawElements(GL_TRIANGLES, mesh.index_count, GL_UNSIGNED_INT, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
} else {
glDrawArrays(GL_TRIANGLES, 0, mesh.vertex_count);
}
// Cleanup
glDisableVertexAttribArray(Shader3D::ATTRIB_POSITION);
glDisableVertexAttribArray(Shader3D::ATTRIB_TEXCOORD);
glDisableVertexAttribArray(Shader3D::ATTRIB_NORMAL);
glDisableVertexAttribArray(Shader3D::ATTRIB_COLOR);
glDisableVertexAttribArray(4);
glDisableVertexAttribArray(5);
}
// Also render regular meshes (may not have skinning)
for (const auto& mesh : meshes_) {
if (mesh.vertex_count == 0) continue;
glBindBuffer(GL_ARRAY_BUFFER, mesh.vbo);
glEnableVertexAttribArray(Shader3D::ATTRIB_POSITION);
glVertexAttribPointer(Shader3D::ATTRIB_POSITION, 3, GL_FLOAT, GL_FALSE,
sizeof(MeshVertex), (void*)offsetof(MeshVertex, position));
glEnableVertexAttribArray(Shader3D::ATTRIB_TEXCOORD);
glVertexAttribPointer(Shader3D::ATTRIB_TEXCOORD, 2, GL_FLOAT, GL_FALSE,
sizeof(MeshVertex), (void*)offsetof(MeshVertex, texcoord));
glEnableVertexAttribArray(Shader3D::ATTRIB_NORMAL);
glVertexAttribPointer(Shader3D::ATTRIB_NORMAL, 3, GL_FLOAT, GL_FALSE,
sizeof(MeshVertex), (void*)offsetof(MeshVertex, normal));
glEnableVertexAttribArray(Shader3D::ATTRIB_COLOR);
glVertexAttribPointer(Shader3D::ATTRIB_COLOR, 4, GL_FLOAT, GL_FALSE,
sizeof(MeshVertex), (void*)offsetof(MeshVertex, color));
if (mesh.index_count > 0) {
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, mesh.ebo);
glDrawElements(GL_TRIANGLES, mesh.index_count, GL_UNSIGNED_INT, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
} else {
glDrawArrays(GL_TRIANGLES, 0, mesh.vertex_count);
}
glDisableVertexAttribArray(Shader3D::ATTRIB_POSITION);
glDisableVertexAttribArray(Shader3D::ATTRIB_TEXCOORD);
glDisableVertexAttribArray(Shader3D::ATTRIB_NORMAL);
glDisableVertexAttribArray(Shader3D::ATTRIB_COLOR);
}
glBindBuffer(GL_ARRAY_BUFFER, 0);
#endif
}
// =============================================================================
// Python API Implementation
// =============================================================================
@ -781,6 +1393,38 @@ PyObject* Model3D::get_mesh_count(PyObject* self, void* closure)
return PyLong_FromLong(static_cast<long>(obj->data->getMeshCount()));
}
PyObject* Model3D::get_bone_count(PyObject* self, void* closure)
{
PyModel3DObject* obj = (PyModel3DObject*)self;
if (!obj->data) {
return PyLong_FromLong(0);
}
return PyLong_FromLong(static_cast<long>(obj->data->getBoneCount()));
}
PyObject* Model3D::get_animation_clips(PyObject* self, void* closure)
{
PyModel3DObject* obj = (PyModel3DObject*)self;
if (!obj->data) {
return PyList_New(0);
}
auto names = obj->data->getAnimationClipNames();
PyObject* list = PyList_New(names.size());
if (!list) return NULL;
for (size_t i = 0; i < names.size(); i++) {
PyObject* name = PyUnicode_FromString(names[i].c_str());
if (!name) {
Py_DECREF(list);
return NULL;
}
PyList_SET_ITEM(list, i, name); // Steals reference
}
return list;
}
// Method and property tables
PyMethodDef Model3D::methods[] = {
{"cube", (PyCFunction)py_cube, METH_VARARGS | METH_KEYWORDS | METH_CLASS,
@ -799,6 +1443,8 @@ PyGetSetDef Model3D::getsetters[] = {
{"bounds", get_bounds, NULL, "AABB as ((min_x, min_y, min_z), (max_x, max_y, max_z)) (read-only)", NULL},
{"name", get_name, NULL, "Model name (read-only)", NULL},
{"mesh_count", get_mesh_count, NULL, "Number of submeshes (read-only)", NULL},
{"bone_count", get_bone_count, NULL, "Number of bones in skeleton (read-only)", NULL},
{"animation_clips", get_animation_clips, NULL, "List of animation clip names (read-only)", NULL},
{NULL}
};

View file

@ -18,7 +18,137 @@ namespace mcrf {
class Shader3D;
// =============================================================================
// ModelMesh - Single submesh within a Model3D
// Bone - Single bone in a skeleton
// =============================================================================
struct Bone {
std::string name;
int parent_index = -1; // -1 for root bones
mat4 inverse_bind_matrix; // Transforms from model space to bone space
mat4 local_transform; // Default local transform (rest pose)
};
// =============================================================================
// Skeleton - Bone hierarchy for skeletal animation
// =============================================================================
struct Skeleton {
std::vector<Bone> bones;
std::vector<int> root_bones; // Indices of bones with parent_index == -1
/// Find bone by name, returns -1 if not found
int findBone(const std::string& name) const {
for (size_t i = 0; i < bones.size(); i++) {
if (bones[i].name == name) return static_cast<int>(i);
}
return -1;
}
/// Compute global (model-space) transforms for all bones
void computeGlobalTransforms(const std::vector<mat4>& local_transforms,
std::vector<mat4>& global_out) const {
global_out.resize(bones.size());
for (size_t i = 0; i < bones.size(); i++) {
if (bones[i].parent_index < 0) {
global_out[i] = local_transforms[i];
} else {
global_out[i] = global_out[bones[i].parent_index] * local_transforms[i];
}
}
}
/// Compute final bone matrices for shader (global * inverse_bind)
void computeBoneMatrices(const std::vector<mat4>& global_transforms,
std::vector<mat4>& matrices_out) const {
matrices_out.resize(bones.size());
for (size_t i = 0; i < bones.size(); i++) {
matrices_out[i] = global_transforms[i] * bones[i].inverse_bind_matrix;
}
}
};
// =============================================================================
// AnimationChannel - Animates a single property of a single bone
// =============================================================================
struct AnimationChannel {
int bone_index = -1;
enum class Path {
Translation,
Rotation,
Scale
} path = Path::Translation;
// Keyframe times (shared for all values in this channel)
std::vector<float> times;
// Keyframe values (only one of these is populated based on path)
std::vector<vec3> translations;
std::vector<quat> rotations;
std::vector<vec3> scales;
/// Sample the channel at a given time, returning the interpolated transform component
/// For Translation/Scale: writes to trans_out
/// For Rotation: writes to rot_out
void sample(float time, vec3& trans_out, quat& rot_out, vec3& scale_out) const;
};
// =============================================================================
// AnimationClip - Named animation containing multiple channels
// =============================================================================
struct AnimationClip {
std::string name;
float duration = 0.0f;
std::vector<AnimationChannel> channels;
/// Sample the animation at a given time, producing bone local transforms
/// @param time Current time in the animation
/// @param num_bones Total number of bones (for output sizing)
/// @param default_transforms Default local transforms for bones without animation
/// @param local_out Output: interpolated local transforms for each bone
void sample(float time, size_t num_bones,
const std::vector<mat4>& default_transforms,
std::vector<mat4>& local_out) const;
};
// =============================================================================
// SkinnedVertex - Vertex with bone weights for skeletal animation
// =============================================================================
struct SkinnedVertex {
vec3 position;
vec2 texcoord;
vec3 normal;
vec4 color;
vec4 bone_ids; // Up to 4 bone indices (as floats for GLES2 compatibility)
vec4 bone_weights; // Corresponding weights (should sum to 1.0)
};
// =============================================================================
// SkinnedMesh - Submesh with skinning data
// =============================================================================
struct SkinnedMesh {
unsigned int vbo = 0;
unsigned int ebo = 0;
int vertex_count = 0;
int index_count = 0;
int material_index = -1;
bool is_skinned = false; // True if this mesh has bone weights
SkinnedMesh() = default;
~SkinnedMesh() = default;
SkinnedMesh(const SkinnedMesh&) = delete;
SkinnedMesh& operator=(const SkinnedMesh&) = delete;
SkinnedMesh(SkinnedMesh&& other) noexcept;
SkinnedMesh& operator=(SkinnedMesh&& other) noexcept;
};
// =============================================================================
// ModelMesh - Single submesh within a Model3D (legacy non-skinned)
// =============================================================================
struct ModelMesh {
@ -109,8 +239,41 @@ public:
/// Check if model has skeletal animation data
bool hasSkeleton() const { return has_skeleton_; }
/// Get number of submeshes
size_t getMeshCount() const { return meshes_.size(); }
/// Get number of submeshes (regular + skinned)
size_t getMeshCount() const { return meshes_.size() + skinned_meshes_.size(); }
// =========================================================================
// Skeleton & Animation
// =========================================================================
/// Get skeleton (may be empty if no skeleton)
const Skeleton& getSkeleton() const { return skeleton_; }
/// Get number of bones
size_t getBoneCount() const { return skeleton_.bones.size(); }
/// Get animation clips
const std::vector<AnimationClip>& getAnimationClips() const { return animation_clips_; }
/// Get animation clip names
std::vector<std::string> getAnimationClipNames() const {
std::vector<std::string> names;
for (const auto& clip : animation_clips_) {
names.push_back(clip.name);
}
return names;
}
/// Find animation clip by name (returns nullptr if not found)
const AnimationClip* findClip(const std::string& name) const {
for (const auto& clip : animation_clips_) {
if (clip.name == name) return &clip;
}
return nullptr;
}
/// Get default bone transforms (rest pose)
const std::vector<mat4>& getDefaultBoneTransforms() const { return default_bone_transforms_; }
// =========================================================================
// Rendering
@ -123,6 +286,15 @@ public:
/// @param projection Projection matrix
void render(unsigned int shader, const mat4& model, const mat4& view, const mat4& projection);
/// Render with skeletal animation
/// @param shader Shader program handle (already bound, should be skinned shader)
/// @param model Model transformation matrix
/// @param view View matrix
/// @param projection Projection matrix
/// @param bone_matrices Final bone matrices (global * inverse_bind)
void renderSkinned(unsigned int shader, const mat4& model, const mat4& view,
const mat4& projection, const std::vector<mat4>& bone_matrices);
// =========================================================================
// Python API
// =========================================================================
@ -142,6 +314,8 @@ public:
static PyObject* get_bounds(PyObject* self, void* closure);
static PyObject* get_name(PyObject* self, void* closure);
static PyObject* get_mesh_count(PyObject* self, void* closure);
static PyObject* get_bone_count(PyObject* self, void* closure);
static PyObject* get_animation_clips(PyObject* self, void* closure);
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
@ -150,13 +324,17 @@ private:
// Model data
std::string name_;
std::vector<ModelMesh> meshes_;
std::vector<SkinnedMesh> skinned_meshes_; // Skinned meshes with bone weights
// Bounds
vec3 bounds_min_ = vec3(0, 0, 0);
vec3 bounds_max_ = vec3(0, 0, 0);
// Future: skeletal animation data
// Skeletal animation data
bool has_skeleton_ = false;
Skeleton skeleton_;
std::vector<AnimationClip> animation_clips_;
std::vector<mat4> default_bone_transforms_; // Rest pose local transforms
// Error handling
static std::string lastError_;
@ -169,6 +347,15 @@ private:
/// @return ModelMesh with GPU resources allocated
static ModelMesh createMesh(const std::vector<MeshVertex>& vertices,
const std::vector<uint32_t>& indices);
/// Create VBO/EBO from skinned vertex and index data
static SkinnedMesh createSkinnedMesh(const std::vector<SkinnedVertex>& vertices,
const std::vector<uint32_t>& indices);
// glTF loading helpers
void loadSkeleton(void* cgltf_data); // void* to avoid header dependency
void loadAnimations(void* cgltf_data);
int findJointIndex(void* cgltf_skin, void* node);
};
} // namespace mcrf
@ -217,7 +404,9 @@ inline PyTypeObject PyModel3DType = {
" triangle_count (int, read-only): Total triangles across all meshes\n"
" has_skeleton (bool, read-only): Whether model has skeletal animation data\n"
" bounds (tuple, read-only): AABB as ((min_x, min_y, min_z), (max_x, max_y, max_z))\n"
" mesh_count (int, read-only): Number of submeshes"
" mesh_count (int, read-only): Number of submeshes\n"
" bone_count (int, read-only): Number of bones in skeleton\n"
" animation_clips (list, read-only): List of animation clip names"
),
.tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int {
return 0;

View file

@ -245,6 +245,223 @@ void main() {
}
)";
// =============================================================================
// Skinned Vertex Shaders (for skeletal animation)
// =============================================================================
const char* PS1_SKINNED_VERTEX_ES2 = R"(
// PS1-style skinned vertex shader for OpenGL ES 2.0 / WebGL 1.0
precision mediump float;
uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
uniform mat4 u_bones[32];
uniform vec2 u_resolution;
uniform bool u_enable_snap;
uniform float u_fog_start;
uniform float u_fog_end;
uniform vec3 u_light_dir;
uniform vec3 u_ambient;
attribute vec3 a_position;
attribute vec2 a_texcoord;
attribute vec3 a_normal;
attribute vec4 a_color;
attribute vec4 a_bone_ids;
attribute vec4 a_bone_weights;
varying vec4 v_color;
varying vec2 v_texcoord;
varying float v_w;
varying float v_fog;
mat4 getBoneMatrix(int index) {
if (index < 8) {
if (index < 4) {
if (index < 2) {
if (index == 0) return u_bones[0];
else return u_bones[1];
} else {
if (index == 2) return u_bones[2];
else return u_bones[3];
}
} else {
if (index < 6) {
if (index == 4) return u_bones[4];
else return u_bones[5];
} else {
if (index == 6) return u_bones[6];
else return u_bones[7];
}
}
} else if (index < 16) {
if (index < 12) {
if (index < 10) {
if (index == 8) return u_bones[8];
else return u_bones[9];
} else {
if (index == 10) return u_bones[10];
else return u_bones[11];
}
} else {
if (index < 14) {
if (index == 12) return u_bones[12];
else return u_bones[13];
} else {
if (index == 14) return u_bones[14];
else return u_bones[15];
}
}
} else if (index < 24) {
if (index < 20) {
if (index < 18) {
if (index == 16) return u_bones[16];
else return u_bones[17];
} else {
if (index == 18) return u_bones[18];
else return u_bones[19];
}
} else {
if (index < 22) {
if (index == 20) return u_bones[20];
else return u_bones[21];
} else {
if (index == 22) return u_bones[22];
else return u_bones[23];
}
}
} else {
if (index < 28) {
if (index < 26) {
if (index == 24) return u_bones[24];
else return u_bones[25];
} else {
if (index == 26) return u_bones[26];
else return u_bones[27];
}
} else {
if (index < 30) {
if (index == 28) return u_bones[28];
else return u_bones[29];
} else {
if (index == 30) return u_bones[30];
else return u_bones[31];
}
}
}
return mat4(1.0);
}
void main() {
int b0 = int(a_bone_ids.x);
int b1 = int(a_bone_ids.y);
int b2 = int(a_bone_ids.z);
int b3 = int(a_bone_ids.w);
mat4 skin_matrix =
getBoneMatrix(b0) * a_bone_weights.x +
getBoneMatrix(b1) * a_bone_weights.y +
getBoneMatrix(b2) * a_bone_weights.z +
getBoneMatrix(b3) * a_bone_weights.w;
vec4 skinned_pos = skin_matrix * vec4(a_position, 1.0);
vec3 skinned_normal = mat3(skin_matrix[0].xyz, skin_matrix[1].xyz, skin_matrix[2].xyz) * a_normal;
vec4 worldPos = u_model * skinned_pos;
vec4 viewPos = u_view * worldPos;
vec4 clipPos = u_projection * viewPos;
if (u_enable_snap) {
vec4 ndc = clipPos;
ndc.xyz /= ndc.w;
vec2 grid = u_resolution * 0.5;
ndc.xy = floor(ndc.xy * grid + 0.5) / grid;
ndc.xyz *= clipPos.w;
clipPos = ndc;
}
gl_Position = clipPos;
vec3 worldNormal = mat3(u_model[0].xyz, u_model[1].xyz, u_model[2].xyz) * skinned_normal;
worldNormal = normalize(worldNormal);
float diffuse = max(dot(worldNormal, -u_light_dir), 0.0);
vec3 lighting = u_ambient + vec3(diffuse);
v_color = vec4(a_color.rgb * lighting, a_color.a);
v_texcoord = a_texcoord * clipPos.w;
v_w = clipPos.w;
float depth = -viewPos.z;
v_fog = clamp((depth - u_fog_start) / (u_fog_end - u_fog_start), 0.0, 1.0);
}
)";
const char* PS1_SKINNED_VERTEX = R"(
#version 150 core
uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
uniform mat4 u_bones[64];
uniform vec2 u_resolution;
uniform bool u_enable_snap;
uniform float u_fog_start;
uniform float u_fog_end;
uniform vec3 u_light_dir;
uniform vec3 u_ambient;
in vec3 a_position;
in vec2 a_texcoord;
in vec3 a_normal;
in vec4 a_color;
in vec4 a_bone_ids;
in vec4 a_bone_weights;
out vec4 v_color;
noperspective out vec2 v_texcoord;
out float v_fog;
void main() {
ivec4 bone_ids = ivec4(a_bone_ids);
mat4 skin_matrix =
u_bones[bone_ids.x] * a_bone_weights.x +
u_bones[bone_ids.y] * a_bone_weights.y +
u_bones[bone_ids.z] * a_bone_weights.z +
u_bones[bone_ids.w] * a_bone_weights.w;
vec4 skinned_pos = skin_matrix * vec4(a_position, 1.0);
vec3 skinned_normal = mat3(skin_matrix) * a_normal;
vec4 worldPos = u_model * skinned_pos;
vec4 viewPos = u_view * worldPos;
vec4 clipPos = u_projection * viewPos;
if (u_enable_snap) {
vec4 ndc = clipPos;
ndc.xyz /= ndc.w;
vec2 grid = u_resolution * 0.5;
ndc.xy = floor(ndc.xy * grid + 0.5) / grid;
ndc.xyz *= clipPos.w;
clipPos = ndc;
}
gl_Position = clipPos;
vec3 worldNormal = mat3(u_model) * skinned_normal;
worldNormal = normalize(worldNormal);
float diffuse = max(dot(worldNormal, -u_light_dir), 0.0);
vec3 lighting = u_ambient + vec3(diffuse);
v_color = vec4(a_color.rgb * lighting, a_color.a);
v_texcoord = a_texcoord;
float depth = -viewPos.z;
v_fog = clamp((depth - u_fog_start) / (u_fog_end - u_fog_start), 0.0, 1.0);
}
)";
} // namespace shaders
// =============================================================================
@ -274,6 +491,20 @@ bool Shader3D::loadPS1Shaders() {
#endif
}
bool Shader3D::loadPS1SkinnedShaders() {
#ifdef MCRF_HAS_GL
#ifdef __EMSCRIPTEN__
// Use GLES2 skinned shaders for Emscripten/WebGL
return load(shaders::PS1_SKINNED_VERTEX_ES2, shaders::PS1_FRAGMENT_ES2);
#else
// Use desktop GL 3.2+ skinned shaders
return load(shaders::PS1_SKINNED_VERTEX, shaders::PS1_FRAGMENT);
#endif
#else
return false;
#endif
}
bool Shader3D::load(const char* vertexSource, const char* fragmentSource) {
if (!gl::isGLReady()) {
return false;

View file

@ -18,6 +18,9 @@ public:
// Automatically selects desktop vs ES2 shaders based on platform
bool loadPS1Shaders();
// Load skinned (skeletal animation) shaders
bool loadPS1SkinnedShaders();
// Load from custom source strings
bool load(const char* vertexSource, const char* fragmentSource);

View file

@ -454,16 +454,53 @@ void Viewport3D::renderEntities(const mat4& view, const mat4& proj) {
#ifdef MCRF_HAS_GL
if (!entities_ || !shader_ || !shader_->isValid()) return;
// Entity rendering uses the same shader as terrain
// Render non-skeletal entities first
shader_->bind();
for (auto& entity : *entities_) {
if (entity && entity->isVisible()) {
entity->render(view, proj, shader_->getProgram());
auto model = entity->getModel();
if (!model || !model->hasSkeleton()) {
entity->render(view, proj, shader_->getProgram());
}
}
}
shader_->unbind();
// Then render skeletal entities with skinned shader
if (skinnedShader_ && skinnedShader_->isValid()) {
skinnedShader_->bind();
// Set up common uniforms for skinned shader
skinnedShader_->setUniform("u_view", view);
skinnedShader_->setUniform("u_projection", proj);
skinnedShader_->setUniform("u_resolution", vec2(static_cast<float>(internalWidth_),
static_cast<float>(internalHeight_)));
skinnedShader_->setUniform("u_enable_snap", vertexSnapEnabled_);
// Lighting
vec3 lightDir = vec3(0.5f, -0.7f, 0.5f).normalized();
skinnedShader_->setUniform("u_light_dir", lightDir);
skinnedShader_->setUniform("u_ambient", vec3(0.3f, 0.3f, 0.3f));
// Fog
skinnedShader_->setUniform("u_fog_start", fogNear_);
skinnedShader_->setUniform("u_fog_end", fogFar_);
skinnedShader_->setUniform("u_fog_color", fogColor_);
// Texture
skinnedShader_->setUniform("u_has_texture", false);
skinnedShader_->setUniform("u_enable_dither", ditheringEnabled_);
for (auto& entity : *entities_) {
if (entity && entity->isVisible()) {
auto model = entity->getModel();
if (model && model->hasSkeleton()) {
entity->render(view, proj, skinnedShader_->getProgram());
}
}
}
skinnedShader_->unbind();
}
#endif
}
@ -557,6 +594,12 @@ void Viewport3D::initShader() {
if (!shader_->loadPS1Shaders()) {
shader_.reset(); // Shader loading failed
}
// Also create skinned shader for skeletal animation
skinnedShader_ = std::make_unique<Shader3D>();
if (!skinnedShader_->loadPS1SkinnedShaders()) {
skinnedShader_.reset(); // Skinned shader loading failed
}
}
void Viewport3D::initTestGeometry() {
@ -705,6 +748,19 @@ void Viewport3D::render3DContent() {
}
#ifdef MCRF_HAS_GL
// Calculate delta time for animation updates
static sf::Clock frameClock;
float currentTime = frameClock.getElapsedTime().asSeconds();
float dt = firstFrame_ ? 0.016f : (currentTime - lastFrameTime_);
lastFrameTime_ = currentTime;
firstFrame_ = false;
// Cap delta time to avoid huge jumps (e.g., after window minimize)
if (dt > 0.1f) dt = 0.016f;
// Update entity animations
updateEntities(dt);
// Save GL state
gl::pushState();

View file

@ -285,6 +285,10 @@ private:
float testRotation_ = 0.0f;
bool renderTestCube_ = true; // Set to false when layers are added
// Animation timing
float lastFrameTime_ = 0.0f;
bool firstFrame_ = true;
// Mesh layers for terrain, static geometry
std::vector<std::shared_ptr<MeshLayer>> meshLayers_;
@ -304,6 +308,7 @@ private:
// Shader for PS1-style rendering
std::unique_ptr<Shader3D> shader_;
std::unique_ptr<Shader3D> skinnedShader_; // For skeletal animation
// Test geometry VBO (cube)
unsigned int testVBO_ = 0;

View file

@ -0,0 +1,108 @@
// PS1-style skinned vertex shader for OpenGL 3.2+
// Implements skeletal animation, vertex snapping, Gouraud shading, and fog
#version 150 core
// Uniforms - transform matrices
uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
// Uniforms - skeletal animation (max 64 bones)
uniform mat4 u_bones[64];
// Uniforms - PS1 effects
uniform vec2 u_resolution; // Internal render resolution for vertex snapping
uniform bool u_enable_snap; // Enable vertex snapping to pixel grid
uniform float u_fog_start; // Fog start distance
uniform float u_fog_end; // Fog end distance
// Uniforms - lighting
uniform vec3 u_light_dir; // Directional light direction (normalized)
uniform vec3 u_ambient; // Ambient light color
// Attributes
in vec3 a_position;
in vec2 a_texcoord;
in vec3 a_normal;
in vec4 a_color;
in vec4 a_bone_ids; // Up to 4 bone indices (as float for compatibility)
in vec4 a_bone_weights; // Corresponding weights
// Varyings - passed to fragment shader
out vec4 v_color; // Gouraud-shaded vertex color
noperspective out vec2 v_texcoord; // Texture coordinates (affine interpolation!)
out float v_fog; // Fog factor (0 = no fog, 1 = full fog)
void main() {
// =========================================================================
// Skeletal Animation: Vertex Skinning
// Transform vertex and normal by weighted bone matrices
// =========================================================================
ivec4 bone_ids = ivec4(a_bone_ids); // Convert to integer indices
// Compute skinned position and normal
mat4 skin_matrix =
u_bones[bone_ids.x] * a_bone_weights.x +
u_bones[bone_ids.y] * a_bone_weights.y +
u_bones[bone_ids.z] * a_bone_weights.z +
u_bones[bone_ids.w] * a_bone_weights.w;
vec4 skinned_pos = skin_matrix * vec4(a_position, 1.0);
vec3 skinned_normal = mat3(skin_matrix) * a_normal;
// Transform vertex to clip space
vec4 worldPos = u_model * skinned_pos;
vec4 viewPos = u_view * worldPos;
vec4 clipPos = u_projection * viewPos;
// =========================================================================
// PS1 Effect: Vertex Snapping
// The PS1 had limited precision for vertex positions, causing vertices
// to "snap" to a grid, creating the characteristic jittery look.
// =========================================================================
if (u_enable_snap) {
// Convert to NDC
vec4 ndc = clipPos;
ndc.xyz /= ndc.w;
// Snap to pixel grid based on render resolution
vec2 grid = u_resolution * 0.5;
ndc.xy = floor(ndc.xy * grid + 0.5) / grid;
// Convert back to clip space
ndc.xyz *= clipPos.w;
clipPos = ndc;
}
gl_Position = clipPos;
// =========================================================================
// PS1 Effect: Gouraud Shading
// Per-vertex lighting was used on PS1 due to hardware limitations.
// This creates characteristic flat-shaded polygons.
// =========================================================================
vec3 worldNormal = mat3(u_model) * skinned_normal;
worldNormal = normalize(worldNormal);
// Simple directional light + ambient
float diffuse = max(dot(worldNormal, -u_light_dir), 0.0);
vec3 lighting = u_ambient + vec3(diffuse);
// Apply lighting to vertex color
v_color = vec4(a_color.rgb * lighting, a_color.a);
// =========================================================================
// PS1 Effect: Affine Texture Mapping
// Using 'noperspective' qualifier disables perspective-correct interpolation
// This creates the characteristic texture warping on large polygons
// =========================================================================
v_texcoord = a_texcoord;
// =========================================================================
// Fog Distance Calculation
// Calculate linear fog factor based on view-space depth
// =========================================================================
float depth = -viewPos.z; // View space depth (positive)
v_fog = clamp((depth - u_fog_start) / (u_fog_end - u_fog_start), 0.0, 1.0);
}

View file

@ -0,0 +1,195 @@
// PS1-style skinned vertex shader for OpenGL ES 2.0 / WebGL 1.0
// Implements skeletal animation, vertex snapping, Gouraud shading, and fog
precision mediump float;
// Uniforms - transform matrices
uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
// Uniforms - skeletal animation (max 64 bones)
// GLES2 doesn't guarantee support for arrays > 128 vec4s in vertex shaders
// 64 bones * 4 vec4s = 256 vec4s, so we use 32 bones for safety
uniform mat4 u_bones[32];
// Uniforms - PS1 effects
uniform vec2 u_resolution; // Internal render resolution for vertex snapping
uniform bool u_enable_snap; // Enable vertex snapping to pixel grid
uniform float u_fog_start; // Fog start distance
uniform float u_fog_end; // Fog end distance
// Uniforms - lighting
uniform vec3 u_light_dir; // Directional light direction (normalized)
uniform vec3 u_ambient; // Ambient light color
// Attributes
attribute vec3 a_position;
attribute vec2 a_texcoord;
attribute vec3 a_normal;
attribute vec4 a_color;
attribute vec4 a_bone_ids; // Up to 4 bone indices (as floats)
attribute vec4 a_bone_weights; // Corresponding weights
// Varyings - passed to fragment shader
varying vec4 v_color; // Gouraud-shaded vertex color
varying vec2 v_texcoord; // Texture coordinates (multiplied by w for affine trick)
varying float v_w; // Clip space w for affine mapping restoration
varying float v_fog; // Fog factor (0 = no fog, 1 = full fog)
// Helper to get bone matrix by index (GLES2 doesn't support dynamic array indexing well)
mat4 getBoneMatrix(int index) {
// GLES2 workaround: use if-chain for dynamic indexing
if (index < 8) {
if (index < 4) {
if (index < 2) {
if (index == 0) return u_bones[0];
else return u_bones[1];
} else {
if (index == 2) return u_bones[2];
else return u_bones[3];
}
} else {
if (index < 6) {
if (index == 4) return u_bones[4];
else return u_bones[5];
} else {
if (index == 6) return u_bones[6];
else return u_bones[7];
}
}
} else if (index < 16) {
if (index < 12) {
if (index < 10) {
if (index == 8) return u_bones[8];
else return u_bones[9];
} else {
if (index == 10) return u_bones[10];
else return u_bones[11];
}
} else {
if (index < 14) {
if (index == 12) return u_bones[12];
else return u_bones[13];
} else {
if (index == 14) return u_bones[14];
else return u_bones[15];
}
}
} else if (index < 24) {
if (index < 20) {
if (index < 18) {
if (index == 16) return u_bones[16];
else return u_bones[17];
} else {
if (index == 18) return u_bones[18];
else return u_bones[19];
}
} else {
if (index < 22) {
if (index == 20) return u_bones[20];
else return u_bones[21];
} else {
if (index == 22) return u_bones[22];
else return u_bones[23];
}
}
} else {
if (index < 28) {
if (index < 26) {
if (index == 24) return u_bones[24];
else return u_bones[25];
} else {
if (index == 26) return u_bones[26];
else return u_bones[27];
}
} else {
if (index < 30) {
if (index == 28) return u_bones[28];
else return u_bones[29];
} else {
if (index == 30) return u_bones[30];
else return u_bones[31];
}
}
}
return mat4(1.0); // Identity fallback
}
void main() {
// =========================================================================
// Skeletal Animation: Vertex Skinning
// Transform vertex and normal by weighted bone matrices
// =========================================================================
int b0 = int(a_bone_ids.x);
int b1 = int(a_bone_ids.y);
int b2 = int(a_bone_ids.z);
int b3 = int(a_bone_ids.w);
// Compute skinned position and normal
mat4 skin_matrix =
getBoneMatrix(b0) * a_bone_weights.x +
getBoneMatrix(b1) * a_bone_weights.y +
getBoneMatrix(b2) * a_bone_weights.z +
getBoneMatrix(b3) * a_bone_weights.w;
vec4 skinned_pos = skin_matrix * vec4(a_position, 1.0);
vec3 skinned_normal = mat3(skin_matrix[0].xyz, skin_matrix[1].xyz, skin_matrix[2].xyz) * a_normal;
// Transform vertex to clip space
vec4 worldPos = u_model * skinned_pos;
vec4 viewPos = u_view * worldPos;
vec4 clipPos = u_projection * viewPos;
// =========================================================================
// PS1 Effect: Vertex Snapping
// The PS1 had limited precision for vertex positions, causing vertices
// to "snap" to a grid, creating the characteristic jittery look.
// =========================================================================
if (u_enable_snap) {
// Convert to NDC
vec4 ndc = clipPos;
ndc.xyz /= ndc.w;
// Snap to pixel grid based on render resolution
vec2 grid = u_resolution * 0.5;
ndc.xy = floor(ndc.xy * grid + 0.5) / grid;
// Convert back to clip space
ndc.xyz *= clipPos.w;
clipPos = ndc;
}
gl_Position = clipPos;
// =========================================================================
// PS1 Effect: Gouraud Shading
// Per-vertex lighting was used on PS1 due to hardware limitations.
// This creates characteristic flat-shaded polygons.
// =========================================================================
vec3 worldNormal = mat3(u_model[0].xyz, u_model[1].xyz, u_model[2].xyz) * skinned_normal;
worldNormal = normalize(worldNormal);
// Simple directional light + ambient
float diffuse = max(dot(worldNormal, -u_light_dir), 0.0);
vec3 lighting = u_ambient + vec3(diffuse);
// Apply lighting to vertex color
v_color = vec4(a_color.rgb * lighting, a_color.a);
// =========================================================================
// PS1 Effect: Affine Texture Mapping Trick
// GLES2 doesn't have 'noperspective' interpolation, so we manually
// multiply texcoords by w here and divide by w in fragment shader.
// This creates the characteristic texture warping on large polygons.
// =========================================================================
v_texcoord = a_texcoord * clipPos.w;
v_w = clipPos.w;
// =========================================================================
// Fog Distance Calculation
// Calculate linear fog factor based on view-space depth
// =========================================================================
float depth = -viewPos.z; // View space depth (positive)
v_fog = clamp((depth - u_fog_start) / (u_fog_end - u_fog_start), 0.0, 1.0);
}

View file

@ -0,0 +1,275 @@
# skeletal_animation_demo.py - 3D Skeletal Animation Demo Screen
# Demonstrates Entity3D animation with real animated glTF models
import mcrfpy
import sys
import os
DEMO_NAME = "3D Skeletal Animation"
DEMO_DESCRIPTION = """Entity3D Animation API with real skeletal models"""
# Create demo scene
scene = mcrfpy.Scene("skeletal_animation_demo")
# Dark background frame
bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(15, 15, 25))
scene.children.append(bg)
# Title
title = mcrfpy.Caption(text="Skeletal Animation Demo", pos=(20, 10))
title.fill_color = mcrfpy.Color(255, 255, 100)
scene.children.append(title)
# Create the 3D viewport
viewport = mcrfpy.Viewport3D(
pos=(50, 50),
size=(600, 500),
render_resolution=(600, 500),
fov=60.0,
camera_pos=(0.0, 2.0, 5.0),
camera_target=(0.0, 1.0, 0.0),
bg_color=mcrfpy.Color(30, 30, 50)
)
scene.children.append(viewport)
# Set up navigation grid
GRID_SIZE = 16
viewport.set_grid_size(GRID_SIZE, GRID_SIZE)
# Build a simple flat floor
hm = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
hm.normalize(0.0, 0.0)
viewport.apply_heightmap(hm, 0.0)
viewport.build_terrain(
layer_name="floor",
heightmap=hm,
y_scale=0.0,
cell_size=1.0
)
# Apply floor colors (dark gray)
r_map = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
g_map = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
b_map = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
for y in range(GRID_SIZE):
for x in range(GRID_SIZE):
checker = ((x + y) % 2) * 0.1 + 0.15
r_map[x, y] = checker
g_map[x, y] = checker
b_map[x, y] = checker + 0.05
viewport.apply_terrain_colors("floor", r_map, g_map, b_map)
# Load animated models
animated_entity = None
model_info = "No animated model"
# Try to load CesiumMan (humanoid with walk animation)
try:
model = mcrfpy.Model3D("../assets/models/CesiumMan.glb")
if model.has_skeleton:
animated_entity = mcrfpy.Entity3D(pos=(8, 8), scale=1.0, color=mcrfpy.Color(200, 180, 150))
animated_entity.model = model
viewport.entities.append(animated_entity)
# Set up animation
clips = model.animation_clips
if clips:
animated_entity.anim_clip = clips[0]
animated_entity.anim_loop = True
animated_entity.anim_speed = 1.0
model_info = f"CesiumMan: {model.bone_count} bones, {model.vertex_count} verts"
print(f"Loaded {model_info}")
print(f"Animation clips: {clips}")
except Exception as e:
print(f"Failed to load CesiumMan: {e}")
# Also try RiggedSimple as a second model
try:
model2 = mcrfpy.Model3D("../assets/models/RiggedSimple.glb")
if model2.has_skeleton:
entity2 = mcrfpy.Entity3D(pos=(10, 8), scale=0.5, color=mcrfpy.Color(100, 200, 255))
entity2.model = model2
viewport.entities.append(entity2)
clips = model2.animation_clips
if clips:
entity2.anim_clip = clips[0]
entity2.anim_loop = True
entity2.anim_speed = 1.5
print(f"Loaded RiggedSimple: {model2.bone_count} bones")
except Exception as e:
print(f"Failed to load RiggedSimple: {e}")
# Info panel on the right
info_panel = mcrfpy.Frame(pos=(670, 50), size=(330, 500),
fill_color=mcrfpy.Color(30, 30, 40),
outline_color=mcrfpy.Color(80, 80, 100),
outline=2.0)
scene.children.append(info_panel)
# Panel title
panel_title = mcrfpy.Caption(text="Animation Properties", pos=(690, 60))
panel_title.fill_color = mcrfpy.Color(200, 200, 255)
scene.children.append(panel_title)
# Status labels (will be updated by timer)
status_labels = []
y_offset = 90
label_texts = [
"Model: loading...",
"anim_clip: ",
"anim_time: 0.00",
"anim_speed: 1.00",
"anim_loop: True",
"anim_paused: False",
"anim_frame: 0",
]
for text in label_texts:
label = mcrfpy.Caption(text=text, pos=(690, y_offset))
label.fill_color = mcrfpy.Color(150, 200, 150)
scene.children.append(label)
status_labels.append(label)
y_offset += 25
# Set initial model info
status_labels[0].text = f"Model: {model_info}"
# Controls section
y_offset += 20
controls_title = mcrfpy.Caption(text="Controls:", pos=(690, y_offset))
controls_title.fill_color = mcrfpy.Color(255, 255, 200)
scene.children.append(controls_title)
y_offset += 25
controls = [
"[SPACE] Toggle pause",
"[L] Toggle loop",
"[+/-] Adjust speed",
"[R] Reset time",
"[1-3] Camera presets",
]
for ctrl in controls:
cap = mcrfpy.Caption(text=ctrl, pos=(690, y_offset))
cap.fill_color = mcrfpy.Color(180, 180, 150)
scene.children.append(cap)
y_offset += 20
# Auto-animate section
y_offset += 20
auto_title = mcrfpy.Caption(text="Auto-Animate:", pos=(690, y_offset))
auto_title.fill_color = mcrfpy.Color(255, 200, 200)
scene.children.append(auto_title)
y_offset += 25
auto_labels = []
auto_texts = [
"auto_animate: True",
"walk_clip: 'walk'",
"idle_clip: 'idle'",
]
for text in auto_texts:
cap = mcrfpy.Caption(text=text, pos=(690, y_offset))
cap.fill_color = mcrfpy.Color(180, 160, 160)
scene.children.append(cap)
auto_labels.append(cap)
y_offset += 20
# Instructions at bottom
status = mcrfpy.Caption(text="Status: Animation playing", pos=(20, 570))
status.fill_color = mcrfpy.Color(100, 200, 100)
scene.children.append(status)
# Camera presets
camera_presets = [
((0.0, 2.0, 5.0), (0.0, 1.0, 0.0), "Front view"),
((5.0, 3.0, 0.0), (0.0, 1.0, 0.0), "Side view"),
((0.0, 6.0, 0.1), (0.0, 0.0, 0.0), "Top-down view"),
]
# Update function - updates display and entity rotation
def update(timer, runtime):
if animated_entity:
# Update status display
status_labels[1].text = f"anim_clip: '{animated_entity.anim_clip}'"
status_labels[2].text = f"anim_time: {animated_entity.anim_time:.2f}"
status_labels[3].text = f"anim_speed: {animated_entity.anim_speed:.2f}"
status_labels[4].text = f"anim_loop: {animated_entity.anim_loop}"
status_labels[5].text = f"anim_paused: {animated_entity.anim_paused}"
status_labels[6].text = f"anim_frame: {animated_entity.anim_frame}"
auto_labels[0].text = f"auto_animate: {animated_entity.auto_animate}"
auto_labels[1].text = f"walk_clip: '{animated_entity.walk_clip}'"
auto_labels[2].text = f"idle_clip: '{animated_entity.idle_clip}'"
# Key handler
def on_key(key, state):
if state != mcrfpy.InputState.PRESSED:
return
if animated_entity:
if key == mcrfpy.Key.SPACE:
animated_entity.anim_paused = not animated_entity.anim_paused
status.text = f"Status: {'Paused' if animated_entity.anim_paused else 'Playing'}"
elif key == mcrfpy.Key.L:
animated_entity.anim_loop = not animated_entity.anim_loop
status.text = f"Status: Loop {'ON' if animated_entity.anim_loop else 'OFF'}"
elif key == mcrfpy.Key.EQUAL or key == mcrfpy.Key.ADD:
animated_entity.anim_speed = min(animated_entity.anim_speed + 0.25, 4.0)
status.text = f"Status: Speed {animated_entity.anim_speed:.2f}x"
elif key == mcrfpy.Key.HYPHEN or key == mcrfpy.Key.SUBTRACT:
animated_entity.anim_speed = max(animated_entity.anim_speed - 0.25, 0.0)
status.text = f"Status: Speed {animated_entity.anim_speed:.2f}x"
elif key == mcrfpy.Key.R:
animated_entity.anim_time = 0.0
status.text = "Status: Animation reset"
# Camera presets
if key == mcrfpy.Key.NUM_1:
pos, target, name = camera_presets[0]
viewport.camera_pos = pos
viewport.camera_target = target
status.text = f"Camera: {name}"
elif key == mcrfpy.Key.NUM_2:
pos, target, name = camera_presets[1]
viewport.camera_pos = pos
viewport.camera_target = target
status.text = f"Camera: {name}"
elif key == mcrfpy.Key.NUM_3:
pos, target, name = camera_presets[2]
viewport.camera_pos = pos
viewport.camera_target = target
status.text = f"Camera: {name}"
elif key == mcrfpy.Key.ESCAPE:
mcrfpy.exit()
# Set up scene
scene.on_key = on_key
# Create timer for updates
timer = mcrfpy.Timer("anim_update", update, 16)
# Activate scene
mcrfpy.current_scene = scene
print()
print("Skeletal Animation Demo loaded!")
print("Controls:")
print(" [Space] Toggle pause")
print(" [L] Toggle loop")
print(" [+/-] Adjust speed")
print(" [R] Reset time")
print(" [1-3] Camera presets")
print(" [ESC] Quit")