Введение
В этой серии туториалов мы реализуем простой 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 height private const float DefaultY = 1;
Теперь объявим следующие поля:
// Stock effect with directional lighting and texturing private BasicEffect _basicEffect; // Ground plane texture private Texture2D _textureGround; // Ground plane mesh private DrMesh _meshGround; // Capsule mesh for the player private DrMesh _meshHero; // Hero position in world space private Vector3 _heroPosition;
DrMesh — это класс из DigitalRiseModel. По сути он является аналогом обычного ModelMesh из MonoGame.
Теперь реализуем LoadContent:
protected override void LoadContent() { base.LoadContent(); // Load ground texture var 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 degrees private float _heroYaw; // Camera mount pitch rotation in degrees private float _cameraMountPitch;
Вся предложенная конструкция является иерархией трансформаций.
Если мы хотим вычислить итоговую трансформацию камеры(а мы этого хотим, дабы вычислить матрицу camera view), то необходимо последовательно вычислить трансформации всех объектов цепочки.
Вычисление иерархии трансформации и рендеринг сцены
Объявим константы камеры:
// Camera near clipping plane private const float NearPlaneDistance = 0.1f; // Camera far clipping plane private const float FarPlaneDistance = 1000.0f; // Camera field of view in degrees private const float ViewAngle = 60.0f;
Теперь можно реализовать 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; // Build camera hierarchy: hero body -> camera mount (head) -> camera var 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 position var cameraMountTransform = ToMatrix(cameraMountOffset, Vector3.One, 0, _cameraMountPitch, 0) * heroTransform; var cameraOffset = new Vector3(0, 0, -5); // Camera is 5 units behind the mount var 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 hero DrawMesh(_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 камеры.
Наоборот, это матрица, которая преобразует мир относительно камеры.
Поэтому для получения View Matrix необходимо инвертировать мировую трансформацию камеры.
Промежуточный итог
Если мы запустим игру сейчас, то получим следующую картинку:

Рендеринг уже работает, однако камера пока остаётся неподвижной.
Движение камеры
Добавим константу, обозначающую чувствительность мышки:
// Mouse look sensitivity multiplier private const float MouseSensitivity = 0.2f;
Добавим поле для хранения последнего состояния мышки:
// Previous mouse state for delta calculation private MouseState? _oldMouse = null;
Теперь в Update добавим следующий код:
protected override void Update(GameTime gameTime) { base.Update(gameTime); // 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 (-20 to 70 degrees) _cameraMountPitch = MathHelper.Clamp(_cameraMountPitch, -20, 70); } _oldMouse = mouse; }
Сам по себе код весьма очевиден. Мы меняем ранее заданные _heroYaw и _cameraMountPitch на соотвествующие изменения координаты мышки(горизонтальную для _heroYaw и вертикальную для _cameraMountPitch).
Запустим игру и убедимся, что вращение камеры работает:
Движение персонажа
Добавим константу, обозначающую скорость движения:
// Movement speed per frame private const float MovementSpeed = 0.1f;
Теперь в Update добавим код, который осуществляет это самое движения при нажатии клавиш WASD:
// WASD movement var 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 second private const float Gravity = 12f; // Jump initial velocity private const float JumpForce = 10f;
А так же пары полей, задающих состояние прыжка
// Jump state and physics private DateTime? _jumpStarted; private Vector3 _jumpMovement;
Наконец перепишем вышеприведённый код движения так:
// Handle movement and jumping if (_jumpStarted == null) { // WASD movement var 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 started var 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 ground if (_heroPosition.Y <= DefaultY) { _heroPosition.Y = DefaultY; _jumpStarted = null; } }
Всё достаточно очевидно. При нажатии пробела, мы устанавливаем время начала прыжка и инерцию.
В самом же прыжке, мы рассчитываем высоту персонажа по известной со школьных времён формуле движения с ускорением. Когда мы падаем ниже DefaultY, то оканчиваем прыжок.
Туториал окончен. Наша игра должна соответствовать видео из начала этой статьи.
Заключение
Полный исходный код этой части можно посмотреть здесь:
https://github.com/rds1983/ThirdPersonTutorial/blob/master/Step1-Capsule/MyGame.cs
Wosk1947
Спасибо! В продолжении хотелось бы, чтобы вы рассказали о том, как разрншаются коллизии камеры от третьего лица с окружением.
rds1983 Автор
Если это и будет, то нескоро. Поскольку в ближайших частях планируется заменить капсулу на нормальную модель с анимациями. Показать как к ней можно приаттачить меч и т.д.