rigging and animation
This commit is contained in:
parent
b85f225789
commit
cc027a2517
11 changed files with 2120 additions and 38 deletions
|
|
@ -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,8 +287,8 @@ void Entity3D::processNextMove()
|
|||
|
||||
void Entity3D::update(float dt)
|
||||
{
|
||||
if (!is_animating_) return;
|
||||
|
||||
// Update movement animation
|
||||
if (is_animating_) {
|
||||
move_progress_ += dt * move_speed_;
|
||||
|
||||
if (move_progress_ >= 1.0f) {
|
||||
|
|
@ -300,6 +304,10 @@ void Entity3D::update(float dt)
|
|||
// 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();
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,7 +788,44 @@ std::shared_ptr<Model3D> Model3D::load(const std::string& path)
|
|||
colors.resize(vertCount, vec4(1, 1, 1, 1));
|
||||
}
|
||||
|
||||
// Interleave vertex data
|
||||
// Extract indices
|
||||
std::vector<uint32_t> indices;
|
||||
if (prim->indices) {
|
||||
cgltf_accessor* accessor = prim->indices;
|
||||
indices.resize(accessor->count);
|
||||
for (size_t idx = 0; idx < accessor->count; ++idx) {
|
||||
indices[idx] = static_cast<uint32_t>(cgltf_accessor_read_index(accessor, idx));
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
@ -579,29 +837,383 @@ std::shared_ptr<Model3D> Model3D::load(const std::string& path)
|
|||
vertices.push_back(mv);
|
||||
allVertices.push_back(mv);
|
||||
}
|
||||
|
||||
// Extract indices
|
||||
std::vector<uint32_t> indices;
|
||||
if (prim->indices) {
|
||||
cgltf_accessor* accessor = prim->indices;
|
||||
indices.resize(accessor->count);
|
||||
for (size_t idx = 0; idx < accessor->count; ++idx) {
|
||||
indices[idx] = static_cast<uint32_t>(cgltf_accessor_read_index(accessor, idx));
|
||||
}
|
||||
}
|
||||
|
||||
// Create mesh
|
||||
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}
|
||||
};
|
||||
|
||||
|
|
|
|||
199
src/3d/Model3D.h
199
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<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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
108
src/3d/shaders/ps1_skinned_vertex.glsl
Normal file
108
src/3d/shaders/ps1_skinned_vertex.glsl
Normal 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);
|
||||
}
|
||||
195
src/3d/shaders/ps1_skinned_vertex_es2.glsl
Normal file
195
src/3d/shaders/ps1_skinned_vertex_es2.glsl
Normal 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);
|
||||
}
|
||||
275
tests/demo/screens/skeletal_animation_demo.py
Normal file
275
tests/demo/screens/skeletal_animation_demo.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue