Character improvements including animation blending, interpolation & delta encoding

This commit is contained in:
tovjemam 2026-02-11 19:20:00 +01:00
parent 374fdb1077
commit bdd2e2eefc
14 changed files with 412 additions and 45 deletions

View File

@ -25,6 +25,8 @@ set(COMMON_SOURCES
"src/collision/motionstate.hpp"
"src/collision/trianglemesh.hpp"
"src/collision/trianglemesh.cpp"
"src/game/character_anim_state.hpp"
"src/game/character_anim_state.cpp"
"src/game/player_input.hpp"
"src/game/skeletoninstance.hpp"
"src/game/skeletoninstance.cpp"

View File

@ -54,14 +54,27 @@ int assets::Skeleton::GetBoneIndex(const std::string& name) const
return -1;
}
assets::AnimIdx assets::Skeleton::GetAnimationIdx(const std::string& name) const
{
auto it = anim_idxs_.find(name);
if (it != anim_idxs_.end())
{
return it->second;
}
return NO_ANIM;
}
const assets::Animation* assets::Skeleton::GetAnimation(AnimIdx idx) const
{
if (idx >= anims_.size())
return nullptr;
return anims_[idx].get();
}
const assets::Animation* assets::Skeleton::GetAnimation(const std::string& name) const
{
auto it = anims_.find(name);
if (it != anims_.end())
{
return it->second.get();
}
return nullptr;
return GetAnimation(GetAnimationIdx(name));
}
void assets::Skeleton::AddBone(const std::string& name, const std::string& parent_name, const Transform& transform)
@ -76,3 +89,9 @@ void assets::Skeleton::AddBone(const std::string& name, const std::string& paren
bone_map_[bone.name] = index;
}
void assets::Skeleton::AddAnimation(const std::string& name, const std::shared_ptr<const Animation>& anim)
{
anim_idxs_[name] = anims_.size();
anims_.push_back(anim);
}

View File

@ -19,6 +19,9 @@ struct Bone
glm::mat4 inv_bind_matrix;
};
using AnimIdx = uint8_t;
constexpr AnimIdx NO_ANIM = 255;
class Skeleton
{
public:
@ -30,17 +33,20 @@ public:
size_t GetNumBones() const { return bones_.size(); }
const Bone& GetBone(size_t idx) const { return bones_[idx]; }
AnimIdx GetAnimationIdx(const std::string& name) const;
const Animation* GetAnimation(AnimIdx idx) const;
const Animation* GetAnimation(const std::string& name) const;
private:
void AddBone(const std::string& name, const std::string& parent_name, const Transform& transform);
void AddAnimation(const std::string& name, const std::shared_ptr<const Animation>& anim) { anims_[name] = anim; }
void AddAnimation(const std::string& name, const std::shared_ptr<const Animation>& anim);
private:
std::vector<Bone> bones_;
std::map<std::string, int> bone_map_;
std::map<std::string, std::shared_ptr<const Animation>> anims_;
std::vector<std::shared_ptr<const Animation>> anims_;
std::map<std::string, AnimIdx> anim_idxs_;
};
} // namespace assets

View File

@ -8,18 +8,21 @@ collision::DynamicsWorld::DynamicsWorld(std::shared_ptr<const assets::Map> map)
{
bt_world_.setGravity(btVector3(0, 0, -9.81f));
bt_broadphase_.getOverlappingPairCache()->setInternalGhostPairCallback(&bt_ghost_pair_cb_);
AddMapCollision();
btTransform t;
t.setIdentity();
t.setOrigin(btVector3(0,0,-12));
// btTransform t;
// t.setIdentity();
// t.setOrigin(btVector3(0,0,-12));
// TODO: remove
static btDefaultMotionState motion(t);
static btBoxShape box(btVector3(100, 100, 2));
btRigidBody::btRigidBodyConstructionInfo rbInfo(0.0f, &motion, &box, btVector3(0,0,0));
static btRigidBody body(rbInfo);
bt_world_.addRigidBody(&body);
// static btDefaultMotionState motion(t);
// static btBoxShape box(btVector3(100, 100, 2));
// btRigidBody::btRigidBodyConstructionInfo rbInfo(0.0f, &motion, &box, btVector3(0,0,0));
// static btRigidBody body(rbInfo);
// bt_world_.addRigidBody(&body);
}
void collision::DynamicsWorld::AddMapCollision()
@ -36,11 +39,13 @@ void collision::DynamicsWorld::AddMapCollision()
}
// add static objects
// for (const auto& sobjs = map_->GetStaticObjects(); const auto& sobj : sobjs)
// {
// AddModelInstance(*sobj.model, sobj.node.local);
// }
for (const auto& chunks = map_->GetChunks(); const auto& chunk : chunks)
{
for (const auto& obj : chunk.objs)
{
AddModelInstance(*obj.model, obj.node.local);
}
}
}
void collision::DynamicsWorld::AddModelInstance(const assets::Model& model, const Transform& trans)

