Введение
Это вторая часть серии туториалов, где мы реализуем Third Person Controller на MonoGame.
Первая часть доступна тут: https://habr.com/ru/articles/1040382/
В этой части мы заменим капсулу персонажа на анимированную модель. И прицепим меч на спину:
Для понимания материала необходимо иметь представление о том, что такое gltf/glb и как работает скелетная анимация. В Интернете полно информации на эту тему. Например, статья на learnopengl: https://learnopengl.com/Guest-Articles/2020/Skeletal-Animation
Объединение fbx анимаций в одну модель gltf(необязательная глава)
Эта глава необязательна для туториала. Поскольку в нём будет предоставлена модель gltf сразу со всем нужными анимациями.
Но если вы сами захотите создать свою анимированную модель на базе mixamo, то вам будет полезно посмотреть это видео, где вначале необходимые анимации скачиваются. А потом объединяются в blendere и экспортятся в glb(бинарная версия gltf):
Важно держать в уме следующие моменты:
-
Первую анимацию из Mixamo (например, «Idle») нужно скачать с параметром «With Skin», все остальные — с параметром «Without Skin»
-
Убедитесь, что установлен флажок «In Place». У некоторых анимаций этот флажок отсутствует. Это можно исправить в Blender с помощью дополнения In Placer
-
В Blender сначала импортируйте FBX-файл, скачанный с параметром «With Skin» (то есть базовую модель), а затем все остальные
-
Не забудьте удалить все арматуры (Armatures), кроме арматуры базовой модели, после нажатия кнопок «Push Down Action»
Стартовая точка
Мы продолжим с того же самого места, на котором остановились в конце прошлой части.
Если у вас не сохранилось исходного кода того туториала, то вы можете взять его здесь: https://github.com/rds1983/ThirdPersonTutorial/tree/master/Step1-Capsule
Небольшие изменения
Для начала мы внесём небольшие QoL изменения в исходный код:
-
Включим DefaultLights в BasicEffect
-
Разобьём метод Update на части
-
Вынесем часть кода DrawMesh в отдельный метод
Включаем DefaultLights
Удалите код, инициализирующий DirectionalLight0 в _basicEffect. И добавьте вместо него(т.е. кода) такую строку:
_basicEffect.EnableDefaultLighting();
Это сделает сцену более освещённой:

