Введение
В этой серии туториалов мы реализуем простой Third Person Controller на базе MonoGame.
Серия рассчитана на читателей, уже знакомых с основами MonoGame и 3D-графики.
Для комфортного понимания материала желательно разбираться в следующих темах:
-
C#
-
MonoGame
-
Основы 3D-геометрии — матрицы, вектора, преобразования
-
Понимание работы камеры в 3D-сцене и того, что такое
world,viewиprojectionматрицы -
Базовый 3D-рендеринг в MonoGame (
BasicEffect,SkinnedEffect) -
Скелетная анимация (для первой части необязательно)
Если с чем-то из этого списка вы пока не знакомы — в Интернете достаточно хороших материалов. В частности, рекомендую знаменитые Reimers Tutorials:
https://github.com/simondarksidej/XNAGameStudio/wiki/RiemersArchiveOverview
DigitalRiseModel
На протяжении всей серии мы будем использовать мою библиотеку:
https://github.com/rds1983/DigitalRiseModel
Это попытка сделать более удобное API для работы с 3D-моделями в MonoGame.
Библиотека позволяет:
-
Создавать 3D-примитивы (кубы, сферы, капсулы и т.д.) прямо в коде
-
Загружать модели в форматах
gltf/glb -
Работать со скелетной анимацией
No Content Pipeline
Также важно отметить, что в этой серии мы не будем использовать Content Pipeline.
Почему я предпочитаю не использовать Content Pipeline, я подробно описал здесь:
https://habr.com/ru/articles/1039344/
Вместо него мы будем использовать другую мою библиотеку:
https://github.com/rds1983/XNAssets
Она позволяет загружать ассеты напрямую в «сыром» виде без промежуточной компиляции.
Часть I
В первой части герой будет представлен в виде капсулы. Мы реализуем рендеринг, движение и прыжки.
Итоговый результат будет выглядеть так:
В следующих частях мы заменим капсулу на полноценную модель персонажа и добавим анимации.
Туториал
Создание проекта
Создайте новый MonoGame-проект под любую платформу (например DesktopGL).
После этого добавьте NuGet-пакет:
https://www.nuget.org/packages/DigitalRiseModel.MonoGame/
Он автоматически подтянет и XNAssets.
Скачайте этот архив:
https://github.com/rds1983/ThirdPersonTutorial/raw/refs/heads/master/Step1-Capsule/Assets.zip
Затем распакуйте его (он состоит всего из одного файла checker.dds) в папку проекта и добавьте в .csproj следующий код:
<ItemGroup> <None Update="Assets\**\*.*"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None></ItemGroup>
Таким образом ассеты нашего проекта будут всегда копироваться в Output Directory.
Инициализация и загрузка контента
Для простоты весь код мы будем писать прямо внутри нашего Game-класса.
Для начала добавим константу, задающую стандартную высоту персонажа:
// Hero ground heightprivate const float DefaultY = 1;
Теперь объявим следующие поля:
// Stock effect with directional lighting and texturingprivate BasicEffect _basicEffect;// Ground plane textureprivate Texture2D _textureGround;// Ground plane meshprivate DrMesh _meshGround;// Capsule mesh for the playerprivate DrMesh _meshHero;// Hero position in world spaceprivate Vector3 _heroPosition;
DrMesh — это класс из DigitalRiseModel. По сути он является аналогом обычного Mesh из MonoGame.
Теперь реализуем LoadContent:
protected override void LoadContent(){base.LoadContent();// Load ground texturevar assetManager = AssetManager.CreateFileAssetManager(Path.Combine(AppContext.BaseDirectory, "Assets"));_textureGround = assetManager.LoadTexture2D(GraphicsDevice, "Textures/checker.dds");// Create ground and hero meshes_meshGround = MeshPrimitives.CreatePlaneMesh(GraphicsDevice, uScale: 50, vScale: 50, normalDirection: NormalDirection.UpY);_meshHero = MeshPrimitives.CreateCapsuleMesh(GraphicsDevice);// Set up rendering effect with lighting_basicEffect = new BasicEffect(GraphicsDevice) { LightingEnabled = true };_basicEffect.DirectionalLight0.Enabled = true;_basicEffect.DirectionalLight0.Direction = new Vector3(-1, -1, -1);_basicEffect.DirectionalLight0.DiffuseColor = Color.White.ToVector3();// Start hero at world center_heroPosition = new Vector3(0, DefaultY, 0);}
Здесь всё довольно прямолинейно:
-
загружаем текстуру земли
-
создаём меш земли
-
создаём капсулу персонажа
-
настраиваем освещение
-
задаём стартовую позицию героя
Вспомогательные методы
Добавим пару утилитных методов: DrawMesh и ToMatrix
DrawMesh
/// <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;var device = GraphicsDevice;foreach (var part in mesh.MeshParts){device.SetVertexBuffer(part.VertexBuffer);device.Indices = part.IndexBuffer;foreach (EffectPass pass in _basicEffect.CurrentTechnique.Passes){pass.Apply();device.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, part.PrimitiveCount);}}}
Метод рисует меш с заданной матрицой трансформации, цветом и текстурой.
ToMatrix
/// <summary>Build transform matrix from position, scale, and rotation (TRS order).</summary>private static Matrix ToMatrix(Vector3 position, Vector3 scale, float yaw, float pitch, float roll){var scaleTransform = Matrix.CreateScale(scale);var rotation = Matrix.CreateFromYawPitchRoll(MathHelper.ToRadians(yaw), MathHelper.ToRadians(pitch), MathHelper.ToRadians(roll));var translation = Matrix.CreateTranslation(position);return scaleTransform * rotation * translation;}
Этот метод собирает стандартную матрицу трансформации.
Важно понимать порядок операций:
Scale -> Rotate -> Translate
В MonoGame/XNA матрицы перемножаются слева направо, поэтому объект сначала масштабируется, затем вращается и только потом перемещается в мир.
Hero, Camera Mount и Camera
Сразу предупреждаю, что это самый сложный раздел в туториале.
Мы хотим получить классическую third-person камеру, которая:
-
Всегда следует за персонажем
-
Позволяет вращать персонажа мышкой по горизонтали
-
Позволяет наклонять камеру вверх-вниз (как в большинстве современных игр)
Другими словами, мы хотим такую конструкцию:

Она состоит 3 объектов:
-
Hero(капсула) — персонаж -
Camera Mount— жёсткий “штатив”, прикреплённый к голове персонажа. У его самого основания есть шарнир, позволяющий вращение вверх-вниз -
Camera— сама камера, которая находится на другом конце штатива. Она всегда повернута на 180 градусов по оси Y, дабы смотреть на спину персонажа.
Когда персонаж поворачивается влево-вправо — поворачивается весь штатив вместе с камерой. Когда мы вращаем штатив — камера наклоняется вместе с ним, но сам персонаж при этом не кренится.
Вся эта конструкция задаётся тремя переменными:
-
Положение
Heroв мире (Vector3 _heroPosition) -
Угол поворота героя вокруг оси Y (
float _heroYaw) -
Угол поворота
Camera Mountвокруг оси X (float _cameraMountPitch)
Первую мы уже задали. Теперь добавим остальные:
// Hero body yaw rotation in degreesprivate float _heroYaw;// Camera mount pitch rotation in degreesprivate float _cameraMountPitch;
Вся предложенная конструкция является иерархией трансформаций.
Если мы хотим вычислить итоговую трансформацию камеры(а мы этого хотим, дабы вычислить матрицу camera view), то необходимо последовательно вычислить трансформации всех объектов цепочки.
Вычисление иерархии трансформации и рендеринг сцены
Объявим константы камеры:
// Camera near clipping planeprivate const float NearPlaneDistance = 0.1f;// Camera far clipping planeprivate const float FarPlaneDistance = 1000.0f;// Camera field of view in degreesprivate const float ViewAngle = 60.0f;
Теперь можно реализовать 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;// Build camera hierarchy: hero body -> camera mount (head) -> cameravar heroTransform = ToMatrix(_heroPosition, Vector3.One, _heroYaw, 0, 0);var cameraMountOffset = new Vector3(0, 1f, 0); // Camera mount is on the head level - 1 unit above hero positionvar cameraMountTransform = ToMatrix(cameraMountOffset, Vector3.One, 0, _cameraMountPitch, 0) * heroTransform;var cameraOffset = new Vector3(0, 0, -5); // Camera is 5 units behind the mountvar cameraTransform = ToMatrix(cameraOffset, Vector3.One, 180, 0, 0) * cameraMountTransform; // Rotate 180 degrees to look back at the hero_basicEffect.View = Matrix.Invert(cameraTransform);// Draw ground and heroDrawMesh(_meshGround, Matrix.CreateScale(200, 1, 200), Color.White, _textureGround);DrawMesh(_meshHero, heroTransform, Color.Green, null);}
Трансформация камеры вычисляется поэтапно. Сначала вычисляется трансформация персонажа heroTransform. Затем на его основании вычисляется cameraMountTransform. Он смешён на 1 по оси Y(cameraMountOffset), дабы быть на уровне головы. После этого вычисляется cameraTransform. Он смещена на -5 по оси Y и повёрнута на 180 градусов, чтобы на некотором расстоянии всегда смотреть на спину персонажа.
Почему View Matrix инвертируется
Рассмотрим эту строку:
_basicEffect.View = Matrix.Invert(cameraTransform);
Важно понимать:
View Matrix — это не transform камеры.
Наоборот, это матрица, которая преобразует мир относительно камеры.
Иными словами, камера в 3D-графике фактически «двигает мир» вокруг наблюдателя.
Поэтому для получения View Matrix необходимо инвертировать мировую трансформацию камеры.
Промежуточный итог
Если мы запустим игру сейчас, то получим следующую картинку:

Рендеринг уже работает, однако камера пока остаётся неподвижной.
Движение камеры
Добавим константу, обозначающую чувствительность мышки:
// Mouse look sensitivity multiplierprivate const float MouseSensitivity = 0.2f;
Добавим поле для хранения последнего состояния мышки:
// Previous mouse state for delta calculationprivate MouseState? _oldMouse = null;
Теперь в Update добавим следующий код:
protected override void Update(GameTime gameTime){base.Update(gameTime);// 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 (-20 to 70 degrees)_cameraMountPitch = MathHelper.Clamp(_cameraMountPitch, -20, 70);}_oldMouse = mouse;}
Сам по себе код весьма очевиден. Мы меняем ранее заданные _heroYaw и _cameraMountPitch на соотвествующие изменения координаты мышки(горизонтальную для _heroYaw и вертикальную для _cameraMountPitch).
Запустим игру и убедимся, что вращение камеры работает:
Движение персонажа
Добавим константу, обозначающую скорость движения:
// Movement speed per frameprivate const float MovementSpeed = 0.1f;
Теперь в Update добавим код, который осуществляет это самое движения при нажатии клавиш WASD:
// WASD movementvar velocity = Vector3.Zero;var heroTransform = ToMatrix(_heroPosition, Vector3.One, _heroYaw, 0, 0);var keyboard = Keyboard.GetState();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;_heroPosition += velocity;
Здесь мы вычисляем матрицу трансформации Hero, дабы получить из неё вектора Forward(нужен для движения вперёд-напад) и Right(для движения влево-вправо). Далее мы используем эти вектора, чтобы рассчитать новое положение персонажа.
Запустим игру и убедимся, что движение персонажа работает:
Прыжки
Последнее, что мы добавим — это прыжки.
Сразу оговоримся, что мы хотим, чтобы при прыжках сохранялась инерция движения.
Начнём как обычно с задания новых констант:
// Jump gravity acceleration per secondprivate const float Gravity = 12f;// Jump initial velocityprivate const float JumpForce = 10f;
А так же пары полей, задающих состояние прыжка
// Jump state and physicsprivate DateTime? _jumpStarted;private Vector3 _jumpMovement;
Наконец перепишем вышеприведённый код движения так:
// Handle movement and jumpingif (_jumpStarted == null){// WASD movementvar velocity = Vector3.Zero;var heroTransform = ToMatrix(_heroPosition, Vector3.One, _heroYaw, 0, 0);var keyboard = Keyboard.GetState();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;_heroPosition += velocity;if (keyboard.IsKeyDown(Keys.Space)){// Jump_jumpStarted = DateTime.Now;_jumpMovement = velocity;}}else{// When moving with acceleration// Formula for the jump height: h = h0 + v0 * t - 0.5 * g * t^2// Where h0 is the initial height(DefaultY), v0 is the initial jump velocity(JumpForce), g is the gravity(JumpGravity), and t is the time passed since jump startedvar t = (float)(DateTime.Now - _jumpStarted.Value).TotalSeconds;var jumpHeight = DefaultY + (JumpForce * t) - (0.5f * Gravity * t * t);_heroPosition.Y = jumpHeight;_heroPosition += _jumpMovement;// Land when reaching groundif (_heroPosition.Y <= DefaultY){_heroPosition.Y = DefaultY;_jumpStarted = null;}}
Всё достаточно очевидно. При нажатии пробела, мы устанавливаем время начала прыжка и инерцию.
В самом же прыжке, мы рассчитываем высоту персонажа по известной со школьных времён формуле движения с ускорением. Когда мы падаем ниже DefaultY, то оканчиваем прыжок.
Туториал окончен. Наша игра должна соответствовать видео из начала этой статьи.
Заключение
Полный исходный код этой части можно посмотреть здесь:
https://github.com/rds1983/ThirdPersonTutorial/blob/master/Step1-Capsule/MyGame.cs
ссылка на оригинал статьи https://habr.com/ru/articles/1040382/