View File

@ -3,6 +3,7 @@
#include <map>
#include <btBulletDynamicsCommon.h>
#include <BulletCollision/CollisionDispatch/btGhostObject.h>
#include "assets/map.hpp"
@ -44,6 +45,7 @@ private:
btDefaultCollisionConfiguration bt_cfg_;
btCollisionDispatcher bt_dispatcher_;
btGhostPairCallback bt_ghost_pair_cb_;
btDbvtBroadphase bt_broadphase_;
btSequentialImpulseConstraintSolver bt_solver_;
btDiscreteDynamicsWorld bt_world_;

View File

@ -1,6 +1,8 @@
#include "character.hpp"
#include "world.hpp"
#include "net/utils.hpp"
#include "assets/cache.hpp"
#include "utils/math.hpp"
game::Character::Character(World& world, const CharacterInfo& info)
: Super(world, net::ET_CHARACTER), shape_(info.shape), bt_shape_(shape_.radius, shape_.height),
@ -14,12 +16,14 @@ game::Character::Character(World& world, const CharacterInfo& info)
btDynamicsWorld& bt_world = world_.GetBtWorld();
static btGhostPairCallback ghostpaircb;
bt_world.getBroadphase()->getOverlappingPairCache()->setInternalGhostPairCallback(&ghostpaircb);
// bt_world.addCollisionObject(&bt_ghost_, btBroadphaseProxy::CharacterFilter,
// btBroadphaseProxy::StaticFilter | btBroadphaseProxy::DefaultFilter);
bt_world.addCollisionObject(&bt_ghost_);
bt_world.addCollisionObject(&bt_ghost_, btBroadphaseProxy::CharacterFilter,
btBroadphaseProxy::StaticFilter | btBroadphaseProxy::DefaultFilter);
// bt_world.addCollisionObject(&bt_ghost_);
bt_world.addAction(&bt_character_);
sk_ = SkeletonInstance(assets::CacheManager::GetSkeleton("data/human.sk"), &root_);
animstate_.idle_anim_idx = GetAnim("idle");
animstate_.walk_anim_idx = GetAnim("walk");
}
static bool Turn(float& angle, float target, float step)
@ -51,12 +55,21 @@ void game::Character::Update()
root_.local.position.z -= shape_.height * 0.5f + shape_.radius - 0.05f; // foot pos
UpdateMovement();
sync_current_ = 1 - sync_current_;
UpdateSyncState();
SendUpdateMsg();
}
void game::Character::SendInitData(Player& player, net::OutMessage& msg) const
{
Super::SendInitData(player, msg);
// write state against default
static const CharacterSyncState default_state;
size_t fields_pos = msg.Reserve<CharacterSyncFieldFlags>();
auto fields = WriteState(msg, default_state);
msg.WriteAt(fields_pos, fields);
}
void game::Character::SetInput(CharacterInputType type, bool enable)
@ -110,7 +123,7 @@ game::Character::~Character()
void game::Character::UpdateMovement()
{
constexpr float dt = 1.0f / 25.0f;
bool walking = false;
glm::vec2 movedir(0.0f);
if (in_ & (1 << CIN_FORWARD))
@ -129,6 +142,7 @@ void game::Character::UpdateMovement()
if (movedir.x != 0.0f || movedir.y != 0.0f)
{
walking = true;
float target_yaw = forward_yaw_ + std::atan2(movedir.y, movedir.x);
Turn(yaw_, target_yaw, 4.0f * dt);
@ -142,16 +156,96 @@ void game::Character::UpdateMovement()
{
bt_character_.jump(btVector3(0.0f, 0.0f, 10.0f));
}
// update anim
float run_blend_target = walking ? 0.5f : 0.0f;
MoveToward(animstate_.loco_blend, run_blend_target, dt * 2.0f);
float anim_speed = glm::mix(0.5f, 1.5f, UnMix(0.0f, 0.5f, animstate_.loco_blend));
animstate_.loco_phase = glm::mod(animstate_.loco_phase + anim_speed * dt, 1.0f);
}
void game::Character::UpdateSyncState()
{
auto& state = sync_[sync_current_];
// transform
net::EncodePosition(root_.local.position, state.pos);
state.yaw.Encode(yaw_);
// idle
state.idle_anim = animstate_.idle_anim_idx;
// loco
state.walk_anim = animstate_.walk_anim_idx;
state.run_anim = animstate_.run_anim_idx;
state.loco_phase.Encode(animstate_.loco_phase);
state.loco_blend.Encode(animstate_.loco_blend);
}
void game::Character::SendUpdateMsg()
{
net::PositionQ posq;
net::EncodePosition(root_.local.position, posq);
auto msg = BeginEntMsg(net::EMSG_UPDATE);
net::WritePositionQ(msg, posq);
msg.Write<net::PositiveAngleQ>(yaw_);
auto fields_pos = msg.Reserve<CharacterSyncFieldFlags>();
auto fields = WriteState(msg, sync_[1 - sync_current_]);
// TODO: allow this
// if (fields == 0)
// {
// DiscardMsg();
// return;
// }
msg.WriteAt(fields_pos, fields);
}
game::CharacterSyncFieldFlags game::Character::WriteState(net::OutMessage& msg, const CharacterSyncState& base) const
{
const auto& curr = sync_[sync_current_];
game::CharacterSyncFieldFlags fields = 0;
// transform
if (curr.pos.x.value != base.pos.x.value || curr.pos.y.value != base.pos.y.value ||
curr.pos.z.value != base.pos.z.value || curr.yaw.value != base.yaw.value)
{
fields |= CSF_TRANSFORM;
net::WriteDelta(msg, curr.pos.x, base.pos.x);
net::WriteDelta(msg, curr.pos.y, base.pos.y);
net::WriteDelta(msg, curr.pos.z, base.pos.z);
net::WriteDelta(msg, curr.yaw, base.yaw);
}
// idle
if (curr.idle_anim != base.idle_anim)
{
fields |= CSF_IDLE_ANIM;
msg.Write(curr.idle_anim);
}
// loco anims
if (curr.walk_anim != base.walk_anim || curr.run_anim != base.run_anim)
{
fields |= CSF_LOCO_ANIMS;
msg.Write(curr.walk_anim);
msg.Write(curr.run_anim);
}
// loco vals
if (curr.loco_blend.value != base.loco_blend.value || curr.loco_phase.value != base.loco_phase.value)
{
fields |= CSF_LOCO_VALS;
net::WriteDelta(msg, curr.loco_blend, base.loco_blend);
net::WriteDelta(msg, curr.loco_phase, base.loco_phase);
}
return fields;
}
void game::Character::Move(glm::vec3& velocity, float t)
@ -188,3 +282,8 @@ void game::Character::Move(glm::vec3& velocity, float t)
// velocity -= glm::dot(velocity, hit_normal) * hit_normal; // Adjust the velocity
// }
}
assets::AnimIdx game::Character::GetAnim(const std::string& name) const
{
return sk_.GetSkeleton()->GetAnimationIdx(name);
}

