feat(creatures): animate creature walk cycles

Add a Gait component synchronized over the network and procedural animation for model nodes. Load animation parameters from assets/model/creature/pig/animation.json and apply leg swing, body bob, and head motion based on gait. Refactor Gait into its own header for reuse.
This commit is contained in:
2026-08-06 10:57:49 +08:00
parent b2187bf0ad
commit d6051486a8
18 changed files with 249 additions and 42 deletions

View File

@@ -0,0 +1,33 @@
{
"walk": {
"speed": 6.0,
"amplitude": 25.0
},
"run": {
"speed": 12.0,
"amplitude": 40.0
},
"body_bob": 0.03,
"head": {
"node": "Head",
"amplitude": 4.0
},
"legs": [
{
"node": "Foot_FL",
"phase": 0.0
},
{
"node": "Foot_FR",
"phase": 3.14159
},
{
"node": "Foot_HL",
"phase": 3.14159
},
{
"node": "Foot_HR",
"phase": 0.0
}
]
}

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include "Cubed/gameplay/ecs/entity.hpp" #include "Cubed/gameplay/ecs/entity.hpp"
#include "Cubed/gameplay/gait.hpp"
#include "glm/ext/vector_float3.hpp" #include "glm/ext/vector_float3.hpp"
#include "world/entity.pb.h" #include "world/entity.pb.h"
@@ -13,7 +14,7 @@ public:
enum class Command { CREATE, DESTORY, UPDATE }; enum class Command { CREATE, DESTORY, UPDATE };
ClientEntityManager(ClientWorld& world); ClientEntityManager(ClientWorld& world);
void update(); void update(float dt);
void init(); void init();
void receive_entity_create(S2CEntityCreate& msg); void receive_entity_create(S2CEntityCreate& msg);
@@ -36,6 +37,7 @@ private:
EntityID id; EntityID id;
glm::vec3 pos; glm::vec3 pos;
glm::vec3 direction; glm::vec3 direction;
Gait gait;
}; };
using EntityMap = tbb::concurrent_hash_map<EntityID, entt::entity>; using EntityMap = tbb::concurrent_hash_map<EntityID, entt::entity>;

View File

