Пишем Third Person Controller на MonoGame. Часть II

Введение

Это вторая часть серии туториалов, где мы реализуем Third Person Controller на MonoGame.

Первая часть доступна тут:

В этой части мы заменим капсулу персонажа на анимированную модель. И прицепим меч на спину:

Для понимания материала необходимо иметь представление о том, что такое gltf/glb и как работает скелетная анимация. В Интернете полно информации на эту тему. Например, статья на learnopengl:

Объединение 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»

Стартовая точка

Мы продолжим с того же самого места, на котором остановились в конце прошлой части.

Если у вас не сохранилось исходного кода того туториала, то вы можете взять его здесь:

Небольшие изменения

Для начала мы внесём небольшие QoL изменения в исходный код:

  • Включим DefaultLights в BasicEffect
  • Разобьём метод Update на части
  • Вынесем часть кода DrawMesh в отдельный метод

Включаем DefaultLights

Удалите код, инициализирующий DirectionalLight0 в _basicEffect. И добавьте вместо него(т.е. кода) такую строку:

_basicEffect.EnableDefaultLighting();

Это сделает сцену более освещённой:

Пишем Third Person Controller на MonoGame. Часть II

Рефактор Update

Добавим 3 метода: ProcessMouse, ProcessKeyboard и UpdateJump

И перенесём в них соответствующий код:

// Handle mouse input for camera rotation private void ProcessMouse() { // Handle mouse input for camera rotation var mouse = Mouse.GetState(); if (_oldMouse != null) { // Rotate hero by mouse X delta var horizontalRotation = -(int)((mouse.X - _oldMouse.Value.X) * MouseSensitivity); _heroYaw += horizontalRotation; // Tilt camera by mouse Y delta var 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 initiation private void ProcessKeyboard() { // Calculate movement velocity based on hero orientation var 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 preservation if (keyboard.IsKeyDown(Keys.Space)) { _jumpStarted = DateTime.Now; _jumpMovement = velocity; } } // Update hero position and animation during jump using projectile motion private 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^2 var jumpHeight = DefaultY + JumpForce * t - (0.5f * Gravity * t * t); // Apply height and preserve horizontal momentum _heroPosition.Y = jumpHeight; _heroPosition += _jumpMovement; // Land when reaching ground if (_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 effect private 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); } }

Промежуточный итог

Результат нашего рефакторинга можно посмотреть тут:

Заменяем капсулу на модель

Скачайте и разархивируйте в папку с проектом:

Архив состоит только из gltf модели персонажа.

Перейдём к коду.

Удаляем поле _meshHero и весь код, который с ним работает. Вместо него добавляем следующие поля:

// Hero character model instance private DrModelInstance _modelHero; // Effect for rendering skeletal mesh with bone transformations private SkinnedEffect _skinnedEffect; // Solid white texture for models without material textures private Texture2D _textureWhite;

SkinnedEffect - это аналог BasicEffect, только умеющий в скелетную анимацию.

_textureWhite - белая текстура единичного размера. Она нам нужна, поскольку у нашей модели нет текстур.

Добавляем инициализацию новых полей в LoadContent:

// Load hero model DrModel 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 meshes private 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 assigned var 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 states device.DepthStencilState = DepthStencilState.Default; device.RasterizerState = RasterizerState.CullCounterClockwise; device.BlendState = BlendState.AlphaBlend; device.SamplerStates[0] = SamplerState.LinearWrap; // Set projection var 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) -> camera var 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 hero DrawMesh(_meshGround, Matrix.CreateScale(200, 1, 200), Color.White, _textureGround); DrawModel(_modelHero, heroTransform); }

Если мы запустим игру, то увидим следующее:

Пишем Third Person Controller на MonoGame. Часть II

Нам удалось заменить капсулу на модель. Но она немного парит над землей. А всё дело в том, что DefaultY у нас стоит в 1. Меняем его на 0:

// Hero ground height private const float DefaultY = 0;

Теперь она стоит ровно на земле:

Пишем Third Person Controller на MonoGame. Часть II

Текущая версия MyGame.cs:

Добавляем анимации

Объявим новое поле:

// Animation state machine for playing and transitioning clips private AnimationController _player;

AnimationController - как и следует из названия - анимирует модели, вычисляя необходимые матрицы трансформации.

Теперь добавим код инициализации в LoadContent:

_player = new AnimationController(_modelHero); _player.StartClip("Idle", AnimationFlags.Looped);

Здесь мы создаём AnimationController, привязываем его к _modelHero и запускаем анимацию Idle(бездействие). Мы её запускаем с флагом Looped, чтобы она перезапускалась по окончании.

Наконец добавим одну строку в метод Update:

_player.Update(gameTime.ElapsedGameTime);

Теперь модель будет проигрывать анимацию Idle на повторе:

Пишем Third Person Controller на MonoGame. Часть II

Новый MyGame.cs:

Добавляем анимации бега и прыжка

Для начала добавим перечисление, обозначающие список возможных анимации:

// Animation states for the hero character private enum AnimationState { Idle, // Standing still Running, // Moving Jumping, // Jumping }

Так же добавим очередную константу, её смысл я поясню в дальнейшем:

// Duration for animation transitions between clips private static readonly TimeSpan AnimationCrossfadeDelay = TimeSpan.FromSeconds(0.2f);

Добавляем поле текущей анимации:

// Current animation state private AnimationState _animationState = AnimationState.Idle;

Добавим в ProcessKeyboard код, меняющий анимации:

// Handle keyboard input for movement and jump initiation private void ProcessKeyboard() { // Calculate movement velocity based on hero orientation var 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; else isRunning = false; // Transition between Run and Idle animations if (_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 preservation if (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 motion private 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^2 var 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 2 if (jumpVelocity < 0 && _heroPosition.Y < 2 && _animationState != AnimationState.Landing) { _player.CrossfadeToClip("JumpEnd", AnimationCrossfadeDelay); _animationState = AnimationState.Landing; } // Land when reaching ground if (_heroPosition.Y <= DefaultY) { _heroPosition.Y = DefaultY; _jumpStarted = null; } }

Мы вычисляем скорость прыжка(jumpVelocity), чтобы понять падаем ли мы(jumpVelocity < 0). Если падаем и упали ниже высоты 2, то включаем анимацию окончания прыжка.

Теперь анимация приземления выглядит как надо:

Да и бег выглядит нормально:

Новый MyGame.cs:

Цепляем меч на спину

Скачайте очередной архив и разархивируйте в папку проекта:

Он содержит модель меча.

Способ присоединения меча очень прост. Мы просто выберем одну из костей модели и применим её трансформацию на модели меча.

Перейдём к коду.

Добавим константу:

// 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 instance private DrModelInstance _modelSword; // Bone where sword is attached private DrModelBone _swordAttachBone;

Собственно, это модель меча и кость модели персонажа, к котором мы его прицепим.

Теперь добавим в LoadContent такой код(обязательно после инициализации _modelHero:

// Load sword model model = 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 transform var swordTransform = _swordSheathedTransform * _modelHero.GetBoneGlobalTransform(_swordAttachBone.Index) * heroTransform; DrawModel(_modelSword, swordTransform);

Вначале мы применяем к модели ранее введённую локальную трансформацию. Дальше мы применяем к модели трансформацию кости спины. А затем всего персонажа.

В итоге меч оказывается на спине.

Пишем Third Person Controller на MonoGame. Часть II

Заключение

Туториал окончен. Игра должна соответствовать видео в начале статьи.

Финальный MyGame.cs:

В следующей части мы изучим смешивание разных анимаций. И добавим доставание/убирание меча и взмах им.

1
Начать дискуссию