View File

@ -3,6 +3,8 @@
#include "entity.hpp"
#include "BulletCollision/CollisionDispatch/btGhostObject.h"
#include "BulletDynamics/Character/btKinematicCharacterController.h"
#include "character_anim_state.hpp"
#include "character_sync.hpp"
namespace game
{
@ -52,10 +54,14 @@ public:
private:
void UpdateMovement();
void UpdateSyncState();
void SendUpdateMsg();
CharacterSyncFieldFlags WriteState(net::OutMessage& msg, const CharacterSyncState& base) const;
void Move(glm::vec3& velocity, float t);
assets::AnimIdx GetAnim(const std::string& name) const;
private:
CapsuleShape shape_;
@ -74,6 +80,11 @@ private:
float walk_speed_ = 2.0f;
SkeletonInstance sk_;
CharacterAnimState animstate_;
CharacterSyncState sync_[2];
size_t sync_current_ = 0;
};
} // namespace game

View File

@ -0,0 +1,44 @@
#include "character_anim_state.hpp"
#include "utils/math.hpp"
void game::CharacterAnimState::ApplyToSkeleton(SkeletonInstance& sk) const
{
const auto& skeleton = sk.GetSkeleton();
const assets::Animation* idle_anim = skeleton->GetAnimation(idle_anim_idx);
const assets::Animation* walk_anim = skeleton->GetAnimation(walk_anim_idx);
const assets::Animation* run_anim = skeleton->GetAnimation(run_anim_idx);
if (!idle_anim)
return;
if (!walk_anim)
walk_anim = idle_anim;
if (!run_anim)
run_anim = walk_anim;
if (loco_blend == 0.0f) // idle
{
sk.ApplySkelAnim(*idle_anim, loco_phase, 1.0f);
}
else if (loco_blend < 0.5f) // idle-walk
{
sk.ApplySkelAnim(*idle_anim, loco_phase, 1.0f);
sk.ApplySkelAnim(*walk_anim, loco_phase, UnMix(0.0f, 0.5f, loco_blend));
}
else if (loco_blend == 0.5f) // walk
{
sk.ApplySkelAnim(*walk_anim, loco_phase, 1.0f);
}
else if (loco_blend < 1.0f) // walk-run
{
sk.ApplySkelAnim(*walk_anim, loco_phase, 1.0f);
sk.ApplySkelAnim(*run_anim, loco_phase, UnMix(0.5f, 1.0f, loco_blend));
}
else // run
{
sk.ApplySkelAnim(*run_anim, loco_phase, 1.0f);
}
}