Рефактор Update
Добавим 3 метода: ProcessMouse, ProcessKeyboard и UpdateJump
И перенесём в них соответствующий код:
// Handle mouse input for camera rotationprivate void ProcessMouse(){// Handle mouse input for camera rotationvar mouse = Mouse.GetState();if (_oldMouse != null){// Rotate hero by mouse X deltavar horizontalRotation = -(int)((mouse.X - _oldMouse.Value.X) * MouseSensitivity);_heroYaw += horizontalRotation;// Tilt camera by mouse Y deltavar verticalRotation = -(int)((mouse.Y - _oldMouse.Value.Y) * MouseSensitivity);_cameraMountPitch += verticalRotation;// Clamp pitch to valid range (5 to 90 degrees)_cameraMountPitch = MathHelper.Clamp(_cameraMountPitch, 5, 90);}_oldMouse = mouse;}// Handle keyboard input for movement and jump initiationprivate void ProcessKeyboard(){// Calculate movement velocity based on hero orientationvar velocity = Vector3.Zero;var heroTransform = ToMatrix(_heroPosition, Vector3.One, _heroYaw, 0, 0);var keyboard = Keyboard.GetState();// Track if hero is moving (for animation transitions)if (keyboard.IsKeyDown(Keys.W))velocity = heroTransform.Forward * -MovementSpeed;else if (keyboard.IsKeyDown(Keys.S))velocity = heroTransform.Forward * MovementSpeed;else if (keyboard.IsKeyDown(Keys.A))velocity = heroTransform.Right * MovementSpeed;else if (keyboard.IsKeyDown(Keys.D))velocity = heroTransform.Right * -MovementSpeed;// Apply velocity to hero position_heroPosition += velocity;// Initiate jump with momentum preservationif (keyboard.IsKeyDown(Keys.Space)){_jumpStarted = DateTime.Now;_jumpMovement = velocity;}}// Update hero position and animation during jump using projectile motionprivate void UpdateJump(){// Time elapsed since jump started (seconds)var t = (float)(DateTime.Now - _jumpStarted.Value).TotalSeconds;// Height from kinematic equation: h = h0 + v0*t - 0.5*g*t^2var jumpHeight = DefaultY + JumpForce * t - (0.5f * Gravity * t * t);// Apply height and preserve horizontal momentum_heroPosition.Y = jumpHeight;_heroPosition += _jumpMovement;// Land when reaching groundif (_heroPosition.Y <= DefaultY){_heroPosition.Y = DefaultY;_jumpStarted = null;}}protected override void Update(GameTime gameTime){base.Update(gameTime);ProcessMouse();if (_jumpStarted == null){ProcessKeyboard();}else{UpdateJump();}}
Теперь код стал более простым и читаемым.
Рефактор DrawMesh
Вынесем рисование DrMeshPart в отдельный метод:
// Draw a single mesh part with the given effectprivate void DrawMeshPart(Effect effect, DrMeshPart part){GraphicsDevice.SetVertexBuffer(part.VertexBuffer);GraphicsDevice.Indices = part.IndexBuffer;foreach (var pass in effect.CurrentTechnique.Passes){pass.Apply();GraphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, part.PrimitiveCount);}}/// <summary>Render a mesh with color and texture.</summary>private void DrawMesh(DrMesh mesh, Matrix world, Color color, Texture2D texture){_basicEffect.DiffuseColor = color.ToVector3();_basicEffect.TextureEnabled = texture != null;_basicEffect.Texture = texture;_basicEffect.World = world;foreach (var part in mesh.MeshParts){DrawMeshPart(_basicEffect, part);}}
Промежуточный итог
Результат нашего рефакторинга можно посмотреть тут: https://github.com/rds1983/ThirdPersonTutorial/blob/master/Step2-Refactor/MyGame.cs
Заменяем капсулу на модель
Скачайте и разархивируйте в папку с проектом: https://github.com/user-attachments/files/28683120/Assets.zip
Архив состоит только из gltf модели персонажа.
Перейдём к коду.
Удаляем поле _meshHero и весь код, который с ним работает. Вместо него добавляем следующие поля:
// Hero character model instanceprivate DrModelInstance _modelHero;// Effect for rendering skeletal mesh with bone transformationsprivate SkinnedEffect _skinnedEffect;// Solid white texture for models without material texturesprivate Texture2D _textureWhite;
SkinnedEffect — это аналог BasicEffect, только умеющий в скелетную анимацию.
_textureWhite — белая текстура единичного размера. Она нам нужна, поскольку у нашей модели нет текстур.
Добавляем инициализацию новых полей в LoadContent:
// Load hero modelDrModel model = assetManager.LoadModel(GraphicsDevice, "Models/mixamo.gltf");_modelHero = new DrModelInstance(model);// Effect for rendering skeletal meshes with bone transformations_skinnedEffect = new SkinnedEffect(GraphicsDevice);_skinnedEffect.EnableDefaultLighting();// Create solid white texture for untextured mesh parts_textureWhite = new Texture2D(GraphicsDevice, 1, 1);_textureWhite.SetData(new Color[] { Color.White });
Если с инициализацией _skinnedEffect и _textureWhite всё более ни менее понятно.
То про загрузку модели стоит сказать пару слов. Здесь мы применили два класса DrModel и DrModelInstance.
DrModel содержит информацию о модели: меши, иерархия костей, анимации, материалы и т.д. Фактически DrModel является immutable.
DrModelInstance является экземпляром DrModel. И содержит такую изменяющуюся информацию, как трансформации костей и т.д.
Поэтому каждую DrModel мы будем оборачивать в DrModelInstance. Хотя в этой серии туториалов на одну DrModel будет приходится один DrModelInstance. В реальных приложениях, на одну модель может приходиться сколько угодно экземпляров.
Метод DrawModel
Добавим следующий метод:
// Render model with material colors and textures, handling both skinned and static meshesprivate void DrawModel(DrModelInstance model, Matrix world){foreach (var mesh in model.Model.Meshes){foreach (var part in mesh.MeshParts){// Extract material properties or use defaults if no material assignedvar color = Color.White;var texture = _textureWhite;if (part.Material != null){color = part.Material.DiffuseColor;if (part.Material.DiffuseTexture != null){texture = part.Material.DiffuseTexture;}}if (part.Skin != null){// Skinned mesh: bone transforms applied per-vertex in shader via SetBoneTransforms// World matrix only positions the entire model in world space_skinnedEffect.DiffuseColor = color.ToVector3();_skinnedEffect.Texture = texture;_skinnedEffect.World = world;_skinnedEffect.SetBoneTransforms(model.GetSkinTransforms(part.Skin.SkinIndex));DrawMeshPart(_skinnedEffect, part);}else{// Static mesh: must include bone transform in World matrix since GPU doesn't apply skeletal deformation// Bone transform positions this mesh part relative to the model, then world positions the whole model_basicEffect.DiffuseColor = color.ToVector3();_basicEffect.Texture = texture;_basicEffect.World = model.GetBoneGlobalTransform(mesh.ParentBone.Index) * world;DrawMeshPart(_basicEffect, part);}}}}
В этом коде наиболее интересна проверка Part.Skin на null и выбор эффекта для рисования на основе результата.
Для понимания этого кода нужно иметь представление о том, как устроена Gltf-модель.
В ней с каждым мешем может быть связан скин. Скин — это коллекция костей. Вызов model.GetSkinTransforms возвращает массив матриц трансформации, соответствующий этой коллекции. Который мы и передаём в SkinnedEffect.
Если с мешем никакой скин не связан, то пользуемся обычным BasicEffect.
Обратите так же внимание, что при наличии скина, мы передаём в параметр World только мировую матрицу трансформации модели(world).
А при его отсутствии, мы дополнительно умножаем её на матрицу трансформации кости, с которой связан меш — model.GetBoneGlobalTransform(mesh.ParentBone.Index).
Это связано с тем, что в первом случае массив матриц итак содержит все необходимые трансформации костей.
Новый метод Draw
Наконец нам необходимо полностью переписать метод Draw, добавив туда выставление матриц проекции и вида для SkinnedEffect. А так же, собственно осуществить вызов нового метода DrawModel.
Поэтому перепишем Draw так:
protected override void Draw(GameTime gameTime){base.Draw(gameTime);var device = GraphicsDevice;device.Clear(Color.Black);// Set GPU statesdevice.DepthStencilState = DepthStencilState.Default;device.RasterizerState = RasterizerState.CullCounterClockwise;device.BlendState = BlendState.AlphaBlend;device.SamplerStates[0] = SamplerState.LinearWrap;// Set projectionvar projection = Matrix.CreatePerspectiveFieldOfView(MathHelper.ToRadians(ViewAngle),device.Viewport.AspectRatio,NearPlaneDistance, FarPlaneDistance);_basicEffect.Projection = projection;_skinnedEffect.Projection = projection;// Build camera hierarchy: hero body -> camera mount (head) -> cameravar heroTransform = ToMatrix(_heroPosition, Vector3.One, _heroYaw, 0, 0);var cameraMountTransform = ToMatrix(new Vector3(0, 1f, 0), Vector3.One, 0, _cameraMountPitch, 0) * heroTransform;var cameraTransform = ToMatrix(new Vector3(0, 0, -5), Vector3.One, 180, 0, 0) * cameraMountTransform;var view = Matrix.Invert(cameraTransform);_basicEffect.View = view;_skinnedEffect.View = view;// Draw ground and heroDrawMesh(_meshGround, Matrix.CreateScale(200, 1, 200), Color.White, _textureGround);DrawModel(_modelHero, heroTransform);}
Если мы запустим игру, то увидим следующее:

Нам удалось заменить капсулу на модель. Но она немного парит над землей. А всё дело в том, что DefaultY у нас стоит в 1. Меняем его на 0:
// Hero ground heightprivate const float DefaultY = 0;
Теперь она стоит ровно на земле:

Новая версия MyGame.cs: https://github.com/rds1983/ThirdPersonTutorial/blob/master/Step3-Model/MyGame.cs
Добавляем анимации
Объявим новое поле:
// Animation state machine for playing and transitioning clipsprivate AnimationController _player;
AnimationController — как и следует из названия — анимирует модели, вычисляя необходимые матрицы трансформации.
Теперь добавим код инициализации в LoadContent:
_player = new AnimationController(_modelHero);_player.StartClip("Idle", AnimationFlags.Looped);
Здесь мы создаём AnimationController, привязываем его к _modelHero и запускаем анимацию Idle(бездействие). Мы её запускаем с флагом Looped, чтобы она перезапускалась по окончании.
Наконец добавим одну строку в метод Update:
_player.Update(gameTime.ElapsedGameTime);
Теперь модель будет проигрывать анимацию Idle на повторе:

Новый MyGame.cs: https://github.com/rds1983/ThirdPersonTutorial/blob/master/Step4-AnimationController/MyGame.cs
Добавляем анимации бега и прыжка
Для начала добавим перечисление, обозначающие список возможных анимации:
// Animation states for the hero characterprivate enum AnimationState{Idle, // Standing stillRunning, // MovingJumping, // Jumping}
Так же добавим очередную константу, её смысл я поясню в дальнейшем:
// Duration for animation transitions between clipsprivate static readonly TimeSpan AnimationCrossfadeDelay = TimeSpan.FromSeconds(0.2f);
Добавляем поле текущей анимации:
// Current animation stateprivate AnimationState _animationState = AnimationState.Idle;
Добавим в ProcessKeyboard код, меняющий анимации:
// Handle keyboard input for movement and jump initiationprivate void ProcessKeyboard(){// Calculate movement velocity based on hero orientationvar velocity = Vector3.Zero;var heroTransform = ToMatrix(_heroPosition, Vector3.One, _heroYaw, 0, 0);var keyboard = Keyboard.GetState();// Track if hero is moving (for animation transitions)var isRunning = true;if (keyboard.IsKeyDown(Keys.W))velocity = heroTransform.Forward * -MovementSpeed;else if (keyboard.IsKeyDown(Keys.S))velocity = heroTransform.Forward * MovementSpeed;else if (keyboard.IsKeyDown(Keys.A))velocity = heroTransform.Right * MovementSpeed;else if (keyboard.IsKeyDown(Keys.D))velocity = heroTransform.Right * -MovementSpeed;elseisRunning = false;// Transition between Run and Idle animationsif (_animationState != AnimationState.Running && isRunning){_player.CrossfadeToClip("Run", AnimationCrossfadeDelay, AnimationFlags.Looped);_animationState = AnimationState.Running;}else if (_animationState != AnimationState.Idle && !isRunning){_player.CrossfadeToClip("Idle", AnimationCrossfadeDelay, AnimationFlags.Looped);_animationState = AnimationState.Idle;}// Apply velocity to hero position_heroPosition += velocity;// Initiate jump with momentum preservationif (keyboard.IsKeyDown(Keys.Space)){_jumpStarted = DateTime.Now;_animationState = AnimationState.Jumping;_jumpMovement = velocity;_player.CrossfadeToClip("JumpStart", AnimationCrossfadeDelay);}}
Собственно, за переключение анимаций отвечает вызов CrossfadeToClip.
Мы могли бы — как и в LoadContent — вызывать StartClip. Но тогда анимации бы резко сменялись одна на другую. CrossfadeToClip, же, делает это плавно в течении заданного времени. В нашем случае, это ранее заданная константа AnimationCrossfadeDelay, равная 200 мс.
Если мы запустим игру, то всё будет работать более ни менее правильно, кроме приземления. Поскольку анимация прыжка начнёт переключаться на анимацию бездействия только в момент приземления:
Мы бы хотели, чтобы анимация приземления начиналась в момент прыжка, когда персонаж падает и приближается к земле.
Для этого перепишем код UpdateJump так:
// Update hero position and animation during jump using projectile motionprivate void UpdateJump(){// Time elapsed since jump started (seconds)var t = (float)(DateTime.Now - _jumpStarted.Value).TotalSeconds;// Height from kinematic equation: h = v0*t - 0.5*g*t^2var jumpHeight = JumpForce * t - (0.5f * Gravity * t * t);// Apply height and preserve horizontal momentum_heroPosition.Y = jumpHeight;_heroPosition += _jumpMovement;// Vertical velocity: v = v0 - g*t (positive = upward, negative = falling)var jumpVelocity = JumpForce - Gravity * t;// Start falling animation once we fall below height 2if (jumpVelocity < 0 && _heroPosition.Y < 2 && _animationState != AnimationState.Landing){_player.CrossfadeToClip("JumpEnd", AnimationCrossfadeDelay);_animationState = AnimationState.Landing;}// Land when reaching groundif (_heroPosition.Y <= DefaultY){_heroPosition.Y = DefaultY;_jumpStarted = null;}}
Мы вычисляем скорость прыжка(jumpVelocity), чтобы понять падаем ли мы(jumpVelocity < 0). Если падаем и упали ниже высоты 2, то включаем анимацию окончания прыжка.
Теперь анимация приземления выглядит как надо:
Да и бег выглядит нормально:
Новый MyGame.cs: https://github.com/rds1983/ThirdPersonTutorial/blob/master/Step5-BasicAnimations/MyGame.cs
Цепляем меч на спину
Скачайте очередной архив и разархивируйте в папку проекта: https://github.com/user-attachments/files/28683215/Assets.zip
Он содержит модель меча.
Способ присоединения меча очень прост. Мы просто выберем одну из костей модели и применим её трансформацию на модели меча.
Перейдём к коду.
Добавим константу:
// Sword local transform: position offset (-12, 0, -20), scale 16x, rotated 180 degrees on Z axis (sheathed on back)private static readonly Matrix _swordSheathedTransform = ToMatrix(new Vector3(-12, 0, -20), new Vector3(16), 0, 0, 180);
Это вручную подобранная локальная трансформация меча, прицепленного к спине.
Добавим поля:
// Sword model instanceprivate DrModelInstance _modelSword;// Bone where sword is attachedprivate DrModelBone _swordAttachBone;
Собственно, это модель меча и кость модели персонажа, к котором мы его прицепим.
Теперь добавим в LoadContent такой код(обязательно после инициализации _modelHero:
// Load sword modelmodel = assetManager.LoadModel(GraphicsDevice, "Models/sword.gltf");_modelSword = new DrModelInstance(model);// Set the bone we will attach the sword to_swordAttachBone = _modelHero.Model.FindBoneByName("mixamorig:Spine");
Здесь мы загружаем модель меча и в качестве кости присоединения выбираем спину персонажа.
Наконец добавим код в Draw:
// Attach the sword to attachment bone// Transform chain: _swordSheathedTransform (local offset) -> attachment bone transform -> hero world transformvar swordTransform = _swordSheathedTransform * _modelHero.GetBoneGlobalTransform(_swordAttachBone.Index) * heroTransform;DrawModel(_modelSword, swordTransform);
Вначале мы применяем к модели ранее введённую локальную трансформацию. Дальше мы применяем к модели трансформацию кости спины. А затем всего персонажа.
В итоге меч оказывается на спине:

Заключение
Туториал окончен. Игра должна соответствовать видео в начале статьи.
Финальный MyGame.cs: https://github.com/rds1983/ThirdPersonTutorial/blob/master/Step6-SwordOnBack/MyGame.cs
В следующей части мы изучим смешивание разных анимаций. И добавим доставание/убирание меча и взмах им.
ссылка на оригинал статьи https://habr.com/ru/articles/1044786/