@@ -3,7 +3,6 @@
#include "Cubed/gameplay/ecs/animation.hpp" #include "Cubed/gameplay/ecs/animation.hpp"
#include "Cubed/gameplay/ecs/identity.hpp" #include "Cubed/gameplay/ecs/identity.hpp"
#include "Cubed/gameplay/ecs/transform.hpp" #include "Cubed/gameplay/ecs/transform.hpp"
#include "Cubed/gameplay/player.hpp"
namespace Cubed { namespace Cubed {
struct ClientPlayer { struct ClientPlayer {

View File

@@ -1,6 +1,6 @@
#pragma once #pragma once
#include "Cubed/gameplay/player.hpp" #include "Cubed/gameplay/gait.hpp"
namespace Cubed { namespace Cubed {
struct WalkPose { struct WalkPose {

View File

@@ -0,0 +1,21 @@
#pragma once
#include <stdexcept>
#include <utility>
namespace Cubed {
enum class Gait { STOP = 0, WALK = 1, RUN = 2 };
constexpr int get_gait_id(Gait gait) { return std::to_underlying(gait); }
inline Gait get_gait_from_id(int id) {
switch (id) {
case std::to_underlying(Gait::STOP):
return Gait::STOP;
case std::to_underlying(Gait::WALK):
return Gait::WALK;
case std::to_underlying(Gait::RUN):
return Gait::RUN;
default:
throw std::runtime_error("Unknown Gait");
}
}
} // namespace Cubed

View File

@@ -11,7 +11,6 @@
#include "Cubed/gameplay/game_time.hpp" #include "Cubed/gameplay/game_time.hpp"
#include "Cubed/gameplay/hitbox.hpp" #include "Cubed/gameplay/hitbox.hpp"
#include "Cubed/gameplay/item_stack.hpp" #include "Cubed/gameplay/item_stack.hpp"
#include "Cubed/gameplay/player.hpp"
#include "Cubed/input/event.hpp" #include "Cubed/input/event.hpp"
#include "Cubed/input/input.hpp" #include "Cubed/input/input.hpp"

View File

@@ -1,23 +1,7 @@
#pragma once #pragma once
#include "glm/ext/vector_float3.hpp" #include "glm/ext/vector_float3.hpp"
#include <stdexcept>
#include <utility>
namespace Cubed { namespace Cubed {
enum class Gait { STOP = 0, WALK = 1, RUN = 2 };
constexpr int get_gait_id(Gait gait) { return std::to_underlying(gait); }
inline Gait get_gait_from_id(int id) {
switch (id) {
case std::to_underlying(Gait::STOP):
return Gait::STOP;
case std::to_underlying(Gait::WALK):
return Gait::WALK;
case std::to_underlying(Gait::RUN):
return Gait::RUN;
default:
throw std::runtime_error("Unknown Gait");
}
}
static constexpr glm::vec3 PLAYER_SIZE{0.6f, 1.8f, 0.6f}; static constexpr glm::vec3 PLAYER_SIZE{0.6f, 1.8f, 0.6f};
} // namespace Cubed } // namespace Cubed

View File

@@ -1,7 +1,7 @@
#pragma once #pragma once
#include "Cubed/gameplay/chunk_pos.hpp" #include "Cubed/gameplay/chunk_pos.hpp"
#include "Cubed/gameplay/gait.hpp"
#include "Cubed/gameplay/game_time.hpp" #include "Cubed/gameplay/game_time.hpp"
#include "Cubed/gameplay/player.hpp"
#include <absl/container/flat_hash_set.h> #include <absl/container/flat_hash_set.h>
#include <atomic> #include <atomic>

View File

@@ -41,5 +41,6 @@ private:
IDMap m_id_map; IDMap m_id_map;
NameMap m_name_map; NameMap m_name_map;
Handle load_model(std::string_view model_name); Handle load_model(std::string_view model_name);
void load_anim_config(ModelNode& node, const std::string& path);
}; };
} // namespace Cubed } // namespace Cubed

View File

@@ -9,6 +9,7 @@
#include <glm/glm.hpp> #include <glm/glm.hpp>
#include <memory> #include <memory>
#include <string> #include <string>
#include <string_view>
namespace Cubed { namespace Cubed {
struct Mesh { struct Mesh {
@@ -32,11 +33,47 @@ struct Mesh {
(void*)offsetof(Vertex3D, nx)); (void*)offsetof(Vertex3D, nx));
}; };
}; };
struct NodeAnimRule {
enum class Role { NONE, LEG, HEAD };
std::string node;
Role role = Role::NONE;
float phase = 0.0f;
};
struct ModelAnimConfig {
float walk_speed = 6.0f;
float walk_amp = 25.0f;
float run_speed = 12.0f;
float run_amp = 40.0f;
float body_bob = 0.05f;
float head_amp = 4.0f;
std::vector<NodeAnimRule> nodes;
const NodeAnimRule* rule_for(std::string_view name) const {
for (const auto& r : nodes) {
if (r.node == name) {
return &r;
}
}
return nullptr;
}
bool has_role(NodeAnimRule::Role role) const {
for (const auto& r : nodes) {
if (r.role == role) {
return true;
}
}
return false;
}
};
struct ModelNode { struct ModelNode {
std::string name; std::string name;
glm::mat4 transform{1.0f}; glm::mat4 transform{1.0f};
std::vector<Mesh> meshes; std::vector<Mesh> meshes;
std::vector<ModelNode> children; std::vector<ModelNode> children;
ModelAnimConfig anim;
}; };
} // namespace Cubed } // namespace Cubed

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include "Cubed/gameplay/ecs/animation.hpp"
#include "Cubed/gameplay/model.hpp" #include "Cubed/gameplay/model.hpp"
#include "Cubed/render/model_node.hpp" #include "Cubed/render/model_node.hpp"
#include "Cubed/shader.hpp" #include "Cubed/shader.hpp"
@@ -12,15 +13,18 @@ class ModelRender {
public: public:
ModelRender(Renderer& renderer); ModelRender(Renderer& renderer);
void render_model(ModelID id, const glm::vec3& pos, float yaw, void render_model(ModelID id, const glm::vec3& pos, float yaw,
Camera& camera); Camera& camera, const WalkPose& pose);
void shadow_pass(ModelID id, const glm::vec3& pos, float yaw, void shadow_pass(ModelID id, const glm::vec3& pos, float yaw,
Camera& camera); Camera& camera, const WalkPose& pose);
private: private:
Renderer& m_renderer; Renderer& m_renderer;
void render_node(const ModelNode& node, const glm::mat4& parent, void render_node(const ModelNode& node, const glm::mat4& parent,
const glm::mat4& view, const Shader& shader, bool shadow); const glm::mat4& view, const Shader& shader, bool shadow,
const WalkPose& pose, const ModelAnimConfig& cfg);
glm::mat4 pose_node(const ModelNode& node, const ModelAnimConfig& cfg,
const WalkPose& pose);
void render_mesh(const Mesh& mesh, bool shadow); void render_mesh(const Mesh& mesh, bool shadow);
}; };
} // namespace Cubed } // namespace Cubed

View File

@@ -13,7 +13,19 @@ using namespace google::protobuf;
namespace Cubed { namespace Cubed {
ClientEntityManager::ClientEntityManager(ClientWorld& world) : m_world(world) {} ClientEntityManager::ClientEntityManager(ClientWorld& world) : m_world(world) {}
void ClientEntityManager::update() { handle_task(); } void ClientEntityManager::update(float dt) {
handle_task();
auto view = m_registry.view<BaseClientCreature>();
for (auto e : view) {
auto& c = view.get<BaseClientCreature>(e);
if (c.pose.gait == Gait::STOP) {
c.pose.walk_time = 0.0f;
} else {
c.pose.walk_time += dt;
}
}
}
void ClientEntityManager::init() { void ClientEntityManager::init() {
m_factories.emplace("cubed:pig", [this](EntityID id) { m_factories.emplace("cubed:pig", [this](EntityID id) {
@@ -53,7 +65,7 @@ void ClientEntityManager::handle_entity_update(UpdateInfo& info) {
ASSERT(creature); ASSERT(creature);
creature->transform.position.value = info.pos; creature->transform.position.value = info.pos;
creature->transform.direction.value = info.direction; creature->transform.direction.value = info.direction;
creature->pose.gait = info.gait;
auto r = m_registry.try_get<RenderTransform>(e); auto r = m_registry.try_get<RenderTransform>(e);
ASSERT(r); ASSERT(r);
r->direction.value = r->direction.value =
@@ -79,6 +91,7 @@ void ClientEntityManager::receive_entity_update(S2CEntityUpdate& msg) {
e.id = msg.id(); e.id = msg.id();
e.pos = Tools::get_net_vec3(msg.pos()); e.pos = Tools::get_net_vec3(msg.pos());
e.direction = Tools::get_net_vec3(msg.direction()); e.direction = Tools::get_net_vec3(msg.direction());
e.gait = get_gait_from_id(msg.gait());
m_tasks.emplace(Command::UPDATE, std::move(e)); m_tasks.emplace(Command::UPDATE, std::move(e));
} }

View File

@@ -743,7 +743,7 @@ void ClientWorld::send_chat_message(ChatMessage& message) {
void ClientWorld::update(float delta_time) { void ClientWorld::update(float delta_time) {
m_player_manager.update(delta_time); m_player_manager.update(delta_time);
m_entity_manager.update(); m_entity_manager.update(delta_time);
{ {
std::lock_guard lk(m_delete_vbo_mutex); std::lock_guard lk(m_delete_vbo_mutex);
m_pending_delete_vbo.clear(); m_pending_delete_vbo.clear();

View File

@@ -3,6 +3,7 @@
#include "Cubed/gameplay/creatures/pig.hpp" #include "Cubed/gameplay/creatures/pig.hpp"
#include "Cubed/gameplay/ecs/identity.hpp" #include "Cubed/gameplay/ecs/identity.hpp"
#include "Cubed/gameplay/ecs/server_entity.hpp" #include "Cubed/gameplay/ecs/server_entity.hpp"
#include "Cubed/gameplay/gait.hpp"
#include "Cubed/gameplay/hitbox_manager.hpp" #include "Cubed/gameplay/hitbox_manager.hpp"
#include "Cubed/gameplay/server_world.hpp" #include "Cubed/gameplay/server_world.hpp"
#include "Cubed/gameplay/session.hpp" #include "Cubed/gameplay/session.hpp"
@@ -11,6 +12,7 @@
#include "Cubed/gameplay/systems/wander_ai_system.hpp" #include "Cubed/gameplay/systems/wander_ai_system.hpp"
#include "Cubed/tools/cubed_assert.hpp" #include "Cubed/tools/cubed_assert.hpp"
#include "Cubed/tools/net_utils.hpp" #include "Cubed/tools/net_utils.hpp"
using namespace google::protobuf; using namespace google::protobuf;
namespace Cubed { namespace Cubed {
@@ -88,6 +90,12 @@ void ServerEntityManager::update_send(
Tools::set_net_vec3(p->mutable_direction(), Tools::set_net_vec3(p->mutable_direction(),
creature.transform.direction.value); creature.transform.direction.value);
const auto& v = creature.velocity.value;
if (v.x * v.x + v.z * v.z > 1e-4f) {
p->set_gait(get_gait_id(Gait::WALK));
} else {
p->set_gait(get_gait_id(Gait::STOP));
}
for (auto& s : sessions) { for (auto& s : sessions) {
s->send(make_packet(p)); s->send(make_packet(p));

View File

@@ -27,4 +27,5 @@ message S2CEntityUpdate {
uint64 id = 1; uint64 id = 1;
Vec3 pos = 2; Vec3 pos = 2;
Vec3 direction = 3; Vec3 direction = 3;
int32 gait = 4;
} }

View File

@@ -3,6 +3,12 @@
#include "Cubed/tools/cubed_assert.hpp" #include "Cubed/tools/cubed_assert.hpp"
#include "Cubed/tools/log.hpp" #include "Cubed/tools/log.hpp"
#include "Cubed/tools/name_space.hpp" #include "Cubed/tools/name_space.hpp"
#include <filesystem>
#include <rapidjson/document.h>
#include <rapidjson/istreamwrapper.h>
namespace fs = std::filesystem;
namespace Cubed { namespace Cubed {
ModelManager& ModelManager::instance() { ModelManager& ModelManager::instance() {
@@ -80,6 +86,9 @@ ModelManager::Handle ModelManager::load_model(std::string_view model_name) {
space[1]); space[1]);
} }
auto model = m_loader.load(path); auto model = m_loader.load(path);
fs::path anim_path = path;
anim_path = anim_path.parent_path() / "animation.json";
load_anim_config(model, anim_path);
ModelMap::accessor acc; ModelMap::accessor acc;
if (m_models.insert(acc, m_next++)) { if (m_models.insert(acc, m_next++)) {
acc->second = std::move(model); acc->second = std::move(model);
@@ -95,4 +104,50 @@ ModelManager::Handle ModelManager::load_model(std::string_view model_name) {
return {acc->second, acc->first}; return {acc->second, acc->first};
} }
void ModelManager::load_anim_config(ModelNode& node, const std::string& path) {
if (!fs::is_regular_file(path)) {
return;
}
std::ifstream s(path);
if (!s.is_open()) {
return;
}
rapidjson::IStreamWrapper isw(s);
rapidjson::Document doc;
doc.ParseStream(isw);
if (doc.HasParseError()) {
Logger::warn("Can't parse anim config {}", path);
return;
}
ModelAnimConfig cfg;
if (doc.HasMember("walk")) {
cfg.walk_speed = doc["walk"]["speed"].GetFloat();
cfg.walk_amp = doc["walk"]["amplitude"].GetFloat();
}
if (doc.HasMember("run")) {
cfg.run_speed = doc["run"]["speed"].GetFloat();
cfg.run_amp = doc["run"]["amplitude"].GetFloat();
}
if (doc.HasMember("body_bob")) {
cfg.body_bob = doc["body_bob"].GetFloat();
}
if (doc.HasMember("head")) {
NodeAnimRule r;
r.node = doc["head"]["node"].GetString();
r.role = NodeAnimRule::Role::HEAD;
cfg.head_amp = doc["head"]["amplitude"].GetFloat();
cfg.nodes.push_back(std::move(r));
}
if (doc.HasMember("legs")) {
for (auto& leg : doc["legs"].GetArray()) {
NodeAnimRule r;
r.node = leg["node"].GetString();
r.role = NodeAnimRule::Role::LEG;
r.phase = leg["phase"].GetFloat();
cfg.nodes.push_back(std::move(r));
}
}
node.anim = std::move(cfg);
}
} // namespace Cubed } // namespace Cubed

View File

@@ -3,37 +3,64 @@
#include "Cubed/camera.hpp" #include "Cubed/camera.hpp"
#include "Cubed/render/model_manager.hpp" #include "Cubed/render/model_manager.hpp"
#include "Cubed/render/renderer.hpp" #include "Cubed/render/renderer.hpp"
#include <cmath>
namespace Cubed { namespace Cubed {
namespace {
float swing_angle(const WalkPose& pose, float speed, float amp_deg) {
if (pose.gait == Gait::STOP) {
return 0.0f;
}
return glm::sin(pose.walk_time * speed) * glm::radians(amp_deg);
}
} // namespace
ModelRender::ModelRender(Renderer& renderer) : m_renderer(renderer) {} ModelRender::ModelRender(Renderer& renderer) : m_renderer(renderer) {}
void ModelRender::render_model(ModelID id, const glm::vec3& pos, float yaw, void ModelRender::render_model(ModelID id, const glm::vec3& pos, float yaw,
Camera& camera) { Camera& camera, const WalkPose& pose) {
auto& root = ModelManager::model(id).node; auto& root = ModelManager::model(id).node;
glm::mat4 transform = glm::translate(glm::mat4(1.0f), pos) * glm::vec3 bob_pos = pos;
if (pose.gait != Gait::STOP &&
root.anim.has_role(NodeAnimRule::Role::LEG)) {
bob_pos.y += std::abs(glm::sin(pose.walk_time * root.anim.walk_speed)) *
root.anim.body_bob;
}
glm::mat4 transform = glm::translate(glm::mat4(1.0f), bob_pos) *
glm::rotate(glm::mat4(1.0f), yaw, {0, 1, 0}); glm::rotate(glm::mat4(1.0f), yaw, {0, 1, 0});
auto& shader = m_renderer.get_shader("model_shader"); auto& shader = m_renderer.get_shader("model_shader");
glm::mat4 view = camera.get_camera_lookat(); glm::mat4 view = camera.get_camera_lookat();
shader.set_loc("proj_matrix", m_renderer.p_mat()); shader.set_loc("proj_matrix", m_renderer.p_mat());
render_node(root, transform, view, shader, false); render_node(root, transform, view, shader, false, pose, root.anim);
} }
void ModelRender::shadow_pass(ModelID id, const glm::vec3& pos, float yaw, void ModelRender::shadow_pass(ModelID id, const glm::vec3& pos, float yaw,
Camera& camera) { Camera& camera, const WalkPose& pose) {
auto& root = ModelManager::model(id).node; auto& root = ModelManager::model(id).node;
glm::mat4 transform = glm::translate(glm::mat4(1.0f), pos) * glm::vec3 bob_pos = pos;
if (pose.gait != Gait::STOP &&
root.anim.has_role(NodeAnimRule::Role::LEG)) {
bob_pos.y += std::abs(glm::sin(pose.walk_time * root.anim.walk_speed)) *
root.anim.body_bob;
}
glm::mat4 transform = glm::translate(glm::mat4(1.0f), bob_pos) *
glm::rotate(glm::mat4(1.0f), yaw, {0, 1, 0}); glm::rotate(glm::mat4(1.0f), yaw, {0, 1, 0});
auto& shader = m_renderer.get_shader("depth_model"); auto& shader = m_renderer.get_shader("depth_model");
glm::mat4 view = camera.get_camera_lookat(); glm::mat4 view = camera.get_camera_lookat();
render_node(root, transform, view, shader, true); render_node(root, transform, view, shader, true, pose, root.anim);
} }
void ModelRender::render_node(const ModelNode& node, const glm::mat4& parent, void ModelRender::render_node(const ModelNode& node, const glm::mat4& parent,
const glm::mat4& view, const Shader& shader, const glm::mat4& view, const Shader& shader,
bool shadow) { bool shadow, const WalkPose& pose,
const ModelAnimConfig& cfg) {
glm::mat4 transform = parent * node.transform; glm::mat4 transform = parent * pose_node(node, cfg, pose);
if (shadow) { if (shadow) {
shader.set_loc("modelMatrix", transform); shader.set_loc("modelMatrix", transform);
} else { } else {
@@ -48,7 +75,7 @@ void ModelRender::render_node(const ModelNode& node, const glm::mat4& parent,
} }
for (auto& child : node.children) { for (auto& child : node.children) {
render_node(child, transform, view, shader, shadow); render_node(child, transform, view, shader, shadow, pose, cfg);
} }
} }
@@ -64,4 +91,27 @@ void ModelRender::render_mesh(const Mesh& mesh, bool) {
GL_UNSIGNED_INT, 0); GL_UNSIGNED_INT, 0);
} }
glm::mat4 ModelRender::pose_node(const ModelNode& node,
const ModelAnimConfig& cfg,
const WalkPose& pose) {
const auto* rule = cfg.rule_for(node.name);
if (!rule) {
return node.transform;
}
float angle = 0.0f;
if (rule->role == NodeAnimRule::Role::LEG) {
float speed = pose.gait == Gait::RUN ? cfg.run_speed : cfg.walk_speed;
float amp = pose.gait == Gait::RUN ? cfg.run_amp : cfg.walk_amp;
angle = swing_angle(pose, speed, amp);
angle *= std::cos(rule->phase);
} else if (rule->role == NodeAnimRule::Role::HEAD) {
angle = glm::sin(pose.walk_time * cfg.walk_speed * 0.5f) *
glm::radians(cfg.head_amp);
} else {
return node.transform;
}
return glm::rotate(node.transform, angle, glm::vec3(1.0f, 0.0f, 0.0f));
}
} // namespace Cubed } // namespace Cubed

View File

@@ -307,9 +307,9 @@ void WorldRenderer::shadow_entity(ClientWorld& world,
auto& t = view.get<RenderTransform>(entity); auto& t = view.get<RenderTransform>(entity);
float yaw = std::atan2(t.direction.value.x, t.direction.value.z); float yaw = std::atan2(t.direction.value.x, t.direction.value.z);
m_renderer.model_renderer().shadow_pass(creature.model, m_renderer.model_renderer().shadow_pass(
t.position.value, yaw, creature.model, t.position.value, yaw, world.world_scene().camera(),
world.world_scene().camera()); creature.pose);
} }
m_player_renderer.render(shader, world, true); m_player_renderer.render(shader, world, true);
} }
@@ -734,9 +734,9 @@ void WorldRenderer::render_entity(ClientWorld& world) {
} }
auto& t = view.get<RenderTransform>(entity); auto& t = view.get<RenderTransform>(entity);
float yaw = std::atan2(t.direction.value.x, t.direction.value.z); float yaw = std::atan2(t.direction.value.x, t.direction.value.z);
m_renderer.model_renderer().render_model(creature.model, m_renderer.model_renderer().render_model(
t.position.value, yaw, creature.model, t.position.value, yaw, world.world_scene().camera(),
world.world_scene().camera()); creature.pose);
} }
m_player_renderer.render(shader, world, false); m_player_renderer.render(shader, world, false);