View File

@ -0,0 +1,21 @@
#pragma once
#include "skeletoninstance.hpp"
namespace game
{
struct CharacterAnimState
{
assets::AnimIdx idle_anim_idx = assets::NO_ANIM;
assets::AnimIdx walk_anim_idx = assets::NO_ANIM;
assets::AnimIdx run_anim_idx = assets::NO_ANIM;
float loco_blend = 0.0f;
float loco_phase = 0.0f;
void ApplyToSkeleton(SkeletonInstance& sk) const;
};
} // namespace game

View File

@ -0,0 +1,41 @@
#pragma once
#include <cstdint>
#include "net/defs.hpp"
#include "assets/skeleton.hpp"
namespace game
{
struct CharacterSyncState
{
// transform
net::PositionQ pos;
net::PositiveAngleQ yaw;
// idle
assets::AnimIdx idle_anim = assets::NO_ANIM;
// loco anim
assets::AnimIdx walk_anim = assets::NO_ANIM;
assets::AnimIdx run_anim = assets::NO_ANIM;
net::AnimBlendQ loco_blend;
net::AnimTimeQ loco_phase;
//assets::AnimIdx strafe_left_anim = assets::NO_ANIM;
//assets::AnimIdx strafe_right_anim = assets::NO_ANIM;
// TODO: action
};
using CharacterSyncFieldFlags = uint8_t;
enum CharacterSyncFieldFlag
{
CSF_TRANSFORM = 0x01,
CSF_IDLE_ANIM = 0x02,
CSF_LOCO_ANIMS = 0x04,
CSF_LOCO_VALS = 0x08,
};
} // namespace game

View File

