diff --git a/src/3d/Entity3D.cpp b/src/3d/Entity3D.cpp index 6a74a69..246e32b 100644 --- a/src/3d/Entity3D.cpp +++ b/src/3d/Entity3D.cpp @@ -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& default_transforms = model_->getDefaultBoneTransforms(); + + std::vector local_transforms; + clip->sample(anim_time_, skeleton.bones.size(), default_transforms, local_transforms); + + // Compute global transforms + std::vector 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(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 }; diff --git a/src/3d/Entity3D.h b/src/3d/Entity3D.h index 2cb01eb..6401ffd 100644 --- a/src/3d/Entity3D.h +++ b/src/3d/Entity3D.h @@ -11,6 +11,7 @@ #include #include #include +#include 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& getBoneMatrices() const { return bone_matrices_; } + + /// Animation complete callback type + using AnimCompleteCallback = std::function; + + /// 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 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; diff --git a/src/3d/Model3D.cpp b/src/3d/Model3D.cpp index 3845969..1f21f9b 100644 --- a/src/3d/Model3D.cpp +++ b/src/3d/Model3D.cpp @@ -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& default_transforms, + std::vector& 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 bone_states(num_bones); + + // Sample all channels + for (const auto& channel : channels) { + if (channel.bone_index < 0 || channel.bone_index >= static_cast(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& 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::load(const std::string& path) std::vector normals; std::vector texcoords; std::vector colors; + std::vector joints; // Bone indices (as floats for shader compatibility) + std::vector weights; // Bone weights // Extract attributes for (size_t k = 0; k < prim->attributes_count; ++k) { @@ -548,6 +749,26 @@ std::shared_ptr 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(indices[0]); + joints[v].y = static_cast(indices[1]); + joints[v].z = static_cast(indices[2]); + joints[v].w = static_cast(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::load(const std::string& path) colors.resize(vertCount, vec4(1, 1, 1, 1)); } - // Interleave vertex data - std::vector 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 indices; if (prim->indices) { @@ -590,18 +798,422 @@ std::shared_ptr 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 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 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_ptr); + cgltf_node* node = static_cast(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(i); + } + } + return -1; +} + +void Model3D::loadSkeleton(void* cgltf_data_ptr) +{ + cgltf_data* data = static_cast(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(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_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& 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& vertices, + const std::vector& indices) +{ + SkinnedMesh mesh; + mesh.vertex_count = static_cast(vertices.size()); + mesh.index_count = static_cast(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& 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(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(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(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} }; diff --git a/src/3d/Model3D.h b/src/3d/Model3D.h index 444ad67..7fb9bb2 100644 --- a/src/3d/Model3D.h +++ b/src/3d/Model3D.h @@ -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 bones; + std::vector 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(i); + } + return -1; + } + + /// Compute global (model-space) transforms for all bones + void computeGlobalTransforms(const std::vector& local_transforms, + std::vector& 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& global_transforms, + std::vector& 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 times; + + // Keyframe values (only one of these is populated based on path) + std::vector translations; + std::vector rotations; + std::vector 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 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& default_transforms, + std::vector& 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& getAnimationClips() const { return animation_clips_; } + + /// Get animation clip names + std::vector getAnimationClipNames() const { + std::vector 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& 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& 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 meshes_; + std::vector 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 animation_clips_; + std::vector 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& vertices, const std::vector& indices); + + /// Create VBO/EBO from skinned vertex and index data + static SkinnedMesh createSkinnedMesh(const std::vector& vertices, + const std::vector& 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; diff --git a/src/3d/Shader3D.cpp b/src/3d/Shader3D.cpp index c842e4c..5729051 100644 --- a/src/3d/Shader3D.cpp +++ b/src/3d/Shader3D.cpp @@ -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; diff --git a/src/3d/Shader3D.h b/src/3d/Shader3D.h index ffb6b2d..f457ac4 100644 --- a/src/3d/Shader3D.h +++ b/src/3d/Shader3D.h @@ -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); diff --git a/src/3d/Viewport3D.cpp b/src/3d/Viewport3D.cpp index a5261b0..240753d 100644 --- a/src/3d/Viewport3D.cpp +++ b/src/3d/Viewport3D.cpp @@ -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(internalWidth_), + static_cast(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(); + 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(); diff --git a/src/3d/Viewport3D.h b/src/3d/Viewport3D.h index 80fe16a..0ab5e17 100644 --- a/src/3d/Viewport3D.h +++ b/src/3d/Viewport3D.h @@ -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> meshLayers_; @@ -304,6 +308,7 @@ private: // Shader for PS1-style rendering std::unique_ptr shader_; + std::unique_ptr skinnedShader_; // For skeletal animation // Test geometry VBO (cube) unsigned int testVBO_ = 0; diff --git a/src/3d/shaders/ps1_skinned_vertex.glsl b/src/3d/shaders/ps1_skinned_vertex.glsl new file mode 100644 index 0000000..406de9e --- /dev/null +++ b/src/3d/shaders/ps1_skinned_vertex.glsl @@ -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); +} diff --git a/src/3d/shaders/ps1_skinned_vertex_es2.glsl b/src/3d/shaders/ps1_skinned_vertex_es2.glsl new file mode 100644 index 0000000..498b924 --- /dev/null +++ b/src/3d/shaders/ps1_skinned_vertex_es2.glsl @@ -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); +} diff --git a/tests/demo/screens/skeletal_animation_demo.py b/tests/demo/screens/skeletal_animation_demo.py new file mode 100644 index 0000000..2f82908 --- /dev/null +++ b/tests/demo/screens/skeletal_animation_demo.py @@ -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")