@ -2,9 +2,16 @@
#include "assets/cache.hpp"
#include "assets/model.hpp"
#include "net/utils.hpp"
#include "worldview.hpp"
game::view::CharacterView::CharacterView(WorldView& world, net::InMessage& msg) : EntityView(world, msg), ubo_(sk_)
{
if (!ReadState(msg))
throw EntityInitError();
states_[0] = states_[1]; // lerp from the read state to avoid jump
basemodel_ = assets::CacheManager::GetModel("data/human.mdl");
sk_ = SkeletonInstance(basemodel_->GetSkeleton(), &root_);
ubo_.Update();
@ -25,8 +32,17 @@ bool game::view::CharacterView::ProcessMsg(net::EntMsgType type, net::InMessage&
void game::view::CharacterView::Update(const UpdateInfo& info)
{
auto anim = sk_.GetSkeleton()->GetAnimation("walk");
sk_.ApplySkelAnim(*anim, info.time, 1.0f);
// interpolate states
float tps = 25.0f;
float t = (info.time - update_time_) * tps * 0.8f; // assume some jitter, interpolate for longer
t = glm::clamp(t, 0.0f, 2.0f);
root_.local = Transform::Lerp(states_[0].trans, states_[1].trans, t);
animstate_.loco_blend = glm::mix(states_[0].loco_blend, states_[1].loco_blend, t);
animstate_.loco_phase = glm::mod(glm::mix(states_[0].loco_phase, states_[1].loco_phase, t), 1.0f);
animstate_.ApplyToSkeleton(sk_);
root_.UpdateMatrix();
sk_.UpdateBoneMatrices();
ubo_valid_ = false;
@ -45,11 +61,11 @@ void game::view::CharacterView::Draw(const DrawArgs& args)
args.dlist.AddBeam(start, end, 0xFF007700, 0.05f);
//// draw bones debug
//const auto& bone_nodes = sk_.GetBoneNodes();
//for (const auto& bone_node : bone_nodes)
// const auto& bone_nodes = sk_.GetBoneNodes();
// for (const auto& bone_node : bone_nodes)
//{
// if (!bone_node.parent)
// continue;
// if (!bone_node.parent)
// continue;
// glm::vec3 p0 = bone_node.parent->matrix[3];
// glm::vec3 p1 = bone_node.matrix[3];
@ -76,14 +92,67 @@ void game::view::CharacterView::Draw(const DrawArgs& args)
}
}
bool game::view::CharacterView::ProcessUpdateMsg(net::InMessage& msg)
bool game::view::CharacterView::ReadState(net::InMessage& msg)
{
net::PositionQ posq;
if (!net::ReadPositionQ(msg, posq) || !msg.Read<net::PositiveAngleQ>(yaw_))
update_time_ = world_.GetTime();
// init lerp start state
states_[0].trans = root_.local;
states_[0].loco_blend = animstate_.loco_blend;
states_[0].loco_phase = animstate_.loco_phase;
auto& new_state = states_[1];
// parse state delta
CharacterSyncFieldFlags fields;
if (!msg.Read(fields))
return false;
net::DecodePosition(posq, root_.local.position);
root_.local.rotation = glm::rotate(glm::quat(1.0f, 0.0f, 0.0f, 0.0f), yaw_ + glm::pi<float>() * 0.5f, glm::vec3(0, 0, 1));
// transform
if (fields & CSF_TRANSFORM)
{
if (!net::ReadDelta(msg, sync_.pos.x) || !net::ReadDelta(msg, sync_.pos.y) ||
!net::ReadDelta(msg, sync_.pos.z) || !net::ReadDelta(msg, sync_.yaw))
return false;
net::DecodePosition(sync_.pos, new_state.trans.position);
new_state.trans.rotation = glm::rotate(glm::quat(1.0f, 0.0f, 0.0f, 0.0f),
sync_.yaw.Decode() + glm::pi<float>() * 0.5f, glm::vec3(0, 0, 1));
}
if (fields & CSF_IDLE_ANIM)
{
if (!msg.Read(sync_.idle_anim))
return false;
animstate_.idle_anim_idx = sync_.idle_anim;
}
if (fields & CSF_LOCO_ANIMS)
{
if (!msg.Read(sync_.walk_anim) || !msg.Read(sync_.run_anim))
return false;
animstate_.walk_anim_idx = sync_.walk_anim;
animstate_.run_anim_idx = sync_.run_anim;
}
if (fields & CSF_LOCO_VALS)
{
if (!net::ReadDelta(msg, sync_.loco_blend) || !net::ReadDelta(msg, sync_.loco_phase))
return false;
new_state.loco_blend = sync_.loco_blend.Decode();
new_state.loco_phase = sync_.loco_phase.Decode();
if (new_state.loco_phase < states_[0].loco_phase)
states_[0].loco_phase -= 1.0f;
}
return true;
}
bool game::view::CharacterView::ProcessUpdateMsg(net::InMessage& msg)
{
return ReadState(msg);
}

View File

@ -4,10 +4,19 @@
#include "assets/model.hpp"
#include "game/skeletoninstance.hpp"
#include "skinning_ubo.hpp"
#include "game/character_anim_state.hpp"
#include "game/character_sync.hpp"
namespace game::view
{
struct CharacterViewState
{
Transform trans;
float loco_blend = 0.0f;
float loco_phase = 0.0f;
};
class CharacterView : public EntityView
{
public:
@ -21,6 +30,7 @@ public:
virtual void Draw(const DrawArgs& args) override;
private:
bool ReadState(net::InMessage& msg);
bool ProcessUpdateMsg(net::InMessage& msg);
private:
@ -31,6 +41,14 @@ private:
SkinningUBO ubo_;
bool ubo_valid_ = false;
CharacterAnimState animstate_;
// sync
CharacterSyncState sync_;
CharacterViewState states_[2];
float update_time_ = 0.0f;
};
}

View File

@ -100,4 +100,7 @@ using ColorQ = Quantized<uint8_t, 0, 1>;
using NameTag = FixedStr<64>;
using AnimBlendQ = Quantized<uint8_t, 0, 1>;
using AnimTimeQ = Quantized<uint8_t, 0, 1>;
} // namespace net

27
src/utils/math.hpp Normal file
View File

@ -0,0 +1,27 @@
#pragma once
#include <glm/glm.hpp>
inline void MoveToward(float& val, float target, float max_delta)
{
if (val == target)
return;
if (val < target)
{
val += max_delta;
if (val > target)
val = target;
}
else
{
val -= max_delta;
if (val < target)
val = target;
}
}
inline float UnMix(float a, float b, float x)
{
return (x - a) / (b - a);
}